300 lines
9.2 KiB
JavaScript
300 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import {
|
|
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' || name === '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;
|
|
if (name === 'uploads' || name === '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 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 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);
|
|
|
|
assertNoEnvFiles(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'],
|
|
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`);
|