@funnelsgrove/cli 0.1.6 → 0.1.8
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/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { Command } from 'commander';
|
|
8
8
|
import { callTrpcProcedure } from './apiClient.js';
|
|
9
9
|
import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
|
|
10
|
-
import { buildSyncManifest, chunkChangedSourceFiles, collectChangedSourceFiles, collectSourceFiles, ensureGitignore, readSyncManifest, writeLocalEnvFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
|
|
10
|
+
import { buildSyncManifest, chunkChangedSourceFiles, collectChangedSourceFiles, collectSourceFiles, ensureGitignore, formatSyncUploadSummary, readSyncManifest, writeLocalEnvFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
|
|
11
11
|
import { pullEnvFile } from './envSync.js';
|
|
12
12
|
import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, } from './githubOutput.js';
|
|
13
13
|
import { syncGitHubDraftIfConnected } from './githubSyncFlow.js';
|
|
@@ -35,6 +35,8 @@ const resolveTemplatePath = (slug) => path.resolve(__dirname, '..', '..', '..',
|
|
|
35
35
|
const resolveTemplateDocsPath = () => path.resolve(__dirname, '..', TEMPLATE_DOCS_DIR);
|
|
36
36
|
const DEFAULT_API_URL = 'https://api.funnelsgrove.com/trpc';
|
|
37
37
|
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;
|
|
38
|
+
const PUBLISH_POLL_INTERVAL_MS = 2_000;
|
|
39
|
+
const PUBLISH_WAIT_TIMEOUT_MS = 30 * 60_000;
|
|
38
40
|
const getApiUrl = () => {
|
|
39
41
|
const options = program.opts();
|
|
40
42
|
return options.apiUrl || process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL;
|
|
@@ -59,6 +61,40 @@ const callApi = async (input) => {
|
|
|
59
61
|
token: input.token,
|
|
60
62
|
});
|
|
61
63
|
};
|
|
64
|
+
const sleep = async (ms) => {
|
|
65
|
+
await new Promise((resolve) => {
|
|
66
|
+
setTimeout(resolve, ms);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
const isTerminalDeploymentState = (state) => {
|
|
70
|
+
return ['ready', 'failed', 'expired', 'canceled'].includes(state.trim().toLowerCase());
|
|
71
|
+
};
|
|
72
|
+
const waitForPublishDeployment = async (input) => {
|
|
73
|
+
const deadline = Date.now() + PUBLISH_WAIT_TIMEOUT_MS;
|
|
74
|
+
while (Date.now() <= deadline) {
|
|
75
|
+
const detail = await callApi({
|
|
76
|
+
path: 'funnels.detail',
|
|
77
|
+
type: 'query',
|
|
78
|
+
token: input.token,
|
|
79
|
+
data: {
|
|
80
|
+
workspaceId: input.workspaceId,
|
|
81
|
+
funnelId: input.funnelId,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
const deployment = detail.deployments.find((item) => item.id === input.deploymentId);
|
|
85
|
+
if (deployment && isTerminalDeploymentState(deployment.state)) {
|
|
86
|
+
if (deployment.state === 'failed') {
|
|
87
|
+
throw new Error(deployment.qa_summary || 'Publish deployment failed.');
|
|
88
|
+
}
|
|
89
|
+
if (deployment.state === 'expired' || deployment.state === 'canceled') {
|
|
90
|
+
throw new Error(`Publish deployment ${deployment.state}.`);
|
|
91
|
+
}
|
|
92
|
+
return deployment;
|
|
93
|
+
}
|
|
94
|
+
await sleep(PUBLISH_POLL_INTERVAL_MS);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Timed out waiting for publish deployment ${input.deploymentId}.`);
|
|
97
|
+
};
|
|
62
98
|
const readCodeFromStdin = async () => {
|
|
63
99
|
const rl = createInterface({
|
|
64
100
|
input: process.stdin,
|
|
@@ -513,6 +549,7 @@ addExamples(syncCommand
|
|
|
513
549
|
let syncedFileCount = 0;
|
|
514
550
|
let deletedFileCount = 0;
|
|
515
551
|
if (changes) {
|
|
552
|
+
console.log(formatSyncUploadSummary(changes).join('\n'));
|
|
516
553
|
const batches = chunkChangedSourceFiles(changes);
|
|
517
554
|
for (const batch of batches) {
|
|
518
555
|
result = await callApi({
|
|
@@ -532,6 +569,8 @@ addExamples(syncCommand
|
|
|
532
569
|
}
|
|
533
570
|
}
|
|
534
571
|
else {
|
|
572
|
+
const files = await collectSourceFiles(target.sourceDir);
|
|
573
|
+
console.log(formatSyncUploadSummary({ deletedPaths: [], files }).join('\n'));
|
|
535
574
|
result = await callApi({
|
|
536
575
|
path: 'funnels.importSource',
|
|
537
576
|
type: 'mutation',
|
|
@@ -540,7 +579,7 @@ addExamples(syncCommand
|
|
|
540
579
|
workspaceId: target.workspaceId,
|
|
541
580
|
funnelId: target.funnelId,
|
|
542
581
|
message: options.message,
|
|
543
|
-
files
|
|
582
|
+
files,
|
|
544
583
|
},
|
|
545
584
|
});
|
|
546
585
|
syncedFileCount = result.syncedFiles.length;
|
|
@@ -605,7 +644,13 @@ addExamples(program
|
|
|
605
644
|
domains: publishEnv === 'production' && options.domain ? [options.domain] : undefined,
|
|
606
645
|
},
|
|
607
646
|
});
|
|
608
|
-
|
|
647
|
+
const readyDeployment = await waitForPublishDeployment({
|
|
648
|
+
token,
|
|
649
|
+
workspaceId: target.workspaceId,
|
|
650
|
+
funnelId: target.funnelId,
|
|
651
|
+
deploymentId: result.deploymentId,
|
|
652
|
+
});
|
|
653
|
+
console.log(`${readyDeployment.deployment_url || result.deploymentUrl}\tv${result.publishedVersionSeq}\t${result.publishedVersionId}`);
|
|
609
654
|
});
|
|
610
655
|
const githubCommand = addExamples(program.command('github').description('Manage GitHub funnel sync'), [
|
|
611
656
|
'fgrove github status --funnel claimbee-general',
|
package/dist/localSync.d.ts
CHANGED
|
@@ -36,4 +36,5 @@ export declare function readSyncManifest(rootDir: string): Promise<SyncManifest
|
|
|
36
36
|
export declare function collectSourceFiles(rootDir: string): Promise<SourceFile[]>;
|
|
37
37
|
export declare function collectChangedSourceFiles(rootDir: string, previousManifest: SyncManifest): Promise<ChangedSourceFiles>;
|
|
38
38
|
export declare function chunkChangedSourceFiles(changes: Pick<ChangedSourceFiles, 'deletedPaths' | 'files'>, maxContentChars?: number): SourceFilePatchBatch[];
|
|
39
|
+
export declare function formatSyncUploadSummary(changes: Pick<ChangedSourceFiles, 'deletedPaths' | 'files'>, largestFileLimit?: number): string[];
|
|
39
40
|
export declare function writeSourceFiles(rootDir: string, files: SourceFile[]): Promise<void>;
|
package/dist/localSync.js
CHANGED
|
@@ -3,16 +3,15 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
export const SYNC_MANIFEST_FILE = '.funnelsgrove-sync.json';
|
|
5
5
|
export const DEFAULT_PATCH_BATCH_CONTENT_CHARS = 12_000_000;
|
|
6
|
-
const
|
|
6
|
+
const EXCLUDED_LOCAL_DIRECTORY_NAMES = ['.git', '.playwright-cli', 'node_modules', '.next', 'out', 'output'];
|
|
7
|
+
const EXCLUDED_PATH_PARTS = new Set(EXCLUDED_LOCAL_DIRECTORY_NAMES);
|
|
7
8
|
const LOCAL_ONLY_GITIGNORE_LINES = [
|
|
8
9
|
'.env',
|
|
9
10
|
'.env.local',
|
|
10
11
|
'.env.*',
|
|
11
12
|
'!.env.example',
|
|
12
13
|
SYNC_MANIFEST_FILE,
|
|
13
|
-
'
|
|
14
|
-
'.next',
|
|
15
|
-
'out',
|
|
14
|
+
...EXCLUDED_LOCAL_DIRECTORY_NAMES.filter((directoryName) => directoryName !== '.git'),
|
|
16
15
|
];
|
|
17
16
|
export function normalizeSyncPath(filePath) {
|
|
18
17
|
const normalized = path.posix.normalize(filePath.replaceAll('\\', '/'));
|
|
@@ -157,6 +156,41 @@ export function chunkChangedSourceFiles(changes, maxContentChars = DEFAULT_PATCH
|
|
|
157
156
|
pushCurrentBatch();
|
|
158
157
|
return batches;
|
|
159
158
|
}
|
|
159
|
+
export function formatSyncUploadSummary(changes, largestFileLimit = 5) {
|
|
160
|
+
const fileSizes = changes.files
|
|
161
|
+
.map((file) => ({
|
|
162
|
+
path: file.path,
|
|
163
|
+
bytes: Buffer.byteLength(file.content, 'utf8'),
|
|
164
|
+
}))
|
|
165
|
+
.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
|
|
166
|
+
const totalBytes = fileSizes.reduce((total, file) => total + file.bytes, 0);
|
|
167
|
+
const lines = [
|
|
168
|
+
`Preparing sync upload: ${formatCount(fileSizes.length, 'file')}, ${formatCount(changes.deletedPaths.length, 'deleted path')}, ${formatByteSize(totalBytes)} content.`,
|
|
169
|
+
];
|
|
170
|
+
if (fileSizes.length > 0 && largestFileLimit > 0) {
|
|
171
|
+
lines.push('Largest included files:');
|
|
172
|
+
for (const file of fileSizes.slice(0, largestFileLimit)) {
|
|
173
|
+
lines.push(` ${formatByteSize(file.bytes).padStart(6)} ${file.path}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
|
178
|
+
function formatCount(count, singular) {
|
|
179
|
+
return `${count} ${count === 1 ? singular : `${singular}s`}`;
|
|
180
|
+
}
|
|
181
|
+
function formatByteSize(byteCount) {
|
|
182
|
+
if (byteCount < 1024) {
|
|
183
|
+
return `${byteCount} B`;
|
|
184
|
+
}
|
|
185
|
+
const units = ['KB', 'MB', 'GB'];
|
|
186
|
+
let value = byteCount / 1024;
|
|
187
|
+
let unitIndex = 0;
|
|
188
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
189
|
+
value /= 1024;
|
|
190
|
+
unitIndex += 1;
|
|
191
|
+
}
|
|
192
|
+
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
193
|
+
}
|
|
160
194
|
async function readSourceFiles(rootDir, manifestFiles) {
|
|
161
195
|
const files = await Promise.all(manifestFiles.map(async (file) => {
|
|
162
196
|
const absolutePath = path.join(rootDir, assertSafeSyncPath(file.path));
|
package/package.json
CHANGED
|
@@ -7,7 +7,9 @@ Flow is product logic. The source of truth is `src/config/funnel.manifest.ts`; r
|
|
|
7
7
|
The manifest defines:
|
|
8
8
|
|
|
9
9
|
- `viewport`: the designed shell size, usually `430 x 932`.
|
|
10
|
-
- `assets`:
|
|
10
|
+
- `assets`: image metadata used by runtime/build tooling. Funnel shells may use
|
|
11
|
+
this data to warm likely next-step images, but first-viewport images should
|
|
12
|
+
still use the framework's normal priority/preload mechanism.
|
|
11
13
|
- `steps`: every routable step with `id`, `path`, `filePath`, `componentKey`, `type`, optional `kind`, and optional `assetIds`.
|
|
12
14
|
- `edgesByStepId`: graph edges between steps.
|
|
13
15
|
- `experiments`: optional variant routing.
|
|
@@ -48,12 +48,16 @@ Runtime config should come from `runtimePublicConfig` through a local `src/runti
|
|
|
48
48
|
|
|
49
49
|
Subscription paywalls with on-page wallet buttons should share one `useStripeSubscriptionCheckoutSession(...)` instance across the manual card checkout and wallet checkout. Do not build funnel-local Stripe PaymentIntent or Express Checkout flows.
|
|
50
50
|
|
|
51
|
+
- Keep funnel-specific checkout orchestration local when it includes product layout, analytics callbacks, return URL branching, or email prompt copy. The shared UI boundary is `SharedStripeCheckoutV2Dialog`; do not add another shared wrapper that duplicates a funnel's paywall composition.
|
|
51
52
|
- Use `SharedStripeCheckoutV2Dialog` for the manual/card checkout modal. Open it from the paywall CTA after the checkout session has an `activeClientSecret` and `stripePromise`.
|
|
52
53
|
- Render real wallet slots on the paywall with `ApplePaySubscriptionCheckoutSlot` and `GooglePaySubscriptionCheckoutSlot`, usually above the manual/card CTA. Pass the same checkout session, selected plan amount, return URL, customer details, summary label, success handler, and any `beforeConfirm` logic used to finalize email or analytics metadata.
|
|
53
54
|
- Use `usePlatformWalletPaymentMethods()` to decide which slots to render. Desktop web can show Apple Pay and Google Pay; iOS shows Apple Pay; Android shows Google Pay.
|
|
54
55
|
- Track each slot's `onAvailabilityChange` result by the current `checkoutSession.intentKey`. When opening `SharedStripeCheckoutV2Dialog`, derive `initialWalletAvailable` from the current intent's confirmed wallet availability and pass it into the dialog so checkout can show the wallet tab immediately when a wallet is already known to be available.
|
|
55
56
|
- Treat unknown wallet availability as `null`, not `false`. Only hide/fallback from a wallet after Stripe or the shared slot reports that wallet unavailable.
|
|
57
|
+
- Treat Checkout Session/API preparation failures as retryable checkout failures, not wallet unavailability. A failed `prepareWalletCheckout()` should leave the wallet slot in unknown availability and show the real clickable fallback placeholder after loading stops; only Stripe readiness/availability reports should mark a wallet unavailable.
|
|
56
58
|
- Suspend paywall wallet slots while the shared checkout dialog or another blocking offer modal is open, so two Stripe Express Checkout elements do not compete for the same active checkout session.
|
|
59
|
+
- If checkout starts without a known customer email, first try to resolve it from a known `user_id` or `stripe_customer_id`. Only show an email prompt when there is no known identity to fetch from, or that lookup returns no email.
|
|
60
|
+
- Before publishing payment changes, run the shared wallet smoke helper from `@funnelsgrove/payments` against local and preview paywall URLs. The package script is `npm run smoke:wallet --workspace @funnelsgrove/payments -- --url <paywall-url>` and accepts selector overrides through CLI flags or `WALLET_SMOKE_*` env vars.
|
|
57
61
|
|
|
58
62
|
## Paywall Identity Handoff
|
|
59
63
|
|
|
@@ -50,6 +50,8 @@ Always pass runtime checkout config so published funnels, preview funnels, and l
|
|
|
50
50
|
|
|
51
51
|
Checkout endpoints receive `user_id` and `customerEmail` when available. Stripe metadata and `client_reference_id` rely on these values for webhook reconciliation, analytics, and project user support links.
|
|
52
52
|
|
|
53
|
+
When a funnel sends a configured Stripe `providerPlanId`, public checkout APIs validate that price id against Stripe and fail with a client-visible error if it is missing. Do not silently replace configured price ids with generated `price_data`; dynamic price fallback is only for legacy/catalog entries that have no provider price id.
|
|
54
|
+
|
|
53
55
|
## Subscription APIs
|
|
54
56
|
|
|
55
57
|
Use the runtime service methods instead of calling the endpoints directly:
|