#!/usr/bin/env node /** * @module scripts/wall-entry * @description The releases-wall entry, made mechanical. The fleet's HQ page * reads one public JSON per product from the ONE releases repo on The Source * (soulcraftlabs/releases, files .json at its root — shape * {product, entries:[{version, date, headline, items, url, thumb?}]}), at * https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/.json. * Those entries were hand-written after every release, then briefly written * into this repo's own releases/.json; this script is the one door * that composes an entry and lands it in the shared repo, so it is never * hand-written and never forked across repos again. * * Two modes: * * 1. Generate + publish (default): * node wall-entry.mjs --product

--version --date \ * --from-changelog * Derives an entry from the CHANGELOG.md entry for (headline = the * entry's first bullet, items = every bullet, trimmed of its trailing * commit hash), then: * - clones (or, if a cached clone already exists, fetches and resets) * the releases repo into a local cache directory, * - prepends the entry to /

.json, newest first — replacing * any existing entry for the same version so a re-run is idempotent, * - validates the file's shape before and after, * - commits the change as "chore(wall):

" and pushes main. * A failure at any step (clone, validation, commit, push, a * non-fast-forward remote) exits non-zero naming the cure. Nothing is * ever skipped — the wall either lands correctly or the release fails. * * 2. Dry run: * node wall-entry.mjs --dry-run --product

--version \ * --date --from-changelog * Derives the entry exactly as above and prints it, along with the file * it would be written to, but touches no clone and no remote — usable * from a fresh checkout with no cache and no network. * * 3. Validate only (--check): * node wall-entry.mjs --check --file * Validates an arbitrary wall file's exact key set (top-level and * per-entry), field types, and strict-descending semver ordering with * no duplicates. Read-only; never writes. Exit 0 = clean, exit 1 = * named violations printed to stderr. * * The remote and the local cache directory are each overridable * (--remote / --cache-dir, or WALL_ENTRY_RELEASES_REMOTE / * WALL_ENTRY_RELEASES_CACHE_DIR) so tests can point at a throwaway local * bare repo and a throwaway cache directory — never the real remote or the * real developer cache. * * No dependencies beyond the system `git` binary — CHANGELOG parsing, * semver comparison, and JSON shape checking are all hand-rolled below. */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { homedir } from 'node:os' import { dirname, join } from 'node:path' const DEFAULT_REMOTE = 'git@source.soulcraft.com:soulcraftlabs/releases.git' /** @returns {string} */ function defaultCacheDir() { const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache') return join(base, 'soulcraft-releases') } // Required on every entry; "thumb" is optional (may be absent, or present as // string | null) — matching the HQ contract's {..., thumb?}. const ENTRY_REQUIRED_KEYS = ['version', 'date', 'headline', 'items', 'url'] const ENTRY_OPTIONAL_KEYS = ['thumb'] const ENTRY_ALLOWED_KEYS = [...ENTRY_REQUIRED_KEYS, ...ENTRY_OPTIONAL_KEYS] const FILE_KEYS = ['product', 'entries'] // The public permalink pattern, by product. Every entry MUST carry an https // permalink: HQ's parser rejects a wall whose entries carry url: null (the // whole feed became unreadable on 2026-09-02). A product whose forge repo is // private links its PUBLIC package page on The Source instead of a release // page that would 404 for HQ's readers. const RELEASE_URL_PATTERNS = { 'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`, 'brainy': (version) => `https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/${version}`, } /** * Parse argv into a flag map. `--flag value` sets a string; `--flag` alone * (end of argv, or followed by another `--flag`) sets boolean true. * @param {string[]} argv * @returns {Record} */ function parseArgs(argv) { /** @type {Record} */ const args = {} for (let i = 0; i < argv.length; i++) { const a = argv[i] if (!a.startsWith('--')) continue const key = a.slice(2) const next = argv[i + 1] if (next === undefined || next.startsWith('--')) { args[key] = true } else { args[key] = next i++ } } return args } /** * Print a loud, named error and exit 1. Every refusal in this script goes * through here so the failure mode is always the same shape: "wall-entry: ". * @param {string} message * @returns {never} */ function fail(message) { console.error(`wall-entry: ${message}`) process.exit(1) } /** * @param {string} version * @returns {{major: number, minor: number, patch: number, pre: string | null} | null} */ function parseSemver(version) { const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version) if (!m) return null return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null } } /** * @param {string} a * @param {string} b * @returns {number} positive if a > b, negative if a < b, 0 if equal. */ function compareSemver(a, b) { const pa = parseSemver(a) const pb = parseSemver(b) if (!pa || !pb) throw new Error(`cannot compare non-semver versions "${a}" vs "${b}"`) if (pa.major !== pb.major) return pa.major - pb.major if (pa.minor !== pb.minor) return pa.minor - pb.minor if (pa.patch !== pb.patch) return pa.patch - pb.patch if (pa.pre === pb.pre) return 0 if (pa.pre === null) return 1 // a release outranks any prerelease of the same core version if (pb.pre === null) return -1 return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0 } /** * Validate a wall file's full shape: top-level keys ("product", "entries" — * no more, no less), per-entry keys and field types ("thumb" optional), and * strict-descending semver ordering with no duplicates. Collects every * violation instead of failing on the first, so a caller reports the whole * picture in one pass. * @param {unknown} data * @returns {string[]} Violation messages; empty means the file is clean. */ function validateShape(data) { /** @type {string[]} */ const errors = [] if (typeof data !== 'object' || data === null || Array.isArray(data)) { return ['top level: expected a JSON object'] } const obj = /** @type {Record} */ (data) const topKeys = Object.keys(obj) const missingTop = FILE_KEYS.filter((k) => !(k in obj)) const extraTop = topKeys.filter((k) => !FILE_KEYS.includes(k)) if (missingTop.length) errors.push(`top level: missing key(s) ${missingTop.join(', ')}`) if (extraTop.length) errors.push(`top level: unexpected key(s) ${extraTop.join(', ')}`) if (typeof obj.product !== 'string' || obj.product.trim() === '') { errors.push('top level: "product" must be a non-empty string') } if (!Array.isArray(obj.entries)) { errors.push('top level: "entries" must be an array') return errors // nothing further to check without an array } const entries = /** @type {unknown[]} */ (obj.entries) entries.forEach((rawEntry, i) => { const label = `entries[${i}]` if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) { errors.push(`${label}: expected an object`) return } const entry = /** @type {Record} */ (rawEntry) const keys = Object.keys(entry) const missing = ENTRY_REQUIRED_KEYS.filter((k) => !(k in entry)) const extra = keys.filter((k) => !ENTRY_ALLOWED_KEYS.includes(k)) if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`) if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`) if (typeof entry.version !== 'string' || !parseSemver(entry.version)) { errors.push(`${label}: "version" must be a semver string (got ${JSON.stringify(entry.version)})`) } if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || Number.isNaN(Date.parse(entry.date))) { errors.push(`${label}: "date" must be a YYYY-MM-DD string (got ${JSON.stringify(entry.date)})`) } if (typeof entry.headline !== 'string' || entry.headline.trim() === '') { errors.push(`${label}: "headline" must be a non-empty string`) } if (!Array.isArray(entry.items) || entry.items.length === 0 || entry.items.some((it) => typeof it !== 'string' || it.trim() === '')) { errors.push(`${label}: "items" must be a non-empty array of non-empty strings`) } if (typeof entry.url !== 'string' || !/^https:\/\/\S+$/.test(entry.url)) { errors.push(`${label}: "url" must be an https permalink — never null; HQ's parser rejects the whole feed`) } if ('thumb' in entry && !(entry.thumb === null || typeof entry.thumb === 'string')) { errors.push(`${label}: "thumb" must be a string or null when present`) } }) // Ordering: newest first, strictly descending, no duplicate versions — // checked only over entries whose version parsed (a bad version is // already reported above; comparing it too would just be noise). const versioned = entries .map((e, i) => ({ i, version: /** @type {any} */ (e)?.version })) .filter((e) => typeof e.version === 'string' && parseSemver(e.version)) for (let i = 0; i < versioned.length - 1; i++) { const a = versioned[i] const b = versioned[i + 1] const cmp = compareSemver(a.version, b.version) if (cmp === 0) { errors.push(`entries[${a.i}] and entries[${b.i}]: duplicate version ${a.version}`) } else if (cmp < 0) { errors.push(`entries[${a.i}] (${a.version}) sits above entries[${b.i}] (${b.version}) — not newest-first`) } } return errors } /** * Extract one version's entry body from a standard-version-style CHANGELOG.md * (headings `### [version](url) (date)`, followed by `- bullet (hash)` lines * until the next heading or EOF). * @param {string} changelog * @param {string} version * @returns {string[]} Bullet lines, trimmed of their leading "- " and * trailing " (hash)". */ function extractChangelogBullets(changelog, version) { const lines = changelog.split('\n') const headingRe = /^### \[([^\]]+)\]\(.*\)\s*\(\d{4}-\d{2}-\d{2}\)\s*$/ let start = -1 for (let i = 0; i < lines.length; i++) { const m = headingRe.exec(lines[i]) if (m && m[1] === version) { start = i + 1 break } } if (start === -1) { fail( `version ${version} has no CHANGELOG entry yet — run this after the CHANGELOG step composes "### [${version}]", not before`, ) } /** @type {string[]} */ const bullets = [] for (let i = start; i < lines.length; i++) { if (headingRe.test(lines[i])) break // next entry starts const bulletMatch = /^- (.+?)(?:\s\(([0-9a-f]{6,40})\))?$/.exec(lines[i].trim()) if (lines[i].trim().startsWith('- ') && bulletMatch) { const text = bulletMatch[1].trim() if (text) bullets.push(text) } } if (bullets.length === 0) { fail(`version ${version}'s CHANGELOG entry has no bullets to derive a headline/items from`) } return bullets } /** * Derive a wall entry from a CHANGELOG.md. * @param {{product: string, version: string, date: string, changelogPath: string, url?: string, thumb?: string | null}} opts * @returns {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} */ function deriveEntry({ product, version, date, changelogPath, url, thumb }) { if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`) if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) { fail(`--date "${date}" is not a YYYY-MM-DD date`) } if (!existsSync(changelogPath)) fail(`--from-changelog "${changelogPath}" does not exist`) const changelog = readFileSync(changelogPath, 'utf8') const items = extractChangelogBullets(changelog, version) const headline = items[0] const pattern = RELEASE_URL_PATTERNS[product] if (url === undefined && pattern === undefined) { throw new Error(`wall-entry: no permalink pattern for product "${product}" — add one to RELEASE_URL_PATTERNS or pass --url; entries never carry url: null`) } const resolvedUrl = url !== undefined ? url : pattern(version) const resolvedThumb = thumb !== undefined ? thumb : null return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb } } /** * Load and shape-validate a wall file. * @param {string} filePath * @returns {Record} */ function loadWallFile(filePath) { if (!existsSync(filePath)) fail(`"${filePath}" does not exist`) /** @type {unknown} */ let data try { data = JSON.parse(readFileSync(filePath, 'utf8')) } catch (err) { fail(`"${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`) } const errors = validateShape(data) if (errors.length) { fail(`"${filePath}" fails shape validation —\n ${errors.join('\n ')}`) } return /** @type {Record} */ (data) } /** * Run a git command, throwing an Error whose message is git's own stderr * (trimmed) on failure — every caller wraps this to name the cure. * @param {string[]} args * @param {string} cwd * @returns {string} stdout, trimmed. */ function git(args, cwd) { try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() } catch (err) { const stderr = /** @type {any} */ (err).stderr const message = (typeof stderr === 'string' && stderr.trim()) || /** @type {Error} */ (err).message throw new Error(message) } } /** * Ensure a clean, up-to-date local clone of the releases repo at * `cacheDir`, checked out on `main` — cloning fresh if `cacheDir` has no * `.git`, otherwise fetching and hard-resetting onto `origin/main` (so a * stray local commit or edit left by a previous failed run can never leak * into the next one). * @param {string} remote * @param {string} cacheDir */ function ensureReleasesClone(remote, cacheDir) { if (existsSync(join(cacheDir, '.git'))) { try { git(['remote', 'set-url', 'origin', remote], cacheDir) git(['fetch', '--prune', 'origin'], cacheDir) git(['checkout', 'main'], cacheDir) git(['reset', '--hard', 'origin/main'], cacheDir) git(['clean', '-fd'], cacheDir) } catch (err) { fail( `cannot refresh the cached releases checkout at "${cacheDir}" from "${remote}" — ${/** @type {Error} */ (err).message}\n` + ` cure: delete "${cacheDir}" and re-run so it re-clones from scratch, or confirm SSH access with "ssh -T git@source.soulcraft.com"`, ) } return } mkdirSync(dirname(cacheDir), { recursive: true }) try { git(['clone', remote, cacheDir], dirname(cacheDir)) } catch (err) { fail( `cannot clone "${remote}" — ${/** @type {Error} */ (err).message}\n` + ` cure: confirm SSH access with "ssh -T git@source.soulcraft.com" and that the soulcraftlabs/releases repo exists yet`, ) } try { git(['checkout', 'main'], cacheDir) } catch (err) { fail( `cloned "${remote}" into "${cacheDir}" but could not check out "main" — ${/** @type {Error} */ (err).message}\n` + ` cure: confirm the releases repo's default branch is named "main"`, ) } } /** * Prepend `entry` to the wall at `/.json`, replacing any * existing entry for the same version (idempotent re-runs), validating * before and after, committing, and pushing — or refusing loudly, naming * the cure, at whichever step fails. * @param {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} entry * @param {string} product * @param {string} remote * @param {string} cacheDir */ function publishEntry(entry, product, remote, cacheDir) { ensureReleasesClone(remote, cacheDir) const filePath = join(cacheDir, `${product}.json`) if (!existsSync(filePath)) { fail( `"${filePath}" does not exist in the releases repo — cure: seed "${product}.json" at the repo root first (it must exist before any release rail can prepend to it)`, ) } const wall = loadWallFile(filePath) if (wall.product !== product) { fail(`"${filePath}" has product "${wall.product}", but --product "${product}" was given — refusing a cross-product write`) } const replacing = wall.entries.some((e) => e.version === entry.version) wall.entries = [entry, ...wall.entries.filter((e) => e.version !== entry.version)] const postErrors = validateShape(wall) if (postErrors.length) { fail(`the entry for ${entry.version} would leave "${filePath}" invalid —\n ${postErrors.join('\n ')}`) } writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8') const status = git(['status', '--porcelain', '--', `${product}.json`], cacheDir) if (status === '') { console.log(`wall-entry: "${product}.json" already carries an identical entry for ${entry.version} — nothing to commit or push`) return } try { git(['add', `${product}.json`], cacheDir) git(['commit', '-m', `chore(wall): ${product} ${entry.version}`], cacheDir) } catch (err) { fail(`cannot commit the wall entry in "${cacheDir}" — ${/** @type {Error} */ (err).message}\n cure: inspect "${cacheDir}" by hand and re-run once its git state is clean`) } try { git(['push', 'origin', 'main'], cacheDir) } catch (err) { fail( `push to "${remote}" failed (likely a non-fast-forward — another release landed on main first) — ${/** @type {Error} */ (err).message}\n` + ` cure: re-run this release step; it re-fetches and resets onto the latest origin/main before retrying`, ) } const sha = git(['rev-parse', 'HEAD'], cacheDir) console.log( `wall-entry: ${replacing ? 'replaced' : 'wrote'} v${entry.version} in "${product}.json" (${wall.entries.length} entries, newest first) — pushed ${sha} to ${remote} main`, ) } function main() { const args = parseArgs(process.argv.slice(2)) if (args.check) { const filePath = /** @type {string | undefined} */ (args.file) if (!filePath) fail('--check needs --file ') const wall = loadWallFile(/** @type {string} */ (filePath)) console.log(`wall-entry --check: "${filePath}" OK — product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`) process.exit(0) } // Generate mode (default, also covers --dry-run): --product, --version, // --date, --from-changelog required. const product = /** @type {string | undefined} */ (args.product) const version = /** @type {string | undefined} */ (args.version) const date = /** @type {string | undefined} */ (args.date) const fromChangelog = /** @type {string | undefined} */ (args['from-changelog']) const missing = [] if (!product) missing.push('--product') if (!version) missing.push('--version') if (!date) missing.push('--date') if (!fromChangelog) missing.push('--from-changelog') if (missing.length) { fail( `missing required flag(s): ${missing.join(', ')}\n` + 'Usage:\n' + ' wall-entry.mjs --product

--version --date --from-changelog [--dry-run]\n' + ' wall-entry.mjs --check --file ', ) } const urlArg = args.url === true ? undefined : /** @type {string | undefined} */ (args.url) const thumbArg = args.thumb === true ? undefined : /** @type {string | undefined} */ (args.thumb) const entry = deriveEntry({ product: /** @type {string} */ (product), version: /** @type {string} */ (version), date: /** @type {string} */ (date), changelogPath: /** @type {string} */ (fromChangelog), url: urlArg, thumb: thumbArg, }) const remote = /** @type {string} */ (args.remote ?? process.env.WALL_ENTRY_RELEASES_REMOTE ?? DEFAULT_REMOTE) const cacheDir = /** @type {string} */ (args['cache-dir'] ?? process.env.WALL_ENTRY_RELEASES_CACHE_DIR ?? defaultCacheDir()) if (args['dry-run']) { console.log(`wall-entry --dry-run: would write to "${join(cacheDir, `${product}.json`)}" in ${remote} (main), pushed as "chore(wall): ${product} ${version}"`) console.log(JSON.stringify(entry, null, 2)) process.exit(0) } publishEntry(entry, /** @type {string} */ (product), remote, cacheDir) } main()