#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { basename, dirname, join, resolve, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const DEFAULT_SOURCE = resolve(ROOT, '..', '..', 'NianxxPlay'); const SOURCE_DIR = resolve(process.env.NIANXX_PLAY_DIR || DEFAULT_SOURCE); const OUTPUT_DIR = resolve(ROOT, 'build', 'apps', 'nianxx-play'); const BUNDLE_RUNTIME_ENV = process.env.NIANXX_PLAY_BUNDLE_ENV === '1'; const RUNTIME_ENV_FILE_NAME = '.env.runtime'; function log(message) { console.log(`[nianxx-play-bundle] ${message}`); } function fail(message) { console.error(`[nianxx-play-bundle] ${message}`); process.exit(1); } function run(command, args, cwd) { const result = spawnSync(command, args, { cwd, env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1', }, stdio: 'inherit', shell: process.platform === 'win32', }); if (result.status !== 0) { fail(`${command} ${args.join(' ')} failed with exit code ${result.status ?? 'unknown'}`); } } function readPackageVersion(packageJsonPath) { try { const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); return typeof pkg.version === 'string' ? pkg.version : '0.0.0'; } catch { return '0.0.0'; } } function parseEnvFile(envPath) { if (!existsSync(envPath)) return []; const entries = []; const raw = readFileSync(envPath, 'utf8'); for (const line of raw.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) continue; const key = match[1]; let value = match[2].trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } entries.push({ key, value }); } return entries; } function selectRuntimeEnvFile(sourceDir) { const explicit = process.env.NIANXX_PLAY_ENV_FILE?.trim(); if (explicit) { const resolved = resolve(explicit); return existsSync(resolved) ? resolved : undefined; } for (const fileName of ['.env.local', '.env.production.local', '.env.production', '.env']) { const candidate = join(sourceDir, fileName); if (existsSync(candidate)) return candidate; } return undefined; } function encodeEnvValue(value) { return JSON.stringify(value); } function writeRuntimeEnvFile(sourceDir, outputDir) { if (!BUNDLE_RUNTIME_ENV) return { bundled: false, values: 0 }; const envFile = selectRuntimeEnvFile(sourceDir); if (!envFile) { fail('NIANXX_PLAY_BUNDLE_ENV=1 set, but no NianxxPlay env file was found.'); } const entries = parseEnvFile(envFile); if (!entries.length) { fail(`NIANXX_PLAY_BUNDLE_ENV=1 set, but env file has no usable entries: ${envFile}`); } const runtimeEnvPath = join(outputDir, RUNTIME_ENV_FILE_NAME); const text = [ '# Bundled only for internal testing. Do not use in production builds.', ...entries.map((entry) => `${entry.key}=${encodeEnvValue(entry.value)}`), '', ].join('\n'); writeFileSync(runtimeEnvPath, text, 'utf8'); return { bundled: true, values: entries.length }; } function collectSecretLikeEnvValues(sourceDir) { const envFileNames = [ '.env', '.env.local', '.env.production', '.env.production.local', ]; const sensitiveKeyPattern = /(SECRET|TOKEN|PASSWORD|PRIVATE|AUTH|API_KEY|ACCESS_KEY|KEY_ID|KEY_SECRET|SERVICE_ROLE)/i; const ignoredValues = new Set(['true', 'false', 'null', 'undefined', 'development', 'production']); const values = []; for (const fileName of envFileNames) { for (const entry of parseEnvFile(join(sourceDir, fileName))) { if (!sensitiveKeyPattern.test(entry.key)) continue; if (!entry.value || entry.value.length < 8) continue; if (ignoredValues.has(entry.value.toLowerCase())) continue; values.push(entry); } } return values; } function shouldCopyPublic(src) { const name = basename(src); if (name === 'uploads') return false; const publicRelative = relative(publicDir, src).split('\\').join('/'); if (publicRelative === 'generated-results' || publicRelative.startsWith('generated-results/')) return false; if (name.startsWith('.env')) return false; return true; } function shouldCopyRuntime(src) { const name = basename(src); if (name.startsWith('.env')) return false; if (name === '.data' || name === '.git' || name === '.next-cache') return false; const runtimeRelative = relative(standaloneDir, src).split('\\').join('/'); if (runtimeRelative === 'public/uploads' || runtimeRelative.startsWith('public/uploads/')) return false; if (runtimeRelative === 'public/generated-results' || runtimeRelative.startsWith('public/generated-results/')) return false; if (runtimeRelative === 'uploads' || runtimeRelative.startsWith('uploads/')) return false; if (runtimeRelative === 'generated-results' || runtimeRelative.startsWith('generated-results/')) return false; return true; } function copyDir(from, to, filter = () => true) { if (!existsSync(from)) return false; mkdirSync(dirname(to), { recursive: true }); cpSync(from, to, { recursive: true, dereference: true, filter, }); return true; } function normalizeBundlePermissions(dir) { if (!existsSync(dir)) return; const stats = statSync(dir); try { if (stats.isDirectory()) { chmodSync(dir, 0o755); for (const entry of readdirSync(dir)) { normalizeBundlePermissions(join(dir, entry)); } return; } if (stats.isFile()) { const executable = (stats.mode & 0o111) !== 0; chmodSync(dir, executable ? 0o755 : 0o644); } } catch (error) { fail(`Unable to normalize bundle permissions for ${relative(ROOT, dir)}: ${error.message}`); } } function dirSizeBytes(dir) { if (!existsSync(dir)) return 0; const stats = statSync(dir); if (stats.isFile()) return stats.size; let size = 0; for (const entry of readdirSync(dir)) { size += dirSizeBytes(join(dir, entry)); } return size; } function assertNoEnvFiles(dir) { const allowedRuntimeEnvPath = BUNDLE_RUNTIME_ENV ? join(OUTPUT_DIR, RUNTIME_ENV_FILE_NAME) : undefined; const stack = [dir]; while (stack.length) { const current = stack.pop(); for (const entry of readdirSync(current)) { const fullPath = join(current, entry); if (allowedRuntimeEnvPath && fullPath === allowedRuntimeEnvPath) { continue; } if (basename(entry).startsWith('.env')) { fail(`Refusing to ship env file: ${relative(ROOT, fullPath)}`); } if (statSync(fullPath).isDirectory()) stack.push(fullPath); } } } function assertNoForbiddenBundlePaths(outputDir) { const forbidden = [ join(outputDir, 'public', 'uploads'), join(outputDir, 'public', 'generated-results'), join(outputDir, '.next', 'cache'), ]; for (const target of forbidden) { if (existsSync(target)) { fail(`Refusing to ship development/user data path: ${relative(ROOT, target)}`); } } } function shouldScanForSecrets(filePath) { const stats = statSync(filePath); if (!stats.isFile()) return false; if (stats.size > 5 * 1024 * 1024) return false; return /\.(?:js|json|html|css|txt|mjs|cjs|map)$/i.test(filePath); } function assertNoSecretValues(dir, secretEntries) { if (!secretEntries.length) return; const stack = [dir]; while (stack.length) { const current = stack.pop(); for (const entry of readdirSync(current)) { const fullPath = join(current, entry); const stats = statSync(fullPath); if (stats.isDirectory()) { stack.push(fullPath); continue; } if (BUNDLE_RUNTIME_ENV && fullPath === join(OUTPUT_DIR, RUNTIME_ENV_FILE_NAME)) continue; if (!shouldScanForSecrets(fullPath)) continue; const content = readFileSync(fullPath, 'utf8'); for (const secret of secretEntries) { if (content.includes(secret.value)) { fail(`Refusing to ship bundle: secret-like env value "${secret.key}" appears in ${relative(ROOT, fullPath)}`); } } } } } if (process.env.SKIP_NIANXX_PLAY_BUNDLE === '1') { log('SKIP_NIANXX_PLAY_BUNDLE=1 set, skipping.'); process.exit(0); } if (!existsSync(join(SOURCE_DIR, 'package.json'))) { fail(`NianxxPlay source not found: ${SOURCE_DIR}`); } const sourceVersion = readPackageVersion(join(SOURCE_DIR, 'package.json')); const secretLikeEnvValues = collectSecretLikeEnvValues(SOURCE_DIR); log(`source: ${SOURCE_DIR}`); log(`version: ${sourceVersion}`); if (process.env.NIANXX_PLAY_SKIP_BUILD !== '1') { log('building Next.js standalone runtime...'); run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build'], SOURCE_DIR); } else { log('NIANXX_PLAY_SKIP_BUILD=1 set, reusing existing .next output.'); } const standaloneDir = join(SOURCE_DIR, '.next', 'standalone'); const staticDir = join(SOURCE_DIR, '.next', 'static'); const publicDir = join(SOURCE_DIR, 'public'); const contentDir = join(SOURCE_DIR, 'content'); if (!existsSync(join(standaloneDir, 'server.js'))) { fail(`Missing Next.js standalone server: ${join(standaloneDir, 'server.js')}`); } if (!existsSync(staticDir)) { fail(`Missing Next.js static output: ${staticDir}`); } rmSync(OUTPUT_DIR, { recursive: true, force: true }); mkdirSync(OUTPUT_DIR, { recursive: true }); log(`copying standalone runtime -> ${OUTPUT_DIR}`); copyDir(standaloneDir, OUTPUT_DIR, shouldCopyRuntime); copyDir(staticDir, join(OUTPUT_DIR, '.next', 'static'), shouldCopyRuntime); copyDir(publicDir, join(OUTPUT_DIR, 'public'), shouldCopyPublic); copyDir(contentDir, join(OUTPUT_DIR, 'content'), shouldCopyRuntime); const runtimeEnv = writeRuntimeEnvFile(SOURCE_DIR, OUTPUT_DIR); normalizeBundlePermissions(OUTPUT_DIR); assertNoEnvFiles(OUTPUT_DIR); assertNoForbiddenBundlePaths(OUTPUT_DIR); assertNoSecretValues(OUTPUT_DIR, secretLikeEnvValues); const manifest = { appId: 'nianxx-play', name: 'NianxxPlay', version: sourceVersion, bundledAt: new Date().toISOString(), runtime: 'next-standalone', entry: 'server.js', excludes: ['.env*', '.data', 'public/uploads', 'public/generated-results', 'development caches'], secretScan: { checked: true, sourceEnvValues: secretLikeEnvValues.length, }, runtimeEnv: runtimeEnv.bundled ? { bundled: true, file: RUNTIME_ENV_FILE_NAME, values: runtimeEnv.values, purpose: 'internal-testing-only', } : { bundled: false, }, sizeBytes: dirSizeBytes(OUTPUT_DIR), }; writeFileSync(join(OUTPUT_DIR, 'bundle-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); log(`ready: ${OUTPUT_DIR}`); log(`size: ${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB`);