import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); const requiredRuntimeDependencies = [ { packageFile: 'apps/server/package.json', dependencies: ['express'], }, ]; const checks = [ { file: 'Dockerfile.web', snippets: [ 'FROM node:20-alpine', 'pnpm --filter @ftb/shared build', 'pnpm --filter web build', 'NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_APP_VERSION', 'ARG APP_VERSION=unknown', 'CMD ["pnpm", "--filter", "web", "start"]', ], }, { file: 'Dockerfile.server', snippets: [ 'FROM node:20-alpine', 'pnpm --filter @ftb/shared build', 'pnpm --filter server build', 'prisma generate', 'APP_VERSION', 'ARG APP_VERSION=unknown', 'CMD ["pnpm", "--filter", "server", "start:prod"]', ], }, { file: 'docker-compose.prod.yml', snippets: [ 'web:', 'server:', 'postgres:', 'redis:', 'nginx:', 'server_data:', 'NEXT_PUBLIC_API_URL', 'SERVER_IMAGE', 'WEB_IMAGE', 'APP_VERSION', '/api/v1/health/version', ], }, { file: '.github/workflows/deploy-production.yml', snippets: [ 'docker/build-push-action', 'appleboy/ssh-action', 'docker compose --env-file .env.production -f docker-compose.prod.yml pull', 'pnpm --filter server db:deploy', '/api/v1/health/version', ], }, { file: 'scripts/check-runtime-version.mjs', snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'], }, { file: 'scripts/backup-postgres.mjs', snippets: ['pg_dump', '--format=custom', '--dry-run'], }, { file: 'scripts/restore-postgres.mjs', snippets: ['--confirm-overwrite', 'dropdb', 'pg_restore'], }, { file: 'scripts/backup-server-data.mjs', snippets: ['server_data', 'tar -czf', '--dry-run'], }, { file: 'docker-compose.local.yml', snippets: [ 'web:', 'server:', 'postgres:', 'redis:', 'nginx:', 'local_server_data:', 'NEXT_PUBLIC_API_URL', '${LOCAL_HTTP_PORT:-8080}:80', ], }, { file: 'deploy/nginx/default.conf.template', snippets: [ 'proxy_pass http://web:3000', 'proxy_pass http://server:3001', 'client_max_body_size', 'X-Forwarded-Proto', ], }, { file: '.env.production.example', snippets: [ 'POSTGRES_PASSWORD=', 'DATABASE_URL=postgresql://', 'NEXT_PUBLIC_API_URL=', 'APP_VERSION=', 'WEB_IMAGE=', 'SERVER_IMAGE=', 'NEXTAUTH_SECRET=', 'ANTHROPIC_API_KEY=', ], }, { file: '.env.local-server.example', snippets: [ 'LOCAL_HTTP_PORT=8080', 'LOCAL_ACCESS_HOST=localhost', 'DATABASE_URL=postgresql://', 'NEXT_PUBLIC_API_URL=/api/v1', 'NEXTAUTH_URL=http://localhost:8080', ], }, { file: 'docs/deployment.md', snippets: [ 'docker-compose.prod.yml', 'docker-compose.local.yml', '本地服务器部署', 'pnpm deploy:verify', 'pnpm backup:postgres', 'pnpm restore:postgres', 'pnpm backup:server-data', 'pnpm db:migrate', 'NEXT_PUBLIC_API_URL', 'Nginx', ], }, ]; let failed = false; for (const check of checks) { const abs = join(root, check.file); if (!existsSync(abs)) { console.error(`Missing deployment artifact: ${check.file}`); failed = true; continue; } const content = readFileSync(abs, 'utf8'); for (const snippet of check.snippets) { if (!content.includes(snippet)) { console.error(`Missing snippet in ${check.file}: ${snippet}`); failed = true; } } } for (const check of requiredRuntimeDependencies) { const abs = join(root, check.packageFile); if (!existsSync(abs)) { console.error(`Missing package file: ${check.packageFile}`); failed = true; continue; } const pkg = JSON.parse(readFileSync(abs, 'utf8')); const dependencies = pkg.dependencies ?? {}; for (const dependency of check.dependencies) { if (!dependencies[dependency]) { console.error(`Missing runtime dependency in ${check.packageFile}: ${dependency}`); failed = true; } } } if (failed) { process.exit(1); } console.log('Production deployment artifacts verified.');