@funnelsgrove/cli 0.1.24 → 0.1.27
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/analyticsOutput.d.ts +11 -0
- package/dist/analyticsOutput.js +63 -16
- package/dist/apiClient.js +10 -0
- package/dist/cli.js +3 -14
- 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/funnel-contract-compatibility.json +1036 -0
- package/package.json +2 -1
- package/template_docs/.funnelsgrove-docs.json +3 -3
- package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_docs/funnel-docs.config.json +1 -1
- package/template_scaffold/.funnelsgrove-docs.json +3 -3
- package/template_scaffold/.funnelsgrove-scaffold.json +6 -6
- package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_scaffold/funnel-agent-docs.test.ts +6 -3
- package/template_scaffold/funnel-docs.config.json +1 -1
|
@@ -101,6 +101,7 @@ export type MarketingCohortPerformanceRow = {
|
|
|
101
101
|
funnelUrl: string;
|
|
102
102
|
mediaSource: string;
|
|
103
103
|
spend: number;
|
|
104
|
+
spendCurrency: string | null;
|
|
104
105
|
subscribers: number;
|
|
105
106
|
subscribersAlive: number;
|
|
106
107
|
subscribersAlivePercent: number | null;
|
|
@@ -108,6 +109,16 @@ export type MarketingCohortPerformanceRow = {
|
|
|
108
109
|
revenueDay3: number;
|
|
109
110
|
revenueDay90: number;
|
|
110
111
|
revenueDay180: number;
|
|
112
|
+
revenueCurrency: string | null;
|
|
113
|
+
revenueCurrencySafe: boolean;
|
|
114
|
+
predictedNetRevenueDay3Minor: number | null;
|
|
115
|
+
predictedNetRevenueDay3Currency: string | null;
|
|
116
|
+
predictedNetRevenueDay90Minor: number | null;
|
|
117
|
+
predictedNetRevenueDay90Currency: string | null;
|
|
118
|
+
predictedNetRevenueDay180Minor: number | null;
|
|
119
|
+
predictedNetRevenueDay180Currency: string | null;
|
|
120
|
+
predictedNetRevenueDay365Minor: number | null;
|
|
121
|
+
predictedNetRevenueDay365Currency: string | null;
|
|
111
122
|
pLtvCurrency: string | null;
|
|
112
123
|
pLtv: number | null;
|
|
113
124
|
pLtvPredictedNetRevenueDay365Minor: number | null;
|
package/dist/analyticsOutput.js
CHANGED
|
@@ -226,9 +226,16 @@ export const formatConversionsTable = (input) => {
|
|
|
226
226
|
};
|
|
227
227
|
const flattenCohortRows = (rows) => rows.flatMap((row) => [row, ...(row.segments || []), ...(row.children || [])]);
|
|
228
228
|
const normalizeCurrency = (value) => {
|
|
229
|
-
|
|
229
|
+
if (typeof value !== 'string') {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const currency = value.trim().toUpperCase();
|
|
230
233
|
return /^[A-Z]{3}$/.test(currency) ? currency : null;
|
|
231
234
|
};
|
|
235
|
+
const sameCurrency = (left, right) => {
|
|
236
|
+
const normalizedLeft = normalizeCurrency(left);
|
|
237
|
+
return normalizedLeft !== null && normalizedLeft === normalizeCurrency(right);
|
|
238
|
+
};
|
|
232
239
|
const predictedAverageLtv = (row) => {
|
|
233
240
|
if (!normalizeCurrency(row.pLtvCurrency)) {
|
|
234
241
|
return null;
|
|
@@ -245,19 +252,49 @@ const predictedAverageLtv = (row) => {
|
|
|
245
252
|
? row.pLtv
|
|
246
253
|
: null;
|
|
247
254
|
};
|
|
248
|
-
const ratioToSpend = (value, spend) => (Number.isFinite(value) && Number.isFinite(spend) && spend
|
|
255
|
+
const ratioToSpend = (value, spend) => (Number.isFinite(value) && Number.isFinite(spend) && spend > 0 ? value / spend : null);
|
|
249
256
|
const formatRoi = (profit, spend) => {
|
|
250
257
|
const ratio = ratioToSpend(profit, spend);
|
|
251
258
|
return ratio === null ? '' : `${(ratio * 100).toFixed(2)}%`;
|
|
252
259
|
};
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
260
|
+
const forecastDefinitions = {
|
|
261
|
+
day3: {
|
|
262
|
+
minor: 'predictedNetRevenueDay3Minor',
|
|
263
|
+
currency: 'predictedNetRevenueDay3Currency',
|
|
264
|
+
},
|
|
265
|
+
day90: {
|
|
266
|
+
minor: 'predictedNetRevenueDay90Minor',
|
|
267
|
+
currency: 'predictedNetRevenueDay90Currency',
|
|
268
|
+
},
|
|
269
|
+
day180: {
|
|
270
|
+
minor: 'predictedNetRevenueDay180Minor',
|
|
271
|
+
currency: 'predictedNetRevenueDay180Currency',
|
|
272
|
+
},
|
|
273
|
+
day365: {
|
|
274
|
+
minor: 'predictedNetRevenueDay365Minor',
|
|
275
|
+
currency: 'predictedNetRevenueDay365Currency',
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
const formatForecastRoas = (row, forecast) => {
|
|
279
|
+
const minor = row[forecast.minor];
|
|
280
|
+
if (typeof minor !== 'number'
|
|
281
|
+
|| !Number.isFinite(minor)
|
|
282
|
+
|| !Number.isFinite(row.spend)
|
|
283
|
+
|| row.spend <= 0) {
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
if (!normalizeCurrency(row.spendCurrency)) {
|
|
287
|
+
return '';
|
|
288
|
+
}
|
|
289
|
+
if (minor !== 0 && !sameCurrency(row[forecast.currency], row.spendCurrency)) {
|
|
290
|
+
return '';
|
|
291
|
+
}
|
|
292
|
+
return `${((minor / 100 / row.spend) * 100).toFixed(2)}%`;
|
|
256
293
|
};
|
|
257
294
|
export const formatCohortTable = (input) => {
|
|
258
295
|
const lines = [
|
|
259
296
|
`Cohort\t${input.date}`,
|
|
260
|
-
'cohort\tmediaSource\tfunnelUrl\tspend\tsubscribers\taliveRate\trevenue\tpLtv\tpLtvCurrency\tcpa\tpredictedProfit\tpROI\tROAS\tROAS d3\tROAS d90\tROAS d180',
|
|
297
|
+
'cohort\tmediaSource\tfunnelUrl\tspend\tsubscribers\taliveRate\trevenue\tpLtv\tpLtvCurrency\tcpa\tpRevenue\tpredictedProfit\tpROI\tROAS\tROAS d3\tROAS d90\tROAS d180',
|
|
261
298
|
];
|
|
262
299
|
for (const row of flattenCohortRows(input.cohort.rows)) {
|
|
263
300
|
const cpa = row.subscribers > 0 ? row.spend / row.subscribers : 0;
|
|
@@ -265,31 +302,41 @@ export const formatCohortTable = (input) => {
|
|
|
265
302
|
const predictedRevenue = averageLtv === null
|
|
266
303
|
? null
|
|
267
304
|
: averageLtv * row.subscribers;
|
|
268
|
-
const predictedProfit = predictedRevenue
|
|
269
|
-
|
|
270
|
-
|
|
305
|
+
const predictedProfit = predictedRevenue !== null
|
|
306
|
+
&& sameCurrency(row.pLtvCurrency, row.spendCurrency)
|
|
307
|
+
? predictedRevenue - row.spend
|
|
308
|
+
: null;
|
|
309
|
+
const spendCurrency = normalizeCurrency(row.spendCurrency);
|
|
310
|
+
const revenueCurrency = row.revenueCurrencySafe === true
|
|
311
|
+
? normalizeCurrency(row.revenueCurrency)
|
|
312
|
+
: null;
|
|
271
313
|
const pLtvCurrency = normalizeCurrency(row.pLtvCurrency);
|
|
272
314
|
lines.push([
|
|
273
315
|
row.cohortDate,
|
|
274
316
|
row.mediaSource,
|
|
275
317
|
row.funnelUrl,
|
|
276
|
-
formatCurrency(row.spend),
|
|
318
|
+
spendCurrency === null ? '' : formatCurrency(row.spend, spendCurrency),
|
|
277
319
|
formatNumber(row.subscribers),
|
|
278
320
|
formatPercentPoints(row.subscribersAlivePercent),
|
|
279
|
-
formatCurrency(row.revenue),
|
|
321
|
+
revenueCurrency === null ? '' : formatCurrency(row.revenue, revenueCurrency),
|
|
280
322
|
row.pLtv === null || pLtvCurrency === null
|
|
281
323
|
? ''
|
|
282
324
|
: formatCurrency(row.pLtv, pLtvCurrency),
|
|
283
325
|
pLtvCurrency || '',
|
|
284
|
-
row.subscribers > 0
|
|
326
|
+
row.subscribers > 0 && spendCurrency !== null
|
|
327
|
+
? formatCurrency(cpa, spendCurrency)
|
|
328
|
+
: '',
|
|
329
|
+
predictedRevenue === null || pLtvCurrency === null
|
|
330
|
+
? ''
|
|
331
|
+
: formatCurrency(predictedRevenue, pLtvCurrency),
|
|
285
332
|
predictedProfit === null
|
|
286
333
|
? ''
|
|
287
334
|
: formatCurrency(predictedProfit, pLtvCurrency || 'USD'),
|
|
288
335
|
predictedProfit === null ? '' : formatRoi(predictedProfit, row.spend),
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
336
|
+
formatForecastRoas(row, forecastDefinitions.day365),
|
|
337
|
+
formatForecastRoas(row, forecastDefinitions.day3),
|
|
338
|
+
formatForecastRoas(row, forecastDefinitions.day90),
|
|
339
|
+
formatForecastRoas(row, forecastDefinitions.day180),
|
|
293
340
|
].join('\t'));
|
|
294
341
|
}
|
|
295
342
|
return `${lines.join('\n')}\n`;
|
package/dist/apiClient.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
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
5
|
'cli-sync',
|
|
4
6
|
'github-sync',
|
|
@@ -235,6 +237,14 @@ const buildHeaders = (input) => {
|
|
|
235
237
|
};
|
|
236
238
|
export async function callTrpcProcedure(input) {
|
|
237
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
|
+
});
|
|
238
248
|
const baseUrl = `${trimTrailingSlash(input.apiUrl)}/${input.path}`;
|
|
239
249
|
const init = input.type === 'query'
|
|
240
250
|
? {
|
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';
|
|
@@ -9,6 +9,7 @@ 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
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';
|
|
14
15
|
import { executeExperimentCreate, formatExperimentCreateSuccess, recoverExperimentCreateTransaction, } from './experimentCreate.js';
|
|
@@ -24,18 +25,6 @@ import { validateFunnel } from './funnelValidation.js';
|
|
|
24
25
|
import { StepContractMigrationError, applyStepContractV2Migration, checkStepContractV2Migration, recoverStepContractV2Migration, resolveStepContractMigrationMode, stepContractMigrationReportPath, } from './stepContractMigration.js';
|
|
25
26
|
import { assertHasCohortData, assertHasConversionData, assertHasFunnelPathData, assertHasTransitionData, buildCohortReportPayload, buildConversionReportPayload, buildFunnelPathReportPayload, buildTransitionReportPayload, formatAnalyticsJson, formatCohortTable, formatConversionsTable, formatFunnelPathTable, formatTransitionsTable, normalizeAnalyticsOutputFormat, parseAnalyticsDate, } from './analyticsOutput.js';
|
|
26
27
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
-
const readCliVersion = () => {
|
|
28
|
-
try {
|
|
29
|
-
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
30
|
-
if (typeof packageJson.version === 'string' && packageJson.version.trim()) {
|
|
31
|
-
return packageJson.version;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
// Fall through to the packaged fallback below.
|
|
36
|
-
}
|
|
37
|
-
return '0.1.3';
|
|
38
|
-
};
|
|
39
28
|
const toKebabCase = (value) => value
|
|
40
29
|
.trim()
|
|
41
30
|
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
@@ -1450,7 +1439,7 @@ program.hook('preAction', async (_command, actionCommand) => {
|
|
|
1450
1439
|
program
|
|
1451
1440
|
.name('fgrove')
|
|
1452
1441
|
.description('FunnelsGrove CLI for editing, syncing, and publishing funnels')
|
|
1453
|
-
.version(
|
|
1442
|
+
.version(cliReleaseIdentity.cliVersion)
|
|
1454
1443
|
.option('--api-url <url>', 'FunnelsGrove tRPC API URL', process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL)
|
|
1455
1444
|
.option('--config <path>', 'Auth config path', process.env.FUNNELSGROVE_CONFIG || getDefaultAuthConfigPath());
|
|
1456
1445
|
addExamples(program, [
|
|
@@ -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();
|