import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; const repoRoot = process.cwd(); const taroRoot = path.join(repoRoot, 'apps', 'taro'); const deployRoot = path.join(taroRoot, 'deploy'); const distRoot = path.join(taroRoot, 'dist'); const portals = [ { portal: 'student', buildScript: 'build:taro:h5:student', appBuildScript: 'build:h5:student', dist: 'h5-student', landingPath: '/pages/student/home/index', runtimeExample: 'h5-student.runtime-config.example.json', deployHint: 'student.example.com -> apps/taro/dist/h5-student', }, { portal: 'tenant-admin', buildScript: 'build:taro:h5:tenant', appBuildScript: 'build:h5:tenant', dist: 'h5-tenant-admin', landingPath: '/pages/tenant-admin/workbench/index', runtimeExample: 'h5-tenant-admin.runtime-config.example.json', deployHint: 'admin.example.com -> apps/taro/dist/h5-tenant-admin', }, { portal: 'platform-admin', buildScript: 'build:taro:h5:platform', appBuildScript: 'build:h5:platform', dist: 'h5-platform-admin', landingPath: '/pages/platform-admin/workbench/index', runtimeExample: 'h5-platform-admin.runtime-config.example.json', deployHint: 'console.example.com -> apps/taro/dist/h5-platform-admin', }, ]; const allowedRuntimeConfigKeys = new Set([ 'portal', 'apiBaseUrl', 'supabaseUrl', 'supabasePublishableKey', 'tenantCode', 'TARO_APP_PORTAL', 'TARO_APP_API_BASE_URL', 'TARO_APP_SUPABASE_URL', 'TARO_APP_SUPABASE_PUBLISHABLE_KEY', 'TARO_APP_TENANT_CODE', ]); const forbiddenRuntimeConfigKeys = new Set([ 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_SECRET_KEY', 'DATABASE_URL', 'ALIYUN_OSS_ACCESS_KEY_SECRET', 'TENCENT_COS_SECRET_KEY', 'WECHAT_PAY_PRIVATE_KEY', 'ALIPAY_APP_PRIVATE_KEY', 'AUTH_SESSION_SECRET', 'PLATFORM_ADMIN_API_KEY', ]); const forbiddenValuePatterns = [ /postgres(?:ql)?:\/\//i, /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, /\bAKIA[0-9A-Z]{16}\b/, /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/, /\b(service_role|secret|private_key)\b/i, ]; function parseArgs(argv) { const options = { json: false, requireDist: false, requireRuntimeConfig: false, writePath: '', }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--json') options.json = true; else if (arg === '--require-dist') options.requireDist = true; else if (arg === '--require-runtime-config') { options.requireRuntimeConfig = true; options.requireDist = true; } else if (arg === '--write') { options.writePath = path.resolve(repoRoot, argv[index + 1] || ''); index += 1; } else if (arg.startsWith('--write=')) { options.writePath = path.resolve(repoRoot, arg.slice('--write='.length)); } } return options; } function normalizeSlashes(value) { return value.replace(/\\/g, '/'); } function relative(filePath) { return normalizeSlashes(path.relative(repoRoot, filePath)); } function readText(filePath) { return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); } function readJson(filePath) { return JSON.parse(readText(filePath)); } function walkFiles(dir) { if (!fs.existsSync(dir)) return []; const result = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) result.push(...walkFiles(entryPath)); else result.push(entryPath); } return result; } function parseAssetUrls(indexHtml) { const urls = new Set(); for (const match of indexHtml.matchAll(/]*\bsrc=["']([^"']+)["'][^>]*>/gi)) urls.add(match[1]); for (const match of indexHtml.matchAll(/]*\bhref=["']([^"']+)["'][^>]*>/gi)) { if (/rel=["']stylesheet["']/i.test(match[0])) urls.add(match[1]); } return [...urls]; } function hashFile(filePath) { const hash = crypto.createHash('sha256'); hash.update(fs.readFileSync(filePath)); return hash.digest('hex'); } function createCollector() { const checks = []; function push(status, id, message, details = {}) { checks.push({ status, id, message, details }); } return { checks, pass: (id, message, details) => push('pass', id, message, details), warn: (id, message, details) => push('warn', id, message, details), fail: (id, message, details) => push('fail', id, message, details), }; } function summarize(checks, portalResults) { const statusCounts = checks.reduce( (summary, item) => { summary[item.status] += 1; return summary; }, { fail: 0, warn: 0, pass: 0 }, ); return { ...statusCounts, portals: portals.length, distReady: portalResults.filter(item => item.dist?.exists && item.dist?.indexHtml).length, runtimeConfigs: portalResults.filter(item => item.runtimeConfig?.exists).length, }; } function looksSecretish(value) { const text = String(value || ''); return forbiddenValuePatterns.some(pattern => pattern.test(text)); } function publicRuntimeConfig(config) { return { portal: config.portal || config.TARO_APP_PORTAL || '', apiBaseUrl: config.apiBaseUrl || config.TARO_APP_API_BASE_URL || '', supabaseUrl: config.supabaseUrl || config.TARO_APP_SUPABASE_URL || '', supabasePublishableKey: config.supabasePublishableKey || config.TARO_APP_SUPABASE_PUBLISHABLE_KEY || '', tenantCode: config.tenantCode || config.TARO_APP_TENANT_CODE || '', }; } function validateRuntimeConfig(filePath, expectedPortal, collector, options = {}) { const required = Boolean(options.required); const allowPlaceholderKey = Boolean(options.allowPlaceholderKey); const result = { exists: fs.existsSync(filePath), file: relative(filePath), portal: '', apiBaseUrl: '', supabaseUrl: '', hasPublishableKey: false, tenantCodeMode: '', }; if (!result.exists) { if (required) collector.fail(`runtime.${expectedPortal}.exists`, 'runtime-config.json is required for production release', { file: result.file }); else collector.warn(`runtime.${expectedPortal}.exists`, 'runtime-config.json is not present; deploy must provide it beside index.html', { file: result.file }); return result; } let config; try { config = readJson(filePath); } catch (error) { collector.fail(`runtime.${expectedPortal}.json`, 'runtime config must be valid JSON', { file: result.file, error: error.message }); return result; } const keys = Object.keys(config); const unknownKeys = keys.filter(key => !allowedRuntimeConfigKeys.has(key)); const forbiddenKeys = keys.filter(key => forbiddenRuntimeConfigKeys.has(key)); if (unknownKeys.length) collector.fail(`runtime.${expectedPortal}.keys`, 'runtime config has unknown keys', { file: result.file, unknownKeys }); else collector.pass(`runtime.${expectedPortal}.keys`, 'runtime config uses only approved public keys', { file: result.file }); if (forbiddenKeys.length) collector.fail(`runtime.${expectedPortal}.secrets`, 'runtime config includes forbidden server secret keys', { file: result.file, forbiddenKeys }); else collector.pass(`runtime.${expectedPortal}.secrets`, 'runtime config contains no forbidden server secret keys', { file: result.file }); const publicConfig = publicRuntimeConfig(config); result.portal = publicConfig.portal; result.apiBaseUrl = publicConfig.apiBaseUrl; result.supabaseUrl = publicConfig.supabaseUrl; result.hasPublishableKey = Boolean(publicConfig.supabasePublishableKey); result.tenantCodeMode = publicConfig.tenantCode ? 'fixed' : 'domain-resolved'; if (publicConfig.portal !== expectedPortal) collector.fail(`runtime.${expectedPortal}.portal`, 'runtime config portal does not match the H5 artifact', { file: result.file, expectedPortal, portal: publicConfig.portal }); else collector.pass(`runtime.${expectedPortal}.portal`, 'runtime config portal matches the H5 artifact', { file: result.file }); if (!String(publicConfig.apiBaseUrl || '').startsWith('https://')) collector.fail(`runtime.${expectedPortal}.api_https`, 'apiBaseUrl must be HTTPS for production H5 release', { file: result.file, apiBaseUrl: publicConfig.apiBaseUrl }); else collector.pass(`runtime.${expectedPortal}.api_https`, 'apiBaseUrl is HTTPS', { file: result.file }); if (!String(publicConfig.supabaseUrl || '').startsWith('https://')) collector.fail(`runtime.${expectedPortal}.supabase_https`, 'supabaseUrl must be HTTPS for production H5 release', { file: result.file, supabaseUrl: publicConfig.supabaseUrl }); else collector.pass(`runtime.${expectedPortal}.supabase_https`, 'supabaseUrl is HTTPS', { file: result.file }); const publishableKey = String(publicConfig.supabasePublishableKey || ''); if (!publishableKey) collector.fail(`runtime.${expectedPortal}.publishable_key`, 'supabasePublishableKey is required', { file: result.file }); else if (publishableKey === 'replace-with-supabase-publishable-key' && allowPlaceholderKey) collector.pass(`runtime.${expectedPortal}.publishable_key`, 'runtime example uses explicit publishable-key placeholder', { file: result.file }); else if (publishableKey === 'replace-with-supabase-publishable-key') collector.fail(`runtime.${expectedPortal}.publishable_key`, 'production runtime config still contains placeholder publishable key', { file: result.file }); else collector.pass(`runtime.${expectedPortal}.publishable_key`, 'supabase publishable key is present', { file: result.file }); const secretValues = Object.entries(config).filter(([, value]) => looksSecretish(value)); if (secretValues.length) { collector.fail(`runtime.${expectedPortal}.secret_values`, 'runtime config contains secret-looking values', { file: result.file, keys: secretValues.map(([key]) => key), }); } else { collector.pass(`runtime.${expectedPortal}.secret_values`, 'runtime config values look public-only', { file: result.file }); } return result; } function validatePackageScript(portal, collector) { const rootPackageJson = readJson(path.join(repoRoot, 'package.json')); const appPackageJson = readJson(path.join(taroRoot, 'package.json')); const rootScript = rootPackageJson.scripts?.[portal.buildScript] || ''; const appScript = appPackageJson.scripts?.[portal.appBuildScript] || ''; if (!rootScript) { collector.fail(`build.${portal.portal}.script`, 'Root H5 portal build script is missing', { script: portal.buildScript }); return ''; } if (!rootScript.includes('@tiku-saas/taro') || !rootScript.includes(portal.appBuildScript)) { collector.fail(`build.${portal.portal}.root_script`, 'Root H5 build script must delegate to the Taro workspace portal script', { script: portal.buildScript, command: rootScript, appBuildScript: portal.appBuildScript, }); } else { collector.pass(`build.${portal.portal}.root_script`, 'Root H5 build script delegates to the Taro workspace portal script', { script: portal.buildScript, command: rootScript, appBuildScript: portal.appBuildScript, }); } if (!appScript) { collector.fail(`build.${portal.portal}.app_script`, 'Taro workspace H5 portal build script is missing', { script: portal.appBuildScript }); } else if (!appScript.includes('TARO_APP_PORTAL') || !appScript.includes(portal.portal)) { collector.fail(`build.${portal.portal}.portal_env`, 'Taro workspace H5 build script must pin TARO_APP_PORTAL', { script: portal.appBuildScript, command: appScript }); } else { collector.pass(`build.${portal.portal}.portal_env`, 'Taro workspace H5 build script pins TARO_APP_PORTAL', { script: portal.appBuildScript, command: appScript }); } return `npm run ${portal.buildScript}`; } function inspectDist(portal, collector, options) { const dir = path.join(distRoot, portal.dist); const indexPath = path.join(dir, 'index.html'); const result = { exists: fs.existsSync(dir), dir: relative(dir), indexHtml: fs.existsSync(indexPath), indexSha256: '', files: 0, totalBytes: 0, assetReferences: 0, }; if (!result.exists) { if (options.requireDist) collector.fail(`dist.${portal.portal}.exists`, 'H5 dist directory is required for production release', { dir: result.dir }); else collector.warn(`dist.${portal.portal}.exists`, 'H5 dist directory not found; run the portal build before deployment', { dir: result.dir }); return result; } collector.pass(`dist.${portal.portal}.exists`, 'H5 dist directory exists', { dir: result.dir }); const files = walkFiles(dir); result.files = files.length; result.totalBytes = files.reduce((sum, filePath) => sum + fs.statSync(filePath).size, 0); if (!result.indexHtml) { if (options.requireDist) collector.fail(`dist.${portal.portal}.index`, 'H5 index.html is missing', { file: relative(indexPath) }); else collector.warn(`dist.${portal.portal}.index`, 'H5 index.html is missing; run the portal build before deployment', { file: relative(indexPath) }); return result; } const indexHtml = readText(indexPath); result.indexSha256 = hashFile(indexPath); result.assetReferences = parseAssetUrls(indexHtml).length; if (!/
{ const buildCommand = validatePackageScript(portal, collector); const runtimeExample = validateRuntimeConfig(path.join(deployRoot, portal.runtimeExample), portal.portal, collector, { required: true, allowPlaceholderKey: true, }); const dist = inspectDist(portal, collector, options); const runtimeConfig = validateRuntimeConfig(path.join(distRoot, portal.dist, 'runtime-config.json'), portal.portal, collector, { required: options.requireRuntimeConfig, allowPlaceholderKey: false, }); return { portal: portal.portal, buildCommand, landingPath: portal.landingPath, deployHint: portal.deployHint, dist, runtimeExample, runtimeConfig, }; }); const summary = summarize(collector.checks, portalResults); return { schemaVersion: 1, generatedAt: new Date().toISOString(), summary, portals: portalResults, checks: collector.checks, }; } function printHuman(manifest) { const { summary } = manifest; console.log(`Taro H5 release manifest: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`); for (const portal of manifest.portals) { console.log(`[${portal.portal}] ${portal.buildCommand}`); console.log(` dist=${portal.dist.dir} files=${portal.dist.files} bytes=${portal.dist.totalBytes} runtime=${portal.runtimeConfig.exists ? 'present' : 'missing'}`); console.log(` landing=${portal.landingPath}`); } for (const item of manifest.checks.filter(check => check.status !== 'pass')) { const marker = item.status === 'fail' ? 'FAIL' : 'WARN'; console.log(`[${marker}] ${item.id}: ${item.message}`); } } function main() { const options = parseArgs(process.argv.slice(2)); const manifest = buildManifest(options); if (options.writePath) { fs.mkdirSync(path.dirname(options.writePath), { recursive: true }); fs.writeFileSync(options.writePath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); } if (options.json) console.log(JSON.stringify(manifest, null, 2)); else printHuman(manifest); if (manifest.summary.fail > 0) process.exitCode = 1; } main();