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; } }