deploy-board-member-roster.mjs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {fileURLToPath} from 'node:url';
  5. const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  6. const sha=s=>crypto.createHash('sha256').update(s).digest('hex');
  7. const normalized=s=>s.replaceAll('\r\n','\n');
  8. const source=s=>normalized(s).replace(/^import .*;\n/gm,'').replace(/^export /gm,'');
  9. export function patchMemberRoster(code){
  10. const priorCore=fs.readFileSync(path.join(root,'deploy/board-local-20261111/member-core-before.js'),'utf8');
  11. const priorReader=fs.readFileSync(path.join(root,'deploy/board-local-20261111/member-reader-before.mjs'),'utf8');
  12. const nextCore=source(fs.readFileSync(path.join(root,'src/core/workshop-roster.js'),'utf8'))+'\n'+source(fs.readFileSync(path.join(root,'src/core/member-groups.js'),'utf8'));
  13. const nextReader=source(fs.readFileSync(path.join(root,'server/src/member-groups.mjs'),'utf8'));
  14. let next=normalized(code);
  15. for(const [before,after]of [[source(priorCore),nextCore],[source(priorReader),nextReader]]){
  16. if(next.split(before).length!==2)throw Error('MEMBER_PATCH_BASELINE_MISMATCH');next=next.replace(before,after);
  17. }
  18. new Function(next);return next;
  19. }
  20. export async function deployMemberRoster({request,manifest,apply=false,outDir}){
  21. const plans=[];
  22. for(const def of manifest.functions){
  23. const row=await request('GET','/classes/Function/'+def.objectId);
  24. if(row.name!==def.name||row.path!==def.path||![def.beforeSha256,def.afterSha256].includes(sha(row.code)))throw Error('UNKNOWN_MEMBER_FUNCTION_BASELINE');
  25. const same=sha(row.code)===def.afterSha256,code=same?row.code:patchMemberRoster(row.code);
  26. if(sha(code)!==def.afterSha256)throw Error('MEMBER_PATCH_HASH_MISMATCH');plans.push({def,row,code,same});
  27. }
  28. const receipt={ok:true,readOnly:!apply,functions:plans.map(p=>({name:p.def.name,action:p.same?'unchanged':'update',beforeSha256:sha(p.row.code),afterSha256:sha(p.code)})),businessWrites:0,schemaChanges:0,permissionsChanged:false};
  29. if(!apply||plans.every(p=>p.same))return receipt;
  30. fs.mkdirSync(outDir,{recursive:true,mode:0o700});
  31. receipt.backup=path.join(outDir,'member-functions-before-'+crypto.randomUUID()+'.private.json');
  32. fs.writeFileSync(receipt.backup,JSON.stringify(plans.map(p=>p.row)),{flag:'wx',mode:0o600});
  33. const attempted=[];
  34. try{
  35. for(const p of plans){if(p.same)continue;const route='/classes/Function/'+p.def.objectId;
  36. if(sha((await request('GET',route)).code)!==p.def.beforeSha256)throw Error('CONCURRENT_MEMBER_FUNCTION_CHANGE');
  37. attempted.push(p);await request('PUT',route,{code:p.code});
  38. const read=await request('GET',route);if(read.code!==p.code||read.name!==p.row.name||read.path!==p.row.path||JSON.stringify(read.ACL)!==JSON.stringify(p.row.ACL))throw Error('MEMBER_FUNCTION_READBACK_MISMATCH');
  39. }
  40. return{...receipt,readbackVerified:true};
  41. }catch(error){
  42. let rollbackComplete=true;for(const p of attempted.reverse())try{
  43. const route='/classes/Function/'+p.def.objectId,row=await request('GET',route);
  44. if(row.code===p.row.code)continue;if(row.code!==p.code){rollbackComplete=false;continue;}
  45. await request('PUT',route,{code:p.row.code});if((await request('GET',route)).code!==p.row.code)rollbackComplete=false;
  46. }catch{rollbackComplete=false;}
  47. throw Object.assign(Error('MEMBER_DEPLOYMENT_STOPPED'),{receipt:{...receipt,ok:false,rollbackComplete}});
  48. }
  49. }
  50. if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
  51. try{
  52. const action=process.argv[2];if(!['plan','apply'].includes(action)||process.argv.length!==3)throw Error('USAGE_PLAN_OR_APPLY');
  53. let request;
  54. if(process.env.KSF_DEPLOY_AUTH_FILE){
  55. const cfg=JSON.parse(fs.readFileSync(process.env.KSF_DEPLOY_AUTH_FILE,'utf8'));
  56. if(cfg.appId!=='FKieRbIoGt_main'||!cfg.masterKey)throw Error('INVALID_PRIVATE_APPLICATION_CONFIG');
  57. const base=cfg.parseUrl||'https://kangshifu.fmode.cn/parse';if(base!=='https://kangshifu.fmode.cn/parse')throw Error('INVALID_PARSE_TARGET');
  58. request=async(method,route,body)=>{const r=await fetch(base+route,{method,headers:{'content-type':'application/json','X-Parse-Application-Id':cfg.appId,'X-Parse-Master-Key':cfg.masterKey},body:body===undefined?undefined:JSON.stringify(body),signal:AbortSignal.timeout(45000),redirect:'error'});if(!r.ok)throw Error('PARSE_DEPLOY_REQUEST_FAILED');return r.json();};
  59. }else({request}=await import('../server/scripts/platform-client.mjs'));
  60. const manifest=JSON.parse(fs.readFileSync(path.join(root,'deploy/board-local-20261111/member-functions.json')));
  61. console.log(JSON.stringify(await deployMemberRoster({request,manifest,apply:action==='apply',outDir:path.join(root,'work/board-local-deploy-private')}),null,2));
  62. }catch(e){console.error(JSON.stringify(e.receipt||{ok:false,error:e.message.match(/^[A-Z_]+$/)?e.message:'MEMBER_DEPLOYMENT_STOPPED'}));process.exitCode=1;}
  63. }