@nexalink/carecompass-sdk 0.2.0-beta.15 → 0.4.0-beta.15
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 +203 -131
- package/dist/cli.js +483 -36
- package/dist/component.d.ts +10 -2
- package/dist/component.js +59 -57
- package/dist/core.d.ts +78 -0
- package/dist/core.js +220 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/markdown.d.ts +3 -0
- package/dist/markdown.js +183 -0
- package/dist/snippet.d.ts +1 -1
- package/dist/snippet.js +4 -1
- package/dist/telemetry.d.ts +43 -2
- package/dist/telemetry.js +85 -3
- package/dist/ui.d.ts +3 -0
- package/dist/ui.js +2 -0
- package/dist/version.js +1 -1
- package/package.json +24 -5
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { createDefaultCareCompassConfig, validateCareCompassConfig, } from "./schema.js";
|
|
6
|
+
import { careCompassApiBaseUrlSchema, createDefaultCareCompassConfig, validateCareCompassConfig, } from "./schema.js";
|
|
7
7
|
import { normalizeApiKey, resolveApiKey, resolveCredentialsPath, writeStoredApiKey, } from "./credentials.js";
|
|
8
8
|
import { generateCareCompassIframeSnippet, generateCareCompassSnippet, } from "./snippet.js";
|
|
9
9
|
import { CARECOMPASS_TELEMETRY_ERROR_CODES, CARECOMPASS_TELEMETRY_EVENTS, createCareCompassCliTelemetry, } from "./telemetry.js";
|
|
@@ -31,6 +31,277 @@ support resources, and appropriate next steps. Stay out of diagnosis, treatment
|
|
|
31
31
|
planning, emergency triage, legal advice, and coverage guarantees. When a member
|
|
32
32
|
needs clinical, emergency, plan-specific, or account-specific help that requires
|
|
33
33
|
a human, route them to the customer's approved support resources.`;
|
|
34
|
+
const CLI_COMMAND_ALLOWLIST = new Set([
|
|
35
|
+
"auth",
|
|
36
|
+
"completion",
|
|
37
|
+
"config",
|
|
38
|
+
"configure",
|
|
39
|
+
"doctor",
|
|
40
|
+
"embed",
|
|
41
|
+
"health",
|
|
42
|
+
"help",
|
|
43
|
+
"init",
|
|
44
|
+
"login",
|
|
45
|
+
"logout",
|
|
46
|
+
"query",
|
|
47
|
+
"setup",
|
|
48
|
+
"snippet",
|
|
49
|
+
"validate",
|
|
50
|
+
"version",
|
|
51
|
+
]);
|
|
52
|
+
const UNAUTHENTICATED_AUTH_ATTEMPT_LIMIT = 2;
|
|
53
|
+
const UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
54
|
+
const UNAUTHENTICATED_AUTH_RATE_LIMIT_FILE = "auth-rate-limit.json";
|
|
55
|
+
class CliTelemetryError extends Error {
|
|
56
|
+
constructor(message, telemetry) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.telemetry = telemetry;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const sanitizeCommandName = (command) => {
|
|
62
|
+
const normalized = command === "--help" || command === "-h" ? "help" : command;
|
|
63
|
+
if (command === "--version" || command === "-v")
|
|
64
|
+
return "version";
|
|
65
|
+
return CLI_COMMAND_ALLOWLIST.has(normalized) ? normalized : "unknown_command";
|
|
66
|
+
};
|
|
67
|
+
const authTelemetryForSource = (source) => {
|
|
68
|
+
if (source === "environment") {
|
|
69
|
+
return { authMethod: "env_var", keySource: "env_var" };
|
|
70
|
+
}
|
|
71
|
+
if (source === "file") {
|
|
72
|
+
return { authMethod: "config_file", keySource: "config_file" };
|
|
73
|
+
}
|
|
74
|
+
return { authMethod: "api_key", keySource: "cli_flag" };
|
|
75
|
+
};
|
|
76
|
+
const unauthenticatedRateLimitPath = (io) => io.env.CARECOMPASS_AUTH_RATE_LIMIT_PATH ??
|
|
77
|
+
join(dirname(resolveCredentialsPath(io.env)), UNAUTHENTICATED_AUTH_RATE_LIMIT_FILE);
|
|
78
|
+
const unauthenticatedRateLimitKey = (io) => {
|
|
79
|
+
const keyMaterial = io.env.CARECOMPASS_AUTH_RATE_LIMIT_KEY
|
|
80
|
+
? `explicit:${io.env.CARECOMPASS_AUTH_RATE_LIMIT_KEY}`
|
|
81
|
+
: io.env.CARECOMPASS_CLIENT_IP
|
|
82
|
+
? `ip:${io.env.CARECOMPASS_CLIENT_IP}`
|
|
83
|
+
: `credentials:${resolveCredentialsPath(io.env)}`;
|
|
84
|
+
return createHash("sha256")
|
|
85
|
+
.update("carecompass unauthenticated auth rate limit")
|
|
86
|
+
.update(keyMaterial)
|
|
87
|
+
.digest("hex");
|
|
88
|
+
};
|
|
89
|
+
const emptyUnauthenticatedAuthLedger = () => ({
|
|
90
|
+
version: 1,
|
|
91
|
+
entries: {},
|
|
92
|
+
});
|
|
93
|
+
const readUnauthenticatedAuthLedger = (path) => {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
96
|
+
if (parsed.version !== 1 || typeof parsed.entries !== "object") {
|
|
97
|
+
return emptyUnauthenticatedAuthLedger();
|
|
98
|
+
}
|
|
99
|
+
const entries = {};
|
|
100
|
+
for (const [key, value] of Object.entries(parsed.entries)) {
|
|
101
|
+
if (typeof value === "object" &&
|
|
102
|
+
value !== null &&
|
|
103
|
+
typeof value.failures ===
|
|
104
|
+
"number" &&
|
|
105
|
+
typeof value.firstAttemptAt ===
|
|
106
|
+
"number" &&
|
|
107
|
+
typeof value.lastAttemptAt ===
|
|
108
|
+
"number") {
|
|
109
|
+
entries[key] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { version: 1, entries };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return emptyUnauthenticatedAuthLedger();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const writeUnauthenticatedAuthLedger = (path, ledger) => {
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
120
|
+
writeFileSync(path, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
|
|
121
|
+
try {
|
|
122
|
+
chmodSync(path, 0o600);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Some filesystems do not support chmod; the ledger contains only hashed
|
|
126
|
+
// limiter keys and counters.
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const pruneUnauthenticatedAuthLedger = (ledger, now) => {
|
|
130
|
+
for (const [key, entry] of Object.entries(ledger.entries)) {
|
|
131
|
+
if (now - entry.firstAttemptAt >
|
|
132
|
+
UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS) {
|
|
133
|
+
delete ledger.entries[key];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const recordUnauthenticatedAuthFailure = (io) => {
|
|
138
|
+
const path = unauthenticatedRateLimitPath(io);
|
|
139
|
+
const key = unauthenticatedRateLimitKey(io);
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const ledger = readUnauthenticatedAuthLedger(path);
|
|
142
|
+
pruneUnauthenticatedAuthLedger(ledger, now);
|
|
143
|
+
const existing = ledger.entries[key];
|
|
144
|
+
const entry = existing &&
|
|
145
|
+
now - existing.firstAttemptAt <= UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS
|
|
146
|
+
? existing
|
|
147
|
+
: { failures: 0, firstAttemptAt: now, lastAttemptAt: now };
|
|
148
|
+
entry.failures += 1;
|
|
149
|
+
entry.lastAttemptAt = now;
|
|
150
|
+
ledger.entries[key] = entry;
|
|
151
|
+
writeUnauthenticatedAuthLedger(path, ledger);
|
|
152
|
+
const failures = entry.failures;
|
|
153
|
+
if (failures < UNAUTHENTICATED_AUTH_ATTEMPT_LIMIT)
|
|
154
|
+
return null;
|
|
155
|
+
return new CliTelemetryError("Too many failed authentication attempts. Wait before trying again or verify the API key in the Nexalink console.", {
|
|
156
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.RATE_LIMIT_EXCEEDED,
|
|
157
|
+
failureCategory: "rate_limit_error",
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
const resetUnauthenticatedAuthFailures = (io) => {
|
|
161
|
+
const path = unauthenticatedRateLimitPath(io);
|
|
162
|
+
const ledger = readUnauthenticatedAuthLedger(path);
|
|
163
|
+
delete ledger.entries[unauthenticatedRateLimitKey(io)];
|
|
164
|
+
writeUnauthenticatedAuthLedger(path, ledger);
|
|
165
|
+
};
|
|
166
|
+
const normalizeBaaStatus = (value) => {
|
|
167
|
+
if (value === "signed" || value === "pending" || value === "not_required") {
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
return "signed";
|
|
171
|
+
};
|
|
172
|
+
const classifyAuthFailure = (status, message) => {
|
|
173
|
+
const normalized = message.toLowerCase();
|
|
174
|
+
if (status === 429 || normalized.includes("rate limit")) {
|
|
175
|
+
return {
|
|
176
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.RATE_LIMIT_EXCEEDED,
|
|
177
|
+
failureCategory: "rate_limit_error",
|
|
178
|
+
baaStatus: "signed",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (status === 403 ||
|
|
182
|
+
normalized.includes("baa") ||
|
|
183
|
+
normalized.includes("business associate")) {
|
|
184
|
+
return {
|
|
185
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.BAA_REQUIRED,
|
|
186
|
+
failureCategory: "baa_not_signed",
|
|
187
|
+
baaStatus: "pending",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (normalized.includes("expired")) {
|
|
191
|
+
return {
|
|
192
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTH_KEY_EXPIRED,
|
|
193
|
+
failureCategory: "auth_error",
|
|
194
|
+
baaStatus: "signed",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTH_KEY_INVALID,
|
|
199
|
+
failureCategory: "auth_error",
|
|
200
|
+
baaStatus: "signed",
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
const classifyCliFailure = (error) => {
|
|
204
|
+
if (error instanceof CliTelemetryError)
|
|
205
|
+
return error.telemetry;
|
|
206
|
+
const code = error && typeof error === "object" && "code" in error
|
|
207
|
+
? String(error.code)
|
|
208
|
+
: "";
|
|
209
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
210
|
+
const normalized = message.toLowerCase();
|
|
211
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
212
|
+
return {
|
|
213
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.PERMISSION_DENIED,
|
|
214
|
+
failureCategory: "permission_error",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (code === "ENOENT" || normalized.includes("was not found")) {
|
|
218
|
+
return {
|
|
219
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CONFIG_NOT_FOUND,
|
|
220
|
+
failureCategory: "config_error",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (normalized.includes("invalid carecompass config") ||
|
|
224
|
+
normalized.includes("unexpected argument") ||
|
|
225
|
+
normalized.includes("must be either") ||
|
|
226
|
+
normalized.includes("api base url")) {
|
|
227
|
+
return {
|
|
228
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CONFIG_INVALID,
|
|
229
|
+
failureCategory: "config_error",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (normalized.includes("already exists")) {
|
|
233
|
+
return {
|
|
234
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CONFIG_ALREADY_EXISTS,
|
|
235
|
+
failureCategory: "config_error",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (normalized.includes("missing nexalink api key")) {
|
|
239
|
+
return {
|
|
240
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTH_KEY_MISSING,
|
|
241
|
+
failureCategory: "auth_error",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (normalized.includes("api key format") ||
|
|
245
|
+
normalized.includes("invalid api key")) {
|
|
246
|
+
return {
|
|
247
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTH_KEY_INVALID,
|
|
248
|
+
failureCategory: "auth_error",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (normalized.includes("baa execution") ||
|
|
252
|
+
normalized.includes("business associate")) {
|
|
253
|
+
return {
|
|
254
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.BAA_REQUIRED,
|
|
255
|
+
failureCategory: "baa_not_signed",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (normalized.includes("rate limit") || normalized.includes("quota")) {
|
|
259
|
+
return {
|
|
260
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.RATE_LIMIT_EXCEEDED,
|
|
261
|
+
failureCategory: "rate_limit_error",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (normalized.includes("timed out") || normalized.includes("timeout")) {
|
|
265
|
+
return {
|
|
266
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.NET_TIMEOUT,
|
|
267
|
+
failureCategory: "network_error",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (normalized.includes("could not reach")) {
|
|
271
|
+
return {
|
|
272
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.NET_UNREACHABLE,
|
|
273
|
+
failureCategory: "network_error",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (normalized.includes("model load")) {
|
|
277
|
+
return {
|
|
278
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.MODEL_LOAD_FAILED,
|
|
279
|
+
failureCategory: "model_error",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (normalized.includes("model") || normalized.includes("inference")) {
|
|
283
|
+
return {
|
|
284
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.MODEL_INFERENCE_FAILED,
|
|
285
|
+
failureCategory: "model_error",
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (normalized.includes("unknown command")) {
|
|
289
|
+
return {
|
|
290
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CLI_COMMAND_UNKNOWN,
|
|
291
|
+
failureCategory: "unknown",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (normalized.includes("cancelled")) {
|
|
295
|
+
return {
|
|
296
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CLI_COMMAND_CANCELLED,
|
|
297
|
+
failureCategory: "unknown",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.UNKNOWN_ERROR,
|
|
302
|
+
failureCategory: "unknown",
|
|
303
|
+
};
|
|
304
|
+
};
|
|
34
305
|
function parseArgs(argv) {
|
|
35
306
|
const [command = "help", ...rest] = argv;
|
|
36
307
|
const flags = {};
|
|
@@ -210,6 +481,12 @@ const signupUrls = (flags, io) => ({
|
|
|
210
481
|
io.env.CARECOMPASS_DEVELOPER_SIGNUP_URL ??
|
|
211
482
|
DEFAULT_DEVELOPER_SIGNUP_URL,
|
|
212
483
|
});
|
|
484
|
+
function resolveTrustedApiBaseUrl(value) {
|
|
485
|
+
const result = careCompassApiBaseUrlSchema.safeParse(value);
|
|
486
|
+
if (result.success)
|
|
487
|
+
return result.data;
|
|
488
|
+
throw new Error(result.error.issues.map((issue) => issue.message).join("\n"));
|
|
489
|
+
}
|
|
213
490
|
function rejectLegacyBaaInitFlags(flags, io) {
|
|
214
491
|
const legacyFlag = LEGACY_BAA_INIT_FLAGS.find((flag) => flag in flags);
|
|
215
492
|
if (!legacyFlag)
|
|
@@ -267,7 +544,7 @@ async function validateInitApiKey(apiBaseUrl, apiKey, accountType) {
|
|
|
267
544
|
}
|
|
268
545
|
catch (error) {
|
|
269
546
|
const message = error instanceof Error ? error.message : "Network request failed";
|
|
270
|
-
throw new
|
|
547
|
+
throw new CliTelemetryError(`Could not reach CareCompass authentication API at ${url}. ${message}`, classifyCliFailure(new Error(`Could not reach CareCompass authentication API. ${message}`)));
|
|
271
548
|
}
|
|
272
549
|
const text = await response.text();
|
|
273
550
|
let payload = {};
|
|
@@ -278,27 +555,33 @@ async function validateInitApiKey(apiBaseUrl, apiKey, accountType) {
|
|
|
278
555
|
payload = {};
|
|
279
556
|
}
|
|
280
557
|
if (!response.ok || payload.ok === false) {
|
|
558
|
+
const message = payload.error ?? "Invalid API key.";
|
|
559
|
+
const failure = classifyAuthFailure(response.status, message);
|
|
281
560
|
return {
|
|
282
561
|
ok: false,
|
|
283
|
-
message
|
|
562
|
+
message,
|
|
563
|
+
...failure,
|
|
284
564
|
};
|
|
285
565
|
}
|
|
286
566
|
const validatedType = typeof payload.accountType === "string"
|
|
287
567
|
? normalizeAccountType(payload.accountType)
|
|
288
568
|
: null;
|
|
289
569
|
if (!validatedType) {
|
|
570
|
+
const message = "Credential validation succeeded but did not return a recognized account type. Contact support@nexalink.care.";
|
|
290
571
|
return {
|
|
291
572
|
ok: false,
|
|
292
|
-
message
|
|
573
|
+
message,
|
|
574
|
+
...classifyAuthFailure(401, message),
|
|
293
575
|
};
|
|
294
576
|
}
|
|
295
577
|
return {
|
|
296
578
|
ok: true,
|
|
297
579
|
accountType: validatedType,
|
|
580
|
+
baaStatus: normalizeBaaStatus(payload.baaStatus),
|
|
298
581
|
setup: normalizeAuthSetup(payload.setup),
|
|
299
582
|
};
|
|
300
583
|
}
|
|
301
|
-
async function resolveInitApiKey(flags, io, apiBaseUrl) {
|
|
584
|
+
async function resolveInitApiKey(flags, io, apiBaseUrl, onAuthChecked = async () => undefined) {
|
|
302
585
|
const credentialsPath = resolveCredentialsPath(io.env);
|
|
303
586
|
let credentials = resolveApiKey(flagString(flags, "api-key"), io.env, credentialsPath);
|
|
304
587
|
const urls = signupUrls(flags, io);
|
|
@@ -316,6 +599,7 @@ async function resolveInitApiKey(flags, io, apiBaseUrl) {
|
|
|
316
599
|
const apiKey = useResolvedCredential
|
|
317
600
|
? credentials.apiKey
|
|
318
601
|
: await promptForSecret(io, "Enter API key: ");
|
|
602
|
+
const authTelemetry = authTelemetryForSource(credentials.source);
|
|
319
603
|
useResolvedCredential = false;
|
|
320
604
|
credentials = { apiKey: null, source: "missing" };
|
|
321
605
|
const normalizedApiKey = normalizeApiKey(apiKey);
|
|
@@ -324,9 +608,32 @@ async function resolveInitApiKey(flags, io, apiBaseUrl) {
|
|
|
324
608
|
continue;
|
|
325
609
|
}
|
|
326
610
|
io.stdout.write("Validating credentials...\n");
|
|
327
|
-
|
|
611
|
+
let validation;
|
|
612
|
+
try {
|
|
613
|
+
validation = await validateInitApiKey(apiBaseUrl, normalizedApiKey, accountType);
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
const failure = classifyCliFailure(error);
|
|
617
|
+
await onAuthChecked({
|
|
618
|
+
authResult: "failure",
|
|
619
|
+
baaStatus: "signed",
|
|
620
|
+
keySource: authTelemetry.keySource,
|
|
621
|
+
...failure,
|
|
622
|
+
});
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
328
625
|
if (!validation.ok) {
|
|
626
|
+
await onAuthChecked({
|
|
627
|
+
authResult: "failure",
|
|
628
|
+
baaStatus: validation.baaStatus,
|
|
629
|
+
keySource: authTelemetry.keySource,
|
|
630
|
+
errorCode: validation.errorCode,
|
|
631
|
+
failureCategory: validation.failureCategory,
|
|
632
|
+
});
|
|
329
633
|
io.stdout.write(`${validation.message}\n`);
|
|
634
|
+
const rateLimitError = recordUnauthenticatedAuthFailure(io);
|
|
635
|
+
if (rateLimitError)
|
|
636
|
+
throw rateLimitError;
|
|
330
637
|
continue;
|
|
331
638
|
}
|
|
332
639
|
if (validation.accountType !== accountType) {
|
|
@@ -338,9 +645,16 @@ async function resolveInitApiKey(flags, io, apiBaseUrl) {
|
|
|
338
645
|
continue;
|
|
339
646
|
}
|
|
340
647
|
writeStoredApiKey(normalizedApiKey, credentialsPath);
|
|
648
|
+
resetUnauthenticatedAuthFailures(io);
|
|
341
649
|
io.stdout.write(`Stored CareCompass credentials at ${credentialsPath}\n`);
|
|
342
650
|
io.stdout.write("Sign-in successful.\n");
|
|
343
|
-
return {
|
|
651
|
+
return {
|
|
652
|
+
apiKey: normalizedApiKey,
|
|
653
|
+
authMethod: authTelemetry.authMethod,
|
|
654
|
+
baaStatus: validation.baaStatus,
|
|
655
|
+
keySource: authTelemetry.keySource,
|
|
656
|
+
setup: validation.setup,
|
|
657
|
+
};
|
|
344
658
|
}
|
|
345
659
|
}
|
|
346
660
|
function resolvePromptFile(config, configPath) {
|
|
@@ -349,8 +663,23 @@ function resolvePromptFile(config, configPath) {
|
|
|
349
663
|
return config;
|
|
350
664
|
// Prompt files are resolved relative to the config file so generated kits work
|
|
351
665
|
// after the caller changes directories.
|
|
352
|
-
|
|
353
|
-
|
|
666
|
+
if (isAbsolute(promptFile)) {
|
|
667
|
+
throw new Error("Prompt file must stay inside the config directory.");
|
|
668
|
+
}
|
|
669
|
+
const configDir = realpathSync(resolve(dirname(configPath)));
|
|
670
|
+
const resolvedPath = resolve(configDir, promptFile);
|
|
671
|
+
let realPromptPath;
|
|
672
|
+
try {
|
|
673
|
+
realPromptPath = realpathSync(resolvedPath);
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
throw new Error(`CareCompass prompt file was not found: ${promptFile}`);
|
|
677
|
+
}
|
|
678
|
+
const relativePromptPath = relative(configDir, realPromptPath);
|
|
679
|
+
if (relativePromptPath.startsWith("..") || isAbsolute(relativePromptPath)) {
|
|
680
|
+
throw new Error("Prompt file must stay inside the config directory.");
|
|
681
|
+
}
|
|
682
|
+
const systemInstruction = readFileSync(realPromptPath, "utf8").trim();
|
|
354
683
|
const validation = validateCareCompassConfig({
|
|
355
684
|
...config,
|
|
356
685
|
prompt: {
|
|
@@ -562,16 +891,104 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
562
891
|
// Start with anonymous CLI telemetry, then rebuild it with payer context after
|
|
563
892
|
// a config has been generated or read.
|
|
564
893
|
let cliTelemetry = createCareCompassCliTelemetry(cliSessionId);
|
|
565
|
-
|
|
894
|
+
const sessionStartedAt = Date.now();
|
|
895
|
+
const rawCommand = argv[0] ?? "help";
|
|
896
|
+
const telemetryCommandName = sanitizeCommandName(rawCommand);
|
|
897
|
+
let commandStartedAt = sessionStartedAt;
|
|
898
|
+
let commandStartedCaptured = false;
|
|
899
|
+
let commandsAttempted = 0;
|
|
900
|
+
let commandsSucceeded = 0;
|
|
901
|
+
let commandsFailed = 0;
|
|
902
|
+
let activeCommand = rawCommand;
|
|
903
|
+
let authCheckedCaptured = false;
|
|
904
|
+
let initStartedCaptured = false;
|
|
905
|
+
let sessionEndedCaptured = false;
|
|
906
|
+
const captureAuthChecked = async (telemetry) => {
|
|
907
|
+
if (authCheckedCaptured)
|
|
908
|
+
return;
|
|
909
|
+
authCheckedCaptured = true;
|
|
910
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_AUTH_CHECKED, {
|
|
911
|
+
authResult: telemetry.authResult,
|
|
912
|
+
failureCategory: telemetry.authResult === "success"
|
|
913
|
+
? null
|
|
914
|
+
: (telemetry.failureCategory ?? "unknown"),
|
|
915
|
+
...(telemetry.errorCode ? { errorCode: telemetry.errorCode } : {}),
|
|
916
|
+
baaStatus: telemetry.baaStatus,
|
|
917
|
+
keySource: telemetry.keySource,
|
|
918
|
+
});
|
|
919
|
+
};
|
|
920
|
+
const captureCommandStarted = async () => {
|
|
921
|
+
if (commandStartedCaptured)
|
|
922
|
+
return;
|
|
923
|
+
commandStartedCaptured = true;
|
|
924
|
+
commandStartedAt = Date.now();
|
|
925
|
+
commandsAttempted += 1;
|
|
926
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_STARTED, {
|
|
927
|
+
commandName: telemetryCommandName,
|
|
928
|
+
commandArgsCount: Math.max(argv.length - 1, 0),
|
|
929
|
+
});
|
|
930
|
+
};
|
|
931
|
+
const captureCommandCompleted = async () => {
|
|
932
|
+
await captureCommandStarted();
|
|
933
|
+
commandsSucceeded += 1;
|
|
934
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_COMPLETED, {
|
|
935
|
+
commandName: telemetryCommandName,
|
|
936
|
+
commandDurationMs: Date.now() - commandStartedAt,
|
|
937
|
+
tokensUsed: null,
|
|
938
|
+
modelId: null,
|
|
939
|
+
});
|
|
940
|
+
};
|
|
941
|
+
const authenticateConfigCommand = async (flags) => {
|
|
942
|
+
let credentials;
|
|
943
|
+
try {
|
|
944
|
+
credentials = requireApiKey(flags, io);
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
const failure = classifyCliFailure(error);
|
|
948
|
+
await captureAuthChecked({
|
|
949
|
+
authResult: "failure",
|
|
950
|
+
baaStatus: "signed",
|
|
951
|
+
keySource: "cli_flag",
|
|
952
|
+
errorCode: failure.errorCode,
|
|
953
|
+
failureCategory: failure.failureCategory,
|
|
954
|
+
});
|
|
955
|
+
const rateLimitError = recordUnauthenticatedAuthFailure(io);
|
|
956
|
+
throw rateLimitError ?? error;
|
|
957
|
+
}
|
|
958
|
+
const config = readConfig(flagString(flags, "config") ?? DEFAULT_CONFIG_PATH);
|
|
959
|
+
resetUnauthenticatedAuthFailures(io);
|
|
960
|
+
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
961
|
+
await captureAuthChecked({
|
|
962
|
+
authResult: "success",
|
|
963
|
+
baaStatus: "not_required",
|
|
964
|
+
keySource: authTelemetryForSource(credentials.source).keySource,
|
|
965
|
+
failureCategory: null,
|
|
966
|
+
});
|
|
967
|
+
return { config, credentials };
|
|
968
|
+
};
|
|
969
|
+
const captureSessionEnded = async (exitReason) => {
|
|
970
|
+
if (sessionEndedCaptured)
|
|
971
|
+
return;
|
|
972
|
+
sessionEndedCaptured = true;
|
|
973
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_SESSION_ENDED, {
|
|
974
|
+
sessionDurationMs: Date.now() - sessionStartedAt,
|
|
975
|
+
commandsAttempted,
|
|
976
|
+
commandsSucceeded,
|
|
977
|
+
commandsFailed,
|
|
978
|
+
exitReason,
|
|
979
|
+
});
|
|
980
|
+
};
|
|
566
981
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_STARTED);
|
|
567
982
|
try {
|
|
568
983
|
const { command, flags, positionals } = parseArgs(argv);
|
|
569
984
|
activeCommand = command;
|
|
570
985
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
571
986
|
printHelp(io);
|
|
987
|
+
await captureCommandCompleted();
|
|
572
988
|
return 0;
|
|
573
989
|
}
|
|
574
990
|
if (command === "login") {
|
|
991
|
+
await captureCommandStarted();
|
|
575
992
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_LOGIN_STARTED);
|
|
576
993
|
const apiKey = flagString(flags, "api-key") ?? positionals[0];
|
|
577
994
|
if (!apiKey) {
|
|
@@ -581,20 +998,33 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
581
998
|
errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTHENTICATION_FAILED,
|
|
582
999
|
failureCategory: "authentication",
|
|
583
1000
|
});
|
|
1001
|
+
commandsFailed += 1;
|
|
584
1002
|
return 1;
|
|
585
1003
|
}
|
|
586
1004
|
const credentialsPath = resolveCredentialsPath(io.env);
|
|
587
1005
|
writeStoredApiKey(apiKey, credentialsPath);
|
|
588
1006
|
io.stdout.write(`Stored CareCompass credentials at ${credentialsPath}\n`);
|
|
589
1007
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_LOGIN_SUCCEEDED);
|
|
1008
|
+
await captureCommandCompleted();
|
|
590
1009
|
return 0;
|
|
591
1010
|
}
|
|
592
1011
|
if (command === "init") {
|
|
593
|
-
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_STARTED);
|
|
594
1012
|
rejectLegacyBaaInitFlags(flags, io);
|
|
595
1013
|
const outputDir = flagString(flags, "output-dir") ?? DEFAULT_OUTPUT_DIR;
|
|
596
|
-
const apiBaseUrl = flagString(flags, "api-base-url") ?? DEFAULT_API_BASE_URL;
|
|
597
|
-
const initAuth = await resolveInitApiKey(flags, io, apiBaseUrl);
|
|
1014
|
+
const apiBaseUrl = resolveTrustedApiBaseUrl(flagString(flags, "api-base-url") ?? DEFAULT_API_BASE_URL);
|
|
1015
|
+
const initAuth = await resolveInitApiKey(flags, io, apiBaseUrl, captureAuthChecked);
|
|
1016
|
+
const config = buildInitConfig(flags, initAuth.setup);
|
|
1017
|
+
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
1018
|
+
await captureAuthChecked({
|
|
1019
|
+
authResult: "success",
|
|
1020
|
+
baaStatus: initAuth.baaStatus,
|
|
1021
|
+
keySource: initAuth.keySource,
|
|
1022
|
+
failureCategory: null,
|
|
1023
|
+
});
|
|
1024
|
+
await captureCommandStarted();
|
|
1025
|
+
const initStartedAt = Date.now();
|
|
1026
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_STARTED);
|
|
1027
|
+
initStartedCaptured = true;
|
|
598
1028
|
const output = flagString(flags, "output") ??
|
|
599
1029
|
join(outputDir, "carecompass.config.json");
|
|
600
1030
|
const snippetOutput = flagString(flags, "snippet-output") ??
|
|
@@ -617,8 +1047,6 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
|
|
|
617
1047
|
}
|
|
618
1048
|
}
|
|
619
1049
|
mkdirSync(outputDir, { recursive: true });
|
|
620
|
-
const config = buildInitConfig(flags, initAuth.setup);
|
|
621
|
-
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
622
1050
|
writeFileSync(output, `${JSON.stringify(config, null, 2)}\n`);
|
|
623
1051
|
writeFileSync(promptOutput, `${DEFAULT_SYSTEM_PROMPT}\n`);
|
|
624
1052
|
const resolvedConfig = readConfig(output);
|
|
@@ -657,33 +1085,41 @@ Step 5: Follow the generated setup guide
|
|
|
657
1085
|
Next command:
|
|
658
1086
|
npx @nexalink/carecompass-sdk validate --config ${output}
|
|
659
1087
|
`);
|
|
1088
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_COMPLETED, {
|
|
1089
|
+
initDurationMs: Date.now() - initStartedAt,
|
|
1090
|
+
authMethod: initAuth.authMethod,
|
|
1091
|
+
baaStatus: initAuth.baaStatus,
|
|
1092
|
+
});
|
|
660
1093
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_SETUP_KIT_GENERATED);
|
|
1094
|
+
await captureCommandCompleted();
|
|
661
1095
|
return 0;
|
|
662
1096
|
}
|
|
663
1097
|
if (command === "validate") {
|
|
664
1098
|
try {
|
|
665
|
-
const credentials =
|
|
666
|
-
|
|
667
|
-
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
1099
|
+
const { config, credentials } = await authenticateConfigCommand(flags);
|
|
1100
|
+
await captureCommandStarted();
|
|
668
1101
|
if (flags["local-only"] !== true) {
|
|
669
1102
|
await assertHostedConfig(config, credentials.apiKey);
|
|
670
1103
|
}
|
|
671
1104
|
io.stdout.write(`CareCompass config is valid for ${config.payerId}. Credentials loaded from ${credentials.source}.\n`);
|
|
672
1105
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_VALIDATION_SUCCEEDED);
|
|
1106
|
+
await captureCommandCompleted();
|
|
673
1107
|
return 0;
|
|
674
1108
|
}
|
|
675
1109
|
catch (error) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1110
|
+
const failure = classifyCliFailure(error);
|
|
1111
|
+
if (commandStartedCaptured) {
|
|
1112
|
+
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_VALIDATION_FAILED, {
|
|
1113
|
+
errorCode: failure.errorCode,
|
|
1114
|
+
failureCategory: failure.failureCategory,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
680
1117
|
throw error;
|
|
681
1118
|
}
|
|
682
1119
|
}
|
|
683
1120
|
if (command === "snippet") {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
1121
|
+
const { config } = await authenticateConfigCommand(flags);
|
|
1122
|
+
await captureCommandStarted();
|
|
687
1123
|
const snippet = generateCareCompassSnippet(runtimeConfig(config), {
|
|
688
1124
|
mode: snippetMode(flags),
|
|
689
1125
|
});
|
|
@@ -697,12 +1133,12 @@ Next command:
|
|
|
697
1133
|
io.stdout.write(`${snippet}\n`);
|
|
698
1134
|
}
|
|
699
1135
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_SNIPPET_GENERATED);
|
|
1136
|
+
await captureCommandCompleted();
|
|
700
1137
|
return 0;
|
|
701
1138
|
}
|
|
702
1139
|
if (command === "doctor") {
|
|
703
|
-
const credentials =
|
|
704
|
-
|
|
705
|
-
cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
|
|
1140
|
+
const { config, credentials } = await authenticateConfigCommand(flags);
|
|
1141
|
+
await captureCommandStarted();
|
|
706
1142
|
let hostedStatus = "not checked";
|
|
707
1143
|
try {
|
|
708
1144
|
await assertHostedConfig(config, credentials.apiKey);
|
|
@@ -723,24 +1159,35 @@ Hosted API: ${hostedStatus}
|
|
|
723
1159
|
Status: ready for staging verification
|
|
724
1160
|
`);
|
|
725
1161
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_DOCTOR_COMPLETED);
|
|
1162
|
+
await captureCommandCompleted();
|
|
726
1163
|
return 0;
|
|
727
1164
|
}
|
|
1165
|
+
await captureCommandStarted();
|
|
728
1166
|
throw new Error(`Unknown command: ${command}`);
|
|
729
1167
|
}
|
|
730
1168
|
catch (error) {
|
|
731
|
-
|
|
1169
|
+
commandsFailed += 1;
|
|
1170
|
+
if (!commandStartedCaptured && commandsAttempted === 0) {
|
|
1171
|
+
commandsAttempted = 1;
|
|
1172
|
+
}
|
|
1173
|
+
const failure = classifyCliFailure(error);
|
|
1174
|
+
if (activeCommand === "init" && initStartedCaptured) {
|
|
732
1175
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_FAILED, {
|
|
733
|
-
errorCode:
|
|
734
|
-
failureCategory:
|
|
1176
|
+
errorCode: failure.errorCode,
|
|
1177
|
+
failureCategory: failure.failureCategory,
|
|
735
1178
|
});
|
|
736
1179
|
}
|
|
737
1180
|
await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_FAILED, {
|
|
738
|
-
errorCode:
|
|
739
|
-
failureCategory:
|
|
1181
|
+
errorCode: failure.errorCode,
|
|
1182
|
+
failureCategory: failure.failureCategory,
|
|
1183
|
+
commandName: telemetryCommandName,
|
|
740
1184
|
});
|
|
741
1185
|
io.stderr.write(`${error.message}\n`);
|
|
742
1186
|
return 1;
|
|
743
1187
|
}
|
|
1188
|
+
finally {
|
|
1189
|
+
await captureSessionEnded(commandsFailed > 0 ? "error" : "user_exit");
|
|
1190
|
+
}
|
|
744
1191
|
}
|
|
745
1192
|
const isMainModule = () => Boolean(process.argv[1]) &&
|
|
746
1193
|
realpathSync(fileURLToPath(import.meta.url)) ===
|