optbrew 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.claude-plugin/marketplace.json +6 -0
  2. package/.claude-plugin/plugin.json +6 -0
  3. package/.codex-plugin/plugin.json +13 -0
  4. package/CHANGELOG.md +20 -0
  5. package/LICENSE +21 -0
  6. package/README.ko.md +73 -0
  7. package/README.md +72 -2
  8. package/THIRD_PARTY_NOTICES.md +10 -0
  9. package/bin/optbrew.mjs +4 -0
  10. package/docs/platforms.md +31 -0
  11. package/docs/release.md +14 -0
  12. package/lib/cli.mjs +103 -0
  13. package/lib/doctor.mjs +44 -0
  14. package/lib/install.mjs +158 -0
  15. package/lib/paths.mjs +30 -0
  16. package/lib/records.mjs +44 -0
  17. package/lib/transaction.mjs +205 -0
  18. package/lib/update.mjs +181 -0
  19. package/package.json +39 -4
  20. package/plugin.json +13 -0
  21. package/skills/optbrew/LICENSE +21 -0
  22. package/skills/optbrew/SKILL.md +17 -0
  23. package/skills/optbrew/THIRD_PARTY_NOTICES.md +10 -0
  24. package/skills/optbrew/agents/openai.yaml +4 -0
  25. package/skills/optbrew/bundle-manifest.json +663 -0
  26. package/skills/optbrew/licenses/firecrawl-seo-audit.txt +15 -0
  27. package/skills/optbrew/licenses/magnus-seo.txt +21 -0
  28. package/skills/optbrew/licenses/marketing-search.txt +21 -0
  29. package/skills/optbrew/licenses/mblode-seo.txt +21 -0
  30. package/skills/optbrew/licenses/naver-searchadvisor.txt +29 -0
  31. package/skills/optbrew/licenses/seranking-seo.txt +21 -0
  32. package/skills/optbrew/modules/firecrawl-seo-audit/GUIDE.md +64 -0
  33. package/skills/optbrew/modules/magnus-seo/GUIDE.md +79 -0
  34. package/skills/optbrew/modules/magnus-seo/README.md +45 -0
  35. package/skills/optbrew/modules/magnus-seo/assets/audit-report-template.md +76 -0
  36. package/skills/optbrew/modules/magnus-seo/references/aeo-methodology.md +105 -0
  37. package/skills/optbrew/modules/magnus-seo/references/agent-readable-content.md +28 -0
  38. package/skills/optbrew/modules/magnus-seo/references/content-and-entity-architecture.md +100 -0
  39. package/skills/optbrew/modules/magnus-seo/references/content-strategy-seo.md +142 -0
  40. package/skills/optbrew/modules/magnus-seo/references/discovery-and-freshness.md +47 -0
  41. package/skills/optbrew/modules/magnus-seo/references/evidence-boundaries.md +75 -0
  42. package/skills/optbrew/modules/magnus-seo/references/ghost-metadata.md +204 -0
  43. package/skills/optbrew/modules/magnus-seo/references/implementation-playbook.md +114 -0
  44. package/skills/optbrew/modules/magnus-seo/references/measurement-and-experimentation.md +47 -0
  45. package/skills/optbrew/modules/magnus-seo/references/onpage-seo.md +154 -0
  46. package/skills/optbrew/modules/magnus-seo/references/platform-guidance.md +30 -0
  47. package/skills/optbrew/modules/magnus-seo/references/schema-markup.md +244 -0
  48. package/skills/optbrew/modules/magnus-seo/references/source-index.md +36 -0
  49. package/skills/optbrew/modules/magnus-seo/references/structured-data.md +56 -0
  50. package/skills/optbrew/modules/magnus-seo/references/technical-seo.md +99 -0
  51. package/skills/optbrew/modules/magnus-seo/scripts/aeo_audit.py +158 -0
  52. package/skills/optbrew/modules/magnus-seo/scripts/build_prompt_matrix.py +52 -0
  53. package/skills/optbrew/modules/magnus-seo/templates/aeo-implementation-plan.md +53 -0
  54. package/skills/optbrew/modules/magnus-seo/templates/citation-observation-log.md +16 -0
  55. package/skills/optbrew/modules/magnus-seo/templates/llms.txt.template +15 -0
  56. package/skills/optbrew/modules/magnus-seo/templates/question-cluster.md +27 -0
  57. package/skills/optbrew/modules/magnus-seo/templates/robots-ai-crawlers.txt +23 -0
  58. package/skills/optbrew/modules/marketing-search/skills/ai-seo/GUIDE.md +483 -0
  59. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/agent-readiness.md +57 -0
  60. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/citations-vs-recommendations.md +85 -0
  61. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-patterns.md +287 -0
  62. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-types.md +71 -0
  63. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/format-volatility.md +98 -0
  64. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/okf.md +104 -0
  65. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/platform-ranking-factors.md +154 -0
  66. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/youtube-ai-citations.md +60 -0
  67. package/skills/optbrew/modules/marketing-search/skills/analytics/GUIDE.md +303 -0
  68. package/skills/optbrew/modules/marketing-search/skills/analytics/references/event-library.md +260 -0
  69. package/skills/optbrew/modules/marketing-search/skills/analytics/references/ga4-implementation.md +300 -0
  70. package/skills/optbrew/modules/marketing-search/skills/analytics/references/gtm-implementation.md +390 -0
  71. package/skills/optbrew/modules/marketing-search/skills/content-strategy/GUIDE.md +432 -0
  72. package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/content-distribution.md +83 -0
  73. package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/headless-cms.md +194 -0
  74. package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/GUIDE.md +231 -0
  75. package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/references/playbooks.md +308 -0
  76. package/skills/optbrew/modules/marketing-search/skills/schema/GUIDE.md +172 -0
  77. package/skills/optbrew/modules/marketing-search/skills/schema/references/schema-examples.md +398 -0
  78. package/skills/optbrew/modules/marketing-search/skills/seo-audit/GUIDE.md +492 -0
  79. package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/ai-writing-detection.md +200 -0
  80. package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/international-seo.md +230 -0
  81. package/skills/optbrew/modules/marketing-search/skills/site-architecture/GUIDE.md +350 -0
  82. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/mermaid-templates.md +216 -0
  83. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/navigation-patterns.md +305 -0
  84. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/site-type-templates.md +293 -0
  85. package/skills/optbrew/modules/mblode-seo/GUIDE.md +74 -0
  86. package/skills/optbrew/modules/mblode-seo/references/answer-engines.md +58 -0
  87. package/skills/optbrew/modules/mblode-seo/references/audit.md +78 -0
  88. package/skills/optbrew/modules/mblode-seo/references/brief-template.md +47 -0
  89. package/skills/optbrew/modules/mblode-seo/references/monitoring.md +56 -0
  90. package/skills/optbrew/modules/mblode-seo/references/nextjs-implementation.md +65 -0
  91. package/skills/optbrew/modules/mblode-seo/references/research-protocol.md +37 -0
  92. package/skills/optbrew/modules/mblode-seo/references/sources.md +47 -0
  93. package/skills/optbrew/modules/mblode-seo/references/validation-evidence.md +21 -0
  94. package/skills/optbrew/modules/naver-searchadvisor/GUIDE.md +75 -0
  95. package/skills/optbrew/modules/naver-searchadvisor/NOTICE.md +59 -0
  96. package/skills/optbrew/modules/naver-searchadvisor/references/INDEX.md +97 -0
  97. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +714 -0
  98. package/skills/optbrew/modules/naver-searchadvisor/scripts/robots.mjs +77 -0
  99. package/skills/optbrew/modules/seranking-seo/skills/seo-ai-search-share-of-voice/GUIDE.md +94 -0
  100. package/skills/optbrew/modules/seranking-seo/skills/seo-api/GUIDE.md +186 -0
  101. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/api-surface-map.md +207 -0
  102. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/auth-and-keys.md +182 -0
  103. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/integration-patterns.md +283 -0
  104. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/rate-limits-and-credits.md +235 -0
  105. package/skills/optbrew/modules/seranking-seo/skills/seo-competitor-gap-analysis/GUIDE.md +117 -0
  106. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/GUIDE.md +155 -0
  107. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/cite.md +84 -0
  108. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/core-eeat.md +147 -0
  109. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/templates/verdict.md +63 -0
  110. package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/GUIDE.md +141 -0
  111. package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/references/preflight.md +73 -0
  112. package/skills/optbrew/modules/seranking-seo/skills/seo-geo/GUIDE.md +159 -0
  113. package/skills/optbrew/modules/seranking-seo/skills/seo-google/GUIDE.md +393 -0
  114. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/cwv-audit-report.md +48 -0
  115. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/gsc-performance-report.md +44 -0
  116. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/indexation-status-report.md +43 -0
  117. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/auth-setup.md +160 -0
  118. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/cross-skill-integration.md +187 -0
  119. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/ga4-data-api.md +184 -0
  120. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/indexing-api.md +109 -0
  121. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/keyword-planner-api.md +66 -0
  122. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/nlp-api.md +55 -0
  123. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/pagespeed-crux-api.md +204 -0
  124. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/rate-limits-quotas.md +75 -0
  125. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/search-console-api.md +156 -0
  126. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/supplementary-apis.md +99 -0
  127. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/youtube-api.md +49 -0
  128. package/skills/optbrew/modules/seranking-seo/skills/seo-keyword-niche/GUIDE.md +174 -0
  129. package/skills/optbrew/modules/seranking-seo/skills/seo-page/GUIDE.md +174 -0
  130. package/skills/optbrew/modules/seranking-seo/skills/seo-plan/GUIDE.md +220 -0
  131. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/GUIDE.md +135 -0
  132. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/references/google-rich-results.md +89 -0
  133. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/article.json +23 -0
  134. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/breadcrumb-list.json +18 -0
  135. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/faq-page.json +22 -0
  136. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/local-business.json +32 -0
  137. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/product.json +25 -0
  138. package/skills/optbrew/modules/seranking-seo/skills/seo-sitemap/GUIDE.md +128 -0
  139. package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/GUIDE.md +235 -0
  140. package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/references/severity-mapping.md +133 -0
  141. package/skills/optbrew/references/api.md +45 -0
  142. package/skills/optbrew/references/check-registry.json +28 -0
  143. package/skills/optbrew/references/evidence.md +9 -0
  144. package/skills/optbrew/references/intake.md +9 -0
  145. package/skills/optbrew/references/measurement.md +29 -0
  146. package/skills/optbrew/references/policies.md +7 -0
  147. package/skills/optbrew/references/profiles.json +13 -0
  148. package/skills/optbrew/references/remediations.json +12 -0
  149. package/skills/optbrew/references/report.md +25 -0
  150. package/skills/optbrew/references/routing.md +15 -0
  151. package/skills/optbrew/references/schemas/contract.schema.json +51 -0
  152. package/skills/optbrew/references/scoring.md +62 -0
  153. package/skills/optbrew/scripts/adapters/anthropic.mjs +116 -0
  154. package/skills/optbrew/scripts/adapters/imports.mjs +81 -0
  155. package/skills/optbrew/scripts/audit.mjs +145 -0
  156. package/skills/optbrew/scripts/cli.mjs +41 -0
  157. package/skills/optbrew/scripts/core/bootstrap.mjs +31 -0
  158. package/skills/optbrew/scripts/core/bundle.mjs +73 -0
  159. package/skills/optbrew/scripts/core/capabilities.mjs +19 -0
  160. package/skills/optbrew/scripts/core/collector.mjs +236 -0
  161. package/skills/optbrew/scripts/core/compare.mjs +47 -0
  162. package/skills/optbrew/scripts/core/contracts.mjs +144 -0
  163. package/skills/optbrew/scripts/core/cost-ledger.mjs +95 -0
  164. package/skills/optbrew/scripts/core/findings.mjs +54 -0
  165. package/skills/optbrew/scripts/core/metrics.mjs +97 -0
  166. package/skills/optbrew/scripts/core/protocol.mjs +125 -0
  167. package/skills/optbrew/scripts/core/report.mjs +652 -0
  168. package/skills/optbrew/scripts/core/scope.mjs +52 -0
  169. package/skills/optbrew/scripts/core/score.mjs +71 -0
  170. package/skills/optbrew/scripts/core/weights.mjs +139 -0
  171. package/skills/optbrew/source-lock.json +2686 -0
  172. package/source-lock.json +2686 -0
@@ -0,0 +1,158 @@
1
+ import { cp, lstat, mkdir, open, readFile, realpath, rename, rm, symlink } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isDistributedBundlePath, verifyBundle } from '../skills/optbrew/scripts/core/bundle.mjs';
4
+ import { readInstallRecord, writeInstallRecord } from './records.mjs';
5
+ import { resolveTargets, scopePaths } from './paths.mjs';
6
+
7
+ async function statOrNull(name) {
8
+ try { return await lstat(name); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
9
+ }
10
+
11
+ async function rejectLinkedAncestors(scopeRoot, destination) {
12
+ const relative = path.relative(scopeRoot, destination);
13
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`outside scope: ${destination}`);
14
+ let current = scopeRoot;
15
+ if ((await statOrNull(current))?.isSymbolicLink()) throw new Error(`scope root is a link: ${current}`);
16
+ for (const part of relative.split(path.sep).slice(0, -1)) {
17
+ current = path.join(current, part);
18
+ if ((await statOrNull(current))?.isSymbolicLink()) throw new Error(`junction/link ancestor outside trusted scope: ${current}`);
19
+ }
20
+ }
21
+
22
+ async function verifiedManifest(bundleRoot) {
23
+ if ((await lstat(bundleRoot)).isSymbolicLink()) throw new Error(`bundle root is a link: ${bundleRoot}`);
24
+ const manifest = JSON.parse(await readFile(path.join(bundleRoot, 'bundle-manifest.json'), 'utf8'));
25
+ const verification = await verifyBundle({ bundleRoot, manifest });
26
+ if (verification.status !== 'valid') throw new Error(`bundle integrity failed: ${verification.errors.map(e => `${e.path}:${e.code}`).join(', ')}`);
27
+ return { manifest, verification };
28
+ }
29
+
30
+ async function targetOwned(target, record, managedBundle) {
31
+ const existing = await statOrNull(target.path);
32
+ const saved = record?.targets.find(item => item.agent === target.agent && item.path === target.path);
33
+ if (!existing) {
34
+ if (saved) throw new Error(`registered target missing: ${target.path}`);
35
+ return false;
36
+ }
37
+ if (!saved || saved.managed_target !== managedBundle) throw new Error(`unmanaged optbrew target exists: ${target.path}`);
38
+ if (saved.strategy === 'link') {
39
+ if (!existing.isSymbolicLink() || await realpath(target.path) !== await realpath(managedBundle)) throw new Error(`registered link changed: ${target.path}`);
40
+ } else if (saved.strategy === 'copy') {
41
+ if (!existing.isDirectory() || existing.isSymbolicLink()) throw new Error(`registered copy changed: ${target.path}`);
42
+ const check = await verifiedManifest(target.path);
43
+ if (check.manifest.bundle_hash !== record.bundle_hash) throw new Error(`registered copy changed: ${target.path}`);
44
+ } else throw new Error(`invalid recorded strategy: ${saved.strategy}`);
45
+ return true;
46
+ }
47
+
48
+ /** Initial install and additive target registration; version changes belong to update. */
49
+ export async function install({ bundleRoot, agents, scope, home, projectRoot, dryRun = false, strategy = 'link' }) {
50
+ if (!['link', 'copy'].includes(strategy)) throw new TypeError('strategy must be link or copy');
51
+ const { scopeRoot, managedRoot } = scopePaths({ scope, home, projectRoot });
52
+ const targets = resolveTargets({ agents, scope, home, projectRoot }).map(t => ({ ...t, strategy }));
53
+ const source = await verifiedManifest(bundleRoot);
54
+ for (const target of targets) await rejectLinkedAncestors(scopeRoot, target.path);
55
+ await rejectLinkedAncestors(scopeRoot, path.join(managedRoot, 'bundle'));
56
+
57
+ async function inspect() {
58
+ if (await statOrNull(path.join(managedRoot, 'transaction.json'))) {
59
+ // Single-quoted literals: neither PowerShell nor a POSIX shell expands $ or backticks inside them.
60
+ const powershell = `'${managedRoot.replaceAll("'", "''")}'`, posix = `'${managedRoot.replaceAll("'", "'\\''")}'`;
61
+ throw new Error(`recovery_required: interrupted transaction; run optbrew update before install. PowerShell: optbrew update --record-root ${powershell}; POSIX shell: optbrew update --record-root ${posix}`);
62
+ }
63
+ const record = await readInstallRecord(managedRoot);
64
+ if (record) {
65
+ if (record.managed_root !== managedRoot || record.scope_id !== `${scope}:${scopeRoot}`) throw new Error('install record scope mismatch');
66
+ if (record.bundle_hash !== source.manifest.bundle_hash || record.release_version !== source.manifest.release_version) throw new Error('existing release differs; use optbrew update');
67
+ const managed = await verifiedManifest(path.join(managedRoot, 'bundle'));
68
+ if (managed.manifest.bundle_hash !== record.bundle_hash) throw new Error('managed bundle differs from record');
69
+ for (const saved of record.targets) {
70
+ const canonical = resolveTargets({ agents: [saved.agent], scope, home, projectRoot })[0];
71
+ if (saved.path !== canonical.path || saved.managed_target !== canonical.managed_target) throw new Error('install record target path mismatch');
72
+ await rejectLinkedAncestors(scopeRoot, saved.path);
73
+ await targetOwned(saved, record, path.join(managedRoot, 'bundle'));
74
+ }
75
+ } else if (await statOrNull(path.join(managedRoot, 'bundle'))) throw new Error('unmanaged bundle exists');
76
+ for (const target of targets) await targetOwned(target, record, path.join(managedRoot, 'bundle'));
77
+ return record;
78
+ }
79
+
80
+ const before = await inspect();
81
+ const pending = targets.filter(t => !before?.targets.some(saved => saved.agent === t.agent));
82
+ const decision = { strategy, targets, pending: pending.map(t => t.path), release_version: source.manifest.release_version };
83
+ if (dryRun) return { status: 'dry_run', dry_run: true, record: null, changed_paths: [], decision, verification: source.verification };
84
+
85
+ await mkdir(managedRoot, { recursive: true });
86
+ const lockPath = path.join(managedRoot, 'install.lock');
87
+ let lock;
88
+ try { lock = await open(lockPath, 'wx'); }
89
+ catch (error) { if (error.code === 'EEXIST') throw new Error('scope install lock already held'); throw error; }
90
+ const changed = [];
91
+ try {
92
+ const record = await inspect();
93
+ const toAdd = targets.filter(t => !record?.targets.some(saved => saved.agent === t.agent));
94
+ const managedBundle = path.join(managedRoot, 'bundle');
95
+ let next = record;
96
+ if (!record) {
97
+ const staging = path.join(managedRoot, `bundle.${process.pid}.${Date.now()}.tmp`);
98
+ try {
99
+ await cp(bundleRoot, staging, { recursive: true, errorOnExist: true, force: false, dereference: false,
100
+ filter: sourcePath => isDistributedBundlePath(path.relative(bundleRoot, sourcePath)) });
101
+ const copied = await verifiedManifest(staging);
102
+ if (copied.manifest.bundle_hash !== source.manifest.bundle_hash) throw new Error('staged bundle changed');
103
+ await rename(staging, managedBundle);
104
+ changed.push(managedBundle);
105
+ } finally { await rm(staging, { recursive: true, force: true }); }
106
+ next = {
107
+ schema_version: '1.0.0', scope_id: `${scope}:${scopeRoot}`, release_version: source.manifest.release_version,
108
+ bundle_hash: source.manifest.bundle_hash, source: path.resolve(bundleRoot), managed_root: managedRoot,
109
+ targets: [], file_hashes: Object.fromEntries(source.manifest.files.map(f => [f.path, f.sha256])),
110
+ previous_releases: [], transaction: { state: 'pending' }, source_ref: 'bundle-manifest.json',
111
+ };
112
+ try { await writeInstallRecord(managedRoot, next); }
113
+ catch (error) {
114
+ // Only remove the bundle this invocation created, if no record took ownership of it.
115
+ if (!(await readInstallRecord(managedRoot)) &&
116
+ (await verifiedManifest(managedBundle)).manifest.bundle_hash === source.manifest.bundle_hash) {
117
+ await rm(managedBundle, { recursive: true, force: true });
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+ for (const target of toAdd) {
123
+ await rejectLinkedAncestors(scopeRoot, target.path);
124
+ if (await statOrNull(target.path)) throw new Error(`unmanaged optbrew target exists: ${target.path}`);
125
+ await mkdir(path.dirname(target.path), { recursive: true });
126
+ if (strategy === 'link') {
127
+ try { await symlink(managedBundle, target.path, process.platform === 'win32' ? 'junction' : 'dir'); }
128
+ catch (error) { throw new Error(`link unavailable for ${target.path}; retry explicitly with --strategy copy (${error.message})`); }
129
+ } else {
130
+ const staging = `${target.path}.${process.pid}.${Date.now()}.tmp`;
131
+ try {
132
+ await cp(managedBundle, staging, { recursive: true, errorOnExist: true, force: false });
133
+ await verifiedManifest(staging);
134
+ await rename(staging, target.path);
135
+ } finally { await rm(staging, { recursive: true, force: true }); }
136
+ }
137
+ changed.push(target.path);
138
+ next.targets = [...next.targets, target];
139
+ next.transaction = { state: 'pending' };
140
+ try { await writeInstallRecord(managedRoot, next); }
141
+ catch (error) {
142
+ // A write failure leaves the previous record intact. Undo only this newly created target.
143
+ const current = await statOrNull(target.path);
144
+ if (current?.isSymbolicLink() && await realpath(target.path) === await realpath(managedBundle)) await rm(target.path);
145
+ else if (current?.isDirectory() && !current.isSymbolicLink() &&
146
+ (await verifiedManifest(target.path)).manifest.bundle_hash === source.manifest.bundle_hash) {
147
+ await rm(target.path, { recursive: true });
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+ if (next.transaction.state !== 'committed') {
153
+ next.transaction = { state: 'committed' };
154
+ await writeInstallRecord(managedRoot, next);
155
+ }
156
+ return { status: changed.length ? 'installed' : 'unchanged', dry_run: false, record: next, changed_paths: changed, decision, verification: source.verification };
157
+ } finally { await lock.close(); await rm(lockPath, { force: true }); }
158
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,30 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ const locations = {
5
+ codex: ['.agents/skills/optbrew', '.agents/skills/optbrew'],
6
+ 'claude-code': ['.claude/skills/optbrew', '.claude/skills/optbrew'],
7
+ cursor: ['.cursor/skills/optbrew', '.cursor/skills/optbrew'],
8
+ opencode: ['.config/opencode/skills/optbrew', '.opencode/skills/optbrew'],
9
+ pi: ['.pi/agent/skills/optbrew', '.pi/skills/optbrew'],
10
+ };
11
+
12
+ export const supportedAgents = Object.keys(locations);
13
+
14
+ export function scopePaths({ scope, home = os.homedir(), projectRoot }) {
15
+ if (scope !== 'user' && scope !== 'project') throw new TypeError('scope must be user or project');
16
+ if (scope === 'project' && !projectRoot) throw new TypeError('project scope requires --project');
17
+ const scopeRoot = path.resolve(scope === 'user' ? home : projectRoot);
18
+ return { scopeRoot, managedRoot: path.join(scopeRoot, scope === 'user' ? '.optbrew/installs/user' : '.optbrew/install') };
19
+ }
20
+
21
+ /** Fixed host paths confirmed against current host documentation. */
22
+ export function resolveTargets({ agents, scope, home, projectRoot }) {
23
+ const { scopeRoot, managedRoot } = scopePaths({ scope, home, projectRoot });
24
+ if (!Array.isArray(agents) || !agents.length) throw new TypeError('at least one agent required');
25
+ if (new Set(agents).size !== agents.length) throw new TypeError('duplicate agent');
26
+ return agents.map(agent => {
27
+ if (!locations[agent]) throw new TypeError(`unknown agent: ${agent}`);
28
+ return { agent, path: path.join(scopeRoot, locations[agent][scope === 'user' ? 0 : 1]), strategy: 'link', managed_target: path.join(managedRoot, 'bundle') };
29
+ });
30
+ }
@@ -0,0 +1,44 @@
1
+ import { mkdir, readFile, open, rename, rm, lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export function validateInstallRecord(record) {
6
+ if (record?.schema_version !== '1.0.0') throw new Error('migration_required: unsupported install record schema; use a compatible optbrew release');
7
+ if (!Array.isArray(record.targets) || !/^[a-f0-9]{64}$/.test(record.bundle_hash) ||
8
+ typeof record.managed_root !== 'string' || typeof record.scope_id !== 'string' ||
9
+ typeof record.release_version !== 'string' || !record.file_hashes || typeof record.file_hashes !== 'object' ||
10
+ !Array.isArray(record.previous_releases) || !['pending', 'committed'].includes(record.transaction?.state)) throw new Error('invalid install record');
11
+ if (Object.entries(record.file_hashes).some(([name, hash]) => path.isAbsolute(name) || name.split(/[\\/]/).includes('..') || !/^[a-f0-9]{64}$/.test(hash))) throw new Error('invalid install record file hashes');
12
+ return record;
13
+ }
14
+
15
+ /** Flush bytes before atomic replacement; callers hold the scope lock. */
16
+ export async function writeDurableJson(file, value) {
17
+ const temp = `${file}.${randomUUID()}.tmp`;
18
+ try {
19
+ const handle = await open(temp, 'wx');
20
+ try { await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); await handle.sync(); }
21
+ finally { await handle.close(); }
22
+ await rename(temp, file);
23
+ // Windows cannot open directories for fsync. File flush + rename remains atomic there.
24
+ if (process.platform !== 'win32') {
25
+ const directory = await open(path.dirname(file), 'r');
26
+ try { await directory.sync(); } finally { await directory.close(); }
27
+ }
28
+ } finally { await rm(temp, { force: true }); }
29
+ }
30
+
31
+ export async function readInstallRecord(scopeRoot) {
32
+ const file = path.join(scopeRoot, 'install-record.json');
33
+ try {
34
+ if ((await lstat(file)).isSymbolicLink()) throw new Error('install record is a link');
35
+ const record = JSON.parse(await readFile(file, 'utf8'));
36
+ return validateInstallRecord(record);
37
+ } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
38
+ }
39
+
40
+ export async function writeInstallRecord(scopeRoot, record) {
41
+ await mkdir(scopeRoot, { recursive: true });
42
+ const file = path.join(scopeRoot, 'install-record.json');
43
+ await writeDurableJson(file, record);
44
+ }
@@ -0,0 +1,205 @@
1
+ import { cp, lstat, mkdir, open, readFile, readlink, realpath, rename, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { createBundleManifest, isDistributedBundlePath, verifyBundle } from '../skills/optbrew/scripts/core/bundle.mjs';
5
+ import { readInstallRecord, validateInstallRecord, writeDurableJson, writeInstallRecord } from './records.mjs';
6
+ import { resolveTargets, scopePaths } from './paths.mjs';
7
+
8
+ export async function statOrNull(file) {
9
+ try { return await lstat(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
10
+ }
11
+
12
+ /** Check the resolved absolute parent immediately before every managed rename. */
13
+ export async function assertManagedPath(scopeRoot, destination) {
14
+ scopeRoot = path.resolve(scopeRoot);
15
+ destination = path.resolve(destination);
16
+ const relative = path.relative(scopeRoot, destination);
17
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`outside managed scope: ${destination}`);
18
+ let current = path.parse(scopeRoot).root;
19
+ for (const part of path.relative(current, path.dirname(destination)).split(path.sep)) {
20
+ current = path.join(current, part);
21
+ const stat = await statOrNull(current);
22
+ if (stat?.isSymbolicLink()) throw new Error(`linked ancestor: ${current}`);
23
+ if (stat && !stat.isDirectory()) throw new Error(`invalid ancestor: ${current}`);
24
+ if (stat && path.resolve(await realpath(current)).toLowerCase() !== current.toLowerCase()) throw new Error(`resolved path changed: ${current}`);
25
+ }
26
+ }
27
+
28
+ export async function validateScope(recordRoot, record) {
29
+ validateInstallRecord(record);
30
+ const match = /^(user|project):(.+)$/.exec(record.scope_id);
31
+ if (!match) throw new Error('invalid record scope');
32
+ const [, scope, scopeRoot] = match;
33
+ const options = { scope, home: scopeRoot, projectRoot: scopeRoot };
34
+ if (scopePaths(options).managedRoot !== recordRoot || record.managed_root !== recordRoot) throw new Error('install record scope mismatch');
35
+ await assertManagedPath(scopeRoot, path.join(recordRoot, 'install-record.json'));
36
+ const agents = record.targets.map(t => t.agent);
37
+ if (new Set(agents).size !== agents.length) throw new Error('duplicate record target');
38
+ for (const target of record.targets) {
39
+ const expected = resolveTargets({ ...options, agents: [target.agent] })[0];
40
+ if (target.path !== expected.path || target.managed_target !== expected.managed_target || !['copy', 'link'].includes(target.strategy)) throw new Error('install record target ownership mismatch');
41
+ await assertManagedPath(scopeRoot, target.path);
42
+ }
43
+ return scopeRoot;
44
+ }
45
+
46
+ export async function verifiedBundle(bundleRoot) {
47
+ if (!(await lstat(bundleRoot)).isDirectory() || (await lstat(bundleRoot)).isSymbolicLink()) throw new Error('bundle ownership: expected real directory');
48
+ if ((await lstat(path.join(bundleRoot, 'bundle-manifest.json'))).isSymbolicLink()) throw new Error('manifest is a link');
49
+ const manifest = JSON.parse(await readFile(path.join(bundleRoot, 'bundle-manifest.json'), 'utf8'));
50
+ if (manifest.schema_version !== '1.0.0') throw new Error('migration_required: unsupported bundle manifest schema');
51
+ const verification = await verifyBundle({ bundleRoot, manifest });
52
+ if (verification.status !== 'valid') throw new Error(`bundle integrity failed: ${JSON.stringify(verification.errors)}`);
53
+ const lock = JSON.parse(await readFile(path.join(bundleRoot, 'source-lock.json'), 'utf8'));
54
+ if (lock.schema_version !== '1.0.0' || !Array.isArray(lock.files) || !Array.isArray(lock.modules)) throw new Error('invalid source-lock');
55
+ const hashes = new Map(manifest.files.map(f => [f.path, f.sha256]));
56
+ const seen = new Set();
57
+ for (const file of lock.files) {
58
+ const name = file.package_path?.startsWith('skills/optbrew/') ? file.package_path.slice('skills/optbrew/'.length) : null;
59
+ if (!name || seen.has(name) || (isDistributedBundlePath(name) && hashes.get(name) !== file.distributed_sha256))
60
+ throw new Error('source-lock hash/mapping mismatch');
61
+ seen.add(name);
62
+ }
63
+ return { manifest, verification };
64
+ }
65
+
66
+ export async function copyBundle(source, destination) {
67
+ await cp(source, destination, { recursive: true, force: false, errorOnExist: true, dereference: false,
68
+ filter: file => isDistributedBundlePath(path.relative(source, file)) });
69
+ return verifiedBundle(destination);
70
+ }
71
+
72
+ /** Fingerprint bytes, including the manifest, to recognize pre/post rename states. */
73
+ export async function fingerprint(file) {
74
+ const stat = await statOrNull(file);
75
+ if (!stat) return null;
76
+ if (stat.isSymbolicLink()) return `link:${await readlink(file)}`;
77
+ if (!stat.isDirectory()) throw new Error(`unexpected managed path type: ${file}`);
78
+ const manifest = await createBundleManifest({ bundleRoot: file, releaseVersion: '0.0.0' });
79
+ const meta = await readFile(path.join(file, 'bundle-manifest.json'));
80
+ return createHash('sha256').update(manifest.bundle_hash).update(meta).digest('hex');
81
+ }
82
+
83
+ export async function withScopeLock(recordRoot, action, { recover = false } = {}) {
84
+ const file = path.join(recordRoot, 'install.lock');
85
+ let handle;
86
+ try { handle = await open(file, 'wx'); }
87
+ catch (error) {
88
+ if (error.code !== 'EEXIST') throw error;
89
+ if (!recover || (await lstat(file)).isSymbolicLink()) throw new Error('scope install lock already held');
90
+ // Serialize stale-owner reclamation so two recoverers cannot remove a new live lock.
91
+ const reclaimPath = path.join(recordRoot, 'recovery.lock');
92
+ const reclaim = await open(reclaimPath, 'wx');
93
+ try {
94
+ let owner;
95
+ try { owner = JSON.parse(await readFile(file, 'utf8')); } catch { throw new Error('recovery_required: unknown lock owner'); }
96
+ if (!Number.isInteger(owner.pid) || owner.pid < 1 || !owner.token) throw new Error('recovery_required: unknown lock owner');
97
+ try { process.kill(owner.pid, 0); throw new Error('scope install lock already held'); }
98
+ catch (e) { if (e.code !== 'ESRCH') throw e; }
99
+ await rm(file);
100
+ handle = await open(file, 'wx');
101
+ } finally { await reclaim.close(); await rm(reclaimPath); }
102
+ }
103
+ const token = randomUUID();
104
+ try {
105
+ await handle.writeFile(JSON.stringify({ pid: process.pid, token })); await handle.sync();
106
+ return await action();
107
+ } finally {
108
+ await handle.close();
109
+ const owner = JSON.parse(await readFile(file, 'utf8'));
110
+ if (owner.token === token) await rm(file);
111
+ }
112
+ }
113
+
114
+ export function operationPaths(destination, id) {
115
+ return { path: destination, staging: `${destination}.optbrew-${id}.stage`, previous: path.join(path.dirname(destination), `_백업_원본_optbrew-${id}-${path.basename(destination)}`) };
116
+ }
117
+
118
+ export async function managedRename(scopeRoot, from, to) {
119
+ await assertManagedPath(scopeRoot, from);
120
+ await assertManagedPath(scopeRoot, to);
121
+ if (await statOrNull(to)) throw new Error(`rename destination exists: ${to}`);
122
+ await rename(from, to);
123
+ }
124
+
125
+ export async function readJournal(recordRoot) {
126
+ const file = path.join(recordRoot, 'transaction.json');
127
+ if (!(await statOrNull(file))) return null;
128
+ if ((await lstat(file)).isSymbolicLink()) throw new Error('journal is a link');
129
+ return JSON.parse(await readFile(file, 'utf8'));
130
+ }
131
+
132
+ export async function finishJournal(recordRoot, journal, state) {
133
+ journal.state = state;
134
+ await writeDurableJson(path.join(recordRoot, 'transaction.json'), journal);
135
+ const directory = path.join(recordRoot, 'transactions', journal.id);
136
+ await mkdir(directory, { recursive: true });
137
+ await managedRename(await validateScope(recordRoot, journal.before), path.join(recordRoot, 'transaction.json'), path.join(directory, 'receipt.json'));
138
+ }
139
+
140
+ async function recoverLocked(recordRoot) {
141
+ const journal = await readJournal(recordRoot);
142
+ if (!journal) return { status: 'clean', restored_version: null, unresolved_paths: [], evidence_ref: null };
143
+ const evidence_ref = path.join(recordRoot, 'transaction.json');
144
+ try {
145
+ if (journal.schema_version !== '1.0.0' || !/^[a-f0-9-]{36}$/.test(journal.id) || !Array.isArray(journal.operations)) throw new Error('invalid journal');
146
+ const scopeRoot = await validateScope(recordRoot, journal.before);
147
+ await validateScope(recordRoot, journal.after);
148
+ const expected = [path.join(recordRoot, 'bundle'), ...journal.before.targets.filter(t => t.strategy === 'copy').map(t => t.path)];
149
+ if (journal.operations.length !== expected.length || journal.after.scope_id !== journal.before.scope_id || JSON.stringify(journal.after.targets) !== JSON.stringify(journal.before.targets)) throw new Error('journal target mismatch');
150
+ for (const [i, op] of journal.operations.entries()) {
151
+ const canonical = operationPaths(expected[i], journal.id);
152
+ if (Object.keys(canonical).some(k => canonical[k] !== op[k])) throw new Error('journal path ownership mismatch');
153
+ for (const p of Object.values(canonical)) await assertManagedPath(scopeRoot, p);
154
+ const current = await fingerprint(op.path), old = await fingerprint(op.previous), staged = await fingerprint(op.staging);
155
+ if (old !== null && old !== op.before_hash || staged !== null && staged !== op.after_hash ||
156
+ current !== null && current !== op.before_hash && current !== op.after_hash ||
157
+ old === null && current !== op.before_hash) throw new Error(`unresolved managed path: ${op.path}`);
158
+ }
159
+ const currentRecord = await readInstallRecord(recordRoot);
160
+ if (JSON.stringify(currentRecord) !== JSON.stringify(journal.before) && JSON.stringify(currentRecord) !== JSON.stringify(journal.after)) throw new Error('install record changed during transaction');
161
+ const snapshot = path.join(recordRoot, 'releases', journal.id, 'bundle');
162
+ await assertManagedPath(scopeRoot, snapshot);
163
+ const verified = await verifiedBundle(snapshot);
164
+ if (verified.manifest.bundle_hash !== journal.before.bundle_hash || verified.manifest.release_version !== journal.before.release_version) throw new Error('previous snapshot changed');
165
+ for (const target of journal.before.targets.filter(t => t.strategy === 'link')) {
166
+ if (!(await statOrNull(target.path))?.isSymbolicLink() || path.resolve(path.dirname(target.path), await readlink(target.path)) !== path.join(recordRoot, 'bundle')) throw new Error(`link ownership changed: ${target.path}`);
167
+ }
168
+ if (journal.state === 'committed') {
169
+ if (JSON.stringify(currentRecord) !== JSON.stringify(journal.after)) throw new Error('committed record mismatch');
170
+ for (const op of journal.operations) if (await fingerprint(op.path) !== op.after_hash) throw new Error('committed readback mismatch');
171
+ await finishJournal(recordRoot, journal, 'committed');
172
+ return { status: 'committed', restored_version: journal.after.release_version, unresolved_paths: [], evidence_ref };
173
+ }
174
+ for (const op of [...journal.operations].reverse()) {
175
+ if (await statOrNull(op.previous)) {
176
+ if (await statOrNull(op.path)) {
177
+ if (await statOrNull(op.staging)) throw new Error(`staging conflict: ${op.path}`);
178
+ await managedRename(scopeRoot, op.path, op.staging);
179
+ }
180
+ await managedRename(scopeRoot, op.previous, op.path);
181
+ }
182
+ if (await fingerprint(op.path) !== op.before_hash) throw new Error(`restore readback failed: ${op.path}`);
183
+ }
184
+ await writeInstallRecord(recordRoot, journal.before);
185
+ for (const op of journal.operations) {
186
+ const restored = await verifiedBundle(op.path);
187
+ if (restored.manifest.bundle_hash !== journal.before.bundle_hash || restored.manifest.release_version !== journal.before.release_version) throw new Error(`restored local edits require review: ${op.path}`);
188
+ }
189
+ await finishJournal(recordRoot, journal, 'recovered');
190
+ return { status: 'recovered', restored_version: journal.before.release_version, unresolved_paths: [], evidence_ref: path.join(recordRoot, 'transactions', journal.id, 'receipt.json') };
191
+ } catch (error) {
192
+ return { status: 'recovery_required', restored_version: null, unresolved_paths: [error.message], evidence_ref };
193
+ }
194
+ }
195
+
196
+ /** Never replace unknown paths, edited recovery material, or an active process's lock. */
197
+ export async function recoverTransaction(recordRoot) {
198
+ recordRoot = path.resolve(recordRoot);
199
+ try {
200
+ const record = await readInstallRecord(recordRoot);
201
+ if (!record) throw new Error('install record missing');
202
+ await validateScope(recordRoot, record);
203
+ return await withScopeLock(recordRoot, () => recoverLocked(recordRoot), { recover: true });
204
+ } catch (error) { return { status: 'recovery_required', restored_version: null, unresolved_paths: [error.message], evidence_ref: path.join(recordRoot, 'transaction.json') }; }
205
+ }
package/lib/update.mjs ADDED
@@ -0,0 +1,181 @@
1
+ import { lstat, mkdir, readFile, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createBundleManifest } from '../skills/optbrew/scripts/core/bundle.mjs';
5
+ import { readInstallRecord, writeDurableJson, writeInstallRecord } from './records.mjs';
6
+ import { assertManagedPath, copyBundle, fingerprint, finishJournal, managedRename, operationPaths,
7
+ readJournal, recoverTransaction, statOrNull, validateScope, verifiedBundle, withScopeLock } from './transaction.mjs';
8
+
9
+ const versionPattern = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
10
+ function compareVersions(a, b) {
11
+ const parse = v => v.split('+')[0].split('-');
12
+ const [aBase, ...aPre] = parse(a), [bBase, ...bPre] = parse(b);
13
+ const x = aBase.split('.').map(Number), y = bBase.split('.').map(Number);
14
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return Math.sign(x[i] - y[i]);
15
+ if (!aPre.length || !bPre.length) return Math.sign(bPre.length - aPre.length);
16
+ const ap = aPre.join('-').split('.'), bp = bPre.join('-').split('.');
17
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
18
+ if (ap[i] === bp[i]) continue;
19
+ if (ap[i] === undefined || bp[i] === undefined) return ap[i] === undefined ? -1 : 1;
20
+ const an = /^\d+$/.test(ap[i]), bn = /^\d+$/.test(bp[i]);
21
+ if (an && bn) return Math.sign(Number(ap[i]) - Number(bp[i]));
22
+ if (an !== bn) return an ? -1 : 1;
23
+ return ap[i] < bp[i] ? -1 : 1;
24
+ }
25
+ return 0;
26
+ }
27
+
28
+ /** Registry metadata only. An explicitly confirmed package name is required. */
29
+ export async function checkUpdates({ packageName, currentVersion, registryTransport }) {
30
+ if (!versionPattern.test(currentVersion)) throw new TypeError('invalid current version');
31
+ if (!packageName) return { current_version: currentVersion, latest_version: null, status: 'local_only', source_ref: 'bundled release; registry package unconfirmed' };
32
+ if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(packageName)) throw new TypeError('invalid registry package name');
33
+ const source_ref = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
34
+ const transport = registryTransport ?? (async () => {
35
+ const response = await fetch(source_ref, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { accept: 'application/json' } });
36
+ if (!response.ok) throw new Error(`registry HTTP ${response.status}`);
37
+ return response.json();
38
+ });
39
+ const metadata = await transport(packageName);
40
+ const latest = metadata?.['dist-tags']?.latest;
41
+ if (!versionPattern.test(latest)) throw new Error('registry latest version missing or invalid');
42
+ return { current_version: currentVersion, latest_version: latest, status: compareVersions(latest, currentVersion) > 0 ? 'update_available' : 'current', source_ref };
43
+ }
44
+
45
+ function matchesRecord(manifest, record) {
46
+ return manifest.bundle_hash === record.bundle_hash && manifest.release_version === record.release_version &&
47
+ JSON.stringify(Object.fromEntries(manifest.files.map(f => [f.path, f.sha256]))) === JSON.stringify(record.file_hashes);
48
+ }
49
+
50
+ async function inspect(recordRoot, record, localChanges) {
51
+ const scopeRoot = await validateScope(recordRoot, record);
52
+ if (record.transaction.state !== 'committed') throw new Error('recovery_required: pending initial install; finish optbrew install first');
53
+ const conflicts = [];
54
+ const bundle = path.join(recordRoot, 'bundle');
55
+ for (const target of [{ path: bundle, strategy: 'copy' }, ...record.targets]) {
56
+ await assertManagedPath(scopeRoot, target.path);
57
+ const stat = await statOrNull(target.path);
58
+ if (target.strategy === 'link') {
59
+ if (!stat?.isSymbolicLink() || await realpath(target.path) !== await realpath(bundle)) throw new Error(`link ownership changed: ${target.path}`);
60
+ continue;
61
+ }
62
+ if (!stat?.isDirectory() || stat.isSymbolicLink()) throw new Error(`copy ownership changed: ${target.path}`);
63
+ // Hash the current bytes against the recorded allowlist, never trust a rewritten manifest.
64
+ const actual = await createBundleManifest({ bundleRoot: target.path, releaseVersion: record.release_version });
65
+ const manifestPath = path.join(target.path, 'bundle-manifest.json');
66
+ if ((await lstat(manifestPath)).isSymbolicLink()) throw new Error('manifest ownership changed');
67
+ const saved = JSON.parse(await readFile(manifestPath, 'utf8'));
68
+ if (!matchesRecord(actual, record) || !matchesRecord(saved, record)) conflicts.push({ path: target.path, reason: 'recorded file hashes or manifest changed' });
69
+ }
70
+ if (conflicts.length && localChanges === 'abort') throw new Error(`local changes: ${conflicts.map(c => c.path).join(', ')}; use --local-changes backup-and-replace to preserve and replace`);
71
+ return { scopeRoot, conflicts };
72
+ }
73
+
74
+ async function compatible(previous, next) {
75
+ // No migration is shipped. Changes to the persisted schema or evaluation contract require one.
76
+ const contractFiles = ['references/schemas/contract.schema.json', 'references/check-registry.json',
77
+ 'references/profiles.json', 'references/scoring.md', 'references/measurement.md', 'scripts/core/score.mjs',
78
+ 'scripts/core/weights.mjs', 'scripts/core/protocol.mjs', 'scripts/core/contracts.mjs',
79
+ 'scripts/core/compare.mjs', 'scripts/core/metrics.mjs', 'scripts/core/bootstrap.mjs'];
80
+ for (const name of contractFiles) {
81
+ const old = previous.files.find(f => f.path === name)?.sha256;
82
+ const current = next.files.find(f => f.path === name)?.sha256;
83
+ if (old !== current) throw new Error(`migration_required: ${name} changed; use a release with an explicit migration before updating this scope`);
84
+ }
85
+ for (const field of ['evaluation_version', 'protocol_version']) {
86
+ if (previous[field] !== next[field]) throw new Error(`migration_required: ${field} changed or became unknown`);
87
+ }
88
+ }
89
+
90
+ async function applyRelease({ bundleRoot, recordRoot, localChanges, faultHook, kind, expectedSnapshot }) {
91
+ if (!['abort', 'backup-and-replace'].includes(localChanges)) throw new TypeError('localChanges must be abort or backup-and-replace');
92
+ recordRoot = path.resolve(recordRoot);
93
+ const original = await readInstallRecord(recordRoot);
94
+ if (!original) throw new Error('install record missing');
95
+ await validateScope(recordRoot, original);
96
+ if (await readJournal(recordRoot)) {
97
+ const recovery = await recoverTransaction(recordRoot);
98
+ return { status: recovery.status === 'recovery_required' ? 'recovery_required' : 'recovered', record: await readInstallRecord(recordRoot), changed_paths: [], decision: { recovery, instruction: 'rerun the requested update after recovery' }, verification: null };
99
+ }
100
+ return withScopeLock(recordRoot, async () => {
101
+ // A different writer may have left a journal between preflight and lock acquisition.
102
+ if (await readJournal(recordRoot)) throw new Error('recovery_required: transaction appeared; rerun update or rollback to recover');
103
+ const before = await readInstallRecord(recordRoot);
104
+ const { scopeRoot, conflicts } = await inspect(recordRoot, before, localChanges);
105
+ const source = await verifiedBundle(bundleRoot);
106
+ if (expectedSnapshot && (source.manifest.bundle_hash !== expectedSnapshot.bundle_hash || source.manifest.release_version !== expectedSnapshot.release_version)) throw new Error('rollback snapshot identity mismatch');
107
+ if (source.manifest.release_version === before.release_version && source.manifest.bundle_hash !== before.bundle_hash) throw new Error('release version reused with different bundle');
108
+ if (kind === 'update' && compareVersions(source.manifest.release_version, before.release_version) < 0) throw new Error('older bundle requires rollback');
109
+ if (source.manifest.bundle_hash === before.bundle_hash && source.manifest.release_version === before.release_version && !conflicts.length) return { status: 'unchanged', record: before, changed_paths: [], decision: {}, verification: source.verification };
110
+ const managed = path.join(recordRoot, 'bundle');
111
+ let previousRoot = managed;
112
+ if (conflicts.some(c => c.path === managed)) previousRoot = before.source;
113
+ const previous = await verifiedBundle(previousRoot);
114
+ if (!matchesRecord(previous.manifest, before)) throw new Error('previous verified bundle unavailable; preserve edits and recover original release first');
115
+ await compatible(previous.manifest, source.manifest);
116
+ const id = randomUUID();
117
+ const snapshotRoot = path.join(recordRoot, 'releases', id, 'bundle');
118
+ await assertManagedPath(scopeRoot, snapshotRoot);
119
+ await mkdir(path.dirname(snapshotRoot), { recursive: true });
120
+ const snapshot = await copyBundle(previousRoot, snapshotRoot);
121
+ if (!matchesRecord(snapshot.manifest, before)) throw new Error('previous snapshot integrity failed');
122
+ const operations = [];
123
+ for (const destination of [managed, ...before.targets.filter(t => t.strategy === 'copy').map(t => t.path)]) {
124
+ const op = operationPaths(destination, id);
125
+ await assertManagedPath(scopeRoot, op.staging);
126
+ if (await statOrNull(op.previous) || await statOrNull(op.staging)) throw new Error('transaction staging already exists');
127
+ const staged = await copyBundle(bundleRoot, op.staging);
128
+ if (JSON.stringify(staged.manifest) !== JSON.stringify(source.manifest)) throw new Error('staged bundle identity changed');
129
+ operations.push({ ...op, before_hash: await fingerprint(destination), after_hash: await fingerprint(op.staging) });
130
+ }
131
+ const decision = { local_changes: localChanges, conflicts, backups: operations.map(op => ({ path: op.path, backup: op.previous })) };
132
+ const after = { ...before, release_version: source.manifest.release_version, bundle_hash: source.manifest.bundle_hash,
133
+ source: path.resolve(bundleRoot), file_hashes: Object.fromEntries(source.manifest.files.map(f => [f.path, f.sha256])),
134
+ previous_releases: [...before.previous_releases, { release_version: before.release_version, bundle_hash: before.bundle_hash, bundle_root: snapshotRoot }],
135
+ transaction: { state: 'committed', id, kind, conflicts, backups: decision.backups } };
136
+ const journal = { schema_version: '1.0.0', id, state: 'prepared', before, after, operations };
137
+ await writeDurableJson(path.join(recordRoot, 'transaction.json'), journal);
138
+ try {
139
+ await faultHook?.('after_staging');
140
+ // Reinspect after staging so edits made while preparing cannot be silently overwritten.
141
+ await inspect(recordRoot, before, localChanges);
142
+ for (const [index, op] of operations.entries()) {
143
+ if (await fingerprint(op.path) !== op.before_hash || await fingerprint(op.staging) !== op.after_hash) throw new Error('local changes during transaction preparation');
144
+ await managedRename(scopeRoot, op.path, op.previous);
145
+ if (await fingerprint(op.previous) !== op.before_hash) throw new Error('backup readback failed');
146
+ await managedRename(scopeRoot, op.staging, op.path);
147
+ if (await fingerprint(op.path) !== op.after_hash) throw new Error('target readback failed');
148
+ if (index === 1 || (index === 0 && operations.length === 1)) await faultHook?.('after_first_target');
149
+ }
150
+ await faultHook?.('before_record_replace');
151
+ await inspect(recordRoot, after, 'abort');
152
+ if (JSON.stringify(await readInstallRecord(recordRoot)) !== JSON.stringify(before)) throw new Error('install record changed during transaction');
153
+ await writeInstallRecord(recordRoot, after);
154
+ if (JSON.stringify(await readInstallRecord(recordRoot)) !== JSON.stringify(after)) throw new Error('record readback failed');
155
+ await inspect(recordRoot, after, 'abort');
156
+ await finishJournal(recordRoot, journal, 'committed');
157
+ return { status: kind === 'rollback' ? 'rolled_back' : 'updated', record: after, changed_paths: [managed, ...after.targets.map(t => t.path)], decision, verification: source.verification };
158
+ } catch (error) {
159
+ return { status: 'recovery_required', record: await readInstallRecord(recordRoot), changed_paths: [], decision: { ...decision, error: error.message, recovery: 'rerun update or call recoverTransaction(recordRoot)', journal: path.join(recordRoot, 'transaction.json') }, verification: null };
160
+ }
161
+ }, { recover: true });
162
+ }
163
+
164
+ /** Apply this running, locally verified release; obtaining npm packages is the caller's job. */
165
+ export async function update({ bundleRoot, recordRoot, localChanges = 'abort', faultHook }) {
166
+ return applyRelease({ bundleRoot, recordRoot, localChanges, faultHook, kind: 'update' });
167
+ }
168
+
169
+ export async function rollback({ recordRoot, releaseVersion, faultHook }) {
170
+ recordRoot = path.resolve(recordRoot);
171
+ const record = await readInstallRecord(recordRoot);
172
+ if (!record) throw new Error('install record missing');
173
+ const scopeRoot = await validateScope(recordRoot, record);
174
+ if (await readJournal(recordRoot)) return applyRelease({ recordRoot, localChanges: 'abort', faultHook, kind: 'rollback' });
175
+ const snapshot = [...record.previous_releases].reverse().find(r => r.release_version === releaseVersion);
176
+ if (!snapshot) throw new Error('verified previous release not found');
177
+ const relative = path.relative(path.join(recordRoot, 'releases'), snapshot.bundle_root);
178
+ if (!/^[a-f0-9-]{36}[\\/]bundle$/.test(relative)) throw new Error('rollback snapshot outside managed releases');
179
+ await assertManagedPath(scopeRoot, snapshot.bundle_root);
180
+ return applyRelease({ bundleRoot: snapshot.bundle_root, recordRoot, localChanges: 'abort', faultHook, kind: 'rollback', expectedSnapshot: snapshot });
181
+ }