diff --git a/app/scripts/production-readiness.mjs b/app/scripts/production-readiness.mjs new file mode 100644 index 0000000..387f574 --- /dev/null +++ b/app/scripts/production-readiness.mjs @@ -0,0 +1,144 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const checks = []; + +function record(level, label, detail = '') { + checks.push({ level, label, detail }); +} + +function pass(label, detail = '') { + record('PASS', label, detail); +} + +function warn(label, detail = '') { + record('WARN', label, detail); +} + +function fail(label, detail = '') { + record('FAIL', label, detail); +} + +function file(rel) { + return path.join(root, rel); +} + +function read(rel) { + return readFileSync(file(rel), 'utf8'); +} + +function requireFile(rel, label = rel) { + if (existsSync(file(rel))) pass(label); + else fail(label, `${rel} is missing`); +} + +function requireText(rel, patterns, label = rel) { + if (!existsSync(file(rel))) { + fail(label, `${rel} is missing`); + return; + } + const text = read(rel); + for (const pattern of patterns) { + if (typeof pattern === 'string' ? text.includes(pattern) : pattern.test(text)) { + pass(`${label}: ${pattern.toString()}`); + } else { + fail(`${label}: ${pattern.toString()}`, 'not found'); + } + } +} + +function walk(rel, predicate, hits = []) { + const abs = file(rel); + if (!existsSync(abs)) return hits; + for (const name of readdirSync(abs)) { + if (['node_modules', 'dist', '.npm-cache'].includes(name)) continue; + const childRel = path.join(rel, name); + const childAbs = file(childRel); + const stat = statSync(childAbs); + if (stat.isDirectory()) walk(childRel, predicate, hits); + else if (predicate(childRel)) hits.push(childRel); + } + return hits; +} + +requireFile('package.json'); +requireFile('backend/package.json'); +requireFile('backend/prisma/schema.prisma'); +requireFile('.env.prod.example'); +requireFile('backend/.env.example'); +requireFile('nginx/nginx.conf.example'); +requireFile('DEPLOY.md'); +requireFile('SECURITY.md'); + +requireText('package.json', [ + '"check:no-cdn"', + '"check:responsive"', + '"check:seo"', + '"preflight:prod"', + 'generate-article-pages', + 'compress-dist', +], 'frontend scripts'); + +requireText('backend/package.json', [ + '"build": "tsc"', + '"test": "node --test tests/*.test.mjs"', + '"prisma:generate"', + '"prisma:apply"', + '"seed"', +], 'backend scripts'); + +requireText('.env.prod.example', [ + 'DATABASE_URL=', + 'REDIS_URL=', + 'SESSION_SECRET=', + 'COOKIE_DOMAIN=".teknav.ir"', + 'CORS_ORIGINS="https://www.teknav.ir,https://teknav.ir"', + 'UPLOAD_DIR="/var/lib/teknav/uploads"', + 'VAPID_PUBLIC_KEY=', + 'VAPID_PRIVATE_KEY=', + 'ZARINPAL_MERCHANT_ID=', + 'STORAGE_BACKEND="local"', +], 'production env template'); + +requireText('backend/.env.example', [ + 'SMTP_HOST=', + 'SMS_PROVIDER=', + 'VAPID_PUBLIC_KEY=', + 'ZARINPAL_MERCHANT_ID=', + 'STORAGE_BACKEND=local', +], 'backend env example'); + +requireText('nginx/nginx.conf.example', [ + 'location = /ACCOUNTS.md', + /location ~ \^\/api\/\(admin\/stream\|auth\/notifications\/stream\)\$/, + /proxy_buffering\s+off;/, + 'gzip_static on', +], 'nginx production template'); + +const accountLeaks = ['public/ACCOUNTS.md', 'dist/ACCOUNTS.md'].filter((rel) => existsSync(file(rel))); +if (accountLeaks.length) fail('operator credentials not public', accountLeaks.join(', ')); +else pass('operator credentials not public'); + +const migrationsDir = file('backend/prisma/migrations'); +if (existsSync(migrationsDir)) pass('Prisma migrations directory present'); +else warn('Prisma migrations directory absent', 'production currently relies on prisma:apply/db push fallback'); + +const engineArtifacts = walk('backend', (rel) => + /(^|\/)(query_engine.*\.node(\.gz)?|schema-engine.*(\.gz)?)$/.test(rel), +); +if (engineArtifacts.length) fail('no generated Prisma engine artifacts committed', engineArtifacts.join(', ')); +else pass('no generated Prisma engine artifacts committed'); + +const backendTests = walk('backend/tests', (rel) => rel.endsWith('.test.mjs')); +if (backendTests.length >= 2) pass('backend test coverage files', `${backendTests.length} test files`); +else warn('backend test coverage files', `${backendTests.length} test file(s); add more domain tests`); + +const output = checks.map(({ level, label, detail }) => `${level} ${label}${detail ? ` - ${detail}` : ''}`); +console.log(output.join('\n')); + +const failures = checks.filter((item) => item.level === 'FAIL'); +const warnings = checks.filter((item) => item.level === 'WARN'); +console.log(`\nProduction readiness: ${failures.length} failure(s), ${warnings.length} warning(s).`); + +if (failures.length) process.exit(1);