@funnelsgrove/cli 0.1.15 → 0.1.18

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 (293) hide show
  1. package/contracts/step-contract-v2.json +733 -0
  2. package/dist/analyticsOutput.d.ts +30 -15
  3. package/dist/analyticsOutput.js +45 -28
  4. package/dist/apiClient.d.ts +85 -0
  5. package/dist/apiClient.js +225 -8
  6. package/dist/cli.d.ts +149 -1
  7. package/dist/cli.js +1232 -71
  8. package/dist/diagnosticOutput.d.ts +42 -0
  9. package/dist/diagnosticOutput.js +83 -0
  10. package/dist/docsOutput.d.ts +19 -0
  11. package/dist/docsOutput.js +73 -0
  12. package/dist/docsTransaction.d.ts +81 -0
  13. package/dist/docsTransaction.js +1755 -0
  14. package/dist/funnelValidation.d.ts +14 -0
  15. package/dist/funnelValidation.js +297 -0
  16. package/dist/localSync.d.ts +79 -0
  17. package/dist/localSync.js +400 -29
  18. package/dist/projectValidator.d.ts +27 -0
  19. package/dist/projectValidator.js +208 -0
  20. package/dist/reskin.js +27 -1
  21. package/dist/stepContractMigration.d.ts +71 -0
  22. package/dist/stepContractMigration.js +1323 -0
  23. package/dist/templateDocs.js +8 -25
  24. package/docs-release-history.json +66 -0
  25. package/legacy-docs-catalog.json +1012 -0
  26. package/package.json +13 -4
  27. package/template_docs/.funnelsgrove-docs.json +144 -0
  28. package/template_docs/AGENTS.md +30 -58
  29. package/template_docs/CLAUDE.md +0 -2
  30. package/template_docs/docs/funnelsgrove/START-HERE.md +73 -0
  31. package/template_docs/docs/funnelsgrove/contracts/analytics-events.md +59 -0
  32. package/template_docs/docs/funnelsgrove/contracts/content-answers.md +44 -0
  33. package/template_docs/docs/funnelsgrove/contracts/flow-routing.md +41 -0
  34. package/template_docs/docs/funnelsgrove/contracts/payments.md +42 -0
  35. package/template_docs/docs/funnelsgrove/contracts/step-metadata.md +57 -0
  36. package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +39 -0
  37. package/template_docs/docs/funnelsgrove/qa/analytics.md +43 -0
  38. package/template_docs/docs/funnelsgrove/qa/local.md +42 -0
  39. package/template_docs/docs/funnelsgrove/qa/paywall-checkout.md +55 -0
  40. package/template_docs/docs/funnelsgrove/qa/publish.md +32 -0
  41. package/template_docs/docs/funnelsgrove/recipes/add-experiment.md +34 -0
  42. package/template_docs/docs/funnelsgrove/recipes/add-step.md +33 -0
  43. package/template_docs/docs/funnelsgrove/recipes/edit-step.md +31 -0
  44. package/template_docs/docs/funnelsgrove/steps/INDEX.md +26 -0
  45. package/template_docs/docs/funnelsgrove/steps/cancellation_offer.md +112 -0
  46. package/template_docs/docs/funnelsgrove/steps/checkout.md +120 -0
  47. package/template_docs/docs/funnelsgrove/steps/form_input.md +115 -0
  48. package/template_docs/docs/funnelsgrove/steps/intro_hero.md +110 -0
  49. package/template_docs/docs/funnelsgrove/steps/multi_select_choice.md +123 -0
  50. package/template_docs/docs/funnelsgrove/steps/paywall_offer.md +115 -0
  51. package/template_docs/docs/funnelsgrove/steps/progress_interstitial.md +111 -0
  52. package/template_docs/docs/funnelsgrove/steps/purchase_completed.md +117 -0
  53. package/template_docs/docs/funnelsgrove/steps/single_step_choice.md +122 -0
  54. package/template_docs/docs/funnelsgrove/steps/single_step_choice_emoji.md +122 -0
  55. package/template_docs/docs/funnelsgrove/steps/social_proof.md +110 -0
  56. package/template_docs/docs/funnelsgrove/steps/subscription_handoff.md +113 -0
  57. package/template_docs/docs/funnelsgrove/steps/subscription_management.md +112 -0
  58. package/template_docs/docs/funnelsgrove/steps/summary_confirmation.md +110 -0
  59. package/template_docs/docs/funnelsgrove/steps/upsell_offer.md +112 -0
  60. package/template_docs/docs/funnelsgrove/steps/value_prop_story.md +110 -0
  61. package/template_docs/funnel-docs.config.json +43 -0
  62. package/template_scaffold/.env.example +41 -0
  63. package/template_scaffold/.funnelsgrove-contract-tools.json +19 -0
  64. package/template_scaffold/.funnelsgrove-docs.json +144 -0
  65. package/template_scaffold/.funnelsgrove-scaffold.json +1078 -0
  66. package/template_scaffold/AGENTS.md +42 -0
  67. package/template_scaffold/CLAUDE.md +1 -0
  68. package/template_scaffold/PLAN.md +81 -0
  69. package/template_scaffold/PRODUCT_SENSE.md +28 -0
  70. package/template_scaffold/context/build-context.md +34 -0
  71. package/template_scaffold/docs/AGENT_FUNNEL_IMPLEMENTATION_SPEC.md +5 -0
  72. package/template_scaffold/docs/ARCHITECTURE.md +5 -0
  73. package/template_scaffold/docs/FLOW_CONFIG_AND_ROUTING.md +5 -0
  74. package/template_scaffold/docs/PAYWALL_AND_PAYMENTS.md +5 -0
  75. package/template_scaffold/docs/README.md +5 -0
  76. package/template_scaffold/docs/funnelsgrove/START-HERE.md +73 -0
  77. package/template_scaffold/docs/funnelsgrove/contracts/analytics-events.md +59 -0
  78. package/template_scaffold/docs/funnelsgrove/contracts/content-answers.md +44 -0
  79. package/template_scaffold/docs/funnelsgrove/contracts/flow-routing.md +41 -0
  80. package/template_scaffold/docs/funnelsgrove/contracts/payments.md +42 -0
  81. package/template_scaffold/docs/funnelsgrove/contracts/step-metadata.md +57 -0
  82. package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +39 -0
  83. package/template_scaffold/docs/funnelsgrove/qa/analytics.md +43 -0
  84. package/template_scaffold/docs/funnelsgrove/qa/local.md +42 -0
  85. package/template_scaffold/docs/funnelsgrove/qa/paywall-checkout.md +55 -0
  86. package/template_scaffold/docs/funnelsgrove/qa/publish.md +32 -0
  87. package/template_scaffold/docs/funnelsgrove/recipes/add-experiment.md +34 -0
  88. package/template_scaffold/docs/funnelsgrove/recipes/add-step.md +33 -0
  89. package/template_scaffold/docs/funnelsgrove/recipes/edit-step.md +31 -0
  90. package/template_scaffold/docs/funnelsgrove/steps/INDEX.md +26 -0
  91. package/template_scaffold/docs/funnelsgrove/steps/cancellation_offer.md +112 -0
  92. package/template_scaffold/docs/funnelsgrove/steps/checkout.md +120 -0
  93. package/template_scaffold/docs/funnelsgrove/steps/form_input.md +115 -0
  94. package/template_scaffold/docs/funnelsgrove/steps/intro_hero.md +110 -0
  95. package/template_scaffold/docs/funnelsgrove/steps/multi_select_choice.md +123 -0
  96. package/template_scaffold/docs/funnelsgrove/steps/paywall_offer.md +115 -0
  97. package/template_scaffold/docs/funnelsgrove/steps/progress_interstitial.md +111 -0
  98. package/template_scaffold/docs/funnelsgrove/steps/purchase_completed.md +117 -0
  99. package/template_scaffold/docs/funnelsgrove/steps/single_step_choice.md +122 -0
  100. package/template_scaffold/docs/funnelsgrove/steps/single_step_choice_emoji.md +122 -0
  101. package/template_scaffold/docs/funnelsgrove/steps/social_proof.md +110 -0
  102. package/template_scaffold/docs/funnelsgrove/steps/subscription_handoff.md +113 -0
  103. package/template_scaffold/docs/funnelsgrove/steps/subscription_management.md +112 -0
  104. package/template_scaffold/docs/funnelsgrove/steps/summary_confirmation.md +110 -0
  105. package/template_scaffold/docs/funnelsgrove/steps/upsell_offer.md +112 -0
  106. package/template_scaffold/docs/funnelsgrove/steps/value_prop_story.md +110 -0
  107. package/template_scaffold/docs/references/funnel-sdk-user-answers.md +5 -0
  108. package/template_scaffold/eslint.config.mjs +16 -0
  109. package/template_scaffold/funnel-agent-docs.test.ts +699 -0
  110. package/template_scaffold/funnel-docs.config.json +43 -0
  111. package/template_scaffold/funnel.config.json +5 -0
  112. package/template_scaffold/gitignore.template +5 -0
  113. package/template_scaffold/next-env.d.ts +6 -0
  114. package/template_scaffold/next.config.ts +53 -0
  115. package/template_scaffold/package-lock.json +7737 -0
  116. package/template_scaffold/package.json +37 -0
  117. package/template_scaffold/package.test.ts +23 -0
  118. package/template_scaffold/public/claimbee/bee.png +0 -0
  119. package/template_scaffold/public/claimbee/device-iphone.png +0 -0
  120. package/template_scaffold/public/claimbee/device-macbook.png +0 -0
  121. package/template_scaffold/public/claimbee/device-none.png +0 -0
  122. package/template_scaffold/public/claimbee/device-watch.png +0 -0
  123. package/template_scaffold/public/claimbee/glow.svg +3 -0
  124. package/template_scaffold/public/claimbee/steps-sprite-alt.png +0 -0
  125. package/template_scaffold/public/claimbee/steps-sprite-default.png +0 -0
  126. package/template_scaffold/public/claimbee/testimonial-avatar.png +0 -0
  127. package/template_scaffold/public/claimbee/testimonial-star.svg +3 -0
  128. package/template_scaffold/public/paywall/ai-claim-assistant.png +0 -0
  129. package/template_scaffold/public/paywall/claim-history.png +0 -0
  130. package/template_scaffold/public/paywall/clear-progress.png +0 -0
  131. package/template_scaffold/public/paywall/hero.png +0 -0
  132. package/template_scaffold/public/paywall/money-alerts.png +0 -0
  133. package/template_scaffold/public/paywall/money-back-badge.png +0 -0
  134. package/template_scaffold/public/paywall/paywall-image.png +0 -0
  135. package/template_scaffold/public/paywall/r1.png +0 -0
  136. package/template_scaffold/public/paywall/r2.png +0 -0
  137. package/template_scaffold/public/paywall/r3.png +0 -0
  138. package/template_scaffold/public/paywall/r4.png +0 -0
  139. package/template_scaffold/public/paywall/r5.png +0 -0
  140. package/template_scaffold/public/paywall/r6.png +0 -0
  141. package/template_scaffold/public/paywall/special-offer-gift.png +0 -0
  142. package/template_scaffold/public/please/bee.png +0 -0
  143. package/template_scaffold/public/please/device-iphone.png +0 -0
  144. package/template_scaffold/public/please/device-macbook.png +0 -0
  145. package/template_scaffold/public/please/device-none.png +0 -0
  146. package/template_scaffold/public/please/device-watch.png +0 -0
  147. package/template_scaffold/public/please/glow.svg +3 -0
  148. package/template_scaffold/public/please/steps-sprite-alt.png +0 -0
  149. package/template_scaffold/public/please/steps-sprite-default.png +0 -0
  150. package/template_scaffold/public/please/testimonial-avatar.png +0 -0
  151. package/template_scaffold/public/please/testimonial-star.svg +3 -0
  152. package/template_scaffold/public/upsell/upgrade-hero.png +0 -0
  153. package/template_scaffold/src/app/[stepId]/page.test.tsx +59 -0
  154. package/template_scaffold/src/app/[stepId]/page.tsx +62 -0
  155. package/template_scaffold/src/app/globals.css +49 -0
  156. package/template_scaffold/src/app/layout.test.ts +26 -0
  157. package/template_scaffold/src/app/layout.tsx +61 -0
  158. package/template_scaffold/src/app/page.tsx +8 -0
  159. package/template_scaffold/src/app/sdk/page.tsx +673 -0
  160. package/template_scaffold/src/app/subscription-dashboard/page.tsx +274 -0
  161. package/template_scaffold/src/assets/claimbee/bee.png +0 -0
  162. package/template_scaffold/src/assets/claimbee/device-iphone.png +0 -0
  163. package/template_scaffold/src/assets/claimbee/device-macbook.png +0 -0
  164. package/template_scaffold/src/assets/claimbee/device-none.png +0 -0
  165. package/template_scaffold/src/assets/claimbee/device-watch.png +0 -0
  166. package/template_scaffold/src/assets/claimbee/glow.svg +3 -0
  167. package/template_scaffold/src/assets/claimbee/steps-sprite-alt.png +0 -0
  168. package/template_scaffold/src/assets/claimbee/steps-sprite-default.png +0 -0
  169. package/template_scaffold/src/assets/claimbee/testimonial-avatar.png +0 -0
  170. package/template_scaffold/src/assets/claimbee/testimonial-star.svg +3 -0
  171. package/template_scaffold/src/components/FunnelEditorPanel.tsx +194 -0
  172. package/template_scaffold/src/components/FunnelFlow.test.tsx +17 -0
  173. package/template_scaffold/src/components/FunnelFlow.tsx +114 -0
  174. package/template_scaffold/src/components/FunnelStepImage.test.ts +15 -0
  175. package/template_scaffold/src/components/FunnelStepImage.tsx +28 -0
  176. package/template_scaffold/src/components/step/FunnelStepHeader.tsx +35 -0
  177. package/template_scaffold/src/config/billing.plans.test.ts +143 -0
  178. package/template_scaffold/src/config/billing.plans.ts +69 -0
  179. package/template_scaffold/src/config/billing.test.plans.ts +32 -0
  180. package/template_scaffold/src/config/experiments.generated.ts +4 -0
  181. package/template_scaffold/src/config/experiments.test.ts +12 -0
  182. package/template_scaffold/src/config/experiments.ts +9 -0
  183. package/template_scaffold/src/config/funnel.config.ts +12 -0
  184. package/template_scaffold/src/config/funnel.manifest.test.ts +166 -0
  185. package/template_scaffold/src/config/funnel.manifest.ts +139 -0
  186. package/template_scaffold/src/config/offer-sets.generated.ts +4 -0
  187. package/template_scaffold/src/contract/funnel-project.validation.test.ts +80 -0
  188. package/template_scaffold/src/contract/funnel-project.validation.ts +44 -0
  189. package/template_scaffold/src/contract/funnel-validator.vite.config.ts +11 -0
  190. package/template_scaffold/src/contract/golden/cancellation_offer.fixture.ts +29 -0
  191. package/template_scaffold/src/contract/golden/checkout.fixture.ts +37 -0
  192. package/template_scaffold/src/contract/golden/form_input.fixture.ts +28 -0
  193. package/template_scaffold/src/contract/golden/golden-fixtures.test.ts +190 -0
  194. package/template_scaffold/src/contract/golden/intro_hero.fixture.ts +27 -0
  195. package/template_scaffold/src/contract/golden/multi_select_choice.fixture.ts +37 -0
  196. package/template_scaffold/src/contract/golden/paywall_offer.fixture.ts +30 -0
  197. package/template_scaffold/src/contract/golden/progress_interstitial.fixture.ts +28 -0
  198. package/template_scaffold/src/contract/golden/purchase_completed.fixture.ts +33 -0
  199. package/template_scaffold/src/contract/golden/single_step_choice.fixture.ts +36 -0
  200. package/template_scaffold/src/contract/golden/single_step_choice_emoji.fixture.ts +36 -0
  201. package/template_scaffold/src/contract/golden/social_proof.fixture.ts +27 -0
  202. package/template_scaffold/src/contract/golden/subscription_handoff.fixture.ts +30 -0
  203. package/template_scaffold/src/contract/golden/subscription_management.fixture.ts +29 -0
  204. package/template_scaffold/src/contract/golden/summary_confirmation.fixture.ts +27 -0
  205. package/template_scaffold/src/contract/golden/upsell_offer.fixture.ts +29 -0
  206. package/template_scaffold/src/contract/golden/value_prop_story.fixture.ts +27 -0
  207. package/template_scaffold/src/contract/validate-funnel.cli.test.ts +180 -0
  208. package/template_scaffold/src/contract/validate-funnel.cli.ts +199 -0
  209. package/template_scaffold/src/runtime/checkout-runtime-config.test.ts +60 -0
  210. package/template_scaffold/src/runtime/checkout-runtime-config.ts +26 -0
  211. package/template_scaffold/src/runtime/funnel-runtime.test.ts +25 -0
  212. package/template_scaffold/src/runtime/funnel-runtime.ts +118 -0
  213. package/template_scaffold/src/runtime/step-content-context.test.ts +38 -0
  214. package/template_scaffold/src/runtime/step-content-context.ts +65 -0
  215. package/template_scaffold/src/runtime/step-registry.ts +66 -0
  216. package/template_scaffold/src/runtime/step-variable-values.ts +31 -0
  217. package/template_scaffold/src/runtime/use-funnel-flow-controller.integration.test.ts +203 -0
  218. package/template_scaffold/src/runtime/use-funnel-flow-controller.test.ts +76 -0
  219. package/template_scaffold/src/runtime/use-funnel-flow-controller.ts +53 -0
  220. package/template_scaffold/src/steps/content/email-capture.content.ts +29 -0
  221. package/template_scaffold/src/steps/content/manage-subscription.content.ts +62 -0
  222. package/template_scaffold/src/steps/content/paywall.content.ts +191 -0
  223. package/template_scaffold/src/steps/content/step-01.content.ts +23 -0
  224. package/template_scaffold/src/steps/content/step-02.content.ts +77 -0
  225. package/template_scaffold/src/steps/content/step-03.content.ts +40 -0
  226. package/template_scaffold/src/steps/content/step-04.content.ts +49 -0
  227. package/template_scaffold/src/steps/content/step-32-paywall.content.ts +352 -0
  228. package/template_scaffold/src/steps/content/subscription-started.content.ts +68 -0
  229. package/template_scaffold/src/steps/editor/email-capture.editor.ts +58 -0
  230. package/template_scaffold/src/steps/editor/manage-subscription.editor.ts +179 -0
  231. package/template_scaffold/src/steps/editor/paywall.editor.ts +232 -0
  232. package/template_scaffold/src/steps/editor/step-01.editor.ts +22 -0
  233. package/template_scaffold/src/steps/editor/step-02.editor.ts +41 -0
  234. package/template_scaffold/src/steps/editor/step-03.editor.ts +58 -0
  235. package/template_scaffold/src/steps/editor/step-04.editor.ts +98 -0
  236. package/template_scaffold/src/steps/editor/step-32-paywall.editor.ts +478 -0
  237. package/template_scaffold/src/steps/editor/subscription-started.editor.ts +131 -0
  238. package/template_scaffold/src/steps/index.ts +25 -0
  239. package/template_scaffold/src/steps/paywall-checkout-options.test.ts +104 -0
  240. package/template_scaffold/src/steps/paywall-checkout-options.ts +130 -0
  241. package/template_scaffold/src/steps/pricing/paywall-b.pricing.ts +12 -0
  242. package/template_scaffold/src/steps/pricing/paywall.pricing.ts +12 -0
  243. package/template_scaffold/src/steps/pricing/shared-plan-order.ts +17 -0
  244. package/template_scaffold/src/steps/pricing/upsell-form.pricing.ts +16 -0
  245. package/template_scaffold/src/steps/shared-header-contract.test.ts +27 -0
  246. package/template_scaffold/src/steps/step-01.test.ts +38 -0
  247. package/template_scaffold/src/steps/step-01.tsx +108 -0
  248. package/template_scaffold/src/steps/step-02.test.ts +43 -0
  249. package/template_scaffold/src/steps/step-02.tsx +140 -0
  250. package/template_scaffold/src/steps/step-03.test.ts +15 -0
  251. package/template_scaffold/src/steps/step-03.tsx +262 -0
  252. package/template_scaffold/src/steps/step-04.test.ts +32 -0
  253. package/template_scaffold/src/steps/step-04.tsx +673 -0
  254. package/template_scaffold/src/steps/step-31-email-capture.test.ts +22 -0
  255. package/template_scaffold/src/steps/step-31-email-capture.tsx +195 -0
  256. package/template_scaffold/src/steps/step-32-paywall.test.ts +770 -0
  257. package/template_scaffold/src/steps/step-32-paywall.tsx +2253 -0
  258. package/template_scaffold/src/steps/step-32b-paywall-b.tsx +35 -0
  259. package/template_scaffold/src/steps/step-33-subscription-started.test.ts +26 -0
  260. package/template_scaffold/src/steps/step-33-subscription-started.tsx +86 -0
  261. package/template_scaffold/src/steps/step-34-upsell-form.test.ts +27 -0
  262. package/template_scaffold/src/steps/step-35-manage-subscription.tsx +46 -0
  263. package/template_scaffold/src/steps/step-content.registry.ts +45 -0
  264. package/template_scaffold/src/steps/step-image-migration.test.ts +20 -0
  265. package/template_scaffold/src/steps/styles/index.css +2 -0
  266. package/template_scaffold/src/steps/styles/shared/final-flow-shared.css +55 -0
  267. package/template_scaffold/src/steps/styles/shared/funnel-base.css +543 -0
  268. package/template_scaffold/src/steps/styles/shared/info-shared.css +139 -0
  269. package/template_scaffold/src/steps/styles/shared/progress-shared.css +117 -0
  270. package/template_scaffold/src/steps/styles/shared/question-shared.css +76 -0
  271. package/template_scaffold/src/steps/styles/shared/responsive-container.css +176 -0
  272. package/template_scaffold/src/steps/types.ts +7 -0
  273. package/template_scaffold/src/steps/use-claimbee-step-content.ts +15 -0
  274. package/template_scaffold/src/theme/theme.ts +81 -0
  275. package/template_scaffold/tsconfig.json +38 -0
  276. package/template_scaffold/vitest.config.ts +32 -0
  277. package/template_validation/.funnelsgrove-contract-tools.json +19 -0
  278. package/template_validation/src/contract/funnel-project.validation.ts +44 -0
  279. package/template_validation/src/contract/funnel-validator.vite.config.ts +11 -0
  280. package/template_validation/src/contract/validate-funnel.cli.ts +199 -0
  281. package/template_docs/docs/ab-experiments.md +0 -111
  282. package/template_docs/docs/analytics.md +0 -65
  283. package/template_docs/docs/editing-flow.md +0 -136
  284. package/template_docs/docs/editing-step.md +0 -125
  285. package/template_docs/docs/editor-and-content.md +0 -58
  286. package/template_docs/docs/funnel-runtime-architecture.md +0 -125
  287. package/template_docs/docs/meta-pixel-conversions-api.md +0 -50
  288. package/template_docs/docs/payment-plans-and-discounts.md +0 -106
  289. package/template_docs/docs/publishing-and-versioning.md +0 -117
  290. package/template_docs/docs/qa-checklist.md +0 -80
  291. package/template_docs/docs/sdk-api-endpoints.md +0 -78
  292. package/template_docs/docs/step-ui-guidelines.md +0 -110
  293. package/template_docs/docs/theme.md +0 -35
@@ -0,0 +1,1755 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, link, lstat, mkdir, open, readFile, readdir, rename, rm, unlink, } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { CURRENT_STEP_CONTRACT_VERSION, PREVIOUS_STEP_CONTRACT_VERSION, } from '@funnelsgrove/runtime';
5
+ export const DOCS_MANIFEST_FILE = '.funnelsgrove-docs.json';
6
+ export class DocsTransactionError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.name = 'DocsTransactionError';
11
+ this.code = code;
12
+ }
13
+ }
14
+ const hashBytes = (bytes) => createHash('sha256').update(bytes).digest('hex');
15
+ const caseKey = (value) => value.normalize('NFC').toLowerCase();
16
+ const docsError = (message) => new DocsTransactionError('FG-DOC-001', message);
17
+ const SUPPORTED_DOCS_CONTRACT_VERSIONS = new Set([
18
+ PREVIOUS_STEP_CONTRACT_VERSION,
19
+ CURRENT_STEP_CONTRACT_VERSION,
20
+ ]);
21
+ const assertManagedPath = (value) => {
22
+ if (typeof value !== 'string'
23
+ || value.length === 0
24
+ || value.includes('\\')
25
+ || /[\x00-\x1f\x7f]/.test(value)
26
+ || /^[A-Za-z]:/.test(value)) {
27
+ throw docsError('Managed documentation path is invalid');
28
+ }
29
+ const segments = value.split('/');
30
+ if (path.posix.isAbsolute(value)
31
+ || path.posix.normalize(value) !== value
32
+ || segments.some((segment) => !segment || segment === '.' || segment === '..')
33
+ || value === DOCS_MANIFEST_FILE
34
+ || segments[0] === '.funnelsgrove') {
35
+ throw docsError(`Unsafe managed documentation path: ${value}`);
36
+ }
37
+ return value;
38
+ };
39
+ const assertHash = (value, label) => {
40
+ if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
41
+ throw docsError(`${label} must be a lowercase SHA-256 hash`);
42
+ }
43
+ return value;
44
+ };
45
+ const parseManifest = (bytes, expectedContract) => {
46
+ let value;
47
+ try {
48
+ value = JSON.parse(bytes.toString('utf8'));
49
+ }
50
+ catch {
51
+ throw docsError('Documentation manifest is not valid JSON');
52
+ }
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
54
+ throw docsError('Documentation manifest must be an object');
55
+ }
56
+ const raw = value;
57
+ if (raw.schemaVersion !== 1
58
+ || typeof raw.bundleVersion !== 'string'
59
+ || raw.bundleVersion.length === 0
60
+ || typeof raw.stepContractVersion !== 'number'
61
+ || !SUPPORTED_DOCS_CONTRACT_VERSIONS.has(raw.stepContractVersion)
62
+ || (expectedContract !== undefined && raw.stepContractVersion !== expectedContract)
63
+ || !Array.isArray(raw.managedFiles)) {
64
+ throw docsError('Unsupported documentation manifest schema or contract version');
65
+ }
66
+ assertHash(raw.contractHash, 'Contract hash');
67
+ const seen = new Map();
68
+ const managedFiles = raw.managedFiles.map((entry) => {
69
+ if (!entry || typeof entry !== 'object')
70
+ throw docsError('Invalid managed file entry');
71
+ const managed = entry;
72
+ const managedPath = assertManagedPath(managed.path);
73
+ const key = caseKey(managedPath);
74
+ const collision = seen.get(key);
75
+ if (collision)
76
+ throw docsError(`Duplicate or case-colliding paths: ${collision}, ${managedPath}`);
77
+ seen.set(key, managedPath);
78
+ return { path: managedPath, sha256: assertHash(managed.sha256, `Hash for ${managedPath}`) };
79
+ });
80
+ return {
81
+ schemaVersion: 1,
82
+ bundleVersion: raw.bundleVersion,
83
+ stepContractVersion: raw.stepContractVersion,
84
+ contractHash: raw.contractHash,
85
+ managedFiles,
86
+ };
87
+ };
88
+ const readOptionalFile = async (filePath) => {
89
+ try {
90
+ return await readFile(filePath);
91
+ }
92
+ catch (error) {
93
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
94
+ return null;
95
+ throw error;
96
+ }
97
+ };
98
+ const assertNoSymlinkPath = async (root, relativePath) => {
99
+ const rootInfo = await lstat(root).catch((error) => {
100
+ if (error.code === 'ENOENT')
101
+ return null;
102
+ throw error;
103
+ });
104
+ if (rootInfo?.isSymbolicLink())
105
+ throw docsError(`Symbolic link root is not allowed: ${root}`);
106
+ let current = root;
107
+ for (const segment of relativePath.split('/')) {
108
+ let entries;
109
+ try {
110
+ entries = await readdir(current, { withFileTypes: true });
111
+ }
112
+ catch (error) {
113
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
114
+ return;
115
+ throw error;
116
+ }
117
+ const matches = entries.filter((entry) => caseKey(entry.name) === caseKey(segment));
118
+ if (matches.length > 1 || (matches.length === 1 && matches[0].name !== segment)) {
119
+ throw docsError(`Case-colliding target path: ${relativePath}`);
120
+ }
121
+ const match = matches[0];
122
+ if (!match)
123
+ return;
124
+ current = path.join(current, match.name);
125
+ const info = await lstat(current);
126
+ if (info.isSymbolicLink())
127
+ throw docsError(`Symbolic link is not allowed: ${relativePath}`);
128
+ }
129
+ };
130
+ const readManagedTarget = async (root, relativePath) => {
131
+ await assertNoSymlinkPath(root, relativePath);
132
+ const absolutePath = path.join(root, ...relativePath.split('/'));
133
+ let info;
134
+ try {
135
+ info = await lstat(absolutePath);
136
+ }
137
+ catch (error) {
138
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
139
+ return null;
140
+ throw error;
141
+ }
142
+ if (!info.isFile())
143
+ throw docsError(`Managed documentation target is not a file: ${relativePath}`);
144
+ const bytes = await readFile(absolutePath);
145
+ return { bytes, mode: info.mode & 0o777, sha256: hashBytes(bytes) };
146
+ };
147
+ export const loadBundledDocs = async (sourceDir) => {
148
+ const root = path.resolve(sourceDir);
149
+ await assertNoSymlinkPath(root, DOCS_MANIFEST_FILE);
150
+ const manifestBytes = await readOptionalFile(path.join(root, DOCS_MANIFEST_FILE));
151
+ if (!manifestBytes)
152
+ throw docsError(`Missing bundled ${DOCS_MANIFEST_FILE}`);
153
+ const manifest = parseManifest(manifestBytes);
154
+ const files = new Map();
155
+ for (const managed of [...manifest.managedFiles].sort((a, b) => a.path.localeCompare(b.path))) {
156
+ const loaded = await readManagedTarget(root, managed.path);
157
+ if (!loaded || loaded.sha256 !== managed.sha256) {
158
+ throw docsError(`Bundled documentation hash mismatch: ${managed.path}`);
159
+ }
160
+ files.set(managed.path, { ...loaded, path: managed.path });
161
+ }
162
+ return { root, manifest, manifestBytes, files };
163
+ };
164
+ const readPriorManifest = async (targetRoot, contractVersion) => {
165
+ await assertNoSymlinkPath(targetRoot, DOCS_MANIFEST_FILE);
166
+ const bytes = await readOptionalFile(path.join(targetRoot, DOCS_MANIFEST_FILE));
167
+ return bytes ? parseManifest(bytes, contractVersion) : null;
168
+ };
169
+ export const inspectTemplateDocs = async (bundle, targetDir, options = {}) => {
170
+ const targetRoot = path.resolve(targetDir);
171
+ const priorManifest = await readPriorManifest(targetRoot, bundle.manifest.stepContractVersion);
172
+ const prior = new Map((priorManifest?.managedFiles ?? []).map((file) => [file.path, file.sha256]));
173
+ const catalog = new Map();
174
+ for (const entry of options.legacyCatalog ?? []) {
175
+ if (!entry || typeof entry.version !== 'string')
176
+ throw docsError('Invalid legacy docs catalog entry');
177
+ const catalogPath = assertManagedPath(entry.path);
178
+ const catalogHash = assertHash(entry.sha256, `Legacy hash for ${catalogPath}`);
179
+ const hashes = catalog.get(catalogPath) ?? new Set();
180
+ hashes.add(catalogHash);
181
+ catalog.set(catalogPath, hashes);
182
+ }
183
+ const authoritativeByCase = new Map();
184
+ for (const authoritativePath of [...bundle.files.keys(), ...prior.keys()]) {
185
+ const key = caseKey(authoritativePath);
186
+ const collision = authoritativeByCase.get(key);
187
+ if (collision && collision !== authoritativePath) {
188
+ throw docsError(`Case-colliding managed paths: ${collision}, ${authoritativePath}`);
189
+ }
190
+ authoritativeByCase.set(key, authoritativePath);
191
+ }
192
+ const relevantCatalogPaths = [...catalog.keys()].filter((catalogPath) => {
193
+ const authoritativePath = authoritativeByCase.get(caseKey(catalogPath));
194
+ return authoritativePath === undefined || authoritativePath === catalogPath;
195
+ });
196
+ const paths = new Set([...bundle.files.keys(), ...prior.keys(), ...relevantCatalogPaths]);
197
+ const files = [];
198
+ for (const relativePath of [...paths].sort((a, b) => a.localeCompare(b))) {
199
+ const bundled = bundle.files.get(relativePath);
200
+ const priorHash = prior.get(relativePath);
201
+ const current = await readManagedTarget(targetRoot, relativePath);
202
+ if (!bundled && priorHash === undefined && !current)
203
+ continue;
204
+ const base = {
205
+ path: relativePath,
206
+ ...(current ? { currentHash: current.sha256 } : {}),
207
+ ...(bundled ? { bundledHash: bundled.sha256 } : {}),
208
+ ...(priorHash === undefined ? {} : { priorHash }),
209
+ };
210
+ if (bundled) {
211
+ if (!current)
212
+ files.push({ ...base, state: 'missing' });
213
+ else if (current.sha256 === bundled.sha256)
214
+ files.push({ ...base, state: 'current' });
215
+ else if (priorHash !== undefined && current.sha256 === priorHash) {
216
+ files.push({ ...base, state: 'known-legacy-current-path' });
217
+ }
218
+ else if (priorHash === undefined && catalog.get(relativePath)?.has(current.sha256)) {
219
+ files.push({ ...base, state: 'known-legacy-current-path' });
220
+ }
221
+ else
222
+ files.push({ ...base, state: priorHash === undefined ? 'conflict' : 'changed' });
223
+ }
224
+ else if (priorHash !== undefined) {
225
+ files.push({
226
+ ...base,
227
+ state: !current || current.sha256 === priorHash ? 'owned-obsolete' : 'conflict',
228
+ });
229
+ }
230
+ else if (current) {
231
+ files.push({
232
+ ...base,
233
+ state: catalog.get(relativePath)?.has(current.sha256)
234
+ ? 'known-legacy-obsolete'
235
+ : 'conflict',
236
+ });
237
+ }
238
+ }
239
+ return { bundle, targetRoot, priorManifest, files };
240
+ };
241
+ const manifestsMatch = (prior, bundled) => {
242
+ if (!prior)
243
+ return false;
244
+ const priorFiles = prior.managedFiles
245
+ .map((file) => `${file.path}\0${file.sha256}`)
246
+ .sort();
247
+ const bundledFiles = bundled.managedFiles
248
+ .map((file) => `${file.path}\0${file.sha256}`)
249
+ .sort();
250
+ return prior.schemaVersion === bundled.schemaVersion
251
+ && prior.bundleVersion === bundled.bundleVersion
252
+ && prior.stepContractVersion === bundled.stepContractVersion
253
+ && prior.contractHash === bundled.contractHash
254
+ && JSON.stringify(priorFiles) === JSON.stringify(bundledFiles);
255
+ };
256
+ const actionForState = (file, prior, manifestCurrent) => {
257
+ switch (file.state) {
258
+ case 'current':
259
+ return manifestCurrent && prior?.managedFiles.some((managed) => managed.path === file.path && managed.sha256 === file.bundledHash) ? 'none' : 'adopt';
260
+ case 'missing': return 'install';
261
+ case 'known-legacy-current-path': return 'replace-known';
262
+ case 'known-legacy-obsolete': return 'retire-known';
263
+ case 'owned-obsolete': return 'retire-owned';
264
+ case 'changed':
265
+ case 'conflict': return 'conflict';
266
+ }
267
+ };
268
+ export const planTemplateDocsUpdate = (inspection) => {
269
+ const manifestCurrent = manifestsMatch(inspection.priorManifest, inspection.bundle.manifest);
270
+ const files = inspection.files.map((file) => ({
271
+ ...file,
272
+ action: actionForState(file, inspection.priorManifest, manifestCurrent),
273
+ }));
274
+ const diagnostics = files
275
+ .filter((file) => file.action === 'conflict')
276
+ .map((file) => ({
277
+ code: 'FG-DOC-002',
278
+ path: file.path,
279
+ message: `Managed documentation conflicts with local content: ${file.path}`,
280
+ }));
281
+ return {
282
+ bundleVersion: inspection.bundle.manifest.bundleVersion,
283
+ contractVersion: inspection.bundle.manifest.stepContractVersion,
284
+ clean: files.every((file) => file.action === 'none') && manifestCurrent,
285
+ files,
286
+ diagnostics,
287
+ };
288
+ };
289
+ const STATE_DIRECTORY = '.funnelsgrove';
290
+ const TRANSACTION_DIRECTORY = 'docs-transaction';
291
+ const PREPARING_DIRECTORY_PREFIX = `${TRANSACTION_DIRECTORY}-preparing-`;
292
+ const TOMBSTONE_DIRECTORY_PREFIX = `${TRANSACTION_DIRECTORY}-tombstone-`;
293
+ const GARBAGE_DIRECTORY_PREFIX = `${TRANSACTION_DIRECTORY}-garbage-`;
294
+ const RECOVERY_CLAIM_NAME = 'docs-recovery-claim';
295
+ const RECOVERY_CLAIM_PREPARING_PREFIX = `${RECOVERY_CLAIM_NAME}-preparing-`;
296
+ const RECOVERY_CLAIM_OBSERVING_PREFIX = `${RECOVERY_CLAIM_NAME}-observing-`;
297
+ const BACKUPS_DIRECTORY = 'docs-backups';
298
+ const JOURNAL_FILE = 'journal.json';
299
+ const isErrno = (error, code) => error instanceof Error && 'code' in error && error.code === code;
300
+ const fault = async (injector, point) => {
301
+ await injector?.(point);
302
+ };
303
+ const syncDirectory = async (directory) => {
304
+ const handle = await open(directory, 'r');
305
+ try {
306
+ await handle.sync();
307
+ }
308
+ finally {
309
+ await handle.close();
310
+ }
311
+ };
312
+ const writeSyncedFile = async (filePath, bytes, mode) => {
313
+ await mkdir(path.dirname(filePath), { recursive: true });
314
+ const handle = await open(filePath, 'wx', mode);
315
+ try {
316
+ await handle.writeFile(bytes);
317
+ await handle.sync();
318
+ }
319
+ finally {
320
+ await handle.close();
321
+ }
322
+ await chmod(filePath, mode);
323
+ };
324
+ const writeJournal = async (journalPath, journal) => {
325
+ const temporaryPath = `${journalPath}.tmp`;
326
+ await rm(temporaryPath, { force: true });
327
+ await writeSyncedFile(temporaryPath, Buffer.from(`${JSON.stringify(journal, null, 2)}\n`), 0o600);
328
+ await rename(temporaryPath, journalPath);
329
+ await syncDirectory(path.dirname(journalPath));
330
+ };
331
+ const transactionPaths = (targetRoot) => {
332
+ const stateRoot = path.join(targetRoot, STATE_DIRECTORY);
333
+ const transactionRoot = path.join(stateRoot, TRANSACTION_DIRECTORY);
334
+ return {
335
+ stateRoot,
336
+ transactionRoot,
337
+ journalPath: path.join(transactionRoot, JOURNAL_FILE),
338
+ };
339
+ };
340
+ const assertTransactionId = (value) => {
341
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
342
+ throw docsError('Invalid documentation transaction ID');
343
+ }
344
+ return value;
345
+ };
346
+ const assertStateDirectoriesSafe = async (targetRoot) => {
347
+ const rootInfo = await lstat(targetRoot).catch((error) => {
348
+ if (error.code === 'ENOENT')
349
+ return null;
350
+ throw error;
351
+ });
352
+ if (rootInfo?.isSymbolicLink())
353
+ throw docsError('Documentation target root cannot be a symlink');
354
+ const statePath = path.join(targetRoot, STATE_DIRECTORY);
355
+ const stateInfo = await lstat(statePath).catch((error) => {
356
+ if (error.code === 'ENOENT')
357
+ return null;
358
+ throw error;
359
+ });
360
+ if (stateInfo && (!stateInfo.isDirectory() || stateInfo.isSymbolicLink())) {
361
+ throw docsError(`${STATE_DIRECTORY} must be a real directory`);
362
+ }
363
+ const backupsPath = path.join(statePath, BACKUPS_DIRECTORY);
364
+ const backupsInfo = await lstat(backupsPath).catch((error) => {
365
+ if (error.code === 'ENOENT')
366
+ return null;
367
+ throw error;
368
+ });
369
+ if (backupsInfo && (!backupsInfo.isDirectory() || backupsInfo.isSymbolicLink())) {
370
+ throw docsError(`${STATE_DIRECTORY}/${BACKUPS_DIRECTORY} must be a real directory`);
371
+ }
372
+ };
373
+ const artifactInfo = async (absolutePath) => {
374
+ let info;
375
+ try {
376
+ info = await lstat(absolutePath);
377
+ }
378
+ catch (error) {
379
+ if (isErrno(error, 'ENOENT'))
380
+ return null;
381
+ throw error;
382
+ }
383
+ if (!info.isFile() || info.isSymbolicLink())
384
+ return null;
385
+ const bytes = await readFile(absolutePath);
386
+ return { bytes, hash: hashBytes(bytes), mode: info.mode & 0o777 };
387
+ };
388
+ const hashMatches = async (absolutePath, expected) => (await artifactInfo(absolutePath))?.hash === expected;
389
+ const relativeBackupPath = (transactionId, relativePath) => `${STATE_DIRECTORY}/${BACKUPS_DIRECTORY}/${transactionId}/${relativePath}`;
390
+ const absoluteTargetPath = (targetRoot, relativePath) => path.join(targetRoot, ...relativePath.split('/'));
391
+ const absoluteTransactionPath = (transactionRoot, relativePath) => path.join(transactionRoot, ...relativePath.split('/'));
392
+ const beforeMatches = (current, operation) => current?.sha256 === operation.beforeHash || (!current && operation.beforeHash === null);
393
+ const afterMatches = (current, operation) => current?.sha256 === operation.afterHash || (!current && operation.afterHash === null);
394
+ const createOperation = async (bundle, targetRoot, file, transactionId) => {
395
+ const current = await readManagedTarget(targetRoot, file.path);
396
+ const bundled = bundle.files.get(file.path);
397
+ const backupPath = current ? relativeBackupPath(transactionId, file.path) : null;
398
+ if (file.action === 'install') {
399
+ if (current || !bundled || file.bundledHash !== bundled.sha256) {
400
+ throw docsError(`Documentation plan is stale for ${file.path}`);
401
+ }
402
+ return {
403
+ operation: {
404
+ action: 'install',
405
+ path: file.path,
406
+ beforeHash: null,
407
+ afterHash: bundled.sha256,
408
+ stagedPath: `staged/${file.path}`,
409
+ backupPath: null,
410
+ beforeMode: null,
411
+ afterMode: bundled.mode,
412
+ applied: false,
413
+ },
414
+ bytes: bundled.bytes,
415
+ };
416
+ }
417
+ if (file.action === 'replace-known') {
418
+ if (!current || !bundled || current.sha256 !== file.currentHash || file.bundledHash !== bundled.sha256) {
419
+ throw docsError(`Documentation plan is stale for ${file.path}`);
420
+ }
421
+ return {
422
+ operation: {
423
+ action: 'replace-known',
424
+ path: file.path,
425
+ beforeHash: current.sha256,
426
+ afterHash: bundled.sha256,
427
+ stagedPath: `staged/${file.path}`,
428
+ backupPath,
429
+ beforeMode: current.mode,
430
+ afterMode: current.mode,
431
+ applied: false,
432
+ },
433
+ bytes: bundled.bytes,
434
+ };
435
+ }
436
+ if (file.action === 'retire-known' || file.action === 'retire-owned') {
437
+ if (!current)
438
+ return null;
439
+ if (current.sha256 !== file.currentHash)
440
+ throw docsError(`Documentation plan is stale for ${file.path}`);
441
+ return {
442
+ operation: {
443
+ action: file.action,
444
+ path: file.path,
445
+ beforeHash: current.sha256,
446
+ afterHash: null,
447
+ stagedPath: null,
448
+ backupPath,
449
+ beforeMode: current.mode,
450
+ afterMode: null,
451
+ applied: false,
452
+ },
453
+ };
454
+ }
455
+ return null;
456
+ };
457
+ const prepareTransaction = async (bundle, targetRoot, status, options, selectedConflict) => {
458
+ await mkdir(targetRoot, { recursive: true });
459
+ await assertStateDirectoriesSafe(targetRoot);
460
+ const { stateRoot, transactionRoot } = transactionPaths(targetRoot);
461
+ await mkdir(stateRoot, { recursive: true });
462
+ await mkdir(path.join(stateRoot, BACKUPS_DIRECTORY), { recursive: true });
463
+ const transactionId = assertTransactionId(options.transactionId ?? randomUUID());
464
+ const preparingRoot = path.join(stateRoot, `${PREPARING_DIRECTORY_PREFIX}${process.pid}-${transactionId}`);
465
+ const preparingJournalPath = path.join(preparingRoot, JOURNAL_FILE);
466
+ try {
467
+ await mkdir(preparingRoot);
468
+ }
469
+ catch (error) {
470
+ if (isErrno(error, 'EEXIST')) {
471
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', 'A documentation transaction is already being prepared');
472
+ }
473
+ throw error;
474
+ }
475
+ const operationBytes = new Map();
476
+ const operations = [];
477
+ let activated = false;
478
+ try {
479
+ if (selectedConflict) {
480
+ const current = await readManagedTarget(targetRoot, selectedConflict.path);
481
+ const bundled = bundle.files.get(selectedConflict.path);
482
+ if (!current || !bundled || current.sha256 !== selectedConflict.currentHash) {
483
+ throw docsError(`Documentation conflict changed before replacement: ${selectedConflict.path}`);
484
+ }
485
+ operations.push({
486
+ action: 'replace-managed',
487
+ path: selectedConflict.path,
488
+ beforeHash: current.sha256,
489
+ afterHash: bundled.sha256,
490
+ stagedPath: `staged/${selectedConflict.path}`,
491
+ backupPath: relativeBackupPath(transactionId, selectedConflict.path),
492
+ beforeMode: current.mode,
493
+ afterMode: current.mode,
494
+ applied: false,
495
+ });
496
+ operationBytes.set(selectedConflict.path, bundled.bytes);
497
+ }
498
+ else {
499
+ for (const file of status.files) {
500
+ const prepared = await createOperation(bundle, targetRoot, file, transactionId);
501
+ if (!prepared)
502
+ continue;
503
+ operations.push(prepared.operation);
504
+ if (prepared.bytes)
505
+ operationBytes.set(file.path, prepared.bytes);
506
+ }
507
+ }
508
+ let manifest = null;
509
+ let manifestBytes = null;
510
+ if (!selectedConflict) {
511
+ const currentManifest = await readManagedTarget(targetRoot, DOCS_MANIFEST_FILE);
512
+ manifestBytes = bundle.manifestBytes;
513
+ manifest = {
514
+ path: DOCS_MANIFEST_FILE,
515
+ beforeHash: currentManifest?.sha256 ?? null,
516
+ afterHash: hashBytes(manifestBytes),
517
+ stagedPath: `staged/${DOCS_MANIFEST_FILE}`,
518
+ backupPath: currentManifest ? relativeBackupPath(transactionId, DOCS_MANIFEST_FILE) : null,
519
+ beforeMode: currentManifest?.mode ?? null,
520
+ afterMode: currentManifest?.mode ?? 0o644,
521
+ };
522
+ }
523
+ const journal = {
524
+ schemaVersion: 1,
525
+ transactionId,
526
+ ownerPid: process.pid,
527
+ phase: 'prepared',
528
+ targetRoot,
529
+ operations: operations.sort((left, right) => left.path.localeCompare(right.path)),
530
+ manifest,
531
+ };
532
+ await writeJournal(preparingJournalPath, journal);
533
+ try {
534
+ await rename(preparingRoot, transactionRoot);
535
+ }
536
+ catch (error) {
537
+ if (isErrno(error, 'EEXIST') || isErrno(error, 'ENOTEMPTY')) {
538
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', 'An unfinished documentation transaction already exists');
539
+ }
540
+ throw error;
541
+ }
542
+ activated = true;
543
+ await syncDirectory(stateRoot);
544
+ return {
545
+ journal,
546
+ transactionRoot,
547
+ journalPath: path.join(transactionRoot, JOURNAL_FILE),
548
+ operationBytes,
549
+ manifestBytes,
550
+ };
551
+ }
552
+ catch (error) {
553
+ if (!activated)
554
+ await rm(preparingRoot, { recursive: true, force: true });
555
+ throw error;
556
+ }
557
+ };
558
+ const stagedArtifacts = (prepared) => {
559
+ const artifacts = prepared.journal.operations.flatMap((operation) => {
560
+ if (!operation.stagedPath || operation.afterMode === null)
561
+ return [];
562
+ const bytes = prepared.operationBytes.get(operation.path);
563
+ if (!bytes)
564
+ throw docsError(`Missing staged bytes for ${operation.path}`);
565
+ return [{
566
+ relativePath: operation.path,
567
+ path: operation.stagedPath,
568
+ bytes,
569
+ mode: operation.afterMode,
570
+ }];
571
+ });
572
+ if (prepared.journal.manifest && prepared.manifestBytes) {
573
+ artifacts.push({
574
+ relativePath: DOCS_MANIFEST_FILE,
575
+ path: prepared.journal.manifest.stagedPath,
576
+ bytes: prepared.manifestBytes,
577
+ mode: prepared.journal.manifest.afterMode,
578
+ });
579
+ }
580
+ return artifacts;
581
+ };
582
+ const backupArtifacts = async (prepared) => {
583
+ const artifacts = [];
584
+ for (const operation of prepared.journal.operations) {
585
+ if (!operation.backupPath || operation.beforeHash === null || operation.beforeMode === null)
586
+ continue;
587
+ const current = await readManagedTarget(prepared.journal.targetRoot, operation.path);
588
+ if (!current || current.sha256 !== operation.beforeHash) {
589
+ throw docsError(`Documentation target changed while preparing: ${operation.path}`);
590
+ }
591
+ artifacts.push({
592
+ relativePath: operation.path,
593
+ path: operation.backupPath,
594
+ bytes: current.bytes,
595
+ mode: operation.beforeMode,
596
+ });
597
+ }
598
+ const manifest = prepared.journal.manifest;
599
+ if (manifest?.backupPath && manifest.beforeHash !== null && manifest.beforeMode !== null) {
600
+ const current = await readManagedTarget(prepared.journal.targetRoot, DOCS_MANIFEST_FILE);
601
+ if (!current || current.sha256 !== manifest.beforeHash) {
602
+ throw docsError(`Documentation target changed while preparing: ${DOCS_MANIFEST_FILE}`);
603
+ }
604
+ artifacts.push({
605
+ relativePath: DOCS_MANIFEST_FILE,
606
+ path: manifest.backupPath,
607
+ bytes: current.bytes,
608
+ mode: manifest.beforeMode,
609
+ });
610
+ }
611
+ return artifacts;
612
+ };
613
+ const writeArtifactTemps = async (root, artifacts) => {
614
+ for (const artifact of artifacts) {
615
+ const finalPath = absoluteTransactionPath(root, artifact.path);
616
+ await writeSyncedFile(`${finalPath}.tmp`, artifact.bytes, artifact.mode);
617
+ }
618
+ };
619
+ const finalizeArtifacts = async (root, artifacts, operation, injector) => {
620
+ for (const artifact of artifacts) {
621
+ const finalPath = absoluteTransactionPath(root, artifact.path);
622
+ await fault(injector, { operation, timing: 'before', path: artifact.relativePath });
623
+ await rename(`${finalPath}.tmp`, finalPath);
624
+ await syncDirectory(path.dirname(finalPath));
625
+ await fault(injector, { operation, timing: 'after', path: artifact.relativePath });
626
+ }
627
+ };
628
+ const prepareArtifacts = async (prepared, injector) => {
629
+ const stages = stagedArtifacts(prepared);
630
+ await writeArtifactTemps(prepared.transactionRoot, stages);
631
+ await finalizeArtifacts(prepared.transactionRoot, stages, 'stage', injector);
632
+ const backups = await backupArtifacts(prepared);
633
+ await writeArtifactTemps(prepared.journal.targetRoot, backups);
634
+ await finalizeArtifacts(prepared.journal.targetRoot, backups, 'backup', injector);
635
+ };
636
+ const assertJournalArtifactPathsSafe = async (transactionRoot, journal) => {
637
+ for (const operation of journal.operations) {
638
+ if (operation.stagedPath) {
639
+ await assertNoSymlinkPath(transactionRoot, operation.stagedPath);
640
+ await assertNoSymlinkPath(transactionRoot, `${operation.stagedPath}.tmp`);
641
+ }
642
+ await assertNoSymlinkPath(transactionRoot, `retired/${operation.path}`);
643
+ if (operation.backupPath) {
644
+ await assertNoSymlinkPath(journal.targetRoot, operation.backupPath);
645
+ await assertNoSymlinkPath(journal.targetRoot, `${operation.backupPath}.tmp`);
646
+ }
647
+ }
648
+ const manifest = journal.manifest;
649
+ if (manifest) {
650
+ await assertNoSymlinkPath(transactionRoot, manifest.stagedPath);
651
+ await assertNoSymlinkPath(transactionRoot, `${manifest.stagedPath}.tmp`);
652
+ if (manifest.backupPath) {
653
+ await assertNoSymlinkPath(journal.targetRoot, manifest.backupPath);
654
+ await assertNoSymlinkPath(journal.targetRoot, `${manifest.backupPath}.tmp`);
655
+ }
656
+ }
657
+ };
658
+ const assertTargetHash = async (targetRoot, operation) => {
659
+ const current = await readManagedTarget(targetRoot, operation.path);
660
+ if (!beforeMatches(current, operation) && !afterMatches(current, operation)) {
661
+ throw new DocsTransactionError('FG-DOC-RECOVERY', `Unexpected documentation target while applying: ${operation.path}`);
662
+ }
663
+ return current;
664
+ };
665
+ const applyOperation = async (transactionRoot, journalPath, journal, operation, injector) => {
666
+ const targetRoot = journal.targetRoot;
667
+ const current = await assertTargetHash(targetRoot, operation);
668
+ if (afterMatches(current, operation)) {
669
+ operation.applied = true;
670
+ await writeJournal(journalPath, journal);
671
+ return;
672
+ }
673
+ const targetPath = absoluteTargetPath(targetRoot, operation.path);
674
+ await assertNoSymlinkPath(targetRoot, operation.path);
675
+ if (operation.afterHash === null) {
676
+ const retiredPath = absoluteTransactionPath(transactionRoot, `retired/${operation.path}`);
677
+ await mkdir(path.dirname(retiredPath), { recursive: true });
678
+ await fault(injector, { operation: 'apply', timing: 'before', path: operation.path });
679
+ const latest = await assertTargetHash(targetRoot, operation);
680
+ if (afterMatches(latest, operation)) {
681
+ operation.applied = true;
682
+ await writeJournal(journalPath, journal);
683
+ return;
684
+ }
685
+ await rename(targetPath, retiredPath);
686
+ }
687
+ else {
688
+ if (!operation.stagedPath)
689
+ throw docsError(`Missing staged path for ${operation.path}`);
690
+ const stagedPath = absoluteTransactionPath(transactionRoot, operation.stagedPath);
691
+ if (!(await hashMatches(stagedPath, operation.afterHash))) {
692
+ throw new DocsTransactionError('FG-DOC-RECOVERY', `Invalid staged file: ${operation.path}`);
693
+ }
694
+ await mkdir(path.dirname(targetPath), { recursive: true });
695
+ await fault(injector, { operation: 'apply', timing: 'before', path: operation.path });
696
+ const latest = await assertTargetHash(targetRoot, operation);
697
+ if (afterMatches(latest, operation)) {
698
+ operation.applied = true;
699
+ await writeJournal(journalPath, journal);
700
+ return;
701
+ }
702
+ await rename(stagedPath, targetPath);
703
+ if (operation.afterMode !== null)
704
+ await chmod(targetPath, operation.afterMode);
705
+ }
706
+ await syncDirectory(path.dirname(targetPath));
707
+ await fault(injector, { operation: 'apply', timing: 'after', path: operation.path });
708
+ operation.applied = true;
709
+ await writeJournal(journalPath, journal);
710
+ };
711
+ const moveStateDirectoryToGarbage = async (stateRoot, sourcePath) => {
712
+ const garbagePath = path.join(stateRoot, `${GARBAGE_DIRECTORY_PREFIX}${randomUUID()}`);
713
+ try {
714
+ await rename(sourcePath, garbagePath);
715
+ }
716
+ catch (error) {
717
+ if (isErrno(error, 'ENOENT'))
718
+ return false;
719
+ throw error;
720
+ }
721
+ await syncDirectory(stateRoot);
722
+ await rm(garbagePath, { recursive: true, force: true });
723
+ await syncDirectory(stateRoot);
724
+ return true;
725
+ };
726
+ const removeTransaction = async (transactionRoot, journal) => {
727
+ const stateRoot = path.dirname(transactionRoot);
728
+ const tombstonePath = path.join(stateRoot, `${TOMBSTONE_DIRECTORY_PREFIX}${journal.transactionId}`);
729
+ const existingTombstone = await lstat(tombstonePath).catch((error) => {
730
+ if (error.code === 'ENOENT')
731
+ return null;
732
+ throw error;
733
+ });
734
+ if (existingTombstone) {
735
+ throw recoveryError(`Documentation transaction tombstone already exists: ${journal.transactionId}`);
736
+ }
737
+ await rename(transactionRoot, tombstonePath);
738
+ await syncDirectory(stateRoot);
739
+ if (!(await moveStateDirectoryToGarbage(stateRoot, tombstonePath))) {
740
+ throw recoveryError(`Documentation transaction tombstone disappeared: ${journal.transactionId}`);
741
+ }
742
+ };
743
+ const commitManifest = async (transactionRoot, journalPath, journal, injector) => {
744
+ const assertAppliedTargetsMatch = async () => {
745
+ for (const operation of journal.operations) {
746
+ const current = await readManagedTarget(journal.targetRoot, operation.path);
747
+ if (!operation.applied || !afterMatches(current, operation)) {
748
+ throw new DocsTransactionError('FG-DOC-RECOVERY', `Documentation target changed before manifest commit: ${operation.path}`);
749
+ }
750
+ }
751
+ };
752
+ const manifest = journal.manifest;
753
+ if (!manifest) {
754
+ await assertAppliedTargetsMatch();
755
+ await removeTransaction(transactionRoot, journal);
756
+ return;
757
+ }
758
+ journal.phase = 'manifest-pending';
759
+ await writeJournal(journalPath, journal);
760
+ let current = await readManagedTarget(journal.targetRoot, DOCS_MANIFEST_FILE);
761
+ if (current?.sha256 === manifest.afterHash) {
762
+ await assertAppliedTargetsMatch();
763
+ await removeTransaction(transactionRoot, journal);
764
+ return;
765
+ }
766
+ if (current?.sha256 !== manifest.beforeHash && !(current === null && manifest.beforeHash === null)) {
767
+ throw new DocsTransactionError('FG-DOC-RECOVERY', 'Unexpected documentation manifest');
768
+ }
769
+ const stagedPath = absoluteTransactionPath(transactionRoot, manifest.stagedPath);
770
+ if (!(await hashMatches(stagedPath, manifest.afterHash))) {
771
+ throw new DocsTransactionError('FG-DOC-RECOVERY', 'Invalid staged documentation manifest');
772
+ }
773
+ const targetPath = path.join(journal.targetRoot, DOCS_MANIFEST_FILE);
774
+ await fault(injector, { operation: 'manifest', timing: 'before', path: DOCS_MANIFEST_FILE });
775
+ await assertAppliedTargetsMatch();
776
+ if (!(await hashMatches(stagedPath, manifest.afterHash))) {
777
+ throw new DocsTransactionError('FG-DOC-RECOVERY', 'Invalid staged documentation manifest');
778
+ }
779
+ current = await readManagedTarget(journal.targetRoot, DOCS_MANIFEST_FILE);
780
+ if (current?.sha256 === manifest.afterHash) {
781
+ await removeTransaction(transactionRoot, journal);
782
+ return;
783
+ }
784
+ if (current?.sha256 !== manifest.beforeHash && !(current === null && manifest.beforeHash === null)) {
785
+ throw new DocsTransactionError('FG-DOC-RECOVERY', 'Unexpected documentation manifest');
786
+ }
787
+ await rename(stagedPath, targetPath);
788
+ await chmod(targetPath, manifest.afterMode);
789
+ await syncDirectory(journal.targetRoot);
790
+ await fault(injector, { operation: 'manifest', timing: 'after', path: DOCS_MANIFEST_FILE });
791
+ await removeTransaction(transactionRoot, journal);
792
+ };
793
+ const executePreparedTransaction = async (prepared, injector) => {
794
+ await assertJournalArtifactPathsSafe(prepared.transactionRoot, prepared.journal);
795
+ await prepareArtifacts(prepared, injector);
796
+ prepared.journal.phase = 'applying';
797
+ await writeJournal(prepared.journalPath, prepared.journal);
798
+ for (const operation of prepared.journal.operations) {
799
+ await applyOperation(prepared.transactionRoot, prepared.journalPath, prepared.journal, operation, injector);
800
+ }
801
+ await commitManifest(prepared.transactionRoot, prepared.journalPath, prepared.journal, injector);
802
+ };
803
+ const relinquishTransactionOwnership = async (prepared) => {
804
+ const stateRoot = path.dirname(prepared.transactionRoot);
805
+ const tombstonePath = path.join(stateRoot, `${TOMBSTONE_DIRECTORY_PREFIX}${prepared.journal.transactionId}`);
806
+ for (const transactionPath of [prepared.transactionRoot, tombstonePath]) {
807
+ const info = await lstat(transactionPath).catch((error) => {
808
+ if (error.code === 'ENOENT')
809
+ return null;
810
+ throw error;
811
+ });
812
+ if (!info)
813
+ continue;
814
+ if (!info.isDirectory() || info.isSymbolicLink()) {
815
+ throw recoveryError('Cannot safely relinquish documentation transaction ownership');
816
+ }
817
+ prepared.journal.ownerPid = null;
818
+ await writeJournal(path.join(transactionPath, JOURNAL_FILE), prepared.journal);
819
+ return;
820
+ }
821
+ };
822
+ const executeOwnedTransaction = async (prepared, injector) => {
823
+ try {
824
+ await executePreparedTransaction(prepared, injector);
825
+ }
826
+ catch (error) {
827
+ await relinquishTransactionOwnership(prepared);
828
+ throw error;
829
+ }
830
+ };
831
+ const assertNoOpenTransaction = async (targetRoot) => {
832
+ const { stateRoot, transactionRoot } = transactionPaths(targetRoot);
833
+ const existing = await lstat(transactionRoot).catch((error) => {
834
+ if (error.code === 'ENOENT')
835
+ return null;
836
+ throw error;
837
+ });
838
+ if (existing) {
839
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', 'An unfinished documentation transaction already exists');
840
+ }
841
+ const residue = (await listStateEntries(stateRoot)).find((entry) => entry.startsWith(PREPARING_DIRECTORY_PREFIX)
842
+ || entry.startsWith(TOMBSTONE_DIRECTORY_PREFIX)
843
+ || entry.startsWith(GARBAGE_DIRECTORY_PREFIX)
844
+ || entry.startsWith(RECOVERY_CLAIM_PREPARING_PREFIX)
845
+ || entry.startsWith(RECOVERY_CLAIM_OBSERVING_PREFIX)
846
+ || entry === RECOVERY_CLAIM_NAME);
847
+ if (residue) {
848
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', `Documentation transaction recovery is required before continuing: ${residue}`);
849
+ }
850
+ };
851
+ const validateStatus = (bundle, status) => {
852
+ if (status.bundleVersion !== bundle.manifest.bundleVersion
853
+ || status.contractVersion !== bundle.manifest.stepContractVersion) {
854
+ throw docsError('Documentation status does not match the bundled documentation');
855
+ }
856
+ const seen = new Set();
857
+ for (const file of status.files) {
858
+ assertManagedPath(file.path);
859
+ const key = caseKey(file.path);
860
+ if (seen.has(key))
861
+ throw docsError(`Duplicate documentation status path: ${file.path}`);
862
+ seen.add(key);
863
+ }
864
+ };
865
+ const docsStatusSignature = (status) => JSON.stringify({
866
+ bundleVersion: status.bundleVersion,
867
+ contractVersion: status.contractVersion,
868
+ clean: status.clean,
869
+ files: [...status.files].sort((left, right) => left.path.localeCompare(right.path)),
870
+ diagnostics: [...status.diagnostics].sort((left, right) => left.code.localeCompare(right.code)
871
+ || (left.path ?? '').localeCompare(right.path ?? '')
872
+ || left.message.localeCompare(right.message)),
873
+ });
874
+ export const applyTemplateDocsUpdate = async (bundle, targetDir, status, options = {}) => {
875
+ const targetRoot = path.resolve(targetDir);
876
+ await assertNoOpenTransaction(targetRoot);
877
+ validateStatus(bundle, status);
878
+ const currentStatus = planTemplateDocsUpdate(await inspectTemplateDocs(bundle, targetRoot, {
879
+ legacyCatalog: options.legacyCatalog,
880
+ }));
881
+ if (docsStatusSignature(status) !== docsStatusSignature(currentStatus)) {
882
+ throw docsError('Documentation update plan is stale or was not produced by current inspection');
883
+ }
884
+ if (currentStatus.files.some((file) => file.action === 'conflict')) {
885
+ throw new DocsTransactionError('FG-DOC-002', 'Documentation conflicts must be resolved explicitly');
886
+ }
887
+ if (currentStatus.clean)
888
+ return;
889
+ const prepared = await prepareTransaction(bundle, targetRoot, currentStatus, options);
890
+ await executeOwnedTransaction(prepared, options.faultInjector);
891
+ };
892
+ const recoveryError = (message) => new DocsTransactionError('FG-DOC-RECOVERY', message);
893
+ const parseNullableHash = (value, label) => {
894
+ if (value === null)
895
+ return null;
896
+ try {
897
+ return assertHash(value, label);
898
+ }
899
+ catch {
900
+ throw recoveryError(`Corrupt journal ${label}`);
901
+ }
902
+ };
903
+ const parseNullableMode = (value, label) => {
904
+ if (value === null)
905
+ return null;
906
+ if (!Number.isInteger(value) || value < 0 || value > 0o777) {
907
+ throw recoveryError(`Corrupt journal ${label}`);
908
+ }
909
+ return value;
910
+ };
911
+ const parseJournal = (bytes, expectedTargetRoot) => {
912
+ let value;
913
+ try {
914
+ value = JSON.parse(bytes.toString('utf8'));
915
+ }
916
+ catch {
917
+ throw recoveryError('Documentation transaction journal is corrupt');
918
+ }
919
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
920
+ throw recoveryError('Documentation transaction journal is corrupt');
921
+ }
922
+ const raw = value;
923
+ if (raw.schemaVersion !== 1
924
+ || typeof raw.transactionId !== 'string'
925
+ || !['prepared', 'applying', 'manifest-pending', 'restoring'].includes(String(raw.phase))
926
+ || raw.targetRoot !== expectedTargetRoot
927
+ || !Array.isArray(raw.operations)) {
928
+ throw recoveryError('Unsupported or mismatched documentation transaction journal');
929
+ }
930
+ let transactionId;
931
+ try {
932
+ transactionId = assertTransactionId(raw.transactionId);
933
+ }
934
+ catch {
935
+ throw recoveryError('Invalid documentation transaction ID in journal');
936
+ }
937
+ const ownerPid = raw.ownerPid === null
938
+ ? null
939
+ : Number.isSafeInteger(raw.ownerPid) && raw.ownerPid > 0
940
+ ? raw.ownerPid
941
+ : undefined;
942
+ if (ownerPid === undefined) {
943
+ throw recoveryError('Invalid documentation transaction owner in journal');
944
+ }
945
+ const seen = new Set();
946
+ const allowedActions = new Set([
947
+ 'install',
948
+ 'replace-known',
949
+ 'replace-managed',
950
+ 'retire-known',
951
+ 'retire-owned',
952
+ ]);
953
+ const operations = raw.operations.map((entry, index) => {
954
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
955
+ throw recoveryError(`Corrupt journal operation ${index}`);
956
+ }
957
+ const operation = entry;
958
+ let operationPath;
959
+ try {
960
+ operationPath = assertManagedPath(operation.path);
961
+ }
962
+ catch {
963
+ throw recoveryError(`Unsafe journal operation path at index ${index}`);
964
+ }
965
+ const key = caseKey(operationPath);
966
+ if (seen.has(key))
967
+ throw recoveryError(`Duplicate journal operation path: ${operationPath}`);
968
+ seen.add(key);
969
+ if (!allowedActions.has(operation.action) || typeof operation.applied !== 'boolean') {
970
+ throw recoveryError(`Corrupt journal operation ${operationPath}`);
971
+ }
972
+ const beforeHash = parseNullableHash(operation.beforeHash, `${operationPath} beforeHash`);
973
+ const afterHash = parseNullableHash(operation.afterHash, `${operationPath} afterHash`);
974
+ const beforeMode = parseNullableMode(operation.beforeMode, `${operationPath} beforeMode`);
975
+ const afterMode = parseNullableMode(operation.afterMode, `${operationPath} afterMode`);
976
+ const action = operation.action;
977
+ const hasBefore = [
978
+ beforeHash !== null,
979
+ beforeMode !== null,
980
+ operation.backupPath != null,
981
+ ];
982
+ const hasAfter = [
983
+ afterHash !== null,
984
+ afterMode !== null,
985
+ operation.stagedPath != null,
986
+ ];
987
+ const allBefore = hasBefore.every(Boolean);
988
+ const noBefore = hasBefore.every((present) => !present);
989
+ const allAfter = hasAfter.every(Boolean);
990
+ const noAfter = hasAfter.every((present) => !present);
991
+ const validSemantics = action === 'install'
992
+ ? noBefore && allAfter
993
+ : action === 'replace-known' || action === 'replace-managed'
994
+ ? allBefore && allAfter && beforeHash !== afterHash
995
+ : allBefore && noAfter;
996
+ if (!validSemantics) {
997
+ throw recoveryError(`Corrupt journal operation semantics for ${operationPath}`);
998
+ }
999
+ const expectedStaged = afterHash === null ? null : `staged/${operationPath}`;
1000
+ const expectedBackup = beforeHash === null
1001
+ ? null
1002
+ : relativeBackupPath(transactionId, operationPath);
1003
+ if (operation.stagedPath !== expectedStaged || operation.backupPath !== expectedBackup) {
1004
+ throw recoveryError(`Corrupt journal artifact path for ${operationPath}`);
1005
+ }
1006
+ return {
1007
+ action,
1008
+ path: operationPath,
1009
+ beforeHash,
1010
+ afterHash,
1011
+ stagedPath: expectedStaged,
1012
+ backupPath: expectedBackup,
1013
+ beforeMode,
1014
+ afterMode,
1015
+ applied: operation.applied,
1016
+ };
1017
+ });
1018
+ let manifest = null;
1019
+ if (raw.manifest !== null) {
1020
+ if (!raw.manifest || typeof raw.manifest !== 'object' || Array.isArray(raw.manifest)) {
1021
+ throw recoveryError('Corrupt journal manifest operation');
1022
+ }
1023
+ const entry = raw.manifest;
1024
+ const beforeHash = parseNullableHash(entry.beforeHash, 'manifest beforeHash');
1025
+ const afterHash = parseNullableHash(entry.afterHash, 'manifest afterHash');
1026
+ const beforeMode = parseNullableMode(entry.beforeMode, 'manifest beforeMode');
1027
+ const afterMode = parseNullableMode(entry.afterMode, 'manifest afterMode');
1028
+ const beforePresence = [
1029
+ beforeHash !== null,
1030
+ beforeMode !== null,
1031
+ entry.backupPath != null,
1032
+ ];
1033
+ const beforeSemanticsValid = beforePresence.every(Boolean)
1034
+ || beforePresence.every((present) => !present);
1035
+ const afterSemanticsValid = afterHash !== null
1036
+ && afterMode !== null
1037
+ && entry.stagedPath != null;
1038
+ if (!beforeSemanticsValid || !afterSemanticsValid) {
1039
+ throw recoveryError('Corrupt journal manifest semantics');
1040
+ }
1041
+ const expectedBackup = beforeHash === null
1042
+ ? null
1043
+ : relativeBackupPath(transactionId, DOCS_MANIFEST_FILE);
1044
+ if (entry.path !== DOCS_MANIFEST_FILE
1045
+ || afterHash === null
1046
+ || afterMode === null
1047
+ || entry.stagedPath !== `staged/${DOCS_MANIFEST_FILE}`
1048
+ || entry.backupPath !== expectedBackup) {
1049
+ throw recoveryError('Corrupt journal manifest operation');
1050
+ }
1051
+ manifest = {
1052
+ path: DOCS_MANIFEST_FILE,
1053
+ beforeHash,
1054
+ afterHash,
1055
+ stagedPath: `staged/${DOCS_MANIFEST_FILE}`,
1056
+ backupPath: expectedBackup,
1057
+ beforeMode,
1058
+ afterMode,
1059
+ };
1060
+ }
1061
+ const conflictReplacement = manifest === null
1062
+ && operations.length === 1
1063
+ && operations[0].action === 'replace-managed';
1064
+ const normalUpdate = manifest !== null
1065
+ && operations.every((operation) => operation.action !== 'replace-managed');
1066
+ if (!conflictReplacement && !normalUpdate) {
1067
+ throw recoveryError('Corrupt journal transaction semantics');
1068
+ }
1069
+ if (raw.phase === 'prepared' && operations.some((operation) => operation.applied)) {
1070
+ throw recoveryError('Corrupt journal phase semantics');
1071
+ }
1072
+ if (raw.phase === 'manifest-pending'
1073
+ && (manifest === null || operations.some((operation) => !operation.applied))) {
1074
+ throw recoveryError('Corrupt journal phase semantics');
1075
+ }
1076
+ return {
1077
+ schemaVersion: 1,
1078
+ transactionId,
1079
+ ownerPid,
1080
+ phase: raw.phase,
1081
+ targetRoot: expectedTargetRoot,
1082
+ operations,
1083
+ manifest,
1084
+ };
1085
+ };
1086
+ const artifactOrTemporaryMatches = async (absolutePath, expectedHash) => await hashMatches(absolutePath, expectedHash)
1087
+ || await hashMatches(`${absolutePath}.tmp`, expectedHash);
1088
+ const ensureArtifactFinal = async (absolutePath, expectedHash) => {
1089
+ if (await hashMatches(absolutePath, expectedHash))
1090
+ return;
1091
+ const temporaryPath = `${absolutePath}.tmp`;
1092
+ if (!(await hashMatches(temporaryPath, expectedHash))) {
1093
+ throw recoveryError(`Missing or invalid transaction artifact: ${absolutePath}`);
1094
+ }
1095
+ await rename(temporaryPath, absolutePath);
1096
+ await syncDirectory(path.dirname(absolutePath));
1097
+ };
1098
+ const readManifestTarget = async (targetRoot) => readManagedTarget(targetRoot, DOCS_MANIFEST_FILE);
1099
+ const canRecoverForward = async (transactionRoot, journal) => {
1100
+ for (const operation of journal.operations) {
1101
+ const current = await readManagedTarget(journal.targetRoot, operation.path);
1102
+ if (operation.applied && !afterMatches(current, operation))
1103
+ return false;
1104
+ if (afterMatches(current, operation)) {
1105
+ if (operation.beforeHash !== null
1106
+ && operation.backupPath
1107
+ && !(await artifactOrTemporaryMatches(absoluteTargetPath(journal.targetRoot, operation.backupPath), operation.beforeHash)))
1108
+ return false;
1109
+ continue;
1110
+ }
1111
+ if (!beforeMatches(current, operation))
1112
+ return false;
1113
+ if (operation.afterHash !== null
1114
+ && operation.stagedPath
1115
+ && !(await artifactOrTemporaryMatches(absoluteTransactionPath(transactionRoot, operation.stagedPath), operation.afterHash)))
1116
+ return false;
1117
+ }
1118
+ const manifest = journal.manifest;
1119
+ if (manifest) {
1120
+ const current = await readManifestTarget(journal.targetRoot);
1121
+ if (current?.sha256 === manifest.afterHash) {
1122
+ if (manifest.beforeHash !== null
1123
+ && manifest.backupPath
1124
+ && !(await artifactOrTemporaryMatches(absoluteTargetPath(journal.targetRoot, manifest.backupPath), manifest.beforeHash)))
1125
+ return false;
1126
+ }
1127
+ else {
1128
+ if (current?.sha256 !== manifest.beforeHash && !(current === null && manifest.beforeHash === null)) {
1129
+ return false;
1130
+ }
1131
+ if (!(await artifactOrTemporaryMatches(absoluteTransactionPath(transactionRoot, manifest.stagedPath), manifest.afterHash)))
1132
+ return false;
1133
+ }
1134
+ }
1135
+ return true;
1136
+ };
1137
+ const backupMatches = async (targetRoot, backupPath, beforeHash) => beforeHash === null || Boolean(backupPath
1138
+ && await artifactOrTemporaryMatches(absoluteTargetPath(targetRoot, backupPath), beforeHash));
1139
+ const canRestore = async (journal) => {
1140
+ for (const operation of journal.operations) {
1141
+ const current = await readManagedTarget(journal.targetRoot, operation.path);
1142
+ if (beforeMatches(current, operation))
1143
+ continue;
1144
+ if (!afterMatches(current, operation))
1145
+ return false;
1146
+ if (!(await backupMatches(journal.targetRoot, operation.backupPath, operation.beforeHash))) {
1147
+ return false;
1148
+ }
1149
+ }
1150
+ const manifest = journal.manifest;
1151
+ if (manifest) {
1152
+ const current = await readManifestTarget(journal.targetRoot);
1153
+ if (current?.sha256 === manifest.beforeHash || (current === null && manifest.beforeHash === null)) {
1154
+ return true;
1155
+ }
1156
+ if (current?.sha256 !== manifest.afterHash)
1157
+ return false;
1158
+ return backupMatches(journal.targetRoot, manifest.backupPath, manifest.beforeHash);
1159
+ }
1160
+ return true;
1161
+ };
1162
+ const ensureBackup = async (journal, relativePath, backupPath, beforeHash, beforeMode) => {
1163
+ if (beforeHash === null || !backupPath || beforeMode === null)
1164
+ return;
1165
+ const absoluteBackup = absoluteTargetPath(journal.targetRoot, backupPath);
1166
+ if (await artifactOrTemporaryMatches(absoluteBackup, beforeHash)) {
1167
+ await ensureArtifactFinal(absoluteBackup, beforeHash);
1168
+ return;
1169
+ }
1170
+ const current = await readManagedTarget(journal.targetRoot, relativePath);
1171
+ if (!current || current.sha256 !== beforeHash) {
1172
+ throw recoveryError(`Cannot recreate documentation backup: ${relativePath}`);
1173
+ }
1174
+ await writeSyncedFile(`${absoluteBackup}.tmp`, current.bytes, beforeMode);
1175
+ await ensureArtifactFinal(absoluteBackup, beforeHash);
1176
+ };
1177
+ const continueForward = async (transactionRoot, journalPath, journal) => {
1178
+ for (const operation of journal.operations) {
1179
+ await ensureBackup(journal, operation.path, operation.backupPath, operation.beforeHash, operation.beforeMode);
1180
+ const current = await readManagedTarget(journal.targetRoot, operation.path);
1181
+ if (!afterMatches(current, operation)
1182
+ && operation.afterHash !== null
1183
+ && operation.stagedPath) {
1184
+ await ensureArtifactFinal(absoluteTransactionPath(transactionRoot, operation.stagedPath), operation.afterHash);
1185
+ }
1186
+ }
1187
+ const manifest = journal.manifest;
1188
+ if (manifest) {
1189
+ await ensureBackup(journal, DOCS_MANIFEST_FILE, manifest.backupPath, manifest.beforeHash, manifest.beforeMode);
1190
+ const current = await readManifestTarget(journal.targetRoot);
1191
+ if (current?.sha256 !== manifest.afterHash) {
1192
+ await ensureArtifactFinal(absoluteTransactionPath(transactionRoot, manifest.stagedPath), manifest.afterHash);
1193
+ }
1194
+ }
1195
+ journal.phase = 'applying';
1196
+ await writeJournal(journalPath, journal);
1197
+ for (const operation of journal.operations) {
1198
+ await applyOperation(transactionRoot, journalPath, journal, operation);
1199
+ }
1200
+ await commitManifest(transactionRoot, journalPath, journal);
1201
+ };
1202
+ const restoreOne = async (journal, relativePath, beforeHash, beforeMode, afterHash, backupPath) => {
1203
+ const current = await readManagedTarget(journal.targetRoot, relativePath);
1204
+ if (current?.sha256 === beforeHash || (!current && beforeHash === null))
1205
+ return;
1206
+ if (current?.sha256 !== afterHash && !(!current && afterHash === null)) {
1207
+ throw recoveryError(`Cannot safely restore unexpected target: ${relativePath}`);
1208
+ }
1209
+ const targetPath = absoluteTargetPath(journal.targetRoot, relativePath);
1210
+ if (beforeHash === null) {
1211
+ await rm(targetPath, { force: true });
1212
+ }
1213
+ else {
1214
+ if (!backupPath || beforeMode === null)
1215
+ throw recoveryError(`Missing backup: ${relativePath}`);
1216
+ const backup = absoluteTargetPath(journal.targetRoot, backupPath);
1217
+ await ensureArtifactFinal(backup, beforeHash);
1218
+ const contents = await artifactInfo(backup);
1219
+ if (!contents || contents.hash !== beforeHash)
1220
+ throw recoveryError(`Invalid backup: ${relativePath}`);
1221
+ await mkdir(path.dirname(targetPath), { recursive: true });
1222
+ const temporaryTarget = `${targetPath}.docs-restore-${journal.transactionId}`;
1223
+ await rm(temporaryTarget, { force: true });
1224
+ await writeSyncedFile(temporaryTarget, contents.bytes, beforeMode);
1225
+ await rename(temporaryTarget, targetPath);
1226
+ await chmod(targetPath, beforeMode);
1227
+ }
1228
+ await syncDirectory(path.dirname(targetPath));
1229
+ };
1230
+ const restorePriorGeneration = async (transactionRoot, journalPath, journal) => {
1231
+ journal.phase = 'restoring';
1232
+ await writeJournal(journalPath, journal);
1233
+ for (const operation of [...journal.operations].reverse()) {
1234
+ await restoreOne(journal, operation.path, operation.beforeHash, operation.beforeMode, operation.afterHash, operation.backupPath);
1235
+ }
1236
+ const manifest = journal.manifest;
1237
+ if (manifest) {
1238
+ await restoreOne(journal, DOCS_MANIFEST_FILE, manifest.beforeHash, manifest.beforeMode, manifest.afterHash, manifest.backupPath);
1239
+ }
1240
+ await removeTransaction(transactionRoot, journal);
1241
+ };
1242
+ const listStateEntries = async (stateRoot) => {
1243
+ try {
1244
+ return (await readdir(stateRoot, { withFileTypes: true }))
1245
+ .map((entry) => entry.name)
1246
+ .sort((left, right) => left.localeCompare(right));
1247
+ }
1248
+ catch (error) {
1249
+ if (isErrno(error, 'ENOENT'))
1250
+ return [];
1251
+ throw error;
1252
+ }
1253
+ };
1254
+ const isProcessAlive = (pid) => {
1255
+ try {
1256
+ process.kill(pid, 0);
1257
+ return true;
1258
+ }
1259
+ catch (error) {
1260
+ if (isErrno(error, 'ESRCH'))
1261
+ return false;
1262
+ if (isErrno(error, 'EPERM'))
1263
+ return true;
1264
+ throw error;
1265
+ }
1266
+ };
1267
+ const assertJournalOwnerInactive = (journal) => {
1268
+ if (journal.ownerPid !== null && isProcessAlive(journal.ownerPid)) {
1269
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', `Documentation transaction is still owned by process ${journal.ownerPid}`);
1270
+ }
1271
+ };
1272
+ const realStateDirectoryExists = async (directoryPath) => {
1273
+ const info = await lstat(directoryPath).catch((error) => {
1274
+ if (error.code === 'ENOENT')
1275
+ return null;
1276
+ throw error;
1277
+ });
1278
+ if (!info)
1279
+ return false;
1280
+ if (!info.isDirectory() || info.isSymbolicLink()) {
1281
+ throw recoveryError(`Unsafe documentation transaction residue: ${directoryPath}`);
1282
+ }
1283
+ return true;
1284
+ };
1285
+ const realStateFileExists = async (filePath) => {
1286
+ const info = await lstat(filePath).catch((error) => {
1287
+ if (error.code === 'ENOENT')
1288
+ return null;
1289
+ throw error;
1290
+ });
1291
+ if (!info)
1292
+ return false;
1293
+ if (!info.isFile() || info.isSymbolicLink()) {
1294
+ throw recoveryError(`Unsafe documentation transaction residue: ${filePath}`);
1295
+ }
1296
+ return true;
1297
+ };
1298
+ const parseRecoveryClaim = (bytes, expectedTargetRoot, claimPath) => {
1299
+ let value;
1300
+ try {
1301
+ value = JSON.parse(bytes.toString('utf8'));
1302
+ }
1303
+ catch {
1304
+ throw recoveryError('Corrupt documentation recovery claim');
1305
+ }
1306
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1307
+ throw recoveryError('Corrupt documentation recovery claim');
1308
+ }
1309
+ const raw = value;
1310
+ if (raw.schemaVersion !== 1
1311
+ || !Number.isSafeInteger(raw.ownerPid)
1312
+ || raw.ownerPid <= 0
1313
+ || typeof raw.claimId !== 'string'
1314
+ || raw.targetRoot !== expectedTargetRoot) {
1315
+ throw recoveryError('Corrupt or mismatched documentation recovery claim');
1316
+ }
1317
+ let claimId;
1318
+ try {
1319
+ claimId = assertTransactionId(raw.claimId);
1320
+ }
1321
+ catch {
1322
+ throw recoveryError('Invalid documentation recovery claim ID');
1323
+ }
1324
+ return {
1325
+ schemaVersion: 1,
1326
+ ownerPid: raw.ownerPid,
1327
+ claimId,
1328
+ targetRoot: expectedTargetRoot,
1329
+ path: claimPath,
1330
+ };
1331
+ };
1332
+ const readRecoveryClaim = async (stateRoot, targetRoot) => {
1333
+ const claimPath = path.join(stateRoot, RECOVERY_CLAIM_NAME);
1334
+ const claimInfo = await lstat(claimPath).catch((error) => {
1335
+ if (error.code === 'ENOENT')
1336
+ return null;
1337
+ throw error;
1338
+ });
1339
+ if (!claimInfo)
1340
+ return { kind: 'missing' };
1341
+ if (!claimInfo.isFile() || claimInfo.isSymbolicLink()) {
1342
+ throw recoveryError('Unsafe documentation recovery claim');
1343
+ }
1344
+ const bytes = await readFile(claimPath).catch((error) => {
1345
+ if (error.code === 'ENOENT')
1346
+ return null;
1347
+ throw error;
1348
+ });
1349
+ if (!bytes)
1350
+ return { kind: 'missing' };
1351
+ return { kind: 'ready', claim: parseRecoveryClaim(bytes, targetRoot, claimPath) };
1352
+ };
1353
+ const parseRecoveryClaimSidecar = (entry, prefix, label) => {
1354
+ const match = new RegExp(`^${prefix}(\\d+)-(.+)$`).exec(entry);
1355
+ if (!match)
1356
+ throw recoveryError(`Invalid recovery claim ${label}: ${entry}`);
1357
+ const ownerPid = Number.parseInt(match[1], 10);
1358
+ let claimId;
1359
+ try {
1360
+ claimId = assertTransactionId(match[2]);
1361
+ }
1362
+ catch {
1363
+ throw recoveryError(`Invalid recovery claim ${label}: ${entry}`);
1364
+ }
1365
+ if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0) {
1366
+ throw recoveryError(`Invalid recovery claim ${label} owner: ${entry}`);
1367
+ }
1368
+ return { ownerPid, claimId };
1369
+ };
1370
+ const cleanRecoveryClaimSidecars = async (stateRoot, prefix, label) => {
1371
+ for (const entry of await listStateEntries(stateRoot)) {
1372
+ if (!entry.startsWith(prefix))
1373
+ continue;
1374
+ const { ownerPid } = parseRecoveryClaimSidecar(entry, prefix, label);
1375
+ const sidecarPath = path.join(stateRoot, entry);
1376
+ if (!(await realStateFileExists(sidecarPath)))
1377
+ continue;
1378
+ if (isProcessAlive(ownerPid)) {
1379
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', `Documentation recovery claim ${label} is still owned by process ${ownerPid}`);
1380
+ }
1381
+ const removed = await unlink(sidecarPath).then(() => true, (error) => {
1382
+ if (error.code === 'ENOENT')
1383
+ return false;
1384
+ throw error;
1385
+ });
1386
+ if (removed)
1387
+ await syncDirectory(stateRoot);
1388
+ }
1389
+ };
1390
+ const reclaimDeadRecoveryClaim = async (stateRoot, targetRoot, claimPath) => {
1391
+ const observationPath = path.join(stateRoot, `${RECOVERY_CLAIM_OBSERVING_PREFIX}${process.pid}-${randomUUID()}`);
1392
+ try {
1393
+ await link(claimPath, observationPath);
1394
+ }
1395
+ catch (error) {
1396
+ if (isErrno(error, 'ENOENT'))
1397
+ return false;
1398
+ throw error;
1399
+ }
1400
+ await syncDirectory(stateRoot);
1401
+ try {
1402
+ if (!(await realStateFileExists(observationPath)))
1403
+ return false;
1404
+ const pinned = parseRecoveryClaim(await readFile(observationPath), targetRoot, observationPath);
1405
+ if (isProcessAlive(pinned.ownerPid)) {
1406
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', `Documentation recovery is already running in process ${pinned.ownerPid}`);
1407
+ }
1408
+ for (let attempt = 0; attempt < 100; attempt += 1) {
1409
+ const observationInfo = await lstat(observationPath);
1410
+ const currentInfo = await lstat(claimPath).catch((error) => {
1411
+ if (error.code === 'ENOENT')
1412
+ return null;
1413
+ throw error;
1414
+ });
1415
+ if (!currentInfo)
1416
+ return false;
1417
+ if (!currentInfo.isFile() || currentInfo.isSymbolicLink()) {
1418
+ throw recoveryError('Unsafe documentation recovery claim');
1419
+ }
1420
+ if (currentInfo.dev !== observationInfo.dev
1421
+ || currentInfo.ino !== observationInfo.ino)
1422
+ return false;
1423
+ if (observationInfo.nlink < 2) {
1424
+ throw recoveryError('Invalid documentation recovery claim link count');
1425
+ }
1426
+ if (observationInfo.nlink === 2) {
1427
+ try {
1428
+ await unlink(claimPath);
1429
+ }
1430
+ catch (error) {
1431
+ if (isErrno(error, 'ENOENT'))
1432
+ return false;
1433
+ throw error;
1434
+ }
1435
+ await syncDirectory(stateRoot);
1436
+ return true;
1437
+ }
1438
+ const observers = (await listStateEntries(stateRoot))
1439
+ .filter((entry) => entry.startsWith(RECOVERY_CLAIM_OBSERVING_PREFIX));
1440
+ if (observers[0] !== path.basename(observationPath)) {
1441
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', 'Another process is reclaiming the documentation recovery claim');
1442
+ }
1443
+ await new Promise((resolve) => setTimeout(resolve, 1));
1444
+ }
1445
+ throw recoveryError('Documentation recovery claim link count did not stabilize');
1446
+ }
1447
+ finally {
1448
+ const removed = await unlink(observationPath).then(() => true, (error) => {
1449
+ if (error.code === 'ENOENT')
1450
+ return false;
1451
+ throw error;
1452
+ });
1453
+ if (removed)
1454
+ await syncDirectory(stateRoot);
1455
+ }
1456
+ };
1457
+ const acquireRecoveryClaim = async (targetRoot) => {
1458
+ const stateRoot = path.join(targetRoot, STATE_DIRECTORY);
1459
+ const claimPath = path.join(stateRoot, RECOVERY_CLAIM_NAME);
1460
+ await mkdir(stateRoot, { recursive: true });
1461
+ await cleanRecoveryClaimSidecars(stateRoot, RECOVERY_CLAIM_OBSERVING_PREFIX, 'observer');
1462
+ await cleanRecoveryClaimSidecars(stateRoot, RECOVERY_CLAIM_PREPARING_PREFIX, 'initializer');
1463
+ const claim = {
1464
+ schemaVersion: 1,
1465
+ ownerPid: process.pid,
1466
+ claimId: randomUUID(),
1467
+ targetRoot,
1468
+ path: claimPath,
1469
+ };
1470
+ const initializerPath = path.join(stateRoot, `${RECOVERY_CLAIM_PREPARING_PREFIX}${claim.ownerPid}-${claim.claimId}`);
1471
+ try {
1472
+ await writeSyncedFile(initializerPath, Buffer.from(`${JSON.stringify({
1473
+ schemaVersion: claim.schemaVersion,
1474
+ ownerPid: claim.ownerPid,
1475
+ claimId: claim.claimId,
1476
+ targetRoot: claim.targetRoot,
1477
+ }, null, 2)}\n`), 0o600);
1478
+ await syncDirectory(stateRoot);
1479
+ while (true) {
1480
+ try {
1481
+ await link(initializerPath, claimPath);
1482
+ await syncDirectory(stateRoot);
1483
+ await unlink(initializerPath);
1484
+ await syncDirectory(stateRoot);
1485
+ return claim;
1486
+ }
1487
+ catch (error) {
1488
+ if (!isErrno(error, 'EEXIST'))
1489
+ throw error;
1490
+ }
1491
+ const existing = await readRecoveryClaim(stateRoot, targetRoot);
1492
+ if (existing.kind === 'missing') {
1493
+ await new Promise((resolve) => setTimeout(resolve, 1 + Math.floor(Math.random() * 8)));
1494
+ continue;
1495
+ }
1496
+ if (isProcessAlive(existing.claim.ownerPid)) {
1497
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', `Documentation recovery is already running in process ${existing.claim.ownerPid}`);
1498
+ }
1499
+ if (!(await reclaimDeadRecoveryClaim(stateRoot, targetRoot, claimPath))) {
1500
+ await new Promise((resolve) => setTimeout(resolve, 1 + Math.floor(Math.random() * 8)));
1501
+ }
1502
+ }
1503
+ }
1504
+ catch (error) {
1505
+ await rm(initializerPath, { force: true });
1506
+ await syncDirectory(stateRoot);
1507
+ throw error;
1508
+ }
1509
+ };
1510
+ const releaseRecoveryClaim = async (claim) => {
1511
+ const stateRoot = path.dirname(claim.path);
1512
+ const current = await readRecoveryClaim(stateRoot, claim.targetRoot);
1513
+ if (current.kind !== 'ready'
1514
+ || current.claim.ownerPid !== claim.ownerPid
1515
+ || current.claim.claimId !== claim.claimId) {
1516
+ throw recoveryError('Documentation recovery claim changed unexpectedly');
1517
+ }
1518
+ await unlink(claim.path).catch((error) => {
1519
+ if (error.code === 'ENOENT') {
1520
+ throw recoveryError('Documentation recovery claim disappeared unexpectedly');
1521
+ }
1522
+ throw error;
1523
+ });
1524
+ await syncDirectory(stateRoot);
1525
+ };
1526
+ const inspectResidue = async (stateRoot, entry, injector) => {
1527
+ await fault(injector, { operation: 'residue', timing: 'before', path: entry });
1528
+ const residuePath = path.join(stateRoot, entry);
1529
+ return await realStateDirectoryExists(residuePath) ? residuePath : null;
1530
+ };
1531
+ const cleanGarbageDirectories = async (stateRoot, injector) => {
1532
+ for (const entry of await listStateEntries(stateRoot)) {
1533
+ if (!entry.startsWith(GARBAGE_DIRECTORY_PREFIX))
1534
+ continue;
1535
+ const garbagePath = await inspectResidue(stateRoot, entry, injector);
1536
+ if (!garbagePath)
1537
+ continue;
1538
+ await fault(injector, { operation: 'residue', timing: 'after', path: entry });
1539
+ await rm(garbagePath, { recursive: true, force: true });
1540
+ await syncDirectory(stateRoot);
1541
+ }
1542
+ };
1543
+ const cleanOrphanedPreparingDirectories = async (stateRoot, injector) => {
1544
+ for (const entry of await listStateEntries(stateRoot)) {
1545
+ if (!entry.startsWith(PREPARING_DIRECTORY_PREFIX))
1546
+ continue;
1547
+ const match = new RegExp(`^${PREPARING_DIRECTORY_PREFIX}(\\d+)-(.+)$`).exec(entry);
1548
+ if (!match)
1549
+ throw recoveryError(`Invalid preparing transaction directory: ${entry}`);
1550
+ const pid = Number.parseInt(match[1], 10);
1551
+ try {
1552
+ assertTransactionId(match[2]);
1553
+ }
1554
+ catch {
1555
+ throw recoveryError(`Invalid preparing transaction directory: ${entry}`);
1556
+ }
1557
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
1558
+ throw recoveryError(`Invalid preparing transaction owner: ${entry}`);
1559
+ }
1560
+ const preparingPath = await inspectResidue(stateRoot, entry, injector);
1561
+ if (!preparingPath)
1562
+ continue;
1563
+ if (isProcessAlive(pid)) {
1564
+ if (!(await realStateDirectoryExists(preparingPath)))
1565
+ continue;
1566
+ throw new DocsTransactionError('FG-DOC-CONCURRENT', `Documentation transaction is still being prepared by process ${pid}`);
1567
+ }
1568
+ await fault(injector, { operation: 'residue', timing: 'after', path: entry });
1569
+ await moveStateDirectoryToGarbage(stateRoot, preparingPath);
1570
+ }
1571
+ };
1572
+ const terminalJournalDisposition = async (journal) => {
1573
+ const restored = journal.phase === 'restoring';
1574
+ for (const operation of journal.operations) {
1575
+ const current = await readManagedTarget(journal.targetRoot, operation.path);
1576
+ const matches = restored
1577
+ ? beforeMatches(current, operation)
1578
+ : afterMatches(current, operation);
1579
+ if (!matches) {
1580
+ throw recoveryError(`Transaction tombstone is not terminal: ${operation.path}`);
1581
+ }
1582
+ }
1583
+ const manifest = journal.manifest;
1584
+ if (manifest) {
1585
+ const current = await readManifestTarget(journal.targetRoot);
1586
+ const matches = restored
1587
+ ? current?.sha256 === manifest.beforeHash || (!current && manifest.beforeHash === null)
1588
+ : current?.sha256 === manifest.afterHash;
1589
+ if (!matches)
1590
+ throw recoveryError('Transaction tombstone manifest is not terminal');
1591
+ }
1592
+ return restored ? 'restored' : 'completed';
1593
+ };
1594
+ const cleanTransactionTombstones = async (targetRoot, stateRoot, injector) => {
1595
+ const tombstones = [];
1596
+ for (const entry of await listStateEntries(stateRoot)) {
1597
+ if (!entry.startsWith(TOMBSTONE_DIRECTORY_PREFIX))
1598
+ continue;
1599
+ const transactionId = entry.slice(TOMBSTONE_DIRECTORY_PREFIX.length);
1600
+ try {
1601
+ assertTransactionId(transactionId);
1602
+ }
1603
+ catch {
1604
+ throw recoveryError(`Invalid transaction tombstone: ${entry}`);
1605
+ }
1606
+ const tombstonePath = await inspectResidue(stateRoot, entry, injector);
1607
+ if (!tombstonePath)
1608
+ continue;
1609
+ const journalPath = path.join(tombstonePath, JOURNAL_FILE);
1610
+ const journalInfo = await lstat(journalPath).catch((error) => {
1611
+ if (error.code === 'ENOENT')
1612
+ return null;
1613
+ throw error;
1614
+ });
1615
+ if (!journalInfo?.isFile() || journalInfo.isSymbolicLink()) {
1616
+ if (!journalInfo && !(await realStateDirectoryExists(tombstonePath)))
1617
+ continue;
1618
+ throw recoveryError(`Transaction tombstone journal is missing or unsafe: ${entry}`);
1619
+ }
1620
+ const journalBytes = await readFile(journalPath).catch(async (error) => {
1621
+ if (error.code === 'ENOENT' && !(await realStateDirectoryExists(tombstonePath)))
1622
+ return null;
1623
+ throw error;
1624
+ });
1625
+ if (!journalBytes)
1626
+ continue;
1627
+ const journal = parseJournal(journalBytes, targetRoot);
1628
+ assertJournalOwnerInactive(journal);
1629
+ if (journal.transactionId !== transactionId) {
1630
+ throw recoveryError(`Transaction tombstone ID mismatch: ${entry}`);
1631
+ }
1632
+ tombstones.push({
1633
+ disposition: await terminalJournalDisposition(journal),
1634
+ entry,
1635
+ path: tombstonePath,
1636
+ });
1637
+ }
1638
+ const disposition = tombstones[0]?.disposition ?? null;
1639
+ if (disposition && tombstones.some((tombstone) => tombstone.disposition !== disposition)) {
1640
+ throw recoveryError('Conflicting terminal documentation transaction tombstones');
1641
+ }
1642
+ for (const tombstone of tombstones) {
1643
+ await fault(injector, { operation: 'residue', timing: 'after', path: tombstone.entry });
1644
+ await moveStateDirectoryToGarbage(stateRoot, tombstone.path);
1645
+ }
1646
+ return disposition;
1647
+ };
1648
+ const recoverTransactionResidues = async (targetRoot, injector) => {
1649
+ const stateRoot = path.join(targetRoot, STATE_DIRECTORY);
1650
+ let disposition = null;
1651
+ while (true) {
1652
+ await cleanGarbageDirectories(stateRoot, injector);
1653
+ const currentDisposition = await cleanTransactionTombstones(targetRoot, stateRoot, injector);
1654
+ if (disposition && currentDisposition && disposition !== currentDisposition) {
1655
+ throw recoveryError('Conflicting terminal documentation transaction tombstones');
1656
+ }
1657
+ disposition ??= currentDisposition;
1658
+ await cleanOrphanedPreparingDirectories(stateRoot, injector);
1659
+ const hasResidue = (await listStateEntries(stateRoot)).some((entry) => entry.startsWith(PREPARING_DIRECTORY_PREFIX)
1660
+ || entry.startsWith(TOMBSTONE_DIRECTORY_PREFIX)
1661
+ || entry.startsWith(GARBAGE_DIRECTORY_PREFIX));
1662
+ if (!hasResidue)
1663
+ return disposition;
1664
+ }
1665
+ };
1666
+ export const recoverTemplateDocs = async (targetDir, options = {}) => {
1667
+ const targetRoot = path.resolve(targetDir);
1668
+ const { transactionRoot, journalPath } = transactionPaths(targetRoot);
1669
+ try {
1670
+ await assertStateDirectoriesSafe(targetRoot);
1671
+ }
1672
+ catch (error) {
1673
+ if (error instanceof DocsTransactionError)
1674
+ throw error;
1675
+ throw recoveryError(`Cannot inspect documentation transaction state: ${error.message}`);
1676
+ }
1677
+ let claim;
1678
+ try {
1679
+ claim = await acquireRecoveryClaim(targetRoot);
1680
+ }
1681
+ catch (error) {
1682
+ if (error instanceof DocsTransactionError)
1683
+ throw error;
1684
+ throw recoveryError(`Cannot claim documentation recovery: ${error.message}`);
1685
+ }
1686
+ try {
1687
+ let residueDisposition;
1688
+ try {
1689
+ residueDisposition = await recoverTransactionResidues(targetRoot, options.faultInjector);
1690
+ }
1691
+ catch (error) {
1692
+ if (error instanceof DocsTransactionError)
1693
+ throw error;
1694
+ throw recoveryError(`Cannot clean documentation transaction residue: ${error.message}`);
1695
+ }
1696
+ let transactionInfo;
1697
+ try {
1698
+ transactionInfo = await lstat(transactionRoot);
1699
+ }
1700
+ catch (error) {
1701
+ if (isErrno(error, 'ENOENT'))
1702
+ return residueDisposition ?? 'none';
1703
+ throw recoveryError(`Cannot inspect documentation transaction: ${error.message}`);
1704
+ }
1705
+ if (!transactionInfo.isDirectory() || transactionInfo.isSymbolicLink()) {
1706
+ throw recoveryError('Documentation transaction path is unsafe');
1707
+ }
1708
+ try {
1709
+ const journalBytes = await readFile(journalPath);
1710
+ const journal = parseJournal(journalBytes, targetRoot);
1711
+ assertJournalOwnerInactive(journal);
1712
+ await assertJournalArtifactPathsSafe(transactionRoot, journal);
1713
+ if (await canRecoverForward(transactionRoot, journal)) {
1714
+ await continueForward(transactionRoot, journalPath, journal);
1715
+ return 'completed';
1716
+ }
1717
+ if (await canRestore(journal)) {
1718
+ await restorePriorGeneration(transactionRoot, journalPath, journal);
1719
+ return 'restored';
1720
+ }
1721
+ throw recoveryError('Documentation transaction cannot be safely completed or restored');
1722
+ }
1723
+ catch (error) {
1724
+ if (error instanceof DocsTransactionError
1725
+ && (error.code === 'FG-DOC-RECOVERY' || error.code === 'FG-DOC-CONCURRENT'))
1726
+ throw error;
1727
+ throw recoveryError(`Documentation recovery failed: ${error.message}`);
1728
+ }
1729
+ }
1730
+ finally {
1731
+ await releaseRecoveryClaim(claim);
1732
+ }
1733
+ };
1734
+ export const resolveTemplateDocsConflict = async (bundle, targetDir, relativePath, options = {}) => {
1735
+ let managedPath;
1736
+ try {
1737
+ managedPath = assertManagedPath(relativePath);
1738
+ }
1739
+ catch (error) {
1740
+ throw error instanceof DocsTransactionError ? error : docsError('Invalid conflict path');
1741
+ }
1742
+ if (!bundle.files.has(managedPath)) {
1743
+ throw docsError(`Conflict path is not managed by the bundled documentation: ${managedPath}`);
1744
+ }
1745
+ const targetRoot = path.resolve(targetDir);
1746
+ await assertNoOpenTransaction(targetRoot);
1747
+ const inspection = await inspectTemplateDocs(bundle, targetRoot);
1748
+ const status = planTemplateDocsUpdate(inspection);
1749
+ const selected = status.files.find((file) => file.path === managedPath);
1750
+ if (!selected || selected.action !== 'conflict') {
1751
+ throw new DocsTransactionError('FG-DOC-002', `Path is not a documentation conflict: ${managedPath}`);
1752
+ }
1753
+ const prepared = await prepareTransaction(bundle, targetRoot, status, options, selected);
1754
+ await executeOwnedTransaction(prepared, options.faultInjector);
1755
+ };