@funnelsgrove/cli 0.1.20 → 0.1.26
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 +16 -0
- package/dist/analyticsOutput.d.ts +2 -1
- package/dist/analyticsOutput.js +35 -10
- package/dist/apiClient.d.ts +9 -1
- package/dist/apiClient.js +16 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +40 -15
- package/dist/cliIdentity.d.ts +13 -0
- package/dist/cliIdentity.js +43 -0
- package/dist/contractCompatibility.d.ts +30 -0
- package/dist/contractCompatibility.js +250 -0
- package/dist/experimentCreate.d.ts +100 -0
- package/dist/experimentCreate.js +887 -0
- package/dist/localSync.d.ts +8 -0
- package/dist/localSync.js +71 -11
- package/funnel-contract-compatibility.json +1036 -0
- package/package.json +2 -1
- package/template_docs/.funnelsgrove-docs.json +4 -4
- package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_docs/docs/funnelsgrove/recipes/add-experiment.md +156 -7
- package/template_docs/funnel-docs.config.json +1 -1
- package/template_scaffold/.funnelsgrove-docs.json +4 -4
- package/template_scaffold/.funnelsgrove-scaffold.json +9 -9
- package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_scaffold/docs/funnelsgrove/recipes/add-experiment.md +156 -7
- package/template_scaffold/funnel-agent-docs.test.ts +32 -3
- package/template_scaffold/funnel-docs.config.json +1 -1
- package/template_scaffold/package-lock.json +4 -4
- package/template_scaffold/package.json +1 -1
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;
|
package/dist/analyticsOutput.js
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
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
|
|
251
|
-
const
|
|
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
|
|
280
|
+
row.pLtv === null || pLtvCurrency === null
|
|
281
|
+
? ''
|
|
282
|
+
: formatCurrency(row.pLtv, pLtvCurrency),
|
|
283
|
+
pLtvCurrency || '',
|
|
261
284
|
row.subscribers > 0 ? formatCurrency(cpa) : '',
|
|
262
|
-
|
|
263
|
-
|
|
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),
|
package/dist/apiClient.d.ts
CHANGED
|
@@ -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 ["
|
|
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,7 @@
|
|
|
1
1
|
import { CURRENT_STEP_CONTRACT_VERSION, FUNNEL_CONTRACT_DIAGNOSTIC_CODES, } from '@funnelsgrove/runtime';
|
|
2
|
+
import { checkContractCompatibility } from './contractCompatibility.js';
|
|
3
|
+
import { cliReleaseIdentity } from './cliIdentity.js';
|
|
2
4
|
export const FUNNEL_CONTRACT_API_INGRESSES = Object.freeze([
|
|
3
|
-
'agent-transaction',
|
|
4
5
|
'cli-sync',
|
|
5
6
|
'github-sync',
|
|
6
7
|
'builder-authoring',
|
|
@@ -236,6 +237,14 @@ const buildHeaders = (input) => {
|
|
|
236
237
|
};
|
|
237
238
|
export async function callTrpcProcedure(input) {
|
|
238
239
|
const fetchFn = input.fetchFn || fetch;
|
|
240
|
+
await checkContractCompatibility({
|
|
241
|
+
apiUrl: input.apiUrl,
|
|
242
|
+
cliVersion: cliReleaseIdentity.cliVersion,
|
|
243
|
+
docsIdentity: cliReleaseIdentity.docsIdentity,
|
|
244
|
+
fetchImpl: fetchFn,
|
|
245
|
+
timeoutMs: 3_000,
|
|
246
|
+
warn: (message) => console.warn(message),
|
|
247
|
+
});
|
|
239
248
|
const baseUrl = `${trimTrailingSlash(input.apiUrl)}/${input.path}`;
|
|
240
249
|
const init = input.type === 'query'
|
|
241
250
|
? {
|
|
@@ -276,3 +285,9 @@ export const beginFunnelSourceCandidate = (context, input) => callFunnelSourceCa
|
|
|
276
285
|
export const stageFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.stageSourceCandidate', input);
|
|
277
286
|
export const finalizeFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.finalizeSourceCandidate', input);
|
|
278
287
|
export const abortFunnelSourceCandidate = (context, input) => callFunnelSourceCandidateMutation(context, 'funnels.abortSourceCandidate', input);
|
|
288
|
+
export const createFunnelExperimentFromSpec = (context, input) => callTrpcProcedure({
|
|
289
|
+
...context,
|
|
290
|
+
path: 'funnelExperiments.createFromSpec',
|
|
291
|
+
type: 'mutation',
|
|
292
|
+
input,
|
|
293
|
+
});
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { constants as fsConstants,
|
|
3
|
+
import { constants as fsConstants, realpathSync } from 'node:fs';
|
|
4
4
|
import { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rmdir, rm, unlink, writeFile, } from 'node:fs/promises';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -8,9 +8,11 @@ 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
|
+
import { cliReleaseIdentity } from './cliIdentity.js';
|
|
12
13
|
import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
|
|
13
14
|
import { buildSyncManifest, buildCommittedSyncManifest, buildSourceCandidateOperations, collectChangedSourceFiles, collectSourceSnapshot, ensureGitignore, formatSyncUploadSummary, hasLocalSourceChanges, readSyncManifest, runSyncDownLocalLifecycle, runSourceCandidateSync, writeLocalEnvFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
|
|
15
|
+
import { executeExperimentCreate, formatExperimentCreateSuccess, recoverExperimentCreateTransaction, } from './experimentCreate.js';
|
|
14
16
|
import { pullEnvFile } from './envSync.js';
|
|
15
17
|
import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, } from './githubOutput.js';
|
|
16
18
|
import { syncGitHubDraftIfConnected } from './githubSyncFlow.js';
|
|
@@ -23,18 +25,6 @@ import { validateFunnel } from './funnelValidation.js';
|
|
|
23
25
|
import { StepContractMigrationError, applyStepContractV2Migration, checkStepContractV2Migration, recoverStepContractV2Migration, resolveStepContractMigrationMode, stepContractMigrationReportPath, } from './stepContractMigration.js';
|
|
24
26
|
import { assertHasCohortData, assertHasConversionData, assertHasFunnelPathData, assertHasTransitionData, buildCohortReportPayload, buildConversionReportPayload, buildFunnelPathReportPayload, buildTransitionReportPayload, formatAnalyticsJson, formatCohortTable, formatConversionsTable, formatFunnelPathTable, formatTransitionsTable, normalizeAnalyticsOutputFormat, parseAnalyticsDate, } from './analyticsOutput.js';
|
|
25
27
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
-
const readCliVersion = () => {
|
|
27
|
-
try {
|
|
28
|
-
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
29
|
-
if (typeof packageJson.version === 'string' && packageJson.version.trim()) {
|
|
30
|
-
return packageJson.version;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
// Fall through to the packaged fallback below.
|
|
35
|
-
}
|
|
36
|
-
return '0.1.3';
|
|
37
|
-
};
|
|
38
28
|
const toKebabCase = (value) => value
|
|
39
29
|
.trim()
|
|
40
30
|
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
@@ -1059,6 +1049,13 @@ const GENERATED_CONFIG_PATH_BY_KIND = {
|
|
|
1059
1049
|
experiments: 'src/config/experiments.generated.ts',
|
|
1060
1050
|
};
|
|
1061
1051
|
export const SYNC_DOWN_GENERATED_CONFIG_KINDS = ['offerSets', 'experiments'];
|
|
1052
|
+
export const EXPERIMENT_TRANSACTION_RECOVERY_COMMANDS = [
|
|
1053
|
+
'sync down',
|
|
1054
|
+
'sync up',
|
|
1055
|
+
'experiments sync',
|
|
1056
|
+
'offer-sets sync',
|
|
1057
|
+
];
|
|
1058
|
+
const experimentTransactionRecoveryCommands = new Set(EXPERIMENT_TRANSACTION_RECOVERY_COMMANDS);
|
|
1062
1059
|
export function resolveSyncTargetIds(input) {
|
|
1063
1060
|
const workspaceId = input.explicitWorkspaceId ||
|
|
1064
1061
|
input.manifest?.workspaceId ||
|
|
@@ -1432,10 +1429,17 @@ const syncGeneratedConfigFilesForCli = async (input) => {
|
|
|
1432
1429
|
};
|
|
1433
1430
|
const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
|
|
1434
1431
|
export const program = new Command();
|
|
1432
|
+
program.hook('preAction', async (_command, actionCommand) => {
|
|
1433
|
+
const commandPath = `${actionCommand.parent?.name() || ''} ${actionCommand.name()}`.trim();
|
|
1434
|
+
if (!experimentTransactionRecoveryCommands.has(commandPath))
|
|
1435
|
+
return;
|
|
1436
|
+
const options = actionCommand.opts();
|
|
1437
|
+
await recoverExperimentCreateTransaction(path.resolve(process.cwd(), options.dir || '.'));
|
|
1438
|
+
});
|
|
1435
1439
|
program
|
|
1436
1440
|
.name('fgrove')
|
|
1437
1441
|
.description('FunnelsGrove CLI for editing, syncing, and publishing funnels')
|
|
1438
|
-
.version(
|
|
1442
|
+
.version(cliReleaseIdentity.cliVersion)
|
|
1439
1443
|
.option('--api-url <url>', 'FunnelsGrove tRPC API URL', process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL)
|
|
1440
1444
|
.option('--config <path>', 'Auth config path', process.env.FUNNELSGROVE_CONFIG || getDefaultAuthConfigPath());
|
|
1441
1445
|
addExamples(program, [
|
|
@@ -1654,9 +1658,30 @@ addExamples(offerSetsCommand
|
|
|
1654
1658
|
console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
|
|
1655
1659
|
});
|
|
1656
1660
|
const experimentsCommand = addExamples(program.command('experiments').description('Manage funnel experiments'), [
|
|
1661
|
+
'fgrove experiments create --spec experiment.json --dir .',
|
|
1657
1662
|
'fgrove experiments sync',
|
|
1658
1663
|
'fgrove experiments sync --funnel claimbee-ios',
|
|
1659
1664
|
]);
|
|
1665
|
+
addExamples(experimentsCommand
|
|
1666
|
+
.command('create')
|
|
1667
|
+
.description('Create a draft experiment from a JSON spec')
|
|
1668
|
+
.requiredOption('--spec <path>', 'Experiment JSON spec')
|
|
1669
|
+
.option('--dir <path>', 'Synced local funnel directory', '.')
|
|
1670
|
+
.option('--json', 'Emit one machine-readable success object'), [
|
|
1671
|
+
'fgrove experiments create --spec experiment.json --dir .',
|
|
1672
|
+
'fgrove experiments create --spec experiment.json --dir ./claimbee-ios --json',
|
|
1673
|
+
])
|
|
1674
|
+
.action(async (options) => {
|
|
1675
|
+
const result = await executeExperimentCreate({
|
|
1676
|
+
sourceDir: path.resolve(process.cwd(), options.dir),
|
|
1677
|
+
specPath: path.resolve(process.cwd(), options.spec),
|
|
1678
|
+
createFromSpec: async (input) => createFunnelExperimentFromSpec({
|
|
1679
|
+
apiUrl: getApiUrl(),
|
|
1680
|
+
token: await readAuthToken(),
|
|
1681
|
+
}, input),
|
|
1682
|
+
});
|
|
1683
|
+
process.stdout.write(formatExperimentCreateSuccess(result, options.json === true));
|
|
1684
|
+
});
|
|
1660
1685
|
addExamples(experimentsCommand
|
|
1661
1686
|
.command('sync')
|
|
1662
1687
|
.description('Write generated experiment config for a funnel')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DocsIdentity } from './contractCompatibility.js';
|
|
2
|
+
export type CliReleaseIdentity = Readonly<{
|
|
3
|
+
cliVersion: string;
|
|
4
|
+
docsIdentity: DocsIdentity;
|
|
5
|
+
}>;
|
|
6
|
+
type ReadTextFile = (path: URL, encoding: 'utf8') => string;
|
|
7
|
+
export declare const parseCliReleaseIdentity: (packageJson: unknown, docsManifest: unknown) => CliReleaseIdentity;
|
|
8
|
+
export declare const loadCliReleaseIdentity: (readTextFile?: ReadTextFile) => CliReleaseIdentity;
|
|
9
|
+
export declare const cliReleaseIdentity: Readonly<{
|
|
10
|
+
cliVersion: string;
|
|
11
|
+
docsIdentity: DocsIdentity;
|
|
12
|
+
}>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isStrictSemVer } from './contractCompatibility.js';
|
|
3
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
4
|
+
const isRecord = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value));
|
|
5
|
+
const startupError = (reason) => new Error(`FG-CLI-IDENTITY-001: invalid packaged CLI release identity: ${reason}`);
|
|
6
|
+
export const parseCliReleaseIdentity = (packageJson, docsManifest) => {
|
|
7
|
+
if (!isRecord(packageJson) || !isStrictSemVer(packageJson.version)) {
|
|
8
|
+
throw startupError('package.json version must be an exact semantic version');
|
|
9
|
+
}
|
|
10
|
+
if (!isRecord(docsManifest) || !isStrictSemVer(docsManifest.bundleVersion)) {
|
|
11
|
+
throw startupError('docs bundleVersion must be an exact semantic version');
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isSafeInteger(docsManifest.stepContractVersion)
|
|
14
|
+
|| docsManifest.stepContractVersion <= 0) {
|
|
15
|
+
throw startupError('stepContractVersion must be a positive integer');
|
|
16
|
+
}
|
|
17
|
+
if (typeof docsManifest.contractHash !== 'string' || !SHA256.test(docsManifest.contractHash)) {
|
|
18
|
+
throw startupError('contractHash must be a lowercase SHA-256 digest');
|
|
19
|
+
}
|
|
20
|
+
return Object.freeze({
|
|
21
|
+
cliVersion: packageJson.version,
|
|
22
|
+
docsIdentity: Object.freeze({
|
|
23
|
+
docsBundleVersion: docsManifest.bundleVersion,
|
|
24
|
+
stepContractVersion: docsManifest.stepContractVersion,
|
|
25
|
+
contractHash: docsManifest.contractHash,
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
export const loadCliReleaseIdentity = (readTextFile = readFileSync) => {
|
|
30
|
+
try {
|
|
31
|
+
const packageJson = JSON.parse(readTextFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
32
|
+
const docsManifest = JSON.parse(readTextFile(new URL('../template_docs/.funnelsgrove-docs.json', import.meta.url), 'utf8'));
|
|
33
|
+
return parseCliReleaseIdentity(packageJson, docsManifest);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof Error && error.message.startsWith('FG-CLI-IDENTITY-001:')) {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
40
|
+
throw startupError(`could not read packaged CLI release identity (${reason})`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
export const cliReleaseIdentity = loadCliReleaseIdentity();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type DocsIdentity = Readonly<{
|
|
2
|
+
docsBundleVersion: string;
|
|
3
|
+
stepContractVersion: number;
|
|
4
|
+
contractHash: string;
|
|
5
|
+
}>;
|
|
6
|
+
export type ContractCapabilities = Readonly<DocsIdentity & {
|
|
7
|
+
preferredCliVersion: string;
|
|
8
|
+
minimumCliVersion: string;
|
|
9
|
+
acceptedDocsBundles: readonly DocsIdentity[];
|
|
10
|
+
}>;
|
|
11
|
+
export type ContractCompatibilityInput = {
|
|
12
|
+
apiUrl: string;
|
|
13
|
+
cliVersion: string;
|
|
14
|
+
docsIdentity: DocsIdentity;
|
|
15
|
+
fetchImpl: typeof fetch;
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
warn: (message: string) => void;
|
|
18
|
+
};
|
|
19
|
+
export declare const CONTRACT_COMPATIBILITY_ERROR_CODES: readonly ["FG-CLI-COMPAT-001", "FG-CLI-COMPAT-002", "FG-CLI-COMPAT-003"];
|
|
20
|
+
export type ContractCompatibilityErrorCode = (typeof CONTRACT_COMPATIBILITY_ERROR_CODES)[number];
|
|
21
|
+
export declare class ContractCompatibilityError extends Error {
|
|
22
|
+
readonly code: ContractCompatibilityErrorCode;
|
|
23
|
+
constructor(code: ContractCompatibilityErrorCode, message: string);
|
|
24
|
+
}
|
|
25
|
+
export declare const isStrictSemVer: (value: unknown) => value is string;
|
|
26
|
+
export declare const compareSemVer: (left: string, right: string) => -1 | 0 | 1 | null;
|
|
27
|
+
export declare const parseContractCapabilities: (value: unknown) => ContractCapabilities | null;
|
|
28
|
+
export declare const deriveContractHealthUrl: (apiUrl: string) => string | null;
|
|
29
|
+
export declare const createContractCompatibilityChecker: () => (input: ContractCompatibilityInput) => Promise<void>;
|
|
30
|
+
export declare const checkContractCompatibility: (input: ContractCompatibilityInput) => Promise<void>;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
export const CONTRACT_COMPATIBILITY_ERROR_CODES = Object.freeze([
|
|
2
|
+
'FG-CLI-COMPAT-001',
|
|
3
|
+
'FG-CLI-COMPAT-002',
|
|
4
|
+
'FG-CLI-COMPAT-003',
|
|
5
|
+
]);
|
|
6
|
+
export class ContractCompatibilityError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(`${code}: ${message}`);
|
|
10
|
+
this.name = 'ContractCompatibilityError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
15
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
16
|
+
const UPDATE_COMMAND = 'npm install -g @funnelsgrove/cli@latest';
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 3_000;
|
|
18
|
+
const MAX_TIMEOUT_MS = 10_000;
|
|
19
|
+
const MAX_HEALTH_RESPONSE_BYTES = 64 * 1_024;
|
|
20
|
+
const MAX_ACCEPTED_DOCS_BUNDLES = 7;
|
|
21
|
+
const isRecord = (value) => (typeof value === 'object'
|
|
22
|
+
&& value !== null
|
|
23
|
+
&& !Array.isArray(value)
|
|
24
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null));
|
|
25
|
+
const parseSemVer = (value) => {
|
|
26
|
+
if (typeof value !== 'string')
|
|
27
|
+
return null;
|
|
28
|
+
const match = SEMVER.exec(value);
|
|
29
|
+
if (!match)
|
|
30
|
+
return null;
|
|
31
|
+
const prerelease = match[4]?.split('.') ?? null;
|
|
32
|
+
if (prerelease?.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
source: value,
|
|
37
|
+
core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
|
|
38
|
+
prerelease,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
export const isStrictSemVer = (value) => parseSemVer(value) !== null;
|
|
42
|
+
const compareBigInt = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
43
|
+
const compareParsedSemVer = (left, right) => {
|
|
44
|
+
for (let index = 0; index < left.core.length; index += 1) {
|
|
45
|
+
const result = compareBigInt(left.core[index], right.core[index]);
|
|
46
|
+
if (result !== 0)
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
if (left.prerelease === null && right.prerelease === null)
|
|
50
|
+
return 0;
|
|
51
|
+
if (left.prerelease === null)
|
|
52
|
+
return 1;
|
|
53
|
+
if (right.prerelease === null)
|
|
54
|
+
return -1;
|
|
55
|
+
const count = Math.max(left.prerelease.length, right.prerelease.length);
|
|
56
|
+
for (let index = 0; index < count; index += 1) {
|
|
57
|
+
const leftIdentifier = left.prerelease[index];
|
|
58
|
+
const rightIdentifier = right.prerelease[index];
|
|
59
|
+
if (leftIdentifier === undefined)
|
|
60
|
+
return -1;
|
|
61
|
+
if (rightIdentifier === undefined)
|
|
62
|
+
return 1;
|
|
63
|
+
if (leftIdentifier === rightIdentifier)
|
|
64
|
+
continue;
|
|
65
|
+
const leftNumeric = /^\d+$/.test(leftIdentifier);
|
|
66
|
+
const rightNumeric = /^\d+$/.test(rightIdentifier);
|
|
67
|
+
if (leftNumeric && rightNumeric) {
|
|
68
|
+
return compareBigInt(BigInt(leftIdentifier), BigInt(rightIdentifier));
|
|
69
|
+
}
|
|
70
|
+
if (leftNumeric)
|
|
71
|
+
return -1;
|
|
72
|
+
if (rightNumeric)
|
|
73
|
+
return 1;
|
|
74
|
+
return leftIdentifier < rightIdentifier ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
};
|
|
78
|
+
export const compareSemVer = (left, right) => {
|
|
79
|
+
const parsedLeft = parseSemVer(left);
|
|
80
|
+
const parsedRight = parseSemVer(right);
|
|
81
|
+
return parsedLeft && parsedRight ? compareParsedSemVer(parsedLeft, parsedRight) : null;
|
|
82
|
+
};
|
|
83
|
+
const parseDocsIdentity = (value) => {
|
|
84
|
+
if (!isRecord(value))
|
|
85
|
+
return null;
|
|
86
|
+
const docsBundleVersion = value.docsBundleVersion;
|
|
87
|
+
const stepContractVersion = value.stepContractVersion;
|
|
88
|
+
const contractHash = value.contractHash;
|
|
89
|
+
if (!isStrictSemVer(docsBundleVersion)
|
|
90
|
+
|| !Number.isSafeInteger(stepContractVersion)
|
|
91
|
+
|| stepContractVersion <= 0
|
|
92
|
+
|| typeof contractHash !== 'string'
|
|
93
|
+
|| !SHA256.test(contractHash))
|
|
94
|
+
return null;
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
docsBundleVersion,
|
|
97
|
+
stepContractVersion: stepContractVersion,
|
|
98
|
+
contractHash,
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const sameDocsIdentity = (left, right) => (left.docsBundleVersion === right.docsBundleVersion
|
|
102
|
+
&& left.stepContractVersion === right.stepContractVersion
|
|
103
|
+
&& left.contractHash === right.contractHash);
|
|
104
|
+
export const parseContractCapabilities = (value) => {
|
|
105
|
+
if (!isRecord(value) || !isRecord(value.funnelContract))
|
|
106
|
+
return null;
|
|
107
|
+
const funnelContract = value.funnelContract;
|
|
108
|
+
const preferredIdentity = parseDocsIdentity(funnelContract);
|
|
109
|
+
const preferredCliVersion = funnelContract.preferredCliVersion;
|
|
110
|
+
const minimumCliVersion = funnelContract.minimumCliVersion;
|
|
111
|
+
const acceptedValue = funnelContract.acceptedDocsBundles;
|
|
112
|
+
if (preferredIdentity === null
|
|
113
|
+
|| !isStrictSemVer(preferredCliVersion)
|
|
114
|
+
|| !isStrictSemVer(minimumCliVersion)
|
|
115
|
+
|| !Array.isArray(acceptedValue)
|
|
116
|
+
|| acceptedValue.length === 0
|
|
117
|
+
|| acceptedValue.length > MAX_ACCEPTED_DOCS_BUNDLES
|
|
118
|
+
|| compareSemVer(minimumCliVersion, preferredCliVersion) > 0)
|
|
119
|
+
return null;
|
|
120
|
+
const acceptedDocsBundles = acceptedValue.map(parseDocsIdentity);
|
|
121
|
+
if (acceptedDocsBundles.some((identity) => identity === null)
|
|
122
|
+
|| !acceptedDocsBundles.some((identity) => (identity !== null && sameDocsIdentity(identity, preferredIdentity))))
|
|
123
|
+
return null;
|
|
124
|
+
return Object.freeze({
|
|
125
|
+
...preferredIdentity,
|
|
126
|
+
preferredCliVersion,
|
|
127
|
+
minimumCliVersion,
|
|
128
|
+
acceptedDocsBundles: Object.freeze(acceptedDocsBundles),
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
export const deriveContractHealthUrl = (apiUrl) => {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = new URL(apiUrl);
|
|
134
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
135
|
+
return null;
|
|
136
|
+
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
|
137
|
+
parsed.pathname = pathname === '/trpc' || pathname.endsWith('/trpc')
|
|
138
|
+
? `${pathname.slice(0, -'/trpc'.length)}/health`
|
|
139
|
+
: '/health';
|
|
140
|
+
parsed.search = '';
|
|
141
|
+
parsed.hash = '';
|
|
142
|
+
return parsed.toString();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const boundedTimeout = (timeoutMs) => (Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
149
|
+
? Math.min(Math.ceil(timeoutMs), MAX_TIMEOUT_MS)
|
|
150
|
+
: DEFAULT_TIMEOUT_MS);
|
|
151
|
+
const readBoundedJson = async (response) => {
|
|
152
|
+
if (response.body === null)
|
|
153
|
+
throw new Error('health response body is missing');
|
|
154
|
+
const reader = response.body.getReader();
|
|
155
|
+
const decoder = new TextDecoder();
|
|
156
|
+
let byteCount = 0;
|
|
157
|
+
let text = '';
|
|
158
|
+
try {
|
|
159
|
+
while (true) {
|
|
160
|
+
const { done, value } = await reader.read();
|
|
161
|
+
if (done)
|
|
162
|
+
break;
|
|
163
|
+
byteCount += value.byteLength;
|
|
164
|
+
if (byteCount > MAX_HEALTH_RESPONSE_BYTES) {
|
|
165
|
+
void reader.cancel('health response exceeds the protocol byte limit').catch(() => undefined);
|
|
166
|
+
throw new Error('health response exceeds the protocol byte limit');
|
|
167
|
+
}
|
|
168
|
+
text += decoder.decode(value, { stream: true });
|
|
169
|
+
}
|
|
170
|
+
text += decoder.decode();
|
|
171
|
+
return JSON.parse(text);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
reader.releaseLock();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const fetchCapabilities = async (healthUrl, fetchImpl, timeoutMs) => {
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
let timeout;
|
|
180
|
+
const timeoutResult = new Promise((_resolve, reject) => {
|
|
181
|
+
timeout = setTimeout(() => {
|
|
182
|
+
controller.abort();
|
|
183
|
+
reject(new Error('contract capability lookup timed out'));
|
|
184
|
+
}, boundedTimeout(timeoutMs));
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
const lookup = (async () => {
|
|
188
|
+
const response = await fetchImpl(healthUrl, {
|
|
189
|
+
headers: { accept: 'application/json' },
|
|
190
|
+
signal: controller.signal,
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
return null;
|
|
194
|
+
return parseContractCapabilities(await readBoundedJson(response));
|
|
195
|
+
})();
|
|
196
|
+
return await Promise.race([lookup, timeoutResult]);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
if (timeout !== undefined)
|
|
203
|
+
clearTimeout(timeout);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
export const createContractCompatibilityChecker = () => {
|
|
207
|
+
const lookups = new Map();
|
|
208
|
+
const warned = new Set();
|
|
209
|
+
const warnOnce = (key, warn, message) => {
|
|
210
|
+
if (warned.has(key))
|
|
211
|
+
return;
|
|
212
|
+
warned.add(key);
|
|
213
|
+
warn(message);
|
|
214
|
+
};
|
|
215
|
+
return async (input) => {
|
|
216
|
+
const healthUrl = deriveContractHealthUrl(input.apiUrl);
|
|
217
|
+
const cacheKey = healthUrl ?? `invalid:${input.apiUrl}`;
|
|
218
|
+
if (healthUrl === null) {
|
|
219
|
+
warnOnce(cacheKey, input.warn, 'FunnelsGrove contract compatibility check could not derive the API health URL; continuing with server-side validation.');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
let lookup = lookups.get(healthUrl);
|
|
223
|
+
if (!lookup) {
|
|
224
|
+
lookup = fetchCapabilities(healthUrl, input.fetchImpl, input.timeoutMs);
|
|
225
|
+
lookups.set(healthUrl, lookup);
|
|
226
|
+
}
|
|
227
|
+
const capabilities = await lookup;
|
|
228
|
+
if (capabilities === null) {
|
|
229
|
+
warnOnce(cacheKey, input.warn, 'FunnelsGrove contract compatibility capabilities are unavailable; continuing with server-side validation.');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!capabilities.acceptedDocsBundles.some((identity) => sameDocsIdentity(identity, input.docsIdentity))) {
|
|
233
|
+
throw new ContractCompatibilityError('FG-CLI-COMPAT-001', `docs bundle ${input.docsIdentity.docsBundleVersion} is not accepted by this API. Update the CLI with: ${UPDATE_COMMAND}`);
|
|
234
|
+
}
|
|
235
|
+
const minimumComparison = compareSemVer(input.cliVersion, capabilities.minimumCliVersion);
|
|
236
|
+
if (minimumComparison === null || minimumComparison < 0) {
|
|
237
|
+
throw new ContractCompatibilityError('FG-CLI-COMPAT-002', `CLI ${input.cliVersion} is below the API minimum ${capabilities.minimumCliVersion}. Update with: ${UPDATE_COMMAND}`);
|
|
238
|
+
}
|
|
239
|
+
const preferredComparison = compareSemVer(input.cliVersion, capabilities.preferredCliVersion);
|
|
240
|
+
if (preferredComparison === null || preferredComparison > 0) {
|
|
241
|
+
throw new ContractCompatibilityError('FG-CLI-COMPAT-003', `CLI ${input.cliVersion} is newer than the server preferred CLI ${capabilities.preferredCliVersion}; API update needed.`);
|
|
242
|
+
}
|
|
243
|
+
const isPreferredDocs = sameDocsIdentity(input.docsIdentity, capabilities);
|
|
244
|
+
const isExactPreferredCli = input.cliVersion === capabilities.preferredCliVersion;
|
|
245
|
+
if (!isPreferredDocs || !isExactPreferredCli) {
|
|
246
|
+
warnOnce(cacheKey, input.warn, `This CLI/docs release is accepted but not preferred by the API. Update when convenient with: ${UPDATE_COMMAND}`);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
export const checkContractCompatibility = createContractCompatibilityChecker();
|