@aixle/insights 0.2.0 → 0.2.2-staging

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.
Files changed (50) hide show
  1. package/README.md +38 -9
  2. package/dist/auth/credentials.d.ts +7 -1
  3. package/dist/auth/credentials.js +71 -14
  4. package/dist/auth/exchange.d.ts +1 -1
  5. package/dist/auth/exchange.js +1 -1
  6. package/dist/auth/flow.d.ts +8 -1
  7. package/dist/auth/flow.js +27 -5
  8. package/dist/auth/keycloak.d.ts +1 -1
  9. package/dist/auth/keycloak.js +20 -1
  10. package/dist/cli.d.ts +5 -3
  11. package/dist/cli.js +69 -21
  12. package/dist/collect-cursor-payloads.d.ts +4 -3
  13. package/dist/collect-cursor-payloads.js +8 -5
  14. package/dist/cursor-checkpoints.d.ts +2 -2
  15. package/dist/cursor-payload-contract.d.ts +5 -5
  16. package/dist/cursor-payload-contract.js +6 -0
  17. package/dist/cursor-settings.d.ts +9 -4
  18. package/dist/cursor-settings.js +80 -10
  19. package/dist/health.d.ts +3 -1
  20. package/dist/health.js +13 -1
  21. package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
  22. package/dist/hooks/cursor-hooks-mapper.js +1 -1
  23. package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
  24. package/dist/hooks/cursor-hooks-reader.js +10 -3
  25. package/dist/install/cursor.d.ts +34 -0
  26. package/dist/install/cursor.js +193 -0
  27. package/dist/install/index.d.ts +6 -4
  28. package/dist/install/index.js +6 -1
  29. package/dist/lib/client.d.ts +7 -0
  30. package/dist/lib/client.js +17 -0
  31. package/dist/lib/config.js +7 -2
  32. package/dist/lib/project-resolver.d.ts +5 -4
  33. package/dist/lib/project-resolver.js +57 -11
  34. package/dist/lib/repo-path-safety.d.ts +35 -0
  35. package/dist/lib/repo-path-safety.js +102 -0
  36. package/dist/lib/spawn-arg-safety.d.ts +25 -0
  37. package/dist/lib/spawn-arg-safety.js +49 -0
  38. package/dist/lib/transport-security.d.ts +1 -0
  39. package/dist/lib/transport-security.js +1 -1
  40. package/dist/readers/claude.d.ts +54 -6
  41. package/dist/readers/claude.js +154 -2
  42. package/dist/readers/cursor.d.ts +10 -7
  43. package/dist/readers/cursor.js +113 -17
  44. package/dist/risk-scanner.js +7 -0
  45. package/dist/server.d.ts +20 -3
  46. package/dist/server.js +101 -67
  47. package/dist/state.js +7 -2
  48. package/dist/sync.d.ts +13 -2
  49. package/dist/sync.js +86 -58
  50. package/package.json +6 -2
@@ -74,7 +74,10 @@ export function probeCursorGlobalStateDb(verbose = false, baseDir) {
74
74
  export function findCursorDbs(baseDir) {
75
75
  const dir = join(baseDir ?? cursorUserDir(), "workspaceStorage");
76
76
  try {
77
- return glob.sync(join(dir, "**", "cursor.db"));
77
+ // Pattern stays forward-slash and the directory goes through `cwd`. Building
78
+ // it with `join` emitted `\` on Windows, and glob treats `\` as an escape
79
+ // character on every platform, so this silently matched nothing there.
80
+ return glob.sync("**/cursor.db", { cwd: dir, absolute: true });
78
81
  }
79
82
  catch {
80
83
  return [];
@@ -244,7 +247,14 @@ export function findStateVscDbs(baseDir) {
244
247
  const results = [];
245
248
  results.push(join(userDir, "globalStorage", "state.vscdb"));
246
249
  try {
247
- results.push(...glob.sync(join(userDir, "workspaceStorage", "**", "state.vscdb")));
250
+ // Forward-slash pattern + `cwd`, as above. This one mattered most: the
251
+ // globalStorage path is pushed unconditionally, so on Windows the function
252
+ // still returned a result while silently dropping every per-workspace
253
+ // state.vscdb — partial data loss that looked like working software.
254
+ results.push(...glob.sync("workspaceStorage/**/state.vscdb", {
255
+ cwd: userDir,
256
+ absolute: true,
257
+ }));
248
258
  }
249
259
  catch {
250
260
  /* ignore */
@@ -447,6 +457,62 @@ function toIsoFromMs(value) {
447
457
  const date = new Date(value);
448
458
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
449
459
  }
460
+ /** Coerce epoch ms/seconds, numeric strings, or ISO-8601 into an ISO timestamp. */
461
+ function coerceTranscriptTimestamp(value) {
462
+ if (value == null)
463
+ return null;
464
+ if (typeof value === "number")
465
+ return toIsoFromMs(value);
466
+ if (typeof value !== "string")
467
+ return null;
468
+ const trimmed = value.trim();
469
+ if (!trimmed)
470
+ return null;
471
+ const fromEpoch = toIsoString(trimmed);
472
+ if (fromEpoch)
473
+ return fromEpoch;
474
+ const ms = Date.parse(trimmed);
475
+ if (Number.isNaN(ms))
476
+ return null;
477
+ return new Date(ms).toISOString();
478
+ }
479
+ /** Prefer explicit per-line / per-message times when Cursor includes them. */
480
+ function extractLineOccurredAt(entry) {
481
+ const candidates = [
482
+ entry.timestamp,
483
+ entry.createdAt,
484
+ entry.unixMs,
485
+ entry.message?.timestamp,
486
+ entry.message?.createdAt,
487
+ ];
488
+ for (const candidate of candidates) {
489
+ const iso = coerceTranscriptTimestamp(candidate);
490
+ if (iso)
491
+ return iso;
492
+ }
493
+ return null;
494
+ }
495
+ /**
496
+ * Spread turns across [start, end] when the JSONL has no per-message times.
497
+ * Prevents first-sync backfill from collapsing every turn onto lastUpdatedAt/mtime
498
+ * (DB90DV-605 weekly chart spike).
499
+ */
500
+ function interpolateTurnOccurredAt(startIso, endIso, index, total, fallbackIso) {
501
+ if (total <= 0)
502
+ return fallbackIso;
503
+ // Single turn: prefer session end (lastUpdatedAt) — matches prior composer-header behavior.
504
+ if (total === 1)
505
+ return endIso ?? startIso ?? fallbackIso;
506
+ const startMs = startIso ? Date.parse(startIso) : NaN;
507
+ const endMs = endIso ? Date.parse(endIso) : NaN;
508
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
509
+ return startIso ?? endIso ?? fallbackIso;
510
+ }
511
+ if (endMs <= startMs)
512
+ return startIso ?? fallbackIso;
513
+ const ms = startMs + ((endMs - startMs) * index) / (total - 1);
514
+ return new Date(ms).toISOString();
515
+ }
450
516
  function readComposerHeaders(baseDir) {
451
517
  const userDir = baseDir ?? cursorUserDir();
452
518
  const dbPath = join(userDir, "globalStorage", "state.vscdb");
@@ -491,6 +557,7 @@ function readComposerHeaders(baseDir) {
491
557
  composerId,
492
558
  name: typeof composer.name === "string" ? composer.name : null,
493
559
  workspacePath: typeof uri?.fsPath === "string" ? uri.fsPath : null,
560
+ createdAt: toIsoFromMs(composer.createdAt),
494
561
  lastUpdatedAt: toIsoFromMs(composer.lastUpdatedAt),
495
562
  });
496
563
  }
@@ -563,11 +630,15 @@ function workspaceFromTranscriptFile(filePath) {
563
630
  const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB
564
631
  export async function parseCursorTranscriptFile(filePath, composerHeaders, verbose = false) {
565
632
  let fileSize = 0;
566
- let occurredAt = new Date().toISOString();
633
+ let fileMtimeIso = new Date().toISOString();
634
+ let fileBirthIso = null;
567
635
  try {
568
636
  const stat = statSync(filePath);
569
637
  fileSize = stat.size;
570
- occurredAt = stat.mtime.toISOString();
638
+ fileMtimeIso = stat.mtime.toISOString();
639
+ if (stat.birthtime && !Number.isNaN(stat.birthtime.getTime()) && stat.birthtime.getTime() > 0) {
640
+ fileBirthIso = stat.birthtime.toISOString();
641
+ }
571
642
  }
572
643
  catch {
573
644
  return [];
@@ -580,15 +651,25 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
580
651
  }
581
652
  const sessionId = basename(filePath, ".jsonl");
582
653
  const header = composerHeaders.get(sessionId);
583
- if (header?.lastUpdatedAt)
584
- occurredAt = header.lastUpdatedAt;
585
- const turns = [];
654
+ const sessionFallback = header?.lastUpdatedAt ?? fileMtimeIso;
655
+ const sessionStart = header?.createdAt ?? fileBirthIso ?? sessionFallback;
656
+ const sessionEnd = header?.lastUpdatedAt ?? fileMtimeIso;
657
+ const drafts = [];
586
658
  let currentPromptParts = [];
587
659
  let currentAssistantParts = [];
660
+ let currentTurnOccurredAt = null;
588
661
  let turnIndex = 0;
589
662
  const hasher = createHash("sha256");
590
663
  const stream = createReadStream(filePath, { encoding: "utf-8" });
591
664
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
665
+ const noteLineTime = (entry) => {
666
+ const lineAt = extractLineOccurredAt(entry);
667
+ if (!lineAt)
668
+ return;
669
+ // Prefer the first timestamp in the turn (usually the user message).
670
+ if (!currentTurnOccurredAt)
671
+ currentTurnOccurredAt = lineAt;
672
+ };
592
673
  const finalizeTurn = () => {
593
674
  const promptText = currentPromptParts.join("\n\n").trim();
594
675
  const assistantText = currentAssistantParts.join("\n\n").trim();
@@ -596,14 +677,14 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
596
677
  return;
597
678
  const risk = scanText(promptText);
598
679
  turnIndex += 1;
599
- turns.push({
680
+ drafts.push({
600
681
  turnId: `${sessionId}:${turnIndex}`,
601
682
  sessionId,
602
683
  filePath,
603
684
  fileSize,
604
685
  workspacePath: header?.workspacePath ?? workspaceFromTranscriptFile(filePath),
605
686
  composerName: header?.name ?? null,
606
- occurredAt,
687
+ turnOccurredAt: currentTurnOccurredAt,
607
688
  promptText,
608
689
  assistantText,
609
690
  tokensIn: estimateTokens(promptText),
@@ -612,6 +693,7 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
612
693
  riskScore: risk.risk_score,
613
694
  riskCategories: risk.risk_categories,
614
695
  });
696
+ currentTurnOccurredAt = null;
615
697
  };
616
698
  let lineNumber = 0;
617
699
  try {
@@ -640,9 +722,11 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
640
722
  currentPromptParts = [];
641
723
  currentAssistantParts = [];
642
724
  }
725
+ noteLineTime(entry);
643
726
  currentPromptParts.push(...texts.map(stripUserQueryWrapper).filter((text) => text.length > 0));
644
727
  }
645
728
  else if (entry.role === "assistant") {
729
+ noteLineTime(entry);
646
730
  currentAssistantParts.push(...texts.map((text) => text.trim()).filter((text) => text.length > 0));
647
731
  }
648
732
  }
@@ -659,9 +743,16 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
659
743
  }
660
744
  finalizeTurn();
661
745
  const contentHash = hasher.digest("hex").slice(0, 32);
662
- for (const t of turns)
663
- t.contentHash = contentHash;
664
- return turns;
746
+ const total = drafts.length;
747
+ return drafts.map((draft, index) => {
748
+ const { turnOccurredAt, ...rest } = draft;
749
+ return {
750
+ ...rest,
751
+ contentHash,
752
+ occurredAt: turnOccurredAt ??
753
+ interpolateTurnOccurredAt(sessionStart, sessionEnd, index, total, sessionFallback),
754
+ };
755
+ });
665
756
  }
666
757
  export async function readCursorTranscriptSessions(cursorUserBaseDir, transcriptProjectDirs, verbose = false) {
667
758
  const composerHeaders = readComposerHeaders(cursorUserBaseDir);
@@ -730,7 +821,7 @@ function dailyStatsSessionId(date, eventType, modelKey) {
730
821
  return `cursor:daily_stats:${date}:${suffix}`;
731
822
  }
732
823
  function buildPayload(opts) {
733
- const { eventType, tokensIn, tokensOut, costUsd, occurredAt, dbPath, date, model = "unknown", modelKey, projectId, costModel = LINE_COST_MODEL, } = opts;
824
+ const { eventType, tokensIn, tokensOut, costUsd, occurredAt, dbPath, date, model = "unknown", modelResolution, modelKey, projectId, costModel = LINE_COST_MODEL, } = opts;
734
825
  const payload = {
735
826
  tool_name: "cursor",
736
827
  event_type: eventType,
@@ -746,13 +837,14 @@ function buildPayload(opts) {
746
837
  cost_model: costModel,
747
838
  scannable: false,
748
839
  risk_level: "none",
840
+ ...(modelResolution !== undefined ? { model_resolution: modelResolution } : {}),
749
841
  },
750
842
  };
751
843
  if (projectId)
752
844
  payload.project_id = projectId;
753
845
  return payload;
754
846
  }
755
- export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING, model) {
847
+ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING, model, modelResolution) {
756
848
  const { date, value, dbPath } = entry;
757
849
  const occurredAt = `${date}T00:00:00.000Z`;
758
850
  const results = [];
@@ -773,6 +865,7 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
773
865
  dbPath,
774
866
  date,
775
867
  model,
868
+ modelResolution,
776
869
  projectId,
777
870
  }));
778
871
  }
@@ -781,11 +874,12 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
781
874
  eventType: "chat",
782
875
  tokensIn: composerSuggested,
783
876
  tokensOut: composerAccepted,
784
- costUsd: computeLineCost("chat", composerAccepted, pricing),
877
+ costUsd: computeLineCost("chat", composerSuggested, pricing),
785
878
  occurredAt,
786
879
  dbPath,
787
880
  date,
788
881
  model,
882
+ modelResolution,
789
883
  projectId,
790
884
  }));
791
885
  }
@@ -820,7 +914,7 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
820
914
  * Cursor only keeps one recent commit row (overwritten on each new commit).
821
915
  * Line-cost math still follows the chat-style line proxy (`computeLineCost("chat", …)`); only `event_type` differs.
822
916
  */
823
- export function mapRecentCommit(entry, projectId, pricing = DEFAULT_CURSOR_PRICING, model) {
917
+ export function mapRecentCommit(entry, projectId, pricing = DEFAULT_CURSOR_PRICING, model, modelResolution) {
824
918
  const { value: obj, dbPath } = entry;
825
919
  const occurredAt = toIsoString(obj.timestamp);
826
920
  if (!occurredAt)
@@ -866,6 +960,7 @@ export function mapRecentCommit(entry, projectId, pricing = DEFAULT_CURSOR_PRICI
866
960
  : undefined,
867
961
  scannable: false,
868
962
  risk_level: "none",
963
+ ...(modelResolution !== undefined ? { model_resolution: modelResolution } : {}),
869
964
  },
870
965
  };
871
966
  if (projectId)
@@ -902,7 +997,7 @@ export function mapEvent(row, workspacePath, projectId, pricing = DEFAULT_CURSOR
902
997
  payload.project_id = projectId;
903
998
  return payload;
904
999
  }
905
- export function mapTranscriptTurn(turn, projectId, pricing = DEFAULT_CURSOR_PRICING, model) {
1000
+ export function mapTranscriptTurn(turn, projectId, pricing = DEFAULT_CURSOR_PRICING, model, modelResolution) {
906
1001
  const payload = {
907
1002
  tool_name: "cursor",
908
1003
  event_type: "chat",
@@ -924,6 +1019,7 @@ export function mapTranscriptTurn(turn, projectId, pricing = DEFAULT_CURSOR_PRIC
924
1019
  composer_name: turn.composerName ?? undefined,
925
1020
  prompt_text: turn.promptText || undefined,
926
1021
  assistant_text: turn.assistantText || undefined,
1022
+ ...(modelResolution !== undefined ? { model_resolution: modelResolution } : {}),
927
1023
  },
928
1024
  };
929
1025
  if (projectId)
@@ -12,6 +12,10 @@ const CATEGORIES = {
12
12
  weight: 3,
13
13
  patterns: [
14
14
  /\b\d{3}-\d{2}-\d{4}\b/g, // SSN
15
+ /* eslint-disable-next-line security/detect-unsafe-regex -- Flagged for
16
+ `{3}` inside `(?:…)?`. Every branch is a fixed-length digit run
17
+ anchored by \b, so the match is bounded and cannot backtrack
18
+ super-linearly. */
15
19
  /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b/g, // Credit card
16
20
  ],
17
21
  },
@@ -19,6 +23,9 @@ const CATEGORIES = {
19
23
  weight: 1,
20
24
  patterns: [
21
25
  /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, // Email
26
+ /* eslint-disable-next-line security/detect-unsafe-regex -- Flagged for
27
+ `?` nested in `?`. All quantified groups are fixed-length digit or
28
+ separator classes anchored by \b; matching is bounded. */
22
29
  /\b(?:\+?1[-.\s]?)?(?:\([0-9]{3}\)|[0-9]{3})[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g, // Phone
23
30
  ],
24
31
  },
package/dist/server.d.ts CHANGED
@@ -7,8 +7,25 @@ export declare const SYNC_NOW_INPUT_SCHEMA: z.ZodObject<{
7
7
  cursor: "cursor";
8
8
  }>>>;
9
9
  }, z.core.$strict>;
10
- /** Structured status for `db90_status` — tolerates missing/malformed credentials and state. */
11
- export declare function buildDb90StatusPayload(): Promise<Record<string, unknown>>;
10
+ /**
11
+ * Whether a `credential_validation_failed` warning should mirror to stderr.
12
+ * Mirrors for one-shot/startup sources so an installed-but-uninitialized MCP is
13
+ * visible, but NOT for the recurring background `"interval"` tick — that fires
14
+ * every SYNC_INTERVAL_MS for the whole process lifetime and would spam the logs.
15
+ * The event is always written to mcp.log regardless of this return value.
16
+ */
17
+ export declare function shouldMirrorMissingCredentials(source: string): boolean;
18
+ /** Structured status for `aixle_insights_status` — tolerates missing/malformed credentials and state. */
19
+ export declare function buildAixleInsightsStatusPayload(): Promise<Record<string, unknown>>;
12
20
  /** In-process MCP server instance (stdio not attached). */
13
- export declare function createDb90McpServer(): McpServer;
21
+ export declare function createAixleInsightsMcpServer(): McpServer;
22
+ /**
23
+ * Subscribes `shutdown` to the given stream's "end" and "close" events.
24
+ * Closing stdin is how the OS signals a stdio-transport MCP server that its
25
+ * parent process is gone — this fires even when the parent can't deliver a
26
+ * signal (e.g. it was itself SIGKILL'd, or the OS reparented this process
27
+ * without ever sending one). Exported standalone so it's testable with a
28
+ * plain EventEmitter instead of the real process.stdin.
29
+ */
30
+ export declare function wireParentExitShutdown(stdin: Pick<NodeJS.ReadStream, "on">, shutdown: () => void): void;
14
31
  export declare function startServer(): Promise<void>;
package/dist/server.js CHANGED
@@ -27,6 +27,12 @@ export const SYNC_NOW_INPUT_SCHEMA = z
27
27
  }
28
28
  })
29
29
  .strict();
30
+ const AUTHENTICATE_INPUT_SCHEMA = z.object({
31
+ keycloakUrl: z.string().optional(),
32
+ clientId: z.string().optional(),
33
+ });
34
+ /** DB90DV-569: `db90_*` names are deprecated aliases kept for one release for existing callers. */
35
+ const DEPRECATED_ALIAS_NOTE = "(Deprecated — use `{name}` instead; kept temporarily for backward compatibility, see DB90DV-569.) ";
30
36
  function jsonContent(value) {
31
37
  return {
32
38
  content: [
@@ -56,6 +62,16 @@ function migrateAllLegacyState(creds) {
56
62
  function syncResultOk(result) {
57
63
  return !result.locked && result.failed === 0;
58
64
  }
65
+ /**
66
+ * Whether a `credential_validation_failed` warning should mirror to stderr.
67
+ * Mirrors for one-shot/startup sources so an installed-but-uninitialized MCP is
68
+ * visible, but NOT for the recurring background `"interval"` tick — that fires
69
+ * every SYNC_INTERVAL_MS for the whole process lifetime and would spam the logs.
70
+ * The event is always written to mcp.log regardless of this return value.
71
+ */
72
+ export function shouldMirrorMissingCredentials(source) {
73
+ return source !== "interval";
74
+ }
59
75
  // Process-lifetime cache keyed on the inputs that drive resolveProjectId: host,
60
76
  // lookup token, and the current repo's git remote. Re-resolve when any of them
61
77
  // changes (re-auth, repo cwd change). `source: "none"` is never cached so a
@@ -70,22 +86,22 @@ async function getProjectResolutionForSync(creds) {
70
86
  if (cachedProjectResolution?.key === cacheKey) {
71
87
  return cachedProjectResolution.value;
72
88
  }
73
- const result = await resolveProjectId(undefined, undefined, creds.host, token, false);
89
+ const result = await resolveProjectId(undefined, undefined, creds.host, token, false, creds.insecureHttpAllowed === true);
74
90
  mcpLog.info("project_attribution_resolved", { project_id: result.projectId, source: result.source }, false);
75
91
  if (result.source !== "none") {
76
92
  cachedProjectResolution = { key: cacheKey, value: result };
77
93
  }
78
94
  return result;
79
95
  }
80
- /** Structured status for `db90_status` — tolerates missing/malformed credentials and state. */
81
- export async function buildDb90StatusPayload() {
96
+ /** Structured status for `aixle_insights_status` — tolerates missing/malformed credentials and state. */
97
+ export async function buildAixleInsightsStatusPayload() {
82
98
  const snapshot = await buildHealthSnapshot();
83
99
  return healthSnapshotToStatusPayload(snapshot);
84
100
  }
85
101
  async function executeSync(parsed) {
86
102
  const creds = await loadCredentials();
87
103
  if (!creds || !credentialsHaveAnyToken(creds)) {
88
- mcpLog.warn("credential_validation_failed", { source: "db90_sync_now", reason: "missing_credentials" }, false);
104
+ mcpLog.warn("credential_validation_failed", { source: "aixle_insights_sync_now", reason: "missing_credentials" }, shouldMirrorMissingCredentials("aixle_insights_sync_now"));
89
105
  return { ok: false, error: "missing_credentials" };
90
106
  }
91
107
  migrateAllLegacyState(creds);
@@ -104,79 +120,96 @@ async function executeSync(parsed) {
104
120
  });
105
121
  return { ok: syncResultOk(result), result };
106
122
  }
107
- /** In-process MCP server instance (stdio not attached). */
108
- export function createDb90McpServer() {
109
- const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
110
- server.registerTool("db90_status", {
111
- description: "Returns Aixle Insights MCP connectivity and last sync metadata from disk (credentials + state). No arguments.",
112
- }, async () => jsonContent(await buildDb90StatusPayload()));
113
- server.registerTool("db90_sync_now", {
114
- description: "Runs one DB90 ingest sync cycle for enabled tools immediately (matches background cadence). " +
115
- "Optional `tools` subset filter: omit to sync every tool credential you have authenticated (Claude transcripts + Cursor telemetry).",
116
- inputSchema: SYNC_NOW_INPUT_SCHEMA,
117
- }, async (input) => {
118
- try {
119
- const parsed = SYNC_NOW_INPUT_SCHEMA.parse(input ?? {});
120
- return jsonContent(await executeSync(parsed));
121
- }
122
- catch (err) {
123
- if (err instanceof z.ZodError) {
124
- return jsonContent({
125
- ok: false,
126
- error: "validation_error",
127
- details: err.flatten(),
128
- });
129
- }
123
+ async function statusHandler() {
124
+ return jsonContent(await buildAixleInsightsStatusPayload());
125
+ }
126
+ async function syncNowHandler(input) {
127
+ try {
128
+ const parsed = SYNC_NOW_INPUT_SCHEMA.parse(input ?? {});
129
+ return jsonContent(await executeSync(parsed));
130
+ }
131
+ catch (err) {
132
+ if (err instanceof z.ZodError) {
130
133
  return jsonContent({
131
134
  ok: false,
132
- error: err instanceof Error ? err.message : String(err),
133
- });
134
- }
135
- });
136
- server.registerTool("db90_authenticate", {
137
- description: "Starts Keycloak device login and returns the visit URL/code for the user. Use aixle-insights init for the full terminal flow that saves credentials.",
138
- inputSchema: z.object({
139
- keycloakUrl: z.string().optional(),
140
- clientId: z.string().optional(),
141
- }),
142
- }, async (input) => {
143
- try {
144
- const args = input;
145
- const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer()).trim();
146
- if (!kc) {
147
- return jsonContent({
148
- ok: false,
149
- error: "keycloakUrl or KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER is required",
150
- });
151
- }
152
- const clientId = args.clientId?.trim() || defaultKeycloakClientId();
153
- const device = await startDeviceAuthorization({
154
- issuer: kc,
155
- clientId,
156
- });
157
- return jsonContent({
158
- ok: true,
159
- verificationUri: device.verification_uri,
160
- verificationUriComplete: device.verification_uri_complete ?? null,
161
- userCode: device.user_code,
162
- expiresIn: device.expires_in,
163
- interval: device.interval ?? 5,
164
- issuer: kc,
165
- clientId,
166
- message: `Visit ${device.verification_uri} and enter code ${device.user_code}`,
135
+ error: "validation_error",
136
+ details: err.flatten(),
167
137
  });
168
138
  }
169
- catch (err) {
139
+ return jsonContent({
140
+ ok: false,
141
+ error: err instanceof Error ? err.message : String(err),
142
+ });
143
+ }
144
+ }
145
+ async function authenticateHandler(args) {
146
+ try {
147
+ const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer()).trim();
148
+ if (!kc) {
170
149
  return jsonContent({
171
150
  ok: false,
172
- error: err instanceof Error ? err.message : String(err),
151
+ error: "keycloakUrl or KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER is required",
173
152
  });
174
153
  }
175
- });
154
+ const clientId = args.clientId?.trim() || defaultKeycloakClientId();
155
+ const device = await startDeviceAuthorization({
156
+ issuer: kc,
157
+ clientId,
158
+ });
159
+ return jsonContent({
160
+ ok: true,
161
+ verificationUri: device.verification_uri,
162
+ verificationUriComplete: device.verification_uri_complete ?? null,
163
+ userCode: device.user_code,
164
+ expiresIn: device.expires_in,
165
+ interval: device.interval ?? 5,
166
+ issuer: kc,
167
+ clientId,
168
+ message: `Visit ${device.verification_uri} and enter code ${device.user_code}`,
169
+ });
170
+ }
171
+ catch (err) {
172
+ return jsonContent({
173
+ ok: false,
174
+ error: err instanceof Error ? err.message : String(err),
175
+ });
176
+ }
177
+ }
178
+ /** In-process MCP server instance (stdio not attached). */
179
+ export function createAixleInsightsMcpServer() {
180
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
181
+ const statusDescription = "Returns Aixle Insights MCP connectivity and last sync metadata from disk (credentials + state). No arguments.";
182
+ server.registerTool("aixle_insights_status", { description: statusDescription }, statusHandler);
183
+ server.registerTool("db90_status", { description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_status") + statusDescription }, statusHandler);
184
+ const syncNowDescription = "Runs one DB90 ingest sync cycle for enabled tools immediately (matches background cadence). " +
185
+ "Optional `tools` subset filter: omit to sync every tool credential you have authenticated (Claude transcripts + Cursor telemetry).";
186
+ server.registerTool("aixle_insights_sync_now", { description: syncNowDescription, inputSchema: SYNC_NOW_INPUT_SCHEMA }, syncNowHandler);
187
+ server.registerTool("db90_sync_now", {
188
+ description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_sync_now") + syncNowDescription,
189
+ inputSchema: SYNC_NOW_INPUT_SCHEMA,
190
+ }, syncNowHandler);
191
+ const authenticateDescription = "Starts Keycloak device login and returns the visit URL/code for the user. Use aixle-insights init for the full terminal flow that saves credentials.";
192
+ server.registerTool("aixle_insights_authenticate", { description: authenticateDescription, inputSchema: AUTHENTICATE_INPUT_SCHEMA }, authenticateHandler);
193
+ server.registerTool("db90_authenticate", {
194
+ description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_authenticate") + authenticateDescription,
195
+ inputSchema: AUTHENTICATE_INPUT_SCHEMA,
196
+ }, authenticateHandler);
176
197
  return server;
177
198
  }
199
+ /**
200
+ * Subscribes `shutdown` to the given stream's "end" and "close" events.
201
+ * Closing stdin is how the OS signals a stdio-transport MCP server that its
202
+ * parent process is gone — this fires even when the parent can't deliver a
203
+ * signal (e.g. it was itself SIGKILL'd, or the OS reparented this process
204
+ * without ever sending one). Exported standalone so it's testable with a
205
+ * plain EventEmitter instead of the real process.stdin.
206
+ */
207
+ export function wireParentExitShutdown(stdin, shutdown) {
208
+ stdin.on("end", shutdown);
209
+ stdin.on("close", shutdown);
210
+ }
178
211
  export async function startServer() {
179
- const server = createDb90McpServer();
212
+ const server = createAixleInsightsMcpServer();
180
213
  const transport = new StdioServerTransport();
181
214
  await server.connect(transport);
182
215
  let intervalId;
@@ -195,12 +228,13 @@ export async function startServer() {
195
228
  };
196
229
  process.on("SIGINT", onSignal);
197
230
  process.on("SIGTERM", onSignal);
231
+ wireParentExitShutdown(process.stdin, onSignal);
198
232
  const runBackground = async (source) => {
199
233
  if (shuttingDown)
200
234
  return;
201
235
  const creds = await loadCredentials();
202
236
  if (!creds || !credentialsHaveAnyToken(creds)) {
203
- mcpLog.warn("credential_validation_failed", { source, reason: "missing_credentials" }, false);
237
+ mcpLog.warn("credential_validation_failed", { source, reason: "missing_credentials" }, shouldMirrorMissingCredentials(source));
204
238
  return;
205
239
  }
206
240
  try {
package/dist/state.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
+ import { mcpLog } from "./log.js";
5
6
  export function getAppDir() {
6
7
  const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
7
8
  if (override && override.length > 0)
@@ -123,8 +124,12 @@ export function readState(dir, host, token) {
123
124
  }
124
125
  }
125
126
  }
126
- catch {
127
- // missing or malformed state file — start fresh
127
+ catch (err) {
128
+ const code = err?.code;
129
+ if (code !== "ENOENT") {
130
+ // State file exists but failed to parse — distinguishes tampering from "never created".
131
+ mcpLog.warn("state_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
132
+ }
128
133
  }
129
134
  return { version: 1, sessions: {} };
130
135
  }
package/dist/sync.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { TelemetryToolId, StoredCredentials } from "./auth/credentials.js";
2
2
  import { type PricingTable } from "./pricing.js";
3
3
  import type { PricingConfig } from "./readers/cursor.js";
4
4
  import { type ProjectResolution } from "./lib/index.js";
5
- import type { CursorDb90Payload } from "./readers/cursor.js";
5
+ import type { CursorPayload } from "./readers/cursor.js";
6
6
  /** Prefix for Claude Code session keys in shared MCP state. */
7
7
  export declare const CLAUDE_STATE_PREFIX: "claude_code:";
8
8
  export { CURSOR_WATERMARK_KEY, CURSOR_EVENTS_WATERMARK_KEY, CURSOR_DAILY_STATS_WATERMARK_KEY, CURSOR_RECENT_COMMIT_WATERMARK_KEY, CURSOR_TRANSCRIPT_TURN_PREFIX, cursorTranscriptTurnStateKey, filterRecentCommitsByHashDedup, } from "./cursor-checkpoints.js";
@@ -24,6 +24,8 @@ export interface SyncOptions {
24
24
  verbose: boolean;
25
25
  projectId: string | null;
26
26
  projectIdSource?: ProjectResolution["source"];
27
+ /** Mirrors StoredCredentials.insecureHttpAllowed — set when `init --insecure` was used for this host. */
28
+ allowInsecureHttp?: boolean;
27
29
  pricing: PricingTable;
28
30
  appDir?: string;
29
31
  transcriptBaseDirs?: string[];
@@ -65,7 +67,16 @@ export declare function getSyncTelemetry(): {
65
67
  lastResult: SyncResult | null;
66
68
  recentErrors: string[];
67
69
  };
68
- export declare function cursorRepoPathFromPayload(payload: CursorDb90Payload): string | undefined;
70
+ /**
71
+ * The repo path for a Cursor payload, normalized and safe to hand to project
72
+ * resolution. Returns an absolute, `..`-collapsed path or undefined.
73
+ *
74
+ * `workspace_folder` comes from the workspace's own `workspace.json` and
75
+ * `workspace` from a composer `uri.fsPath` or the `state.vscdb` path — all
76
+ * untrusted. When the preferred field is unusable we fall through to the other
77
+ * rather than giving up. See DB90DV-547.
78
+ */
79
+ export declare function cursorRepoPathFromPayload(payload: CursorPayload): string | undefined;
69
80
  /**
70
81
  * Parallel multi-tool cycle under the global advisory ingest lock (`state.lock`).
71
82
  */