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