#!/usr/bin/env node // Prepare and verify static releases without reading or uploading server credentials. import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; const repo=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); const sha=data=>createHash('sha256').update(data).digest('hex'); const run=(cmd,args,options={})=>execFileSync(cmd,args,{cwd:repo,stdio:'inherit',...options}); export async function manifestFor(directory,releaseId,commit){ const files=[]; async function walk(relative=''){ for(const entry of await fs.readdir(path.join(directory,relative),{withFileTypes:true})){ const name=path.posix.join(relative,entry.name); if(entry.isSymbolicLink()||/[\r\n]/.test(name)||/(^|\/)\.(env|git)|\.(pem|key)$/i.test(name))throw Error('发布包包含不允许的文件: '+name); if(entry.isDirectory())await walk(name); else if(entry.isFile()&&name!=='deployment-version.json')files.push({path:name,sha256:sha(await fs.readFile(path.join(directory,name)))}); } } await walk(); if(!files.some(f=>f.path==='index.html')||!files.some(f=>/^main-.*\.js$/.test(f.path)))throw Error('缺少管理端生产入口或主脚本'); return{application:'xiaoshu-admin',releaseId,commit,builtAt:new Date().toISOString(),files:files.sort((a,b)=>a.path.localeCompare(b.path))}; } async function response(url){const result=await fetch(url,{signal:AbortSignal.timeout(30000),headers:{'Cache-Control':'no-cache'}});if(!result.ok)throw Error('HTTP '+result.status+' '+url);return result;} export async function verifyRelease(base,manifest){ const url=new URL(base);if(url.protocol!=='https:'&&!['localhost','127.0.0.1'].includes(url.hostname))throw Error('公网发布地址必须使用 HTTPS'); const live=await(await response(new URL('/deployment-version.json?release='+manifest.releaseId,url))).json(); if(live.releaseId!==manifest.releaseId||live.commit!==manifest.commit)throw Error('公网版本与本次发布不一致'); for(let i=0;i{ const remote=new URL('/'+file.path,url);remote.searchParams.set('release',manifest.releaseId); if(sha(Buffer.from(await(await response(remote)).arrayBuffer()))!==file.sha256)throw Error('公网文件校验失败: '+file.path); })); const expected=manifest.files.find(f=>f.path==='index.html').sha256; for(const route of ['/admin/login','/admin/payroll']){ const page=await response(new URL(route+'?release='+manifest.releaseId,url)); if(!page.headers.get('content-type')?.includes('text/html')||sha(Buffer.from(await page.arrayBuffer()))!==expected)throw Error('Angular 深层路由未返回新入口: '+route); } return{releaseId:manifest.releaseId,commit:manifest.commit,filesVerified:manifest.files.length,url:url.origin}; } async function main(){ const mode=process.argv[2]||'--prepare'; if(!['--prepare','--verify'].includes(mode))throw Error('使用 --prepare 或 --verify;上传前需另行配置现有站点的部署入口'); const base=process.env.XIAOSHU_ADMIN_URL||'https://admin.xiaoshu.pro'; const output=path.join(repo,'dist/admin-release'),bundle=path.join(output,'site'); if(mode==='--verify'){const manifest=JSON.parse(await fs.readFile(path.join(bundle,'deployment-version.json'),'utf8'));console.log(JSON.stringify(await verifyRelease(base,manifest)));return;} const commit=run('git',['rev-parse','HEAD'],{encoding:'utf8',stdio:'pipe'}).trim(); if(run('git',['status','--porcelain','--untracked-files=no'],{encoding:'utf8',stdio:'pipe'}).trim())throw Error('请先提交已跟踪文件的修改,再构建可追踪的发布版本'); run('npm',['run','build:admin']); await fs.rm(output,{recursive:true,force:true});await fs.mkdir(output,{recursive:true}); await fs.cp(path.join(repo,'dist/xiaoshu-admin/browser'),bundle,{recursive:true}); const releaseId=commit.slice(0,12)+'-'+new Date().toISOString().replace(/[-:.]/g,''); const manifest=await manifestFor(bundle,releaseId,commit); await fs.writeFile(path.join(bundle,'deployment-version.json'),JSON.stringify(manifest,null,2)); const archive=path.join(output,'xiaoshu-admin-'+releaseId+'.tar.gz'); run('tar',['-czf',archive,'-C',bundle,'.']); await fs.writeFile(path.join(output,'SHA256SUMS'),sha(await fs.readFile(archive))+' '+path.basename(archive)+'\n'); console.log(JSON.stringify({prepared:true,releaseId,archive,files:manifest.files.length})); } if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url))main().catch(error=>{console.error(error.message);process.exitCode=1;});