@aixle/insights 0.2.1-staging → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -15
- package/dist/auth/credentials.js +24 -8
- package/dist/auth/exchange.d.ts +1 -1
- package/dist/auth/exchange.js +1 -1
- package/dist/auth/flow.d.ts +1 -8
- package/dist/auth/flow.js +5 -26
- package/dist/auth/keycloak.d.ts +1 -1
- package/dist/auth/keycloak.js +1 -20
- package/dist/cli.d.ts +3 -5
- package/dist/cli.js +20 -68
- package/dist/collect-cursor-payloads.d.ts +3 -3
- package/dist/cursor-checkpoints.d.ts +2 -2
- package/dist/cursor-payload-contract.d.ts +5 -5
- package/dist/health.d.ts +0 -2
- package/dist/health.js +0 -12
- package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
- package/dist/hooks/cursor-hooks-mapper.js +1 -1
- package/dist/hooks/cursor-hooks-reader.js +8 -1
- package/dist/install/index.d.ts +4 -6
- package/dist/install/index.js +1 -6
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +28 -20
- package/dist/lib/parse-error.d.ts +21 -0
- package/dist/lib/parse-error.js +25 -0
- package/dist/lib/project-resolver.js +37 -3
- package/dist/lib/repo-path-safety.d.ts +35 -0
- package/dist/lib/repo-path-safety.js +102 -0
- package/dist/lib/spawn-arg-safety.d.ts +25 -0
- package/dist/lib/spawn-arg-safety.js +49 -0
- package/dist/lib/transport-security.d.ts +0 -1
- package/dist/lib/transport-security.js +1 -1
- package/dist/readers/claude.d.ts +7 -2
- package/dist/readers/claude.js +44 -15
- package/dist/readers/cursor.d.ts +7 -7
- package/dist/readers/cursor.js +27 -6
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +0 -17
- package/dist/server.js +2 -25
- package/dist/state.js +38 -35
- package/dist/sync.d.ts +11 -2
- package/dist/sync.js +32 -14
- package/package.json +8 -4
- package/dist/install/cursor.d.ts +0 -34
- package/dist/install/cursor.js +0 -193
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Two channels are published. Pick one deliberately — they are **not** interchan
|
|
|
13
13
|
| | Production | Staging (QA) |
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| Install | `npm i -g @aixle/insights` | `npm i -g @aixle/insights@staging` |
|
|
16
|
-
| Version looks like | `0.2.
|
|
16
|
+
| Version looks like | `0.2.1` | `0.2.6-staging` |
|
|
17
17
|
| Points at | the production API | the staging API |
|
|
18
18
|
| Who should use it | **everyone** | QA validating unreleased work |
|
|
19
19
|
| Stability | released, supported | may change or break without notice |
|
|
@@ -28,7 +28,7 @@ npx -y @aixle/insights init \
|
|
|
28
28
|
|
|
29
29
|
# Or global install:
|
|
30
30
|
npm i -g @aixle/insights
|
|
31
|
-
aixle-insights --version # e.g. 0.2.
|
|
31
|
+
aixle-insights --version # e.g. 0.2.1 (no suffix)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
### Staging — QA only
|
|
@@ -44,7 +44,7 @@ npx -y @aixle/insights@staging init \
|
|
|
44
44
|
|
|
45
45
|
# Or global install:
|
|
46
46
|
npm i -g @aixle/insights@staging
|
|
47
|
-
aixle-insights --version # e.g. 0.2.
|
|
47
|
+
aixle-insights --version # e.g. 0.2.6-staging (note the suffix)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
Point a staging build at the **staging** API host. Sending staging telemetry to production
|
|
@@ -60,7 +60,7 @@ npm view @aixle/insights dist-tags # what each channel currently resolves
|
|
|
60
60
|
Expected output — `latest` and `staging` move independently:
|
|
61
61
|
|
|
62
62
|
```
|
|
63
|
-
{ latest: '0.2.
|
|
63
|
+
{ latest: '0.2.1', staging: '0.2.6-staging' }
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
### Switching back to production
|
|
@@ -73,8 +73,8 @@ Then re-run `init` against the production host, since credentials and the MCP en
|
|
|
73
73
|
per-host.
|
|
74
74
|
|
|
75
75
|
> **Why `npm install` never surprises you with a staging build:** `-staging` versions are semver
|
|
76
|
-
> prereleases, and no ordinary version range resolves to a prerelease. `*`, `^0.2.
|
|
77
|
-
> and `>=0.1.0` all select `0.2.
|
|
76
|
+
> prereleases, and no ordinary version range resolves to a prerelease. `*`, `^0.2.1`, `~0.2.1`
|
|
77
|
+
> and `>=0.1.0` all select `0.2.1` even when `0.2.6-staging` exists. Staging builds are
|
|
78
78
|
> reachable only by exact version or the `staging` dist-tag.
|
|
79
79
|
|
|
80
80
|
Maintainers: see [`../RELEASING.md`](../RELEASING.md) for how each channel is cut.
|
|
@@ -113,14 +113,6 @@ npx -y @aixle/insights init \
|
|
|
113
113
|
|
|
114
114
|
You can also set `DB90_ORGANIZATION_ID=<uuid>` in your shell environment, or pin it via `mcpServers.aixle-insights.env` in `~/.claude.json`. The CLI flag overrides the env var when both are set.
|
|
115
115
|
|
|
116
|
-
## First run and backfill
|
|
117
|
-
|
|
118
|
-
Before you run `init`, the MCP server is a deliberate no-op: it does not read, buffer, or send anything, and it does not create any state files. Nothing is lost during this window — Claude Code's transcripts and Cursor's local telemetry stores are unaffected by whether `@aixle/insights` is watching them.
|
|
119
|
-
|
|
120
|
-
The moment `init` succeeds, this package's per-credential state starts from empty (no watermark, no dedupe checkpoint). That means **the very first sync after `init` treats "everything currently on disk" as new** — Claude transcript JSONL files and Cursor SQLite history alike — and sends all of it, not just activity from that point forward. There is no separate "backfill mode" to opt into; it is simply what an empty watermark means the first time `run --once` or the background sync loop executes.
|
|
121
|
-
|
|
122
|
-
If the MCP has been installed but never connected, `aixle-insights health` (or the `status` MCP tool) reports `needs_init: true` with a human-readable `onboarding_message` explaining exactly this — install-but-uninitialized is not a state you need to worry about losing data in.
|
|
123
|
-
|
|
124
116
|
## Commands
|
|
125
117
|
|
|
126
118
|
| Command | What it does |
|
|
@@ -162,6 +154,12 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
|
|
|
162
154
|
}
|
|
163
155
|
```
|
|
164
156
|
|
|
157
|
+
The file is optional — an absent `config.json` is the normal case and is silent. A file that is
|
|
158
|
+
present but unusable is ignored entirely (every override falls back to its default) and a
|
|
159
|
+
`config_parse_failed` line is written to `mcp.log`. That covers malformed JSON and valid JSON that
|
|
160
|
+
isn't an object, including a **top-level array** — a common mistake when writing per-model rates.
|
|
161
|
+
Nothing is printed to the terminal, so check the log if an override appears to have no effect.
|
|
162
|
+
|
|
165
163
|
## Security
|
|
166
164
|
|
|
167
165
|
`@aixle/insights` enforces HTTPS for any remote host. Plaintext `http://` is allowed only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) so that local-dev flows against `make up` continue to work without friction.
|
|
@@ -188,6 +186,22 @@ The runtime gate exists because `init`'s two gates only run once, at login time.
|
|
|
188
186
|
|
|
189
187
|
The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-gated by this package. Same threat model, different ticket — tracked separately. For now, use HTTPS for any remote Keycloak issuer; the OIDC device-flow library will fail the request if the cert is invalid, but it will not refuse to attempt plaintext.
|
|
190
188
|
|
|
189
|
+
### Local store integrity
|
|
190
|
+
|
|
191
|
+
Transport security covers data in flight. The other half of the threat model is what the package
|
|
192
|
+
reads back off the local machine: credentials (keychain or file), `config.json`, and state files are
|
|
193
|
+
all attacker-writable if the account is compromised, so none of them is trusted on read.
|
|
194
|
+
|
|
195
|
+
Each is validated every time it is loaded. A payload that fails to parse, or that parses but does
|
|
196
|
+
not match the expected shape, is **rejected** and the caller falls back to its documented default —
|
|
197
|
+
no credentials, no config overrides, fresh state. Every rejection is recorded in `mcp.log`, so a
|
|
198
|
+
corrupted or tampered store is distinguishable from one that was never created; before this, both
|
|
199
|
+
were silent and looked identical to a fresh install. See
|
|
200
|
+
[Diagnostics](#diagnostics) for the event names.
|
|
201
|
+
|
|
202
|
+
Log fields carry only the file path (or the keychain service name) and a short machine-readable
|
|
203
|
+
reason. File contents, keychain payloads, and tokens are never logged.
|
|
204
|
+
|
|
191
205
|
## Cursor hook forwarder (opt-in)
|
|
192
206
|
|
|
193
207
|
`aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
|
|
@@ -225,6 +239,35 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
|
|
|
225
239
|
|
|
226
240
|
`mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`aixle_insights_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
|
|
227
241
|
|
|
242
|
+
### Local-store integrity events
|
|
243
|
+
|
|
244
|
+
These four are the only signal that a local store was present but unusable — `health` and
|
|
245
|
+
`aixle_insights_status` do **not** report them, so `mcp.log` is the sole surface:
|
|
246
|
+
|
|
247
|
+
| Event | Fires when |
|
|
248
|
+
|---|---|
|
|
249
|
+
| `credentials_parse_failed` | `credentials.json` exists but was rejected |
|
|
250
|
+
| `credentials_keytar_parse_failed` | the OS keychain entry exists but was rejected |
|
|
251
|
+
| `config_parse_failed` | `config.json` exists but was rejected |
|
|
252
|
+
| `state_parse_failed` | a state file exists but was rejected |
|
|
253
|
+
|
|
254
|
+
Each carries a `reason` distinguishing the two failure modes:
|
|
255
|
+
|
|
256
|
+
- `invalid_json` — the payload did not parse at all.
|
|
257
|
+
- `invalid_shape` — it parsed, but validation rejected it: credentials with no usable token, a
|
|
258
|
+
`config.json` that is a JSON array, a state file missing `version` / `sessions`, and so on.
|
|
259
|
+
|
|
260
|
+
Three properties are worth relying on:
|
|
261
|
+
|
|
262
|
+
- An **absent** file never warns. That is the everyday case (most users never create a
|
|
263
|
+
`config.json`, and every machine starts with no state file), so a warning always means something
|
|
264
|
+
is actually there and wrong.
|
|
265
|
+
- A **missing or disabled OS keychain** never warns either — falling back to the file is expected
|
|
266
|
+
on headless Linux, CI, and containers, not an error.
|
|
267
|
+
- All four are written to the log **only**, never mirrored to stderr, because stray output on the
|
|
268
|
+
stdio transport corrupts the MCP protocol. Emitting a warning never changes the fallback the
|
|
269
|
+
caller returns.
|
|
270
|
+
|
|
228
271
|
## Troubleshooting
|
|
229
272
|
|
|
230
273
|
| Symptom | Most likely cause | Fix |
|
|
@@ -236,6 +279,9 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
|
|
|
236
279
|
| `health` shows `authenticated: true` but `last_result` is `sent: 0, failed: N` cycle after cycle | Same as the 401 row above. `authenticated` only proves the OIDC token was acquired, not that the ingest token still validates server-side. | Re-init as above. |
|
|
237
280
|
| `last_result` reports `sent: N` but the Events UI shows nothing | The Temporal worker is not running. The ingest endpoint returns HTTP 202 (queued) regardless of worker state. | `make worker` (or check `docker ps` for `db90-worker`). See [LOCAL-DEV.md](./LOCAL-DEV.md) §1. |
|
|
238
281
|
| `sync_lock_skip {reason: "advisory_lock_held"}` in the log | Another sync cycle is still holding `~/.aixle-insights/state.lock`. | Wait for it to finish; only delete the lock file (`rm -f ~/.aixle-insights/state.lock`) after confirming no `aixle-insights run` process is alive (`pgrep -fa aixle-insights`). |
|
|
282
|
+
| `health` reports `authenticated: false` right after a successful `init`, and `credentials_parse_failed` or `credentials_keytar_parse_failed` is in the log | The credential store exists but was rejected, so it is treated as absent. The `reason` field says whether it failed to parse (`invalid_json`) or parsed into the wrong shape (`invalid_shape`). | Re-run `init`. If you hand-edited `credentials.json` for local testing, remember the keychain is read **first** — see [State + credentials](#state--credentials). |
|
|
283
|
+
| A `config.json` override has no effect, and `config_parse_failed` is in the log | The file is malformed, or is valid JSON that is not an object — a top-level array is the usual mistake. | Fix it to match the shape under [Environment](#environment); until it parses, every override is ignored. |
|
|
284
|
+
| Sync re-sends history that was already delivered, and `state_parse_failed` is in the log | A state file was present but rejected, so sync fell back to fresh state and lost its dedup checkpoints. | This is recovery, not a loop — the next successful cycle writes valid state. Ingest upserts by session, so duplicates are absorbed. Worth investigating what wrote the bad file. |
|
|
239
285
|
| `aixle-insights --help` doesn't list `--insecure` | You're running an older published version of the package, not the local source. | `which aixle-insights` shows the path. To run local source: `cd packages/tools/aixle-insights && npm run build && npm link`. To return to the published version: `npm unlink -g @aixle/insights && npm install -g @aixle/insights@latest`. |
|
|
240
286
|
| Not sure whether `aixle-insights` is a `npm link` or a real install | Real installs are regular files; `npm link` is a symlink chain into the repo. | `readlink "$(which aixle-insights)"` shows the link target if any. A linked install will trace back to a path under your monorepo checkout. |
|
|
241
287
|
|
|
@@ -274,7 +320,9 @@ After the script reports success: **quit and reopen Claude Code / Cursor** so ea
|
|
|
274
320
|
|
|
275
321
|
## Requirements
|
|
276
322
|
|
|
277
|
-
- Node.js
|
|
323
|
+
- Node.js >= 20.19.0 — matches `engines.node` in `package.json`, and
|
|
324
|
+
`src/test/supply-chain-contract.test.ts` fails if the two drift apart. Older
|
|
325
|
+
20.x patch releases are not supported.
|
|
278
326
|
- macOS / Linux / Windows. On Windows, the package writes a `cmd /c npx …` wrapper in `~/.claude.json` so Claude Code can spawn the MCP server reliably.
|
|
279
327
|
- `better-sqlite3` is a native module. After a Node upgrade, if SQLite reads start failing, rebuild it from the tools workspace:
|
|
280
328
|
|
package/dist/auth/credentials.js
CHANGED
|
@@ -4,6 +4,7 @@ import { userInfo } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { getAppDir } from "../state.js";
|
|
6
6
|
import { mcpLog } from "../log.js";
|
|
7
|
+
import { describeReadFailure } from "../lib/parse-error.js";
|
|
7
8
|
export const KEYTAR_SERVICE = "aixle-insights";
|
|
8
9
|
const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
|
|
9
10
|
function credentialsPath(appDir) {
|
|
@@ -62,15 +63,23 @@ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
|
|
|
62
63
|
const filePath = credentialsPath(appDir);
|
|
63
64
|
if (!existsSync(filePath))
|
|
64
65
|
return null;
|
|
66
|
+
let raw;
|
|
65
67
|
try {
|
|
66
|
-
|
|
67
|
-
return normalizeLoadedCredentials(raw);
|
|
68
|
+
raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
68
69
|
}
|
|
69
70
|
catch (err) {
|
|
70
|
-
// File exists (checked above) but
|
|
71
|
-
mcpLog.warn("credentials_parse_failed", { path: filePath,
|
|
71
|
+
// File exists (checked above) but is not readable/valid JSON — distinguishes tampering from "never created".
|
|
72
|
+
mcpLog.warn("credentials_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
|
|
72
73
|
return null;
|
|
73
74
|
}
|
|
75
|
+
const normalized = normalizeLoadedCredentials(raw);
|
|
76
|
+
if (normalized === null) {
|
|
77
|
+
// Valid JSON, but not a credential shape we accept. `normalizeLoadedCredentials` signals
|
|
78
|
+
// rejection by returning null and never throws, so this cannot surface in the catch
|
|
79
|
+
// above — without this branch a plausible-looking replacement file stays silent. (DB90DV-699)
|
|
80
|
+
mcpLog.warn("credentials_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
|
|
81
|
+
}
|
|
82
|
+
return normalized;
|
|
74
83
|
}
|
|
75
84
|
async function tryKeytarGet() {
|
|
76
85
|
if (keytarDisabled())
|
|
@@ -86,15 +95,22 @@ async function tryKeytarGet() {
|
|
|
86
95
|
}
|
|
87
96
|
if (!raw)
|
|
88
97
|
return null;
|
|
98
|
+
let parsed;
|
|
89
99
|
try {
|
|
90
|
-
|
|
91
|
-
return normalizeLoadedCredentials(parsed);
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
92
101
|
}
|
|
93
102
|
catch (err) {
|
|
94
|
-
// Keychain entry exists (checked above) but
|
|
95
|
-
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE,
|
|
103
|
+
// Keychain entry exists (checked above) but is not valid JSON — distinguishes tampering from "no entry".
|
|
104
|
+
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, ...describeReadFailure(err) }, false);
|
|
96
105
|
return null;
|
|
97
106
|
}
|
|
107
|
+
const normalized = normalizeLoadedCredentials(parsed);
|
|
108
|
+
if (normalized === null) {
|
|
109
|
+
// Same shape-rejection hole as the file path above. Fields stay service-only — never the
|
|
110
|
+
// keychain payload. (DB90DV-699)
|
|
111
|
+
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, reason: "invalid_shape" }, false);
|
|
112
|
+
}
|
|
113
|
+
return normalized;
|
|
98
114
|
}
|
|
99
115
|
async function tryKeytarSet(payload) {
|
|
100
116
|
if (keytarDisabled())
|
package/dist/auth/exchange.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface ExchangeResult {
|
|
|
11
11
|
}
|
|
12
12
|
type ExchangeToolId = "claude_code" | "cursor";
|
|
13
13
|
export declare function exchangeIngestToken(params: {
|
|
14
|
-
|
|
14
|
+
db90Host: string;
|
|
15
15
|
keycloakAccessToken: string;
|
|
16
16
|
/** Legacy single-tool mint. Omit when tools is provided. */
|
|
17
17
|
toolName?: ExchangeToolId;
|
package/dist/auth/exchange.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export async function exchangeIngestToken(params) {
|
|
2
2
|
const fetchFn = params.fetchImpl ?? fetch;
|
|
3
3
|
const requestedTools = params.tools?.length ? [...params.tools] : params.toolName ? [params.toolName] : [];
|
|
4
|
-
const base = params.
|
|
4
|
+
const base = params.db90Host.replace(/\/$/, "");
|
|
5
5
|
const url = `${base}/api/v1/integrations/mcp/exchange`;
|
|
6
6
|
const body = {};
|
|
7
7
|
if (params.tools?.length) {
|
package/dist/auth/flow.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defaultKeycloakClientId, defaultKeycloakIssuer } from "./keycloak.js";
|
|
2
2
|
import type { TelemetryToolId } from "./credentials.js";
|
|
3
3
|
export interface LoginAndPersistOptions {
|
|
4
|
-
|
|
4
|
+
db90Host: string;
|
|
5
5
|
keycloakIssuer: string;
|
|
6
6
|
/** @deprecated Prefer `tools`; kept for callers that mint a single ingest account. */
|
|
7
7
|
toolName?: string;
|
|
@@ -12,8 +12,6 @@ export interface LoginAndPersistOptions {
|
|
|
12
12
|
clientId?: string;
|
|
13
13
|
appDir?: string;
|
|
14
14
|
allowInsecureHttp?: boolean;
|
|
15
|
-
/** Skip the "already authenticated" short-circuit and re-run the device flow. */
|
|
16
|
-
force?: boolean;
|
|
17
15
|
onSecurityWarning?: (message: string) => void;
|
|
18
16
|
onVisitInstructions?: (verification_uri: string, user_code: string) => void;
|
|
19
17
|
fetchImpl?: typeof fetch;
|
|
@@ -21,11 +19,6 @@ export interface LoginAndPersistOptions {
|
|
|
21
19
|
export declare function loginAndPersistCredentials(opts: LoginAndPersistOptions): Promise<{
|
|
22
20
|
ok: true;
|
|
23
21
|
organizationId: string;
|
|
24
|
-
} | {
|
|
25
|
-
ok: true;
|
|
26
|
-
alreadyAuthenticated: true;
|
|
27
|
-
host: string;
|
|
28
|
-
organizationId?: string;
|
|
29
22
|
} | {
|
|
30
23
|
ok: false;
|
|
31
24
|
error: string;
|
package/dist/auth/flow.js
CHANGED
|
@@ -3,36 +3,15 @@ import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTok
|
|
|
3
3
|
import { loadCredentials, saveStoredCredentials } from "./credentials.js";
|
|
4
4
|
import { getAppDir } from "../state.js";
|
|
5
5
|
import { evaluateTransportSecurity } from "../lib/transport-security.js";
|
|
6
|
-
function normalizeHostForComparison(host) {
|
|
7
|
-
return host.replace(/\/$/, "");
|
|
8
|
-
}
|
|
9
|
-
function credentialsCoverRequestedTools(creds, requestedTools) {
|
|
10
|
-
return requestedTools.every((tool) => {
|
|
11
|
-
const token = creds.accounts[tool];
|
|
12
|
-
return typeof token === "string" && token.length > 0;
|
|
13
|
-
});
|
|
14
|
-
}
|
|
15
|
-
function credentialsMatchRequestedScope(creds, opts, requestedTools) {
|
|
16
|
-
if (normalizeHostForComparison(creds.host) !== normalizeHostForComparison(opts.apiHost))
|
|
17
|
-
return false;
|
|
18
|
-
if (opts.exchangeOrganizationId && creds.organizationId !== opts.exchangeOrganizationId)
|
|
19
|
-
return false;
|
|
20
|
-
return credentialsCoverRequestedTools(creds, requestedTools);
|
|
21
|
-
}
|
|
22
6
|
export async function loginAndPersistCredentials(opts) {
|
|
23
|
-
const appDir = opts.appDir ?? getAppDir();
|
|
24
|
-
const requestedTools = opts.tools ?? ((opts.toolName ?? "claude_code") === "cursor" ? ["cursor"] : ["claude_code"]);
|
|
25
|
-
if (!opts.force) {
|
|
26
|
-
const existing = await loadCredentials(appDir);
|
|
27
|
-
if (existing && credentialsMatchRequestedScope(existing, opts, requestedTools)) {
|
|
28
|
-
return { ok: true, alreadyAuthenticated: true, host: existing.host, organizationId: existing.organizationId };
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
7
|
const issuer = opts.keycloakIssuer.trim();
|
|
32
8
|
if (!issuer) {
|
|
33
9
|
return { ok: false, error: "Keycloak issuer is empty; set KEYCLOAK_ISSUER or pass --keycloak-url." };
|
|
34
10
|
}
|
|
35
11
|
const clientId = opts.clientId?.trim() || defaultKeycloakClientId();
|
|
12
|
+
const appDir = opts.appDir ?? getAppDir();
|
|
13
|
+
const requestedTools = opts.tools ??
|
|
14
|
+
((opts.toolName ?? "claude_code") === "cursor" ? ["cursor"] : ["claude_code"]);
|
|
36
15
|
let accessToken;
|
|
37
16
|
try {
|
|
38
17
|
accessToken = await obtainKeycloakAccessTokenViaDeviceFlow({
|
|
@@ -49,7 +28,7 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
49
28
|
try {
|
|
50
29
|
if (requestedTools.length > 1) {
|
|
51
30
|
exchanged = await exchangeIngestToken({
|
|
52
|
-
|
|
31
|
+
db90Host: opts.db90Host,
|
|
53
32
|
keycloakAccessToken: accessToken,
|
|
54
33
|
tools: requestedTools,
|
|
55
34
|
deviceLabel: opts.deviceLabel,
|
|
@@ -59,7 +38,7 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
59
38
|
}
|
|
60
39
|
else {
|
|
61
40
|
exchanged = await exchangeIngestToken({
|
|
62
|
-
|
|
41
|
+
db90Host: opts.db90Host,
|
|
63
42
|
keycloakAccessToken: accessToken,
|
|
64
43
|
toolName: requestedTools[0],
|
|
65
44
|
deviceLabel: opts.deviceLabel,
|
package/dist/auth/keycloak.d.ts
CHANGED
|
@@ -31,5 +31,5 @@ export declare function obtainKeycloakAccessTokenViaDeviceFlow(params: {
|
|
|
31
31
|
onInstructions?: (verification_uri: string, user_code: string) => void;
|
|
32
32
|
fetchImpl?: typeof fetch;
|
|
33
33
|
}): Promise<string>;
|
|
34
|
-
export declare function defaultKeycloakIssuer(
|
|
34
|
+
export declare function defaultKeycloakIssuer(): string;
|
|
35
35
|
export declare function defaultKeycloakClientId(): string;
|
package/dist/auth/keycloak.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* RFC 8628 OAuth 2.0 Device Authorization Grant against Keycloak OIDC endpoints.
|
|
3
3
|
*/
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
-
import { isLoopbackHost } from "../lib/transport-security.js";
|
|
6
5
|
function normalizeIssuer(issuer) {
|
|
7
6
|
return issuer.replace(/\/$/, "");
|
|
8
7
|
}
|
|
@@ -155,31 +154,13 @@ export async function obtainKeycloakAccessTokenViaDeviceFlow(params) {
|
|
|
155
154
|
fetchImpl: params.fetchImpl,
|
|
156
155
|
});
|
|
157
156
|
}
|
|
158
|
-
export function defaultKeycloakIssuer(
|
|
157
|
+
export function defaultKeycloakIssuer() {
|
|
159
158
|
const fromEnv = process.env["DB90_KEYCLOAK_ISSUER"]?.trim() ||
|
|
160
159
|
process.env["KEYCLOAK_ISSUER"]?.trim();
|
|
161
160
|
if (fromEnv)
|
|
162
161
|
return fromEnv.replace(/\/$/, "");
|
|
163
162
|
const useLocalDefault = ["1", "true", "yes"].includes(process.env["DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT"]?.toLowerCase() ?? "");
|
|
164
163
|
if (useLocalDefault || process.env["NODE_ENV"] === "development") {
|
|
165
|
-
if (ingestHost) {
|
|
166
|
-
let hostname;
|
|
167
|
-
try {
|
|
168
|
-
hostname = new URL(ingestHost).hostname;
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
hostname = ingestHost;
|
|
172
|
-
}
|
|
173
|
-
if (!isLoopbackHost(hostname)) {
|
|
174
|
-
const reason = useLocalDefault
|
|
175
|
-
? `DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT=${process.env["DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT"]}`
|
|
176
|
-
: `NODE_ENV=development`;
|
|
177
|
-
console.error(`[aixle-insights] Warning: ${reason} would redirect Keycloak authentication to ` +
|
|
178
|
-
`http://localhost:8080, but the ingest host "${ingestHost}" is not localhost. ` +
|
|
179
|
-
`Ignoring the local Keycloak default. Set DB90_KEYCLOAK_ISSUER explicitly.`);
|
|
180
|
-
return "";
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
164
|
return "http://localhost:8080/realms/db90";
|
|
184
165
|
}
|
|
185
166
|
return "";
|
package/dist/cli.d.ts
CHANGED
|
@@ -7,9 +7,8 @@ import { migrateLegacyState, getAppDir } from "./state.js";
|
|
|
7
7
|
import { syncTelemetryTools } from "./sync.js";
|
|
8
8
|
import { mergePricing } from "./pricing.js";
|
|
9
9
|
import { type InstallClaudeUserMcpOptions, type InstallResult } from "./install/claude.js";
|
|
10
|
-
import { type InstallCursorUserMcpOptions } from "./install/cursor.js";
|
|
11
10
|
export interface Args {
|
|
12
|
-
command: "init" | "health" | "run" | "help" | "uninstall-hooks" | "verify-hooks"
|
|
11
|
+
command: "init" | "health" | "run" | "help" | "uninstall-hooks" | "verify-hooks";
|
|
13
12
|
help: boolean;
|
|
14
13
|
once: boolean;
|
|
15
14
|
/** With `run --once`: ignore Cursor watermarks and commit hash dedupe. */
|
|
@@ -40,13 +39,12 @@ interface InitDeps {
|
|
|
40
39
|
defaultKeycloakIssuer: typeof defaultKeycloakIssuer;
|
|
41
40
|
getAppDir: typeof getAppDir;
|
|
42
41
|
installClaudeUserMcp: (options: InstallClaudeUserMcpOptions) => InstallResult;
|
|
43
|
-
installCursorUserMcp: (options: InstallCursorUserMcpOptions) => InstallResult;
|
|
44
42
|
log: (message: string) => void;
|
|
45
43
|
error: (message: string) => void;
|
|
46
44
|
}
|
|
47
45
|
/** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
|
|
48
|
-
export declare const
|
|
49
|
-
export declare function
|
|
46
|
+
export declare const DB90_ORGANIZATION_UUID_PATTERN: RegExp;
|
|
47
|
+
export declare function isValidDb90OrganizationUuid(value: string): boolean;
|
|
50
48
|
export declare function parseArgs(argv: string[]): Args;
|
|
51
49
|
export declare function runInit(cliArgs: Args, deps?: Partial<InitDeps>): Promise<number>;
|
|
52
50
|
export declare function runOnce(deps?: Partial<RunOnceDeps>, options?: {
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,6 @@ import { DEFAULT_PRICING, mergePricing } from "./pricing.js";
|
|
|
11
11
|
import { resolveCursorPricing } from "./cursor-config.js";
|
|
12
12
|
import { buildHealthSnapshot, formatHealthForCli } from "./health.js";
|
|
13
13
|
import { installClaudeUserMcp } from "./install/claude.js";
|
|
14
|
-
import { installCursorUserMcp, uninstallCursorUserMcp } from "./install/cursor.js";
|
|
15
14
|
import { installHooksConfig, uninstallHooksConfig, verifyHooksConfig, FORWARDER_FILENAME } from "./hooks/hooks-config.js";
|
|
16
15
|
import { evaluateTransportSecurity } from "./lib/transport-security.js";
|
|
17
16
|
import { join } from "node:path";
|
|
@@ -21,9 +20,9 @@ const GLOBAL_FLAGS = new Set(["--help", "-h", "--once", "--full"]);
|
|
|
21
20
|
const INIT_VALUE_FLAGS = new Set(["--host", "--keycloak-url", "--tool-name", "--organization-id"]);
|
|
22
21
|
const INIT_BOOLEAN_FLAGS = new Set(["--force", "--hooks", "--insecure"]);
|
|
23
22
|
/** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
|
|
24
|
-
export const
|
|
25
|
-
export function
|
|
26
|
-
return
|
|
23
|
+
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;
|
|
24
|
+
export function isValidDb90OrganizationUuid(value) {
|
|
25
|
+
return DB90_ORGANIZATION_UUID_PATTERN.test(value.trim());
|
|
27
26
|
}
|
|
28
27
|
function takeFlagValue(argv, name) {
|
|
29
28
|
const eqForm = argv.find((a) => a.startsWith(`${name}=`));
|
|
@@ -148,7 +147,7 @@ export function parseArgs(argv) {
|
|
|
148
147
|
if (raw === "serve") {
|
|
149
148
|
return { command: "run", help, once, full: full || undefined };
|
|
150
149
|
}
|
|
151
|
-
if (raw === "uninstall-hooks" || raw === "verify-hooks"
|
|
150
|
+
if (raw === "uninstall-hooks" || raw === "verify-hooks") {
|
|
152
151
|
return { command: raw, help: false, once: false };
|
|
153
152
|
}
|
|
154
153
|
return { command: "help", help: true, once: false };
|
|
@@ -161,12 +160,11 @@ Usage:
|
|
|
161
160
|
aixle-insights [command] [options]
|
|
162
161
|
|
|
163
162
|
Commands:
|
|
164
|
-
run
|
|
165
|
-
init
|
|
166
|
-
health
|
|
167
|
-
uninstall-hooks
|
|
168
|
-
verify-hooks
|
|
169
|
-
uninstall-cursor-mcp Remove aixle-insights from ~/.cursor/mcp.json and restore backup (if any).
|
|
163
|
+
run Start the MCP stdio server (default — used by Claude Code).
|
|
164
|
+
init Keycloak device login once, then persist DB90 ingest credentials (keychain or file).
|
|
165
|
+
health Multi-line diagnostic (credentials, sync, log path, state files).
|
|
166
|
+
uninstall-hooks Remove DB90 from ~/.cursor/hooks.json and restore backup (if any).
|
|
167
|
+
verify-hooks Print hooks install status and queue depth as JSON.
|
|
170
168
|
|
|
171
169
|
Options:
|
|
172
170
|
--once With 'run': perform a multi-tool sync then exit (no MCP server).
|
|
@@ -178,8 +176,7 @@ init options:
|
|
|
178
176
|
--keycloak-url <issuer> Keycloak realm issuer (default: env KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER)
|
|
179
177
|
--tool-name <name> Optional: mint only \`claude_code\`, only \`cursor\`, or omit to mint BOTH.
|
|
180
178
|
--organization-id <uuid> Optional: scope MCP token exchange to this org (overrides env DB90_ORGANIZATION_ID).
|
|
181
|
-
--force
|
|
182
|
-
replace an existing user "aixle-insights" MCP entry in ~/.claude.json if it differs.
|
|
179
|
+
--force Replace an existing user "aixle-insights" MCP entry in ~/.claude.json if it differs.
|
|
183
180
|
--hooks (opt-in) Install Cursor hook forwarder for per-turn model attribution.
|
|
184
181
|
Requires Cursor restart. Run 'aixle-insights uninstall-hooks' to remove.
|
|
185
182
|
--insecure Allow remote http:// hosts for trusted non-production test endpoints only.
|
|
@@ -194,7 +191,7 @@ Credentials:
|
|
|
194
191
|
Note: Omitting --tool-name provisions separate ingest tokens for Claude Code + Cursor behind a single Keycloak login.
|
|
195
192
|
`);
|
|
196
193
|
}
|
|
197
|
-
function
|
|
194
|
+
function defaultDb90Host() {
|
|
198
195
|
const v = process.env["DB90_API_URL"]?.trim();
|
|
199
196
|
if (v)
|
|
200
197
|
return v.replace(/\/$/, "");
|
|
@@ -214,13 +211,12 @@ export async function runInit(cliArgs, deps) {
|
|
|
214
211
|
defaultKeycloakIssuer,
|
|
215
212
|
getAppDir,
|
|
216
213
|
installClaudeUserMcp,
|
|
217
|
-
installCursorUserMcp,
|
|
218
214
|
log: console.log,
|
|
219
215
|
error: console.error,
|
|
220
216
|
...deps,
|
|
221
217
|
};
|
|
222
|
-
const
|
|
223
|
-
const transportSecurity = evaluateTransportSecurity(
|
|
218
|
+
const db90Host = (cliArgs.host ?? defaultDb90Host()).replace(/\/$/, "");
|
|
219
|
+
const transportSecurity = evaluateTransportSecurity(db90Host, {
|
|
224
220
|
allowInsecureHttp: cliArgs.insecure === true,
|
|
225
221
|
label: "DB90 API host",
|
|
226
222
|
});
|
|
@@ -232,6 +228,10 @@ export async function runInit(cliArgs, deps) {
|
|
|
232
228
|
runtime.error(`Warning: ${transportSecurity.warning}`);
|
|
233
229
|
}
|
|
234
230
|
const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer()).trim();
|
|
231
|
+
if (!kcIssuer) {
|
|
232
|
+
runtime.error("Error: Keycloak issuer is not configured. Pass --keycloak-url or set KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER.");
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
235
|
if (cliArgs.toolName !== undefined && !["claude_code", "cursor"].includes(cliArgs.toolName)) {
|
|
236
236
|
runtime.error("Error: --tool-name must be one of: claude_code, cursor.");
|
|
237
237
|
return 1;
|
|
@@ -244,12 +244,12 @@ export async function runInit(cliArgs, deps) {
|
|
|
244
244
|
const fromFlag = cliArgs.organizationId?.trim();
|
|
245
245
|
const fromEnv = process.env["DB90_ORGANIZATION_ID"]?.trim();
|
|
246
246
|
const exchangeOrganizationId = fromFlag || fromEnv;
|
|
247
|
-
if (exchangeOrganizationId && !
|
|
247
|
+
if (exchangeOrganizationId && !isValidDb90OrganizationUuid(exchangeOrganizationId)) {
|
|
248
248
|
runtime.error("Error: --organization-id / DB90_ORGANIZATION_ID must be a valid UUID (RFC 4122, version 1–5, variant per DB90 API).");
|
|
249
249
|
return 1;
|
|
250
250
|
}
|
|
251
251
|
const result = await runtime.loginAndPersistCredentials({
|
|
252
|
-
|
|
252
|
+
db90Host,
|
|
253
253
|
keycloakIssuer: kcIssuer,
|
|
254
254
|
tools: provisionTools.length > 1 ? provisionTools : undefined,
|
|
255
255
|
toolName: provisionTools.length === 1
|
|
@@ -261,7 +261,6 @@ export async function runInit(cliArgs, deps) {
|
|
|
261
261
|
appDir: runtime.getAppDir(),
|
|
262
262
|
exchangeOrganizationId: exchangeOrganizationId || undefined,
|
|
263
263
|
allowInsecureHttp: cliArgs.insecure === true,
|
|
264
|
-
force: cliArgs.force === true,
|
|
265
264
|
onSecurityWarning: (message) => {
|
|
266
265
|
runtime.error(`Warning: ${message}`);
|
|
267
266
|
},
|
|
@@ -273,12 +272,7 @@ export async function runInit(cliArgs, deps) {
|
|
|
273
272
|
runtime.error(`Auth failed: ${result.error}`);
|
|
274
273
|
return 1;
|
|
275
274
|
}
|
|
276
|
-
|
|
277
|
-
runtime.log(`Already authenticated (organization ${result.organizationId ?? "unknown"}). Pass --force to re-authenticate.`);
|
|
278
|
-
}
|
|
279
|
-
else {
|
|
280
|
-
runtime.log(`Credentials saved (organization ${result.organizationId}).`);
|
|
281
|
-
}
|
|
275
|
+
runtime.log(`Credentials saved (organization ${result.organizationId}).`);
|
|
282
276
|
if (cliArgs.hooks) {
|
|
283
277
|
const appDir = runtime.getAppDir();
|
|
284
278
|
const thisFile = nodeFileURLToPath(import.meta.url);
|
|
@@ -324,28 +318,6 @@ export async function runInit(cliArgs, deps) {
|
|
|
324
318
|
else {
|
|
325
319
|
runtime.log("Skipped Claude Code MCP auto-install (--tool-name cursor only).");
|
|
326
320
|
}
|
|
327
|
-
const shouldInstallCursor = provisionTools.includes("cursor");
|
|
328
|
-
if (shouldInstallCursor) {
|
|
329
|
-
const cursorInstallResult = runtime.installCursorUserMcp({ force: cliArgs.force === true });
|
|
330
|
-
switch (cursorInstallResult.kind) {
|
|
331
|
-
case "already-configured":
|
|
332
|
-
runtime.log("Cursor MCP: aixle-insights server is already configured for your user.");
|
|
333
|
-
break;
|
|
334
|
-
case "installed":
|
|
335
|
-
runtime.log("Cursor MCP: added aixle-insights server to ~/.cursor/mcp.json.");
|
|
336
|
-
break;
|
|
337
|
-
case "requires-force":
|
|
338
|
-
runtime.error(cursorInstallResult.detail);
|
|
339
|
-
return 1;
|
|
340
|
-
case "error":
|
|
341
|
-
runtime.error(`Cursor MCP install failed: ${cursorInstallResult.message}`);
|
|
342
|
-
return 1;
|
|
343
|
-
}
|
|
344
|
-
runtime.log("Restart Cursor to activate the aixle-insights MCP server.");
|
|
345
|
-
}
|
|
346
|
-
else {
|
|
347
|
-
runtime.log("Skipped Cursor MCP auto-install (--tool-name claude_code only).");
|
|
348
|
-
}
|
|
349
321
|
return 0;
|
|
350
322
|
}
|
|
351
323
|
export async function runOnce(deps, options) {
|
|
@@ -362,8 +334,6 @@ export async function runOnce(deps, options) {
|
|
|
362
334
|
};
|
|
363
335
|
const creds = await runtime.loadCredentials();
|
|
364
336
|
if (!creds || !credentialsHaveAnyToken(creds)) {
|
|
365
|
-
// File-only: runOnce prints a user-facing error below, so mirroring this
|
|
366
|
-
// structured warn would just duplicate the message on stderr.
|
|
367
337
|
mcpLog.warn("credential_validation_failed", { source: "cli_once", reason: "missing_credentials" }, false);
|
|
368
338
|
runtime.error("Error: no Aixle Insights credentials. Run `aixle-insights init` first (dual-tool auth is the default).");
|
|
369
339
|
return 1;
|
|
@@ -438,24 +408,6 @@ async function main() {
|
|
|
438
408
|
}
|
|
439
409
|
return;
|
|
440
410
|
}
|
|
441
|
-
case "uninstall-cursor-mcp": {
|
|
442
|
-
const result = uninstallCursorUserMcp();
|
|
443
|
-
switch (result.kind) {
|
|
444
|
-
case "restored":
|
|
445
|
-
console.log(`Cursor MCP entry removed; ~/.cursor/mcp.json restored from ${result.backupPath}.`);
|
|
446
|
-
break;
|
|
447
|
-
case "removed":
|
|
448
|
-
console.log("Cursor MCP entry removed from ~/.cursor/mcp.json.");
|
|
449
|
-
break;
|
|
450
|
-
case "noop":
|
|
451
|
-
console.log("No aixle-insights entry found in ~/.cursor/mcp.json — nothing to uninstall.");
|
|
452
|
-
break;
|
|
453
|
-
case "error":
|
|
454
|
-
console.error(`Cursor MCP uninstall failed: ${result.message}`);
|
|
455
|
-
process.exit(1);
|
|
456
|
-
}
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
411
|
case "verify-hooks": {
|
|
460
412
|
const report = verifyHooksConfig(getAppDir());
|
|
461
413
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ProjectResolution } from "./lib/index.js";
|
|
2
2
|
import type { State } from "./state.js";
|
|
3
|
-
import { type
|
|
3
|
+
import { type CursorDb90Payload, type CursorTranscriptTurn, type PricingConfig } from "./readers/cursor.js";
|
|
4
4
|
export interface CursorSliceGroup {
|
|
5
5
|
key: string;
|
|
6
6
|
label: string;
|
|
7
|
-
payloads:
|
|
7
|
+
payloads: CursorDb90Payload[];
|
|
8
8
|
}
|
|
9
9
|
export interface PrepareCursorSliceGroupsOptions {
|
|
10
10
|
stateBefore: State;
|
|
@@ -47,7 +47,7 @@ export interface CollectLocalCursorPayloadsOptions {
|
|
|
47
47
|
stateBefore?: State;
|
|
48
48
|
}
|
|
49
49
|
export interface CollectedCursorPayloads {
|
|
50
|
-
payloads:
|
|
50
|
+
payloads: CursorDb90Payload[];
|
|
51
51
|
counts: PreparedCursorSliceGroups["counts"];
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { State } from "./state.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { CursorDb90Payload } from "./readers/cursor.js";
|
|
3
3
|
/** Cursor SQLite watermark checkpoints — never collide with Claude `claude_code:*` session keys. */
|
|
4
4
|
export declare const CURSOR_WATERMARK_KEY: "cursor:watermark";
|
|
5
5
|
export declare const CURSOR_EVENTS_WATERMARK_KEY: "cursor:events_watermark";
|
|
@@ -9,4 +9,4 @@ export declare const CURSOR_TRANSCRIPT_TURN_PREFIX: "cursor:transcript_turn:";
|
|
|
9
9
|
export declare function cursorTranscriptTurnStateKey(turnId: string): string;
|
|
10
10
|
export declare function cursorWatermarkDate(state: Pick<State, "sessions">, ...keys: string[]): Date | null;
|
|
11
11
|
/** Skip recent-commit payloads whose hash was already successfully POSTed. */
|
|
12
|
-
export declare function filterRecentCommitsByHashDedup(payloads:
|
|
12
|
+
export declare function filterRecentCommitsByHashDedup(payloads: CursorDb90Payload[], lastRecentCommitHashes?: string[]): CursorDb90Payload[];
|