@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/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,1018 @@ 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
+ await cp(stageDir, outputPath, {
1037
+ recursive: true,
1038
+ force: false,
1039
+ errorOnExist: true,
1040
+ mode: fsConstants.COPYFILE_EXCL,
1041
+ });
1042
+ await assertOwnedOutputReservation(outputPath, outputReservation, { requireEmpty: false });
1043
+ await closeOutputPathReservation(outputReservation);
1044
+ outputReservation = undefined;
1045
+ return { outputPath, updatedFiles };
1046
+ }
1047
+ finally {
1048
+ if (outputReservation) {
1049
+ await closeOutputPathReservation(outputReservation);
1050
+ await removeOwnedOutputReservation(outputPath, outputReservation);
1051
+ }
1052
+ await rm(stageRoot, { recursive: true, force: true });
1053
+ }
1054
+ }
41
1055
  const GENERATED_CONFIG_PATH_BY_KIND = {
42
1056
  offerSets: 'src/config/offer-sets.generated.ts',
43
1057
  experiments: 'src/config/experiments.generated.ts',
@@ -88,6 +1102,13 @@ export function buildPatchSourceInput(input) {
88
1102
  deletedPaths: input.deletedPaths,
89
1103
  };
90
1104
  }
1105
+ export function formatSourceCandidateSyncSuccess(input) {
1106
+ const synced = `${input.syncedFileCount} ${input.syncedFileCount === 1 ? 'file' : 'files'}`;
1107
+ const deleted = input.deletedFileCount > 0
1108
+ ? ` and removed ${input.deletedFileCount} ${input.deletedFileCount === 1 ? 'file' : 'files'}`
1109
+ : '';
1110
+ return `Synced ${synced}${deleted} to draft version ${input.versionId}`;
1111
+ }
91
1112
  export function buildSyncOfferSetPlansInput(input) {
92
1113
  const offerSet = input.offerSet.trim();
93
1114
  if (!offerSet) {
@@ -185,6 +1206,18 @@ const readCodeFromStdin = async () => {
185
1206
  rl.close();
186
1207
  }
187
1208
  };
1209
+ const readDocsConfirmationFromStdin = async (question) => {
1210
+ const rl = createInterface({
1211
+ input: process.stdin,
1212
+ output: process.stderr,
1213
+ });
1214
+ try {
1215
+ return await rl.question(question);
1216
+ }
1217
+ finally {
1218
+ rl.close();
1219
+ }
1220
+ };
188
1221
  const getCurrentUser = async (token) => {
189
1222
  return callApi({
190
1223
  path: 'auth.me',
@@ -396,7 +1429,7 @@ const syncGeneratedConfigFilesForCli = async (input) => {
396
1429
  return files;
397
1430
  };
398
1431
  const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
399
- const program = new Command();
1432
+ export const program = new Command();
400
1433
  program
401
1434
  .name('fgrove')
402
1435
  .description('FunnelsGrove CLI for editing, syncing, and publishing funnels')
@@ -508,6 +1541,10 @@ addExamples(program
508
1541
  console.log(`localFunnel\t${manifest.funnelId}`);
509
1542
  console.log(`localDraft\t${manifest.draftVersionId}`);
510
1543
  }
1544
+ const legacyDiagnostic = await auditLegacyFunnel(path.resolve(process.cwd(), options.dir));
1545
+ if (legacyDiagnostic) {
1546
+ console.log(`${legacyDiagnostic.code}\t${legacyDiagnostic.message}`);
1547
+ }
511
1548
  });
512
1549
  const projectsCommand = addExamples(program.command('projects').description('Manage projects'), [
513
1550
  'fgrove projects list',
@@ -684,8 +1721,8 @@ const loadProjectAnalyticsContext = async (options) => {
684
1721
  commandInput: buildAnalyticsCommandInput(options),
685
1722
  };
686
1723
  };
687
- const fetchStepDropoffAnalytics = async (input) => {
688
- return callApi({
1724
+ const fetchStepDropoffAnalytics = async (input, apiCall = callApi) => {
1725
+ return apiCall({
689
1726
  path: 'projects.analyticsStepDropoff',
690
1727
  type: 'query',
691
1728
  token: input.token,
@@ -699,6 +1736,25 @@ const fetchStepDropoffAnalytics = async (input) => {
699
1736
  },
700
1737
  });
701
1738
  };
1739
+ const fetchPublishedFunnelFlow = async (input, apiCall = callApi) => {
1740
+ return apiCall({
1741
+ path: 'projects.publishedFunnelFlow',
1742
+ type: 'query',
1743
+ token: input.token,
1744
+ data: {
1745
+ workspaceId: input.workspaceId,
1746
+ projectId: input.projectId,
1747
+ funnelId: input.funnelId,
1748
+ },
1749
+ });
1750
+ };
1751
+ export const fetchFunnelPathAnalytics = async (input, apiCall = callApi) => {
1752
+ const [stepDropoff, publishedFunnelFlow] = await Promise.all([
1753
+ fetchStepDropoffAnalytics(input, apiCall),
1754
+ fetchPublishedFunnelFlow(input, apiCall),
1755
+ ]);
1756
+ return { stepDropoff, publishedFunnelFlow };
1757
+ };
702
1758
  addExamples(analyticsCommand
703
1759
  .command('conversions')
704
1760
  .description('Download one-day conversion data, full funnel path, and step transitions')
@@ -714,7 +1770,7 @@ addExamples(analyticsCommand
714
1770
  ])
715
1771
  .action(async (options) => {
716
1772
  const context = await loadFunnelAnalyticsContext(options);
717
- const [overview, stepDropoff] = await Promise.all([
1773
+ const [overview, pathAnalytics] = await Promise.all([
718
1774
  callApi({
719
1775
  path: 'projects.analyticsOverview',
720
1776
  type: 'query',
@@ -729,7 +1785,7 @@ addExamples(analyticsCommand
729
1785
  limit: 20_000,
730
1786
  },
731
1787
  }),
732
- fetchStepDropoffAnalytics({
1788
+ fetchFunnelPathAnalytics({
733
1789
  token: context.token,
734
1790
  workspaceId: context.workspace.id,
735
1791
  projectId: context.project.id,
@@ -737,6 +1793,7 @@ addExamples(analyticsCommand
737
1793
  commandInput: context.commandInput,
738
1794
  }),
739
1795
  ]);
1796
+ const { stepDropoff, publishedFunnelFlow } = pathAnalytics;
740
1797
  assertHasConversionData(context.commandInput.date, overview, stepDropoff);
741
1798
  const payload = buildConversionReportPayload({
742
1799
  date: context.commandInput.date,
@@ -744,6 +1801,7 @@ addExamples(analyticsCommand
744
1801
  project: context.project,
745
1802
  overview,
746
1803
  stepDropoff,
1804
+ publishedFunnelFlow,
747
1805
  });
748
1806
  await writeOrPrintAnalyticsOutput({
749
1807
  format: context.commandInput.format,
@@ -752,6 +1810,7 @@ addExamples(analyticsCommand
752
1810
  date: context.commandInput.date,
753
1811
  overview,
754
1812
  stepDropoff,
1813
+ publishedFunnelFlow,
755
1814
  }),
756
1815
  out: options.out,
757
1816
  });
@@ -771,19 +1830,20 @@ addExamples(analyticsCommand
771
1830
  ])
772
1831
  .action(async (options) => {
773
1832
  const context = await loadFunnelAnalyticsContext(options);
774
- const stepDropoff = await fetchStepDropoffAnalytics({
1833
+ const { stepDropoff, publishedFunnelFlow } = await fetchFunnelPathAnalytics({
775
1834
  token: context.token,
776
1835
  workspaceId: context.workspace.id,
777
1836
  projectId: context.project.id,
778
1837
  funnelId: context.funnel.id,
779
1838
  commandInput: context.commandInput,
780
1839
  });
781
- assertHasFunnelPathData(context.commandInput.date, stepDropoff);
1840
+ assertHasFunnelPathData(context.commandInput.date, stepDropoff, publishedFunnelFlow);
782
1841
  const payload = buildFunnelPathReportPayload({
783
1842
  date: context.commandInput.date,
784
1843
  workspaceId: context.workspace.id,
785
1844
  project: context.project,
786
1845
  stepDropoff,
1846
+ publishedFunnelFlow,
787
1847
  });
788
1848
  await writeOrPrintAnalyticsOutput({
789
1849
  format: context.commandInput.format,
@@ -791,6 +1851,7 @@ addExamples(analyticsCommand
791
1851
  table: formatFunnelPathTable({
792
1852
  date: context.commandInput.date,
793
1853
  stepDropoff,
1854
+ publishedFunnelFlow,
794
1855
  }),
795
1856
  out: options.out,
796
1857
  });
@@ -918,11 +1979,26 @@ addExamples(syncCommand
918
1979
  });
919
1980
  await writeLocalEnvFile(target.sourceDir, result.envFile);
920
1981
  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
- }));
1982
+ await runSyncDownLocalLifecycle({
1983
+ recoverMigration: async () => recoverStepContractV2Migration(target.sourceDir),
1984
+ recoverDocs: async () => recoverTemplateDocs(target.sourceDir),
1985
+ refreshDocs: async () => {
1986
+ const docs = await executeDocsCommand({
1987
+ targetDir: target.sourceDir,
1988
+ options: { json: true },
1989
+ });
1990
+ if (docs.exitCode !== 0) {
1991
+ const output = JSON.parse(docs.output);
1992
+ const diagnostic = output.diagnostics?.[0];
1993
+ throw new CliContractError(diagnostic?.code ?? 'FG-DOC-001', diagnostic?.message ?? 'Managed documentation refresh failed after sync down.');
1994
+ }
1995
+ },
1996
+ writeSyncState: async () => writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
1997
+ workspaceId: target.workspaceId,
1998
+ funnelId: target.funnelId,
1999
+ draftVersionId: result.draftVersionId,
2000
+ })),
2001
+ });
926
2002
  console.log(`Synced ${result.files.length} files to ${target.sourceDir}`);
927
2003
  console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
928
2004
  });
@@ -971,6 +2047,8 @@ addExamples(syncCommand
971
2047
  'fgrove sync up --dir ./claimbee-ios --message "Update paywall"',
972
2048
  ])
973
2049
  .action(async (options) => {
2050
+ const sourceDir = path.resolve(process.cwd(), options.dir);
2051
+ await assertFunnelValidForMutation(sourceDir);
974
2052
  const token = await readAuthToken();
975
2053
  const target = await resolveSyncTarget({
976
2054
  token,
@@ -1001,33 +2079,63 @@ addExamples(syncCommand
1001
2079
  return;
1002
2080
  }
1003
2081
  let result = null;
2082
+ let uploadedManifestFiles = null;
1004
2083
  let syncedFileCount = 0;
1005
2084
  let deletedFileCount = 0;
1006
2085
  if (changes) {
2086
+ uploadedManifestFiles = changes.currentManifest.files;
1007
2087
  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,
2088
+ const cancellation = new AbortController();
2089
+ const cancel = () => cancellation.abort();
2090
+ process.once('SIGINT', cancel);
2091
+ process.once('SIGTERM', cancel);
2092
+ try {
2093
+ const candidateResult = await runSourceCandidateSync({
2094
+ workspaceId: target.workspaceId,
2095
+ funnelId: target.funnelId,
2096
+ baseDraftVersionId: changes.currentManifest.draftVersionId,
2097
+ message: options.message,
2098
+ operations: buildSourceCandidateOperations({
2099
+ previousFiles: target.manifest?.files ?? [],
2100
+ files: changes.files,
2101
+ deletedPaths: changes.deletedPaths,
1022
2102
  }),
2103
+ signal: cancellation.signal,
2104
+ api: {
2105
+ begin: (input) => beginFunnelSourceCandidate({
2106
+ apiUrl: getApiUrl(),
2107
+ token,
2108
+ signal: cancellation.signal,
2109
+ }, input),
2110
+ stage: (input) => stageFunnelSourceCandidate({
2111
+ apiUrl: getApiUrl(),
2112
+ token,
2113
+ signal: cancellation.signal,
2114
+ }, input),
2115
+ finalize: (input) => finalizeFunnelSourceCandidate({
2116
+ apiUrl: getApiUrl(),
2117
+ token,
2118
+ signal: cancellation.signal,
2119
+ }, input),
2120
+ abort: (input) => abortFunnelSourceCandidate({
2121
+ apiUrl: getApiUrl(),
2122
+ token,
2123
+ }, input),
2124
+ },
1023
2125
  });
1024
- baseDraftVersionId = result.versionId;
1025
- syncedFileCount += result.syncedFiles.length;
1026
- deletedFileCount += result.deletedFiles?.length || 0;
2126
+ result = { versionId: candidateResult.versionId };
2127
+ }
2128
+ finally {
2129
+ process.off('SIGINT', cancel);
2130
+ process.off('SIGTERM', cancel);
1027
2131
  }
2132
+ syncedFileCount = changes.files.length;
2133
+ deletedFileCount = changes.deletedPaths.length;
1028
2134
  }
1029
2135
  else {
1030
- const files = await collectSourceFiles(target.sourceDir);
2136
+ const snapshot = await collectSourceSnapshot(target.sourceDir);
2137
+ const files = snapshot.sourceFiles;
2138
+ uploadedManifestFiles = snapshot.manifestFiles;
1031
2139
  console.log(formatSyncUploadSummary({ deletedPaths: [], files }).join('\n'));
1032
2140
  result = await callApi({
1033
2141
  path: 'funnels.importSource',
@@ -1040,24 +2148,31 @@ addExamples(syncCommand
1040
2148
  files,
1041
2149
  },
1042
2150
  });
1043
- syncedFileCount = result.syncedFiles.length;
2151
+ syncedFileCount = result.syncedFiles?.length ?? files.length;
1044
2152
  deletedFileCount = result.deletedFiles?.length || 0;
1045
2153
  }
1046
2154
  if (!result) {
1047
2155
  throw new Error('No sync result returned.');
1048
2156
  }
1049
- await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
2157
+ if (!uploadedManifestFiles) {
2158
+ throw new Error('No uploaded source snapshot was recorded.');
2159
+ }
2160
+ await writeSyncManifest(target.sourceDir, buildCommittedSyncManifest({
1050
2161
  workspaceId: target.workspaceId,
1051
2162
  funnelId: target.funnelId,
1052
2163
  draftVersionId: result.versionId,
2164
+ files: uploadedManifestFiles,
1053
2165
  }));
1054
2166
  await syncGitHubDraftForCli({
1055
2167
  token,
1056
2168
  workspaceId: target.workspaceId,
1057
2169
  funnelId: target.funnelId,
1058
2170
  });
1059
- const deletedSummary = deletedFileCount > 0 ? ` and removed ${deletedFileCount} files` : '';
1060
- console.log(`Synced ${syncedFileCount} files${deletedSummary} to draft v${result.versionSeq} (${result.versionId})`);
2171
+ console.log(formatSourceCandidateSyncSuccess({
2172
+ syncedFileCount,
2173
+ deletedFileCount,
2174
+ versionId: result.versionId,
2175
+ }));
1061
2176
  });
1062
2177
  addExamples(program
1063
2178
  .command('publish')
@@ -1072,13 +2187,6 @@ addExamples(program
1072
2187
  'fgrove publish --env production --domain claimbee.example.com --message "Launch"',
1073
2188
  ])
1074
2189
  .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
2190
  const publishEnv = options.env.trim().toLowerCase();
1083
2191
  if (publishEnv !== 'preview' && publishEnv !== 'production') {
1084
2192
  throw new Error('--env must be preview or production');
@@ -1086,6 +2194,15 @@ addExamples(program
1086
2194
  if (publishEnv === 'production' && !options.domain?.trim()) {
1087
2195
  throw new Error('--domain is required when publishing production');
1088
2196
  }
2197
+ const sourceDir = path.resolve(process.cwd(), options.dir);
2198
+ await assertFunnelValidForMutation(sourceDir);
2199
+ const token = await readAuthToken();
2200
+ const target = await resolveSyncTarget({
2201
+ token,
2202
+ workspace: options.workspace,
2203
+ funnel: options.funnel,
2204
+ dir: options.dir,
2205
+ });
1089
2206
  await syncGitHubDraftForCli({
1090
2207
  token,
1091
2208
  workspaceId: target.workspaceId,
@@ -1234,49 +2351,93 @@ addExamples(githubCommand
1234
2351
  });
1235
2352
  printValueRows(formatGitHubJobRows(result));
1236
2353
  });
2354
+ addExamples(program
2355
+ .command('validate')
2356
+ .description('Validate a local funnel against the current authoring contract')
2357
+ .option('--dir <path>', 'Local funnel directory', '.')
2358
+ .option('--json', 'Emit one machine-readable validation object'), [
2359
+ 'fgrove validate',
2360
+ 'fgrove validate --dir ./claimbee-ios --json',
2361
+ ])
2362
+ .action(async (options) => {
2363
+ const result = await executeValidateCommand({
2364
+ targetDir: path.resolve(process.cwd(), options.dir),
2365
+ json: options.json,
2366
+ });
2367
+ process.stdout.write(result.output);
2368
+ process.exitCode = result.exitCode;
2369
+ });
2370
+ const migrateCommand = program.command('migrate').description('Migrate local funnel contracts');
2371
+ addExamples(migrateCommand
2372
+ .command('step-contract')
2373
+ .description('Plan or apply the version-3 step contract migration')
2374
+ .requiredOption('--to <version>', 'Target step contract version')
2375
+ .option('--dir <path>', 'Local funnel directory', '.')
2376
+ .option('--check', 'Plan the migration without writing (default)')
2377
+ .option('--apply', 'Apply the migration transactionally')
2378
+ .option('--json', 'Emit one machine-readable migration object'), [
2379
+ 'fgrove migrate step-contract --to 3',
2380
+ 'fgrove migrate step-contract --to 3 --apply',
2381
+ ])
2382
+ .action(async (options) => {
2383
+ const result = await executeStepContractMigrationCommand({
2384
+ targetDir: path.resolve(process.cwd(), options.dir),
2385
+ to: options.to,
2386
+ options,
2387
+ });
2388
+ process.stdout.write(result.output);
2389
+ process.exitCode = result.exitCode;
2390
+ });
1237
2391
  addExamples(program
1238
2392
  .command('docs')
1239
2393
  .description('Install or refresh local funnel editing docs in a funnel directory')
1240
- .option('--dir <path>', 'Local funnel directory', '.'), [
2394
+ .option('--dir <path>', 'Local funnel directory', '.')
2395
+ .option('--check', 'Report documentation drift without writing')
2396
+ .option('--diff', 'Show proposed managed documentation changes')
2397
+ .option('--resolve <path>', 'Resolve one conflicted managed documentation path')
2398
+ .option('--replace-managed', 'Confirm that --resolve may replace the managed path')
2399
+ .option('--json', 'Emit machine-readable documentation status'), [
1241
2400
  'fgrove docs',
1242
2401
  'fgrove docs --dir ./claimbee-ios',
2402
+ 'fgrove docs --check --json',
2403
+ 'fgrove docs --diff',
1243
2404
  ])
1244
2405
  .action(async (options) => {
1245
2406
  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}`);
2407
+ const result = await executeDocsCommand({
2408
+ targetDir,
2409
+ options,
2410
+ auditLegacy: auditLegacyFunnel,
2411
+ isTTY: process.stdin.isTTY === true && process.stdout.isTTY === true,
2412
+ prompt: readDocsConfirmationFromStdin,
2413
+ });
2414
+ process.stdout.write(result.output);
2415
+ process.exitCode = result.exitCode;
1248
2416
  });
1249
2417
  addExamples(program
1250
2418
  .command('create')
1251
- .description('Create a new funnel from a template')
1252
- .requiredOption('--from <template>', 'Template slug from rag-catalog')
2419
+ .description('Create a new funnel from the canonical funnel template')
2420
+ .option('--from <template>', 'Template slug; defaults to the canonical funnel-template', CANONICAL_CREATE_TEMPLATE_SLUG)
1253
2421
  .requiredOption('--app <name>', 'Your app name')
1254
2422
  .option('--output <dir>', 'Output directory'), [
1255
- 'fgrove create --from headway-funnel --app ClaimBee',
1256
- 'fgrove create --from promova --app ClaimBee --output ./claimbee-funnel',
2423
+ 'fgrove create --app ClaimBee',
2424
+ 'fgrove create --app ClaimBee --output ./claimbee-funnel',
1257
2425
  ])
1258
2426
  .action(async (options) => {
1259
2427
  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);
2428
+ assertCanonicalCreateTemplateSlug(templateSlug);
1265
2429
  const outputDir = output ?? `./${toKebabCase(appName)}-funnel`;
1266
2430
  const outputPath = path.resolve(process.cwd(), outputDir);
1267
2431
  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);
2432
+ const result = await executeCreateFunnel({
2433
+ templateSlug,
2434
+ outputPath,
2435
+ appName,
2436
+ });
1276
2437
  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) {
2438
+ if (result.updatedFiles.length > 0) {
2439
+ console.log(`Updated ${result.updatedFiles.length} files with "${appName}" branding:`);
2440
+ for (const file of result.updatedFiles) {
1280
2441
  console.log(` - ${file}`);
1281
2442
  }
1282
2443
  }