@genn-inc/cluebase-cli 0.0.1
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 +101 -0
- package/bin/cluebase-cli.mjs +11 -0
- package/package.json +17 -0
- package/src/cli-command.mjs +515 -0
- package/src/cli-invocation.mjs +17 -0
- package/src/code-evidence-analyzer.mjs +2041 -0
- package/src/contracts.mjs +36 -0
- package/src/generated-code-evidence-contract.mjs +22 -0
- package/src/generated-sdk-version-contract.mjs +5 -0
- package/src/generated-source-path-policy.mjs +20 -0
- package/src/lifecycle-guard.mjs +202 -0
- package/src/path-policy.mjs +81 -0
- package/src/setup-ai-contract.mjs +221 -0
- package/src/setup-check-constants.mjs +110 -0
- package/src/setup-check-scan-a.mjs +849 -0
- package/src/setup-check-scan-b.mjs +994 -0
- package/src/setup-check.mjs +575 -0
- package/src/setup-discover-check.mjs +755 -0
- package/src/setup-doctor-deadline.mjs +221 -0
- package/src/setup-doctor-env.mjs +331 -0
- package/src/setup-doctor-file-boundary.mjs +426 -0
- package/src/setup-doctor-probe.mjs +719 -0
- package/src/setup-doctor-quality-checks-a.mjs +593 -0
- package/src/setup-doctor-quality-checks-b.mjs +638 -0
- package/src/setup-doctor-quality-shared.mjs +382 -0
- package/src/setup-doctor-quality.mjs +209 -0
- package/src/setup-doctor-route-scan.mjs +160 -0
- package/src/setup-doctor-sdk-probe.mjs +340 -0
- package/src/setup-doctor.mjs +545 -0
- package/src/setup-documents.mjs +112 -0
- package/src/setup-help.mjs +130 -0
- package/src/setup-prepare.mjs +360 -0
- package/src/setup-repository-discovery.mjs +764 -0
- package/src/setup-step-builders-discover.mjs +701 -0
- package/src/setup-step-builders-events.mjs +229 -0
- package/src/setup-step-builders-implement.mjs +710 -0
- package/src/setup-step-commands.mjs +427 -0
- package/src/setup-tool.mjs +27 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearTimeout as clearDeadlineTimeout,
|
|
3
|
+
setTimeout as setDeadlineTimeout,
|
|
4
|
+
} from "node:timers";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SETUP_DOCTOR_DEADLINE_MS = 180_000;
|
|
7
|
+
export const MAX_SETUP_DOCTOR_DEADLINE_MS = 2_147_483_647;
|
|
8
|
+
export const SETUP_DOCTOR_DEADLINE_ENV = "CLUEBASE_SETUP_DOCTOR_DEADLINE_MS";
|
|
9
|
+
export const SETUP_DOCTOR_DEADLINE_ERROR_CODE =
|
|
10
|
+
"SETUP_DOCTOR_DEADLINE_EXCEEDED";
|
|
11
|
+
|
|
12
|
+
export const readSetupDoctorDeadlineConfig = () => {
|
|
13
|
+
const raw = process.env[SETUP_DOCTOR_DEADLINE_ENV];
|
|
14
|
+
if (!raw) {
|
|
15
|
+
return { deadlineMs: DEFAULT_SETUP_DOCTOR_DEADLINE_MS, error: null };
|
|
16
|
+
}
|
|
17
|
+
const parsed = Number(raw);
|
|
18
|
+
if (
|
|
19
|
+
!Number.isSafeInteger(parsed) ||
|
|
20
|
+
parsed <= 0 ||
|
|
21
|
+
parsed > MAX_SETUP_DOCTOR_DEADLINE_MS
|
|
22
|
+
) {
|
|
23
|
+
return {
|
|
24
|
+
deadlineMs: DEFAULT_SETUP_DOCTOR_DEADLINE_MS,
|
|
25
|
+
error: `${SETUP_DOCTOR_DEADLINE_ENV} must be a positive safe integer no greater than ${MAX_SETUP_DOCTOR_DEADLINE_MS}.`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { deadlineMs: parsed, error: null };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const setupDoctorDeadlineCheck = (config) =>
|
|
32
|
+
config.error
|
|
33
|
+
? {
|
|
34
|
+
id: "setup_deadline_config",
|
|
35
|
+
name: "setup-doctor overall deadline configuration",
|
|
36
|
+
severity: "error",
|
|
37
|
+
passed: false,
|
|
38
|
+
error: config.error,
|
|
39
|
+
env_var: SETUP_DOCTOR_DEADLINE_ENV,
|
|
40
|
+
deadline_ms: config.deadlineMs,
|
|
41
|
+
}
|
|
42
|
+
: null;
|
|
43
|
+
|
|
44
|
+
export const setupDoctorDeadlineError = () => {
|
|
45
|
+
const error = new Error("setup-doctor deadline exceeded");
|
|
46
|
+
error.code = SETUP_DOCTOR_DEADLINE_ERROR_CODE;
|
|
47
|
+
return error;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const isSetupDoctorDeadlineError = (error) =>
|
|
51
|
+
error?.code === SETUP_DOCTOR_DEADLINE_ERROR_CODE;
|
|
52
|
+
|
|
53
|
+
export const throwIfSetupDoctorDeadlineExceeded = (signal, error = null) => {
|
|
54
|
+
if (
|
|
55
|
+
signal?.aborted ||
|
|
56
|
+
(signal && (error?.name === "AbortError" || error?.code === "ABORT_ERR"))
|
|
57
|
+
) {
|
|
58
|
+
throw setupDoctorDeadlineError();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const runSetupDoctorStage = async (stage, signal) => {
|
|
63
|
+
throwIfSetupDoctorDeadlineExceeded(signal);
|
|
64
|
+
const result = await stage();
|
|
65
|
+
throwIfSetupDoctorDeadlineExceeded(signal);
|
|
66
|
+
return result;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const setupDoctorDeadlineFailureReport = ({ deadlineMs, contract }) => ({
|
|
70
|
+
status: "failed",
|
|
71
|
+
passed: false,
|
|
72
|
+
contract,
|
|
73
|
+
checks: [
|
|
74
|
+
{
|
|
75
|
+
id: "setup_deadline",
|
|
76
|
+
name: "setup-doctor overall deadline",
|
|
77
|
+
severity: "error",
|
|
78
|
+
passed: false,
|
|
79
|
+
error: "setup-doctor overall deadline exceeded.",
|
|
80
|
+
deadline_ms: deadlineMs,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
qualityChecks: [],
|
|
84
|
+
qualityCheckSummary: null,
|
|
85
|
+
summary: {
|
|
86
|
+
apiConnectivity: { total: 1, failed: 1, passed: false },
|
|
87
|
+
doctrineWarnings: { total: 0, failed: 0 },
|
|
88
|
+
dataQuality: null,
|
|
89
|
+
},
|
|
90
|
+
inputs: {
|
|
91
|
+
manifest_loaded: false,
|
|
92
|
+
client_frontend_url_configured: false,
|
|
93
|
+
origin_configured: false,
|
|
94
|
+
cluebase_api_base_url_configured: false,
|
|
95
|
+
project_key_configured: false,
|
|
96
|
+
frontend_project_key_configured: false,
|
|
97
|
+
backend_project_key_configured: false,
|
|
98
|
+
frontend_cluebase_api_base_url_configured: false,
|
|
99
|
+
backend_ingest_url_configured: false,
|
|
100
|
+
environment_configured: false,
|
|
101
|
+
backend_service_key_configured: false,
|
|
102
|
+
cluebase_api_key_configured: false,
|
|
103
|
+
env_files_loaded: [],
|
|
104
|
+
frontend_env_files_loaded: [],
|
|
105
|
+
backend_env_files_loaded: [],
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const requestWithSetupDoctorDeadline = ({
|
|
110
|
+
fetchImpl,
|
|
111
|
+
options,
|
|
112
|
+
signal,
|
|
113
|
+
url,
|
|
114
|
+
}) => {
|
|
115
|
+
if (!signal) return fetchImpl(url, options);
|
|
116
|
+
if (signal.aborted) return Promise.reject(setupDoctorDeadlineError());
|
|
117
|
+
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
let settled = false;
|
|
120
|
+
let onAbort;
|
|
121
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
122
|
+
const finish = (handler, value) => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
settled = true;
|
|
125
|
+
cleanup();
|
|
126
|
+
handler(value);
|
|
127
|
+
};
|
|
128
|
+
onAbort = () => finish(reject, setupDoctorDeadlineError());
|
|
129
|
+
|
|
130
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
131
|
+
Promise.resolve()
|
|
132
|
+
.then(() => fetchImpl(url, { ...options, signal }))
|
|
133
|
+
.then(
|
|
134
|
+
(response) => finish(resolve, response),
|
|
135
|
+
(error) => finish(reject, error),
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const waitForSetupDoctorPollInterval = (intervalMs, signal) => {
|
|
141
|
+
if (!signal) {
|
|
142
|
+
return new Promise((resolve) => setTimeout(() => resolve(true), intervalMs));
|
|
143
|
+
}
|
|
144
|
+
if (signal.aborted) return Promise.resolve(false);
|
|
145
|
+
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
let settled = false;
|
|
148
|
+
let timer = null;
|
|
149
|
+
let onAbort;
|
|
150
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
151
|
+
const finish = (value) => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
cleanup();
|
|
156
|
+
resolve(value);
|
|
157
|
+
};
|
|
158
|
+
onAbort = () => finish(false);
|
|
159
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
160
|
+
timer = setTimeout(() => finish(true), intervalMs);
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const createSetupDoctorDeadlineFetch = ({
|
|
165
|
+
deadlineCheck,
|
|
166
|
+
fetchImpl,
|
|
167
|
+
signal,
|
|
168
|
+
}) => {
|
|
169
|
+
const deadlineFetch = (url, options) =>
|
|
170
|
+
requestWithSetupDoctorDeadline({
|
|
171
|
+
fetchImpl,
|
|
172
|
+
options,
|
|
173
|
+
signal,
|
|
174
|
+
url,
|
|
175
|
+
});
|
|
176
|
+
return Object.assign(deadlineFetch, {
|
|
177
|
+
deadlineCheck,
|
|
178
|
+
runStage: (stage) => runSetupDoctorStage(stage, signal),
|
|
179
|
+
signal,
|
|
180
|
+
waitForPollInterval: (intervalMs) =>
|
|
181
|
+
waitForSetupDoctorPollInterval(intervalMs, signal),
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const withSetupDoctorDeadline = (run, contract = null) => async (args) => {
|
|
186
|
+
const setupDeadlineConfig = readSetupDoctorDeadlineConfig();
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const deadlineCheck = setupDoctorDeadlineCheck(setupDeadlineConfig);
|
|
189
|
+
const runPromise = Promise.resolve().then(() =>
|
|
190
|
+
run({
|
|
191
|
+
...args,
|
|
192
|
+
signal: controller.signal,
|
|
193
|
+
fetchImpl: createSetupDoctorDeadlineFetch({
|
|
194
|
+
deadlineCheck,
|
|
195
|
+
fetchImpl: args.fetchImpl ?? fetch,
|
|
196
|
+
signal: controller.signal,
|
|
197
|
+
}),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
let deadlineTimer;
|
|
201
|
+
const deadlinePromise = new Promise((_, reject) => {
|
|
202
|
+
deadlineTimer = setDeadlineTimeout(() => {
|
|
203
|
+
controller.abort();
|
|
204
|
+
reject(setupDoctorDeadlineError());
|
|
205
|
+
}, setupDeadlineConfig.deadlineMs);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
return await Promise.race([runPromise, deadlinePromise]);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!isSetupDoctorDeadlineError(error) || !controller.signal.aborted) throw error;
|
|
212
|
+
return setupDoctorDeadlineFailureReport({
|
|
213
|
+
contract,
|
|
214
|
+
deadlineMs: setupDeadlineConfig.deadlineMs,
|
|
215
|
+
});
|
|
216
|
+
} finally {
|
|
217
|
+
clearDeadlineTimeout(deadlineTimer);
|
|
218
|
+
controller.abort();
|
|
219
|
+
void runPromise.catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// setup-doctor env / URL / dotenv helpers.
|
|
2
|
+
// Extracted from setup-doctor for file-size limits; content is unchanged.
|
|
3
|
+
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { API_CONNECTIVITY_CONTRACT } from "./setup-ai-contract.mjs";
|
|
6
|
+
import {
|
|
7
|
+
readSetupDoctorFile,
|
|
8
|
+
readSetupDoctorJson,
|
|
9
|
+
} from "./setup-doctor-file-boundary.mjs";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_SETUP_MANIFEST_PATH = ".cluebase/setup-manifest.json";
|
|
12
|
+
export const CLUEBASE_BROWSER_TOKEN_PATH =
|
|
13
|
+
API_CONNECTIVITY_CONTRACT.hops.cluebase_backend_browser_token_issue.path;
|
|
14
|
+
export const BROWSER_INGEST_PATH = API_CONNECTIVITY_CONTRACT.hops.browser_ingest.path;
|
|
15
|
+
export const BACKEND_INGEST_PATH = API_CONNECTIVITY_CONTRACT.hops.backend_ingest.path;
|
|
16
|
+
export const BATCH_STATUS_PATH = API_CONNECTIVITY_CONTRACT.hops.batch_status.path;
|
|
17
|
+
export const CLUEBASE_TEST_SETUP_SDK_VERSION = "cluebase_test_setup";
|
|
18
|
+
export const FRONTEND_SOURCE_IDENTIFIER = "frontend";
|
|
19
|
+
export const DEFAULT_BATCH_VISIBILITY_TIMEOUT_MS = 60_000;
|
|
20
|
+
export const BATCH_VISIBILITY_POLL_INTERVAL_MS = 500;
|
|
21
|
+
export const DOCTOR_ENV_FILE_NAMES = [
|
|
22
|
+
".env",
|
|
23
|
+
".env.development",
|
|
24
|
+
".env.local",
|
|
25
|
+
".env.development.local",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Frontend SDK calls Cluebase backend directly. Customer backends must not expose
|
|
29
|
+
// Cluebase-specific route handlers.
|
|
30
|
+
export const CLUEBASE_RESERVED_ROUTE_PATTERN =
|
|
31
|
+
/(?:["'`])(?:\/api\/v1)?\/cluebase(?:\/|["'`)]|$)|\/api\/v1\/cluebase(?:\/|$)/i;
|
|
32
|
+
export const BROWSER_TOKEN_PROXY_HINT_PATTERN =
|
|
33
|
+
/browser[-_]?tokens?|\/api\/v1\/ingest\/browser-tokens/i;
|
|
34
|
+
export const ROUTE_DECLARATION_PATTERN =
|
|
35
|
+
/(?:@(?:router|app)\.(?:get|post|put|patch|delete)|\b(?:router|app)\.(?:get|post|put|patch|delete)\s*\(|@Controller\s*\(|path\s*\(|Route::(?:get|post|put|patch|delete)\s*\(|http\.HandleFunc\s*\()/i;
|
|
36
|
+
export const PROXY_SCAN_FILE_EXTENSIONS = new Set([
|
|
37
|
+
".py",
|
|
38
|
+
".ts",
|
|
39
|
+
".tsx",
|
|
40
|
+
".js",
|
|
41
|
+
".jsx",
|
|
42
|
+
".mjs",
|
|
43
|
+
".cjs",
|
|
44
|
+
".go",
|
|
45
|
+
".rb",
|
|
46
|
+
".java",
|
|
47
|
+
".kt",
|
|
48
|
+
".cs",
|
|
49
|
+
".php",
|
|
50
|
+
".rs",
|
|
51
|
+
".ex",
|
|
52
|
+
".exs",
|
|
53
|
+
]);
|
|
54
|
+
export const PROXY_SCAN_EXCLUDED_PATHS = [".cluebase", "out", "target", ".turbo"];
|
|
55
|
+
export const PROXY_SCAN_MAX_FILES = 2000;
|
|
56
|
+
|
|
57
|
+
export const optionalString = (value) =>
|
|
58
|
+
typeof value === "string" && value.trim() ? value.trim() : null;
|
|
59
|
+
|
|
60
|
+
export const trimTrailingSlash = (value) => String(value).replace(/\/+$/, "");
|
|
61
|
+
|
|
62
|
+
export const isPlainObject = (value) =>
|
|
63
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
64
|
+
|
|
65
|
+
export const normalizeCluebaseApiBaseUrl = (baseUrl) =>
|
|
66
|
+
trimTrailingSlash(baseUrl).replace(/\/api\/v1$/i, "");
|
|
67
|
+
|
|
68
|
+
export const readBatchVisibilityTimeoutConfig = () => {
|
|
69
|
+
const raw = process.env.CLUEBASE_SETUP_VERIFICATION_BATCH_TIMEOUT_MS;
|
|
70
|
+
if (!raw) {
|
|
71
|
+
return { timeoutMs: DEFAULT_BATCH_VISIBILITY_TIMEOUT_MS, error: null };
|
|
72
|
+
}
|
|
73
|
+
const parsed = Number(raw);
|
|
74
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
75
|
+
return {
|
|
76
|
+
timeoutMs: DEFAULT_BATCH_VISIBILITY_TIMEOUT_MS,
|
|
77
|
+
error:
|
|
78
|
+
"CLUEBASE_SETUP_VERIFICATION_BATCH_TIMEOUT_MS must be a positive integer.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { timeoutMs: Math.max(1_000, parsed), error: null };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const joinUrl = (baseUrl, path) => `${normalizeCluebaseApiBaseUrl(baseUrl)}${path}`;
|
|
85
|
+
|
|
86
|
+
export const batchStatusPathFor = (batchId) =>
|
|
87
|
+
BATCH_STATUS_PATH.replace(":batchId", encodeURIComponent(batchId));
|
|
88
|
+
|
|
89
|
+
export const BATCH_STATUS_PLACEHOLDER_PATH = BATCH_STATUS_PATH.replace(
|
|
90
|
+
":batchId",
|
|
91
|
+
"<batchId>",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
export const publicCluebaseApiBaseUrl = (env) =>
|
|
95
|
+
optionalString(env.NEXT_PUBLIC_CLUEBASE_API_BASE_URL) ??
|
|
96
|
+
optionalString(env.VITE_CLUEBASE_API_BASE_URL) ??
|
|
97
|
+
optionalString(env.REACT_APP_CLUEBASE_API_BASE_URL) ??
|
|
98
|
+
optionalString(env.PUBLIC_CLUEBASE_API_BASE_URL) ??
|
|
99
|
+
optionalString(env.NUXT_PUBLIC_CLUEBASE_API_BASE_URL);
|
|
100
|
+
|
|
101
|
+
export const publicProjectKeyFromEnv = (env) =>
|
|
102
|
+
optionalString(env.NEXT_PUBLIC_CLUEBASE_PROJECT_KEY) ??
|
|
103
|
+
optionalString(env.VITE_CLUEBASE_PROJECT_KEY) ??
|
|
104
|
+
optionalString(env.REACT_APP_CLUEBASE_PROJECT_KEY) ??
|
|
105
|
+
optionalString(env.PUBLIC_CLUEBASE_PROJECT_KEY) ??
|
|
106
|
+
optionalString(env.NUXT_PUBLIC_CLUEBASE_PROJECT_KEY);
|
|
107
|
+
|
|
108
|
+
export const cluebaseApiBaseUrlFromIngestEndpoint = (endpoint) => {
|
|
109
|
+
const raw = optionalString(endpoint);
|
|
110
|
+
if (!raw) return null;
|
|
111
|
+
const trimmed = trimTrailingSlash(raw);
|
|
112
|
+
const base = trimmed.replace(/\/api\/v1\/ingest\/(?:backend|browser)$/i, "");
|
|
113
|
+
return base === trimmed ? null : base;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const normalizeBrowserIngestUrl = (url) => {
|
|
117
|
+
const raw = optionalString(url);
|
|
118
|
+
if (!raw) return null;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const parsed = new URL(raw);
|
|
122
|
+
if (
|
|
123
|
+
parsed.pathname === "" ||
|
|
124
|
+
parsed.pathname === "/" ||
|
|
125
|
+
parsed.pathname.replace(/\/+$/, "") === "/api/v1"
|
|
126
|
+
) {
|
|
127
|
+
parsed.pathname = BROWSER_INGEST_PATH;
|
|
128
|
+
parsed.search = "";
|
|
129
|
+
parsed.hash = "";
|
|
130
|
+
return parsed.toString();
|
|
131
|
+
}
|
|
132
|
+
return parsed.toString();
|
|
133
|
+
} catch {
|
|
134
|
+
const trimmed = trimTrailingSlash(raw);
|
|
135
|
+
if (trimmed.endsWith("/api/v1")) {
|
|
136
|
+
return `${trimmed.slice(0, -"/api/v1".length)}${BROWSER_INGEST_PATH}`;
|
|
137
|
+
}
|
|
138
|
+
return raw;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const manifestDetectedServices = (manifest, kind) => {
|
|
143
|
+
const targets = manifest?.detected_services;
|
|
144
|
+
if (!Array.isArray(targets)) return [];
|
|
145
|
+
return kind
|
|
146
|
+
? targets.filter((target) => target?.kind === kind)
|
|
147
|
+
: targets.filter(isPlainObject);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const firstTargetUrl = ({ manifest, kind }) => {
|
|
151
|
+
for (const target of manifestDetectedServices(manifest, kind)) {
|
|
152
|
+
const explicitUrl = optionalString(target.url);
|
|
153
|
+
if (explicitUrl) return explicitUrl;
|
|
154
|
+
const localUrl = Array.isArray(target.local_url_candidates)
|
|
155
|
+
? target.local_url_candidates.map(optionalString).find(Boolean)
|
|
156
|
+
: null;
|
|
157
|
+
if (localUrl) return localUrl;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const stripDotenvInlineComment = (value) => {
|
|
163
|
+
let quote = null;
|
|
164
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
165
|
+
const char = value[index];
|
|
166
|
+
if ((char === "'" || char === '"') && value[index - 1] !== "\\") {
|
|
167
|
+
quote = quote === char ? null : (quote ?? char);
|
|
168
|
+
}
|
|
169
|
+
if (char === "#" && quote === null && /\s/.test(value[index - 1] ?? "")) {
|
|
170
|
+
return value.slice(0, index).trimEnd();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const unquoteDotenvValue = (value) => {
|
|
177
|
+
const trimmed = stripDotenvInlineComment(value.trim());
|
|
178
|
+
if (trimmed.length < 2) return trimmed;
|
|
179
|
+
const quote = trimmed[0];
|
|
180
|
+
if (
|
|
181
|
+
(quote !== "'" && quote !== '"') ||
|
|
182
|
+
trimmed[trimmed.length - 1] !== quote
|
|
183
|
+
) {
|
|
184
|
+
return trimmed;
|
|
185
|
+
}
|
|
186
|
+
const inner = trimmed.slice(1, -1);
|
|
187
|
+
if (quote === "'") return inner;
|
|
188
|
+
return inner
|
|
189
|
+
.replace(/\\n/g, "\n")
|
|
190
|
+
.replace(/\\r/g, "\r")
|
|
191
|
+
.replace(/\\t/g, "\t")
|
|
192
|
+
.replace(/\\"/g, '"')
|
|
193
|
+
.replace(/\\\\/g, "\\");
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const parseDotenv = (content) => {
|
|
197
|
+
const result = {};
|
|
198
|
+
for (const rawLine of String(content).split(/\r?\n/)) {
|
|
199
|
+
let line = rawLine.trim();
|
|
200
|
+
if (!line || line.startsWith("#")) continue;
|
|
201
|
+
if (line.startsWith("export "))
|
|
202
|
+
line = line.slice("export ".length).trimStart();
|
|
203
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
204
|
+
if (!match) continue;
|
|
205
|
+
result[match[1]] = unquoteDotenvValue(match[2]);
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const relativeInsideRepo = ({ path, repoRoot }) => {
|
|
211
|
+
const rawPath = String(path ?? "");
|
|
212
|
+
if (rawPath.split(/[\\/]+/).filter(Boolean).includes("..")) return null;
|
|
213
|
+
const absolutePath = isAbsolute(path)
|
|
214
|
+
? resolve(path)
|
|
215
|
+
: resolve(repoRoot, path);
|
|
216
|
+
const relativePath = relative(repoRoot, absolutePath);
|
|
217
|
+
if (
|
|
218
|
+
relativePath.startsWith("..") ||
|
|
219
|
+
isAbsolute(relativePath)
|
|
220
|
+
) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
return relativePath || ".";
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const addEnvCandidate = ({ candidates, path, repoRoot }) => {
|
|
227
|
+
const relPath = optionalString(path);
|
|
228
|
+
if (!relPath) return;
|
|
229
|
+
const insidePath = relativeInsideRepo({ repoRoot, path: relPath });
|
|
230
|
+
if (insidePath) candidates.add(insidePath);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const envRootsFromManifest = (manifest, kind) => {
|
|
234
|
+
const roots = [];
|
|
235
|
+
if (kind === "backend") {
|
|
236
|
+
const detectedBackendRoot = optionalString(
|
|
237
|
+
manifest?.detected?.backend_root_path,
|
|
238
|
+
);
|
|
239
|
+
if (detectedBackendRoot) roots.push(detectedBackendRoot);
|
|
240
|
+
}
|
|
241
|
+
for (const target of manifestDetectedServices(manifest, kind)) {
|
|
242
|
+
const root = optionalString(target.root_path ?? target.path);
|
|
243
|
+
if (root) roots.push(root);
|
|
244
|
+
}
|
|
245
|
+
return [...new Set(roots)].flatMap((root) => {
|
|
246
|
+
const expanded = [root];
|
|
247
|
+
if (root.endsWith("/src") || root.endsWith("/app")) {
|
|
248
|
+
expanded.push(dirname(root));
|
|
249
|
+
}
|
|
250
|
+
return expanded;
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export const envFileCandidates = ({ discoveries, kind, manifest, repoRoot }) => {
|
|
255
|
+
const candidates = new Set();
|
|
256
|
+
if (isPlainObject(discoveries?.env_files)) {
|
|
257
|
+
const entry = discoveries.env_files[kind];
|
|
258
|
+
if (isPlainObject(entry)) {
|
|
259
|
+
addEnvCandidate({ candidates, path: entry.path, repoRoot });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const name of DOCTOR_ENV_FILE_NAMES) {
|
|
263
|
+
addEnvCandidate({ candidates, path: name, repoRoot });
|
|
264
|
+
}
|
|
265
|
+
for (const root of envRootsFromManifest(manifest, kind)) {
|
|
266
|
+
for (const name of DOCTOR_ENV_FILE_NAMES) {
|
|
267
|
+
addEnvCandidate({ candidates, path: join(root, name), repoRoot });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return [...candidates];
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export const loadDoctorEnvFiles = async ({
|
|
274
|
+
discoveries,
|
|
275
|
+
kind,
|
|
276
|
+
manifest,
|
|
277
|
+
repoRoot,
|
|
278
|
+
signal,
|
|
279
|
+
}) => {
|
|
280
|
+
const fileEnv = {};
|
|
281
|
+
const loadedFiles = [];
|
|
282
|
+
for (const relPath of envFileCandidates({
|
|
283
|
+
discoveries,
|
|
284
|
+
kind,
|
|
285
|
+
manifest,
|
|
286
|
+
repoRoot,
|
|
287
|
+
})) {
|
|
288
|
+
const content = await readSetupDoctorFile({
|
|
289
|
+
repoRoot,
|
|
290
|
+
path: relPath,
|
|
291
|
+
signal,
|
|
292
|
+
optional: true,
|
|
293
|
+
});
|
|
294
|
+
if (content === null) continue;
|
|
295
|
+
Object.assign(fileEnv, parseDotenv(content));
|
|
296
|
+
loadedFiles.push(relPath);
|
|
297
|
+
}
|
|
298
|
+
return { env: fileEnv, loadedFiles };
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export const loadDoctorEnv = async ({ manifest, repoRoot, signal }) => {
|
|
302
|
+
const discoveries = await readSetupDoctorJson({
|
|
303
|
+
repoRoot,
|
|
304
|
+
path: ".cluebase/discoveries.json",
|
|
305
|
+
signal,
|
|
306
|
+
optional: true,
|
|
307
|
+
});
|
|
308
|
+
const frontend = await loadDoctorEnvFiles({
|
|
309
|
+
discoveries,
|
|
310
|
+
kind: "frontend",
|
|
311
|
+
manifest,
|
|
312
|
+
repoRoot,
|
|
313
|
+
signal,
|
|
314
|
+
});
|
|
315
|
+
const backend = await loadDoctorEnvFiles({
|
|
316
|
+
discoveries,
|
|
317
|
+
kind: "backend",
|
|
318
|
+
manifest,
|
|
319
|
+
repoRoot,
|
|
320
|
+
signal,
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
frontendEnv: frontend.env,
|
|
324
|
+
backendEnv: backend.env,
|
|
325
|
+
frontendLoadedFiles: frontend.loadedFiles,
|
|
326
|
+
backendLoadedFiles: backend.loadedFiles,
|
|
327
|
+
loadedFiles: [
|
|
328
|
+
...new Set([...frontend.loadedFiles, ...backend.loadedFiles]),
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
};
|