@funnelsgrove/cli 0.1.15 → 0.1.19

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 +1234 -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
package/dist/cli.js CHANGED
@@ -1,18 +1,26 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, realpathSync } from 'node:fs';
3
- import { cp, mkdir, writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { constants as fsConstants, readFileSync, realpathSync } from 'node:fs';
4
+ import { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rmdir, rm, unlink, writeFile, } from 'node:fs/promises';
5
+ import { spawn } from 'node:child_process';
4
6
  import { createInterface } from 'node:readline/promises';
5
7
  import path from 'node:path';
6
8
  import { fileURLToPath } from 'node:url';
7
9
  import { Command } from 'commander';
8
- import { callTrpcProcedure } from './apiClient.js';
10
+ import { CURRENT_STEP_CONTRACT_VERSION, FUNNEL_CONTRACT_DIAGNOSTIC_CODES, PREVIOUS_STEP_CONTRACT_VERSION, createFunnelContractDiagnostic, } from '@funnelsgrove/runtime';
11
+ import { abortFunnelSourceCandidate, beginFunnelSourceCandidate, callTrpcProcedure, finalizeFunnelSourceCandidate, stageFunnelSourceCandidate, } from './apiClient.js';
9
12
  import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
10
- import { buildSyncManifest, chunkChangedSourceFiles, collectChangedSourceFiles, collectSourceFiles, ensureGitignore, formatSyncUploadSummary, hasLocalSourceChanges, readSyncManifest, writeLocalEnvFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
13
+ import { buildSyncManifest, buildCommittedSyncManifest, buildSourceCandidateOperations, collectChangedSourceFiles, collectSourceSnapshot, ensureGitignore, formatSyncUploadSummary, hasLocalSourceChanges, readSyncManifest, runSyncDownLocalLifecycle, runSourceCandidateSync, writeLocalEnvFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
11
14
  import { pullEnvFile } from './envSync.js';
12
15
  import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, } from './githubOutput.js';
13
16
  import { syncGitHubDraftIfConnected } from './githubSyncFlow.js';
14
- import { isKnownTemplate, KNOWN_TEMPLATE_SLUGS, reskinFunnel } from './reskin.js';
15
- import { syncTemplateDocs, TEMPLATE_DOCS_DIR } from './templateDocs.js';
17
+ import { reskinFunnel } from './reskin.js';
18
+ import { TEMPLATE_DOCS_DIR } from './templateDocs.js';
19
+ import { renderDocsHuman, renderDocsJson, } from './docsOutput.js';
20
+ import { applyTemplateDocsUpdate, inspectTemplateDocs, loadBundledDocs, planTemplateDocsUpdate, recoverTemplateDocs, resolveTemplateDocsConflict, } from './docsTransaction.js';
21
+ import { renderValidationHuman, renderValidationJson, } from './diagnosticOutput.js';
22
+ import { validateFunnel } from './funnelValidation.js';
23
+ import { StepContractMigrationError, applyStepContractV2Migration, checkStepContractV2Migration, recoverStepContractV2Migration, resolveStepContractMigrationMode, stepContractMigrationReportPath, } from './stepContractMigration.js';
16
24
  import { assertHasCohortData, assertHasConversionData, assertHasFunnelPathData, assertHasTransitionData, buildCohortReportPayload, buildConversionReportPayload, buildFunnelPathReportPayload, buildTransitionReportPayload, formatAnalyticsJson, formatCohortTable, formatConversionsTable, formatFunnelPathTable, formatTransitionsTable, normalizeAnalyticsOutputFormat, parseAnalyticsDate, } from './analyticsOutput.js';
17
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
26
  const readCliVersion = () => {
@@ -32,12 +40,1020 @@ const toKebabCase = (value) => value
32
40
  .replace(/([a-z])([A-Z])/g, '$1-$2')
33
41
  .replace(/[\s_]+/g, '-')
34
42
  .toLowerCase();
35
- const resolveTemplatePath = (slug) => path.resolve(__dirname, '..', '..', '..', 'funnels', 'rag-catalog', slug);
43
+ export const CANONICAL_CREATE_TEMPLATE_SLUG = 'template';
44
+ export const SCAFFOLD_TRANSIENT_PATHS = [
45
+ 'node_modules',
46
+ '.next',
47
+ 'out',
48
+ 'tsconfig.tsbuildinfo',
49
+ '.funnelsgrove-scaffold.json',
50
+ ];
51
+ const STEP_CONTRACT_MIGRATION_GUIDANCE = 'fgrove migrate step-contract --to 3';
52
+ export class CliContractError extends Error {
53
+ code;
54
+ constructor(code, message) {
55
+ super(message.startsWith(`[${code}]`) ? message : `[${code}] ${message}`);
56
+ this.name = 'CliContractError';
57
+ this.code = code;
58
+ }
59
+ }
60
+ export const assertCanonicalCreateTemplateSlug = (slug) => {
61
+ if (slug !== CANONICAL_CREATE_TEMPLATE_SLUG) {
62
+ throw new CliContractError('FG-VERSION-002', `Legacy template slug "${slug}" is not supported by this CLI. For an existing local checkout, run ${STEP_CONTRACT_MIGRATION_GUIDANCE}.`);
63
+ }
64
+ };
65
+ export const resolveCreateTemplatePath = (slug = CANONICAL_CREATE_TEMPLATE_SLUG) => {
66
+ assertCanonicalCreateTemplateSlug(slug);
67
+ return path.resolve(__dirname, '..', 'template_scaffold');
68
+ };
69
+ const removeScaffoldTransientPaths = async (dir) => {
70
+ await Promise.all(SCAFFOLD_TRANSIENT_PATHS.map((relativePath) => rm(path.join(dir, relativePath), { recursive: true, force: true })));
71
+ };
36
72
  const resolveTemplateDocsPath = () => path.resolve(__dirname, '..', TEMPLATE_DOCS_DIR);
73
+ const resolveLegacyDocsCatalogPath = () => path.resolve(__dirname, '..', 'legacy-docs-catalog.json');
74
+ const resolveBundledContractPath = () => path.resolve(__dirname, '..', 'contracts', 'step-contract-v2.json');
75
+ const resolveTemplateValidationPath = () => path.resolve(__dirname, '..', 'template_validation');
37
76
  const DEFAULT_API_URL = 'https://api.funnelsgrove.com/trpc';
38
77
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
39
78
  const PUBLISH_POLL_INTERVAL_MS = 2_000;
40
79
  const PUBLISH_WAIT_TIMEOUT_MS = 30 * 60_000;
80
+ export function resolveDocsCommandMode(options) {
81
+ const selectedModes = [options.check, options.diff, Boolean(options.resolve)].filter(Boolean);
82
+ if (selectedModes.length > 1) {
83
+ throw new Error('Choose only one of --check, --diff, or --resolve.');
84
+ }
85
+ if (options.resolve && !options.replaceManaged) {
86
+ throw new Error('--resolve requires --replace-managed.');
87
+ }
88
+ if (options.replaceManaged && !options.resolve) {
89
+ throw new Error('--replace-managed requires --resolve <path>.');
90
+ }
91
+ const json = options.json === true;
92
+ if (options.resolve) {
93
+ return { mode: 'resolve', json, path: options.resolve };
94
+ }
95
+ if (options.check) {
96
+ return { mode: 'check', json };
97
+ }
98
+ if (options.diff) {
99
+ return { mode: 'diff', json };
100
+ }
101
+ return { mode: 'apply', json };
102
+ }
103
+ const loadPackagedLegacyDocsCatalog = async () => {
104
+ const value = JSON.parse(await readFile(resolveLegacyDocsCatalogPath(), 'utf8'));
105
+ if (!Array.isArray(value)) {
106
+ throw Object.assign(new Error('Legacy docs catalog must be an array.'), {
107
+ code: 'FG-DOC-001',
108
+ });
109
+ }
110
+ return value;
111
+ };
112
+ const createDefaultDocsCommandBackend = () => {
113
+ let bundlePromise;
114
+ let catalogPromise;
115
+ const loadBundle = () => {
116
+ bundlePromise ??= loadBundledDocs(resolveTemplateDocsPath());
117
+ return bundlePromise;
118
+ };
119
+ const loadCatalog = () => {
120
+ catalogPromise ??= loadPackagedLegacyDocsCatalog();
121
+ return catalogPromise;
122
+ };
123
+ return {
124
+ recover: async (targetDir) => {
125
+ await recoverTemplateDocs(targetDir);
126
+ },
127
+ inspect: async (targetDir) => planTemplateDocsUpdate(await inspectTemplateDocs(await loadBundle(), targetDir, {
128
+ legacyCatalog: await loadCatalog(),
129
+ })),
130
+ apply: async (targetDir, status) => {
131
+ await applyTemplateDocsUpdate(await loadBundle(), targetDir, status, {
132
+ legacyCatalog: await loadCatalog(),
133
+ });
134
+ },
135
+ resolve: async (targetDir, relativePath) => {
136
+ await resolveTemplateDocsConflict(await loadBundle(), targetDir, relativePath);
137
+ },
138
+ };
139
+ };
140
+ export const docsResolveConfirmation = (relativePath) => `replace-managed ${relativePath}`;
141
+ const docsCommandLabel = (mode) => {
142
+ if (mode.mode === 'check') {
143
+ return 'docs --check';
144
+ }
145
+ if (mode.mode === 'diff') {
146
+ return 'docs --diff';
147
+ }
148
+ if (mode.mode === 'resolve') {
149
+ return `docs --resolve ${mode.path} --replace-managed`;
150
+ }
151
+ return 'docs';
152
+ };
153
+ const unknownDocsStatus = () => ({
154
+ bundleVersion: 'unknown',
155
+ contractVersion: 0,
156
+ clean: false,
157
+ files: [],
158
+ diagnostics: [],
159
+ });
160
+ const addDocsDiagnostic = (status, diagnostic) => ({
161
+ ...status,
162
+ clean: false,
163
+ diagnostics: [...status.diagnostics, diagnostic],
164
+ });
165
+ const docsErrorDiagnostic = (error) => {
166
+ const code = typeof error === 'object'
167
+ && error !== null
168
+ && 'code' in error
169
+ && typeof error.code === 'string'
170
+ ? error.code
171
+ : 'FG-DOC-CLI';
172
+ return {
173
+ code,
174
+ message: error instanceof Error ? error.message : String(error),
175
+ };
176
+ };
177
+ const renderDocsCommandResult = (mode, status, exitCode) => {
178
+ const input = { command: docsCommandLabel(mode), status, exitCode };
179
+ return {
180
+ exitCode,
181
+ output: mode.json
182
+ ? renderDocsJson(input)
183
+ : renderDocsHuman(input, mode.mode === 'diff' ? 'diff' : 'status'),
184
+ };
185
+ };
186
+ const hasDocsConflicts = (status) => status.files.some((file) => file.action === 'conflict');
187
+ const docsFailureExitCode = (phase, diagnostic) => {
188
+ if (phase === 'recovery') {
189
+ return 4;
190
+ }
191
+ if (diagnostic.code === 'FG-DOC-001') {
192
+ return 2;
193
+ }
194
+ if (diagnostic.code === 'FG-DOC-002') {
195
+ return 3;
196
+ }
197
+ if (diagnostic.code === 'FG-DOC-RECOVERY'
198
+ || diagnostic.code === 'FG-DOC-CONCURRENT'
199
+ || phase === 'apply'
200
+ || phase === 'resolve') {
201
+ return 4;
202
+ }
203
+ return 2;
204
+ };
205
+ export async function executeDocsCommand(input) {
206
+ let mode;
207
+ try {
208
+ mode = resolveDocsCommandMode(input.options);
209
+ }
210
+ catch (error) {
211
+ const fallbackMode = { mode: 'apply', json: input.options.json === true };
212
+ return renderDocsCommandResult(fallbackMode, addDocsDiagnostic(unknownDocsStatus(), docsErrorDiagnostic(error)), 2);
213
+ }
214
+ if (mode.mode === 'resolve' && input.isTTY !== true) {
215
+ return renderDocsCommandResult(mode, addDocsDiagnostic(unknownDocsStatus(), {
216
+ code: 'FG-DOC-USAGE',
217
+ message: '--resolve requires an interactive TTY.',
218
+ }), 2);
219
+ }
220
+ const backend = input.backend ?? createDefaultDocsCommandBackend();
221
+ const render = async (currentStatus, exitCode) => {
222
+ const diagnostic = await input.auditLegacy?.(input.targetDir);
223
+ const auditedStatus = diagnostic && !currentStatus.diagnostics.some((item) => item.code === diagnostic.code && item.path === diagnostic.path)
224
+ ? { ...currentStatus, diagnostics: [...currentStatus.diagnostics, diagnostic] }
225
+ : currentStatus;
226
+ return renderDocsCommandResult(mode, auditedStatus, exitCode);
227
+ };
228
+ let phase = 'recovery';
229
+ let status;
230
+ try {
231
+ await backend.recover(input.targetDir);
232
+ phase = 'inspection';
233
+ status = await backend.inspect(input.targetDir);
234
+ if (mode.mode === 'check') {
235
+ return render(status, status.clean ? 0 : hasDocsConflicts(status) ? 3 : 1);
236
+ }
237
+ if (mode.mode === 'diff') {
238
+ return render(status, 0);
239
+ }
240
+ if (mode.mode === 'apply') {
241
+ if (hasDocsConflicts(status)) {
242
+ return render(status, 3);
243
+ }
244
+ if (!status.clean) {
245
+ phase = 'apply';
246
+ await backend.apply(input.targetDir, status);
247
+ phase = 'inspection';
248
+ status = await backend.inspect(input.targetDir);
249
+ }
250
+ return render(status, hasDocsConflicts(status) ? 3 : status.clean ? 0 : 1);
251
+ }
252
+ const conflict = status.files.find((file) => file.path === mode.path && file.action === 'conflict');
253
+ if (!conflict) {
254
+ return render(addDocsDiagnostic(status, {
255
+ code: 'FG-DOC-002',
256
+ path: mode.path,
257
+ message: 'The requested managed path is not an unresolved conflict.',
258
+ }), 3);
259
+ }
260
+ const confirmation = docsResolveConfirmation(mode.path);
261
+ const answer = await input.prompt?.(`Type "${confirmation}" to replace this managed file: `);
262
+ if (answer !== confirmation) {
263
+ return render(addDocsDiagnostic(status, {
264
+ code: 'FG-DOC-USAGE',
265
+ path: mode.path,
266
+ message: 'Managed file replacement was not confirmed exactly.',
267
+ }), 2);
268
+ }
269
+ phase = 'resolve';
270
+ await backend.resolve(input.targetDir, mode.path);
271
+ phase = 'inspection';
272
+ status = await backend.inspect(input.targetDir);
273
+ return render(status, hasDocsConflicts(status) ? 3 : 0);
274
+ }
275
+ catch (error) {
276
+ const diagnostic = docsErrorDiagnostic(error);
277
+ const exitCode = docsFailureExitCode(phase, diagnostic);
278
+ return renderDocsCommandResult(mode, addDocsDiagnostic(status ?? unknownDocsStatus(), diagnostic), exitCode);
279
+ }
280
+ }
281
+ const validatePackagedFunnel = async (targetDir) => validateFunnel({
282
+ projectDir: path.resolve(targetDir),
283
+ bundledDocsDir: resolveTemplateDocsPath(),
284
+ bundledContractPath: resolveBundledContractPath(),
285
+ bundledValidationDir: resolveTemplateValidationPath(),
286
+ legacyCatalog: await loadPackagedLegacyDocsCatalog(),
287
+ });
288
+ export async function executeValidateCommand(input) {
289
+ const result = await (input.validate ?? validatePackagedFunnel)(path.resolve(input.targetDir));
290
+ const outputInput = { command: 'validate', result };
291
+ return {
292
+ exitCode: result.exitCode,
293
+ output: input.json ? renderValidationJson(outputInput) : renderValidationHuman(outputInput),
294
+ };
295
+ }
296
+ export async function auditLegacyFunnel(targetDir, validate = validatePackagedFunnel) {
297
+ try {
298
+ const manifest = await lstat(path.join(targetDir, 'src/config/funnel.manifest.ts'));
299
+ if (!manifest.isFile() || manifest.isSymbolicLink())
300
+ return null;
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ const result = await validate(path.resolve(targetDir));
306
+ const versionDiagnostic = result.diagnostics.find((diagnostic) => diagnostic.code === 'FG-VERSION-001' || diagnostic.code === 'FG-VERSION-002');
307
+ if (!versionDiagnostic)
308
+ return null;
309
+ return {
310
+ code: versionDiagnostic.code,
311
+ path: versionDiagnostic.file ?? undefined,
312
+ message: `${versionDiagnostic.reason} ${versionDiagnostic.repair}`,
313
+ };
314
+ }
315
+ export class FunnelValidationError extends CliContractError {
316
+ result;
317
+ constructor(result) {
318
+ const diagnostic = result.diagnostics[0];
319
+ super(diagnostic?.code ?? 'FG-CLIENT-001', renderValidationHuman({ command: 'validate', result }).trimEnd());
320
+ this.name = 'FunnelValidationError';
321
+ this.result = result;
322
+ }
323
+ }
324
+ export async function runValidatedFunnelMutation(input) {
325
+ const result = await (input.validate ?? validatePackagedFunnel)(path.resolve(input.targetDir));
326
+ if (!result.valid || result.exitCode !== 0 || result.diagnostics.length > 0) {
327
+ throw new FunnelValidationError(result);
328
+ }
329
+ return input.mutate();
330
+ }
331
+ const assertFunnelValidForMutation = async (targetDir) => {
332
+ await runValidatedFunnelMutation({ targetDir, mutate: async () => undefined });
333
+ };
334
+ const isFunnelContractDiagnosticCode = (code) => FUNNEL_CONTRACT_DIAGNOSTIC_CODES.includes(code);
335
+ export const toMigrationValidationResult = (result) => ({
336
+ valid: result.valid && result.exitCode === 0 && result.diagnostics.length === 0,
337
+ diagnostics: result.diagnostics.map((diagnostic) => createFunnelContractDiagnostic({
338
+ code: isFunnelContractDiagnosticCode(diagnostic.code)
339
+ ? diagnostic.code
340
+ : 'FG-CLIENT-001',
341
+ file: diagnostic.file,
342
+ stepId: diagnostic.stepId,
343
+ expected: diagnostic.expected ?? null,
344
+ received: diagnostic.received ?? null,
345
+ reason: diagnostic.reason,
346
+ guide: diagnostic.guide,
347
+ repair: diagnostic.repair,
348
+ })),
349
+ });
350
+ const migrationCommandLabel = (mode, targetVersion) => `migrate step-contract --to ${targetVersion} --${mode}`;
351
+ const renderMigrationResult = (input) => {
352
+ const errorMessage = input.error instanceof Error
353
+ ? input.error.message
354
+ : input.error === undefined ? null : String(input.error);
355
+ const output = {
356
+ schemaVersion: 1,
357
+ command: migrationCommandLabel(input.mode, input.targetVersion),
358
+ mode: input.mode,
359
+ reportPath: input.result?.reportPath ?? null,
360
+ report: input.result?.report ?? null,
361
+ applied: 'applied' in (input.result ?? {}) ? true : false,
362
+ diagnostics: errorMessage === null ? [] : [{
363
+ code: input.error instanceof CliContractError
364
+ ? input.error.code
365
+ : 'FG-CLIENT-001',
366
+ message: errorMessage,
367
+ }],
368
+ exitCode: input.exitCode,
369
+ };
370
+ if (input.json) {
371
+ return { exitCode: input.exitCode, output: `${JSON.stringify(output, null, 2)}\n` };
372
+ }
373
+ const lines = [
374
+ `Step contract migration: ${input.exitCode === 0 ? 'ready' : input.exitCode === 1 ? 'blocked' : 'error'} (${input.mode})`,
375
+ ...(input.result ? [
376
+ `Report: ${input.result.reportPath}`,
377
+ `Operations: ${input.result.report.operations.length}`,
378
+ ...input.result.report.diagnostics.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.reason}`),
379
+ ] : []),
380
+ ...(errorMessage === null ? [] : [errorMessage]),
381
+ ];
382
+ return { exitCode: input.exitCode, output: `${lines.join('\n')}\n` };
383
+ };
384
+ const CONTRACT_TOOLS_MANIFEST_PATH = '.funnelsgrove-contract-tools.json';
385
+ const SUPPORTED_CLI_CONTRACT_VERSIONS = new Set([
386
+ PREVIOUS_STEP_CONTRACT_VERSION,
387
+ CURRENT_STEP_CONTRACT_VERSION,
388
+ ]);
389
+ const isSafeManagedValidatorPath = (relativePath) => (relativePath.startsWith('src/contract/')
390
+ && !relativePath.includes('\\')
391
+ && !/[\u0000-\u001f\u007f]/.test(relativePath)
392
+ && path.posix.normalize(relativePath) === relativePath
393
+ && relativePath.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'));
394
+ const parseContractToolsManifest = (bytes, code) => {
395
+ let value;
396
+ try {
397
+ value = JSON.parse(bytes.toString('utf8'));
398
+ }
399
+ catch {
400
+ throw new CliContractError(code, 'The managed validator ownership manifest is malformed.');
401
+ }
402
+ if (value === null
403
+ || typeof value !== 'object'
404
+ || Array.isArray(value)
405
+ || Object.getPrototypeOf(value) !== Object.prototype) {
406
+ throw new CliContractError(code, 'The managed validator ownership manifest is malformed.');
407
+ }
408
+ const manifest = value;
409
+ if (JSON.stringify(Object.keys(manifest).sort())
410
+ !== JSON.stringify(['contractHash', 'managedFiles', 'schemaVersion', 'stepContractVersion'])
411
+ ||
412
+ manifest.schemaVersion !== 1
413
+ || typeof manifest.stepContractVersion !== 'number'
414
+ || !SUPPORTED_CLI_CONTRACT_VERSIONS.has(manifest.stepContractVersion)
415
+ || typeof manifest.contractHash !== 'string'
416
+ || !/^[a-f0-9]{64}$/.test(manifest.contractHash)
417
+ || !Array.isArray(manifest.managedFiles)
418
+ || manifest.managedFiles.length === 0) {
419
+ throw new CliContractError(code, 'The managed validator ownership manifest is malformed.');
420
+ }
421
+ const managedFiles = [];
422
+ const paths = new Set();
423
+ for (const item of manifest.managedFiles) {
424
+ if (item === null
425
+ || typeof item !== 'object'
426
+ || Array.isArray(item)
427
+ || Object.getPrototypeOf(item) !== Object.prototype) {
428
+ throw new CliContractError(code, 'The managed validator ownership manifest is malformed.');
429
+ }
430
+ const entry = item;
431
+ if (Object.keys(entry).sort().join(',') !== 'path,sha256'
432
+ ||
433
+ typeof entry.path !== 'string'
434
+ || typeof entry.sha256 !== 'string'
435
+ || !isSafeManagedValidatorPath(entry.path)
436
+ || !/^[a-f0-9]{64}$/.test(entry.sha256)
437
+ || paths.has(entry.path)) {
438
+ throw new CliContractError(code, 'The managed validator ownership manifest is malformed.');
439
+ }
440
+ paths.add(entry.path);
441
+ managedFiles.push({ path: entry.path, sha256: entry.sha256 });
442
+ }
443
+ if (managedFiles.some((entry, index) => (index > 0 && managedFiles[index - 1].path.localeCompare(entry.path) >= 0))) {
444
+ throw new CliContractError(code, 'The managed validator ownership manifest is malformed.');
445
+ }
446
+ return { managedFiles };
447
+ };
448
+ const listManagedValidatorSourceFiles = async (rootDir, relativeDir = '') => {
449
+ const entries = await readdir(path.join(rootDir, relativeDir), { withFileTypes: true });
450
+ const files = [];
451
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
452
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
453
+ if (entry.isSymbolicLink()) {
454
+ throw new CliContractError('FG-DOC-001', 'Managed validator files may not be symlinks.');
455
+ }
456
+ if (entry.isDirectory()) {
457
+ files.push(...await listManagedValidatorSourceFiles(rootDir, relativePath));
458
+ }
459
+ else if (entry.isFile()) {
460
+ files.push(relativePath);
461
+ }
462
+ else {
463
+ throw new CliContractError('FG-DOC-001', 'Managed validator files must be regular files.');
464
+ }
465
+ }
466
+ return files;
467
+ };
468
+ const readTargetRegularFile = async (rootDir, relativePath) => {
469
+ const segments = relativePath.split('/');
470
+ for (let index = 0; index < segments.length; index += 1) {
471
+ const absolutePath = path.join(rootDir, ...segments.slice(0, index + 1));
472
+ let stats;
473
+ try {
474
+ stats = await lstat(absolutePath);
475
+ }
476
+ catch (error) {
477
+ if (error.code === 'ENOENT') {
478
+ return null;
479
+ }
480
+ throw error;
481
+ }
482
+ if (stats.isSymbolicLink()) {
483
+ throw new CliContractError('FG-DOC-002', 'Managed validator targets may not be symlinks.');
484
+ }
485
+ if (index < segments.length - 1 && !stats.isDirectory()) {
486
+ throw new CliContractError('FG-DOC-002', 'Managed validator target parents must be directories.');
487
+ }
488
+ if (index === segments.length - 1 && !stats.isFile()) {
489
+ throw new CliContractError('FG-DOC-002', 'Managed validator targets must be regular files.');
490
+ }
491
+ }
492
+ return readFile(path.join(rootDir, relativePath));
493
+ };
494
+ export const copyManagedValidationFiles = async (targetDir, sourceDir = resolveTemplateValidationPath()) => {
495
+ const sourcePaths = await listManagedValidatorSourceFiles(sourceDir);
496
+ const sourceManifestBytes = await readFile(path.join(sourceDir, CONTRACT_TOOLS_MANIFEST_PATH));
497
+ const sourceManifest = parseContractToolsManifest(sourceManifestBytes, 'FG-DOC-001');
498
+ const expectedSourcePaths = [
499
+ CONTRACT_TOOLS_MANIFEST_PATH,
500
+ ...sourceManifest.managedFiles.map(({ path: filePath }) => filePath),
501
+ ].sort();
502
+ if (JSON.stringify(sourcePaths) !== JSON.stringify(expectedSourcePaths)) {
503
+ throw new CliContractError('FG-DOC-001', 'The managed validator bundle does not match its ownership manifest.');
504
+ }
505
+ const sourceFiles = new Map();
506
+ for (const entry of sourceManifest.managedFiles) {
507
+ const bytes = await readFile(path.join(sourceDir, entry.path));
508
+ const digest = createHash('sha256').update(bytes).digest('hex');
509
+ if (digest !== entry.sha256) {
510
+ throw new CliContractError('FG-DOC-001', 'The managed validator bundle does not match its ownership manifest.');
511
+ }
512
+ sourceFiles.set(entry.path, bytes);
513
+ }
514
+ const priorManifestBytes = await readTargetRegularFile(targetDir, CONTRACT_TOOLS_MANIFEST_PATH);
515
+ const priorManifest = priorManifestBytes === null
516
+ ? null
517
+ : parseContractToolsManifest(priorManifestBytes, 'FG-DOC-002');
518
+ const priorHashes = new Map(priorManifest?.managedFiles.map((entry) => [entry.path, entry.sha256]) ?? []);
519
+ for (const [relativePath, incomingBytes] of sourceFiles) {
520
+ const currentBytes = await readTargetRegularFile(targetDir, relativePath);
521
+ if (currentBytes === null || currentBytes.equals(incomingBytes)) {
522
+ continue;
523
+ }
524
+ const currentHash = createHash('sha256').update(currentBytes).digest('hex');
525
+ if (priorHashes.get(relativePath) !== currentHash) {
526
+ throw new CliContractError('FG-DOC-002', `Managed validator conflicts with local changes: ${relativePath}`);
527
+ }
528
+ }
529
+ for (const [relativePath, bytes] of sourceFiles) {
530
+ const absolutePath = path.join(targetDir, relativePath);
531
+ await mkdir(path.dirname(absolutePath), { recursive: true });
532
+ await writeFile(absolutePath, bytes);
533
+ }
534
+ await writeFile(path.join(targetDir, CONTRACT_TOOLS_MANIFEST_PATH), sourceManifestBytes);
535
+ };
536
+ const MANAGED_VALIDATOR_SCRIPT = 'vite-node --config src/contract/funnel-validator.vite.config.ts src/contract/validate-funnel.cli.ts';
537
+ const ensureManagedValidatorPackage = async (targetDir) => {
538
+ const packagePath = path.join(targetDir, 'package.json');
539
+ const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
540
+ const scripts = packageJson.scripts ?? {};
541
+ const existingScript = scripts['validate:funnel'];
542
+ if (existingScript !== undefined
543
+ && existingScript !== MANAGED_VALIDATOR_SCRIPT
544
+ && existingScript !== 'vite-node src/contract/validate-funnel.cli.ts'
545
+ && existingScript !== 'vite-node --config vitest.config.ts src/contract/validate-funnel.cli.ts') {
546
+ throw new CliContractError('FG-VERSION-002', 'The existing validate:funnel script is not a known managed value.');
547
+ }
548
+ if (packageJson.dependencies?.['vite-node'] !== undefined) {
549
+ throw new CliContractError('FG-VERSION-002', 'Move the custom vite-node runtime dependency before migrating.');
550
+ }
551
+ const devDependencies = packageJson.devDependencies ?? {};
552
+ const existingViteNode = devDependencies['vite-node'];
553
+ if (existingViteNode !== undefined && existingViteNode !== '3.2.4') {
554
+ throw new CliContractError('FG-VERSION-002', 'The existing vite-node dependency is not the managed version 3.2.4.');
555
+ }
556
+ packageJson.scripts = { ...scripts, 'validate:funnel': MANAGED_VALIDATOR_SCRIPT };
557
+ packageJson.devDependencies = { ...devDependencies, 'vite-node': '3.2.4' };
558
+ await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
559
+ };
560
+ const installManagedFunnelFiles = async (targetDir) => {
561
+ await copyManagedValidationFiles(targetDir);
562
+ await ensureManagedValidatorPackage(targetDir);
563
+ const docs = await executeDocsCommand({ targetDir, options: { json: true } });
564
+ if (docs.exitCode !== 0) {
565
+ const output = JSON.parse(docs.output);
566
+ const diagnostic = output.diagnostics?.[0];
567
+ throw new CliContractError(diagnostic?.code ?? 'FG-DOC-001', diagnostic?.message ?? 'Managed documentation could not be installed.');
568
+ }
569
+ };
570
+ export const runBoundedNpmCommand = async (targetDir, args, options = {}) => new Promise((resolve, reject) => {
571
+ const timeoutMs = options.timeoutMs ?? 10 * 60_000;
572
+ const terminationGraceMs = options.terminationGraceMs ?? 5_000;
573
+ const finalizationGraceMs = options.finalizationGraceMs ?? 1_000;
574
+ const child = spawn('npm', [...args], {
575
+ cwd: targetDir,
576
+ detached: process.platform !== 'win32',
577
+ env: process.env,
578
+ shell: false,
579
+ stdio: 'inherit',
580
+ windowsHide: true,
581
+ });
582
+ let timedOut = false;
583
+ let settled = false;
584
+ let terminationTimer;
585
+ let finalizationTimer;
586
+ const settle = (error) => {
587
+ if (settled)
588
+ return;
589
+ settled = true;
590
+ clearTimeout(timeout);
591
+ if (terminationTimer)
592
+ clearTimeout(terminationTimer);
593
+ if (finalizationTimer)
594
+ clearTimeout(finalizationTimer);
595
+ if (error)
596
+ reject(error);
597
+ else
598
+ resolve();
599
+ };
600
+ const killProcessTree = (signal) => {
601
+ if (process.platform === 'win32' && child.pid !== undefined) {
602
+ // Killing npm first can orphan its descendants before taskkill can discover the tree.
603
+ const taskkill = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
604
+ stdio: 'ignore',
605
+ windowsHide: true,
606
+ });
607
+ taskkill.once('error', () => {
608
+ // A second bounded attempt is made after the termination grace period.
609
+ });
610
+ taskkill.unref();
611
+ return;
612
+ }
613
+ if (process.platform !== 'win32' && child.pid !== undefined) {
614
+ try {
615
+ process.kill(-child.pid, signal);
616
+ return;
617
+ }
618
+ catch {
619
+ // The group may already be gone; fall back to the npm process handle.
620
+ }
621
+ }
622
+ try {
623
+ child.kill(signal);
624
+ }
625
+ catch {
626
+ // Completion is bounded by the finalization timer even if signaling fails.
627
+ }
628
+ };
629
+ const timeout = setTimeout(() => {
630
+ timedOut = true;
631
+ killProcessTree('SIGTERM');
632
+ terminationTimer = setTimeout(() => {
633
+ killProcessTree('SIGKILL');
634
+ finalizationTimer = setTimeout(() => {
635
+ child.unref();
636
+ settle(new CliContractError('FG-CLIENT-001', 'npm dependency installation timed out.'));
637
+ }, finalizationGraceMs);
638
+ }, terminationGraceMs);
639
+ }, timeoutMs);
640
+ child.once('error', (error) => {
641
+ if (!timedOut)
642
+ settle(error);
643
+ });
644
+ child.once('close', (code, signal) => {
645
+ if (timedOut)
646
+ return;
647
+ if (code !== 0) {
648
+ settle(new CliContractError('FG-CLIENT-001', `npm dependency installation failed${signal ? ` (${signal})` : ''}.`));
649
+ }
650
+ else {
651
+ settle();
652
+ }
653
+ });
654
+ });
655
+ const managedBundleAvailable = async () => {
656
+ try {
657
+ await Promise.all([
658
+ loadBundledDocs(resolveTemplateDocsPath()),
659
+ lstat(resolveBundledContractPath()),
660
+ lstat(resolveTemplateValidationPath()),
661
+ ]);
662
+ return true;
663
+ }
664
+ catch {
665
+ return false;
666
+ }
667
+ };
668
+ const createDefaultMigrationBackend = () => ({
669
+ recoverMigration: recoverStepContractV2Migration,
670
+ recoverDocs: recoverTemplateDocs,
671
+ check: async (targetDir, targetVersion) => checkStepContractV2Migration({
672
+ projectDir: targetDir,
673
+ managedBundleAvailable: await managedBundleAvailable(),
674
+ targetVersion,
675
+ }),
676
+ apply: async (targetDir, targetVersion) => applyStepContractV2Migration({
677
+ projectDir: targetDir,
678
+ managedBundleAvailable: await managedBundleAvailable(),
679
+ targetVersion,
680
+ installManagedCandidate: async ({ candidateDir }) => {
681
+ await installManagedFunnelFiles(candidateDir);
682
+ await runBoundedNpmCommand(candidateDir, [
683
+ 'install',
684
+ '--ignore-scripts',
685
+ '--no-audit',
686
+ '--no-fund',
687
+ '--workspaces=false',
688
+ ]);
689
+ },
690
+ validateCandidate: async ({ candidateDir, phase }) => {
691
+ if (phase === 'committed') {
692
+ await runBoundedNpmCommand(candidateDir, [
693
+ 'ci',
694
+ '--ignore-scripts',
695
+ '--no-audit',
696
+ '--no-fund',
697
+ '--workspaces=false',
698
+ ]);
699
+ }
700
+ return toMigrationValidationResult(await validatePackagedFunnel(candidateDir));
701
+ },
702
+ }),
703
+ });
704
+ export async function executeStepContractMigrationCommand(input) {
705
+ let mode = 'check';
706
+ let targetVersion = 3;
707
+ try {
708
+ if (String(input.to) !== '3') {
709
+ throw new CliContractError('FG-VERSION-002', '--to must be 3.');
710
+ }
711
+ targetVersion = Number(input.to);
712
+ mode = resolveStepContractMigrationMode(input.options);
713
+ }
714
+ catch (error) {
715
+ return renderMigrationResult({
716
+ mode,
717
+ targetVersion,
718
+ error,
719
+ json: input.options.json === true,
720
+ exitCode: 2,
721
+ });
722
+ }
723
+ const targetDir = path.resolve(input.targetDir);
724
+ const backend = input.backend ?? createDefaultMigrationBackend();
725
+ try {
726
+ await backend.recoverMigration(targetDir, targetVersion);
727
+ await backend.recoverDocs(targetDir);
728
+ const result = mode === 'apply'
729
+ ? await backend.apply(targetDir, targetVersion)
730
+ : await backend.check(targetDir, targetVersion);
731
+ return renderMigrationResult({
732
+ mode,
733
+ targetVersion,
734
+ result,
735
+ json: input.options.json === true,
736
+ exitCode: result.report.valid ? 0 : 1,
737
+ });
738
+ }
739
+ catch (error) {
740
+ const report = error instanceof StepContractMigrationError && error.report
741
+ ? {
742
+ reportPath: path.join(targetDir, stepContractMigrationReportPath(targetVersion)),
743
+ report: error.report,
744
+ }
745
+ : undefined;
746
+ return renderMigrationResult({
747
+ mode,
748
+ targetVersion,
749
+ result: report,
750
+ error,
751
+ json: input.options.json === true,
752
+ exitCode: report ? 1 : 2,
753
+ });
754
+ }
755
+ }
756
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
757
+ const isSafeScaffoldPath = (relativePath) => {
758
+ if (!relativePath
759
+ || relativePath.includes('\\')
760
+ || /[\u0000-\u001f\u007f]/.test(relativePath)
761
+ || path.posix.isAbsolute(relativePath)
762
+ || /^[A-Za-z]:/.test(relativePath)
763
+ || path.posix.normalize(relativePath) !== relativePath)
764
+ return false;
765
+ return relativePath.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
766
+ };
767
+ const scaffoldTreeHash = (files) => {
768
+ const hash = createHash('sha256');
769
+ hash.update('funnelsgrove-scaffold-v1\0');
770
+ for (const file of files) {
771
+ hash.update(file.path);
772
+ hash.update('\0');
773
+ hash.update(file.mode);
774
+ hash.update('\0');
775
+ hash.update(file.sha256);
776
+ hash.update('\n');
777
+ }
778
+ return hash.digest('hex');
779
+ };
780
+ const parseScaffoldManifest = (value) => {
781
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
782
+ throw new CliContractError('FG-DOC-001', 'The packaged scaffold manifest is invalid.');
783
+ }
784
+ const manifest = value;
785
+ if (manifest.schemaVersion !== 1
786
+ || typeof manifest.stepContractVersion !== 'number'
787
+ || !SUPPORTED_CLI_CONTRACT_VERSIONS.has(manifest.stepContractVersion)
788
+ || typeof manifest.sourceTreeHash !== 'string'
789
+ || !/^[a-f0-9]{64}$/.test(manifest.sourceTreeHash)
790
+ || typeof manifest.docsBundleVersion !== 'string'
791
+ || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.docsBundleVersion)
792
+ || !Array.isArray(manifest.files)) {
793
+ throw new CliContractError('FG-DOC-001', 'The packaged scaffold manifest is invalid.');
794
+ }
795
+ const files = [];
796
+ const seen = new Set();
797
+ let previousPath = '';
798
+ for (const value of manifest.files) {
799
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
800
+ throw new CliContractError('FG-DOC-001', 'The packaged scaffold file list is invalid.');
801
+ }
802
+ const file = value;
803
+ if (typeof file.path !== 'string'
804
+ || !isSafeScaffoldPath(file.path)
805
+ || typeof file.sha256 !== 'string'
806
+ || !/^[a-f0-9]{64}$/.test(file.sha256)
807
+ || (file.mode !== '100644' && file.mode !== '100755')
808
+ || (previousPath !== '' && previousPath >= file.path)
809
+ || seen.has(file.path.normalize('NFC').toLowerCase())
810
+ || file.path === '.funnelsgrove-scaffold.json') {
811
+ throw new CliContractError('FG-DOC-001', 'The packaged scaffold file list is invalid.');
812
+ }
813
+ previousPath = file.path;
814
+ seen.add(file.path.normalize('NFC').toLowerCase());
815
+ files.push(file);
816
+ }
817
+ const parsed = manifest;
818
+ if (scaffoldTreeHash(files) !== parsed.sourceTreeHash) {
819
+ throw new CliContractError('FG-DOC-001', 'The packaged scaffold tree hash is invalid.');
820
+ }
821
+ return { ...parsed, files };
822
+ };
823
+ const collectScaffoldFiles = async (rootDir, relativeDir = '') => {
824
+ const files = [];
825
+ const entries = (await readdir(path.join(rootDir, relativeDir), { withFileTypes: true }))
826
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
827
+ for (const entry of entries) {
828
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
829
+ if (!isSafeScaffoldPath(relativePath)) {
830
+ throw new CliContractError('FG-DOC-001', `Unsafe packaged scaffold path: ${relativePath}`);
831
+ }
832
+ const absolutePath = path.join(rootDir, ...relativePath.split('/'));
833
+ const metadata = await lstat(absolutePath);
834
+ if (metadata.isSymbolicLink()) {
835
+ throw new CliContractError('FG-DOC-001', `Packaged scaffold symlink: ${relativePath}`);
836
+ }
837
+ if (metadata.isDirectory()) {
838
+ files.push(...await collectScaffoldFiles(rootDir, relativePath));
839
+ }
840
+ else if (metadata.isFile() && relativePath !== '.funnelsgrove-scaffold.json') {
841
+ files.push({
842
+ path: relativePath,
843
+ sha256: sha256(await readFile(absolutePath)),
844
+ mode: metadata.mode & 0o111 ? '100755' : '100644',
845
+ });
846
+ }
847
+ else if (!metadata.isFile()) {
848
+ throw new CliContractError('FG-DOC-001', `Unsupported packaged scaffold entry: ${relativePath}`);
849
+ }
850
+ }
851
+ return files.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
852
+ };
853
+ export const verifyPackagedScaffold = async (templatePath) => {
854
+ const root = await lstat(templatePath);
855
+ if (!root.isDirectory() || root.isSymbolicLink()) {
856
+ throw new CliContractError('FG-DOC-001', 'The packaged template scaffold is invalid.');
857
+ }
858
+ let manifest;
859
+ try {
860
+ manifest = parseScaffoldManifest(JSON.parse(await readFile(path.join(templatePath, '.funnelsgrove-scaffold.json'), 'utf8')));
861
+ }
862
+ catch (error) {
863
+ if (error instanceof CliContractError)
864
+ throw error;
865
+ throw new CliContractError('FG-DOC-001', 'The packaged scaffold manifest is unreadable.');
866
+ }
867
+ const actualFiles = await collectScaffoldFiles(templatePath);
868
+ if (actualFiles.length !== manifest.files.length
869
+ || actualFiles.some((file, index) => {
870
+ const expected = manifest.files[index];
871
+ return file.path !== expected.path
872
+ || file.sha256 !== expected.sha256
873
+ || file.mode !== expected.mode;
874
+ })) {
875
+ throw new CliContractError('FG-DOC-001', 'The packaged template scaffold does not match its manifest.');
876
+ }
877
+ };
878
+ export const materializePackagedScaffoldAliases = async (stageDir) => {
879
+ const aliasPath = path.join(stageDir, 'gitignore.template');
880
+ const gitignorePath = path.join(stageDir, '.gitignore');
881
+ let alias;
882
+ try {
883
+ alias = await lstat(aliasPath);
884
+ }
885
+ catch {
886
+ throw new CliContractError('FG-DOC-001', 'The verified scaffold gitignore alias is missing.');
887
+ }
888
+ if (!alias.isFile() || alias.isSymbolicLink()) {
889
+ throw new CliContractError('FG-DOC-001', 'The verified scaffold gitignore alias is unsafe.');
890
+ }
891
+ try {
892
+ await link(aliasPath, gitignorePath);
893
+ }
894
+ catch (error) {
895
+ if (error.code === 'EEXIST') {
896
+ throw new CliContractError('FG-DOC-001', 'The scaffold .gitignore path already exists.');
897
+ }
898
+ throw error;
899
+ }
900
+ await unlink(aliasPath);
901
+ };
902
+ const defaultCreateHooks = {
903
+ verifyScaffold: verifyPackagedScaffold,
904
+ materializeAliases: materializePackagedScaffoldAliases,
905
+ reskin: reskinFunnel,
906
+ installManagedFiles: installManagedFunnelFiles,
907
+ installDependencies: async (stageDir) => runBoundedNpmCommand(stageDir, [
908
+ 'ci',
909
+ '--ignore-scripts',
910
+ '--no-audit',
911
+ '--no-fund',
912
+ '--workspaces=false',
913
+ ]),
914
+ validate: validatePackagedFunnel,
915
+ };
916
+ const localPathExists = async (target) => {
917
+ try {
918
+ await lstat(target);
919
+ return true;
920
+ }
921
+ catch (error) {
922
+ if (error.code === 'ENOENT')
923
+ return false;
924
+ throw error;
925
+ }
926
+ };
927
+ const reserveOutputPath = async (outputPath) => {
928
+ try {
929
+ await mkdir(outputPath);
930
+ }
931
+ catch (error) {
932
+ if (error.code === 'EEXIST') {
933
+ throw new CliContractError('FG-CLIENT-001', `Output path was created while the funnel was staged: ${outputPath}`);
934
+ }
935
+ throw error;
936
+ }
937
+ let handle;
938
+ try {
939
+ // Pin the reservation inode so delete/recreate cannot pass via inode reuse.
940
+ handle = await open(outputPath, 'r');
941
+ const [handleMetadata, pathMetadata] = await Promise.all([
942
+ handle.stat(),
943
+ lstat(outputPath),
944
+ ]);
945
+ if (!pathMetadata.isDirectory()
946
+ || pathMetadata.isSymbolicLink()
947
+ || pathMetadata.dev !== handleMetadata.dev
948
+ || pathMetadata.ino !== handleMetadata.ino) {
949
+ throw new CliContractError('FG-CLIENT-001', `Output path reservation changed while it was opened: ${outputPath}`);
950
+ }
951
+ return { dev: handleMetadata.dev, ino: handleMetadata.ino, handle };
952
+ }
953
+ catch (error) {
954
+ await handle?.close();
955
+ // The pathname could have changed while it was opened, so preserve it unless ownership
956
+ // was fully established and returned to the caller.
957
+ throw error;
958
+ }
959
+ };
960
+ const closeOutputPathReservation = async (reservation) => {
961
+ const handle = reservation.handle;
962
+ reservation.handle = null;
963
+ await handle?.close();
964
+ };
965
+ const removeOwnedOutputReservation = async (outputPath, reservation) => {
966
+ try {
967
+ const metadata = await lstat(outputPath);
968
+ if (!metadata.isDirectory()
969
+ || metadata.isSymbolicLink()
970
+ || metadata.dev !== reservation.dev
971
+ || metadata.ino !== reservation.ino
972
+ || (await readdir(outputPath)).length > 0) {
973
+ return;
974
+ }
975
+ await rmdir(outputPath);
976
+ }
977
+ catch (error) {
978
+ const code = error.code;
979
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
980
+ throw error;
981
+ }
982
+ };
983
+ const assertOwnedOutputReservation = async (outputPath, reservation, { requireEmpty = true } = {}) => {
984
+ let metadata;
985
+ let handleMetadata;
986
+ try {
987
+ [metadata, handleMetadata] = await Promise.all([
988
+ lstat(outputPath),
989
+ reservation.handle?.stat(),
990
+ ]);
991
+ }
992
+ catch {
993
+ throw new CliContractError('FG-CLIENT-001', `Output path reservation disappeared before commit: ${outputPath}`);
994
+ }
995
+ if (!metadata.isDirectory()
996
+ || metadata.isSymbolicLink()
997
+ || !handleMetadata
998
+ || metadata.dev !== reservation.dev
999
+ || metadata.ino !== reservation.ino
1000
+ || handleMetadata.dev !== reservation.dev
1001
+ || handleMetadata.ino !== reservation.ino
1002
+ || (requireEmpty && (await readdir(outputPath)).length > 0)) {
1003
+ throw new CliContractError('FG-CLIENT-001', `Output path reservation changed before commit: ${outputPath}`);
1004
+ }
1005
+ };
1006
+ export async function executeCreateFunnel(input) {
1007
+ assertCanonicalCreateTemplateSlug(input.templateSlug);
1008
+ const templatePath = path.resolve(input.templatePath ?? resolveCreateTemplatePath(input.templateSlug));
1009
+ const outputPath = path.resolve(input.outputPath);
1010
+ if (await localPathExists(outputPath)) {
1011
+ throw new CliContractError('FG-CLIENT-001', `Output path already exists: ${outputPath}`);
1012
+ }
1013
+ const hooks = input.hooks ?? defaultCreateHooks;
1014
+ await hooks.verifyScaffold(templatePath);
1015
+ await mkdir(path.dirname(outputPath), { recursive: true });
1016
+ const stageRoot = await mkdtemp(path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.fgrove-create-`));
1017
+ const stageDir = path.join(stageRoot, 'project');
1018
+ let outputReservation;
1019
+ try {
1020
+ await cp(templatePath, stageDir, { recursive: true, force: false, errorOnExist: true });
1021
+ await hooks.verifyScaffold(stageDir);
1022
+ await hooks.materializeAliases(stageDir);
1023
+ await hooks.installManagedFiles(stageDir);
1024
+ const updatedFiles = await hooks.reskin(stageDir, input.appName);
1025
+ await hooks.installDependencies(stageDir);
1026
+ const validation = await hooks.validate(stageDir);
1027
+ if (!validation.valid || validation.exitCode !== 0 || validation.diagnostics.length > 0) {
1028
+ throw new FunnelValidationError(validation);
1029
+ }
1030
+ await removeScaffoldTransientPaths(stageDir);
1031
+ outputReservation = await reserveOutputPath(outputPath);
1032
+ await hooks.beforeCommit?.(outputPath);
1033
+ await assertOwnedOutputReservation(outputPath, outputReservation);
1034
+ // Populate the mkdir-owned final directory without replacing its pathname. Besides keeping
1035
+ // the reservation identity intact, this avoids platform-specific directory rename behavior.
1036
+ for (const entry of await readdir(stageDir)) {
1037
+ await cp(path.join(stageDir, entry), path.join(outputPath, entry), {
1038
+ recursive: true,
1039
+ force: false,
1040
+ errorOnExist: true,
1041
+ mode: fsConstants.COPYFILE_EXCL,
1042
+ });
1043
+ }
1044
+ await assertOwnedOutputReservation(outputPath, outputReservation, { requireEmpty: false });
1045
+ await closeOutputPathReservation(outputReservation);
1046
+ outputReservation = undefined;
1047
+ return { outputPath, updatedFiles };
1048
+ }
1049
+ finally {
1050
+ if (outputReservation) {
1051
+ await closeOutputPathReservation(outputReservation);
1052
+ await removeOwnedOutputReservation(outputPath, outputReservation);
1053
+ }
1054
+ await rm(stageRoot, { recursive: true, force: true });
1055
+ }
1056
+ }
41
1057
  const GENERATED_CONFIG_PATH_BY_KIND = {
42
1058
  offerSets: 'src/config/offer-sets.generated.ts',
43
1059
  experiments: 'src/config/experiments.generated.ts',
@@ -88,6 +1104,13 @@ export function buildPatchSourceInput(input) {
88
1104
  deletedPaths: input.deletedPaths,
89
1105
  };
90
1106
  }
1107
+ export function formatSourceCandidateSyncSuccess(input) {
1108
+ const synced = `${input.syncedFileCount} ${input.syncedFileCount === 1 ? 'file' : 'files'}`;
1109
+ const deleted = input.deletedFileCount > 0
1110
+ ? ` and removed ${input.deletedFileCount} ${input.deletedFileCount === 1 ? 'file' : 'files'}`
1111
+ : '';
1112
+ return `Synced ${synced}${deleted} to draft version ${input.versionId}`;
1113
+ }
91
1114
  export function buildSyncOfferSetPlansInput(input) {
92
1115
  const offerSet = input.offerSet.trim();
93
1116
  if (!offerSet) {
@@ -185,6 +1208,18 @@ const readCodeFromStdin = async () => {
185
1208
  rl.close();
186
1209
  }
187
1210
  };
1211
+ const readDocsConfirmationFromStdin = async (question) => {
1212
+ const rl = createInterface({
1213
+ input: process.stdin,
1214
+ output: process.stderr,
1215
+ });
1216
+ try {
1217
+ return await rl.question(question);
1218
+ }
1219
+ finally {
1220
+ rl.close();
1221
+ }
1222
+ };
188
1223
  const getCurrentUser = async (token) => {
189
1224
  return callApi({
190
1225
  path: 'auth.me',
@@ -396,7 +1431,7 @@ const syncGeneratedConfigFilesForCli = async (input) => {
396
1431
  return files;
397
1432
  };
398
1433
  const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
399
- const program = new Command();
1434
+ export const program = new Command();
400
1435
  program
401
1436
  .name('fgrove')
402
1437
  .description('FunnelsGrove CLI for editing, syncing, and publishing funnels')
@@ -508,6 +1543,10 @@ addExamples(program
508
1543
  console.log(`localFunnel\t${manifest.funnelId}`);
509
1544
  console.log(`localDraft\t${manifest.draftVersionId}`);
510
1545
  }
1546
+ const legacyDiagnostic = await auditLegacyFunnel(path.resolve(process.cwd(), options.dir));
1547
+ if (legacyDiagnostic) {
1548
+ console.log(`${legacyDiagnostic.code}\t${legacyDiagnostic.message}`);
1549
+ }
511
1550
  });
512
1551
  const projectsCommand = addExamples(program.command('projects').description('Manage projects'), [
513
1552
  'fgrove projects list',
@@ -684,8 +1723,8 @@ const loadProjectAnalyticsContext = async (options) => {
684
1723
  commandInput: buildAnalyticsCommandInput(options),
685
1724
  };
686
1725
  };
687
- const fetchStepDropoffAnalytics = async (input) => {
688
- return callApi({
1726
+ const fetchStepDropoffAnalytics = async (input, apiCall = callApi) => {
1727
+ return apiCall({
689
1728
  path: 'projects.analyticsStepDropoff',
690
1729
  type: 'query',
691
1730
  token: input.token,
@@ -699,6 +1738,25 @@ const fetchStepDropoffAnalytics = async (input) => {
699
1738
  },
700
1739
  });
701
1740
  };
1741
+ const fetchPublishedFunnelFlow = async (input, apiCall = callApi) => {
1742
+ return apiCall({
1743
+ path: 'projects.publishedFunnelFlow',
1744
+ type: 'query',
1745
+ token: input.token,
1746
+ data: {
1747
+ workspaceId: input.workspaceId,
1748
+ projectId: input.projectId,
1749
+ funnelId: input.funnelId,
1750
+ },
1751
+ });
1752
+ };
1753
+ export const fetchFunnelPathAnalytics = async (input, apiCall = callApi) => {
1754
+ const [stepDropoff, publishedFunnelFlow] = await Promise.all([
1755
+ fetchStepDropoffAnalytics(input, apiCall),
1756
+ fetchPublishedFunnelFlow(input, apiCall),
1757
+ ]);
1758
+ return { stepDropoff, publishedFunnelFlow };
1759
+ };
702
1760
  addExamples(analyticsCommand
703
1761
  .command('conversions')
704
1762
  .description('Download one-day conversion data, full funnel path, and step transitions')
@@ -714,7 +1772,7 @@ addExamples(analyticsCommand
714
1772
  ])
715
1773
  .action(async (options) => {
716
1774
  const context = await loadFunnelAnalyticsContext(options);
717
- const [overview, stepDropoff] = await Promise.all([
1775
+ const [overview, pathAnalytics] = await Promise.all([
718
1776
  callApi({
719
1777
  path: 'projects.analyticsOverview',
720
1778
  type: 'query',
@@ -729,7 +1787,7 @@ addExamples(analyticsCommand
729
1787
  limit: 20_000,
730
1788
  },
731
1789
  }),
732
- fetchStepDropoffAnalytics({
1790
+ fetchFunnelPathAnalytics({
733
1791
  token: context.token,
734
1792
  workspaceId: context.workspace.id,
735
1793
  projectId: context.project.id,
@@ -737,6 +1795,7 @@ addExamples(analyticsCommand
737
1795
  commandInput: context.commandInput,
738
1796
  }),
739
1797
  ]);
1798
+ const { stepDropoff, publishedFunnelFlow } = pathAnalytics;
740
1799
  assertHasConversionData(context.commandInput.date, overview, stepDropoff);
741
1800
  const payload = buildConversionReportPayload({
742
1801
  date: context.commandInput.date,
@@ -744,6 +1803,7 @@ addExamples(analyticsCommand
744
1803
  project: context.project,
745
1804
  overview,
746
1805
  stepDropoff,
1806
+ publishedFunnelFlow,
747
1807
  });
748
1808
  await writeOrPrintAnalyticsOutput({
749
1809
  format: context.commandInput.format,
@@ -752,6 +1812,7 @@ addExamples(analyticsCommand
752
1812
  date: context.commandInput.date,
753
1813
  overview,
754
1814
  stepDropoff,
1815
+ publishedFunnelFlow,
755
1816
  }),
756
1817
  out: options.out,
757
1818
  });
@@ -771,19 +1832,20 @@ addExamples(analyticsCommand
771
1832
  ])
772
1833
  .action(async (options) => {
773
1834
  const context = await loadFunnelAnalyticsContext(options);
774
- const stepDropoff = await fetchStepDropoffAnalytics({
1835
+ const { stepDropoff, publishedFunnelFlow } = await fetchFunnelPathAnalytics({
775
1836
  token: context.token,
776
1837
  workspaceId: context.workspace.id,
777
1838
  projectId: context.project.id,
778
1839
  funnelId: context.funnel.id,
779
1840
  commandInput: context.commandInput,
780
1841
  });
781
- assertHasFunnelPathData(context.commandInput.date, stepDropoff);
1842
+ assertHasFunnelPathData(context.commandInput.date, stepDropoff, publishedFunnelFlow);
782
1843
  const payload = buildFunnelPathReportPayload({
783
1844
  date: context.commandInput.date,
784
1845
  workspaceId: context.workspace.id,
785
1846
  project: context.project,
786
1847
  stepDropoff,
1848
+ publishedFunnelFlow,
787
1849
  });
788
1850
  await writeOrPrintAnalyticsOutput({
789
1851
  format: context.commandInput.format,
@@ -791,6 +1853,7 @@ addExamples(analyticsCommand
791
1853
  table: formatFunnelPathTable({
792
1854
  date: context.commandInput.date,
793
1855
  stepDropoff,
1856
+ publishedFunnelFlow,
794
1857
  }),
795
1858
  out: options.out,
796
1859
  });
@@ -918,11 +1981,26 @@ addExamples(syncCommand
918
1981
  });
919
1982
  await writeLocalEnvFile(target.sourceDir, result.envFile);
920
1983
  await ensureGitignore(target.sourceDir);
921
- await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
922
- workspaceId: target.workspaceId,
923
- funnelId: target.funnelId,
924
- draftVersionId: result.draftVersionId,
925
- }));
1984
+ await runSyncDownLocalLifecycle({
1985
+ recoverMigration: async () => recoverStepContractV2Migration(target.sourceDir),
1986
+ recoverDocs: async () => recoverTemplateDocs(target.sourceDir),
1987
+ refreshDocs: async () => {
1988
+ const docs = await executeDocsCommand({
1989
+ targetDir: target.sourceDir,
1990
+ options: { json: true },
1991
+ });
1992
+ if (docs.exitCode !== 0) {
1993
+ const output = JSON.parse(docs.output);
1994
+ const diagnostic = output.diagnostics?.[0];
1995
+ throw new CliContractError(diagnostic?.code ?? 'FG-DOC-001', diagnostic?.message ?? 'Managed documentation refresh failed after sync down.');
1996
+ }
1997
+ },
1998
+ writeSyncState: async () => writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
1999
+ workspaceId: target.workspaceId,
2000
+ funnelId: target.funnelId,
2001
+ draftVersionId: result.draftVersionId,
2002
+ })),
2003
+ });
926
2004
  console.log(`Synced ${result.files.length} files to ${target.sourceDir}`);
927
2005
  console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
928
2006
  });
@@ -971,6 +2049,8 @@ addExamples(syncCommand
971
2049
  'fgrove sync up --dir ./claimbee-ios --message "Update paywall"',
972
2050
  ])
973
2051
  .action(async (options) => {
2052
+ const sourceDir = path.resolve(process.cwd(), options.dir);
2053
+ await assertFunnelValidForMutation(sourceDir);
974
2054
  const token = await readAuthToken();
975
2055
  const target = await resolveSyncTarget({
976
2056
  token,
@@ -1001,33 +2081,63 @@ addExamples(syncCommand
1001
2081
  return;
1002
2082
  }
1003
2083
  let result = null;
2084
+ let uploadedManifestFiles = null;
1004
2085
  let syncedFileCount = 0;
1005
2086
  let deletedFileCount = 0;
1006
2087
  if (changes) {
2088
+ uploadedManifestFiles = changes.currentManifest.files;
1007
2089
  console.log(formatSyncUploadSummary(changes).join('\n'));
1008
- const batches = chunkChangedSourceFiles(changes);
1009
- let baseDraftVersionId = changes.currentManifest.draftVersionId;
1010
- for (const batch of batches) {
1011
- result = await callApi({
1012
- path: 'funnels.patchSource',
1013
- type: 'mutation',
1014
- token,
1015
- data: buildPatchSourceInput({
1016
- workspaceId: target.workspaceId,
1017
- funnelId: target.funnelId,
1018
- message: options.message,
1019
- baseDraftVersionId,
1020
- files: batch.files,
1021
- deletedPaths: batch.deletedPaths,
2090
+ const cancellation = new AbortController();
2091
+ const cancel = () => cancellation.abort();
2092
+ process.once('SIGINT', cancel);
2093
+ process.once('SIGTERM', cancel);
2094
+ try {
2095
+ const candidateResult = await runSourceCandidateSync({
2096
+ workspaceId: target.workspaceId,
2097
+ funnelId: target.funnelId,
2098
+ baseDraftVersionId: changes.currentManifest.draftVersionId,
2099
+ message: options.message,
2100
+ operations: buildSourceCandidateOperations({
2101
+ previousFiles: target.manifest?.files ?? [],
2102
+ files: changes.files,
2103
+ deletedPaths: changes.deletedPaths,
1022
2104
  }),
2105
+ signal: cancellation.signal,
2106
+ api: {
2107
+ begin: (input) => beginFunnelSourceCandidate({
2108
+ apiUrl: getApiUrl(),
2109
+ token,
2110
+ signal: cancellation.signal,
2111
+ }, input),
2112
+ stage: (input) => stageFunnelSourceCandidate({
2113
+ apiUrl: getApiUrl(),
2114
+ token,
2115
+ signal: cancellation.signal,
2116
+ }, input),
2117
+ finalize: (input) => finalizeFunnelSourceCandidate({
2118
+ apiUrl: getApiUrl(),
2119
+ token,
2120
+ signal: cancellation.signal,
2121
+ }, input),
2122
+ abort: (input) => abortFunnelSourceCandidate({
2123
+ apiUrl: getApiUrl(),
2124
+ token,
2125
+ }, input),
2126
+ },
1023
2127
  });
1024
- baseDraftVersionId = result.versionId;
1025
- syncedFileCount += result.syncedFiles.length;
1026
- deletedFileCount += result.deletedFiles?.length || 0;
2128
+ result = { versionId: candidateResult.versionId };
2129
+ }
2130
+ finally {
2131
+ process.off('SIGINT', cancel);
2132
+ process.off('SIGTERM', cancel);
1027
2133
  }
2134
+ syncedFileCount = changes.files.length;
2135
+ deletedFileCount = changes.deletedPaths.length;
1028
2136
  }
1029
2137
  else {
1030
- const files = await collectSourceFiles(target.sourceDir);
2138
+ const snapshot = await collectSourceSnapshot(target.sourceDir);
2139
+ const files = snapshot.sourceFiles;
2140
+ uploadedManifestFiles = snapshot.manifestFiles;
1031
2141
  console.log(formatSyncUploadSummary({ deletedPaths: [], files }).join('\n'));
1032
2142
  result = await callApi({
1033
2143
  path: 'funnels.importSource',
@@ -1040,24 +2150,31 @@ addExamples(syncCommand
1040
2150
  files,
1041
2151
  },
1042
2152
  });
1043
- syncedFileCount = result.syncedFiles.length;
2153
+ syncedFileCount = result.syncedFiles?.length ?? files.length;
1044
2154
  deletedFileCount = result.deletedFiles?.length || 0;
1045
2155
  }
1046
2156
  if (!result) {
1047
2157
  throw new Error('No sync result returned.');
1048
2158
  }
1049
- await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
2159
+ if (!uploadedManifestFiles) {
2160
+ throw new Error('No uploaded source snapshot was recorded.');
2161
+ }
2162
+ await writeSyncManifest(target.sourceDir, buildCommittedSyncManifest({
1050
2163
  workspaceId: target.workspaceId,
1051
2164
  funnelId: target.funnelId,
1052
2165
  draftVersionId: result.versionId,
2166
+ files: uploadedManifestFiles,
1053
2167
  }));
1054
2168
  await syncGitHubDraftForCli({
1055
2169
  token,
1056
2170
  workspaceId: target.workspaceId,
1057
2171
  funnelId: target.funnelId,
1058
2172
  });
1059
- const deletedSummary = deletedFileCount > 0 ? ` and removed ${deletedFileCount} files` : '';
1060
- console.log(`Synced ${syncedFileCount} files${deletedSummary} to draft v${result.versionSeq} (${result.versionId})`);
2173
+ console.log(formatSourceCandidateSyncSuccess({
2174
+ syncedFileCount,
2175
+ deletedFileCount,
2176
+ versionId: result.versionId,
2177
+ }));
1061
2178
  });
1062
2179
  addExamples(program
1063
2180
  .command('publish')
@@ -1072,13 +2189,6 @@ addExamples(program
1072
2189
  'fgrove publish --env production --domain claimbee.example.com --message "Launch"',
1073
2190
  ])
1074
2191
  .action(async (options) => {
1075
- const token = await readAuthToken();
1076
- const target = await resolveSyncTarget({
1077
- token,
1078
- workspace: options.workspace,
1079
- funnel: options.funnel,
1080
- dir: options.dir,
1081
- });
1082
2192
  const publishEnv = options.env.trim().toLowerCase();
1083
2193
  if (publishEnv !== 'preview' && publishEnv !== 'production') {
1084
2194
  throw new Error('--env must be preview or production');
@@ -1086,6 +2196,15 @@ addExamples(program
1086
2196
  if (publishEnv === 'production' && !options.domain?.trim()) {
1087
2197
  throw new Error('--domain is required when publishing production');
1088
2198
  }
2199
+ const sourceDir = path.resolve(process.cwd(), options.dir);
2200
+ await assertFunnelValidForMutation(sourceDir);
2201
+ const token = await readAuthToken();
2202
+ const target = await resolveSyncTarget({
2203
+ token,
2204
+ workspace: options.workspace,
2205
+ funnel: options.funnel,
2206
+ dir: options.dir,
2207
+ });
1089
2208
  await syncGitHubDraftForCli({
1090
2209
  token,
1091
2210
  workspaceId: target.workspaceId,
@@ -1234,49 +2353,93 @@ addExamples(githubCommand
1234
2353
  });
1235
2354
  printValueRows(formatGitHubJobRows(result));
1236
2355
  });
2356
+ addExamples(program
2357
+ .command('validate')
2358
+ .description('Validate a local funnel against the current authoring contract')
2359
+ .option('--dir <path>', 'Local funnel directory', '.')
2360
+ .option('--json', 'Emit one machine-readable validation object'), [
2361
+ 'fgrove validate',
2362
+ 'fgrove validate --dir ./claimbee-ios --json',
2363
+ ])
2364
+ .action(async (options) => {
2365
+ const result = await executeValidateCommand({
2366
+ targetDir: path.resolve(process.cwd(), options.dir),
2367
+ json: options.json,
2368
+ });
2369
+ process.stdout.write(result.output);
2370
+ process.exitCode = result.exitCode;
2371
+ });
2372
+ const migrateCommand = program.command('migrate').description('Migrate local funnel contracts');
2373
+ addExamples(migrateCommand
2374
+ .command('step-contract')
2375
+ .description('Plan or apply the version-3 step contract migration')
2376
+ .requiredOption('--to <version>', 'Target step contract version')
2377
+ .option('--dir <path>', 'Local funnel directory', '.')
2378
+ .option('--check', 'Plan the migration without writing (default)')
2379
+ .option('--apply', 'Apply the migration transactionally')
2380
+ .option('--json', 'Emit one machine-readable migration object'), [
2381
+ 'fgrove migrate step-contract --to 3',
2382
+ 'fgrove migrate step-contract --to 3 --apply',
2383
+ ])
2384
+ .action(async (options) => {
2385
+ const result = await executeStepContractMigrationCommand({
2386
+ targetDir: path.resolve(process.cwd(), options.dir),
2387
+ to: options.to,
2388
+ options,
2389
+ });
2390
+ process.stdout.write(result.output);
2391
+ process.exitCode = result.exitCode;
2392
+ });
1237
2393
  addExamples(program
1238
2394
  .command('docs')
1239
2395
  .description('Install or refresh local funnel editing docs in a funnel directory')
1240
- .option('--dir <path>', 'Local funnel directory', '.'), [
2396
+ .option('--dir <path>', 'Local funnel directory', '.')
2397
+ .option('--check', 'Report documentation drift without writing')
2398
+ .option('--diff', 'Show proposed managed documentation changes')
2399
+ .option('--resolve <path>', 'Resolve one conflicted managed documentation path')
2400
+ .option('--replace-managed', 'Confirm that --resolve may replace the managed path')
2401
+ .option('--json', 'Emit machine-readable documentation status'), [
1241
2402
  'fgrove docs',
1242
2403
  'fgrove docs --dir ./claimbee-ios',
2404
+ 'fgrove docs --check --json',
2405
+ 'fgrove docs --diff',
1243
2406
  ])
1244
2407
  .action(async (options) => {
1245
2408
  const targetDir = path.resolve(process.cwd(), options.dir);
1246
- const copiedFiles = await syncTemplateDocs(resolveTemplateDocsPath(), targetDir);
1247
- console.log(`Synced ${copiedFiles.length} docs files to ${targetDir}`);
2409
+ const result = await executeDocsCommand({
2410
+ targetDir,
2411
+ options,
2412
+ auditLegacy: auditLegacyFunnel,
2413
+ isTTY: process.stdin.isTTY === true && process.stdout.isTTY === true,
2414
+ prompt: readDocsConfirmationFromStdin,
2415
+ });
2416
+ process.stdout.write(result.output);
2417
+ process.exitCode = result.exitCode;
1248
2418
  });
1249
2419
  addExamples(program
1250
2420
  .command('create')
1251
- .description('Create a new funnel from a template')
1252
- .requiredOption('--from <template>', 'Template slug from rag-catalog')
2421
+ .description('Create a new funnel from the canonical funnel template')
2422
+ .option('--from <template>', 'Template slug; defaults to the canonical funnel-template', CANONICAL_CREATE_TEMPLATE_SLUG)
1253
2423
  .requiredOption('--app <name>', 'Your app name')
1254
2424
  .option('--output <dir>', 'Output directory'), [
1255
- 'fgrove create --from headway-funnel --app ClaimBee',
1256
- 'fgrove create --from promova --app ClaimBee --output ./claimbee-funnel',
2425
+ 'fgrove create --app ClaimBee',
2426
+ 'fgrove create --app ClaimBee --output ./claimbee-funnel',
1257
2427
  ])
1258
2428
  .action(async (options) => {
1259
2429
  const { from: templateSlug, app: appName, output } = options;
1260
- if (!isKnownTemplate(templateSlug)) {
1261
- console.error(`Unknown template "${templateSlug}". Available templates:\n${KNOWN_TEMPLATE_SLUGS.map((s) => ` - ${s}`).join('\n')}`);
1262
- process.exit(1);
1263
- }
1264
- const templatePath = resolveTemplatePath(templateSlug);
2430
+ assertCanonicalCreateTemplateSlug(templateSlug);
1265
2431
  const outputDir = output ?? `./${toKebabCase(appName)}-funnel`;
1266
2432
  const outputPath = path.resolve(process.cwd(), outputDir);
1267
2433
  console.log(`Creating funnel from "${templateSlug}" for "${appName}"...`);
1268
- try {
1269
- await cp(templatePath, outputPath, { recursive: true });
1270
- }
1271
- catch (err) {
1272
- console.error(`Failed to copy template: ${err instanceof Error ? err.message : String(err)}`);
1273
- process.exit(1);
1274
- }
1275
- const updated = await reskinFunnel(outputPath, appName);
2434
+ const result = await executeCreateFunnel({
2435
+ templateSlug,
2436
+ outputPath,
2437
+ appName,
2438
+ });
1276
2439
  console.log(`\nCreated funnel at ${outputDir}`);
1277
- if (updated.length > 0) {
1278
- console.log(`Updated ${updated.length} files with "${appName}" branding:`);
1279
- for (const file of updated) {
2440
+ if (result.updatedFiles.length > 0) {
2441
+ console.log(`Updated ${result.updatedFiles.length} files with "${appName}" branding:`);
2442
+ for (const file of result.updatedFiles) {
1280
2443
  console.log(` - ${file}`);
1281
2444
  }
1282
2445
  }