Files
NianToB/scripts/prepare-nianxx-play-bundle.mjs
inman 0abc48189c
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: prepare Zhinian desktop pilot
2026-05-07 21:49:20 +08:00

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`);