#!/usr/bin/env tsx /** * render-for-instance.ts — Approach C Phase 0 verification harness. * * Loads a CCP-tracked Instance row, builds its template context, and renders * all templates to a scratch directory under /tmp/render-/. Operator * then diffs the rendered output against the tenant's actual on-disk files * to verify the template-vs-prod-compose equivalence contract. * * Usage (run inside ccp-api container): * docker compose exec ccp-api npx tsx scripts/render-for-instance.ts --slug changemakerlite * docker compose exec ccp-api npx tsx scripts/render-for-instance.ts --id * * Output: prints scratch dir path; exits 0 on success, 1 on failure. * * This script does NOT touch any tenant. It only reads from the CCP database * and writes to /tmp on the CCP api container. */ import { prisma } from '../src/lib/prisma'; import { decryptJson } from '../src/utils/encryption'; import { buildTemplateContext, renderAllTemplates, } from '../src/services/template-engine'; import path from 'node:path'; import fs from 'node:fs/promises'; interface Args { slug?: string; id?: string; outDir?: string; } function parseArgs(argv: string[]): Args { const args: Args = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--slug' && argv[i + 1]) { args.slug = argv[++i]; continue; } if (a === '--id' && argv[i + 1]) { args.id = argv[++i]; continue; } if (a === '--out' && argv[i + 1]) { args.outDir = argv[++i]; continue; } if (a === '-h' || a === '--help') { console.log('usage: render-for-instance.ts (--slug X | --id Y) [--out /tmp/render-X]'); process.exit(0); } } return args; } async function main() { const args = parseArgs(process.argv.slice(2)); if (!args.slug && !args.id) { console.error('error: --slug or --id is required'); process.exit(1); } const instance = await prisma.instance.findUnique({ where: args.id ? { id: args.id } : { slug: args.slug! }, }); if (!instance) { console.error(`error: instance not found (slug=${args.slug ?? '?'}, id=${args.id ?? '?'})`); process.exit(1); } // For isRegistered tenants there are no encrypted secrets. Use empty stubs // so buildTemplateContext doesn't crash; env.hbs values that read from // {{secrets.*}} will render as blank, which is fine for diff purposes // because the tenant's own .env still has the real values via install.sh. let secrets: Record = {}; if (instance.encryptedSecrets) { try { secrets = decryptJson>(instance.encryptedSecrets); } catch (err) { console.warn(`warn: decryptJson failed (${(err as Error).message}); using empty secrets`); } } else { console.log(`(isRegistered=true tenant; using empty secrets for compose/nginx render — env.hbs values will be blank)`); } const outDir = args.outDir ?? path.join('/tmp', `render-${instance.slug}`); await fs.rm(outDir, { recursive: true, force: true }); await fs.mkdir(outDir, { recursive: true }); const context = buildTemplateContext(instance, secrets); await renderAllTemplates(context, outDir); // Summarize what we rendered const entries: string[] = []; async function walk(dir: string, rel = '') { const items = await fs.readdir(dir, { withFileTypes: true }); for (const item of items) { const full = path.join(dir, item.name); const r = path.join(rel, item.name); if (item.isDirectory()) await walk(full, r); else entries.push(r); } } await walk(outDir); console.log(`\n=== rendered ${entries.length} files to: ${outDir} ===`); for (const e of entries.sort()) { const stat = await fs.stat(path.join(outDir, e)); console.log(` ${e} (${stat.size} bytes)`); } console.log(`\nTo diff against the live tenant:`); console.log(` ssh 'cat /docker-compose.yml' | diff -u - ${outDir}/docker-compose.yml`); console.log(``); await prisma.$disconnect(); } main().catch((err) => { console.error('render-for-instance.ts failed:', err); process.exit(1); });