| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171 |
- import fs from 'node:fs';
- import path from 'node:path';
- import crypto from 'node:crypto';
- import { fileURLToPath } from 'node:url';
- export const RELEASE = 'ksf.workshop-board.20261103.v5';
- export const FILES = Object.freeze([
- 'src/layout/kf-app.js', 'src/layout/kf-nav.js', 'src/layout/sample-shell.js',
- 'src/utils/router.js', 'src/utils/http.js', 'src/services/workshop-data.js',
- 'src/services/sample-data.js', 'src/services/showcase-data.js',
- 'src/core/sample.js', 'src/core/showcase.js',
- 'src/pages/sample-base.js', 'src/pages/sample-detail.js',
- 'src/pages/view-cockpit.js', 'src/pages/view-intel.js', 'src/pages/view-coop.js',
- 'src/pages/view-review.js', 'src/pages/view-class.js',
- 'src/pages/view-showcase.js', 'src/pages/showcase-content.js',
- 'src/assets/showcase/leadership.png', 'src/assets/showcase/research.png',
- 'src/assets/showcase/collaboration.png', 'src/assets/showcase/validation.png',
- 'src/assets/showcase/classroom.png', 'src/assets/showcase/preview-seed.json', 'src/assets/showcase/demo-extension.json',
- 'src/styles/tokens.js', 'src/layout/kf-topbar.js', 'src/pages/view-reports.js', 'src/pages/view-report-type.js',
- 'src/core/kfs/build.js', 'src/core/kfs/employee.js', 'src/core/opportunity/opportunity.js', 'src/pages/view-members.js',
- ]);
- const PRESERVED = ['src/services/board-service.js', 'src/components/kf-opportunity-card.js',
- 'src/pages/view-pool.js', 'src/core/report.js', 'src/core/evidence/chain.js'];
- const sha = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
- const normalized = (bytes) => sha(Buffer.from(bytes.toString('utf8').replaceAll('\r\n', '\n')));
- const fail = (code, file) => { throw Object.assign(new Error(code), { code, file }); };
- const plain = (file) => { const s = fs.lstatSync(file); if (!s.isFile() || s.isSymbolicLink()) fail('NOT_PLAIN_FILE', file); return s; };
- const hashPattern = /^[a-f0-9]{64}$/;
- function safe(root, relative) {
- if (typeof relative !== 'string' || relative.split('/').some((s) => !s || s === '.' || s === '..' || s.includes('\\') || s.includes(':'))) fail('BAD_RELATIVE_PATH');
- let current = root;
- for (const part of relative.split('/')) {
- current = path.join(current, part);
- if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) fail('SYMLINK_PATH', relative);
- }
- return current;
- }
- function unchanged(file, expected) {
- if (expected === null ? fs.existsSync(file) : !fs.existsSync(file) || sha(fs.readFileSync(file)) !== expected) fail('CONCURRENT_CHANGE', file);
- }
- function atomicWrite(file, bytes, mode) {
- const temp = file + '.ksf-' + crypto.randomUUID(); let fd;
- try {
- fd = fs.openSync(temp, 'wx', mode); fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
- fs.renameSync(temp, file);
- } finally { if (fd !== undefined) fs.closeSync(fd); if (fs.existsSync(temp)) fs.unlinkSync(temp); }
- }
- function targetRoot(targetDir) {
- if (!path.isAbsolute(targetDir)) fail('ABSOLUTE_TARGET_REQUIRED');
- const root = path.resolve(targetDir);
- if (root === path.parse(root).root || fs.realpathSync(root) !== root) fail('UNSAFE_TARGET');
- return root;
- }
- function checkUntouched(root, manifest) {
- const homepage = safe(root, 'index.html'); plain(homepage);
- if (sha(fs.readFileSync(homepage)) !== manifest.homepageSha256) fail('HOMEPAGE_MISMATCH');
- for (const def of manifest.preserved) {
- const file = safe(root, def.path); plain(file);
- if (normalized(fs.readFileSync(file)) !== def.normalizedSha256) fail('PUBLISHED_REPAIR_MISMATCH', def.path);
- }
- }
- function readManifest(file) {
- const m = JSON.parse(fs.readFileSync(file, 'utf8'));
- if (m.version !== 'ksf.five-views.publish.v1' || m.release !== RELEASE || !hashPattern.test(m.homepageSha256)) fail('BAD_MANIFEST');
- const pathsEqual = (actual, expected) => JSON.stringify([...actual].sort()) === JSON.stringify([...expected].sort());
- 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');
- 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');
- for (const f of m.preserved) if (!hashPattern.test(f.normalizedSha256)) fail('BAD_MANIFEST');
- return m;
- }
- /** Static filesystem changes only. Does not contact an API or infer a site directory. */
- export function publish({ sourceDir, targetDir, manifestPath, apply = false }, hooks = {}) {
- if (!path.isAbsolute(sourceDir)) fail('ABSOLUTE_SOURCE_REQUIRED');
- const source = fs.realpathSync(sourceDir), root = targetRoot(targetDir);
- if (source === root || source.startsWith(root + path.sep) || root.startsWith(source + path.sep)) fail('SOURCE_TARGET_OVERLAP');
- const manifest = readManifest(manifestPath);
- checkUntouched(root, manifest);
- const entries = manifest.files.map((def) => {
- const input = safe(source, 'payload/' + def.path), target = safe(root, def.path); plain(input);
- const bytes = fs.readFileSync(input);
- if (sha(bytes) !== def.sha256) fail('PAYLOAD_HASH_MISMATCH', def.path);
- const stat = fs.existsSync(target) ? plain(target) : null;
- const before = stat ? fs.readFileSync(target) : null;
- const same = before !== null && sha(before) === def.sha256;
- if (before !== null && !same && !def.priorNormalizedSha256.includes(normalized(before))) fail('UNKNOWN_TARGET_CONTENT', def.path);
- if (!stat && !def.allowMissing) fail('BASELINE_FILE_MISSING', def.path);
- return { path: def.path, target, bytes, before, beforeSha256: before === null ? null : sha(before), mode: stat ? stat.mode & 0o777 : 0o644, same };
- });
- const changes = entries.filter((e) => !e.same);
- const result = { ok: true, release: RELEASE, operation: apply ? 'filesystem-update' : 'read-only-plan', readOnly: !apply,
- filesChecked: entries.length, changes: changes.map((e) => e.path), unchanged: entries.filter((e) => e.same).map((e) => e.path),
- preservedRepairs: PRESERVED, homepageModified: false, networkRequests: 0, businessWrites: 0, browserVerified: false };
- if (!apply || !changes.length) return { ...result, backup: null };
- const lock = path.join(path.dirname(root), '.' + path.basename(root) + '.ksf-static.lock');
- const fd = fs.openSync(lock, 'wx', 0o600); let backup;
- const done = [], createdDirs = [];
- try {
- backup = fs.mkdtempSync(path.join(path.dirname(root), 'ksf-five-views-backup-'));
- const journal = { release: RELEASE, targetDir: root, at: new Date().toISOString(), status: 'prepared', homepageSha256: manifest.homepageSha256,
- preserved: manifest.preserved, files: changes.map((e) => ({ path: e.path, beforeSha256: e.beforeSha256, afterSha256: sha(e.bytes), mode: e.mode })) };
- const saveJournal = () => fs.writeFileSync(path.join(backup, 'journal.json'), JSON.stringify(journal, null, 2), { mode: 0o600 });
- for (const e of changes) if (e.before !== null) {
- const file = safe(backup, e.path); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, e.before, { flag: 'wx', mode: 0o600 });
- }
- saveJournal();
- for (const e of changes) {
- hooks.beforeWrite?.(e.path);
- checkUntouched(root, manifest); safe(root, e.path); unchanged(e.target, e.beforeSha256);
- const absent = []; let parent = path.dirname(e.target);
- while (!fs.existsSync(parent)) { absent.push(parent); parent = path.dirname(parent); }
- for (const dir of absent.reverse()) { fs.mkdirSync(dir); createdDirs.push(dir); }
- atomicWrite(e.target, e.bytes, e.mode); done.push(e);
- }
- for (const e of entries) { plain(e.target); if (sha(fs.readFileSync(e.target)) !== sha(e.bytes)) fail('TARGET_VERIFY_FAILED', e.path); }
- checkUntouched(root, manifest); journal.status = 'verified'; saveJournal();
- return { ...result, backup, filesystemVerified: true, publicVerificationRequired: true };
- } catch (error) {
- let rollbackComplete = true;
- for (const e of done.reverse()) try {
- safe(root, e.path); unchanged(e.target, sha(e.bytes));
- if (e.before === null) fs.unlinkSync(e.target); else atomicWrite(e.target, e.before, e.mode);
- } catch { rollbackComplete = false; }
- for (const dir of createdDirs.reverse()) try { fs.rmdirSync(dir); } catch {}
- 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)); } }
- throw Object.assign(error, { backup, rollbackComplete });
- } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
- }
- /** Explicit rollback checks every target and backup before restoring the first file. */
- export function rollback({ targetDir, backupDir, apply = false }) {
- const root = targetRoot(targetDir);
- if (!path.isAbsolute(backupDir)) fail('ABSOLUTE_BACKUP_REQUIRED');
- const backup = fs.realpathSync(backupDir);
- if (backup === root || backup.startsWith(root + path.sep) || root.startsWith(backup + path.sep)) fail('BACKUP_TARGET_OVERLAP');
- const journalFile = safe(backup, 'journal.json'); plain(journalFile);
- const j = JSON.parse(fs.readFileSync(journalFile, 'utf8'));
- 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');
- checkUntouched(root, j);
- const entries = j.files.map((e) => {
- const target = safe(root, e.path); plain(target); unchanged(target, e.afterSha256);
- let bytes = null;
- 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); }
- return { ...e, target, bytes };
- });
- const result = { ok: true, operation: apply ? 'filesystem-rollback' : 'read-only-rollback-plan', readOnly: !apply, filesChecked: entries.length, businessWrites: 0, networkRequests: 0, homepageModified: false };
- if (!apply) return result;
- const lock = path.join(path.dirname(root), '.' + path.basename(root) + '.ksf-static.lock');
- const fd = fs.openSync(lock, 'wx', 0o600);
- try {
- 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); }
- for (const e of entries) unchanged(e.target, e.beforeSha256);
- j.status = 'rolled-back'; fs.writeFileSync(journalFile, JSON.stringify(j, null, 2));
- return { ...result, filesystemVerified: true };
- } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
- }
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
- try {
- const args = process.argv.slice(2); let targetDir, backupDir, apply = false;
- for (let i = 0; i < args.length; i++) {
- if (args[i] === '--apply' && !apply) apply = true;
- else if (args[i] === '--target-dir' && !targetDir) targetDir = args[++i];
- else if (args[i] === '--rollback' && !backupDir) backupDir = args[++i];
- else fail('USAGE');
- }
- if (!targetDir) fail('USAGE');
- const sourceDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
- 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));
- } 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; }
- }
|