@funnelsgrove/cli 0.1.19 → 0.1.24

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/README.md CHANGED
@@ -41,6 +41,22 @@ for the same local diff.
41
41
 
42
42
  Use `fgrove env pull` from a synced folder to refresh only the ignored local `.env` file after project settings change, without replacing source files.
43
43
 
44
+ Create an experiment draft from a strict JSON spec:
45
+
46
+ ```bash
47
+ fgrove experiments create --spec experiment.json --dir .
48
+ fgrove experiments create --spec experiment.json --dir . --json
49
+ fgrove validate
50
+ ```
51
+
52
+ The spec uses a stable `id` and `type: "step"`, `"paywall"`, or `"pricing"` with two to five variants, exactly one control, unique metrics and variant keys, and integer traffic totaling 100. Pricing variants also require `offerSetKey` and must route to the top-level `stepId`. See `docs/funnelsgrove/recipes/add-experiment.md` in a synced funnel for complete valid JSON examples and remediation guidance.
53
+
54
+ Creation is draft-only: it creates no PostHog flag and activates no traffic. The experiment becomes visible in the UI after the API commits its hosted generated snapshot, and the CLI installs those exact generated bytes locally through recoverable staging. `.funnelsgrove-sync.json` stays local and ignored.
55
+
56
+ With `--json`, success output contains only `experimentId`, `experimentKey`, `draftVersionId`, and `writtenPaths`, with generated paths in canonical order.
57
+
58
+ For a non-GitHub funnel, deliver the local source with `fgrove sync up`. For a GitHub-connected funnel, commit and push the generated source with the rest of the change, then run `fgrove github pull`; do not commit `.funnelsgrove-sync.json` or use `fgrove sync up` for that diff.
59
+
44
60
  GitHub sync workflow:
45
61
 
46
62
  ```bash
@@ -108,8 +108,9 @@ export type MarketingCohortPerformanceRow = {
108
108
  revenueDay3: number;
109
109
  revenueDay90: number;
110
110
  revenueDay180: number;
111
+ pLtvCurrency: string | null;
111
112
  pLtv: number | null;
112
- pLtvPredictedNetRevenueDay365Minor: number;
113
+ pLtvPredictedNetRevenueDay365Minor: number | null;
113
114
  pLtvPredictedCustomerCount: number;
114
115
  campaign: string;
115
116
  channel: string;
@@ -78,8 +78,8 @@ const formatPercent = (value) => (typeof value === 'number' && Number.isFinite(v
78
78
  const formatPercentPoints = (value) => (typeof value === 'number' && Number.isFinite(value)
79
79
  ? `${value.toFixed(2)}%`
80
80
  : '');
81
- const formatCurrency = (value) => new Intl.NumberFormat('en-US', {
82
- currency: 'USD',
81
+ const formatCurrency = (value, currency = 'USD') => new Intl.NumberFormat('en-US', {
82
+ currency,
83
83
  maximumFractionDigits: 2,
84
84
  style: 'currency',
85
85
  }).format(value);
@@ -225,11 +225,25 @@ export const formatConversionsTable = (input) => {
225
225
  return `${lines.join('\n')}\n`;
226
226
  };
227
227
  const flattenCohortRows = (rows) => rows.flatMap((row) => [row, ...(row.segments || []), ...(row.children || [])]);
228
+ const normalizeCurrency = (value) => {
229
+ const currency = value?.trim().toUpperCase() || '';
230
+ return /^[A-Z]{3}$/.test(currency) ? currency : null;
231
+ };
228
232
  const predictedAverageLtv = (row) => {
233
+ if (!normalizeCurrency(row.pLtvCurrency)) {
234
+ return null;
235
+ }
229
236
  if (row.pLtvPredictedCustomerCount > 0) {
230
- return row.pLtvPredictedNetRevenueDay365Minor / row.pLtvPredictedCustomerCount / 100;
237
+ return typeof row.pLtvPredictedNetRevenueDay365Minor === 'number'
238
+ && Number.isFinite(row.pLtvPredictedNetRevenueDay365Minor)
239
+ ? row.pLtvPredictedNetRevenueDay365Minor
240
+ / row.pLtvPredictedCustomerCount
241
+ / 100
242
+ : null;
231
243
  }
232
- return Number(row.pLtv) || 0;
244
+ return typeof row.pLtv === 'number' && Number.isFinite(row.pLtv)
245
+ ? row.pLtv
246
+ : null;
233
247
  };
234
248
  const ratioToSpend = (value, spend) => (Number.isFinite(value) && Number.isFinite(spend) && spend !== 0 ? value / spend : null);
235
249
  const formatRoi = (profit, spend) => {
@@ -243,12 +257,18 @@ const formatRoas = (revenue, spend) => {
243
257
  export const formatCohortTable = (input) => {
244
258
  const lines = [
245
259
  `Cohort\t${input.date}`,
246
- 'cohort\tmediaSource\tfunnelUrl\tspend\tsubscribers\taliveRate\trevenue\tpLtv\tcpa\tpredictedProfit\tpROI\tROAS\tROAS d3\tROAS d90\tROAS d180',
260
+ 'cohort\tmediaSource\tfunnelUrl\tspend\tsubscribers\taliveRate\trevenue\tpLtv\tpLtvCurrency\tcpa\tpredictedProfit\tpROI\tROAS\tROAS d3\tROAS d90\tROAS d180',
247
261
  ];
248
262
  for (const row of flattenCohortRows(input.cohort.rows)) {
249
263
  const cpa = row.subscribers > 0 ? row.spend / row.subscribers : 0;
250
- const predictedRevenue = predictedAverageLtv(row) * row.subscribers;
251
- const predictedProfit = predictedRevenue - row.spend;
264
+ const averageLtv = predictedAverageLtv(row);
265
+ const predictedRevenue = averageLtv === null
266
+ ? null
267
+ : averageLtv * row.subscribers;
268
+ const predictedProfit = predictedRevenue === null
269
+ ? null
270
+ : predictedRevenue - row.spend;
271
+ const pLtvCurrency = normalizeCurrency(row.pLtvCurrency);
252
272
  lines.push([
253
273
  row.cohortDate,
254
274
  row.mediaSource,
@@ -257,10 +277,15 @@ export const formatCohortTable = (input) => {
257
277
  formatNumber(row.subscribers),
258
278
  formatPercentPoints(row.subscribersAlivePercent),
259
279
  formatCurrency(row.revenue),
260
- row.pLtv === null ? '' : formatCurrency(row.pLtv),
280
+ row.pLtv === null || pLtvCurrency === null
281
+ ? ''
282
+ : formatCurrency(row.pLtv, pLtvCurrency),
283
+ pLtvCurrency || '',
261
284
  row.subscribers > 0 ? formatCurrency(cpa) : '',
262
- formatCurrency(predictedProfit),
263
- formatRoi(predictedProfit, row.spend),
285
+ predictedProfit === null
286
+ ? ''
287
+ : formatCurrency(predictedProfit, pLtvCurrency || 'USD'),
288
+ predictedProfit === null ? '' : formatRoi(predictedProfit, row.spend),
264
289
  formatRoas(row.revenue, row.spend),
265
290
  formatRoas(row.revenueDay3, row.spend),
266
291
  formatRoas(row.revenueDay90, row.spend),
@@ -1,5 +1,6 @@
1
1
  import { type FunnelContractDiagnosticCode } from '@funnelsgrove/runtime';
2
2
  import type { SourceCandidateOperation } from './localSync.js';
3
+ import type { AgentExperimentSpec, ExperimentCreateResponse } from './experimentCreate.js';
3
4
  type FetchFn = typeof fetch;
4
5
  type TrpcProcedureType = 'query' | 'mutation';
5
6
  export type TrpcCallInput = {
@@ -17,6 +18,12 @@ export type FunnelSourceCandidateApiContext = {
17
18
  fetchFn?: FetchFn;
18
19
  signal?: AbortSignal;
19
20
  };
21
+ export type CreateFunnelExperimentFromSpecInput = {
22
+ workspaceId: string;
23
+ funnelId: string;
24
+ expectedDraftVersionId: string;
25
+ spec: AgentExperimentSpec;
26
+ };
20
27
  export type FunnelSourceCandidateStatus = 'staging' | 'validating' | 'committed' | 'aborted' | 'expired' | 'failed';
21
28
  export type BeginFunnelSourceCandidateInput = {
22
29
  workspaceId: string;
@@ -64,7 +71,7 @@ export type FinalizeFunnelSourceCandidateResult = {
64
71
  idempotent: boolean;
65
72
  diagnostics: readonly FunnelContractApiDiagnostic[];
66
73
  };
67
- export declare const FUNNEL_CONTRACT_API_INGRESSES: readonly ["agent-transaction", "cli-sync", "github-sync", "builder-authoring", "preview-publish", "production-publish", "published-artifact-serving"];
74
+ export declare const FUNNEL_CONTRACT_API_INGRESSES: readonly ["cli-sync", "github-sync", "builder-authoring", "preview-publish", "production-publish", "published-artifact-serving"];
68
75
  export type FunnelContractApiIngress = (typeof FUNNEL_CONTRACT_API_INGRESSES)[number];
69
76
  export type FunnelContractApiDiagnostic = {
70
77
  code: FunnelContractDiagnosticCode;
@@ -95,4 +102,5 @@ export declare const beginFunnelSourceCandidate: (context: FunnelSourceCandidate
95
102
  export declare const stageFunnelSourceCandidate: (context: FunnelSourceCandidateApiContext, input: StageFunnelSourceCandidateInput) => Promise<StageFunnelSourceCandidateResult>;
96
103
  export declare const finalizeFunnelSourceCandidate: (context: FunnelSourceCandidateApiContext, input: FinalizeFunnelSourceCandidateInput) => Promise<FinalizeFunnelSourceCandidateResult>;
97
104
  export declare const abortFunnelSourceCandidate: (context: FunnelSourceCandidateApiContext, input: FinishFunnelSourceCandidateInput) => Promise<FunnelSourceCandidateSummary>;
105
+ export declare const createFunnelExperimentFromSpec: (context: FunnelSourceCandidateApiContext, input: CreateFunnelExperimentFromSpecInput) => Promise<ExperimentCreateResponse>;
98
106
  export {};
package/dist/apiClient.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { CURRENT_STEP_CONTRACT_VERSION, FUNNEL_CONTRACT_DIAGNOSTIC_CODES, } from '@funnelsgrove/runtime';
2
2
  export const FUNNEL_CONTRACT_API_INGRESSES = Object.freeze([
3
- 'agent-transaction',
4
3
  'cli-sync',
5
4
  'github-sync',
6
5
  'builder-authoring',
@@ -276,3 +275,9 @@ export const beginFunnelSourceCandidate = (context, input) => callFunnelSourceCa
276
275
  export const stageFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.stageSourceCandidate', input);
277
276
  export const finalizeFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.finalizeSourceCandidate', input);
278
277
  export const abortFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.abortSourceCandidate', input);
278
+ export const createFunnelExperimentFromSpec = (context, input) => callTrpcProcedure({
279
+ ...context,
280
+ path: 'funnelExperiments.createFromSpec',
281
+ type: 'mutation',
282
+ input,
283
+ });
package/dist/cli.d.ts CHANGED
@@ -144,6 +144,7 @@ type SyncTargetIdInput = {
144
144
  };
145
145
  type GeneratedConfigKind = 'offerSets' | 'experiments';
146
146
  export declare const SYNC_DOWN_GENERATED_CONFIG_KINDS: GeneratedConfigKind[];
147
+ export declare const EXPERIMENT_TRANSACTION_RECOVERY_COMMANDS: readonly ["sync down", "sync up", "experiments sync", "offer-sets sync"];
147
148
  export declare function resolveSyncTargetIds(input: SyncTargetIdInput): {
148
149
  workspaceId: string;
149
150
  funnelId?: string;
package/dist/cli.js CHANGED
@@ -8,9 +8,10 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { Command } from 'commander';
10
10
  import { CURRENT_STEP_CONTRACT_VERSION, FUNNEL_CONTRACT_DIAGNOSTIC_CODES, PREVIOUS_STEP_CONTRACT_VERSION, createFunnelContractDiagnostic, } from '@funnelsgrove/runtime';
11
- import { abortFunnelSourceCandidate, beginFunnelSourceCandidate, callTrpcProcedure, finalizeFunnelSourceCandidate, stageFunnelSourceCandidate, } from './apiClient.js';
11
+ import { abortFunnelSourceCandidate, beginFunnelSourceCandidate, callTrpcProcedure, createFunnelExperimentFromSpec, finalizeFunnelSourceCandidate, stageFunnelSourceCandidate, } from './apiClient.js';
12
12
  import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
13
13
  import { buildSyncManifest, buildCommittedSyncManifest, buildSourceCandidateOperations, collectChangedSourceFiles, collectSourceSnapshot, ensureGitignore, formatSyncUploadSummary, hasLocalSourceChanges, readSyncManifest, runSyncDownLocalLifecycle, runSourceCandidateSync, writeLocalEnvFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
14
+ import { executeExperimentCreate, formatExperimentCreateSuccess, recoverExperimentCreateTransaction, } from './experimentCreate.js';
14
15
  import { pullEnvFile } from './envSync.js';
15
16
  import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, } from './githubOutput.js';
16
17
  import { syncGitHubDraftIfConnected } from './githubSyncFlow.js';
@@ -1059,6 +1060,13 @@ const GENERATED_CONFIG_PATH_BY_KIND = {
1059
1060
  experiments: 'src/config/experiments.generated.ts',
1060
1061
  };
1061
1062
  export const SYNC_DOWN_GENERATED_CONFIG_KINDS = ['offerSets', 'experiments'];
1063
+ export const EXPERIMENT_TRANSACTION_RECOVERY_COMMANDS = [
1064
+ 'sync down',
1065
+ 'sync up',
1066
+ 'experiments sync',
1067
+ 'offer-sets sync',
1068
+ ];
1069
+ const experimentTransactionRecoveryCommands = new Set(EXPERIMENT_TRANSACTION_RECOVERY_COMMANDS);
1062
1070
  export function resolveSyncTargetIds(input) {
1063
1071
  const workspaceId = input.explicitWorkspaceId ||
1064
1072
  input.manifest?.workspaceId ||
@@ -1432,6 +1440,13 @@ const syncGeneratedConfigFilesForCli = async (input) => {
1432
1440
  };
1433
1441
  const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
1434
1442
  export const program = new Command();
1443
+ program.hook('preAction', async (_command, actionCommand) => {
1444
+ const commandPath = `${actionCommand.parent?.name() || ''} ${actionCommand.name()}`.trim();
1445
+ if (!experimentTransactionRecoveryCommands.has(commandPath))
1446
+ return;
1447
+ const options = actionCommand.opts();
1448
+ await recoverExperimentCreateTransaction(path.resolve(process.cwd(), options.dir || '.'));
1449
+ });
1435
1450
  program
1436
1451
  .name('fgrove')
1437
1452
  .description('FunnelsGrove CLI for editing, syncing, and publishing funnels')
@@ -1654,9 +1669,30 @@ addExamples(offerSetsCommand
1654
1669
  console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
1655
1670
  });
1656
1671
  const experimentsCommand = addExamples(program.command('experiments').description('Manage funnel experiments'), [
1672
+ 'fgrove experiments create --spec experiment.json --dir .',
1657
1673
  'fgrove experiments sync',
1658
1674
  'fgrove experiments sync --funnel claimbee-ios',
1659
1675
  ]);
1676
+ addExamples(experimentsCommand
1677
+ .command('create')
1678
+ .description('Create a draft experiment from a JSON spec')
1679
+ .requiredOption('--spec <path>', 'Experiment JSON spec')
1680
+ .option('--dir <path>', 'Synced local funnel directory', '.')
1681
+ .option('--json', 'Emit one machine-readable success object'), [
1682
+ 'fgrove experiments create --spec experiment.json --dir .',
1683
+ 'fgrove experiments create --spec experiment.json --dir ./claimbee-ios --json',
1684
+ ])
1685
+ .action(async (options) => {
1686
+ const result = await executeExperimentCreate({
1687
+ sourceDir: path.resolve(process.cwd(), options.dir),
1688
+ specPath: path.resolve(process.cwd(), options.spec),
1689
+ createFromSpec: async (input) => createFunnelExperimentFromSpec({
1690
+ apiUrl: getApiUrl(),
1691
+ token: await readAuthToken(),
1692
+ }, input),
1693
+ });
1694
+ process.stdout.write(formatExperimentCreateSuccess(result, options.json === true));
1695
+ });
1660
1696
  addExamples(experimentsCommand
1661
1697
  .command('sync')
1662
1698
  .description('Write generated experiment config for a funnel')
@@ -0,0 +1,100 @@
1
+ import { readSyncManifest, type SyncManifest } from './localSync.js';
2
+ export declare const EXPERIMENT_GENERATED_PATHS: readonly ["src/config/experiments.generated.ts", "src/config/experiments.ts"];
3
+ declare const EXPERIMENT_GENERATED_CONTENT_TYPE = "text/typescript";
4
+ declare const EXPERIMENT_METRICS: readonly ["step_completion", "next_step_reached", "checkout_opened", "funnel_completed", "paying_customer"];
5
+ export type ExperimentMetric = (typeof EXPERIMENT_METRICS)[number];
6
+ export type AgentExperimentVariant = {
7
+ variantKey: string;
8
+ label: string;
9
+ routeToStepId: string;
10
+ trafficPercent: number;
11
+ isControl: boolean;
12
+ };
13
+ export type AgentPricingExperimentVariant = AgentExperimentVariant & {
14
+ offerSetKey: string;
15
+ };
16
+ type SharedAgentExperimentSpec = {
17
+ id: string;
18
+ name: string;
19
+ stepId: string;
20
+ primaryMetric: ExperimentMetric;
21
+ trackedMetrics: ExperimentMetric[];
22
+ };
23
+ export type AgentExperimentSpec = SharedAgentExperimentSpec & ({
24
+ type: 'step' | 'paywall';
25
+ variants: AgentExperimentVariant[];
26
+ } | {
27
+ type: 'pricing';
28
+ variants: AgentPricingExperimentVariant[];
29
+ });
30
+ export type GeneratedExperimentFile = {
31
+ path: (typeof EXPERIMENT_GENERATED_PATHS)[number];
32
+ content: string;
33
+ contentType: typeof EXPERIMENT_GENERATED_CONTENT_TYPE;
34
+ };
35
+ export type ExperimentCreateResponse = {
36
+ experiment: Record<string, unknown> & {
37
+ id: string;
38
+ posthog_flag_key: string;
39
+ };
40
+ variants: Array<Record<string, unknown> & {
41
+ id: string;
42
+ experiment_id: string;
43
+ variant_key: string;
44
+ label: string;
45
+ route_to_step_id: string;
46
+ traffic_percent: number;
47
+ is_control: boolean;
48
+ }>;
49
+ draftVersionId: string;
50
+ generatedFiles: [GeneratedExperimentFile, GeneratedExperimentFile];
51
+ githubConnected: boolean;
52
+ };
53
+ export declare class ExperimentCreateContractError extends Error {
54
+ constructor(fieldPath: string, reason: string);
55
+ }
56
+ export declare function parseAgentExperimentSpec(value: unknown): AgentExperimentSpec;
57
+ export declare function readAgentExperimentSpec(filePath: string): Promise<AgentExperimentSpec>;
58
+ export declare function parseExperimentCreateResponse(value: unknown, requestedSpec: AgentExperimentSpec, expectedFunnelIdValue: string): ExperimentCreateResponse;
59
+ export declare function assertGeneratedExperimentFilesUnchanged(root: string, manifestValue: SyncManifest): Promise<void>;
60
+ export declare function buildExperimentCreateManifest(manifestValue: SyncManifest, draftVersionIdValue: string, filesValue: unknown): SyncManifest;
61
+ export type ExperimentCreateInstallPhase = 'prepared' | 'ready' | `generated:${GeneratedExperimentFile['path']}` | 'before-manifest' | 'manifest';
62
+ type ExperimentCreateInstallHooks = {
63
+ afterPhase?: (phase: ExperimentCreateInstallPhase) => void | Promise<void>;
64
+ };
65
+ export declare function recoverExperimentCreateTransaction(root: string): Promise<boolean>;
66
+ export declare function installExperimentCreateTransaction(root: string, input: {
67
+ generatedFiles: unknown;
68
+ manifest: SyncManifest;
69
+ }, hooks?: ExperimentCreateInstallHooks): Promise<void>;
70
+ export type ExecuteExperimentCreateInput = {
71
+ sourceDir: string;
72
+ specPath: string;
73
+ createFromSpec: (input: {
74
+ workspaceId: string;
75
+ funnelId: string;
76
+ expectedDraftVersionId: string;
77
+ spec: AgentExperimentSpec;
78
+ }) => Promise<unknown>;
79
+ };
80
+ export type ExperimentCreateSuccess = {
81
+ experimentId: string;
82
+ experimentKey: string;
83
+ draftVersionId: string;
84
+ writtenPaths: [...typeof EXPERIMENT_GENERATED_PATHS];
85
+ githubConnected: boolean;
86
+ };
87
+ type ExecuteExperimentCreateDependencies = {
88
+ recover: typeof recoverExperimentCreateTransaction;
89
+ readManifest: typeof readSyncManifest;
90
+ readSpec: typeof readAgentExperimentSpec;
91
+ assertGeneratedFilesUnchanged: typeof assertGeneratedExperimentFilesUnchanged;
92
+ parseResponse: typeof parseExperimentCreateResponse;
93
+ install: (sourceDir: string, input: {
94
+ generatedFiles: unknown;
95
+ manifest: SyncManifest;
96
+ }) => Promise<void>;
97
+ };
98
+ export declare function executeExperimentCreate(input: ExecuteExperimentCreateInput, dependencyOverrides?: Partial<ExecuteExperimentCreateDependencies>): Promise<ExperimentCreateSuccess>;
99
+ export declare function formatExperimentCreateSuccess(result: ExperimentCreateSuccess, json: boolean): string;
100
+ export {};