Files
Home/build-pipeline.js

403 lines
12 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const AdmZip = require('adm-zip');
const { execSync } = require('child_process');
const BUILD_DIR = path.join(__dirname, 'dist-build');
const REPOS = ['frontend', 'player'];
const TEMP_EXTRACT_DIR = path.join(__dirname, 'temp-validation-extract');
const GITLAB_GROUP = 'vyla-entertainment';
const REPO_CONFIG = {
'frontend': { name: 'frontend', branch: 'main' },
'player': { name: 'player', branch: 'main' },
};
const clonedRepos = new Set();
function clonePrivateRepo(repoConfig, targetDir) {
if (fs.existsSync(targetDir)) {
console.log(`Removing stale ${repoConfig.name} before fresh clone...`);
fs.rmSync(targetDir, { recursive: true, force: true });
}
const token = process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN;
const auth = token ? `oauth2:${token}@` : '';
const repoUrl = `https://${auth}gitlab.com/${GITLAB_GROUP}/${repoConfig.name}.git`;
try {
execSync(`git clone --depth 1 --branch ${repoConfig.branch} ${repoUrl} ${targetDir}`, {
stdio: 'inherit'
});
clonedRepos.add(repoConfig.name);
} catch (error) {
throw new Error(`Failed to clone ${repoConfig.name}: ${error.message}`);
}
}
function cloneAllRepos() {
for (const [key, config] of Object.entries(REPO_CONFIG)) {
const targetDir = path.join(__dirname, key);
clonePrivateRepo(config, targetDir);
}
}
async function installDependencies() {
await Promise.all(REPOS.map(repo => installOneRepo(repo)));
}
function installOneRepo(repo) {
const repoDir = path.join(__dirname, repo);
const pkgJsonPath = path.join(repoDir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) {
return;
}
try {
execSync('npm install --omit=dev', {
cwd: repoDir,
stdio: 'inherit'
});
} catch (error) {
throw new Error(`npm install failed for ${repo}: ${error.message}`);
}
}
function cleanBuildDir() {
if (fs.existsSync(BUILD_DIR)) {
fs.rmSync(BUILD_DIR, { recursive: true, force: true });
}
fs.mkdirSync(BUILD_DIR, { recursive: true });
}
let credentialsId = null;
function encryptCredentialsFile() {
const CryptoUtils = require('./crypto-utils');
const credentialsTemplate = path.join(__dirname, 'config/credentials-template.json');
const encryptedOutput = path.join(__dirname, 'config/encrypted-credentials.json');
if (!fs.existsSync(credentialsTemplate)) {
return null;
}
try {
const credentials = JSON.parse(fs.readFileSync(credentialsTemplate, 'utf8'));
const hasPlaceholders = Object.values(credentials).some(service =>
Object.values(service).some(value =>
value && value.includes('YOUR_') && value.includes('_HERE')
)
);
if (hasPlaceholders) {
const envCredentials = {};
for (const [service, keys] of Object.entries(credentials)) {
envCredentials[service] = {};
for (const key of Object.keys(keys)) {
const envVarName = `${service.toUpperCase().replace(/-/g, '_')}_${key.toUpperCase()}`;
if (process.env[envVarName]) {
envCredentials[service][key] = process.env[envVarName];
}
}
}
if (Object.values(envCredentials).some(s => Object.keys(s).length > 0)) {
Object.assign(credentials, envCredentials);
}
}
credentialsId = crypto.createHash('sha256').update(JSON.stringify(credentials)).digest('hex');
const buildKey = CryptoUtils.deriveKey('vyla-home-build-key', 'vyla-home-payload-salt-v1');
const encryptedData = CryptoUtils.encryptObject(credentials, buildKey);
fs.writeFileSync(encryptedOutput, encryptedData, 'utf8');
return encryptedOutput;
} catch (error) {
throw error;
}
}
function collectJsFiles(src, dest, out) {
const files = fs.readdirSync(src);
fs.mkdirSync(dest, { recursive: true });
for (const file of files) {
const srcPath = path.join(src, file);
const destPath = path.join(dest, file);
const stat = fs.statSync(srcPath);
if (stat.isDirectory()) {
if (file === 'node_modules' || file === '.git' || file === '.github') continue;
collectJsFiles(srcPath, destPath, out);
} else if (stat.isFile()) {
fs.copyFileSync(srcPath, destPath);
if (path.extname(srcPath).toLowerCase() === '.js') {
out.push(destPath);
}
}
}
}
function processFolder(src, dest) {
const jsFiles = [];
collectJsFiles(src, dest, jsFiles);
let isEsm = false;
const pkgPath = path.join(src, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (pkg.type === 'module') isEsm = true;
} catch (e) { }
}
}
function getFileChecksum(filePath) {
const hash = crypto.createHash('sha256');
const data = fs.readFileSync(filePath);
hash.update(data);
return hash.digest('hex');
}
function validatePayload(zipPath) {
if (fs.existsSync(TEMP_EXTRACT_DIR)) {
fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true });
}
fs.mkdirSync(TEMP_EXTRACT_DIR, { recursive: true });
const zip = new AdmZip(zipPath);
zip.extractAllTo(TEMP_EXTRACT_DIR, true);
const requiredFiles = [
'frontend/index.html'
];
const validationResults = {
valid: true,
errors: [],
checksum: getFileChecksum(zipPath),
timestamp: new Date().toISOString()
};
for (const file of requiredFiles) {
const fullPath = path.join(TEMP_EXTRACT_DIR, file);
if (!fs.existsSync(fullPath)) {
validationResults.valid = false;
validationResults.errors.push(`Missing critical file: ${file}`);
}
}
const envPath = path.join(__dirname, 'config/env.json');
const replacementsPath = path.join(__dirname, 'config/replacements.json');
if (!fs.existsSync(envPath)) {
validationResults.valid = false;
validationResults.errors.push('Missing environment configuration: config/env.json');
} else {
try {
JSON.parse(fs.readFileSync(envPath, 'utf8'));
} catch (e) {
validationResults.valid = false;
validationResults.errors.push(`Invalid JSON format in config/env.json: ${e.message}`);
}
}
if (!fs.existsSync(replacementsPath)) {
validationResults.valid = false;
validationResults.errors.push('Missing replacements configuration: config/replacements.json');
} else {
try {
JSON.parse(fs.readFileSync(replacementsPath, 'utf8'));
} catch (e) {
validationResults.valid = false;
validationResults.errors.push(`Invalid JSON format in config/replacements.json: ${e.message}`);
}
}
fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true });
fs.writeFileSync(path.join(__dirname, 'validation-report.json'), JSON.stringify(validationResults, null, 2), 'utf8');
if (!validationResults.valid) {
console.error("Payload validation failed with errors:", validationResults.errors);
process.exit(1);
}
return validationResults.checksum;
}
function directoryId(dir) {
const hash = crypto.createHash('sha256');
const walk = (current, rel) => {
for (const name of fs.readdirSync(current).sort()) {
const full = path.join(current, name);
const relPath = rel ? `${rel}/${name}` : name;
if (fs.statSync(full).isDirectory()) {
walk(full, relPath);
} else {
hash.update(`${relPath}\0${getFileChecksum(full)}\n`);
}
}
};
walk(dir, '');
return hash.digest('hex');
}
function signManifest(manifest) {
const key = process.env.VYLA_UPDATE_SIGNING_KEY;
if (!key) {
throw new Error('VYLA_UPDATE_SIGNING_KEY is required to sign the update manifest.');
}
const UpdateClient = require('./update-client');
return crypto.sign(null, Buffer.from(UpdateClient.canonicalManifest(manifest)), key.replace(/\\n/g, '\n')).toString('base64');
}
async function build() {
try {
cloneAllRepos();
} catch (error) {
console.error("Cloning failed:", error);
process.exit(1);
}
for (const repo of REPOS) {
const srcRepo = path.join(__dirname, repo);
if (!fs.existsSync(srcRepo)) {
console.error(`Repository directory missing: ${srcRepo}`);
process.exit(1);
}
}
try {
await installDependencies();
} catch (error) {
console.error("Dependency installation failed:", error);
process.exit(1);
}
let encryptedCredentialsPath = null;
try {
encryptedCredentialsPath = encryptCredentialsFile();
} catch (error) {
console.error("Credential encryption warning (non-fatal):", error);
}
cleanBuildDir();
for (const repo of REPOS) {
const srcRepo = path.join(__dirname, repo);
const destRepo = path.join(BUILD_DIR, repo);
processFolder(srcRepo, destRepo);
const srcModules = path.join(srcRepo, 'node_modules');
const destModules = path.join(destRepo, 'node_modules');
if (fs.existsSync(srcModules)) {
fs.cpSync(srcModules, destModules, {
recursive: true,
filter: (src) => !/[\\/](test|tests|docs|\.github|\.git)([\\/]|$)/.test(src) && !/\.(md|markdown|map|ts)$/i.test(src)
});
}
}
if (encryptedCredentialsPath && fs.existsSync(encryptedCredentialsPath)) {
const credentialsDest = path.join(BUILD_DIR, 'encrypted-credentials.json');
fs.copyFileSync(encryptedCredentialsPath, credentialsDest);
}
const configDir = path.join(BUILD_DIR, 'config');
fs.mkdirSync(configDir, { recursive: true });
const envConfig = path.join(__dirname, 'config/env.json');
const replacementsConfig = path.join(__dirname, 'config/replacements.json');
if (fs.existsSync(envConfig)) {
fs.copyFileSync(envConfig, path.join(configDir, 'env.json'));
}
if (fs.existsSync(replacementsConfig)) {
fs.copyFileSync(replacementsConfig, path.join(configDir, 'replacements.json'));
}
const zip = new AdmZip();
for (const repo of REPOS) {
const destRepo = path.join(BUILD_DIR, repo);
zip.addLocalFolder(destRepo, repo);
}
zip.addLocalFolder(configDir, 'config');
if (fs.existsSync(path.join(BUILD_DIR, 'encrypted-credentials.json'))) {
zip.addFile('encrypted-credentials.json', fs.readFileSync(path.join(BUILD_DIR, 'encrypted-credentials.json')));
}
const zipPath = path.join(__dirname, 'update-payload.zip');
if (fs.existsSync(zipPath)) {
fs.rmSync(zipPath, { force: true });
}
zip.writeZip(zipPath);
const checksum = validatePayload(zipPath);
let wrapperVersion = '2.0.0';
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
wrapperVersion = pkg.version || '2.0.0';
} catch (e) { }
const releaseVersion = (process.env.RELEASE_VERSION || wrapperVersion).replace(/^v/, '');
const assetsDir = path.join(__dirname, 'release-assets');
fs.rmSync(assetsDir, { recursive: true, force: true });
fs.mkdirSync(assetsDir, { recursive: true });
const components = {};
const addComponent = (name, fill, id) => {
const file = `${name}.zip`;
const target = path.join(assetsDir, file);
const componentZip = new AdmZip();
fill(componentZip);
componentZip.writeZip(target);
components[name] = { file, sha256: getFileChecksum(target), id, size: fs.statSync(target).size };
};
for (const repo of REPOS) {
const destRepo = path.join(BUILD_DIR, repo);
addComponent(repo, (z) => z.addLocalFolder(destRepo, repo), directoryId(destRepo));
}
addComponent('config', (z) => z.addLocalFolder(configDir, 'config'), directoryId(configDir));
const bundledCredentials = path.join(BUILD_DIR, 'encrypted-credentials.json');
if (fs.existsSync(bundledCredentials) && credentialsId) {
addComponent('secrets', (z) => z.addFile('encrypted-credentials.json', fs.readFileSync(bundledCredentials)), credentialsId);
}
const manifest = {
version: releaseVersion,
sha256: checksum,
timestamp: new Date().toISOString(),
hasEncryptedCredentials: !!encryptedCredentialsPath,
components
};
manifest.signature = signManifest(manifest);
fs.writeFileSync(path.join(__dirname, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
for (const repo of REPOS) {
if (clonedRepos.has(repo)) {
const repoPath = path.join(__dirname, repo);
if (fs.existsSync(repoPath)) {
fs.rmSync(repoPath, { recursive: true, force: true });
}
}
}
}
build().catch((error) => {
console.error("Build process encountered an unhandled exception:", error);
process.exit(1);
});