#!/usr/bin/env node /** * @module scripts/push-docs * @description Push this repo's PUBLIC docs to the soulcraft.com docs ingest * door after an npm publish (VENUE-DOCS-RELEASE-PUSH — retires the old * build-time docs sync). * * Contract (mirrors the reference implementation on the serving side): * POST {base}/api/docs/ingest * headers: x-service-secret: $DOCS_INGEST_SECRET, Content-Type: application/json * body: { docs: [{ slug, title, markdown, nav: { order, section } }] } * batches of 10, idempotent per slug. * * A doc is public iff its frontmatter has `public: true` AND a `slug`. The * frontmatter is stripped; `category` → nav.section, `order` → nav.order. * * Deliberately NOT pushed: the combined /docs landing index. It spans BOTH * engine corpora (this repo's and the native accelerator's), so a per-repo * push would clobber the union — the index is authored on the serving side. * * Env: DOCS_INGEST_SECRET (required), DOCS_INGEST_BASE (default * https://soulcraft.com). Exits 0 with a LOUD warning when the secret is * absent (the npm publish has already happened; the serving side runs its * interim sync on request) and exits 1 when a push actually fails — the docs * site would silently trail npm otherwise, and that must be visible. */ import * as fs from 'node:fs' import * as path from 'node:path' const BASE = (process.env.DOCS_INGEST_BASE || 'https://soulcraft.com').replace(/\/+$/, '') const SECRET = process.env.DOCS_INGEST_SECRET const DOCS_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'docs') const BATCH = 10 if (!SECRET) { console.warn( '⚠️ DOCS PUSH SKIPPED: DOCS_INGEST_SECRET is not set.\n' + ' soulcraft.com/docs now TRAILS this npm release until docs are pushed.\n' + ' Either export DOCS_INGEST_SECRET and re-run `node scripts/push-docs.js`,\n' + ' or ping venue on VENUE-DOCS-RELEASE-PUSH for the interim sync.' ) process.exit(0) } /** Minimal frontmatter split — returns [meta, body] or [null, raw]. */ function parseFrontmatter(raw) { const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/) if (!m) return [null, raw] const meta = {} for (const line of m[1].split('\n')) { const kv = line.match(/^(\w[\w-]*):\s*(.*)$/) if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '') } return [meta, m[2]] } const docs = [] ;(function walk(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name) if (entry.isDirectory()) walk(full) else if (entry.name.endsWith('.md')) { const [meta, body] = parseFrontmatter(fs.readFileSync(full, 'utf-8')) if (!meta || meta.public !== 'true' || !meta.slug) continue docs.push({ slug: meta.slug, title: meta.title || meta.slug, markdown: body.trim(), nav: { order: Number.parseInt(meta.order || '99', 10) || 99, section: meta.category || 'guides' } }) } } })(DOCS_DIR) if (docs.length === 0) { console.error('❌ DOCS PUSH FAILED: zero public docs collected — refusing to push an empty corpus.') process.exit(1) } docs.sort((a, b) => a.slug.localeCompare(b.slug)) console.log(`Pushing ${docs.length} public docs to ${BASE}/api/docs/ingest …`) let failed = false for (let i = 0; i < docs.length; i += BATCH) { const batch = docs.slice(i, i + BATCH) try { const res = await fetch(`${BASE}/api/docs/ingest`, { method: 'POST', headers: { 'x-service-secret': SECRET, 'Content-Type': 'application/json', 'User-Agent': 'brainy-docs-push/1.0' }, body: JSON.stringify({ docs: batch }), signal: AbortSignal.timeout(120_000) }) if (!res.ok) { throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`) } console.log(` batch ${i / BATCH + 1}: ${batch.map((d) => d.slug).join(', ')} → ok`) } catch (err) { failed = true console.error(` batch ${i / BATCH + 1} FAILED: ${err instanceof Error ? err.message : err}`) } } if (failed) { console.error( '❌ DOCS PUSH INCOMPLETE — soulcraft.com/docs may trail npm. ' + 'Re-run `node scripts/push-docs.js` or ping venue on VENUE-DOCS-RELEASE-PUSH.' ) process.exit(1) } console.log('✅ Docs pushed.')