@funnelsgrove/cli 0.1.12 → 0.1.14
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.d.ts +22 -0
- package/dist/cli.js +123 -0
- package/dist/localSync.d.ts +1 -0
- package/dist/localSync.js +15 -1
- package/package.json +1 -1
- package/template_docs/docs/ab-experiments.md +2 -0
package/dist/cli.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ type SyncTargetIdInput = {
|
|
|
17
17
|
active?: Pick<ActiveContext, 'workspaceId' | 'funnelId'> | null;
|
|
18
18
|
defaultWorkspaceId?: string;
|
|
19
19
|
};
|
|
20
|
+
type GeneratedConfigKind = 'offerSets' | 'experiments';
|
|
21
|
+
export declare const SYNC_DOWN_GENERATED_CONFIG_KINDS: GeneratedConfigKind[];
|
|
20
22
|
export declare function resolveSyncTargetIds(input: SyncTargetIdInput): {
|
|
21
23
|
workspaceId: string;
|
|
22
24
|
funnelId?: string;
|
|
@@ -42,5 +44,25 @@ export declare function buildPatchSourceInput(input: {
|
|
|
42
44
|
files: SourceFile[];
|
|
43
45
|
deletedPaths: string[];
|
|
44
46
|
};
|
|
47
|
+
export declare function buildSyncOfferSetPlansInput(input: {
|
|
48
|
+
workspaceId: string;
|
|
49
|
+
funnelId: string;
|
|
50
|
+
offerSet: string;
|
|
51
|
+
}): {
|
|
52
|
+
workspaceId: string;
|
|
53
|
+
funnelId: string;
|
|
54
|
+
offerSetId?: string;
|
|
55
|
+
offerSetKey?: string;
|
|
56
|
+
};
|
|
57
|
+
export declare function buildGeneratedConfigSyncInput(input: {
|
|
58
|
+
workspaceId: string;
|
|
59
|
+
funnelId: string;
|
|
60
|
+
kinds: GeneratedConfigKind[];
|
|
61
|
+
}): {
|
|
62
|
+
workspaceId: string;
|
|
63
|
+
funnelId: string;
|
|
64
|
+
kinds: GeneratedConfigKind[];
|
|
65
|
+
};
|
|
66
|
+
export declare function selectGeneratedConfigFiles(files: SourceFile[], kinds: GeneratedConfigKind[]): SourceFile[];
|
|
45
67
|
export declare function assertCanDraftSyncSource(status: GitHubStatusResponse): void;
|
|
46
68
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -38,6 +38,11 @@ const DEFAULT_API_URL = 'https://api.funnelsgrove.com/trpc';
|
|
|
38
38
|
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
39
|
const PUBLISH_POLL_INTERVAL_MS = 2_000;
|
|
40
40
|
const PUBLISH_WAIT_TIMEOUT_MS = 30 * 60_000;
|
|
41
|
+
const GENERATED_CONFIG_PATH_BY_KIND = {
|
|
42
|
+
offerSets: 'src/config/offer-sets.generated.ts',
|
|
43
|
+
experiments: 'src/config/experiments.generated.ts',
|
|
44
|
+
};
|
|
45
|
+
export const SYNC_DOWN_GENERATED_CONFIG_KINDS = ['offerSets', 'experiments'];
|
|
41
46
|
export function resolveSyncTargetIds(input) {
|
|
42
47
|
const workspaceId = input.explicitWorkspaceId ||
|
|
43
48
|
input.manifest?.workspaceId ||
|
|
@@ -83,6 +88,28 @@ export function buildPatchSourceInput(input) {
|
|
|
83
88
|
deletedPaths: input.deletedPaths,
|
|
84
89
|
};
|
|
85
90
|
}
|
|
91
|
+
export function buildSyncOfferSetPlansInput(input) {
|
|
92
|
+
const offerSet = input.offerSet.trim();
|
|
93
|
+
if (!offerSet) {
|
|
94
|
+
throw new Error('--offer-set is required');
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
workspaceId: input.workspaceId,
|
|
98
|
+
funnelId: input.funnelId,
|
|
99
|
+
...(UUID_PATTERN.test(offerSet) ? { offerSetId: offerSet } : { offerSetKey: offerSet }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function buildGeneratedConfigSyncInput(input) {
|
|
103
|
+
return {
|
|
104
|
+
workspaceId: input.workspaceId,
|
|
105
|
+
funnelId: input.funnelId,
|
|
106
|
+
kinds: input.kinds,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function selectGeneratedConfigFiles(files, kinds) {
|
|
110
|
+
const allowedPaths = new Set(kinds.map((kind) => GENERATED_CONFIG_PATH_BY_KIND[kind]));
|
|
111
|
+
return files.filter((file) => allowedPaths.has(file.path));
|
|
112
|
+
}
|
|
86
113
|
export function assertCanDraftSyncSource(status) {
|
|
87
114
|
if (status.connection && status.connection.status !== 'disconnected') {
|
|
88
115
|
throw new Error('This funnel is connected to GitHub. Commit and push changes with git, then run `fgrove github pull` to sync the hosted draft. Do not use `fgrove sync up` for the same change.');
|
|
@@ -352,6 +379,22 @@ const syncGitHubDraftForCli = async (input) => {
|
|
|
352
379
|
console.log(summary);
|
|
353
380
|
}
|
|
354
381
|
};
|
|
382
|
+
const formatGeneratedFileCount = (count) => `${count} generated config ${count === 1 ? 'file' : 'files'}`;
|
|
383
|
+
const syncGeneratedConfigFilesForCli = async (input) => {
|
|
384
|
+
const result = await callApi({
|
|
385
|
+
path: 'funnels.generatedConfigFiles',
|
|
386
|
+
type: 'query',
|
|
387
|
+
token: input.token,
|
|
388
|
+
data: buildGeneratedConfigSyncInput({
|
|
389
|
+
workspaceId: input.target.workspaceId,
|
|
390
|
+
funnelId: input.target.funnelId,
|
|
391
|
+
kinds: input.kinds,
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
const files = selectGeneratedConfigFiles(result.files, input.kinds);
|
|
395
|
+
await writeSourceFiles(input.target.sourceDir, files);
|
|
396
|
+
return files;
|
|
397
|
+
};
|
|
355
398
|
const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
|
|
356
399
|
const program = new Command();
|
|
357
400
|
program
|
|
@@ -525,6 +568,80 @@ addExamples(funnelsCommand
|
|
|
525
568
|
});
|
|
526
569
|
console.log(`${result.funnel.id}\t${result.funnel.name}\t${result.funnel.slug}`);
|
|
527
570
|
});
|
|
571
|
+
const offerSetsCommand = addExamples(program.command('offer-sets').alias('offers').description('Manage project offer sets'), [
|
|
572
|
+
'fgrove offer-sets sync --offer-set default-paywall',
|
|
573
|
+
'fgrove offers sync --funnel claimbee-ios --offer-set three-month-test',
|
|
574
|
+
]);
|
|
575
|
+
addExamples(offerSetsCommand
|
|
576
|
+
.command('sync')
|
|
577
|
+
.description('Apply an offer set to a funnel draft billing snapshot')
|
|
578
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
579
|
+
.option('--funnel <id-or-slug>', 'Funnel id or slug')
|
|
580
|
+
.option('--dir <path>', 'Local source directory for reading sync manifest', '.')
|
|
581
|
+
.requiredOption('--offer-set <key-or-id>', 'Offer set key or id'), [
|
|
582
|
+
'fgrove offer-sets sync --offer-set default-paywall',
|
|
583
|
+
'fgrove offers sync --funnel claimbee-ios --offer-set quarterly-test',
|
|
584
|
+
])
|
|
585
|
+
.action(async (options) => {
|
|
586
|
+
const token = await readAuthToken();
|
|
587
|
+
const target = await resolveSyncTarget({
|
|
588
|
+
token,
|
|
589
|
+
workspace: options.workspace,
|
|
590
|
+
funnel: options.funnel,
|
|
591
|
+
dir: options.dir,
|
|
592
|
+
});
|
|
593
|
+
const result = await callApi({
|
|
594
|
+
path: 'funnels.syncOfferSetPlans',
|
|
595
|
+
type: 'mutation',
|
|
596
|
+
token,
|
|
597
|
+
data: buildSyncOfferSetPlansInput({
|
|
598
|
+
workspaceId: target.workspaceId,
|
|
599
|
+
funnelId: target.funnelId,
|
|
600
|
+
offerSet: options.offerSet,
|
|
601
|
+
}),
|
|
602
|
+
});
|
|
603
|
+
const generatedFiles = await syncGeneratedConfigFilesForCli({
|
|
604
|
+
token,
|
|
605
|
+
target,
|
|
606
|
+
kinds: ['offerSets'],
|
|
607
|
+
});
|
|
608
|
+
await syncGitHubDraftForCli({
|
|
609
|
+
token,
|
|
610
|
+
workspaceId: target.workspaceId,
|
|
611
|
+
funnelId: target.funnelId,
|
|
612
|
+
});
|
|
613
|
+
const offerSetLabel = result.offerSet?.key || result.offerSet?.display_name || options.offerSet.trim();
|
|
614
|
+
console.log(`Synced offer set ${offerSetLabel} to funnel draft`);
|
|
615
|
+
console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
|
|
616
|
+
});
|
|
617
|
+
const experimentsCommand = addExamples(program.command('experiments').description('Manage funnel experiments'), [
|
|
618
|
+
'fgrove experiments sync',
|
|
619
|
+
'fgrove experiments sync --funnel claimbee-ios',
|
|
620
|
+
]);
|
|
621
|
+
addExamples(experimentsCommand
|
|
622
|
+
.command('sync')
|
|
623
|
+
.description('Write generated experiment config for a funnel')
|
|
624
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
625
|
+
.option('--funnel <id-or-slug>', 'Funnel id or slug')
|
|
626
|
+
.option('--dir <path>', 'Local source directory for reading sync manifest', '.'), [
|
|
627
|
+
'fgrove experiments sync',
|
|
628
|
+
'fgrove experiments sync --funnel claimbee-ios --dir ./claimbee-ios',
|
|
629
|
+
])
|
|
630
|
+
.action(async (options) => {
|
|
631
|
+
const token = await readAuthToken();
|
|
632
|
+
const target = await resolveSyncTarget({
|
|
633
|
+
token,
|
|
634
|
+
workspace: options.workspace,
|
|
635
|
+
funnel: options.funnel,
|
|
636
|
+
dir: options.dir,
|
|
637
|
+
});
|
|
638
|
+
const generatedFiles = await syncGeneratedConfigFilesForCli({
|
|
639
|
+
token,
|
|
640
|
+
target,
|
|
641
|
+
kinds: ['experiments'],
|
|
642
|
+
});
|
|
643
|
+
console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
|
|
644
|
+
});
|
|
528
645
|
const analyticsCommand = addExamples(program.command('analytics').description('Download and inspect project analytics'), [
|
|
529
646
|
'fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json --out analytics.json',
|
|
530
647
|
'fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11',
|
|
@@ -794,6 +911,11 @@ addExamples(syncCommand
|
|
|
794
911
|
});
|
|
795
912
|
await mkdir(target.sourceDir, { recursive: true });
|
|
796
913
|
await writeSourceFiles(target.sourceDir, result.files);
|
|
914
|
+
const generatedFiles = await syncGeneratedConfigFilesForCli({
|
|
915
|
+
token,
|
|
916
|
+
target,
|
|
917
|
+
kinds: SYNC_DOWN_GENERATED_CONFIG_KINDS,
|
|
918
|
+
});
|
|
797
919
|
await writeLocalEnvFile(target.sourceDir, result.envFile);
|
|
798
920
|
await ensureGitignore(target.sourceDir);
|
|
799
921
|
await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
|
|
@@ -802,6 +924,7 @@ addExamples(syncCommand
|
|
|
802
924
|
draftVersionId: result.draftVersionId,
|
|
803
925
|
}));
|
|
804
926
|
console.log(`Synced ${result.files.length} files to ${target.sourceDir}`);
|
|
927
|
+
console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
|
|
805
928
|
});
|
|
806
929
|
const envCommand = addExamples(program.command('env').description('Manage local funnel environment files'), [
|
|
807
930
|
'fgrove env pull --dir ./claimbee-ios',
|
package/dist/localSync.d.ts
CHANGED
package/dist/localSync.js
CHANGED
|
@@ -213,7 +213,8 @@ export async function writeSourceFiles(rootDir, files) {
|
|
|
213
213
|
const relativePath = assertSafeSyncPath(file.path);
|
|
214
214
|
const absolutePath = path.join(rootDir, relativePath);
|
|
215
215
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
216
|
-
|
|
216
|
+
const decodedContent = decodeBinarySourceContent(file);
|
|
217
|
+
await writeFile(absolutePath, decodedContent || file.content, decodedContent ? undefined : 'utf8');
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
async function collectSyncFiles(rootDir, dir) {
|
|
@@ -270,3 +271,16 @@ function decodeDataUrl(content) {
|
|
|
270
271
|
}
|
|
271
272
|
return Buffer.from(match[2].replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
272
273
|
}
|
|
274
|
+
function decodeBase64Content(content) {
|
|
275
|
+
const normalized = content.trim().replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/');
|
|
276
|
+
if (!normalized || !/^[A-Za-z0-9+/=]+$/.test(normalized)) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
return Buffer.from(normalized, 'base64');
|
|
280
|
+
}
|
|
281
|
+
function decodeBinarySourceContent(file) {
|
|
282
|
+
if (file.contentEncoding === 'base64') {
|
|
283
|
+
return decodeBase64Content(file.content);
|
|
284
|
+
}
|
|
285
|
+
return decodeDataUrl(file.content);
|
|
286
|
+
}
|
package/package.json
CHANGED
|
@@ -29,6 +29,8 @@ That's it — `toManifestExperiments(experiments)` in the same file feeds the ma
|
|
|
29
29
|
|
|
30
30
|
When a visitor opens the source step, the runtime suspends rendering until the assignment from `@funnelsgrove/analytics` is ready, then routes to the assigned `stepId` and syncs the URL. After assignment, navigation continues through the assigned step's normal edges — the experiment is not re-evaluated mid-flow. Assignments are sticky per visitor.
|
|
31
31
|
|
|
32
|
+
Feature-flag evaluation is scoped by the public project and funnel ids (`NEXT_PUBLIC_PROJECT_ID` and `NEXT_PUBLIC_FUNNEL_ID`). The shared runtime bootstrap fills that scope from public env when callers omit it; if a local funnel owns a custom controller, keep those ids wired into PostHog flag bootstrap or verify the runtime fallback is still in place.
|
|
33
|
+
|
|
32
34
|
The paywall runtime is experiment-ready out of the box: paywall variants can differ in copy, layout, plan presentation, or pricing source, and checkout/discount state stays scoped per funnel.
|
|
33
35
|
|
|
34
36
|
## Verify (part of the < 1 min setup)
|