morphit/apps/ops-cli/scripts/ops-cli-smoke.ts

381 lines
16 KiB
TypeScript

/**
* Morphit ops CLI — smoke runner.
*
* Exercises the pure-logic helpers that every subcommand depends
* on:
* - parseDurationSpec / formatDuration / relativeTime /
* ageSeconds / utcMidnightToday from lib/time.ts
* - applyThreshold from config.ts
*
* Doesn't exercise commands themselves (those need a live DB)
* or the main.ts arg parser (separate scope).
*
* Usage (from apps/ops-cli):
* tsx scripts/ops-cli-smoke.ts
*/
import {
parseDurationSpec,
formatDuration,
relativeTime,
ageSeconds,
utcMidnightToday
} from '../src/lib/time.ts';
import { applyThreshold } from '../src/config.ts';
import type { Threshold } from '../src/config.ts';
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
}
function assertEqual(actual: unknown, expected: unknown, label: string): void {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
throw new Error(`${label}: expected ${e}, got ${a}`);
}
}
// ─── parseDurationSpec ───────────────────────────────────────────
scenario('parseDurationSpec: seconds', () => {
assertEqual(parseDurationSpec('30s'), 30, '30s');
assertEqual(parseDurationSpec('0s'), 0, '0s');
});
scenario('parseDurationSpec: minutes', () => {
assertEqual(parseDurationSpec('5m'), 300, '5m');
});
scenario('parseDurationSpec: hours', () => {
assertEqual(parseDurationSpec('1h'), 3600, '1h');
assertEqual(parseDurationSpec('24h'), 86400, '24h');
});
scenario('parseDurationSpec: days', () => {
assertEqual(parseDurationSpec('7d'), 7 * 86400, '7d');
});
scenario('parseDurationSpec: case-insensitive unit', () => {
assertEqual(parseDurationSpec('5M'), 300, '5M');
assertEqual(parseDurationSpec('1H'), 3600, '1H');
assertEqual(parseDurationSpec('1D'), 86400, '1D');
});
scenario('parseDurationSpec: whitespace tolerated', () => {
assertEqual(parseDurationSpec('5 m'), 300, '5 m');
assertEqual(parseDurationSpec('1 h'), 3600, '1 h');
});
scenario('parseDurationSpec: rejects empty', () => {
assertEqual(parseDurationSpec(''), null, 'empty');
});
scenario('parseDurationSpec: rejects bare number', () => {
assertEqual(parseDurationSpec('5'), null, '5');
});
scenario('parseDurationSpec: rejects bare unit', () => {
assertEqual(parseDurationSpec('m'), null, 'm');
});
scenario('parseDurationSpec: rejects unknown unit', () => {
assertEqual(parseDurationSpec('5x'), null, '5x');
assertEqual(parseDurationSpec('5min'), null, '5min');
});
scenario('parseDurationSpec: rejects negative', () => {
assertEqual(parseDurationSpec('-5m'), null, '-5m');
});
scenario('parseDurationSpec: rejects non-numeric', () => {
assertEqual(parseDurationSpec('abc'), null, 'abc');
});
// ─── formatDuration ──────────────────────────────────────────────
scenario('formatDuration: zero and negative', () => {
assertEqual(formatDuration(0), '0s', '0');
assertEqual(formatDuration(-1), '0s', '-1');
});
scenario('formatDuration: sub-minute', () => {
assertEqual(formatDuration(1), '1s', '1');
assertEqual(formatDuration(59), '59s', '59');
});
scenario('formatDuration: minutes with seconds', () => {
assertEqual(formatDuration(60), '1m', '60');
assertEqual(formatDuration(90), '1m 30s', '90');
assertEqual(formatDuration(125), '2m 5s', '125');
});
scenario('formatDuration: hours with minutes', () => {
assertEqual(formatDuration(3600), '1h', '3600');
assertEqual(formatDuration(3660), '1h 1m', '3660');
assertEqual(formatDuration(7320), '2h 2m', '7320');
});
scenario('formatDuration: days with hours', () => {
assertEqual(formatDuration(86400), '1d', '86400');
assertEqual(formatDuration(86400 + 3600), '1d 1h', '1d1h');
assertEqual(formatDuration(86400 * 3 + 3600 * 5), '3d 5h', '3d5h');
});
scenario('formatDuration: floors fractional', () => {
assertEqual(formatDuration(59.9), '59s', '59.9');
assertEqual(formatDuration(120.7), '2m', '120.7');
});
// ─── relativeTime ────────────────────────────────────────────────
const FIXED_NOW = new Date('2026-04-26T12:00:00Z');
scenario('relativeTime: just now', () => {
const then = new Date('2026-04-26T11:59:58Z');
assertEqual(relativeTime(then, FIXED_NOW), 'just now', 'just now');
});
scenario('relativeTime: seconds ago', () => {
const then = new Date('2026-04-26T11:59:30Z');
assertEqual(relativeTime(then, FIXED_NOW), '30s ago', '30s');
});
scenario('relativeTime: minutes ago', () => {
const then = new Date('2026-04-26T11:55:00Z');
assertEqual(relativeTime(then, FIXED_NOW), '5m ago', '5m');
});
scenario('relativeTime: hours ago', () => {
const then = new Date('2026-04-26T08:00:00Z');
assertEqual(relativeTime(then, FIXED_NOW), '4h ago', '4h');
});
scenario('relativeTime: days ago', () => {
const then = new Date('2026-04-23T12:00:00Z');
assertEqual(relativeTime(then, FIXED_NOW), '3d ago', '3d');
});
scenario('relativeTime: months ago', () => {
const then = new Date('2026-02-01T00:00:00Z');
assertEqual(relativeTime(then, FIXED_NOW), '2mo ago', '2mo');
});
scenario('relativeTime: future date', () => {
const then = new Date('2026-04-26T12:01:00Z');
assertEqual(relativeTime(then, FIXED_NOW), 'in the future', 'future');
});
// ─── ageSeconds ──────────────────────────────────────────────────
scenario('ageSeconds: positive seconds', () => {
const now = new Date('2026-04-26T12:00:00Z');
const then = new Date('2026-04-26T11:59:30Z');
assertEqual(ageSeconds(then, now), 30, '30');
});
scenario('ageSeconds: floors to integer', () => {
const now = new Date('2026-04-26T12:00:00.500Z');
const then = new Date('2026-04-26T11:59:59.000Z');
assertEqual(ageSeconds(then, now), 1, '1');
});
// ─── utcMidnightToday ────────────────────────────────────────────
scenario('utcMidnightToday: returns UTC 00:00:00 of given date', () => {
const now = new Date('2026-04-26T15:30:00Z');
const midnight = utcMidnightToday(now);
assertEqual(midnight.toISOString(), '2026-04-26T00:00:00.000Z', 'mid');
});
scenario('utcMidnightToday: handles late-evening UTC', () => {
const now = new Date('2026-04-26T23:59:59Z');
const midnight = utcMidnightToday(now);
assertEqual(midnight.toISOString(), '2026-04-26T00:00:00.000Z', 'late');
});
// ─── applyThreshold ──────────────────────────────────────────────
scenario('applyThreshold: lower_worse — value above warn is ok', () => {
const t: Threshold = { warn: 100, error: 30, direction: 'lower_worse' };
assertEqual(applyThreshold(150, t), 'ok', '150');
assertEqual(applyThreshold(101, t), 'ok', '101');
});
scenario('applyThreshold: lower_worse — value at warn is warn', () => {
const t: Threshold = { warn: 100, error: 30, direction: 'lower_worse' };
assertEqual(applyThreshold(100, t), 'warn', '100');
assertEqual(applyThreshold(50, t), 'warn', '50');
});
scenario('applyThreshold: lower_worse — value at or below error is error', () => {
const t: Threshold = { warn: 100, error: 30, direction: 'lower_worse' };
assertEqual(applyThreshold(30, t), 'error', '30');
assertEqual(applyThreshold(0, t), 'error', '0');
});
scenario('applyThreshold: higher_worse — small value is ok', () => {
const t: Threshold = { warn: 5, error: 30, direction: 'higher_worse' };
assertEqual(applyThreshold(0, t), 'ok', '0');
assertEqual(applyThreshold(4, t), 'ok', '4');
});
scenario('applyThreshold: higher_worse — value at warn is warn', () => {
const t: Threshold = { warn: 5, error: 30, direction: 'higher_worse' };
assertEqual(applyThreshold(5, t), 'warn', '5');
assertEqual(applyThreshold(20, t), 'warn', '20');
});
scenario('applyThreshold: higher_worse — value at or above error is error', () => {
const t: Threshold = { warn: 5, error: 30, direction: 'higher_worse' };
assertEqual(applyThreshold(30, t), 'error', '30');
assertEqual(applyThreshold(100, t), 'error', '100');
});
// ─── cp186 — wizard-UX surface (edit reminder, init re-run guard, main menu) ───
// Source-invariant checks: these pin the operator-facing UX so the
// affordances (re-register reminder, Edit/Overwrite/Cancel guard,
// the menu) can't silently regress. Runtime behaviour is exercised
// by hand; here we lock the structural guarantees.
import { readFileSync as _rf } from 'node:fs';
import { fileURLToPath as _fu } from 'node:url';
import { dirname as _dn, join as _jn } from 'node:path';
const _cmd = _jn(_dn(_fu(import.meta.url)), '..', 'src', 'commands');
const _src = (f: string): string => _rf(_jn(_cmd, f), 'utf-8');
scenario('cp186: edit reminds operator to re-register after an origin/tag change', () => {
const e = _src('edit.ts');
assertEqual(e.includes('originChanged = true'), true, 'edit tracks origin change');
assertEqual(e.includes('tagChanged = true'), true, 'edit tracks tag change');
assertEqual(/if \(originChanged \|\| tagChanged\)/.test(e), true, 'edit gates reminder on origin/tag change');
assertEqual(e.includes('morphit-ops register'), true, 'edit names the re-register command');
assertEqual(
e.includes('record other Morphit') || e.includes('reach the federation'),
true,
'edit explains WHY (federation visibility)'
);
});
scenario('cp186: init re-run guard offers Edit/Overwrite/Cancel and can hand off to edit', () => {
const i = _src('init.ts');
assertEqual(i.includes('This instance is already set up'), true, 'init detects existing config with a heading');
assertEqual(i.includes('Edit a few settings (recommended)'), true, 'init offers Edit (recommended)');
assertEqual(i.includes('Overwrite EVERYTHING'), true, 'init offers Overwrite');
assertEqual(i.includes('return await runEdit('), true, 'init hands off to edit in-process');
// The destructive path must still double-confirm before overwriting.
assertEqual(/Are you sure\? This replaces your entire instance config/.test(i), true, 'init double-confirms overwrite');
});
scenario('cp186: bare morphit-ops opens a menu on a TTY but keeps help+exit1 for scripts', () => {
const m = _src('mainMenu.ts');
assertEqual(m.includes('export async function runMainMenu'), true, 'runMainMenu exported');
// Menu must offer the high-value actions the operator asked for.
// (`init` is intentionally NOT a menu item — `install` wraps the
// wizard and does the full setup; init stays a CLI-only command.)
for (const sub of [
'install',
'upgrade',
'edit',
'alt-address',
'payment-method',
'show-key',
'edit-active-key',
'register',
'harden',
'ssl',
'bunkerweb',
'doctor',
'health',
'status',
'signups',
'failed-broadcasts',
'drain-queue',
'moderation'
]) {
assertEqual(new RegExp(`subcommand: '${sub}'`).test(m), true, `menu offers '${sub}'`);
}
// beta11 item 5c: the menu is grouped by lifecycle (install → configure
// → secure → check/operate), and the live indexer health view is a
// first-class menu item alongside doctor + status.
assertEqual(/subcommand: 'health'/.test(m), true, 'menu offers the beta11 health view');
for (const heading of ['Install & upgrade', 'Configure the instance', 'Secure the server', 'Check & operate']) {
assertEqual(m.includes(heading), true, `menu has the '${heading}' lifecycle group`);
}
assertEqual(/subcommand: 'init'/.test(m), false, 'menu does NOT offer init (install wraps it)');
// beta5: abuse + flags folded into the unified `moderation` screen;
// both remain CLI-only subcommands but are no longer menu items.
assertEqual(/subcommand: 'abuse'/.test(m), false, 'menu does NOT offer abuse (folded into moderation)');
assertEqual(/subcommand: 'flags'/.test(m), false, 'menu does NOT offer flags (folded into moderation)');
// beta6: fast-forward demoted to a CLI-only recovery command — the
// indexer auto-starts at the Morphit genesis block and resumes from
// its cursor, so the normal flow never needs it; a menu entry only
// invited a footgun. Still a dispatched `morphit-ops fast-forward`.
assertEqual(/subcommand: 'fast-forward'/.test(m), false, 'menu does NOT offer fast-forward (CLI-only recovery tool)');
const main = _rf(_jn(_dn(_fu(import.meta.url)), '..', 'src', 'main.ts'), 'utf-8');
assertEqual(
main.includes("process.stdin.isTTY === true && args.flags['no-menu'] !== 'true'"),
true,
'main gates the menu on an interactive TTY (and --no-menu opt-out)'
);
assertEqual(/if \(!interactive\) \{\s*printHelp\(\);\s*return 1;/.test(main), true, 'non-interactive keeps help+exit1');
});
scenario('cp187: harden is a standalone subcommand that reuses the shared checklist renderer', () => {
const h = _src('harden.ts');
assertEqual(h.includes('export async function runHarden'), true, 'runHarden exported');
// It must REUSE the init step functions + the shared renderer, not
// re-implement hardening guidance (no drift from setup).
assertEqual(h.includes("from '../init/steps.ts'"), true, 'harden reuses init step functions');
assertEqual(h.includes('renderHardeningChecklist'), true, 'harden reuses the shared checklist renderer');
assertEqual(h.includes('stepBunkerWeb') && h.includes('stepHardening') && h.includes('stepBackup'), true, 'harden walks BunkerWeb + host + backups');
// The renderer must be exported from render.ts for harden to import.
const r = _rf(_jn(_cmd, '..', 'init', 'render.ts'), 'utf-8');
assertEqual(r.includes('export function renderHardeningChecklist'), true, 'renderHardeningChecklist is exported');
assertEqual(r.includes('export interface HardeningChecklistInput'), true, 'minimal checklist input type exported');
// main.ts must dispatch harden BEFORE loadConfig (no DB needed).
const main = _rf(_jn(_dn(_fu(import.meta.url)), '..', 'src', 'main.ts'), 'utf-8');
assertEqual(main.includes("args.subcommand === 'harden'"), true, 'main dispatches harden');
});
scenario('cp192: install is a guided orchestrator that reuses init/harden and offers a PATH symlink', () => {
const i = _src('install.ts');
assertEqual(i.includes('export async function runInstall'), true, 'runInstall exported');
// Reuses the existing wizard pieces, does not re-implement them.
assertEqual(i.includes("from './init.ts'") && i.includes('runInit'), true, 'install reuses runInit');
assertEqual(i.includes("from './harden.ts'") && i.includes('runHarden'), true, 'install reuses runHarden');
// Preflight checks the real prerequisites.
for (const tool of ['node', 'npm', 'psql', 'git']) {
assertEqual(new RegExp(`'${tool}`).test(i), true, `install preflight probes ${tool}`);
}
// Offers the PATH symlink that kills the npx-friction class.
assertEqual(i.includes('/usr/local/bin/morphit-ops') && i.includes('symlinkSync'), true, 'install offers PATH symlink');
// HONEST BOUNDARY: it must NOT silently run host mutation it can't
// validate — it points at the Ansible path / docs when prereqs are
// missing rather than installing Node/Postgres itself.
assertEqual(i.includes('ops/ansible/') || i.includes('Ansible'), true, 'install points at the Ansible path for OS-level install');
// main.ts dispatches it before loadConfig.
const main = _rf(_jn(_dn(_fu(import.meta.url)), '..', 'src', 'main.ts'), 'utf-8');
assertEqual(main.includes("args.subcommand === 'install'"), true, 'main dispatches install');
});
// ─── Summary ─────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(54)}`);
if (failures === 0) {
console.log(`✓ all ${scenarios} scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}