@aixle/insights 0.1.0
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/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/auth/credentials.d.ts +23 -0
- package/dist/auth/credentials.js +174 -0
- package/dist/auth/exchange.d.ts +25 -0
- package/dist/auth/exchange.js +87 -0
- package/dist/auth/flow.d.ts +24 -0
- package/dist/auth/flow.js +66 -0
- package/dist/auth/keycloak.d.ts +35 -0
- package/dist/auth/keycloak.js +170 -0
- package/dist/cli.d.ts +51 -0
- package/dist/cli.js +426 -0
- package/dist/client.d.ts +28 -0
- package/dist/client.js +102 -0
- package/dist/collect-cursor-payloads.d.ts +57 -0
- package/dist/collect-cursor-payloads.js +134 -0
- package/dist/credentials.d.ts +2 -0
- package/dist/credentials.js +1 -0
- package/dist/cursor-checkpoints.d.ts +12 -0
- package/dist/cursor-checkpoints.js +28 -0
- package/dist/cursor-config.d.ts +5 -0
- package/dist/cursor-config.js +34 -0
- package/dist/cursor-payload-contract.d.ts +17 -0
- package/dist/cursor-payload-contract.js +258 -0
- package/dist/cursor-settings.d.ts +6 -0
- package/dist/cursor-settings.js +38 -0
- package/dist/cursor-store-audit.d.ts +48 -0
- package/dist/cursor-store-audit.js +155 -0
- package/dist/daily-stats-versions.d.ts +31 -0
- package/dist/daily-stats-versions.js +170 -0
- package/dist/health.d.ts +31 -0
- package/dist/health.js +195 -0
- package/dist/hooks/cursor-hooks-mapper.d.ts +22 -0
- package/dist/hooks/cursor-hooks-mapper.js +84 -0
- package/dist/hooks/cursor-hooks-reader.d.ts +30 -0
- package/dist/hooks/cursor-hooks-reader.js +117 -0
- package/dist/hooks/hook-forwarder.mjs +110 -0
- package/dist/hooks/hooks-config.d.ts +92 -0
- package/dist/hooks/hooks-config.js +235 -0
- package/dist/install/claude.d.ts +37 -0
- package/dist/install/claude.js +144 -0
- package/dist/install/index.d.ts +8 -0
- package/dist/install/index.js +11 -0
- package/dist/lib/args.d.ts +26 -0
- package/dist/lib/args.js +17 -0
- package/dist/lib/client.d.ts +33 -0
- package/dist/lib/client.js +52 -0
- package/dist/lib/config.d.ts +26 -0
- package/dist/lib/config.js +39 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/project-resolver.d.ts +48 -0
- package/dist/lib/project-resolver.js +203 -0
- package/dist/lock.d.ts +9 -0
- package/dist/lock.js +84 -0
- package/dist/log.d.ts +14 -0
- package/dist/log.js +81 -0
- package/dist/pricing.d.ts +40 -0
- package/dist/pricing.js +149 -0
- package/dist/readers/claude.d.ts +83 -0
- package/dist/readers/claude.js +317 -0
- package/dist/readers/cursor.d.ts +134 -0
- package/dist/readers/cursor.js +900 -0
- package/dist/risk-scanner.d.ts +8 -0
- package/dist/risk-scanner.js +59 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +234 -0
- package/dist/state.d.ts +69 -0
- package/dist/state.js +155 -0
- package/dist/sync.d.ts +74 -0
- package/dist/sync.js +679 -0
- package/package.json +66 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 8628 OAuth 2.0 Device Authorization Grant against Keycloak OIDC endpoints.
|
|
3
|
+
*/
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
function normalizeIssuer(issuer) {
|
|
6
|
+
return issuer.replace(/\/$/, "");
|
|
7
|
+
}
|
|
8
|
+
function formEncode(body) {
|
|
9
|
+
return new URLSearchParams(body).toString();
|
|
10
|
+
}
|
|
11
|
+
function base64Url(input) {
|
|
12
|
+
return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
13
|
+
}
|
|
14
|
+
function generatePkceVerifier() {
|
|
15
|
+
return base64Url(randomBytes(32));
|
|
16
|
+
}
|
|
17
|
+
function pkceChallenge(verifier) {
|
|
18
|
+
return base64Url(createHash("sha256").update(verifier).digest());
|
|
19
|
+
}
|
|
20
|
+
export async function startDeviceAuthorization(params) {
|
|
21
|
+
const fetchFn = params.fetchImpl ?? fetch;
|
|
22
|
+
const issuer = normalizeIssuer(params.issuer);
|
|
23
|
+
const url = `${issuer}/protocol/openid-connect/auth/device`;
|
|
24
|
+
const scope = params.scope ?? "openid profile email";
|
|
25
|
+
const codeVerifier = generatePkceVerifier();
|
|
26
|
+
const res = await fetchFn(url, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
29
|
+
body: formEncode({
|
|
30
|
+
client_id: params.clientId,
|
|
31
|
+
scope,
|
|
32
|
+
code_challenge: pkceChallenge(codeVerifier),
|
|
33
|
+
code_challenge_method: "S256",
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
let json;
|
|
38
|
+
try {
|
|
39
|
+
json = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`Keycloak device auth: expected JSON, got HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const err = json;
|
|
46
|
+
const desc = typeof err["error_description"] === "string" ? err["error_description"] : text;
|
|
47
|
+
throw new Error(`Keycloak device auth failed (${res.status}): ${desc}`);
|
|
48
|
+
}
|
|
49
|
+
const o = json;
|
|
50
|
+
const device_code = o["device_code"];
|
|
51
|
+
const user_code = o["user_code"];
|
|
52
|
+
const verification_uri = o["verification_uri"];
|
|
53
|
+
const expires_in = o["expires_in"];
|
|
54
|
+
if (typeof device_code !== "string" ||
|
|
55
|
+
typeof user_code !== "string" ||
|
|
56
|
+
typeof verification_uri !== "string" ||
|
|
57
|
+
typeof expires_in !== "number") {
|
|
58
|
+
throw new Error("Keycloak device auth: malformed response (missing device_code, user_code, verification_uri, or expires_in)");
|
|
59
|
+
}
|
|
60
|
+
const interval = typeof o["interval"] === "number" ? o["interval"] : 5;
|
|
61
|
+
return {
|
|
62
|
+
device_code,
|
|
63
|
+
user_code,
|
|
64
|
+
verification_uri,
|
|
65
|
+
verification_uri_complete: typeof o["verification_uri_complete"] === "string" ? o["verification_uri_complete"] : undefined,
|
|
66
|
+
expires_in,
|
|
67
|
+
interval,
|
|
68
|
+
code_verifier: codeVerifier,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function sleep(ms) {
|
|
72
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
73
|
+
}
|
|
74
|
+
export async function pollDeviceAccessToken(params) {
|
|
75
|
+
const fetchFn = params.fetchImpl ?? fetch;
|
|
76
|
+
const issuer = normalizeIssuer(params.issuer);
|
|
77
|
+
const tokenUrl = `${issuer}/protocol/openid-connect/token`;
|
|
78
|
+
const { deviceAuthorization: d } = params;
|
|
79
|
+
let intervalSec = Math.max(1, d.interval ?? 5);
|
|
80
|
+
const deadline = Date.now() + d.expires_in * 1000;
|
|
81
|
+
let lastTransientError = null;
|
|
82
|
+
params.onInstructions?.(d.verification_uri, d.user_code);
|
|
83
|
+
// RFC 8628: wait at least `interval` before the first token request.
|
|
84
|
+
await sleep(intervalSec * 1000);
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
let res;
|
|
87
|
+
try {
|
|
88
|
+
res = await fetchFn(tokenUrl, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
91
|
+
body: formEncode({
|
|
92
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
93
|
+
device_code: d.device_code,
|
|
94
|
+
client_id: params.clientId,
|
|
95
|
+
...(d.code_verifier ? { code_verifier: d.code_verifier } : {}),
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
lastTransientError = e instanceof Error ? e.message : String(e);
|
|
101
|
+
await sleep(intervalSec * 1000);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
let json;
|
|
106
|
+
try {
|
|
107
|
+
json = JSON.parse(text);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
lastTransientError = `invalid JSON from token endpoint (HTTP ${res.status}): ${text.slice(0, 200)}`;
|
|
111
|
+
await sleep(intervalSec * 1000);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const o = json;
|
|
115
|
+
const err = o["error"];
|
|
116
|
+
if (res.ok && typeof o["access_token"] === "string") {
|
|
117
|
+
return o["access_token"];
|
|
118
|
+
}
|
|
119
|
+
if (err === "authorization_pending") {
|
|
120
|
+
await sleep(intervalSec * 1000);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (err === "slow_down") {
|
|
124
|
+
intervalSec += 5;
|
|
125
|
+
await sleep(intervalSec * 1000);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (err === "access_denied") {
|
|
129
|
+
throw new Error("Keycloak device flow: access_denied");
|
|
130
|
+
}
|
|
131
|
+
if (err === "expired_token") {
|
|
132
|
+
throw new Error("Keycloak device flow: expired_token");
|
|
133
|
+
}
|
|
134
|
+
const desc = typeof o["error_description"] === "string" ? o["error_description"] : text;
|
|
135
|
+
throw new Error(`Keycloak token endpoint error: ${String(err)} — ${desc}`);
|
|
136
|
+
}
|
|
137
|
+
if (lastTransientError) {
|
|
138
|
+
throw new Error(`Keycloak device flow: timed out waiting for authorization; last polling error: ${lastTransientError}`);
|
|
139
|
+
}
|
|
140
|
+
throw new Error("Keycloak device flow: timed out waiting for authorization");
|
|
141
|
+
}
|
|
142
|
+
export async function obtainKeycloakAccessTokenViaDeviceFlow(params) {
|
|
143
|
+
const start = await startDeviceAuthorization({
|
|
144
|
+
issuer: params.issuer,
|
|
145
|
+
clientId: params.clientId,
|
|
146
|
+
scope: params.scope,
|
|
147
|
+
fetchImpl: params.fetchImpl,
|
|
148
|
+
});
|
|
149
|
+
return pollDeviceAccessToken({
|
|
150
|
+
issuer: params.issuer,
|
|
151
|
+
clientId: params.clientId,
|
|
152
|
+
deviceAuthorization: start,
|
|
153
|
+
onInstructions: params.onInstructions,
|
|
154
|
+
fetchImpl: params.fetchImpl,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
export function defaultKeycloakIssuer() {
|
|
158
|
+
const fromEnv = process.env["DB90_KEYCLOAK_ISSUER"]?.trim() ||
|
|
159
|
+
process.env["KEYCLOAK_ISSUER"]?.trim();
|
|
160
|
+
if (fromEnv)
|
|
161
|
+
return fromEnv.replace(/\/$/, "");
|
|
162
|
+
const useLocalDefault = ["1", "true", "yes"].includes(process.env["DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT"]?.toLowerCase() ?? "");
|
|
163
|
+
if (useLocalDefault || process.env["NODE_ENV"] === "development") {
|
|
164
|
+
return "http://localhost:8080/realms/db90";
|
|
165
|
+
}
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
export function defaultKeycloakClientId() {
|
|
169
|
+
return process.env["DB90_KEYCLOAK_CLIENT_ID"]?.trim() || "db90-web";
|
|
170
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolveProjectId } from "./lib/index.js";
|
|
3
|
+
import { loadCredentials } from "./credentials.js";
|
|
4
|
+
import { loginAndPersistCredentials } from "./auth/flow.js";
|
|
5
|
+
import { defaultKeycloakIssuer } from "./auth/keycloak.js";
|
|
6
|
+
import { migrateLegacyState, getAppDir } from "./state.js";
|
|
7
|
+
import { syncTelemetryTools } from "./sync.js";
|
|
8
|
+
import { mergePricing } from "./pricing.js";
|
|
9
|
+
import { type InstallClaudeUserMcpOptions, type InstallResult } from "./install/claude.js";
|
|
10
|
+
export interface Args {
|
|
11
|
+
command: "init" | "health" | "run" | "help" | "uninstall-hooks" | "verify-hooks";
|
|
12
|
+
help: boolean;
|
|
13
|
+
once: boolean;
|
|
14
|
+
/** With `run --once`: ignore Cursor watermarks and commit hash dedupe. */
|
|
15
|
+
full?: boolean;
|
|
16
|
+
host?: string;
|
|
17
|
+
keycloakUrl?: string;
|
|
18
|
+
toolName?: string;
|
|
19
|
+
/** When set on init, sent as `X-Organization-ID` on MCP exchange (overrides DB90_ORGANIZATION_ID). */
|
|
20
|
+
organizationId?: string;
|
|
21
|
+
force?: boolean;
|
|
22
|
+
/** When set on init, install the Cursor hooks forwarder into ~/.cursor/hooks.json. */
|
|
23
|
+
hooks?: boolean;
|
|
24
|
+
}
|
|
25
|
+
interface RunOnceDeps {
|
|
26
|
+
loadCredentials: typeof loadCredentials;
|
|
27
|
+
migrateLegacyState: typeof migrateLegacyState;
|
|
28
|
+
getAppDir: typeof getAppDir;
|
|
29
|
+
syncTelemetryTools: typeof syncTelemetryTools;
|
|
30
|
+
resolveProjectId: typeof resolveProjectId;
|
|
31
|
+
pricing: ReturnType<typeof mergePricing>;
|
|
32
|
+
log: (message: string) => void;
|
|
33
|
+
error: (message: string) => void;
|
|
34
|
+
}
|
|
35
|
+
interface InitDeps {
|
|
36
|
+
loginAndPersistCredentials: typeof loginAndPersistCredentials;
|
|
37
|
+
defaultKeycloakIssuer: typeof defaultKeycloakIssuer;
|
|
38
|
+
getAppDir: typeof getAppDir;
|
|
39
|
+
installClaudeUserMcp: (options: InstallClaudeUserMcpOptions) => InstallResult;
|
|
40
|
+
log: (message: string) => void;
|
|
41
|
+
error: (message: string) => void;
|
|
42
|
+
}
|
|
43
|
+
/** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
|
|
44
|
+
export declare const DB90_ORGANIZATION_UUID_PATTERN: RegExp;
|
|
45
|
+
export declare function isValidDb90OrganizationUuid(value: string): boolean;
|
|
46
|
+
export declare function parseArgs(argv: string[]): Args;
|
|
47
|
+
export declare function runInit(cliArgs: Args, deps?: Partial<InitDeps>): Promise<number>;
|
|
48
|
+
export declare function runOnce(deps?: Partial<RunOnceDeps>, options?: {
|
|
49
|
+
fullScan?: boolean;
|
|
50
|
+
}): Promise<number>;
|
|
51
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { resolveProjectId } from "./lib/index.js";
|
|
5
|
+
import { loadCredentials, credentialsHaveAnyToken, pickProjectLookupToken } from "./credentials.js";
|
|
6
|
+
import { loginAndPersistCredentials } from "./auth/flow.js";
|
|
7
|
+
import { defaultKeycloakIssuer } from "./auth/keycloak.js";
|
|
8
|
+
import { migrateLegacyState, getAppDir } from "./state.js";
|
|
9
|
+
import { syncTelemetryTools } from "./sync.js";
|
|
10
|
+
import { DEFAULT_PRICING, mergePricing } from "./pricing.js";
|
|
11
|
+
import { resolveCursorPricing } from "./cursor-config.js";
|
|
12
|
+
import { buildHealthSnapshot, formatHealthForCli } from "./health.js";
|
|
13
|
+
import { installClaudeUserMcp } from "./install/claude.js";
|
|
14
|
+
import { installHooksConfig, uninstallHooksConfig, verifyHooksConfig, FORWARDER_FILENAME } from "./hooks/hooks-config.js";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { fileURLToPath as nodeFileURLToPath } from "node:url";
|
|
17
|
+
import { mcpLog } from "./log.js";
|
|
18
|
+
const GLOBAL_FLAGS = new Set(["--help", "-h", "--once", "--full"]);
|
|
19
|
+
const INIT_VALUE_FLAGS = new Set(["--host", "--keycloak-url", "--tool-name", "--organization-id"]);
|
|
20
|
+
const INIT_BOOLEAN_FLAGS = new Set(["--force", "--hooks"]);
|
|
21
|
+
/** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
|
|
22
|
+
export const DB90_ORGANIZATION_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
23
|
+
export function isValidDb90OrganizationUuid(value) {
|
|
24
|
+
return DB90_ORGANIZATION_UUID_PATTERN.test(value.trim());
|
|
25
|
+
}
|
|
26
|
+
function takeFlagValue(argv, name) {
|
|
27
|
+
const eqForm = argv.find((a) => a.startsWith(`${name}=`));
|
|
28
|
+
if (eqForm) {
|
|
29
|
+
return eqForm.slice(name.length + 1);
|
|
30
|
+
}
|
|
31
|
+
const idx = argv.indexOf(name);
|
|
32
|
+
if (idx === -1)
|
|
33
|
+
return undefined;
|
|
34
|
+
const next = argv[idx + 1];
|
|
35
|
+
if (!next || next.startsWith("-"))
|
|
36
|
+
return undefined;
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
function unknownFlags(argv, valueFlags, booleanFlags) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (!a.startsWith("--") && a !== "-h")
|
|
44
|
+
continue;
|
|
45
|
+
if (GLOBAL_FLAGS.has(a))
|
|
46
|
+
continue;
|
|
47
|
+
if (booleanFlags.has(a)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (valueFlags.has(a)) {
|
|
51
|
+
const next = argv[i + 1];
|
|
52
|
+
if (!a.includes("=") && next && !next.startsWith("-")) {
|
|
53
|
+
i += 1;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (a.startsWith("--") && a.includes("=")) {
|
|
58
|
+
const key = a.slice(0, a.indexOf("="));
|
|
59
|
+
if (booleanFlags.has(key)) {
|
|
60
|
+
out.push(a);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (valueFlags.has(key))
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (a === "-h")
|
|
67
|
+
continue;
|
|
68
|
+
out.push(a);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function initExtraPositionals(argv) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (let i = 0; i < argv.length; i++) {
|
|
75
|
+
const a = argv[i];
|
|
76
|
+
if (a === "init")
|
|
77
|
+
continue;
|
|
78
|
+
if (a === "-h" || GLOBAL_FLAGS.has(a) || INIT_BOOLEAN_FLAGS.has(a))
|
|
79
|
+
continue;
|
|
80
|
+
if (INIT_VALUE_FLAGS.has(a)) {
|
|
81
|
+
const next = argv[i + 1];
|
|
82
|
+
if (next && !next.startsWith("-")) {
|
|
83
|
+
i += 1;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (a.startsWith("--")) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out.push(a);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
export function parseArgs(argv) {
|
|
95
|
+
const args = argv.slice(2);
|
|
96
|
+
const help = args.includes("--help") || args.includes("-h");
|
|
97
|
+
const once = args.includes("--once");
|
|
98
|
+
const full = args.includes("--full");
|
|
99
|
+
const positional = args.filter((a) => !a.startsWith("-") && a !== "-h");
|
|
100
|
+
const raw = positional[0];
|
|
101
|
+
if (raw === "init") {
|
|
102
|
+
const bad = unknownFlags(args.filter((a) => a !== "init"), INIT_VALUE_FLAGS, INIT_BOOLEAN_FLAGS);
|
|
103
|
+
if (bad.length > 0) {
|
|
104
|
+
return { command: "help", help: true, once: false };
|
|
105
|
+
}
|
|
106
|
+
if (initExtraPositionals(args).length > 0) {
|
|
107
|
+
return { command: "help", help: true, once: false };
|
|
108
|
+
}
|
|
109
|
+
const host = takeFlagValue(args, "--host");
|
|
110
|
+
const keycloakUrl = takeFlagValue(args, "--keycloak-url");
|
|
111
|
+
const toolName = takeFlagValue(args, "--tool-name");
|
|
112
|
+
const organizationId = takeFlagValue(args, "--organization-id");
|
|
113
|
+
const force = args.includes("--force");
|
|
114
|
+
const hooks = args.includes("--hooks");
|
|
115
|
+
return { command: "init", help, once: false, host, keycloakUrl, toolName, organizationId, force, hooks };
|
|
116
|
+
}
|
|
117
|
+
const nonInitBad = args.filter((a) => {
|
|
118
|
+
if (!a.startsWith("--") && a !== "-h")
|
|
119
|
+
return false;
|
|
120
|
+
if (GLOBAL_FLAGS.has(a))
|
|
121
|
+
return false;
|
|
122
|
+
return true;
|
|
123
|
+
});
|
|
124
|
+
if (nonInitBad.length > 0) {
|
|
125
|
+
return { command: "help", help: true, once: false };
|
|
126
|
+
}
|
|
127
|
+
if (!raw || raw.startsWith("-")) {
|
|
128
|
+
if (raw === "--help" || raw === "-h" || (!raw && help)) {
|
|
129
|
+
return { command: "help", help: true, once: false };
|
|
130
|
+
}
|
|
131
|
+
if (!raw && once) {
|
|
132
|
+
return { command: "run", help, once: true, full: full || undefined };
|
|
133
|
+
}
|
|
134
|
+
if (!raw) {
|
|
135
|
+
return { command: "run", help, once: false, full: full || undefined };
|
|
136
|
+
}
|
|
137
|
+
return { command: "help", help: true, once: false };
|
|
138
|
+
}
|
|
139
|
+
if ((raw === "init" || raw === "health") && (once || full)) {
|
|
140
|
+
return { command: "help", help: true, once: false };
|
|
141
|
+
}
|
|
142
|
+
if (raw === "init" || raw === "health" || raw === "run") {
|
|
143
|
+
return { command: raw, help, once, full: full || undefined };
|
|
144
|
+
}
|
|
145
|
+
if (raw === "serve") {
|
|
146
|
+
return { command: "run", help, once, full: full || undefined };
|
|
147
|
+
}
|
|
148
|
+
if (raw === "uninstall-hooks" || raw === "verify-hooks") {
|
|
149
|
+
return { command: raw, help: false, once: false };
|
|
150
|
+
}
|
|
151
|
+
return { command: "help", help: true, once: false };
|
|
152
|
+
}
|
|
153
|
+
function printHelp() {
|
|
154
|
+
console.log(`
|
|
155
|
+
aixle-insights — AI coding-assistant telemetry (Claude transcripts + Cursor SQLite ingest)
|
|
156
|
+
|
|
157
|
+
Usage:
|
|
158
|
+
aixle-insights [command] [options]
|
|
159
|
+
|
|
160
|
+
Commands:
|
|
161
|
+
run Start the MCP stdio server (default — used by Claude Code).
|
|
162
|
+
init Keycloak device login once, then persist DB90 ingest credentials (keychain or file).
|
|
163
|
+
health Multi-line diagnostic (credentials, sync, log path, state files).
|
|
164
|
+
uninstall-hooks Remove DB90 from ~/.cursor/hooks.json and restore backup (if any).
|
|
165
|
+
verify-hooks Print hooks install status and queue depth as JSON.
|
|
166
|
+
|
|
167
|
+
Options:
|
|
168
|
+
--once With 'run': perform a multi-tool sync then exit (no MCP server).
|
|
169
|
+
--full With 'run --once': ignore Cursor watermarks and commit hash dedupe (backfill).
|
|
170
|
+
--help, -h Show this help message.
|
|
171
|
+
|
|
172
|
+
init options:
|
|
173
|
+
--host <url> DB90 API base URL (default: env DB90_API_URL or http://localhost:3000)
|
|
174
|
+
--keycloak-url <issuer> Keycloak realm issuer (default: env KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER)
|
|
175
|
+
--tool-name <name> Optional: mint only \`claude_code\`, only \`cursor\`, or omit to mint BOTH.
|
|
176
|
+
--organization-id <uuid> Optional: scope MCP token exchange to this org (overrides env DB90_ORGANIZATION_ID).
|
|
177
|
+
--force Replace an existing user "aixle-insights" MCP entry in ~/.claude.json if it differs.
|
|
178
|
+
--hooks (opt-in) Install Cursor hook forwarder for per-turn model attribution.
|
|
179
|
+
Requires Cursor restart. Run 'aixle-insights uninstall-hooks' to remove.
|
|
180
|
+
|
|
181
|
+
Multi-org:
|
|
182
|
+
Set \`DB90_ORGANIZATION_ID\` to a UUID, or pass \`--organization-id\` on \`init\`, so ingest tokens are minted for that membership instead of the default (oldest) org.
|
|
183
|
+
|
|
184
|
+
Credentials:
|
|
185
|
+
Stored in the OS keychain via keytar when available; otherwise
|
|
186
|
+
~/.aixle-insights/credentials.json (mode 0600 on POSIX).
|
|
187
|
+
|
|
188
|
+
Note: Omitting --tool-name provisions separate ingest tokens for Claude Code + Cursor behind a single Keycloak login.
|
|
189
|
+
`);
|
|
190
|
+
}
|
|
191
|
+
function defaultDb90Host() {
|
|
192
|
+
const v = process.env["DB90_API_URL"]?.trim();
|
|
193
|
+
if (v)
|
|
194
|
+
return v.replace(/\/$/, "");
|
|
195
|
+
return "http://localhost:3000";
|
|
196
|
+
}
|
|
197
|
+
async function runHealth() {
|
|
198
|
+
const snap = await buildHealthSnapshot();
|
|
199
|
+
console.log(formatHealthForCli(snap));
|
|
200
|
+
}
|
|
201
|
+
async function runMcpServer() {
|
|
202
|
+
const { startServer } = await import("./server.js");
|
|
203
|
+
await startServer();
|
|
204
|
+
}
|
|
205
|
+
export async function runInit(cliArgs, deps) {
|
|
206
|
+
const runtime = {
|
|
207
|
+
loginAndPersistCredentials,
|
|
208
|
+
defaultKeycloakIssuer,
|
|
209
|
+
getAppDir,
|
|
210
|
+
installClaudeUserMcp,
|
|
211
|
+
log: console.log,
|
|
212
|
+
error: console.error,
|
|
213
|
+
...deps,
|
|
214
|
+
};
|
|
215
|
+
const db90Host = (cliArgs.host ?? defaultDb90Host()).replace(/\/$/, "");
|
|
216
|
+
const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer()).trim();
|
|
217
|
+
if (!kcIssuer) {
|
|
218
|
+
runtime.error("Error: Keycloak issuer is not configured. Pass --keycloak-url or set KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER.");
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
if (cliArgs.toolName !== undefined && !["claude_code", "cursor"].includes(cliArgs.toolName)) {
|
|
222
|
+
runtime.error("Error: --tool-name must be one of: claude_code, cursor.");
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
const provisionTools = cliArgs.toolName === "cursor"
|
|
226
|
+
? ["cursor"]
|
|
227
|
+
: cliArgs.toolName === "claude_code"
|
|
228
|
+
? ["claude_code"]
|
|
229
|
+
: ["claude_code", "cursor"];
|
|
230
|
+
const fromFlag = cliArgs.organizationId?.trim();
|
|
231
|
+
const fromEnv = process.env["DB90_ORGANIZATION_ID"]?.trim();
|
|
232
|
+
const exchangeOrganizationId = fromFlag || fromEnv;
|
|
233
|
+
if (exchangeOrganizationId && !isValidDb90OrganizationUuid(exchangeOrganizationId)) {
|
|
234
|
+
runtime.error("Error: --organization-id / DB90_ORGANIZATION_ID must be a valid UUID (RFC 4122, version 1–5, variant per DB90 API).");
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
const result = await runtime.loginAndPersistCredentials({
|
|
238
|
+
db90Host,
|
|
239
|
+
keycloakIssuer: kcIssuer,
|
|
240
|
+
tools: provisionTools.length > 1 ? provisionTools : undefined,
|
|
241
|
+
toolName: provisionTools.length === 1
|
|
242
|
+
? provisionTools[0] === "cursor"
|
|
243
|
+
? "cursor"
|
|
244
|
+
: "claude_code"
|
|
245
|
+
: undefined,
|
|
246
|
+
deviceLabel: "aixle-insights CLI init",
|
|
247
|
+
appDir: runtime.getAppDir(),
|
|
248
|
+
exchangeOrganizationId: exchangeOrganizationId || undefined,
|
|
249
|
+
onVisitInstructions: (uri, code) => {
|
|
250
|
+
runtime.log(`Visit ${uri} and enter code ${code}`);
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
if (!result.ok) {
|
|
254
|
+
runtime.error(`Auth failed: ${result.error}`);
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
runtime.log(`Credentials saved (organization ${result.organizationId}).`);
|
|
258
|
+
if (cliArgs.hooks) {
|
|
259
|
+
const appDir = runtime.getAppDir();
|
|
260
|
+
const thisFile = nodeFileURLToPath(import.meta.url);
|
|
261
|
+
// From dist/cli.js: one `..` → dist/, two `..` → package root.
|
|
262
|
+
// (Pre-existing off-by-one bug — used three `..`s, which lands on the
|
|
263
|
+
// npm scope dir `@aixle/` instead of `@aixle/insights/`. Surfaced by
|
|
264
|
+
// the rename-parity verification when the published-tarball install
|
|
265
|
+
// path exercised this code for the first time via npx.)
|
|
266
|
+
const pkgRoot = join(thisFile, "..", "..");
|
|
267
|
+
const srcForwarder = join(pkgRoot, "dist", "hooks", FORWARDER_FILENAME);
|
|
268
|
+
try {
|
|
269
|
+
const { forwarderInstalled, backupPath } = installHooksConfig(srcForwarder, appDir);
|
|
270
|
+
runtime.log(`Cursor hooks installed (forwarder: ${forwarderInstalled}).`);
|
|
271
|
+
if (backupPath) {
|
|
272
|
+
runtime.log(`Existing hooks.json backed up to: ${backupPath}`);
|
|
273
|
+
}
|
|
274
|
+
runtime.log("Restart Cursor to activate hook-based model attribution.");
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
runtime.error(`Warning: --hooks install failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
278
|
+
runtime.error(`Run 'aixle-insights init --hooks' again after verifying ${srcForwarder} exists.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const shouldInstall = provisionTools.includes("claude_code");
|
|
282
|
+
if (shouldInstall) {
|
|
283
|
+
const installResult = runtime.installClaudeUserMcp({ force: cliArgs.force === true });
|
|
284
|
+
switch (installResult.kind) {
|
|
285
|
+
case "already-configured":
|
|
286
|
+
runtime.log("Claude Code MCP: aixle-insights server is already configured for your user.");
|
|
287
|
+
break;
|
|
288
|
+
case "installed":
|
|
289
|
+
runtime.log("Claude Code MCP: added aixle-insights server to your user config (~/.claude.json).");
|
|
290
|
+
break;
|
|
291
|
+
case "requires-force":
|
|
292
|
+
runtime.error(installResult.detail);
|
|
293
|
+
return 1;
|
|
294
|
+
case "error":
|
|
295
|
+
runtime.error(`Claude Code MCP install failed: ${installResult.message}`);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
runtime.log("Restart Claude Code to activate.");
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
runtime.log("Skipped Claude Code MCP auto-install (--tool-name cursor only).");
|
|
302
|
+
}
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
export async function runOnce(deps, options) {
|
|
306
|
+
const runtime = {
|
|
307
|
+
loadCredentials,
|
|
308
|
+
migrateLegacyState,
|
|
309
|
+
getAppDir,
|
|
310
|
+
syncTelemetryTools,
|
|
311
|
+
resolveProjectId,
|
|
312
|
+
pricing: mergePricing(DEFAULT_PRICING, {}),
|
|
313
|
+
log: console.log,
|
|
314
|
+
error: console.error,
|
|
315
|
+
...deps,
|
|
316
|
+
};
|
|
317
|
+
const creds = await runtime.loadCredentials();
|
|
318
|
+
if (!creds || !credentialsHaveAnyToken(creds)) {
|
|
319
|
+
mcpLog.warn("credential_validation_failed", { source: "cli_once", reason: "missing_credentials" }, false);
|
|
320
|
+
runtime.error("Error: no Aixle Insights credentials. Run `aixle-insights init` first (dual-tool auth is the default).");
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
const appDirRuntime = runtime.getAppDir();
|
|
324
|
+
for (const tok of Object.values(creds.accounts)) {
|
|
325
|
+
if (typeof tok === "string" && tok.length > 0) {
|
|
326
|
+
runtime.migrateLegacyState(appDirRuntime, creds.host, tok);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const lookupToken = pickProjectLookupToken(creds);
|
|
330
|
+
let projectId = null;
|
|
331
|
+
let projectIdSource = "none";
|
|
332
|
+
if (lookupToken) {
|
|
333
|
+
const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false);
|
|
334
|
+
projectId = resolution.projectId;
|
|
335
|
+
projectIdSource = resolution.source;
|
|
336
|
+
mcpLog.info("project_attribution_resolved", { project_id: resolution.projectId, source: resolution.source }, false);
|
|
337
|
+
}
|
|
338
|
+
const result = await runtime.syncTelemetryTools({
|
|
339
|
+
credentials: creds,
|
|
340
|
+
dryRun: false,
|
|
341
|
+
verbose: false,
|
|
342
|
+
projectId,
|
|
343
|
+
projectIdSource,
|
|
344
|
+
projectLookupToken: lookupToken,
|
|
345
|
+
pricing: runtime.pricing,
|
|
346
|
+
cursorPricing: resolveCursorPricing(undefined, appDirRuntime),
|
|
347
|
+
appDir: appDirRuntime,
|
|
348
|
+
scopeDir: process.cwd(),
|
|
349
|
+
fullScan: options?.fullScan === true,
|
|
350
|
+
});
|
|
351
|
+
if (result.locked || result.failed > 0) {
|
|
352
|
+
runtime.error(`Sync finished with failures: sent=${result.sent} failed=${result.failed} skipped=${result.skipped}`);
|
|
353
|
+
return 1;
|
|
354
|
+
}
|
|
355
|
+
runtime.log(`Sync complete: sent=${result.sent} failed=${result.failed} skipped=${result.skipped}`);
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
async function runOnceAndExit(fullScan) {
|
|
359
|
+
const exitCode = await runOnce(undefined, { fullScan });
|
|
360
|
+
if (exitCode !== 0)
|
|
361
|
+
process.exit(exitCode);
|
|
362
|
+
}
|
|
363
|
+
async function main() {
|
|
364
|
+
const args = parseArgs(process.argv);
|
|
365
|
+
if (args.help || args.command === "help") {
|
|
366
|
+
printHelp();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
switch (args.command) {
|
|
370
|
+
case "health": {
|
|
371
|
+
await runHealth();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
case "init": {
|
|
375
|
+
const code = await runInit(args);
|
|
376
|
+
if (code !== 0)
|
|
377
|
+
process.exit(code);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "uninstall-hooks": {
|
|
381
|
+
const appDir = getAppDir();
|
|
382
|
+
const { restored, backupPath, queueWarning } = uninstallHooksConfig(appDir);
|
|
383
|
+
if (queueWarning)
|
|
384
|
+
console.warn(`Warning: ${queueWarning}`);
|
|
385
|
+
if (restored) {
|
|
386
|
+
console.log(backupPath ? `Hooks uninstalled; hooks.json restored from ${backupPath}.` : "Hooks uninstalled; hooks.json removed.");
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
console.log("No DB90 hooks entry found in ~/.cursor/hooks.json — nothing to uninstall.");
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
case "verify-hooks": {
|
|
394
|
+
const report = verifyHooksConfig(getAppDir());
|
|
395
|
+
console.log(JSON.stringify(report, null, 2));
|
|
396
|
+
if (report.next_steps.length > 0) {
|
|
397
|
+
for (const step of report.next_steps)
|
|
398
|
+
console.log(`Next: ${step}`);
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
case "run":
|
|
403
|
+
if (args.once) {
|
|
404
|
+
await runOnceAndExit(args.full);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
await runMcpServer();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function isEntryPoint() {
|
|
412
|
+
if (!process.argv[1])
|
|
413
|
+
return false;
|
|
414
|
+
try {
|
|
415
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (isEntryPoint()) {
|
|
422
|
+
main().catch((err) => {
|
|
423
|
+
console.error("Unexpected error:", err instanceof Error ? err.message : String(err));
|
|
424
|
+
process.exit(1);
|
|
425
|
+
});
|
|
426
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type IngestPayload, type PostEventOptions } from "./lib/index.js";
|
|
2
|
+
export interface PostResult {
|
|
3
|
+
sent: number;
|
|
4
|
+
failed: number;
|
|
5
|
+
}
|
|
6
|
+
export interface PostEventsResult extends PostResult {
|
|
7
|
+
/** ISO timestamp of the latest successfully-sent event's occurred_at, or null if none sent. */
|
|
8
|
+
lastSentAt: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface PostEventExtras {
|
|
11
|
+
/** Injected wait for tests (default: real setTimeout). */
|
|
12
|
+
waitMs?: (ms: number) => Promise<void>;
|
|
13
|
+
/** When false, skip operational logging of transient retries. */
|
|
14
|
+
logTransientRetries?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export type PostEventOptionsWithRetry = PostEventOptions & PostEventExtras;
|
|
17
|
+
/** @internal */
|
|
18
|
+
export declare function setIngestRetryWaitOverrideForTests(fn: ((ms: number) => Promise<void>) | undefined): void;
|
|
19
|
+
/**
|
|
20
|
+
* Single-event POST with intra-sync retries for transient failures (5xx / network).
|
|
21
|
+
* Does not retry 429: the SDK invokes `on429` and returns false immediately.
|
|
22
|
+
*/
|
|
23
|
+
export declare function postEvent(payload: IngestPayload, host: string, token: string, options?: PostEventOptionsWithRetry): Promise<boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Batch POST with sent/failed aggregation plus max `occurred_at` watermarking
|
|
26
|
+
* (used by Cursor multi-event sync loops).
|
|
27
|
+
*/
|
|
28
|
+
export declare function postEvents(events: IngestPayload[], host: string, token: string, options?: PostEventOptionsWithRetry): Promise<PostEventsResult>;
|