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.
520 lines
No EOL
16 KiB
TypeScript
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);
|
|
}); |