@funnelsgrove/cli 0.1.10 → 0.1.12

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
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, realpathSync } from 'node:fs';
3
- import { cp, mkdir } from 'node:fs/promises';
3
+ import { cp, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import path from 'node:path';
6
6
  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, formatSyncUploadSummary, readSyncManifest, writeLocalEnvFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
10
+ import { buildSyncManifest, chunkChangedSourceFiles, collectChangedSourceFiles, collectSourceFiles, ensureGitignore, formatSyncUploadSummary, hasLocalSourceChanges, 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';
14
14
  import { isKnownTemplate, KNOWN_TEMPLATE_SLUGS, reskinFunnel } from './reskin.js';
15
15
  import { syncTemplateDocs, TEMPLATE_DOCS_DIR } from './templateDocs.js';
16
+ import { assertHasCohortData, assertHasConversionData, assertHasFunnelPathData, assertHasTransitionData, buildCohortReportPayload, buildConversionReportPayload, buildFunnelPathReportPayload, buildTransitionReportPayload, formatAnalyticsJson, formatCohortTable, formatConversionsTable, formatFunnelPathTable, formatTransitionsTable, normalizeAnalyticsOutputFormat, parseAnalyticsDate, } from './analyticsOutput.js';
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
  const readCliVersion = () => {
18
19
  try {
@@ -61,6 +62,32 @@ export function isCliEntrypoint(invokedPath, modulePath, realpath = realpathSync
61
62
  return path.resolve(invokedPath) === path.resolve(modulePath);
62
63
  }
63
64
  }
65
+ export function buildAnalyticsCommandInput(options) {
66
+ const parsedDate = parseAnalyticsDate(options.date);
67
+ const timezone = options.timezone?.trim() || undefined;
68
+ return {
69
+ date: parsedDate.date,
70
+ from: parsedDate.fromIso,
71
+ to: parsedDate.toIso,
72
+ format: normalizeAnalyticsOutputFormat(options.format),
73
+ timezone,
74
+ };
75
+ }
76
+ export function buildPatchSourceInput(input) {
77
+ return {
78
+ workspaceId: input.workspaceId,
79
+ funnelId: input.funnelId,
80
+ message: input.message,
81
+ baseDraftVersionId: input.baseDraftVersionId,
82
+ files: input.files,
83
+ deletedPaths: input.deletedPaths,
84
+ };
85
+ }
86
+ export function assertCanDraftSyncSource(status) {
87
+ if (status.connection && status.connection.status !== 'disconnected') {
88
+ 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.');
89
+ }
90
+ }
64
91
  const getApiUrl = () => {
65
92
  const options = program.opts();
66
93
  return options.apiUrl || process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL;
@@ -264,6 +291,34 @@ const resolveSyncTarget = async (input) => {
264
291
  manifest,
265
292
  };
266
293
  };
294
+ const resolveAnalyticsProject = async (input) => {
295
+ const active = await loadActiveContext(getConfigPath());
296
+ const project = input.project || (active?.workspaceId === input.workspaceId ? active.projectId : undefined);
297
+ if (!project) {
298
+ throw new Error('No project selected. Pass `--project <id-or-slug>` or set one with `fgrove use --project <id-or-slug>`.');
299
+ }
300
+ return resolveProject(input.token, input.workspaceId, project);
301
+ };
302
+ const resolveAnalyticsFunnel = async (input) => {
303
+ const active = await loadActiveContext(getConfigPath());
304
+ const funnel = input.funnel || (active?.workspaceId === input.workspaceId ? active.funnelId : undefined);
305
+ if (!funnel) {
306
+ throw new Error('No funnel selected. Pass `--funnel <id-or-slug>` or set one with `fgrove use --funnel <id-or-slug>`.');
307
+ }
308
+ return resolveFunnel(input.token, input.workspaceId, funnel);
309
+ };
310
+ const writeOrPrintAnalyticsOutput = async (input) => {
311
+ const output = input.format === 'json'
312
+ ? formatAnalyticsJson(input.payload)
313
+ : input.table;
314
+ if (input.out?.trim()) {
315
+ const outputPath = path.resolve(process.cwd(), input.out);
316
+ await writeFile(outputPath, output, 'utf8');
317
+ console.log(`Wrote ${outputPath}`);
318
+ return;
319
+ }
320
+ process.stdout.write(output);
321
+ };
267
322
  const printRows = (rows, columns) => {
268
323
  for (const row of rows) {
269
324
  console.log(columns.map((column) => row[column] || '').join('\t'));
@@ -470,6 +525,240 @@ addExamples(funnelsCommand
470
525
  });
471
526
  console.log(`${result.funnel.id}\t${result.funnel.name}\t${result.funnel.slug}`);
472
527
  });
528
+ const analyticsCommand = addExamples(program.command('analytics').description('Download and inspect project analytics'), [
529
+ 'fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json --out analytics.json',
530
+ 'fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11',
531
+ 'fgrove analytics transitions --project claimbee --funnel claimbee-ios --date 2026-06-11',
532
+ 'fgrove analytics cohort --project claimbee --date 2026-06-11 --format json',
533
+ ]);
534
+ const loadFunnelAnalyticsContext = async (options) => {
535
+ const token = await readAuthToken();
536
+ const workspace = await resolveWorkspace(token, options.workspace);
537
+ const project = await resolveAnalyticsProject({
538
+ token,
539
+ workspaceId: workspace.id,
540
+ project: options.project,
541
+ });
542
+ const funnel = await resolveAnalyticsFunnel({
543
+ token,
544
+ workspaceId: workspace.id,
545
+ funnel: options.funnel,
546
+ });
547
+ return {
548
+ token,
549
+ workspace,
550
+ project,
551
+ funnel,
552
+ commandInput: buildAnalyticsCommandInput(options),
553
+ };
554
+ };
555
+ const loadProjectAnalyticsContext = async (options) => {
556
+ const token = await readAuthToken();
557
+ const workspace = await resolveWorkspace(token, options.workspace);
558
+ const project = await resolveAnalyticsProject({
559
+ token,
560
+ workspaceId: workspace.id,
561
+ project: options.project,
562
+ });
563
+ return {
564
+ token,
565
+ workspace,
566
+ project,
567
+ commandInput: buildAnalyticsCommandInput(options),
568
+ };
569
+ };
570
+ const fetchStepDropoffAnalytics = async (input) => {
571
+ return callApi({
572
+ path: 'projects.analyticsStepDropoff',
573
+ type: 'query',
574
+ token: input.token,
575
+ data: {
576
+ workspaceId: input.workspaceId,
577
+ projectId: input.projectId,
578
+ funnelId: input.funnelId,
579
+ from: input.commandInput.from,
580
+ to: input.commandInput.to,
581
+ timezone: input.commandInput.timezone,
582
+ },
583
+ });
584
+ };
585
+ addExamples(analyticsCommand
586
+ .command('conversions')
587
+ .description('Download one-day conversion data, full funnel path, and step transitions')
588
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
589
+ .option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
590
+ .option('--funnel <id-or-slug-or-name>', 'Funnel id, slug, or name')
591
+ .requiredOption('--date <yyyy-mm-dd>', 'Analytics date')
592
+ .option('--format <table-or-json>', 'Output format: table or json', 'table')
593
+ .option('--out <path>', 'Write output to a file')
594
+ .option('--timezone <iana-timezone>', 'Analytics timezone, for example America/Los_Angeles'), [
595
+ 'fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11',
596
+ 'fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json --out analytics.json',
597
+ ])
598
+ .action(async (options) => {
599
+ const context = await loadFunnelAnalyticsContext(options);
600
+ const [overview, stepDropoff] = await Promise.all([
601
+ callApi({
602
+ path: 'projects.analyticsOverview',
603
+ type: 'query',
604
+ token: context.token,
605
+ data: {
606
+ workspaceId: context.workspace.id,
607
+ projectId: context.project.id,
608
+ funnelId: context.funnel.id,
609
+ from: context.commandInput.from,
610
+ to: context.commandInput.to,
611
+ timezone: context.commandInput.timezone,
612
+ limit: 20_000,
613
+ },
614
+ }),
615
+ fetchStepDropoffAnalytics({
616
+ token: context.token,
617
+ workspaceId: context.workspace.id,
618
+ projectId: context.project.id,
619
+ funnelId: context.funnel.id,
620
+ commandInput: context.commandInput,
621
+ }),
622
+ ]);
623
+ assertHasConversionData(context.commandInput.date, overview, stepDropoff);
624
+ const payload = buildConversionReportPayload({
625
+ date: context.commandInput.date,
626
+ workspaceId: context.workspace.id,
627
+ project: context.project,
628
+ overview,
629
+ stepDropoff,
630
+ });
631
+ await writeOrPrintAnalyticsOutput({
632
+ format: context.commandInput.format,
633
+ payload,
634
+ table: formatConversionsTable({
635
+ date: context.commandInput.date,
636
+ overview,
637
+ stepDropoff,
638
+ }),
639
+ out: options.out,
640
+ });
641
+ });
642
+ addExamples(analyticsCommand
643
+ .command('funnel-path')
644
+ .description('Download the full one-day funnel path report')
645
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
646
+ .option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
647
+ .option('--funnel <id-or-slug-or-name>', 'Funnel id, slug, or name')
648
+ .requiredOption('--date <yyyy-mm-dd>', 'Analytics date')
649
+ .option('--format <table-or-json>', 'Output format: table or json', 'table')
650
+ .option('--out <path>', 'Write output to a file')
651
+ .option('--timezone <iana-timezone>', 'Analytics timezone, for example America/Los_Angeles'), [
652
+ 'fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11',
653
+ 'fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json',
654
+ ])
655
+ .action(async (options) => {
656
+ const context = await loadFunnelAnalyticsContext(options);
657
+ const stepDropoff = await fetchStepDropoffAnalytics({
658
+ token: context.token,
659
+ workspaceId: context.workspace.id,
660
+ projectId: context.project.id,
661
+ funnelId: context.funnel.id,
662
+ commandInput: context.commandInput,
663
+ });
664
+ assertHasFunnelPathData(context.commandInput.date, stepDropoff);
665
+ const payload = buildFunnelPathReportPayload({
666
+ date: context.commandInput.date,
667
+ workspaceId: context.workspace.id,
668
+ project: context.project,
669
+ stepDropoff,
670
+ });
671
+ await writeOrPrintAnalyticsOutput({
672
+ format: context.commandInput.format,
673
+ payload,
674
+ table: formatFunnelPathTable({
675
+ date: context.commandInput.date,
676
+ stepDropoff,
677
+ }),
678
+ out: options.out,
679
+ });
680
+ });
681
+ addExamples(analyticsCommand
682
+ .command('transitions')
683
+ .description('Download one-day step transitions and drop-offs')
684
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
685
+ .option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
686
+ .option('--funnel <id-or-slug-or-name>', 'Funnel id, slug, or name')
687
+ .requiredOption('--date <yyyy-mm-dd>', 'Analytics date')
688
+ .option('--format <table-or-json>', 'Output format: table or json', 'table')
689
+ .option('--out <path>', 'Write output to a file')
690
+ .option('--timezone <iana-timezone>', 'Analytics timezone, for example America/Los_Angeles'), [
691
+ 'fgrove analytics transitions --project claimbee --funnel claimbee-ios --date 2026-06-11',
692
+ 'fgrove analytics transitions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json',
693
+ ])
694
+ .action(async (options) => {
695
+ const context = await loadFunnelAnalyticsContext(options);
696
+ const stepDropoff = await fetchStepDropoffAnalytics({
697
+ token: context.token,
698
+ workspaceId: context.workspace.id,
699
+ projectId: context.project.id,
700
+ funnelId: context.funnel.id,
701
+ commandInput: context.commandInput,
702
+ });
703
+ assertHasTransitionData(context.commandInput.date, stepDropoff);
704
+ const payload = buildTransitionReportPayload({
705
+ date: context.commandInput.date,
706
+ workspaceId: context.workspace.id,
707
+ project: context.project,
708
+ stepDropoff,
709
+ });
710
+ await writeOrPrintAnalyticsOutput({
711
+ format: context.commandInput.format,
712
+ payload,
713
+ table: formatTransitionsTable({
714
+ date: context.commandInput.date,
715
+ stepDropoff,
716
+ }),
717
+ out: options.out,
718
+ });
719
+ });
720
+ addExamples(analyticsCommand
721
+ .command('cohort')
722
+ .description('Download one-day cohort marketing data')
723
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
724
+ .option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
725
+ .requiredOption('--date <yyyy-mm-dd>', 'Cohort date')
726
+ .option('--format <table-or-json>', 'Output format: table or json', 'table')
727
+ .option('--out <path>', 'Write output to a file')
728
+ .option('--timezone <iana-timezone>', 'Reserved for consistency with conversion commands'), [
729
+ 'fgrove analytics cohort --project claimbee --date 2026-06-11',
730
+ 'fgrove analytics cohort --project claimbee --date 2026-06-11 --format json --out cohort.json',
731
+ ])
732
+ .action(async (options) => {
733
+ const context = await loadProjectAnalyticsContext(options);
734
+ const cohort = await callApi({
735
+ path: 'projects.marketingCohortPerformance',
736
+ type: 'query',
737
+ token: context.token,
738
+ data: {
739
+ workspaceId: context.workspace.id,
740
+ projectId: context.project.id,
741
+ from: context.commandInput.date,
742
+ to: context.commandInput.date,
743
+ },
744
+ });
745
+ assertHasCohortData(context.commandInput.date, cohort);
746
+ const payload = buildCohortReportPayload({
747
+ date: context.commandInput.date,
748
+ workspaceId: context.workspace.id,
749
+ project: context.project,
750
+ cohort,
751
+ });
752
+ await writeOrPrintAnalyticsOutput({
753
+ format: context.commandInput.format,
754
+ payload,
755
+ table: formatCohortTable({
756
+ date: context.commandInput.date,
757
+ cohort,
758
+ }),
759
+ out: options.out,
760
+ });
761
+ });
473
762
  const syncCommand = addExamples(program.command('sync').description('Sync funnel source'), [
474
763
  'fgrove sync down --funnel claimbee-ios --dir ./claimbee-ios',
475
764
  'fgrove sync up --message "Update copy"',
@@ -479,7 +768,8 @@ addExamples(syncCommand
479
768
  .description('Download funnel draft source to a local directory')
480
769
  .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
481
770
  .option('--funnel <id-or-slug>', 'Funnel id or slug')
482
- .requiredOption('--dir <path>', 'Local target directory'), [
771
+ .requiredOption('--dir <path>', 'Local target directory')
772
+ .option('--force', 'Overwrite local changes in an existing synced directory'), [
483
773
  'fgrove sync down --funnel claimbee-ios --dir ./claimbee-ios',
484
774
  ])
485
775
  .action(async (options) => {
@@ -490,6 +780,9 @@ addExamples(syncCommand
490
780
  funnel: options.funnel,
491
781
  dir: options.dir,
492
782
  });
783
+ if (!options.force && target.manifest && await hasLocalSourceChanges(target.sourceDir, target.manifest)) {
784
+ throw new Error('Local sync directory has unuploaded changes. Commit, stash, sync up, or merge them before `fgrove sync down`; pass `--force` only if overwriting local changes is intentional.');
785
+ }
493
786
  const result = await callApi({
494
787
  path: 'funnels.exportSource',
495
788
  type: 'query',
@@ -546,7 +839,7 @@ addExamples(envCommand
546
839
  });
547
840
  addExamples(syncCommand
548
841
  .command('up')
549
- .description('Upload local source into a new funnel draft version')
842
+ .description('Upload local source into a new funnel draft version for funnels without GitHub source sync')
550
843
  .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
551
844
  .option('--funnel <id-or-slug>', 'Funnel id or slug')
552
845
  .option('--dir <path>', 'Local source directory', '.')
@@ -562,6 +855,16 @@ addExamples(syncCommand
562
855
  funnel: options.funnel,
563
856
  dir: options.dir,
564
857
  });
858
+ const githubStatus = await callApi({
859
+ path: 'github.status',
860
+ type: 'query',
861
+ token,
862
+ data: {
863
+ workspaceId: target.workspaceId,
864
+ funnelId: target.funnelId,
865
+ },
866
+ });
867
+ assertCanDraftSyncSource(githubStatus);
565
868
  const changes = target.manifest
566
869
  ? await collectChangedSourceFiles(target.sourceDir, target.manifest)
567
870
  : null;
@@ -580,19 +883,22 @@ addExamples(syncCommand
580
883
  if (changes) {
581
884
  console.log(formatSyncUploadSummary(changes).join('\n'));
582
885
  const batches = chunkChangedSourceFiles(changes);
886
+ let baseDraftVersionId = changes.currentManifest.draftVersionId;
583
887
  for (const batch of batches) {
584
888
  result = await callApi({
585
889
  path: 'funnels.patchSource',
586
890
  type: 'mutation',
587
891
  token,
588
- data: {
892
+ data: buildPatchSourceInput({
589
893
  workspaceId: target.workspaceId,
590
894
  funnelId: target.funnelId,
591
895
  message: options.message,
896
+ baseDraftVersionId,
592
897
  files: batch.files,
593
898
  deletedPaths: batch.deletedPaths,
594
- },
899
+ }),
595
900
  });
901
+ baseDraftVersionId = result.versionId;
596
902
  syncedFileCount += result.syncedFiles.length;
597
903
  deletedFileCount += result.deletedFiles?.length || 0;
598
904
  }
@@ -35,6 +35,7 @@ export declare function writeLocalEnvFile(rootDir: string, envFile: string | nul
35
35
  export declare function readSyncManifest(rootDir: string): Promise<SyncManifest | null>;
36
36
  export declare function collectSourceFiles(rootDir: string): Promise<SourceFile[]>;
37
37
  export declare function collectChangedSourceFiles(rootDir: string, previousManifest: SyncManifest): Promise<ChangedSourceFiles>;
38
+ export declare function hasLocalSourceChanges(rootDir: string, previousManifest: SyncManifest): Promise<boolean>;
38
39
  export declare function chunkChangedSourceFiles(changes: Pick<ChangedSourceFiles, 'deletedPaths' | 'files'>, maxContentChars?: number): SourceFilePatchBatch[];
39
40
  export declare function formatSyncUploadSummary(changes: Pick<ChangedSourceFiles, 'deletedPaths' | 'files'>, largestFileLimit?: number): string[];
40
41
  export declare function writeSourceFiles(rootDir: string, files: SourceFile[]): Promise<void>;
package/dist/localSync.js CHANGED
@@ -127,6 +127,10 @@ export async function collectChangedSourceFiles(rootDir, previousManifest) {
127
127
  files: await readSourceFiles(rootDir, changedManifestFiles),
128
128
  };
129
129
  }
130
+ export async function hasLocalSourceChanges(rootDir, previousManifest) {
131
+ const changes = await collectChangedSourceFiles(rootDir, previousManifest);
132
+ return changes.files.length > 0 || changes.deletedPaths.length > 0;
133
+ }
130
134
  export function chunkChangedSourceFiles(changes, maxContentChars = DEFAULT_PATCH_BATCH_CONTENT_CHARS) {
131
135
  const batches = [];
132
136
  let currentBatch = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "FunnelsGrove command-line tools for editing, syncing, and publishing funnels",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,107 +1,70 @@
1
1
  # AGENTS.md
2
2
 
3
- Behavioral guidelines to reduce common LLM coding mistakes. Merge these rules with project-specific instructions as needed.
4
-
5
- **Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
6
-
7
- ## Read Docs Before Editing
8
-
9
- Before changing any funnel file, read the relevant topic docs in this folder and follow their contracts. Make the edit according to those docs first; if the requested change conflicts with the docs, stop and explain the conflict before editing.
10
-
11
- Architecture docs are part of the change. When you change funnel routing, runtime state, SDK API contracts, URL handoff parameters, checkout/subscription behavior, analytics events, environment config, or shared package boundaries, update `template_docs` in the same change so future agents inherit the new contract.
12
-
13
- ## 1. Think Before Coding
14
-
15
- **Don't assume. Don't hide confusion. Surface tradeoffs.**
16
-
17
- Before implementing:
18
- - State your assumptions explicitly. If uncertain, ask.
19
- - If multiple interpretations exist, present them. Do not pick silently.
20
- - If a simpler approach exists, say so. Push back when warranted.
21
- - If something is unclear, stop. Name what's confusing. Ask.
22
-
23
- ## 2. Simplicity First
24
-
25
- **Minimum code that solves the problem. Nothing speculative.**
26
-
27
- - No features beyond what was asked.
28
- - No abstractions for single-use code.
29
- - No flexibility or configurability that was not requested.
30
- - No error handling for impossible scenarios.
31
- - If you write 200 lines and it could be 50, rewrite it.
32
-
33
- Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
34
-
35
- ## 3. Surgical Changes
36
-
37
- **Touch only what you must. Clean up only your own mess.**
38
-
39
- When editing existing code:
40
- - Do not improve adjacent code, comments, or formatting.
41
- - Do not refactor things that are not broken.
42
- - Match existing style, even if you would do it differently.
43
- - If you notice unrelated dead code, mention it. Do not delete it.
44
-
45
- When your changes create orphans:
46
- - Remove imports, variables, functions, and files that your changes made unused.
47
- - Do not remove pre-existing dead code unless asked.
48
-
49
- The test: every changed line should trace directly to the user's request.
50
-
51
- ## 4. Goal-Driven Execution
52
-
53
- **Define success criteria. Loop until verified.**
54
-
55
- Transform tasks into verifiable goals:
56
- - "Add validation" -> "Write tests for invalid inputs, then make them pass"
57
- - "Fix the bug" -> "Write a test that reproduces it, then make it pass"
58
- - "Refactor X" -> "Ensure tests pass before and after"
59
-
60
- For multi-step tasks, state a brief plan:
61
-
62
- ```text
63
- 1. [Step] -> verify: [check]
64
- 2. [Step] -> verify: [check]
65
- 3. [Step] -> verify: [check]
66
- ```
67
-
68
- Strong success criteria let you loop independently. Weak criteria like "make it work" require constant clarification.
69
-
70
- ---
71
-
72
- **These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
73
-
74
- ## Documentation Index
75
-
76
- Use this folder as the local source-of-truth guide when editing a synced FunnelsGrove funnel.
77
-
78
- Current funnel templates are small Next.js apps built on shared runtime modules:
79
-
80
- - `@funnelsgrove/runtime` owns the funnel contract: routing, user state, content localization, builder preview patches, subscriptions, analytics helpers, runtime env, and theme variables.
81
- - `@funnelsgrove/analytics` owns analytics tracking and A/B experiment tracking. It encapsulates the PostHog integration so funnel steps do not call PostHog directly.
82
- - `@funnelsgrove/payments` owns checkout plans, discounts, Stripe sessions/intents, wallet slots, and shared checkout UI.
83
- - The local funnel owns product decisions: step views, content files, editor fields, flow manifest, theme, checked-in assets, and billing plan ids.
84
-
85
- Keep changes simple and local. Most edits should touch one step file plus its matching `content/` and `editor/` files, or one manifest/config file for flow, theme, or billing changes.
86
-
87
- ## Funnel Workflow
88
-
89
- 1. Run `fgrove status` and confirm the active project and funnel.
90
- 2. Keep edits inside the synced funnel tree.
91
- 3. Run `fgrove env pull` when remote project settings changed and you need the latest local `.env`.
92
- 4. Run the funnel's local checks before syncing.
93
- 5. Run `fgrove sync up --message '<summary>'`.
94
- 6. Run `fgrove publish --env preview --message '<summary>'` and verify the preview.
95
- 7. Publish production only when explicitly requested.
96
-
97
- - [Editing or Creating a Step](docs/editing-step.md)
98
- - [Editing Flow](docs/editing-flow.md)
99
- - [Funnel Runtime Architecture](docs/funnel-runtime-architecture.md)
100
- - [Editor and Content](docs/editor-and-content.md)
101
- - [Payment Plans and Discounts](docs/payment-plans-and-discounts.md)
102
- - [SDK API Endpoints](docs/sdk-api-endpoints.md)
103
- - [Analytics](docs/analytics.md)
104
- - [Meta Pixel and Conversions API](docs/meta-pixel-conversions-api.md)
105
- - [A/B Experiments](docs/ab-experiments.md)
106
- - [Theme](docs/theme.md)
107
- - [Publishing and Versioning](docs/publishing-and-versioning.md)
3
+ Source-of-truth guide for editing this synced FunnelsGrove funnel. Read the matching topic doc before editing; the docs define the contracts the runtime expects.
4
+
5
+ ## Find Your Task
6
+
7
+ | Task | Read | Edit | Target time |
8
+ | --- | --- | --- | --- |
9
+ | Add or edit a step | [docs/editing-step.md](docs/editing-step.md) + [docs/step-ui-guidelines.md](docs/step-ui-guidelines.md) | `src/steps/*` + registries + manifest | < 3 min to first preview |
10
+ | Change step order / branching | [docs/editing-flow.md](docs/editing-flow.md) | `src/config/funnel.manifest.ts` | < 2 min |
11
+ | Set up an A/B experiment | [docs/ab-experiments.md](docs/ab-experiments.md) | `src/config/experiments.ts` | < 1 min when variant step exists |
12
+ | Edit copy or images | [docs/editor-and-content.md](docs/editor-and-content.md) | `src/steps/content/*.content.ts` | < 2 min |
13
+ | Edit image loading/performance | [docs/editing-flow.md](docs/editing-flow.md) + [docs/step-ui-guidelines.md](docs/step-ui-guidelines.md) + [docs/publishing-and-versioning.md](docs/publishing-and-versioning.md) | `src/config/funnel.manifest.ts`, `src/components/FunnelFlow.tsx`, image assets | careful, QA required |
14
+ | Change plans, prices, discounts | [docs/payment-plans-and-discounts.md](docs/payment-plans-and-discounts.md) | `src/config/billing.plans.ts` | careful, read doc fully |
15
+ | Edit paywall or checkout | [docs/payment-plans-and-discounts.md](docs/payment-plans-and-discounts.md) + [docs/step-ui-guidelines.md](docs/step-ui-guidelines.md) | `src/steps/step-32-paywall*.tsx` | careful, QA required |
16
+ | Change colors / fonts | [docs/theme.md](docs/theme.md) | `src/theme/theme.ts` | < 2 min |
17
+ | QA before publish | [docs/qa-checklist.md](docs/qa-checklist.md) | nothing — test and report | as long as it takes |
18
+ | Publish | [docs/publishing-and-versioning.md](docs/publishing-and-versioning.md) | nothing `fgrove` commands | — |
19
+
20
+ Deep dives: [Funnel Runtime Architecture](docs/funnel-runtime-architecture.md), [SDK API Endpoints](docs/sdk-api-endpoints.md), [Analytics](docs/analytics.md), [Meta Pixel and Conversions API](docs/meta-pixel-conversions-api.md).
21
+
22
+ ## Architecture in 30 Seconds
23
+
24
+ The funnel is a small Next.js app on shared packages:
25
+
26
+ - `@funnelsgrove/runtime` — routing, user state, content localization, builder preview, theme.
27
+ - `@funnelsgrove/analytics` event tracking and A/B assignment (wraps PostHog; never call PostHog directly).
28
+ - `@funnelsgrove/payments` plans, discounts, Stripe checkout, wallet buttons, shared checkout UI.
29
+ - This repo product decisions only: step views, content, editor fields, flow manifest, theme, assets, plan ids.
30
+
31
+ Most edits touch one step file plus its matching `content/` and `editor/` files, or one config file. If an edit spreads wider than that, stop and re-read the topic doc.
32
+
33
+ ## Standard Workflow
34
+
35
+ 1. `fgrove status` — confirm the active project and funnel.
36
+ 2. `git status --short` — if local changes exist, checkpoint them before any refresh.
37
+ 3. `fgrove github status` when GitHub is connected and remote is ahead, run `fgrove github pull`, then poll `fgrove github status` until the pull job is completed or skipped before syncing the latest draft into a clean directory and merging the local checkpoint intentionally.
38
+ 4. When GitHub is not connected, use `fgrove sync down --funnel <id-or-slug> --dir <temp-dir>` or an already selected `fgrove use` context as the remote source, then merge that clean hosted draft with local changes before editing further.
39
+ 5. Make the scoped edit per the topic doc.
40
+ 6. `npm run test:run && npm run lint` (or the checks this tree defines).
41
+ 7. `npm run dev` verify in local preview at all four default breakpoints: small 375x667, medium 393x852, large 402x874, and desktop-small 1280x800 ([docs/step-ui-guidelines.md](docs/step-ui-guidelines.md)).
42
+ 8. Run the relevant part of [docs/qa-checklist.md](docs/qa-checklist.md).
43
+ 9. Ask the user before publishing. If GitHub is connected, commit and push with normal git, run `fgrove github pull`, then poll `fgrove github status` until the pull job is completed or skipped. Do not also run `fgrove sync up` for the same diff.
44
+ 10. If GitHub is not connected, run `fgrove sync up --message '<summary>'`.
45
+ 11. Publish preview with `fgrove publish --env preview --message '<summary>'`.
46
+ 12. QA the preview URL. Production publish only on explicit request, after preview QA.
47
+
48
+ `fgrove env pull` refreshes the local `.env` when remote project settings changed.
49
+ Do not run `fgrove sync down` over a dirty synced directory unless discarding
50
+ local changes is intentional and `--force` is passed. If `fgrove sync up`
51
+ reports that the remote draft changed since this directory was synced, download
52
+ the current draft into a temp directory, merge local changes, rerun checks, and
53
+ sync again.
54
+ For GitHub-connected funnels, source changes must flow through GitHub first:
55
+ `git push`, then `fgrove github pull`, then publish. Do not mix `fgrove sync up`
56
+ with the same local source diff.
57
+
58
+ ## Behavioral Rules
59
+
60
+ 1. **Think before coding.** State assumptions. If multiple interpretations exist, present them don't pick silently. If something is unclear, ask before editing.
61
+ 2. **Simplicity first.** Minimum code that solves the problem. No speculative abstractions, options, or error handling for impossible states.
62
+ 3. **Surgical changes.** Touch only what the request requires. Match existing style. Remove only orphans your own change created. Every changed line should trace to the request.
63
+ 4. **Goal-driven execution.** Define the success check before editing ("step renders at all default breakpoints and Continue advances to step-X"), then loop until it passes.
64
+ 5. **Image performance locked.** Keep build-time raster compression plus AVIF/WebP variants enabled. For funnel step images, use `funnelManifest.assets` + step `assetIds`, priority/preload only for first-viewport images, and low-priority next-step warming from the shell. Do not preload the whole funnel image set.
65
+ 6. **Meaningful URLs.** New step `path` values are public product routes, so use readable slugs like `/motivation`, `/fitness-goal`, or `/email-capture`. Sequential ids and `step-NN-*` filenames are okay for ordering, but do not create public routes like `/step-1`.
66
+ 7. **Flow labeling hygiene.** When changing `edgesByStepId`, keep manifest `branches` current for conditional paths that own steps before reconverging. Keep builder metadata ClaimBee-style and source-readable: `edgesByStepId` keys/targets and `branches: [...]` must use inline string literals, not `someStep.id` variables or a `branches: flowBranches` indirection. Each branch needs a readable `name`, answer-derived `label` such as `yes-branch`, useful `tags`, and the owned `stepIds`. Running experiment variants need labels/tags like `paywall-test-control` and `paywall-test-variant-b`; stopped A/B variants or other inactive screens should keep stable tags and remain unreachable from the default flow so builder marks them `unused`.
67
+
68
+ Architecture docs are part of the change: if you change routing, runtime state, SDK contracts, URL handoff parameters, checkout behavior, analytics events, or shared package boundaries, update the matching doc in the same change.
69
+
70
+ Preserve existing step ids, paths, and answer keys unless the task is an explicit migration — they feed routes, persisted answers, analytics, and publish history.