@funnelsgrove/cli 0.1.14 → 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 +428 -53
  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 -55
  282. package/template_docs/docs/analytics.md +0 -65
  283. package/template_docs/docs/editing-flow.md +0 -125
  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
package/dist/localSync.js CHANGED
@@ -1,9 +1,32 @@
1
- import { createHash } from 'node:crypto';
2
- import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  export const SYNC_MANIFEST_FILE = '.funnelsgrove-sync.json';
5
6
  export const DEFAULT_PATCH_BATCH_CONTENT_CHARS = 12_000_000;
6
- const EXCLUDED_LOCAL_DIRECTORY_NAMES = ['.git', '.playwright-cli', 'node_modules', '.next', 'out', 'output'];
7
+ export const DEFAULT_SOURCE_CANDIDATE_BATCH_CONTENT_BYTES = 12_000_000;
8
+ export const MAX_SOURCE_CANDIDATE_OPERATIONS = 200 * 500;
9
+ export const MAX_SOURCE_CANDIDATE_FILE_BYTES = 5 * 1024 * 1024;
10
+ export const MAX_SOURCE_CANDIDATE_PATH_CHARACTERS = 1_024;
11
+ export const MAX_SOURCE_CANDIDATE_TOTAL_BYTES = 100 * 1024 * 1024;
12
+ const MAX_SOURCE_CANDIDATE_ATTEMPTS = 100;
13
+ export async function runSyncDownLocalLifecycle(lifecycle) {
14
+ await lifecycle.recoverMigration();
15
+ await lifecycle.recoverDocs();
16
+ await lifecycle.refreshDocs();
17
+ await lifecycle.writeSyncState();
18
+ }
19
+ const EXCLUDED_LOCAL_DIRECTORY_NAMES = [
20
+ '.git',
21
+ '.funnelsgrove',
22
+ '.playwright-cli',
23
+ 'node_modules',
24
+ '.next',
25
+ 'coverage',
26
+ 'dist',
27
+ 'out',
28
+ 'output',
29
+ ];
7
30
  const EXCLUDED_PATH_PARTS = new Set(EXCLUDED_LOCAL_DIRECTORY_NAMES);
8
31
  const LOCAL_ONLY_GITIGNORE_LINES = [
9
32
  '.env',
@@ -13,22 +36,152 @@ const LOCAL_ONLY_GITIGNORE_LINES = [
13
36
  SYNC_MANIFEST_FILE,
14
37
  ...EXCLUDED_LOCAL_DIRECTORY_NAMES.filter((directoryName) => directoryName !== '.git'),
15
38
  ];
39
+ const isErrno = (error, code) => (error instanceof Error && 'code' in error && error.code === code);
40
+ const assertContainedPath = (rootDir, targetPath) => {
41
+ const relative = path.relative(rootDir, targetPath);
42
+ if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) {
43
+ return;
44
+ }
45
+ throw sourceCandidateInvariantError('Local sync path escaped the selected funnel directory.');
46
+ };
47
+ const resolveSafeRoot = async (rootDir, create) => {
48
+ let metadata;
49
+ try {
50
+ metadata = await lstat(rootDir);
51
+ }
52
+ catch (error) {
53
+ if (!create || !isErrno(error, 'ENOENT'))
54
+ throw error;
55
+ await mkdir(rootDir, { recursive: true });
56
+ metadata = await lstat(rootDir);
57
+ }
58
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
59
+ throw sourceCandidateInvariantError('The selected funnel directory must be a real directory, not a symlink.');
60
+ }
61
+ return realpath(rootDir);
62
+ };
63
+ const assertSafeExistingPath = async (rootDir, relativePath, createParents) => {
64
+ const safePath = assertSafeSyncPath(relativePath);
65
+ const segments = safePath.split('/');
66
+ let current = rootDir;
67
+ for (const segment of segments.slice(0, -1)) {
68
+ current = path.join(current, segment);
69
+ let metadata;
70
+ try {
71
+ metadata = await lstat(current);
72
+ }
73
+ catch (error) {
74
+ if (!createParents || !isErrno(error, 'ENOENT'))
75
+ throw error;
76
+ await mkdir(current);
77
+ metadata = await lstat(current);
78
+ }
79
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
80
+ throw sourceCandidateInvariantError(`Local sync path traverses a symlink: ${relativePath}`);
81
+ }
82
+ }
83
+ const absolutePath = path.join(rootDir, ...segments);
84
+ assertContainedPath(rootDir, absolutePath);
85
+ try {
86
+ const metadata = await lstat(absolutePath);
87
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
88
+ throw sourceCandidateInvariantError(`Local sync path is not a regular file: ${relativePath}`);
89
+ }
90
+ }
91
+ catch (error) {
92
+ if (!isErrno(error, 'ENOENT'))
93
+ throw error;
94
+ }
95
+ return absolutePath;
96
+ };
97
+ const assertOpenFileContained = async (rootDir, absolutePath, opened) => {
98
+ const openedStat = await opened.stat();
99
+ if (!openedStat.isFile()) {
100
+ throw sourceCandidateInvariantError('Local sync only accepts regular files.');
101
+ }
102
+ const resolvedPath = await realpath(absolutePath);
103
+ assertContainedPath(rootDir, resolvedPath);
104
+ const currentStat = await stat(resolvedPath);
105
+ if (openedStat.dev !== currentStat.dev || openedStat.ino !== currentStat.ino) {
106
+ throw sourceCandidateInvariantError('Local source changed while opening it. Retry the sync.');
107
+ }
108
+ };
109
+ const readSafeRootFile = async (rootDir, relativePath) => {
110
+ const resolvedRoot = await resolveSafeRoot(rootDir, false);
111
+ const absolutePath = await assertSafeExistingPath(resolvedRoot, relativePath, false);
112
+ let handle;
113
+ try {
114
+ handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
115
+ }
116
+ catch (error) {
117
+ if (isErrno(error, 'ELOOP')) {
118
+ throw sourceCandidateInvariantError(`Local sync path is a symlink: ${relativePath}`);
119
+ }
120
+ throw error;
121
+ }
122
+ try {
123
+ await assertOpenFileContained(resolvedRoot, absolutePath, handle);
124
+ return await handle.readFile();
125
+ }
126
+ finally {
127
+ await handle.close();
128
+ }
129
+ };
130
+ const writeSafeRootFile = async (rootDir, relativePath, content) => {
131
+ const resolvedRoot = await resolveSafeRoot(rootDir, true);
132
+ const absolutePath = await assertSafeExistingPath(resolvedRoot, relativePath, true);
133
+ const tempPath = path.join(path.dirname(absolutePath), `.${path.basename(absolutePath)}.fgrove-${randomUUID()}.tmp`);
134
+ let handle = null;
135
+ try {
136
+ handle = await open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o666);
137
+ await assertOpenFileContained(resolvedRoot, tempPath, handle);
138
+ await handle.writeFile(content);
139
+ await handle.sync();
140
+ await handle.close();
141
+ handle = null;
142
+ await assertSafeExistingPath(resolvedRoot, relativePath, true);
143
+ await rename(tempPath, absolutePath);
144
+ assertContainedPath(resolvedRoot, await realpath(absolutePath));
145
+ }
146
+ finally {
147
+ if (handle)
148
+ await handle.close().catch(() => undefined);
149
+ await rm(tempPath, { force: true }).catch(() => undefined);
150
+ }
151
+ };
16
152
  export function normalizeSyncPath(filePath) {
17
153
  const normalized = path.posix.normalize(filePath.replaceAll('\\', '/'));
18
154
  return normalized.replace(/^(\.\/|\/)+/, '');
19
155
  }
156
+ const sourceCandidateInvariantError = (reason) => Object.assign(new Error(reason), { code: 'FG-CANDIDATE-001' });
20
157
  function assertSafeSyncPath(filePath) {
21
- const normalized = normalizeSyncPath(filePath);
22
- if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
23
- throw new Error(`Unsafe sync path "${filePath}"`);
158
+ if (filePath.length === 0
159
+ || Array.from(filePath).length > MAX_SOURCE_CANDIDATE_PATH_CHARACTERS
160
+ || filePath !== filePath.trim()
161
+ || filePath.startsWith('/')
162
+ || filePath.endsWith('/')
163
+ || filePath.includes('\\')
164
+ || filePath.includes('//')
165
+ || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(filePath)
166
+ || /%(?:2e|2f|5c)/i.test(filePath)
167
+ || /[\x00-\x1f\x7f]/.test(filePath)
168
+ || filePath.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) {
169
+ throw sourceCandidateInvariantError(`Unsafe sync path "${filePath}"`);
170
+ }
171
+ return filePath;
172
+ }
173
+ function assertSafeSyncTargetPath(filePath) {
174
+ const safePath = assertSafeSyncPath(filePath);
175
+ if (!shouldSyncFile(safePath)) {
176
+ throw sourceCandidateInvariantError(`Excluded local-only or generated sync path "${safePath}"`);
24
177
  }
25
- return normalized;
178
+ return safePath;
26
179
  }
27
180
  export function shouldSyncFile(filePath) {
28
181
  const normalized = normalizeSyncPath(filePath);
29
182
  const parts = normalized.split('/');
30
183
  const fileName = parts.at(-1) ?? '';
31
- if (parts.some((part) => EXCLUDED_PATH_PARTS.has(part))) {
184
+ if (parts.some((part) => EXCLUDED_PATH_PARTS.has(part.toLocaleLowerCase('en-US')))) {
32
185
  return false;
33
186
  }
34
187
  if (fileName === SYNC_MANIFEST_FILE) {
@@ -40,7 +193,8 @@ export function shouldSyncFile(filePath) {
40
193
  return true;
41
194
  }
42
195
  export async function buildSyncManifest(rootDir, input) {
43
- const files = await collectSyncFiles(rootDir, rootDir);
196
+ const resolvedRoot = await resolveSafeRoot(rootDir, false);
197
+ const files = await collectSyncFiles(resolvedRoot, resolvedRoot);
44
198
  return {
45
199
  version: 1,
46
200
  workspaceId: input.workspaceId,
@@ -49,12 +203,27 @@ export async function buildSyncManifest(rootDir, input) {
49
203
  files: files.sort((left, right) => left.path.localeCompare(right.path)),
50
204
  };
51
205
  }
206
+ export function buildCommittedSyncManifest(input) {
207
+ return {
208
+ version: 1,
209
+ workspaceId: input.workspaceId,
210
+ funnelId: input.funnelId,
211
+ draftVersionId: input.draftVersionId,
212
+ files: input.files.map((file) => ({ ...file })),
213
+ };
214
+ }
52
215
  export async function writeSyncManifest(rootDir, manifest) {
53
- await writeFile(path.join(rootDir, SYNC_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
216
+ await writeSafeRootFile(rootDir, SYNC_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`);
54
217
  }
55
218
  export async function ensureGitignore(rootDir) {
56
- const gitignorePath = path.join(rootDir, '.gitignore');
57
- const existing = await readFile(gitignorePath, 'utf8').catch(() => '');
219
+ let existing = '';
220
+ try {
221
+ existing = (await readSafeRootFile(rootDir, '.gitignore')).toString('utf8');
222
+ }
223
+ catch (error) {
224
+ if (!isErrno(error, 'ENOENT'))
225
+ throw error;
226
+ }
58
227
  const lines = existing.split(/\r?\n/g).filter(Boolean);
59
228
  const seen = new Set(lines);
60
229
  for (const line of LOCAL_ONLY_GITIGNORE_LINES) {
@@ -62,19 +231,18 @@ export async function ensureGitignore(rootDir) {
62
231
  lines.push(line);
63
232
  }
64
233
  }
65
- await writeFile(gitignorePath, `${lines.join('\n')}\n`, 'utf8');
234
+ await writeSafeRootFile(rootDir, '.gitignore', `${lines.join('\n')}\n`);
66
235
  }
67
236
  export async function writeLocalEnvFile(rootDir, envFile) {
68
237
  if (!envFile) {
69
238
  return false;
70
239
  }
71
- await mkdir(rootDir, { recursive: true });
72
- await writeFile(path.join(rootDir, '.env'), envFile, 'utf8');
240
+ await writeSafeRootFile(rootDir, '.env', envFile);
73
241
  return true;
74
242
  }
75
243
  export async function readSyncManifest(rootDir) {
76
244
  try {
77
- const raw = JSON.parse(await readFile(path.join(rootDir, SYNC_MANIFEST_FILE), 'utf8'));
245
+ const raw = JSON.parse((await readSafeRootFile(rootDir, SYNC_MANIFEST_FILE)).toString('utf8'));
78
246
  if (raw.version !== 1 ||
79
247
  typeof raw.workspaceId !== 'string' ||
80
248
  typeof raw.funnelId !== 'string' ||
@@ -100,13 +268,19 @@ export async function readSyncManifest(rootDir) {
100
268
  throw error;
101
269
  }
102
270
  }
103
- export async function collectSourceFiles(rootDir) {
271
+ export async function collectSourceSnapshot(rootDir) {
104
272
  const manifest = await buildSyncManifest(rootDir, {
105
273
  workspaceId: '',
106
274
  funnelId: '',
107
275
  draftVersionId: '',
108
276
  });
109
- return readSourceFiles(rootDir, manifest.files);
277
+ return {
278
+ manifestFiles: manifest.files,
279
+ sourceFiles: await readSourceFiles(rootDir, manifest.files),
280
+ };
281
+ }
282
+ export async function collectSourceFiles(rootDir) {
283
+ return (await collectSourceSnapshot(rootDir)).sourceFiles;
110
284
  }
111
285
  export async function collectChangedSourceFiles(rootDir, previousManifest) {
112
286
  const currentManifest = await buildSyncManifest(rootDir, {
@@ -160,6 +334,199 @@ export function chunkChangedSourceFiles(changes, maxContentChars = DEFAULT_PATCH
160
334
  pushCurrentBatch();
161
335
  return batches;
162
336
  }
337
+ export function buildSourceCandidateOperations(input) {
338
+ const previousPathByLower = new Map();
339
+ for (const file of input.previousFiles) {
340
+ const filePath = assertSafeSyncPath(file.path);
341
+ const lowerPath = filePath.toLowerCase();
342
+ if (previousPathByLower.has(lowerPath)) {
343
+ throw sourceCandidateInvariantError(`Source candidate has a case-colliding path: ${filePath}`);
344
+ }
345
+ previousPathByLower.set(lowerPath, filePath);
346
+ }
347
+ const deletedPathByLower = new Map();
348
+ for (const deletedPath of input.deletedPaths) {
349
+ const filePath = assertSafeSyncPath(deletedPath);
350
+ const lowerPath = filePath.toLowerCase();
351
+ if (deletedPathByLower.has(lowerPath)) {
352
+ throw sourceCandidateInvariantError(`Source candidate repeats a path: ${filePath}`);
353
+ }
354
+ deletedPathByLower.set(lowerPath, filePath);
355
+ }
356
+ const writtenPathByLower = new Map();
357
+ for (const file of input.files) {
358
+ const filePath = assertSafeSyncTargetPath(file.path);
359
+ const lowerPath = filePath.toLowerCase();
360
+ if (writtenPathByLower.has(lowerPath) || deletedPathByLower.has(lowerPath)) {
361
+ throw sourceCandidateInvariantError(`Source candidate repeats or case-collides at: ${filePath}`);
362
+ }
363
+ const previousPath = previousPathByLower.get(lowerPath);
364
+ if (previousPath !== undefined && previousPath !== filePath) {
365
+ throw sourceCandidateInvariantError(`Source candidate has a case-colliding path: ${filePath}`);
366
+ }
367
+ writtenPathByLower.set(lowerPath, filePath);
368
+ }
369
+ const finalPaths = new Set(Array.from(previousPathByLower.keys()).filter((filePath) => !deletedPathByLower.has(filePath)));
370
+ for (const filePath of writtenPathByLower.keys())
371
+ finalPaths.add(filePath);
372
+ if (finalPaths.size > 5_000) {
373
+ throw sourceCandidateInvariantError('Source candidate final snapshot exceeds 5,000 files.');
374
+ }
375
+ const deleted = Array.from(deletedPathByLower.values())
376
+ .sort((left, right) => left.localeCompare(right))
377
+ .map((filePath) => ({ type: 'delete', path: filePath }));
378
+ const changed = input.files
379
+ .map((file) => {
380
+ const filePath = assertSafeSyncTargetPath(file.path);
381
+ return {
382
+ type: previousPathByLower.has(filePath.toLowerCase()) ? 'modify' : 'create',
383
+ path: filePath,
384
+ content: file.content,
385
+ contentType: file.contentType,
386
+ sha256: createHash('sha256').update(file.content).digest('hex'),
387
+ };
388
+ })
389
+ .sort((left, right) => left.path.localeCompare(right.path));
390
+ return [...deleted, ...changed];
391
+ }
392
+ const sourceCandidateRequestHash = (input) => createHash('sha256').update(JSON.stringify(input)).digest('hex');
393
+ const throwIfAborted = (signal) => {
394
+ if (!signal?.aborted)
395
+ return;
396
+ const error = new Error('Source candidate sync was canceled.');
397
+ error.name = 'AbortError';
398
+ throw error;
399
+ };
400
+ const assertSourceCandidateLimits = (operations) => {
401
+ if (operations.length > MAX_SOURCE_CANDIDATE_OPERATIONS) {
402
+ throw sourceCandidateInvariantError('Source candidate exceeds 200 batches of 500 operations.');
403
+ }
404
+ let totalBytes = 0;
405
+ const affectedPathByLower = new Map();
406
+ for (const operation of operations) {
407
+ const affectedPaths = operation.type === 'rename'
408
+ ? [assertSafeSyncPath(operation.oldPath), assertSafeSyncTargetPath(operation.path)]
409
+ : operation.type === 'delete'
410
+ ? [assertSafeSyncPath(operation.path)]
411
+ : [assertSafeSyncTargetPath(operation.path)];
412
+ for (const filePath of affectedPaths) {
413
+ const lowerPath = filePath.toLowerCase();
414
+ if (affectedPathByLower.has(lowerPath)) {
415
+ throw sourceCandidateInvariantError(`Source candidate repeats or case-collides at: ${filePath}`);
416
+ }
417
+ affectedPathByLower.set(lowerPath, filePath);
418
+ }
419
+ if (!('content' in operation))
420
+ continue;
421
+ const sizeBytes = Buffer.byteLength(operation.content, 'utf8');
422
+ if (sizeBytes > MAX_SOURCE_CANDIDATE_FILE_BYTES) {
423
+ throw sourceCandidateInvariantError(`Source candidate file exceeds 5 MiB: ${operation.path}`);
424
+ }
425
+ if (createHash('sha256').update(operation.content).digest('hex') !== operation.sha256) {
426
+ throw sourceCandidateInvariantError(`Source candidate hash does not match: ${operation.path}`);
427
+ }
428
+ totalBytes += sizeBytes;
429
+ if (totalBytes > MAX_SOURCE_CANDIDATE_TOTAL_BYTES) {
430
+ throw sourceCandidateInvariantError('Source candidate exceeds 100 MiB total content.');
431
+ }
432
+ }
433
+ };
434
+ export async function runSourceCandidateSync(input) {
435
+ const maxOperationsPerBatch = input.maxOperationsPerBatch ?? 500;
436
+ const maxContentBytesPerBatch = input.maxContentBytesPerBatch
437
+ ?? DEFAULT_SOURCE_CANDIDATE_BATCH_CONTENT_BYTES;
438
+ const message = input.message?.trim() || undefined;
439
+ if (input.operations.length === 0
440
+ || !Number.isInteger(maxOperationsPerBatch)
441
+ || maxOperationsPerBatch < 1
442
+ || maxOperationsPerBatch > 500
443
+ || !Number.isInteger(maxContentBytesPerBatch)
444
+ || maxContentBytesPerBatch < 1) {
445
+ throw new Error('Source candidate sync requires operations in batches of 1 to 500.');
446
+ }
447
+ if (message && message.length > 200) {
448
+ throw sourceCandidateInvariantError('Source candidate message exceeds 200 characters.');
449
+ }
450
+ assertSourceCandidateLimits(input.operations);
451
+ throwIfAborted(input.signal);
452
+ const requestHash = sourceCandidateRequestHash({
453
+ workspaceId: input.workspaceId,
454
+ funnelId: input.funnelId,
455
+ baseDraftVersionId: input.baseDraftVersionId,
456
+ message,
457
+ operations: input.operations,
458
+ });
459
+ const baseIdempotencyKey = `cli-sync:${requestHash}`;
460
+ let begin = null;
461
+ for (let attempt = 1; attempt <= MAX_SOURCE_CANDIDATE_ATTEMPTS; attempt += 1) {
462
+ const receipt = await input.api.begin({
463
+ workspaceId: input.workspaceId,
464
+ funnelId: input.funnelId,
465
+ baseDraftVersionId: input.baseDraftVersionId,
466
+ idempotencyKey: attempt === 1
467
+ ? baseIdempotencyKey
468
+ : `${baseIdempotencyKey}:attempt:${attempt}`,
469
+ });
470
+ if (!receipt.candidateId || receipt.baseDraftVersionId !== input.baseDraftVersionId) {
471
+ throw sourceCandidateInvariantError('Source candidate begin receipt does not match the requested transaction.');
472
+ }
473
+ if (receipt.status === 'aborted' || receipt.status === 'expired' || receipt.status === 'failed') {
474
+ continue;
475
+ }
476
+ begin = receipt;
477
+ break;
478
+ }
479
+ if (!begin) {
480
+ throw sourceCandidateInvariantError('Source candidate begin receipt does not match the requested transaction.');
481
+ }
482
+ try {
483
+ const batches = [];
484
+ let batch = [];
485
+ let batchContentBytes = 0;
486
+ for (const operation of input.operations) {
487
+ const contentBytes = 'content' in operation
488
+ ? Buffer.byteLength(operation.content, 'utf8')
489
+ : 0;
490
+ if (batch.length > 0
491
+ && (batch.length >= maxOperationsPerBatch
492
+ || batchContentBytes + contentBytes > maxContentBytesPerBatch)) {
493
+ batches.push(batch);
494
+ batch = [];
495
+ batchContentBytes = 0;
496
+ }
497
+ batch.push(operation);
498
+ batchContentBytes += contentBytes;
499
+ }
500
+ if (batch.length > 0)
501
+ batches.push(batch);
502
+ if (begin.status === 'staging') {
503
+ for (const [index, operations] of batches.entries()) {
504
+ throwIfAborted(input.signal);
505
+ await input.api.stage({
506
+ workspaceId: input.workspaceId,
507
+ candidateId: begin.candidateId,
508
+ idempotencyKey: `cli-sync:${requestHash}:stage:${index + 1}`,
509
+ operations,
510
+ });
511
+ }
512
+ }
513
+ throwIfAborted(input.signal);
514
+ return await input.api.finalize({
515
+ workspaceId: input.workspaceId,
516
+ candidateId: begin.candidateId,
517
+ message,
518
+ });
519
+ }
520
+ catch (error) {
521
+ if (begin.status === 'staging') {
522
+ await input.api.abort({
523
+ workspaceId: input.workspaceId,
524
+ candidateId: begin.candidateId,
525
+ }).catch(() => undefined);
526
+ }
527
+ throw error;
528
+ }
529
+ }
163
530
  export function formatSyncUploadSummary(changes, largestFileLimit = 5) {
164
531
  const fileSizes = changes.files
165
532
  .map((file) => ({
@@ -197,24 +564,24 @@ function formatByteSize(byteCount) {
197
564
  }
198
565
  async function readSourceFiles(rootDir, manifestFiles) {
199
566
  const files = await Promise.all(manifestFiles.map(async (file) => {
200
- const absolutePath = path.join(rootDir, assertSafeSyncPath(file.path));
201
- const imageContentType = inferImageContentType(file.path);
202
- const buffer = await readFile(absolutePath);
567
+ const binaryContentType = inferBinaryContentType(file.path);
568
+ const buffer = await readSafeRootFile(rootDir, file.path);
569
+ if (createHash('sha256').update(buffer).digest('hex') !== file.hash) {
570
+ throw sourceCandidateInvariantError(`Local source changed while preparing the candidate: ${file.path}. Retry the sync.`);
571
+ }
203
572
  return {
204
573
  path: file.path,
205
- content: imageContentType ? `data:${imageContentType};base64,${buffer.toString('base64')}` : buffer.toString('utf8'),
206
- contentType: imageContentType || 'text/plain',
574
+ content: binaryContentType ? `data:${binaryContentType};base64,${buffer.toString('base64')}` : buffer.toString('utf8'),
575
+ contentType: binaryContentType || 'text/plain',
207
576
  };
208
577
  }));
209
578
  return files.sort((left, right) => left.path.localeCompare(right.path));
210
579
  }
211
580
  export async function writeSourceFiles(rootDir, files) {
212
581
  for (const file of files) {
213
- const relativePath = assertSafeSyncPath(file.path);
214
- const absolutePath = path.join(rootDir, relativePath);
215
- await mkdir(path.dirname(absolutePath), { recursive: true });
582
+ const relativePath = assertSafeSyncTargetPath(file.path);
216
583
  const decodedContent = decodeBinarySourceContent(file);
217
- await writeFile(absolutePath, decodedContent || file.content, decodedContent ? undefined : 'utf8');
584
+ await writeSafeRootFile(rootDir, relativePath, decodedContent || file.content);
218
585
  }
219
586
  }
220
587
  async function collectSyncFiles(rootDir, dir) {
@@ -226,43 +593,51 @@ async function collectSyncFiles(rootDir, dir) {
226
593
  if (!shouldSyncFile(relativePath)) {
227
594
  continue;
228
595
  }
229
- if (entry.isDirectory()) {
596
+ const metadata = await lstat(absolutePath);
597
+ if (metadata.isSymbolicLink()) {
598
+ throw sourceCandidateInvariantError(`Local sync path is a symlink: ${relativePath}`);
599
+ }
600
+ if (metadata.isDirectory()) {
230
601
  files.push(...(await collectSyncFiles(rootDir, absolutePath)));
231
602
  continue;
232
603
  }
233
- if (entry.isFile() || (entry.isSymbolicLink() && (await stat(absolutePath)).isFile())) {
604
+ if (metadata.isFile()) {
234
605
  files.push({
235
606
  path: relativePath,
236
- hash: await hashFile(absolutePath),
607
+ hash: await hashFile(rootDir, relativePath),
237
608
  });
238
609
  }
239
610
  }
240
611
  return files;
241
612
  }
242
- async function hashFile(filePath) {
243
- return createHash('sha256').update(await readFile(filePath)).digest('hex');
613
+ async function hashFile(rootDir, relativePath) {
614
+ return createHash('sha256').update(await readSafeRootFile(rootDir, relativePath)).digest('hex');
244
615
  }
245
- function inferImageContentType(filePath) {
246
- const extension = path.extname(filePath).toLowerCase();
247
- if (extension === '.png') {
248
- return 'image/png';
249
- }
250
- if (extension === '.jpg' || extension === '.jpeg') {
251
- return 'image/jpeg';
252
- }
253
- if (extension === '.webp') {
254
- return 'image/webp';
255
- }
256
- if (extension === '.gif') {
257
- return 'image/gif';
258
- }
259
- if (extension === '.svg') {
260
- return 'image/svg+xml';
261
- }
262
- if (extension === '.avif') {
263
- return 'image/avif';
264
- }
265
- return null;
616
+ const BINARY_CONTENT_TYPE_BY_EXTENSION = {
617
+ '.aac': 'audio/aac',
618
+ '.avif': 'image/avif',
619
+ '.flac': 'audio/flac',
620
+ '.gif': 'image/gif',
621
+ '.jpeg': 'image/jpeg',
622
+ '.jpg': 'image/jpeg',
623
+ '.m4a': 'audio/mp4',
624
+ '.m4v': 'video/mp4',
625
+ '.mov': 'video/quicktime',
626
+ '.mp3': 'audio/mpeg',
627
+ '.mp4': 'video/mp4',
628
+ '.oga': 'audio/ogg',
629
+ '.ogg': 'audio/ogg',
630
+ '.ogv': 'video/ogg',
631
+ '.opus': 'audio/ogg',
632
+ '.png': 'image/png',
633
+ '.svg': 'image/svg+xml',
634
+ '.wav': 'audio/wav',
635
+ '.weba': 'audio/webm',
636
+ '.webm': 'video/webm',
637
+ '.webp': 'image/webp',
638
+ };
639
+ function inferBinaryContentType(filePath) {
640
+ return BINARY_CONTENT_TYPE_BY_EXTENSION[path.extname(filePath).toLowerCase()] || null;
266
641
  }
267
642
  function decodeDataUrl(content) {
268
643
  const match = content.trim().match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s_-]+)$/);
@@ -0,0 +1,27 @@
1
+ import { type FunnelValidationResult } from './diagnosticOutput.js';
2
+ export declare const PROJECT_VALIDATOR_TIMEOUT_MS = 120000;
3
+ export declare const PROJECT_VALIDATOR_CONTRACT_VERSION: 3;
4
+ export type ProjectValidatorProcessRequest = {
5
+ command: 'npm';
6
+ args: ['run', 'validate:funnel', '--', '--json'];
7
+ cwd: string;
8
+ env: NodeJS.ProcessEnv;
9
+ timeoutMs: number;
10
+ };
11
+ export type ProjectValidatorProcessResult = {
12
+ stdout: string;
13
+ stderr: string;
14
+ exitCode: number | null;
15
+ signal: NodeJS.Signals | null;
16
+ timedOut: boolean;
17
+ error?: unknown;
18
+ };
19
+ export type ProjectValidatorProcess = (request: ProjectValidatorProcessRequest) => Promise<ProjectValidatorProcessResult>;
20
+ export type RunProjectValidatorOptions = {
21
+ projectDir: string;
22
+ parentEnv?: NodeJS.ProcessEnv;
23
+ runProcess?: ProjectValidatorProcess;
24
+ timeoutMs?: number;
25
+ };
26
+ export declare const buildProjectValidatorEnvironment: (parentEnv: NodeJS.ProcessEnv) => NodeJS.ProcessEnv;
27
+ export declare const runProjectValidator: (options: RunProjectValidatorOptions) => Promise<FunnelValidationResult>;