@funnelsgrove/cli 0.1.24 → 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/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
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();
|