lilith-platform.live/scripts/prod-build-drift.ts
Natalie 9acca0e438 fix(messenger): rename drift probe label and fix deploy deps resolution
The prod-build-drift report still labeled the m.transquinnftw.com SPA as
quinn.m frontend; rename to messenger frontend to match the product name.

Deploy was failing because npm tried to resolve @lilith/quinn-my-mcp from
Verdaccio even though bun build already bundles it (and ws). Strip bundled
workspace deps before the standalone npm install step.
2026-06-23 00:34:26 -04:00

520 lines
No EOL
16 KiB
TypeScript

#!/usr/bin/env bun
/**
* prod-build-drift — report each quinn prod deploy's running build SHA and
* how many commits it is behind origin/main.
*
* Usage (from repo root):
* bun scripts/prod-build-drift.ts
* bun scripts/prod-build-drift.ts --json
* bun scripts/prod-build-drift.ts --no-ssh # public HTTP probes only
* bun scripts/prod-build-drift.ts --no-fetch # skip git fetch origin main
*
* Also: ./run check:prod-builds
*/
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
// ---------------------------------------------------------------------------
// Repo root
// ---------------------------------------------------------------------------
function findRepoRoot(start: string): string {
let dir = resolve(start);
for (let i = 0; i < 8; i++) {
if (existsSync(join(dir, 'users/transquinnftw/app.manifest.yaml'))) return dir;
const parent = resolve(dir, '..');
if (parent === dir) break;
dir = parent;
}
throw new Error('Could not find lilith-platform.live repo root');
}
const REPO = findRepoRoot(process.cwd());
const JSON_OUT = process.argv.includes('--json');
const NO_SSH = process.argv.includes('--no-ssh');
const NO_FETCH = process.argv.includes('--no-fetch');
// ---------------------------------------------------------------------------
// Probe definitions — one row per independently deployed prod surface.
// ---------------------------------------------------------------------------
type ProbeKind = 'http' | 'ssh-curl' | 'ssh-file';
interface Probe {
id: string;
label: string;
kind: ProbeKind;
/** Public HTTPS URL (http probes) */
url?: string;
/** SSH host alias (ssh-* probes) */
sshHost?: string;
/** Remote curl target (ssh-curl) */
curlUrl?: string;
/** Remote JSON file path (ssh-file) */
filePath?: string;
}
const PROBES: Probe[] = [
{
id: 'quinn.www',
label: 'quinn.www (public site)',
kind: 'http',
url: 'https://transquinnftw.com/version.json',
},
{
id: 'quinn.api.edge',
label: 'quinn.api (edge cache)',
kind: 'http',
url: 'https://api.transquinnftw.com/health',
},
{
id: 'quinn.admin.vps',
label: 'quinn.admin-api (vps-0)',
kind: 'http',
url: 'https://admin.transquinnftw.com/health',
},
{
id: 'quinn.admin.black',
label: 'quinn.admin-api (black)',
kind: 'ssh-curl',
sshHost: 'black',
curlUrl: 'http://127.0.0.1:3023/health',
},
{
id: 'quinn.api.black',
label: 'quinn.api (black internal)',
kind: 'ssh-curl',
sshHost: 'black',
curlUrl: 'http://127.0.0.1:3030/health',
},
{
id: 'quinn.my.api',
label: 'quinn.my API',
kind: 'http',
url: 'https://my.transquinnftw.com/health',
},
{
id: 'quinn.my.fe',
label: 'quinn.my frontend',
kind: 'ssh-file',
sshHost: 'quinn-vps',
filePath: '/var/www/quinn.my/public/build-info.json',
},
{
id: 'quinn.sso',
label: 'quinn.sso',
kind: 'http',
url: 'https://sso.transquinnftw.com/health',
},
{
id: 'quinn.admin.fe',
label: 'quinn.admin frontend',
kind: 'ssh-file',
sshHost: 'quinn-vps',
filePath: '/var/www/quinn.admin/dist/build-info.json',
},
{
id: 'quinn.ai.fe',
label: 'quinn.ai frontend',
kind: 'ssh-file',
sshHost: 'quinn-vps',
filePath: '/var/www/quinn.ai/public/build-info.json',
},
{
id: 'messenger.fe',
label: 'messenger frontend',
kind: 'ssh-file',
sshHost: 'quinn-vps',
filePath: '/var/www/quinn.m/dist/build-info.json',
},
{
id: 'quinn.data.website',
label: 'quinn.data (website SPA)',
kind: 'ssh-file',
sshHost: 'quinn-vps',
filePath: '/var/www/quinn.data/website/dist/build-info.json',
},
{
id: 'quinn.data.provider',
label: 'quinn.data (provider SPA)',
kind: 'ssh-file',
sshHost: 'quinn-vps',
filePath: '/var/www/quinn.data/dist/build-info.json',
},
];
// ---------------------------------------------------------------------------
// Fetch helpers
// ---------------------------------------------------------------------------
const SHA_FIELDS = ['sha', 'gitSha', 'gitCommit'] as const;
const VERSION_FIELDS = ['version'] as const;
const BUILT_FIELDS = ['builtAt', 'buildTime'] as const;
interface BuildRecord {
sha: string | null;
version: string | null;
builtAt: string | null;
buildCount: string | null;
raw: Record<string, unknown> | null;
}
function pickField(obj: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const val = obj[key];
if (typeof val === 'string' && val.trim()) return val.trim();
if (typeof val === 'number' && Number.isFinite(val)) return String(val);
}
return null;
}
function parseBuildPayload(text: string): BuildRecord | null {
const trimmed = text.trim();
if (!trimmed || trimmed === 'ok') return null;
try {
const obj = JSON.parse(trimmed) as Record<string, unknown>;
return {
sha: pickField(obj, SHA_FIELDS),
version: pickField(obj, VERSION_FIELDS),
builtAt: pickField(obj, BUILT_FIELDS),
buildCount: pickField(obj, ['buildCount']),
raw: obj,
};
} catch {
return null;
}
}
async function httpFetch(url: string): Promise<{ ok: boolean; body: string; error?: string }> {
try {
const res = await fetch(url, {
headers: { 'User-Agent': 'lilith-prod-build-drift/1.0' },
signal: AbortSignal.timeout(12_000),
redirect: 'follow',
});
const body = await res.text();
if (!res.ok) return { ok: false, body, error: `HTTP ${res.status}` };
return { ok: true, body };
} catch (err) {
return { ok: false, body: '', error: err instanceof Error ? err.message : String(err) };
}
}
function sshExec(host: string, remoteCmd: string): { ok: boolean; stdout: string; error?: string } {
const result = spawnSync(
'ssh',
['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', host, remoteCmd],
{ encoding: 'utf8', timeout: 20_000 },
);
const stdout = (result.stdout ?? '').trim();
const stderr = (result.stderr ?? '').trim();
if (result.status !== 0) {
return { ok: false, stdout, error: stderr || `ssh exit ${result.status ?? '?'}` };
}
return { ok: true, stdout };
}
async function runProbe(probe: Probe): Promise<{ build: BuildRecord | null; error?: string; source: string }> {
if (probe.kind === 'http') {
const res = await httpFetch(probe.url!);
if (!res.ok) return { build: null, error: res.error ?? 'fetch failed', source: probe.url! };
const build = parseBuildPayload(res.body);
if (!build?.sha && !build?.version) {
return { build: null, error: 'response lacks version/sha fields', source: probe.url! };
}
return { build, source: probe.url! };
}
if (NO_SSH) {
return { build: null, error: 'skipped (--no-ssh)', source: probe.sshHost ?? 'ssh' };
}
if (probe.kind === 'ssh-curl') {
const res = sshExec(probe.sshHost!, `curl -sf -m 8 '${probe.curlUrl}'`);
if (!res.ok) return { build: null, error: res.error, source: `${probe.sshHost}:${probe.curlUrl}` };
const build = parseBuildPayload(res.stdout);
if (!build?.sha && !build?.version) {
return { build: null, error: 'response lacks version/sha fields', source: `${probe.sshHost}:${probe.curlUrl}` };
}
return { build, source: `${probe.sshHost}:${probe.curlUrl}` };
}
if (probe.kind === 'ssh-file') {
const res = sshExec(probe.sshHost!, `cat '${probe.filePath}'`);
if (!res.ok) return { build: null, error: res.error, source: `${probe.sshHost}:${probe.filePath}` };
const build = parseBuildPayload(res.stdout);
if (!build?.sha && !build?.version) {
return { build: null, error: 'file lacks version/sha fields', source: `${probe.sshHost}:${probe.filePath}` };
}
return { build, source: `${probe.sshHost}:${probe.filePath}` };
}
return { build: null, error: 'unknown probe kind', source: '?' };
}
// ---------------------------------------------------------------------------
// Git drift
// ---------------------------------------------------------------------------
interface MainRef {
sha: string;
short: string;
fetched: boolean;
fetchError?: string;
}
function resolveMainRef(): MainRef {
if (!NO_FETCH) {
const fetch = spawnSync('git', ['-C', REPO, 'fetch', 'origin', 'main'], {
encoding: 'utf8',
timeout: 30_000,
});
if (fetch.status !== 0) {
return {
sha: '',
short: '',
fetched: false,
fetchError: (fetch.stderr ?? '').trim() || `git fetch exit ${fetch.status}`,
};
}
}
const rev = spawnSync('git', ['-C', REPO, 'rev-parse', 'origin/main'], { encoding: 'utf8' });
if (rev.status !== 0) {
return { sha: '', short: '', fetched: false, fetchError: (rev.stderr ?? '').trim() || 'origin/main missing' };
}
const sha = (rev.stdout ?? '').trim();
const shortRev = spawnSync('git', ['-C', REPO, 'rev-parse', '--short', sha], { encoding: 'utf8' });
const short = shortRev.status === 0 ? (shortRev.stdout ?? '').trim() : sha.slice(0, 8);
return { sha, short, fetched: !NO_FETCH };
}
type DriftStatus = 'current' | 'slightly-stale' | 'stale' | 'untracked' | 'unknown' | 'error';
interface DriftResult {
behind: number | null;
ahead: number | null;
status: DriftStatus;
note?: string;
}
function computeDrift(prodSha: string | null, main: MainRef): DriftResult {
if (!main.sha) {
return { behind: null, ahead: null, status: 'error', note: main.fetchError ?? 'no main ref' };
}
if (!prodSha || prodSha === 'dev' || prodSha === 'unknown') {
return { behind: null, ahead: null, status: 'unknown', note: 'no prod SHA (dev/unknown build stamp)' };
}
const candidates = [prodSha];
if (/^[0-9a-f]+$/i.test(prodSha) && prodSha.length > 8) {
candidates.push(prodSha.slice(0, 8));
}
let fullProd = '';
for (const cand of candidates) {
const resolve = spawnSync('git', ['-C', REPO, 'rev-parse', '--verify', `${cand}^{commit}`], {
encoding: 'utf8',
});
if (resolve.status === 0) {
fullProd = (resolve.stdout ?? '').trim();
break;
}
}
if (!fullProd) {
return { behind: null, ahead: null, status: 'untracked', note: `SHA ${prodSha} not in local repo` };
}
const behindCmd = spawnSync(
'git',
['-C', REPO, 'rev-list', '--count', `${fullProd}..${main.sha}`],
{ encoding: 'utf8' },
);
const aheadCmd = spawnSync(
'git',
['-C', REPO, 'rev-list', '--count', `${main.sha}..${fullProd}`],
{ encoding: 'utf8' },
);
if (behindCmd.status !== 0 || aheadCmd.status !== 0) {
return { behind: null, ahead: null, status: 'error', note: 'git rev-list failed' };
}
const behind = Number.parseInt((behindCmd.stdout ?? '').trim(), 10);
const ahead = Number.parseInt((aheadCmd.stdout ?? '').trim(), 10);
if (!Number.isFinite(behind) || !Number.isFinite(ahead)) {
return { behind: null, ahead: null, status: 'error', note: 'invalid rev-list output' };
}
if (ahead > 0) {
return { behind, ahead, status: 'untracked', note: `${ahead} commits ahead of main (not on main?)` };
}
if (behind === 0) return { behind, ahead, status: 'current' };
if (behind <= 10) return { behind, ahead, status: 'slightly-stale' };
return { behind, ahead, status: 'stale' };
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
interface Row {
probe: Probe;
build: BuildRecord | null;
drift: DriftResult;
error?: string;
source: string;
}
const STATUS_LABEL: Record<DriftStatus, string> = {
current: 'current',
'slightly-stale': 'slightly stale',
stale: 'stale',
untracked: 'untracked',
unknown: 'unknown SHA',
error: 'error',
};
function shortSha(sha: string | null): string {
if (!sha) return '—';
return sha.length > 8 ? sha.slice(0, 8) : sha;
}
function formatBehind(drift: DriftResult): string {
if (drift.behind === null) return '—';
if (drift.ahead && drift.ahead > 0) return `+${drift.ahead} ahead`;
if (drift.behind === 0) return '0';
return String(drift.behind);
}
function statusColor(status: DriftStatus): string {
switch (status) {
case 'current':
return '\x1b[32m';
case 'slightly-stale':
return '\x1b[33m';
case 'stale':
case 'unknown':
case 'untracked':
case 'error':
return '\x1b[31m';
default:
return '';
}
}
const RESET = '\x1b[0m';
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
async function main(): Promise<void> {
const mainRef = resolveMainRef();
const rows: Row[] = [];
for (const probe of PROBES) {
if (NO_SSH && probe.kind !== 'http') {
rows.push({
probe,
build: null,
drift: { behind: null, ahead: null, status: 'error', note: 'skipped (--no-ssh)' },
error: 'skipped (--no-ssh)',
source: probe.sshHost ?? 'ssh',
});
continue;
}
const result = await runProbe(probe);
const drift = computeDrift(result.build?.sha ?? null, mainRef);
rows.push({
probe,
build: result.build,
drift,
error: result.error,
source: result.source,
});
}
if (JSON_OUT) {
console.log(
JSON.stringify(
{
main: mainRef,
generatedAt: new Date().toISOString(),
rows: rows.map((r) => ({
id: r.probe.id,
label: r.probe.label,
source: r.source,
sha: r.build?.sha ?? null,
version: r.build?.version ?? null,
builtAt: r.build?.builtAt ?? null,
buildCount: r.build?.buildCount ?? null,
behindMain: r.drift.behind,
aheadMain: r.drift.ahead,
status: r.drift.status,
note: r.drift.note ?? r.error ?? null,
})),
},
null,
2,
),
);
const hasHardError = rows.some((r) => r.drift.status === 'error' && !r.error?.includes('skipped'));
const hasStale = rows.some((r) => r.drift.status === 'stale' || r.drift.status === 'unknown');
process.exit(hasHardError ? 2 : hasStale ? 1 : 0);
return;
}
console.log(`${BOLD}Quinn prod build drift${RESET}`);
if (mainRef.sha) {
console.log(`origin/main: ${mainRef.short} (${mainRef.sha.slice(0, 12)}…)`);
} else {
console.log(`${statusColor('error')}origin/main: unavailable — ${mainRef.fetchError ?? 'unknown'}${RESET}`);
}
if (NO_SSH) console.log(`${DIM}(SSH probes skipped — pass no flag for full matrix)${RESET}`);
console.log('');
const labelW = Math.max(...rows.map((r) => r.probe.label.length), 'Service'.length);
console.log(
`${'Service'.padEnd(labelW)} ${'SHA'.padEnd(10)} ${'Behind'.padEnd(8)} ${'Version'.padEnd(28)} Status`,
);
console.log(`${'─'.repeat(labelW)} ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(28)} ${'─'.repeat(14)}`);
let staleCount = 0;
let errorCount = 0;
for (const row of rows) {
const { probe, build, drift, error } = row;
const color = statusColor(drift.status);
const sha = shortSha(build?.sha ?? null);
const behind = formatBehind(drift);
const version = (build?.version ?? '—').slice(0, 28);
let status = STATUS_LABEL[drift.status];
if (error && !build) status = `ERR: ${error.slice(0, 40)}`;
else if (drift.note && drift.status !== 'current') status = drift.note.slice(0, 40);
if (drift.status === 'stale' || drift.status === 'unknown') staleCount++;
if (drift.status === 'error' && !error?.includes('skipped')) errorCount++;
console.log(
`${probe.label.padEnd(labelW)} ${sha.padEnd(10)} ${behind.padEnd(8)} ${version.padEnd(28)} ${color}${status}${RESET}`,
);
}
console.log('');
const current = rows.filter((r) => r.drift.status === 'current').length;
const slightly = rows.filter((r) => r.drift.status === 'slightly-stale').length;
console.log(
`Summary: ${current} current, ${slightly} slightly stale, ${staleCount} stale/unknown, ${errorCount} errors` +
(NO_SSH ? ` (${rows.filter((r) => r.error?.includes('--no-ssh')).length} skipped)` : ''),
);
process.exit(errorCount > 0 ? 2 : staleCount > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(2);
});