@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 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',
@@ -15,6 +15,7 @@ export type SourceFile = {
15
15
  path: string;
16
16
  content: string;
17
17
  contentType?: string;
18
+ contentEncoding?: 'base64';
18
19
  };
19
20
  export type ChangedSourceFiles = {
20
21
  currentManifest: SyncManifest;
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
- await writeFile(absolutePath, decodeDataUrl(file.content) || file.content, decodeDataUrl(file.content) ? undefined : 'utf8');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "FunnelsGrove command-line tools for editing, syncing, and publishing funnels",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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)