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