@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 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,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, readFileSync, realpathSync } from 'node:fs';
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(readCliVersion())
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();