@funnelsgrove/cli 0.1.15 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/contracts/step-contract-v2.json +733 -0
- package/dist/analyticsOutput.d.ts +30 -15
- package/dist/analyticsOutput.js +45 -28
- package/dist/apiClient.d.ts +85 -0
- package/dist/apiClient.js +225 -8
- package/dist/cli.d.ts +149 -1
- package/dist/cli.js +1232 -71
- package/dist/diagnosticOutput.d.ts +42 -0
- package/dist/diagnosticOutput.js +83 -0
- package/dist/docsOutput.d.ts +19 -0
- package/dist/docsOutput.js +73 -0
- package/dist/docsTransaction.d.ts +81 -0
- package/dist/docsTransaction.js +1755 -0
- package/dist/funnelValidation.d.ts +14 -0
- package/dist/funnelValidation.js +297 -0
- package/dist/localSync.d.ts +79 -0
- package/dist/localSync.js +400 -29
- package/dist/projectValidator.d.ts +27 -0
- package/dist/projectValidator.js +208 -0
- package/dist/reskin.js +27 -1
- package/dist/stepContractMigration.d.ts +71 -0
- package/dist/stepContractMigration.js +1323 -0
- package/dist/templateDocs.js +8 -25
- package/docs-release-history.json +66 -0
- package/legacy-docs-catalog.json +1012 -0
- package/package.json +13 -4
- package/template_docs/.funnelsgrove-docs.json +144 -0
- package/template_docs/AGENTS.md +30 -58
- package/template_docs/CLAUDE.md +0 -2
- package/template_docs/docs/funnelsgrove/START-HERE.md +73 -0
- package/template_docs/docs/funnelsgrove/contracts/analytics-events.md +59 -0
- package/template_docs/docs/funnelsgrove/contracts/content-answers.md +44 -0
- package/template_docs/docs/funnelsgrove/contracts/flow-routing.md +41 -0
- package/template_docs/docs/funnelsgrove/contracts/payments.md +42 -0
- package/template_docs/docs/funnelsgrove/contracts/step-metadata.md +57 -0
- package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +39 -0
- package/template_docs/docs/funnelsgrove/qa/analytics.md +43 -0
- package/template_docs/docs/funnelsgrove/qa/local.md +42 -0
- package/template_docs/docs/funnelsgrove/qa/paywall-checkout.md +55 -0
- package/template_docs/docs/funnelsgrove/qa/publish.md +32 -0
- package/template_docs/docs/funnelsgrove/recipes/add-experiment.md +34 -0
- package/template_docs/docs/funnelsgrove/recipes/add-step.md +33 -0
- package/template_docs/docs/funnelsgrove/recipes/edit-step.md +31 -0
- package/template_docs/docs/funnelsgrove/steps/INDEX.md +26 -0
- package/template_docs/docs/funnelsgrove/steps/cancellation_offer.md +112 -0
- package/template_docs/docs/funnelsgrove/steps/checkout.md +120 -0
- package/template_docs/docs/funnelsgrove/steps/form_input.md +115 -0
- package/template_docs/docs/funnelsgrove/steps/intro_hero.md +110 -0
- package/template_docs/docs/funnelsgrove/steps/multi_select_choice.md +123 -0
- package/template_docs/docs/funnelsgrove/steps/paywall_offer.md +115 -0
- package/template_docs/docs/funnelsgrove/steps/progress_interstitial.md +111 -0
- package/template_docs/docs/funnelsgrove/steps/purchase_completed.md +117 -0
- package/template_docs/docs/funnelsgrove/steps/single_step_choice.md +122 -0
- package/template_docs/docs/funnelsgrove/steps/single_step_choice_emoji.md +122 -0
- package/template_docs/docs/funnelsgrove/steps/social_proof.md +110 -0
- package/template_docs/docs/funnelsgrove/steps/subscription_handoff.md +113 -0
- package/template_docs/docs/funnelsgrove/steps/subscription_management.md +112 -0
- package/template_docs/docs/funnelsgrove/steps/summary_confirmation.md +110 -0
- package/template_docs/docs/funnelsgrove/steps/upsell_offer.md +112 -0
- package/template_docs/docs/funnelsgrove/steps/value_prop_story.md +110 -0
- package/template_docs/funnel-docs.config.json +43 -0
- package/template_scaffold/.env.example +41 -0
- package/template_scaffold/.funnelsgrove-contract-tools.json +19 -0
- package/template_scaffold/.funnelsgrove-docs.json +144 -0
- package/template_scaffold/.funnelsgrove-scaffold.json +1078 -0
- package/template_scaffold/AGENTS.md +42 -0
- package/template_scaffold/CLAUDE.md +1 -0
- package/template_scaffold/PLAN.md +81 -0
- package/template_scaffold/PRODUCT_SENSE.md +28 -0
- package/template_scaffold/context/build-context.md +34 -0
- package/template_scaffold/docs/AGENT_FUNNEL_IMPLEMENTATION_SPEC.md +5 -0
- package/template_scaffold/docs/ARCHITECTURE.md +5 -0
- package/template_scaffold/docs/FLOW_CONFIG_AND_ROUTING.md +5 -0
- package/template_scaffold/docs/PAYWALL_AND_PAYMENTS.md +5 -0
- package/template_scaffold/docs/README.md +5 -0
- package/template_scaffold/docs/funnelsgrove/START-HERE.md +73 -0
- package/template_scaffold/docs/funnelsgrove/contracts/analytics-events.md +59 -0
- package/template_scaffold/docs/funnelsgrove/contracts/content-answers.md +44 -0
- package/template_scaffold/docs/funnelsgrove/contracts/flow-routing.md +41 -0
- package/template_scaffold/docs/funnelsgrove/contracts/payments.md +42 -0
- package/template_scaffold/docs/funnelsgrove/contracts/step-metadata.md +57 -0
- package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +39 -0
- package/template_scaffold/docs/funnelsgrove/qa/analytics.md +43 -0
- package/template_scaffold/docs/funnelsgrove/qa/local.md +42 -0
- package/template_scaffold/docs/funnelsgrove/qa/paywall-checkout.md +55 -0
- package/template_scaffold/docs/funnelsgrove/qa/publish.md +32 -0
- package/template_scaffold/docs/funnelsgrove/recipes/add-experiment.md +34 -0
- package/template_scaffold/docs/funnelsgrove/recipes/add-step.md +33 -0
- package/template_scaffold/docs/funnelsgrove/recipes/edit-step.md +31 -0
- package/template_scaffold/docs/funnelsgrove/steps/INDEX.md +26 -0
- package/template_scaffold/docs/funnelsgrove/steps/cancellation_offer.md +112 -0
- package/template_scaffold/docs/funnelsgrove/steps/checkout.md +120 -0
- package/template_scaffold/docs/funnelsgrove/steps/form_input.md +115 -0
- package/template_scaffold/docs/funnelsgrove/steps/intro_hero.md +110 -0
- package/template_scaffold/docs/funnelsgrove/steps/multi_select_choice.md +123 -0
- package/template_scaffold/docs/funnelsgrove/steps/paywall_offer.md +115 -0
- package/template_scaffold/docs/funnelsgrove/steps/progress_interstitial.md +111 -0
- package/template_scaffold/docs/funnelsgrove/steps/purchase_completed.md +117 -0
- package/template_scaffold/docs/funnelsgrove/steps/single_step_choice.md +122 -0
- package/template_scaffold/docs/funnelsgrove/steps/single_step_choice_emoji.md +122 -0
- package/template_scaffold/docs/funnelsgrove/steps/social_proof.md +110 -0
- package/template_scaffold/docs/funnelsgrove/steps/subscription_handoff.md +113 -0
- package/template_scaffold/docs/funnelsgrove/steps/subscription_management.md +112 -0
- package/template_scaffold/docs/funnelsgrove/steps/summary_confirmation.md +110 -0
- package/template_scaffold/docs/funnelsgrove/steps/upsell_offer.md +112 -0
- package/template_scaffold/docs/funnelsgrove/steps/value_prop_story.md +110 -0
- package/template_scaffold/docs/references/funnel-sdk-user-answers.md +5 -0
- package/template_scaffold/eslint.config.mjs +16 -0
- package/template_scaffold/funnel-agent-docs.test.ts +699 -0
- package/template_scaffold/funnel-docs.config.json +43 -0
- package/template_scaffold/funnel.config.json +5 -0
- package/template_scaffold/gitignore.template +5 -0
- package/template_scaffold/next-env.d.ts +6 -0
- package/template_scaffold/next.config.ts +53 -0
- package/template_scaffold/package-lock.json +7737 -0
- package/template_scaffold/package.json +37 -0
- package/template_scaffold/package.test.ts +23 -0
- package/template_scaffold/public/claimbee/bee.png +0 -0
- package/template_scaffold/public/claimbee/device-iphone.png +0 -0
- package/template_scaffold/public/claimbee/device-macbook.png +0 -0
- package/template_scaffold/public/claimbee/device-none.png +0 -0
- package/template_scaffold/public/claimbee/device-watch.png +0 -0
- package/template_scaffold/public/claimbee/glow.svg +3 -0
- package/template_scaffold/public/claimbee/steps-sprite-alt.png +0 -0
- package/template_scaffold/public/claimbee/steps-sprite-default.png +0 -0
- package/template_scaffold/public/claimbee/testimonial-avatar.png +0 -0
- package/template_scaffold/public/claimbee/testimonial-star.svg +3 -0
- package/template_scaffold/public/paywall/ai-claim-assistant.png +0 -0
- package/template_scaffold/public/paywall/claim-history.png +0 -0
- package/template_scaffold/public/paywall/clear-progress.png +0 -0
- package/template_scaffold/public/paywall/hero.png +0 -0
- package/template_scaffold/public/paywall/money-alerts.png +0 -0
- package/template_scaffold/public/paywall/money-back-badge.png +0 -0
- package/template_scaffold/public/paywall/paywall-image.png +0 -0
- package/template_scaffold/public/paywall/r1.png +0 -0
- package/template_scaffold/public/paywall/r2.png +0 -0
- package/template_scaffold/public/paywall/r3.png +0 -0
- package/template_scaffold/public/paywall/r4.png +0 -0
- package/template_scaffold/public/paywall/r5.png +0 -0
- package/template_scaffold/public/paywall/r6.png +0 -0
- package/template_scaffold/public/paywall/special-offer-gift.png +0 -0
- package/template_scaffold/public/please/bee.png +0 -0
- package/template_scaffold/public/please/device-iphone.png +0 -0
- package/template_scaffold/public/please/device-macbook.png +0 -0
- package/template_scaffold/public/please/device-none.png +0 -0
- package/template_scaffold/public/please/device-watch.png +0 -0
- package/template_scaffold/public/please/glow.svg +3 -0
- package/template_scaffold/public/please/steps-sprite-alt.png +0 -0
- package/template_scaffold/public/please/steps-sprite-default.png +0 -0
- package/template_scaffold/public/please/testimonial-avatar.png +0 -0
- package/template_scaffold/public/please/testimonial-star.svg +3 -0
- package/template_scaffold/public/upsell/upgrade-hero.png +0 -0
- package/template_scaffold/src/app/[stepId]/page.test.tsx +59 -0
- package/template_scaffold/src/app/[stepId]/page.tsx +62 -0
- package/template_scaffold/src/app/globals.css +49 -0
- package/template_scaffold/src/app/layout.test.ts +26 -0
- package/template_scaffold/src/app/layout.tsx +61 -0
- package/template_scaffold/src/app/page.tsx +8 -0
- package/template_scaffold/src/app/sdk/page.tsx +673 -0
- package/template_scaffold/src/app/subscription-dashboard/page.tsx +274 -0
- package/template_scaffold/src/assets/claimbee/bee.png +0 -0
- package/template_scaffold/src/assets/claimbee/device-iphone.png +0 -0
- package/template_scaffold/src/assets/claimbee/device-macbook.png +0 -0
- package/template_scaffold/src/assets/claimbee/device-none.png +0 -0
- package/template_scaffold/src/assets/claimbee/device-watch.png +0 -0
- package/template_scaffold/src/assets/claimbee/glow.svg +3 -0
- package/template_scaffold/src/assets/claimbee/steps-sprite-alt.png +0 -0
- package/template_scaffold/src/assets/claimbee/steps-sprite-default.png +0 -0
- package/template_scaffold/src/assets/claimbee/testimonial-avatar.png +0 -0
- package/template_scaffold/src/assets/claimbee/testimonial-star.svg +3 -0
- package/template_scaffold/src/components/FunnelEditorPanel.tsx +194 -0
- package/template_scaffold/src/components/FunnelFlow.test.tsx +17 -0
- package/template_scaffold/src/components/FunnelFlow.tsx +114 -0
- package/template_scaffold/src/components/FunnelStepImage.test.ts +15 -0
- package/template_scaffold/src/components/FunnelStepImage.tsx +28 -0
- package/template_scaffold/src/components/step/FunnelStepHeader.tsx +35 -0
- package/template_scaffold/src/config/billing.plans.test.ts +143 -0
- package/template_scaffold/src/config/billing.plans.ts +69 -0
- package/template_scaffold/src/config/billing.test.plans.ts +32 -0
- package/template_scaffold/src/config/experiments.generated.ts +4 -0
- package/template_scaffold/src/config/experiments.test.ts +12 -0
- package/template_scaffold/src/config/experiments.ts +9 -0
- package/template_scaffold/src/config/funnel.config.ts +12 -0
- package/template_scaffold/src/config/funnel.manifest.test.ts +166 -0
- package/template_scaffold/src/config/funnel.manifest.ts +139 -0
- package/template_scaffold/src/config/offer-sets.generated.ts +4 -0
- package/template_scaffold/src/contract/funnel-project.validation.test.ts +80 -0
- package/template_scaffold/src/contract/funnel-project.validation.ts +44 -0
- package/template_scaffold/src/contract/funnel-validator.vite.config.ts +11 -0
- package/template_scaffold/src/contract/golden/cancellation_offer.fixture.ts +29 -0
- package/template_scaffold/src/contract/golden/checkout.fixture.ts +37 -0
- package/template_scaffold/src/contract/golden/form_input.fixture.ts +28 -0
- package/template_scaffold/src/contract/golden/golden-fixtures.test.ts +190 -0
- package/template_scaffold/src/contract/golden/intro_hero.fixture.ts +27 -0
- package/template_scaffold/src/contract/golden/multi_select_choice.fixture.ts +37 -0
- package/template_scaffold/src/contract/golden/paywall_offer.fixture.ts +30 -0
- package/template_scaffold/src/contract/golden/progress_interstitial.fixture.ts +28 -0
- package/template_scaffold/src/contract/golden/purchase_completed.fixture.ts +33 -0
- package/template_scaffold/src/contract/golden/single_step_choice.fixture.ts +36 -0
- package/template_scaffold/src/contract/golden/single_step_choice_emoji.fixture.ts +36 -0
- package/template_scaffold/src/contract/golden/social_proof.fixture.ts +27 -0
- package/template_scaffold/src/contract/golden/subscription_handoff.fixture.ts +30 -0
- package/template_scaffold/src/contract/golden/subscription_management.fixture.ts +29 -0
- package/template_scaffold/src/contract/golden/summary_confirmation.fixture.ts +27 -0
- package/template_scaffold/src/contract/golden/upsell_offer.fixture.ts +29 -0
- package/template_scaffold/src/contract/golden/value_prop_story.fixture.ts +27 -0
- package/template_scaffold/src/contract/validate-funnel.cli.test.ts +180 -0
- package/template_scaffold/src/contract/validate-funnel.cli.ts +199 -0
- package/template_scaffold/src/runtime/checkout-runtime-config.test.ts +60 -0
- package/template_scaffold/src/runtime/checkout-runtime-config.ts +26 -0
- package/template_scaffold/src/runtime/funnel-runtime.test.ts +25 -0
- package/template_scaffold/src/runtime/funnel-runtime.ts +118 -0
- package/template_scaffold/src/runtime/step-content-context.test.ts +38 -0
- package/template_scaffold/src/runtime/step-content-context.ts +65 -0
- package/template_scaffold/src/runtime/step-registry.ts +66 -0
- package/template_scaffold/src/runtime/step-variable-values.ts +31 -0
- package/template_scaffold/src/runtime/use-funnel-flow-controller.integration.test.ts +203 -0
- package/template_scaffold/src/runtime/use-funnel-flow-controller.test.ts +76 -0
- package/template_scaffold/src/runtime/use-funnel-flow-controller.ts +53 -0
- package/template_scaffold/src/steps/content/email-capture.content.ts +29 -0
- package/template_scaffold/src/steps/content/manage-subscription.content.ts +62 -0
- package/template_scaffold/src/steps/content/paywall.content.ts +191 -0
- package/template_scaffold/src/steps/content/step-01.content.ts +23 -0
- package/template_scaffold/src/steps/content/step-02.content.ts +77 -0
- package/template_scaffold/src/steps/content/step-03.content.ts +40 -0
- package/template_scaffold/src/steps/content/step-04.content.ts +49 -0
- package/template_scaffold/src/steps/content/step-32-paywall.content.ts +352 -0
- package/template_scaffold/src/steps/content/subscription-started.content.ts +68 -0
- package/template_scaffold/src/steps/editor/email-capture.editor.ts +58 -0
- package/template_scaffold/src/steps/editor/manage-subscription.editor.ts +179 -0
- package/template_scaffold/src/steps/editor/paywall.editor.ts +232 -0
- package/template_scaffold/src/steps/editor/step-01.editor.ts +22 -0
- package/template_scaffold/src/steps/editor/step-02.editor.ts +41 -0
- package/template_scaffold/src/steps/editor/step-03.editor.ts +58 -0
- package/template_scaffold/src/steps/editor/step-04.editor.ts +98 -0
- package/template_scaffold/src/steps/editor/step-32-paywall.editor.ts +478 -0
- package/template_scaffold/src/steps/editor/subscription-started.editor.ts +131 -0
- package/template_scaffold/src/steps/index.ts +25 -0
- package/template_scaffold/src/steps/paywall-checkout-options.test.ts +104 -0
- package/template_scaffold/src/steps/paywall-checkout-options.ts +130 -0
- package/template_scaffold/src/steps/pricing/paywall-b.pricing.ts +12 -0
- package/template_scaffold/src/steps/pricing/paywall.pricing.ts +12 -0
- package/template_scaffold/src/steps/pricing/shared-plan-order.ts +17 -0
- package/template_scaffold/src/steps/pricing/upsell-form.pricing.ts +16 -0
- package/template_scaffold/src/steps/shared-header-contract.test.ts +27 -0
- package/template_scaffold/src/steps/step-01.test.ts +38 -0
- package/template_scaffold/src/steps/step-01.tsx +108 -0
- package/template_scaffold/src/steps/step-02.test.ts +43 -0
- package/template_scaffold/src/steps/step-02.tsx +140 -0
- package/template_scaffold/src/steps/step-03.test.ts +15 -0
- package/template_scaffold/src/steps/step-03.tsx +262 -0
- package/template_scaffold/src/steps/step-04.test.ts +32 -0
- package/template_scaffold/src/steps/step-04.tsx +673 -0
- package/template_scaffold/src/steps/step-31-email-capture.test.ts +22 -0
- package/template_scaffold/src/steps/step-31-email-capture.tsx +195 -0
- package/template_scaffold/src/steps/step-32-paywall.test.ts +770 -0
- package/template_scaffold/src/steps/step-32-paywall.tsx +2253 -0
- package/template_scaffold/src/steps/step-32b-paywall-b.tsx +35 -0
- package/template_scaffold/src/steps/step-33-subscription-started.test.ts +26 -0
- package/template_scaffold/src/steps/step-33-subscription-started.tsx +86 -0
- package/template_scaffold/src/steps/step-34-upsell-form.test.ts +27 -0
- package/template_scaffold/src/steps/step-35-manage-subscription.tsx +46 -0
- package/template_scaffold/src/steps/step-content.registry.ts +45 -0
- package/template_scaffold/src/steps/step-image-migration.test.ts +20 -0
- package/template_scaffold/src/steps/styles/index.css +2 -0
- package/template_scaffold/src/steps/styles/shared/final-flow-shared.css +55 -0
- package/template_scaffold/src/steps/styles/shared/funnel-base.css +543 -0
- package/template_scaffold/src/steps/styles/shared/info-shared.css +139 -0
- package/template_scaffold/src/steps/styles/shared/progress-shared.css +117 -0
- package/template_scaffold/src/steps/styles/shared/question-shared.css +76 -0
- package/template_scaffold/src/steps/styles/shared/responsive-container.css +176 -0
- package/template_scaffold/src/steps/types.ts +7 -0
- package/template_scaffold/src/steps/use-claimbee-step-content.ts +15 -0
- package/template_scaffold/src/theme/theme.ts +81 -0
- package/template_scaffold/tsconfig.json +38 -0
- package/template_scaffold/vitest.config.ts +32 -0
- package/template_validation/.funnelsgrove-contract-tools.json +19 -0
- package/template_validation/src/contract/funnel-project.validation.ts +44 -0
- package/template_validation/src/contract/funnel-validator.vite.config.ts +11 -0
- package/template_validation/src/contract/validate-funnel.cli.ts +199 -0
- package/template_docs/docs/ab-experiments.md +0 -111
- package/template_docs/docs/analytics.md +0 -65
- package/template_docs/docs/editing-flow.md +0 -136
- package/template_docs/docs/editing-step.md +0 -125
- package/template_docs/docs/editor-and-content.md +0 -58
- package/template_docs/docs/funnel-runtime-architecture.md +0 -125
- package/template_docs/docs/meta-pixel-conversions-api.md +0 -50
- package/template_docs/docs/payment-plans-and-discounts.md +0 -106
- package/template_docs/docs/publishing-and-versioning.md +0 -117
- package/template_docs/docs/qa-checklist.md +0 -80
- package/template_docs/docs/sdk-api-endpoints.md +0 -78
- package/template_docs/docs/step-ui-guidelines.md +0 -110
- 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 {
|
|
3
|
-
import {
|
|
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 {
|
|
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,
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
files:
|
|
1021
|
-
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
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
|
|
1247
|
-
|
|
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
|
|
1252
|
-
.
|
|
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 --
|
|
1256
|
-
'fgrove create --
|
|
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
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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 (
|
|
1278
|
-
console.log(`Updated ${
|
|
1279
|
-
for (const file of
|
|
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
|
}
|