Files
proxmox/mission-control/scripts/generate-doc-runbook-manifest.mjs
TorNation01 18767b7d8b feat: add Mission Control operator console and workspace wiring
- New mission-control Next.js app: runbook catalog, GO execution, SSE stream, audit ZIP export

- Generated doc-manifest from docs runbooks; curated JSON specs; health-check script

- pnpm workspace package, root scripts, README updates

- Resilience: Windows-safe path checks, optional MISSION_CONTROL_PROJECT_ROOT fallback, system fonts

- Bump mcp-proxmox submodule to tracked main

Made-with: Cursor
2026-03-28 14:50:11 +08:00

239 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Scans docs for markdown files whose names contain RUNBOOK.
* Writes mission-control/runbooks/doc-manifest.json with executable steps.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MC_ROOT = path.resolve(__dirname, '..');
const REPO_ROOT = path.resolve(MC_ROOT, '..');
const OUT = path.join(MC_ROOT, 'runbooks', 'doc-manifest.json');
const EXCLUDE_NAMES = new Set([
'RUNBOOKS_MASTER_INDEX.md',
'OPERATIONAL_RUNBOOKS.md',
'TEZOS_CCIP_RUNBOOKS_INDEX.md',
'OMNL_OFFICE_MASTER_RUNBOOK_INDEX.md',
]);
const SCRIPT_RE =
/(?:^|[\s"'`(])\.?\/?((?:scripts|explorer-monorepo\/scripts)\/[a-zA-Z0-9_.\/-]+\.(?:sh|mjs))/g;
const MAX_STEPS = 14;
const FALLBACK_SCRIPT = 'scripts/validation/validate-config-files.sh';
/** Paths meant to be sourced (running them as a step is misleading). */
const SKIP_SCRIPT_PATHS = new Set([
'scripts/lib/load-project-env.sh',
'scripts/lib/load-contract-addresses.sh',
]);
const STANDARD_INPUTS = [
{
name: 'proxmoxHost',
label: 'Proxmox host',
type: 'string',
help: 'Used as PROXMOX_HOST in the environment for scripts that read it (e.g. 192.168.11.10).',
example: '192.168.11.10',
default: '192.168.11.10',
},
{
name: 'rpcUrlOverride',
label: 'RPC URL override (optional)',
type: 'string',
help: 'If non-empty, set as RPC_URL_138 for scripts that use Chain 138 RPC.',
example: 'http://192.168.11.211:8545',
default: '',
},
{
name: 'practiceMode',
label: 'Practice mode (--dry-run where supported)',
type: 'boolean',
help: 'When enabled, each step whose script advertises --dry-run receives that flag.',
default: false,
},
];
function walkDocs(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const name of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, name.name);
if (name.isDirectory()) walkDocs(p, acc);
else {
const up = name.name.toUpperCase();
if (!up.includes('RUNBOOK') || !name.name.toLowerCase().endsWith('.md')) continue;
if (EXCLUDE_NAMES.has(name.name)) continue;
acc.push(p);
}
}
return acc;
}
function relFromRepo(abs) {
return path.relative(REPO_ROOT, abs).split(path.sep).join('/');
}
function makeId(rel) {
const slug = rel
.replace(/^docs[/\\]/, '')
.replace(/\.md$/i, '')
.split(/[/\\]/)
.join('-')
.replace(/[^a-zA-Z0-9-]+/g, '-')
.toLowerCase()
.replace(/^-|-$/g, '');
const base = `doc-${slug}`.slice(0, 120);
const h = crypto.createHash('sha256').update(rel).digest('hex').slice(0, 8);
return `${base}-${h}`;
}
function extractTitle(content) {
const m = content.match(/^#\s+(.+)$/m);
return m ? m[1].trim() : 'Runbook';
}
function extractSummary(content) {
const lines = content.split('\n');
for (const line of lines) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
if (t.startsWith('```')) continue;
return t.slice(0, 400);
}
return 'Operational procedure from repository documentation.';
}
function normalizeScript(raw) {
let s = raw.replace(/^\.\//, '');
if (s.startsWith('/')) return null;
if (s.includes('..')) return null;
return s;
}
function extractScripts(content) {
const seen = new Set();
const ordered = [];
let m;
const re = new RegExp(SCRIPT_RE.source, 'g');
while ((m = re.exec(content)) !== null) {
const n = normalizeScript(m[1]);
if (!n || seen.has(n) || SKIP_SCRIPT_PATHS.has(n)) continue;
const abs = path.join(REPO_ROOT, n);
if (!fs.existsSync(abs)) continue;
seen.add(n);
ordered.push(n);
if (ordered.length >= MAX_STEPS) break;
}
return ordered;
}
function scriptSupportsDryRun(scriptRel) {
try {
const abs = path.join(REPO_ROOT, scriptRel);
const chunk = fs.readFileSync(abs, 'utf8').slice(0, 12000);
return /--dry-run\b/.test(chunk);
} catch {
return false;
}
}
function buildEntry(absPath) {
const rel = relFromRepo(absPath);
const content = fs.readFileSync(absPath, 'utf8');
const title = extractTitle(content);
const summary = extractSummary(content);
const scripts = extractScripts(content);
let usedFallback = false;
let steps = scripts.map((scriptRelative) => ({
interpreter: scriptRelative.endsWith('.mjs') ? 'node' : 'bash',
scriptRelative,
args: [],
supportsDryRun: scriptRelative.endsWith('.sh') && scriptSupportsDryRun(scriptRelative),
}));
if (steps.length === 0) {
usedFallback = true;
const fr = FALLBACK_SCRIPT;
if (fs.existsSync(path.join(REPO_ROOT, fr))) {
steps = [
{
interpreter: 'bash',
scriptRelative: fr,
args: [],
supportsDryRun: scriptSupportsDryRun(fr),
},
];
}
}
const id = makeId(rel);
const why = usedFallback
? 'No shell/Node script paths were detected in this markdown. Mission Control runs repository config validation so you still get an automated check; follow the documentation for the full manual procedure.'
: 'Automated steps are the scripts explicitly referenced in this runbook. Review the documentation for prerequisites (SSH, VPN, secrets) before running in production.';
const spec = {
id,
title,
summary,
whyItMatters:
'This links documentation to executable automation in the monorepo. Operators get repeatable runs and an audit trail.',
audienceHelp:
'Use Practice mode when a script supports it. Set Proxmox host and RPC override when your environment differs from defaults.',
docPath: rel,
prerequisites: [
'Read the linked markdown runbook for safety and ordering.',
'Bash (Linux, macOS, WSL, or Git Bash on Windows) for .sh steps; Node for .mjs.',
'Network, SSH, or API access as required by the underlying scripts.',
],
steps: [
{
title: 'Documentation',
plainText: `Open and follow: ${rel}`,
technicalNote: 'Automated steps below are derived from script paths mentioned in that file.',
},
],
inputs: STANDARD_INPUTS,
execution: { steps },
touchpoints: [
{
id: 'pipeline_exit',
label: 'All automated steps completed',
description: 'Aggregate exit status of the script chain.',
passCondition: 'exit_zero',
},
],
complianceFramework: 'DBIS-MC-DOC-RUNBOOK-1',
executionNote: why,
};
return spec;
}
function main() {
const docsRoot = path.join(REPO_ROOT, 'docs');
const files = walkDocs(docsRoot);
files.sort((a, b) => relFromRepo(a).localeCompare(relFromRepo(b)));
const entries = [];
const ids = new Set();
for (const f of files) {
const spec = buildEntry(f);
if (ids.has(spec.id)) {
spec.id = `${spec.id}-x${crypto.randomBytes(2).toString('hex')}`;
}
ids.add(spec.id);
entries.push(spec);
}
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, JSON.stringify({ generatedAt: new Date().toISOString(), runbooks: entries }, null, 2), 'utf8');
console.error(`Wrote ${entries.length} doc-derived runbooks to ${path.relative(REPO_ROOT, OUT)}`);
}
main();