- 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
239 lines
7.1 KiB
JavaScript
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();
|