publish-five-views.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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. export const RELEASE = 'ksf.workshop-board.20261103.v5';
  6. export const FILES = Object.freeze([
  7. 'src/layout/kf-app.js', 'src/layout/kf-nav.js', 'src/layout/sample-shell.js',
  8. 'src/utils/router.js', 'src/utils/http.js', 'src/services/workshop-data.js',
  9. 'src/services/sample-data.js', 'src/services/showcase-data.js',
  10. 'src/core/sample.js', 'src/core/showcase.js',
  11. 'src/pages/sample-base.js', 'src/pages/sample-detail.js',
  12. 'src/pages/view-cockpit.js', 'src/pages/view-intel.js', 'src/pages/view-coop.js',
  13. 'src/pages/view-review.js', 'src/pages/view-class.js',
  14. 'src/pages/view-showcase.js', 'src/pages/showcase-content.js',
  15. 'src/assets/showcase/leadership.png', 'src/assets/showcase/research.png',
  16. 'src/assets/showcase/collaboration.png', 'src/assets/showcase/validation.png',
  17. 'src/assets/showcase/classroom.png', 'src/assets/showcase/preview-seed.json', 'src/assets/showcase/demo-extension.json',
  18. 'src/styles/tokens.js', 'src/layout/kf-topbar.js', 'src/pages/view-reports.js', 'src/pages/view-report-type.js',
  19. 'src/core/kfs/build.js', 'src/core/kfs/employee.js', 'src/core/opportunity/opportunity.js', 'src/pages/view-members.js',
  20. ]);
  21. const PRESERVED = ['src/services/board-service.js', 'src/components/kf-opportunity-card.js',
  22. 'src/pages/view-pool.js', 'src/core/report.js', 'src/core/evidence/chain.js'];
  23. const sha = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
  24. const normalized = (bytes) => sha(Buffer.from(bytes.toString('utf8').replaceAll('\r\n', '\n')));
  25. const fail = (code, file) => { throw Object.assign(new Error(code), { code, file }); };
  26. const plain = (file) => { const s = fs.lstatSync(file); if (!s.isFile() || s.isSymbolicLink()) fail('NOT_PLAIN_FILE', file); return s; };
  27. const hashPattern = /^[a-f0-9]{64}$/;
  28. function safe(root, relative) {
  29. if (typeof relative !== 'string' || relative.split('/').some((s) => !s || s === '.' || s === '..' || s.includes('\\') || s.includes(':'))) fail('BAD_RELATIVE_PATH');
  30. let current = root;
  31. for (const part of relative.split('/')) {
  32. current = path.join(current, part);
  33. if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) fail('SYMLINK_PATH', relative);
  34. }
  35. return current;
  36. }
  37. function unchanged(file, expected) {
  38. if (expected === null ? fs.existsSync(file) : !fs.existsSync(file) || sha(fs.readFileSync(file)) !== expected) fail('CONCURRENT_CHANGE', file);
  39. }
  40. function atomicWrite(file, bytes, mode) {
  41. const temp = file + '.ksf-' + crypto.randomUUID(); let fd;
  42. try {
  43. fd = fs.openSync(temp, 'wx', mode); fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
  44. fs.renameSync(temp, file);
  45. } finally { if (fd !== undefined) fs.closeSync(fd); if (fs.existsSync(temp)) fs.unlinkSync(temp); }
  46. }
  47. function targetRoot(targetDir) {
  48. if (!path.isAbsolute(targetDir)) fail('ABSOLUTE_TARGET_REQUIRED');
  49. const root = path.resolve(targetDir);
  50. if (root === path.parse(root).root || fs.realpathSync(root) !== root) fail('UNSAFE_TARGET');
  51. return root;
  52. }
  53. function checkUntouched(root, manifest) {
  54. const homepage = safe(root, 'index.html'); plain(homepage);
  55. if (sha(fs.readFileSync(homepage)) !== manifest.homepageSha256) fail('HOMEPAGE_MISMATCH');
  56. for (const def of manifest.preserved) {
  57. const file = safe(root, def.path); plain(file);
  58. if (normalized(fs.readFileSync(file)) !== def.normalizedSha256) fail('PUBLISHED_REPAIR_MISMATCH', def.path);
  59. }
  60. }
  61. function readManifest(file) {
  62. const m = JSON.parse(fs.readFileSync(file, 'utf8'));
  63. if (m.version !== 'ksf.five-views.publish.v1' || m.release !== RELEASE || !hashPattern.test(m.homepageSha256)) fail('BAD_MANIFEST');
  64. const pathsEqual = (actual, expected) => JSON.stringify([...actual].sort()) === JSON.stringify([...expected].sort());
  65. if (!Array.isArray(m.files) || !pathsEqual(m.files.map((f) => f.path), FILES) || !Array.isArray(m.preserved) || !pathsEqual(m.preserved.map((f) => f.path), PRESERVED)) fail('UNEXPECTED_FILE_LIST');
  66. for (const f of m.files) if (!hashPattern.test(f.sha256) || !Array.isArray(f.priorNormalizedSha256) || f.priorNormalizedSha256.some((h) => !hashPattern.test(h)) || typeof f.allowMissing !== 'boolean') fail('BAD_MANIFEST');
  67. for (const f of m.preserved) if (!hashPattern.test(f.normalizedSha256)) fail('BAD_MANIFEST');
  68. return m;
  69. }
  70. /** Static filesystem changes only. Does not contact an API or infer a site directory. */
  71. export function publish({ sourceDir, targetDir, manifestPath, apply = false }, hooks = {}) {
  72. if (!path.isAbsolute(sourceDir)) fail('ABSOLUTE_SOURCE_REQUIRED');
  73. const source = fs.realpathSync(sourceDir), root = targetRoot(targetDir);
  74. if (source === root || source.startsWith(root + path.sep) || root.startsWith(source + path.sep)) fail('SOURCE_TARGET_OVERLAP');
  75. const manifest = readManifest(manifestPath);
  76. checkUntouched(root, manifest);
  77. const entries = manifest.files.map((def) => {
  78. const input = safe(source, 'payload/' + def.path), target = safe(root, def.path); plain(input);
  79. const bytes = fs.readFileSync(input);
  80. if (sha(bytes) !== def.sha256) fail('PAYLOAD_HASH_MISMATCH', def.path);
  81. const stat = fs.existsSync(target) ? plain(target) : null;
  82. const before = stat ? fs.readFileSync(target) : null;
  83. const same = before !== null && sha(before) === def.sha256;
  84. if (before !== null && !same && !def.priorNormalizedSha256.includes(normalized(before))) fail('UNKNOWN_TARGET_CONTENT', def.path);
  85. if (!stat && !def.allowMissing) fail('BASELINE_FILE_MISSING', def.path);
  86. return { path: def.path, target, bytes, before, beforeSha256: before === null ? null : sha(before), mode: stat ? stat.mode & 0o777 : 0o644, same };
  87. });
  88. const changes = entries.filter((e) => !e.same);
  89. const result = { ok: true, release: RELEASE, operation: apply ? 'filesystem-update' : 'read-only-plan', readOnly: !apply,
  90. filesChecked: entries.length, changes: changes.map((e) => e.path), unchanged: entries.filter((e) => e.same).map((e) => e.path),
  91. preservedRepairs: PRESERVED, homepageModified: false, networkRequests: 0, businessWrites: 0, browserVerified: false };
  92. if (!apply || !changes.length) return { ...result, backup: null };
  93. const lock = path.join(path.dirname(root), '.' + path.basename(root) + '.ksf-static.lock');
  94. const fd = fs.openSync(lock, 'wx', 0o600); let backup;
  95. const done = [], createdDirs = [];
  96. try {
  97. backup = fs.mkdtempSync(path.join(path.dirname(root), 'ksf-five-views-backup-'));
  98. const journal = { release: RELEASE, targetDir: root, at: new Date().toISOString(), status: 'prepared', homepageSha256: manifest.homepageSha256,
  99. preserved: manifest.preserved, files: changes.map((e) => ({ path: e.path, beforeSha256: e.beforeSha256, afterSha256: sha(e.bytes), mode: e.mode })) };
  100. const saveJournal = () => fs.writeFileSync(path.join(backup, 'journal.json'), JSON.stringify(journal, null, 2), { mode: 0o600 });
  101. for (const e of changes) if (e.before !== null) {
  102. const file = safe(backup, e.path); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, e.before, { flag: 'wx', mode: 0o600 });
  103. }
  104. saveJournal();
  105. for (const e of changes) {
  106. hooks.beforeWrite?.(e.path);
  107. checkUntouched(root, manifest); safe(root, e.path); unchanged(e.target, e.beforeSha256);
  108. const absent = []; let parent = path.dirname(e.target);
  109. while (!fs.existsSync(parent)) { absent.push(parent); parent = path.dirname(parent); }
  110. for (const dir of absent.reverse()) { fs.mkdirSync(dir); createdDirs.push(dir); }
  111. atomicWrite(e.target, e.bytes, e.mode); done.push(e);
  112. }
  113. for (const e of entries) { plain(e.target); if (sha(fs.readFileSync(e.target)) !== sha(e.bytes)) fail('TARGET_VERIFY_FAILED', e.path); }
  114. checkUntouched(root, manifest); journal.status = 'verified'; saveJournal();
  115. return { ...result, backup, filesystemVerified: true, publicVerificationRequired: true };
  116. } catch (error) {
  117. let rollbackComplete = true;
  118. for (const e of done.reverse()) try {
  119. safe(root, e.path); unchanged(e.target, sha(e.bytes));
  120. if (e.before === null) fs.unlinkSync(e.target); else atomicWrite(e.target, e.before, e.mode);
  121. } catch { rollbackComplete = false; }
  122. for (const dir of createdDirs.reverse()) try { fs.rmdirSync(dir); } catch {}
  123. if (backup) { const file = path.join(backup, 'journal.json'); if (fs.existsSync(file)) { const journal = JSON.parse(fs.readFileSync(file)); fs.writeFileSync(file, JSON.stringify({ ...journal, status: 'stopped', rollbackComplete }, null, 2)); } }
  124. throw Object.assign(error, { backup, rollbackComplete });
  125. } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
  126. }
  127. /** Explicit rollback checks every target and backup before restoring the first file. */
  128. export function rollback({ targetDir, backupDir, apply = false }) {
  129. const root = targetRoot(targetDir);
  130. if (!path.isAbsolute(backupDir)) fail('ABSOLUTE_BACKUP_REQUIRED');
  131. const backup = fs.realpathSync(backupDir);
  132. if (backup === root || backup.startsWith(root + path.sep) || root.startsWith(backup + path.sep)) fail('BACKUP_TARGET_OVERLAP');
  133. const journalFile = safe(backup, 'journal.json'); plain(journalFile);
  134. const j = JSON.parse(fs.readFileSync(journalFile, 'utf8'));
  135. if (j.targetDir !== root || j.release !== RELEASE || j.status !== 'verified' || !Array.isArray(j.files) || j.files.some((e) => !FILES.includes(e.path))) fail('BAD_BACKUP_JOURNAL');
  136. checkUntouched(root, j);
  137. const entries = j.files.map((e) => {
  138. const target = safe(root, e.path); plain(target); unchanged(target, e.afterSha256);
  139. let bytes = null;
  140. if (e.beforeSha256 !== null) { const file = safe(backup, e.path); plain(file); bytes = fs.readFileSync(file); if (sha(bytes) !== e.beforeSha256) fail('BACKUP_HASH_MISMATCH', e.path); }
  141. return { ...e, target, bytes };
  142. });
  143. const result = { ok: true, operation: apply ? 'filesystem-rollback' : 'read-only-rollback-plan', readOnly: !apply, filesChecked: entries.length, businessWrites: 0, networkRequests: 0, homepageModified: false };
  144. if (!apply) return result;
  145. const lock = path.join(path.dirname(root), '.' + path.basename(root) + '.ksf-static.lock');
  146. const fd = fs.openSync(lock, 'wx', 0o600);
  147. try {
  148. for (const e of entries.reverse()) { checkUntouched(root, j); safe(root, e.path); unchanged(e.target, e.afterSha256); if (e.bytes === null) fs.unlinkSync(e.target); else atomicWrite(e.target, e.bytes, e.mode); }
  149. for (const e of entries) unchanged(e.target, e.beforeSha256);
  150. j.status = 'rolled-back'; fs.writeFileSync(journalFile, JSON.stringify(j, null, 2));
  151. return { ...result, filesystemVerified: true };
  152. } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
  153. }
  154. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  155. try {
  156. const args = process.argv.slice(2); let targetDir, backupDir, apply = false;
  157. for (let i = 0; i < args.length; i++) {
  158. if (args[i] === '--apply' && !apply) apply = true;
  159. else if (args[i] === '--target-dir' && !targetDir) targetDir = args[++i];
  160. else if (args[i] === '--rollback' && !backupDir) backupDir = args[++i];
  161. else fail('USAGE');
  162. }
  163. if (!targetDir) fail('USAGE');
  164. const sourceDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  165. console.log(JSON.stringify(backupDir ? rollback({ targetDir, backupDir, apply }) : publish({ sourceDir, targetDir, manifestPath: path.join(sourceDir, 'deploy/five-views-files-20261103.json'), apply }), null, 2));
  166. } catch (error) { console.error(JSON.stringify({ ok: false, code: error.code || 'PUBLISH_STOPPED', file: error.file, backup: error.backup, rollbackComplete: error.rollbackComplete, message: '已停止。保留回执,核对目录或版本差异,不放宽校验。' }, null, 2)); process.exitCode = 1; }
  167. }