@clue-ai/cli 0.0.15 → 0.0.17
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 +9 -0
- package/bin/clue-cli.mjs +11 -0
- package/package.json +1 -1
- package/src/contracts.mjs +1 -1
- package/src/lifecycle-init.mjs +17 -3
- package/src/path-policy.mjs +8 -1
- package/src/public-schema.cjs +81 -0
- package/src/semantic-ci.mjs +423 -33
- package/src/setup-ai-contract.mjs +91 -0
- package/src/setup-check.mjs +68 -1
- package/src/setup-doctor.mjs +435 -0
- package/src/setup-help.mjs +19 -1
- package/src/setup-prepare.mjs +8 -0
- package/src/setup-tool.mjs +19 -5
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export const AI_SETUP_CONTRACT_VERSION =
|
|
2
|
+
"2026-05-10.api-connectivity-contract.v6";
|
|
3
|
+
|
|
4
|
+
export const SETUP_DOCTRINE = {
|
|
5
|
+
purpose:
|
|
6
|
+
"Clue setup installs an external SDK integration so observed product facts can reach Clue's Customer Value Understanding Engine. It is not an opportunity to improve, refactor, redesign, or reinterpret the host application.",
|
|
7
|
+
minimal_diff_reason:
|
|
8
|
+
"The customer must be able to review and merge the setup diff with confidence. Extra formatting, refactors, auth rewrites, UI changes, or unrelated cleanup make the integration harder to trust.",
|
|
9
|
+
ai_decision_boundary:
|
|
10
|
+
"The AI should use repository understanding only to choose existing lifecycle boundaries for ClueInit, ClueIdentify, ClueSetAccount, and ClueLogout.",
|
|
11
|
+
deterministic_control_boundary:
|
|
12
|
+
"Everything that can be controlled mechanically should be controlled by the CLI, generated skills, documentation contract, lifecycle plan schema, and setup-check static guards.",
|
|
13
|
+
documentation_reason:
|
|
14
|
+
"SDK signatures, environment variable names, browser token behavior, and verification ownership are contracts. The AI must read the setup documents instead of relying on memory.",
|
|
15
|
+
failure_posture:
|
|
16
|
+
"When a lifecycle point, SDK package, signature, env name, or verification step is unclear, the correct output is a blocker or user_verification_pending, not a guessed implementation or a completion claim.",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const DETERMINISTIC_CONTROL_MODEL = {
|
|
20
|
+
ai_should_decide: [
|
|
21
|
+
"which existing bootstrap point owns ClueInit",
|
|
22
|
+
"which existing login/session success point owns ClueIdentify",
|
|
23
|
+
"which existing account/workspace/organization resolution point owns ClueSetAccount",
|
|
24
|
+
"which existing logout/session reset point owns ClueLogout",
|
|
25
|
+
"whether a lifecycle point is unclear and should be reported as blocked",
|
|
26
|
+
],
|
|
27
|
+
cli_should_control: [
|
|
28
|
+
"official SDK package names",
|
|
29
|
+
"official public SDK function names and supported lifecycle API set",
|
|
30
|
+
"environment variable names produced by setup and consumed by setup code",
|
|
31
|
+
"machine-owned semantic workflow generation and verification",
|
|
32
|
+
"setup-watch ownership as user-operated verification",
|
|
33
|
+
"static rejection of known unsafe wiring such as leaked secrets, wrong SDKs, blocking lifecycle calls, broad ClueTrack setup, and unsafe browser token proxy patterns",
|
|
34
|
+
"local API connectivity preflight for the four required setup hops before user-operated setup-watch",
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const API_CONNECTIVITY_CONTRACT = {
|
|
39
|
+
purpose:
|
|
40
|
+
"Clue setup has four distinct HTTP hops. The AI must not collapse customer-owned proxy routes and Clue-owned ingest routes into one vague browser-token endpoint.",
|
|
41
|
+
hops: {
|
|
42
|
+
client_backend_browser_token_proxy: {
|
|
43
|
+
owner: "customer_backend",
|
|
44
|
+
method: "POST",
|
|
45
|
+
path: "/api/v1/clue/browser-tokens",
|
|
46
|
+
caller: "customer_frontend_browserTokenProvider",
|
|
47
|
+
purpose:
|
|
48
|
+
"Issue a short-lived browser token without exposing CLUE_API_KEY to browser code.",
|
|
49
|
+
},
|
|
50
|
+
clue_backend_browser_token_issue: {
|
|
51
|
+
owner: "clue_backend",
|
|
52
|
+
method: "POST",
|
|
53
|
+
path: "/api/v1/ingest/browser-tokens",
|
|
54
|
+
caller: "customer_backend_browser_token_proxy",
|
|
55
|
+
purpose:
|
|
56
|
+
"Clue backend validates CLUE_API_KEY, project, environment, service key, and origin, then returns a browser token.",
|
|
57
|
+
},
|
|
58
|
+
browser_ingest: {
|
|
59
|
+
owner: "clue_backend",
|
|
60
|
+
method: "POST",
|
|
61
|
+
path: "/api/v1/ingest/browser",
|
|
62
|
+
caller: "customer_frontend_browser_sdk",
|
|
63
|
+
env_name: "NEXT_PUBLIC_CLUE_INGEST_ENDPOINT for Next.js browser code",
|
|
64
|
+
purpose:
|
|
65
|
+
"Browser SDK sends observation source events with x-clue-browser-token.",
|
|
66
|
+
},
|
|
67
|
+
backend_ingest: {
|
|
68
|
+
owner: "clue_backend",
|
|
69
|
+
method: "POST",
|
|
70
|
+
path: "/api/v1/ingest/backend",
|
|
71
|
+
caller: "customer_backend_sdk",
|
|
72
|
+
env_name: "CLUE_INGEST_ENDPOINT",
|
|
73
|
+
purpose:
|
|
74
|
+
"Backend SDK sends observation source events with server-side CLUE_API_KEY.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
preflight_command: "setup-doctor --local",
|
|
78
|
+
setup_watch_boundary:
|
|
79
|
+
"setup-doctor checks local API connectivity before user flows. setup-watch remains user-operated lifecycle and event-delivery verification.",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const setupDoctrineSkillLines = () => [
|
|
83
|
+
`- Purpose: ${SETUP_DOCTRINE.purpose}`,
|
|
84
|
+
`- Minimal diff reason: ${SETUP_DOCTRINE.minimal_diff_reason}`,
|
|
85
|
+
`- AI decision boundary: ${SETUP_DOCTRINE.ai_decision_boundary}`,
|
|
86
|
+
`- Static control boundary: ${SETUP_DOCTRINE.deterministic_control_boundary}`,
|
|
87
|
+
`- Documentation reason: ${SETUP_DOCTRINE.documentation_reason}`,
|
|
88
|
+
`- Failure posture: ${SETUP_DOCTRINE.failure_posture}`,
|
|
89
|
+
`- API connectivity: ${API_CONNECTIVITY_CONTRACT.purpose}`,
|
|
90
|
+
`- API preflight: run ${API_CONNECTIVITY_CONTRACT.preflight_command} when local services and required env are available; do not substitute it for user-operated setup-watch.`,
|
|
91
|
+
];
|
package/src/setup-check.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
stripSourceNoise,
|
|
13
13
|
} from "./lifecycle-guard.mjs";
|
|
14
14
|
import { listAllowedSourceFiles } from "./path-policy.mjs";
|
|
15
|
+
import { AI_SETUP_CONTRACT_VERSION } from "./setup-ai-contract.mjs";
|
|
15
16
|
import { runSemanticInventory } from "./semantic-ci.mjs";
|
|
16
17
|
|
|
17
18
|
const DEFAULT_WORKFLOW_PATH = ".github/workflows/clue-semantic-snapshot.yml";
|
|
@@ -27,7 +28,7 @@ const SETUP_SKILLS = [
|
|
|
27
28
|
"clue-local-verification",
|
|
28
29
|
"clue-setup-report",
|
|
29
30
|
];
|
|
30
|
-
const SETUP_SKILL_CONTENT_VERSION =
|
|
31
|
+
const SETUP_SKILL_CONTENT_VERSION = AI_SETUP_CONTRACT_VERSION;
|
|
31
32
|
const REQUIRED_SETUP_SKILL_PHRASES = {
|
|
32
33
|
"clue-sdk-instrumentation": [
|
|
33
34
|
"Do not create no-op wrappers",
|
|
@@ -42,6 +43,7 @@ const REQUIRED_SETUP_SKILL_PHRASES = {
|
|
|
42
43
|
"Whitespace-only changes are allowed only on lines directly changed for Clue SDK wiring",
|
|
43
44
|
"The local backend token endpoint is part of the customer app, not the Clue API",
|
|
44
45
|
"The frontend `browserTokenProvider` must send the same service key used by `ClueInit`",
|
|
46
|
+
"Do not forward `origin`, `projectKey`, or `environment` from JSON/body payload fields under server `CLUE_API_KEY`",
|
|
45
47
|
"For Django code, use `clue-django-sdk` only after package-manager or registry verification confirms it is installable",
|
|
46
48
|
],
|
|
47
49
|
"clue-setup-audit": [
|
|
@@ -49,12 +51,14 @@ const REQUIRED_SETUP_SKILL_PHRASES = {
|
|
|
49
51
|
"Reject Django SDK setup when `clue-django-sdk` installability has not been verified",
|
|
50
52
|
"Reject ClueTrack instrumentation unless the user explicitly requested product event tracking",
|
|
51
53
|
"Reject Next.js browser/client code that reads non-public `process.env.CLUE_*` variables",
|
|
54
|
+
"Reject browser token proxy code that forwards origin, projectKey, or environment from request JSON/body under server `CLUE_API_KEY`",
|
|
52
55
|
"Reject whitespace-only edits, import sorting, formatter churn",
|
|
53
56
|
"Reject unrelated refactors, renames, file moves",
|
|
54
57
|
"Execution agents must not approve, certify, or mark their own work complete",
|
|
55
58
|
],
|
|
56
59
|
"clue-local-verification": [
|
|
57
60
|
"`setup-check --require-sdk-lifecycle` is a static source check only",
|
|
61
|
+
"`setup-doctor --local` API connectivity preflight",
|
|
58
62
|
"Verify frontend SDK installability/import",
|
|
59
63
|
"Verify backend SDK installability/import",
|
|
60
64
|
"Do not run `npx -y @clue-ai/cli setup-watch --local` automatically",
|
|
@@ -806,6 +810,17 @@ const findNextBrowserTokenProviderMissingPublicServiceKeyFiles = ({
|
|
|
806
810
|
.map((source) => source.file_path);
|
|
807
811
|
};
|
|
808
812
|
|
|
813
|
+
const findBrowserTokenProviderWrongProxyPathFiles = (frontendSources) =>
|
|
814
|
+
frontendSources
|
|
815
|
+
.filter(sourceLooksLikeBrowserTokenProvider)
|
|
816
|
+
.filter((source) => {
|
|
817
|
+
const text = stripSourceNoise(source.text);
|
|
818
|
+
if (!/\bfetch\s*\(/.test(text)) return false;
|
|
819
|
+
if (/\/api\/v1\/clue\/browser-tokens\b/.test(text)) return false;
|
|
820
|
+
return /browser[-_]?tokens?|browser[-_]?token/i.test(text);
|
|
821
|
+
})
|
|
822
|
+
.map((source) => source.file_path);
|
|
823
|
+
|
|
809
824
|
const sourceLooksLikeBrowserTokenProxy = (source) => {
|
|
810
825
|
const text = stripSourceNoise(source.text);
|
|
811
826
|
return (
|
|
@@ -827,6 +842,43 @@ const findBrowserTokenProxyUsingBackendServiceKeyFiles = (backendSources) =>
|
|
|
827
842
|
})
|
|
828
843
|
.map((source) => source.file_path);
|
|
829
844
|
|
|
845
|
+
const bodyOriginPatterns = [
|
|
846
|
+
/\b(?:payload|body|requestBody|request_body|data|input)\.origin\b/i,
|
|
847
|
+
/\b(?:payload|body|requestBody|request_body|data|input)\s*\[\s*["']origin["']\s*\]/i,
|
|
848
|
+
/\b(?:payload|body|requestBody|request_body|data|input)\.get\s*\(\s*["']origin["']/i,
|
|
849
|
+
/["']origin["']\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
|
|
850
|
+
/\borigin\s*=\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
|
|
851
|
+
];
|
|
852
|
+
|
|
853
|
+
const bodyProjectEnvironmentPatterns = [
|
|
854
|
+
/\b(?:projectKey|project_key)\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
|
|
855
|
+
/["'](?:projectKey|project_key)["']\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
|
|
856
|
+
/\benvironment\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
|
|
857
|
+
/["']environment["']\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
|
|
858
|
+
];
|
|
859
|
+
|
|
860
|
+
const findBrowserTokenProxyTrustingBodyOriginFiles = (backendSources) =>
|
|
861
|
+
backendSources
|
|
862
|
+
.filter(sourceLooksLikeBrowserTokenProxy)
|
|
863
|
+
.filter((source) => {
|
|
864
|
+
const text = stripSourceNoise(source.text);
|
|
865
|
+
return bodyOriginPatterns.some((pattern) => pattern.test(text));
|
|
866
|
+
})
|
|
867
|
+
.map((source) => source.file_path);
|
|
868
|
+
|
|
869
|
+
const findBrowserTokenProxyTrustingBodyProjectEnvironmentFiles = (
|
|
870
|
+
backendSources,
|
|
871
|
+
) =>
|
|
872
|
+
backendSources
|
|
873
|
+
.filter(sourceLooksLikeBrowserTokenProxy)
|
|
874
|
+
.filter((source) => {
|
|
875
|
+
const text = stripSourceNoise(source.text);
|
|
876
|
+
return bodyProjectEnvironmentPatterns.some((pattern) =>
|
|
877
|
+
pattern.test(text),
|
|
878
|
+
);
|
|
879
|
+
})
|
|
880
|
+
.map((source) => source.file_path);
|
|
881
|
+
|
|
830
882
|
const backendSdkSpec = (framework) =>
|
|
831
883
|
BACKEND_SDK_BY_FRAMEWORK[String(framework ?? "").toLowerCase()] ?? {
|
|
832
884
|
packages: Object.values(BACKEND_SDK_BY_FRAMEWORK).flatMap(
|
|
@@ -947,6 +999,8 @@ const checkSdkLifecycle = ({
|
|
|
947
999
|
findWildcardFrontendSdkDependencyFiles(dependencySources);
|
|
948
1000
|
const browserTokenProviderMissingServiceKeyFiles =
|
|
949
1001
|
findBrowserTokenProviderMissingServiceKeyFiles(frontendSources);
|
|
1002
|
+
const browserTokenProviderWrongProxyPathFiles =
|
|
1003
|
+
findBrowserTokenProviderWrongProxyPathFiles(frontendSources);
|
|
950
1004
|
const nextBrowserTokenProviderMissingPublicServiceKeyFiles =
|
|
951
1005
|
findNextBrowserTokenProviderMissingPublicServiceKeyFiles({
|
|
952
1006
|
dependencySources,
|
|
@@ -954,6 +1008,10 @@ const checkSdkLifecycle = ({
|
|
|
954
1008
|
});
|
|
955
1009
|
const browserTokenProxyUsingBackendServiceKeyFiles =
|
|
956
1010
|
findBrowserTokenProxyUsingBackendServiceKeyFiles(backendSources);
|
|
1011
|
+
const browserTokenProxyTrustingBodyOriginFiles =
|
|
1012
|
+
findBrowserTokenProxyTrustingBodyOriginFiles(backendSources);
|
|
1013
|
+
const browserTokenProxyTrustingBodyProjectEnvironmentFiles =
|
|
1014
|
+
findBrowserTokenProxyTrustingBodyProjectEnvironmentFiles(backendSources);
|
|
957
1015
|
const backendIdentityRequired =
|
|
958
1016
|
backendPresent &&
|
|
959
1017
|
/\b(login|signin|sign_in|auth|token|session)\b/i.test(backendCombined);
|
|
@@ -1014,10 +1072,16 @@ const checkSdkLifecycle = ({
|
|
|
1014
1072
|
wildcard_sdk_dependency_files: wildcardFrontendSdkDependencyFiles,
|
|
1015
1073
|
browser_token_provider_missing_service_key_files:
|
|
1016
1074
|
browserTokenProviderMissingServiceKeyFiles,
|
|
1075
|
+
browser_token_provider_wrong_proxy_path_files:
|
|
1076
|
+
browserTokenProviderWrongProxyPathFiles,
|
|
1017
1077
|
next_browser_token_provider_missing_public_service_key_files:
|
|
1018
1078
|
nextBrowserTokenProviderMissingPublicServiceKeyFiles,
|
|
1019
1079
|
browser_token_proxy_uses_backend_service_key_files:
|
|
1020
1080
|
browserTokenProxyUsingBackendServiceKeyFiles,
|
|
1081
|
+
browser_token_proxy_trusts_body_origin_files:
|
|
1082
|
+
browserTokenProxyTrustingBodyOriginFiles,
|
|
1083
|
+
browser_token_proxy_trusts_body_project_environment_files:
|
|
1084
|
+
browserTokenProxyTrustingBodyProjectEnvironmentFiles,
|
|
1021
1085
|
},
|
|
1022
1086
|
has_noop_wrapper: noOpPattern.test(combined),
|
|
1023
1087
|
component_lifecycle_init_files: componentLifecycleInitFiles,
|
|
@@ -1037,8 +1101,11 @@ const checkSdkLifecycle = ({
|
|
|
1037
1101
|
nextLifecycleNonPublicEnvFiles.length === 0 &&
|
|
1038
1102
|
wildcardFrontendSdkDependencyFiles.length === 0 &&
|
|
1039
1103
|
browserTokenProviderMissingServiceKeyFiles.length === 0 &&
|
|
1104
|
+
browserTokenProviderWrongProxyPathFiles.length === 0 &&
|
|
1040
1105
|
nextBrowserTokenProviderMissingPublicServiceKeyFiles.length === 0 &&
|
|
1041
1106
|
browserTokenProxyUsingBackendServiceKeyFiles.length === 0 &&
|
|
1107
|
+
browserTokenProxyTrustingBodyOriginFiles.length === 0 &&
|
|
1108
|
+
browserTokenProxyTrustingBodyProjectEnvironmentFiles.length === 0 &&
|
|
1042
1109
|
!noOpPattern.test(combined) &&
|
|
1043
1110
|
componentLifecycleInitFiles.length === 0 &&
|
|
1044
1111
|
blockingLifecycleFiles.length === 0,
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { API_CONNECTIVITY_CONTRACT } from "./setup-ai-contract.mjs";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_SETUP_MANIFEST_PATH = ".clue/setup-manifest.json";
|
|
6
|
+
const BROWSER_TOKEN_PROXY_PATH =
|
|
7
|
+
API_CONNECTIVITY_CONTRACT.hops.client_backend_browser_token_proxy.path;
|
|
8
|
+
const CLUE_BROWSER_TOKEN_PATH =
|
|
9
|
+
API_CONNECTIVITY_CONTRACT.hops.clue_backend_browser_token_issue.path;
|
|
10
|
+
const BROWSER_INGEST_PATH = API_CONNECTIVITY_CONTRACT.hops.browser_ingest.path;
|
|
11
|
+
const BACKEND_INGEST_PATH = API_CONNECTIVITY_CONTRACT.hops.backend_ingest.path;
|
|
12
|
+
|
|
13
|
+
const optionalString = (value) =>
|
|
14
|
+
typeof value === "string" && value.trim() ? value.trim() : null;
|
|
15
|
+
|
|
16
|
+
const trimTrailingSlash = (value) => String(value).replace(/\/+$/, "");
|
|
17
|
+
|
|
18
|
+
const joinUrl = (baseUrl, path) => `${trimTrailingSlash(baseUrl)}${path}`;
|
|
19
|
+
|
|
20
|
+
const readJsonIfPresent = async (path) => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error?.code === "ENOENT") return null;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const manifestWatchTargets = (manifest, kind) => {
|
|
30
|
+
const targets = manifest?.lifecycle_verification?.watch_targets;
|
|
31
|
+
if (!Array.isArray(targets)) return [];
|
|
32
|
+
return targets.filter((target) => target?.kind === kind);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const firstTargetUrl = ({ env, manifest, kind }) => {
|
|
36
|
+
for (const target of manifestWatchTargets(manifest, kind)) {
|
|
37
|
+
const explicitUrl = optionalString(target.url);
|
|
38
|
+
if (explicitUrl) return explicitUrl;
|
|
39
|
+
const envName = optionalString(target.url_env_name);
|
|
40
|
+
if (envName && optionalString(env[envName])) return optionalString(env[envName]);
|
|
41
|
+
const candidates = Array.isArray(target.local_url_candidates)
|
|
42
|
+
? target.local_url_candidates
|
|
43
|
+
: [];
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
const value = optionalString(candidate);
|
|
46
|
+
if (value) return value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const firstTargetServiceKey = (manifest, kind) => {
|
|
53
|
+
for (const target of manifestWatchTargets(manifest, kind)) {
|
|
54
|
+
const serviceKey = optionalString(target.service_key ?? target.serviceKey);
|
|
55
|
+
if (serviceKey) return serviceKey;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const readTextResponse = async (response) => {
|
|
61
|
+
try {
|
|
62
|
+
return await response.text();
|
|
63
|
+
} catch {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const parseJsonText = (text) => {
|
|
69
|
+
try {
|
|
70
|
+
return text.trim() ? JSON.parse(text) : null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const postJson = async ({ body, fetchImpl, headers = {}, url }) => {
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetchImpl(url, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: {
|
|
81
|
+
"content-type": "application/json",
|
|
82
|
+
...headers,
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
});
|
|
86
|
+
const text = await readTextResponse(response);
|
|
87
|
+
return {
|
|
88
|
+
transportOk: true,
|
|
89
|
+
response,
|
|
90
|
+
text,
|
|
91
|
+
json: parseJsonText(text),
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
transportOk: false,
|
|
96
|
+
error: error instanceof Error ? error.message : String(error),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const compactFailure = (result) => {
|
|
102
|
+
if (!result.transportOk) return result.error ?? "request failed";
|
|
103
|
+
const jsonMessage =
|
|
104
|
+
typeof result.json?.message === "string"
|
|
105
|
+
? result.json.message
|
|
106
|
+
: typeof result.json?.error === "string"
|
|
107
|
+
? result.json.error
|
|
108
|
+
: null;
|
|
109
|
+
return jsonMessage ?? result.text?.slice(0, 240) ?? "request failed";
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const tokenFromResult = (result) =>
|
|
113
|
+
result.transportOk &&
|
|
114
|
+
result.response.ok &&
|
|
115
|
+
typeof result.json?.token === "string" &&
|
|
116
|
+
result.json.token.trim()
|
|
117
|
+
? result.json.token.trim()
|
|
118
|
+
: null;
|
|
119
|
+
|
|
120
|
+
const buildCheck = ({
|
|
121
|
+
error = null,
|
|
122
|
+
id,
|
|
123
|
+
method = "POST",
|
|
124
|
+
passed,
|
|
125
|
+
result = null,
|
|
126
|
+
url,
|
|
127
|
+
}) => ({
|
|
128
|
+
id,
|
|
129
|
+
method,
|
|
130
|
+
url,
|
|
131
|
+
passed: Boolean(passed),
|
|
132
|
+
status: result?.transportOk ? result.response.status : null,
|
|
133
|
+
error: passed ? null : (error ?? compactFailure(result)),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const buildBrowserEventPayload = ({ environment, projectKey, serviceKey }) => {
|
|
137
|
+
const timestamp = new Date().toISOString();
|
|
138
|
+
const id = `setup_doctor_${Date.now()}`;
|
|
139
|
+
return {
|
|
140
|
+
batch_id: `batch_${id}`,
|
|
141
|
+
idempotency_key: `idem_${id}`,
|
|
142
|
+
sent_at: timestamp,
|
|
143
|
+
source_type: "browser_sdk",
|
|
144
|
+
source_schema_version: "1",
|
|
145
|
+
producer_metadata: {
|
|
146
|
+
producer_id: serviceKey,
|
|
147
|
+
sdk_type: "browser",
|
|
148
|
+
sdk_version: "setup-doctor",
|
|
149
|
+
},
|
|
150
|
+
events: [
|
|
151
|
+
{
|
|
152
|
+
event_id: `event_${id}`,
|
|
153
|
+
event_category: "custom",
|
|
154
|
+
event_name: "setup_doctor_browser_connectivity",
|
|
155
|
+
occurred_at: timestamp,
|
|
156
|
+
source_event_id: `event_${id}`,
|
|
157
|
+
source_schema_version: "1",
|
|
158
|
+
source_event_type: "setup_doctor_browser_connectivity",
|
|
159
|
+
source_event_kind: "custom",
|
|
160
|
+
producer_id: serviceKey,
|
|
161
|
+
project_key: projectKey,
|
|
162
|
+
environment,
|
|
163
|
+
properties: {},
|
|
164
|
+
metrics: { count: 1 },
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const buildBackendEventPayload = ({
|
|
171
|
+
apiKey,
|
|
172
|
+
backendServiceKey,
|
|
173
|
+
environment,
|
|
174
|
+
projectKey,
|
|
175
|
+
}) => {
|
|
176
|
+
const timestamp = new Date().toISOString();
|
|
177
|
+
return {
|
|
178
|
+
projectKey,
|
|
179
|
+
apiKey,
|
|
180
|
+
environment,
|
|
181
|
+
sdkType: "backend",
|
|
182
|
+
sdkVersion: "setup-doctor",
|
|
183
|
+
schemaVersion: 1,
|
|
184
|
+
events: [
|
|
185
|
+
{
|
|
186
|
+
event_name: "setup_doctor_backend_connectivity",
|
|
187
|
+
occurred_at: timestamp,
|
|
188
|
+
service_key: backendServiceKey,
|
|
189
|
+
properties: {},
|
|
190
|
+
metrics: { count: 1 },
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const requiredInputCheck = ({ id, missing, url = null }) => ({
|
|
197
|
+
id,
|
|
198
|
+
method: "POST",
|
|
199
|
+
url,
|
|
200
|
+
passed: false,
|
|
201
|
+
status: null,
|
|
202
|
+
error: `missing required input: ${missing.join(", ")}`,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
export const runSetupDoctor = async ({
|
|
206
|
+
env = process.env,
|
|
207
|
+
fetchImpl = fetch,
|
|
208
|
+
flags,
|
|
209
|
+
repoRoot = ".",
|
|
210
|
+
}) => {
|
|
211
|
+
const manifestPath = String(
|
|
212
|
+
flags.get("manifest") || DEFAULT_SETUP_MANIFEST_PATH,
|
|
213
|
+
);
|
|
214
|
+
const manifest = await readJsonIfPresent(resolve(repoRoot, manifestPath));
|
|
215
|
+
const clueApiBaseUrl =
|
|
216
|
+
optionalString(flags.get("clue-api-base-url")) ??
|
|
217
|
+
optionalString(env.CLUE_API_BASE_URL) ??
|
|
218
|
+
optionalString(manifest?.clue_context?.clue_api_base_url);
|
|
219
|
+
const projectKey =
|
|
220
|
+
optionalString(flags.get("project-key")) ??
|
|
221
|
+
optionalString(env.CLUE_PROJECT_KEY) ??
|
|
222
|
+
optionalString(manifest?.clue_context?.project_key);
|
|
223
|
+
const environment =
|
|
224
|
+
optionalString(flags.get("environment")) ??
|
|
225
|
+
optionalString(env.CLUE_ENVIRONMENT) ??
|
|
226
|
+
optionalString(manifest?.clue_context?.environment) ??
|
|
227
|
+
"dev";
|
|
228
|
+
const apiKey =
|
|
229
|
+
optionalString(flags.get("clue-api-key")) ??
|
|
230
|
+
optionalString(env.CLUE_API_KEY);
|
|
231
|
+
const serviceKey =
|
|
232
|
+
optionalString(flags.get("service-key")) ??
|
|
233
|
+
optionalString(env.NEXT_PUBLIC_CLUE_SERVICE_KEY) ??
|
|
234
|
+
firstTargetServiceKey(manifest, "frontend");
|
|
235
|
+
const backendServiceKey =
|
|
236
|
+
optionalString(flags.get("backend-service-key")) ??
|
|
237
|
+
optionalString(env.CLUE_SERVICE_KEY) ??
|
|
238
|
+
firstTargetServiceKey(manifest, "backend") ??
|
|
239
|
+
serviceKey;
|
|
240
|
+
const clientBackendUrl =
|
|
241
|
+
optionalString(flags.get("client-backend-url")) ??
|
|
242
|
+
firstTargetUrl({ env, manifest, kind: "backend" });
|
|
243
|
+
const clientFrontendUrl =
|
|
244
|
+
optionalString(flags.get("client-frontend-url")) ??
|
|
245
|
+
firstTargetUrl({ env, manifest, kind: "frontend" });
|
|
246
|
+
const origin =
|
|
247
|
+
optionalString(flags.get("origin")) ??
|
|
248
|
+
clientFrontendUrl ??
|
|
249
|
+
"http://localhost";
|
|
250
|
+
const browserTokenProxyUrl = clientBackendUrl
|
|
251
|
+
? joinUrl(clientBackendUrl, BROWSER_TOKEN_PROXY_PATH)
|
|
252
|
+
: null;
|
|
253
|
+
const clueBrowserTokenUrl = clueApiBaseUrl
|
|
254
|
+
? joinUrl(clueApiBaseUrl, CLUE_BROWSER_TOKEN_PATH)
|
|
255
|
+
: null;
|
|
256
|
+
const browserIngestUrl =
|
|
257
|
+
optionalString(flags.get("browser-ingest-url")) ??
|
|
258
|
+
optionalString(env.NEXT_PUBLIC_CLUE_INGEST_ENDPOINT) ??
|
|
259
|
+
optionalString(manifest?.clue_context?.ingest_endpoints?.browser) ??
|
|
260
|
+
(clueApiBaseUrl ? joinUrl(clueApiBaseUrl, BROWSER_INGEST_PATH) : null);
|
|
261
|
+
const backendIngestUrl =
|
|
262
|
+
optionalString(flags.get("backend-ingest-url")) ??
|
|
263
|
+
optionalString(env.CLUE_INGEST_ENDPOINT) ??
|
|
264
|
+
optionalString(manifest?.clue_context?.ingest_endpoints?.backend) ??
|
|
265
|
+
(clueApiBaseUrl ? joinUrl(clueApiBaseUrl, BACKEND_INGEST_PATH) : null);
|
|
266
|
+
|
|
267
|
+
const checks = [];
|
|
268
|
+
|
|
269
|
+
let proxyToken = null;
|
|
270
|
+
if (!browserTokenProxyUrl || !serviceKey) {
|
|
271
|
+
checks.push(
|
|
272
|
+
requiredInputCheck({
|
|
273
|
+
id: "client_backend_browser_token_proxy",
|
|
274
|
+
missing: [
|
|
275
|
+
...(!browserTokenProxyUrl ? ["client-backend-url"] : []),
|
|
276
|
+
...(!serviceKey ? ["service-key"] : []),
|
|
277
|
+
],
|
|
278
|
+
url: browserTokenProxyUrl,
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
} else {
|
|
282
|
+
const result = await postJson({
|
|
283
|
+
fetchImpl,
|
|
284
|
+
url: browserTokenProxyUrl,
|
|
285
|
+
headers: { origin },
|
|
286
|
+
body: { serviceKey },
|
|
287
|
+
});
|
|
288
|
+
proxyToken = tokenFromResult(result);
|
|
289
|
+
checks.push(
|
|
290
|
+
buildCheck({
|
|
291
|
+
id: "client_backend_browser_token_proxy",
|
|
292
|
+
passed: Boolean(proxyToken),
|
|
293
|
+
result,
|
|
294
|
+
url: browserTokenProxyUrl,
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let directToken = null;
|
|
300
|
+
if (!clueBrowserTokenUrl || !apiKey || !projectKey || !environment || !serviceKey) {
|
|
301
|
+
checks.push(
|
|
302
|
+
requiredInputCheck({
|
|
303
|
+
id: "clue_backend_browser_token_issue",
|
|
304
|
+
missing: [
|
|
305
|
+
...(!clueBrowserTokenUrl ? ["clue-api-base-url"] : []),
|
|
306
|
+
...(!apiKey ? ["CLUE_API_KEY"] : []),
|
|
307
|
+
...(!projectKey ? ["CLUE_PROJECT_KEY"] : []),
|
|
308
|
+
...(!environment ? ["CLUE_ENVIRONMENT"] : []),
|
|
309
|
+
...(!serviceKey ? ["service-key"] : []),
|
|
310
|
+
],
|
|
311
|
+
url: clueBrowserTokenUrl,
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
} else {
|
|
315
|
+
const result = await postJson({
|
|
316
|
+
fetchImpl,
|
|
317
|
+
url: clueBrowserTokenUrl,
|
|
318
|
+
headers: { "x-clue-api-key": apiKey },
|
|
319
|
+
body: {
|
|
320
|
+
projectKey,
|
|
321
|
+
environment,
|
|
322
|
+
serviceKey,
|
|
323
|
+
origin,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
directToken = tokenFromResult(result);
|
|
327
|
+
checks.push(
|
|
328
|
+
buildCheck({
|
|
329
|
+
id: "clue_backend_browser_token_issue",
|
|
330
|
+
passed: Boolean(directToken),
|
|
331
|
+
result,
|
|
332
|
+
url: clueBrowserTokenUrl,
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const browserToken = proxyToken ?? directToken;
|
|
338
|
+
if (!browserIngestUrl || !projectKey || !environment || !serviceKey || !browserToken) {
|
|
339
|
+
checks.push(
|
|
340
|
+
requiredInputCheck({
|
|
341
|
+
id: "browser_ingest",
|
|
342
|
+
missing: [
|
|
343
|
+
...(!browserIngestUrl ? ["browser-ingest-url"] : []),
|
|
344
|
+
...(!projectKey ? ["CLUE_PROJECT_KEY"] : []),
|
|
345
|
+
...(!environment ? ["CLUE_ENVIRONMENT"] : []),
|
|
346
|
+
...(!serviceKey ? ["service-key"] : []),
|
|
347
|
+
...(!browserToken ? ["browser token"] : []),
|
|
348
|
+
],
|
|
349
|
+
url: browserIngestUrl,
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
const result = await postJson({
|
|
354
|
+
fetchImpl,
|
|
355
|
+
url: browserIngestUrl,
|
|
356
|
+
headers: {
|
|
357
|
+
origin,
|
|
358
|
+
"x-clue-project-key": projectKey,
|
|
359
|
+
"x-clue-service-key": serviceKey,
|
|
360
|
+
"x-clue-browser-token": browserToken,
|
|
361
|
+
"x-clue-sdk-request": "browser",
|
|
362
|
+
"x-clue-environment": environment,
|
|
363
|
+
"x-clue-sdk-version": "setup-doctor",
|
|
364
|
+
"x-clue-source-schema-version": "1",
|
|
365
|
+
},
|
|
366
|
+
body: buildBrowserEventPayload({ environment, projectKey, serviceKey }),
|
|
367
|
+
});
|
|
368
|
+
checks.push(
|
|
369
|
+
buildCheck({
|
|
370
|
+
id: "browser_ingest",
|
|
371
|
+
passed: Boolean(result.transportOk && result.response.ok),
|
|
372
|
+
result,
|
|
373
|
+
url: browserIngestUrl,
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!backendIngestUrl || !apiKey || !projectKey || !environment || !backendServiceKey) {
|
|
379
|
+
checks.push(
|
|
380
|
+
requiredInputCheck({
|
|
381
|
+
id: "backend_ingest",
|
|
382
|
+
missing: [
|
|
383
|
+
...(!backendIngestUrl ? ["backend-ingest-url"] : []),
|
|
384
|
+
...(!apiKey ? ["CLUE_API_KEY"] : []),
|
|
385
|
+
...(!projectKey ? ["CLUE_PROJECT_KEY"] : []),
|
|
386
|
+
...(!environment ? ["CLUE_ENVIRONMENT"] : []),
|
|
387
|
+
...(!backendServiceKey ? ["backend-service-key"] : []),
|
|
388
|
+
],
|
|
389
|
+
url: backendIngestUrl,
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
} else {
|
|
393
|
+
const result = await postJson({
|
|
394
|
+
fetchImpl,
|
|
395
|
+
url: backendIngestUrl,
|
|
396
|
+
headers: {
|
|
397
|
+
"x-clue-project-key": projectKey,
|
|
398
|
+
"x-clue-api-key": apiKey,
|
|
399
|
+
},
|
|
400
|
+
body: buildBackendEventPayload({
|
|
401
|
+
apiKey,
|
|
402
|
+
backendServiceKey,
|
|
403
|
+
environment,
|
|
404
|
+
projectKey,
|
|
405
|
+
}),
|
|
406
|
+
});
|
|
407
|
+
checks.push(
|
|
408
|
+
buildCheck({
|
|
409
|
+
id: "backend_ingest",
|
|
410
|
+
passed: Boolean(result.transportOk && result.response.ok),
|
|
411
|
+
result,
|
|
412
|
+
url: backendIngestUrl,
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const passed = checks.every((check) => check.passed);
|
|
418
|
+
return {
|
|
419
|
+
status: passed ? "passed" : "failed",
|
|
420
|
+
passed,
|
|
421
|
+
contract: API_CONNECTIVITY_CONTRACT,
|
|
422
|
+
checks,
|
|
423
|
+
inputs: {
|
|
424
|
+
manifest_loaded: Boolean(manifest),
|
|
425
|
+
client_backend_url_configured: Boolean(clientBackendUrl),
|
|
426
|
+
client_frontend_url_configured: Boolean(clientFrontendUrl),
|
|
427
|
+
clue_api_base_url_configured: Boolean(clueApiBaseUrl),
|
|
428
|
+
project_key_configured: Boolean(projectKey),
|
|
429
|
+
environment_configured: Boolean(environment),
|
|
430
|
+
service_key_configured: Boolean(serviceKey),
|
|
431
|
+
backend_service_key_configured: Boolean(backendServiceKey),
|
|
432
|
+
clue_api_key_configured: Boolean(apiKey),
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
};
|