publish-employee-name.mjs 4.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  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 FILE = 'src/core/kfs/employee.js', RELEASE = 'ksf.employee-name.20261103.v1';
  6. const PRIOR = 'd2b312ac7ae6c749279a9c04c9e87e697b86c269e503ab3009c6e1b69c852eb1';
  7. const sha = b => crypto.createHash('sha256').update(b).digest('hex');
  8. const fail = code => { throw Object.assign(Error(code), { code }); };
  9. function plain(file) { const s = fs.lstatSync(file); if (s.isSymbolicLink() || !s.isFile()) fail('NOT_PLAIN_FILE'); return s; }
  10. function site(targetDir) {
  11. if (!path.isAbsolute(targetDir)) fail('ABSOLUTE_TARGET_REQUIRED');
  12. const root = path.resolve(targetDir);
  13. if (fs.realpathSync(root) !== root || root === path.parse(root).root) fail('UNSAFE_TARGET');
  14. for (let p = path.join(root, FILE); p !== path.dirname(p); p = path.dirname(p)) if (fs.lstatSync(p).isSymbolicLink()) fail('UNSAFE_TARGET');
  15. plain(path.join(root, FILE)); plain(path.join(root, 'index.html'));
  16. return root;
  17. }
  18. function replace(file, bytes, mode) {
  19. const temp = file + '.name-' + crypto.randomUUID();
  20. try { fs.writeFileSync(temp, bytes, { flag: 'wx', mode }); fs.renameSync(temp, file); }
  21. finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
  22. }
  23. export function publishEmployeeName({ sourceDir, targetDir, apply = false, backupFile = null }) {
  24. const root = site(targetDir), target = path.join(root, FILE), homepage = sha(fs.readFileSync(path.join(root, 'index.html')));
  25. const before = fs.readFileSync(target), current = sha(before), mode = fs.statSync(target).mode & 0o777;
  26. let bytes, journal;
  27. if (backupFile) {
  28. if (!path.isAbsolute(backupFile) || path.resolve(backupFile).startsWith(root + path.sep)) fail('UNSAFE_BACKUP');
  29. plain(backupFile); journal = JSON.parse(fs.readFileSync(backupFile));
  30. if (journal.release !== RELEASE || journal.root !== root || journal.homepage !== homepage || journal.prior !== PRIOR ||
  31. current !== journal.after || sha(Buffer.from(journal.before, 'base64')) !== PRIOR) fail('ROLLBACK_VERSION_CONFLICT');
  32. bytes = Buffer.from(journal.before, 'base64');
  33. } else {
  34. if (!path.isAbsolute(sourceDir) || path.resolve(sourceDir) === root || path.resolve(sourceDir).startsWith(root + path.sep) || root.startsWith(path.resolve(sourceDir) + path.sep)) fail('UNSAFE_SOURCE');
  35. const input = path.join(sourceDir, 'payload', FILE); plain(input); bytes = fs.readFileSync(input);
  36. const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'deploy/employee-name-static.json')));
  37. if (manifest.release !== RELEASE || manifest.path !== FILE || manifest.prior !== PRIOR || manifest.sha256 !== sha(bytes)) fail('PAYLOAD_HASH_MISMATCH');
  38. if (![PRIOR, sha(bytes)].includes(current)) fail('UNKNOWN_TARGET_CONTENT');
  39. }
  40. const changed = current !== sha(bytes), result = { ok: true, readOnly: !apply, release: RELEASE, filesChecked: 1,
  41. changes: changed ? [FILE] : [], homepageModified: false, networkRequests: 0, businessWrites: 0, browserVerified: false };
  42. if (!apply || !changed) return result;
  43. const lock = path.join(path.dirname(root), '.' + path.basename(root) + '.ksf-static.lock');
  44. const fd = fs.openSync(lock, 'wx', 0o600); let backup = backupFile;
  45. try {
  46. if (sha(fs.readFileSync(target)) !== current || sha(fs.readFileSync(path.join(root, 'index.html'))) !== homepage) fail('CONCURRENT_CHANGE');
  47. if (!backupFile) {
  48. backup = path.join(path.dirname(root), 'ksf-employee-name-backup-' + crypto.randomUUID() + '.json');
  49. fs.writeFileSync(backup, JSON.stringify({ release: RELEASE, root, homepage, prior: current, after: sha(bytes), before: before.toString('base64') }), { flag: 'wx', mode: 0o600 });
  50. }
  51. replace(target, bytes, mode);
  52. if (sha(fs.readFileSync(target)) !== sha(bytes)) fail('READBACK_MISMATCH');
  53. return { ...result, backup, filesystemVerified: true };
  54. } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
  55. }
  56. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  57. try {
  58. const args = process.argv.slice(2), options = { sourceDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') };
  59. const seen = new Set();
  60. for (let i = 0; i < args.length; i++) {
  61. const flag = args[i]; if (seen.has(flag)) fail('USAGE'); seen.add(flag);
  62. if (flag === '--apply') options.apply = true;
  63. else if (['--target-dir', '--rollback'].includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) options[flag === '--target-dir' ? 'targetDir' : 'backupFile'] = args[++i];
  64. else fail('USAGE');
  65. }
  66. if (!options.targetDir) fail('USAGE');
  67. console.log(JSON.stringify(publishEmployeeName(options), null, 2));
  68. } catch (e) { console.error(JSON.stringify({ ok: false, code: e.code || 'PUBLISH_STOPPED' })); process.exitCode = 1; }
  69. }