memtrace 0.7.13 → 0.7.20

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.
@@ -0,0 +1,7 @@
1
+ export interface EnterpriseInstallOptions {
2
+ seedPath: string;
3
+ scope?: 'global' | 'local';
4
+ binary?: string;
5
+ yes?: boolean;
6
+ }
7
+ export declare function runEnterpriseInstall(opts: EnterpriseInstallOptions): Promise<void>;
@@ -0,0 +1,129 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
5
+ import { resolveMemtraceBinary } from './install.js';
6
+ import { registerCursorMcpAt } from '../transformers/cursor.js';
7
+ function loadSeed(seedPath) {
8
+ const raw = fs.readFileSync(seedPath, 'utf8');
9
+ const parsed = JSON.parse(raw);
10
+ if (!parsed.memdb?.database) {
11
+ throw new Error('seed JSON must include memdb.database');
12
+ }
13
+ return parsed;
14
+ }
15
+ function loadWriterToken(seedDir) {
16
+ const tokPath = path.join(seedDir, 'tokens.plaintext.json');
17
+ if (!fs.existsSync(tokPath)) {
18
+ throw new Error(`Missing ${tokPath} — generate bundle via enterprise admin first`);
19
+ }
20
+ const tokens = JSON.parse(fs.readFileSync(tokPath, 'utf8'));
21
+ if (!tokens.writer)
22
+ throw new Error('tokens.plaintext.json must include writer token');
23
+ return tokens.writer;
24
+ }
25
+ function resolveLicenseKey(seed, seedDir) {
26
+ if (seed.license?.key)
27
+ return seed.license.key;
28
+ const seedJson = path.join(seedDir, 'seed.json');
29
+ if (fs.existsSync(seedJson)) {
30
+ const full = JSON.parse(fs.readFileSync(seedJson, 'utf8'));
31
+ if (full.license?.key)
32
+ return full.license.key;
33
+ }
34
+ const env = process.env.MEMTRACE_LICENSE_KEY;
35
+ if (env)
36
+ return env;
37
+ throw new Error('License key not found in seed; set MEMTRACE_LICENSE_KEY or include license.key');
38
+ }
39
+ const DEFAULT_AUTH_URL = 'https://www.memtrace.io';
40
+ async function verifySubscriptionWithApi(organizationId, licenseKey) {
41
+ const base = (process.env.MEMTRACE_AUTH_URL ?? DEFAULT_AUTH_URL).replace(/\/$/, '');
42
+ const resp = await fetch(`${base}/api/enterprise/install/verify`, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ organizationId, licenseKey }),
46
+ signal: AbortSignal.timeout(10_000),
47
+ });
48
+ if (!resp.ok) {
49
+ const payload = await resp.json().catch(() => ({}));
50
+ throw new Error(payload.error ?? `Enterprise install blocked (${resp.status})`);
51
+ }
52
+ const payload = await resp.json();
53
+ if (!payload.ok) {
54
+ throw new Error('Enterprise subscription verification failed');
55
+ }
56
+ }
57
+ async function validateRemoteEndpoint(endpoint, token) {
58
+ const url = endpoint.replace(/\/$/, '');
59
+ // gRPC health is not available from Node without deps; ping via HTTP metrics if exposed.
60
+ const metricsUrl = url.replace(/:50051$/, ':9090');
61
+ try {
62
+ const resp = await fetch(`${metricsUrl}/metrics`, {
63
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
64
+ signal: AbortSignal.timeout(5000),
65
+ });
66
+ if (!resp.ok) {
67
+ console.warn(` ⚠ MemDB metrics at ${metricsUrl} returned ${resp.status} (gRPC may still work)`);
68
+ }
69
+ else {
70
+ console.log(` ✓ MemDB metrics reachable at ${metricsUrl}`);
71
+ }
72
+ }
73
+ catch (e) {
74
+ console.warn(` ⚠ Could not reach MemDB at ${endpoint}: ${e.message}`);
75
+ console.warn(' Continuing — verify VPN/firewall if remote mode fails at runtime.');
76
+ }
77
+ }
78
+ function mcpPath(scope) {
79
+ const base = scope === 'global' ? os.homedir() : process.cwd();
80
+ return path.join(base, '.cursor', 'mcp.json');
81
+ }
82
+ export async function runEnterpriseInstall(opts) {
83
+ const seedPath = path.resolve(opts.seedPath);
84
+ const seedDir = path.dirname(seedPath);
85
+ const seed = loadSeed(seedPath);
86
+ const scope = opts.scope ?? 'global';
87
+ const endpoint = seed.memdb.endpoint ?? process.env.MEMTRACE_MEMDB_ENDPOINT;
88
+ if (!endpoint) {
89
+ throw new Error('memdb.endpoint required in seed or MEMTRACE_MEMDB_ENDPOINT env');
90
+ }
91
+ const writerToken = loadWriterToken(seedDir);
92
+ const licenseKey = resolveLicenseKey(seed, seedDir);
93
+ const binary = opts.binary ?? (await resolveMemtraceBinary()) ?? 'memtrace';
94
+ console.log('\n Memtrace Enterprise Installer\n');
95
+ console.log(` Org: ${seed.orgId}`);
96
+ console.log(` MemDB: ${endpoint}`);
97
+ console.log(` Database: ${seed.memdb.database}`);
98
+ console.log(` Scope: ${scope}\n`);
99
+ console.log(' Verifying enterprise subscription…');
100
+ await verifySubscriptionWithApi(seed.orgId, licenseKey);
101
+ console.log(' ✓ Subscription active');
102
+ await validateRemoteEndpoint(endpoint, writerToken);
103
+ const mcpFile = mcpPath(scope);
104
+ const { value, corrupted, backupPath } = safeReadJson(mcpFile);
105
+ if (corrupted) {
106
+ console.warn(` ⚠ ${mcpFile} malformed; backed up to ${backupPath}`);
107
+ }
108
+ const cfg = (value ?? {});
109
+ cfg.mcpServers = cfg.mcpServers ?? {};
110
+ cfg.mcpServers.memtrace = {
111
+ command: binary,
112
+ args: ['mcp'],
113
+ env: {
114
+ MEMTRACE_LICENSE_KEY: licenseKey,
115
+ MEMTRACE_MEMDB_MODE: 'external',
116
+ MEMTRACE_MEMDB_ENDPOINT: endpoint,
117
+ MEMTRACE_MEMDB_AUTH_TOKEN: writerToken,
118
+ MEMTRACE_MEMDB_DB: seed.memdb.database,
119
+ MEMTRACE_WORKSPACE_ROOT: '${workspaceFolder}',
120
+ },
121
+ };
122
+ writeJsonAtomic(mcpFile, cfg);
123
+ console.log(` ✓ Wrote enterprise MCP config → ${mcpFile}`);
124
+ // Also register via cursor helper when file is the standard cursor path
125
+ if (mcpFile.endsWith('.cursor/mcp.json')) {
126
+ registerCursorMcpAt(mcpFile, binary);
127
+ }
128
+ console.log('\n Done. Restart Cursor and run `memtrace auth login` if needed.\n');
129
+ }
@@ -3,6 +3,7 @@ import { Command } from 'commander';
3
3
  import { runInstall, runUninstall } from './commands/install.js';
4
4
  import { runDoctor } from './commands/doctor.js';
5
5
  import { runInstallRail, runUninstallRail } from './commands/rail-install.js';
6
+ import { runEnterpriseInstall } from './commands/enterprise-install.js';
6
7
  const program = new Command();
7
8
  program
8
9
  .name('memtrace-skills')
@@ -46,6 +47,21 @@ program
46
47
  only: opts.only,
47
48
  });
48
49
  });
50
+ program
51
+ .command('install-enterprise')
52
+ .description('Configure Memtrace MCP for self-hosted enterprise MemDB (remote mode)')
53
+ .requiredOption('--seed <path>', 'path to enterprise seed.json from your admin bundle')
54
+ .option('--local', 'write MCP config to the current project', false)
55
+ .option('--binary <path>', 'memtrace executable (default: on PATH)')
56
+ .option('-y, --yes', 'non-interactive')
57
+ .action(async (opts) => {
58
+ await runEnterpriseInstall({
59
+ seedPath: opts.seed,
60
+ scope: opts.local ? 'local' : 'global',
61
+ binary: opts.binary,
62
+ yes: opts.yes,
63
+ });
64
+ });
49
65
  program
50
66
  .command('install-rail')
51
67
  .description('Wire Memtrace Rail discovery hooks for Claude, Cursor, Codex, Gemini, OpenCode')
@@ -224,6 +224,10 @@ const RAIL_HOOK_MATCHER = 'Grep|Glob|Bash';
224
224
  /** Substring that identifies a memtrace-owned Rail hook entry (for idempotent
225
225
  * re-register + clean removal). */
226
226
  const RAIL_HOOK_MARKER = 'route --hook';
227
+ const LEGACY_HOOK_SCRIPT_BASENAMES = [
228
+ 'userprompt-claude.sh',
229
+ 'posttool-mcp-telemetry.sh',
230
+ ];
227
231
  /**
228
232
  * Register the Memtrace Rail PreToolUse hook in a Claude settings.json.
229
233
  *
@@ -245,12 +249,13 @@ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary) {
245
249
  return { registered: false, backupPath };
246
250
  }
247
251
  const settings = (value ?? {});
248
- settings.hooks = settings.hooks ?? {};
252
+ settings.hooks = isRecord(settings.hooks) ? settings.hooks : {};
253
+ pruneMemtraceOwnedHookEntries(settings.hooks);
249
254
  const existing = Array.isArray(settings.hooks.PreToolUse)
250
255
  ? settings.hooks.PreToolUse
251
256
  : [];
252
257
  // Drop any prior memtrace Rail entry so re-install doesn't duplicate it.
253
- const preserved = existing.filter((entry) => !isRailHookEntry(entry));
258
+ const preserved = existing.filter((entry) => !isMemtraceOwnedHookEntry(entry));
254
259
  preserved.push({
255
260
  matcher: RAIL_HOOK_MATCHER,
256
261
  hooks: [
@@ -266,9 +271,46 @@ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary) {
266
271
  }
267
272
  /** True when a PreToolUse entry is a memtrace-owned Rail hook. */
268
273
  function isRailHookEntry(entry) {
269
- if (!isRecord(entry) || !Array.isArray(entry.hooks))
270
- return false;
271
- return entry.hooks.some((h) => isRecord(h) && typeof h.command === 'string' && h.command.includes(RAIL_HOOK_MARKER));
274
+ return hookCommands(entry).some((command) => command.includes(RAIL_HOOK_MARKER));
275
+ }
276
+ function isLegacyMemtraceHookEntry(entry) {
277
+ return hookCommands(entry).some((command) => (LEGACY_HOOK_SCRIPT_BASENAMES.some((basename) => command.includes(basename))));
278
+ }
279
+ function isMemtraceOwnedHookEntry(entry) {
280
+ return isRailHookEntry(entry) || isLegacyMemtraceHookEntry(entry);
281
+ }
282
+ function hookCommands(entry) {
283
+ if (!isRecord(entry))
284
+ return [];
285
+ const commands = [];
286
+ if (typeof entry.command === 'string') {
287
+ commands.push(entry.command);
288
+ }
289
+ if (Array.isArray(entry.hooks)) {
290
+ for (const hook of entry.hooks) {
291
+ if (isRecord(hook) && typeof hook.command === 'string') {
292
+ commands.push(hook.command);
293
+ }
294
+ }
295
+ }
296
+ return commands;
297
+ }
298
+ function pruneMemtraceOwnedHookEntries(hooks) {
299
+ let changed = false;
300
+ for (const key of Object.keys(hooks)) {
301
+ const entries = hooks[key];
302
+ if (!Array.isArray(entries))
303
+ continue;
304
+ const preserved = entries.filter((entry) => !isMemtraceOwnedHookEntry(entry));
305
+ if (preserved.length === entries.length)
306
+ continue;
307
+ changed = true;
308
+ hooks[key] = preserved.length === 0 ? undefined : preserved;
309
+ }
310
+ return changed;
311
+ }
312
+ function hasHookSettings(hooks) {
313
+ return Object.values(hooks).some((entries) => (entries !== undefined && (!Array.isArray(entries) || entries.length > 0)));
272
314
  }
273
315
  /**
274
316
  * Remove the Memtrace Rail PreToolUse hook from a Claude settings.json.
@@ -361,21 +403,11 @@ export function removeClaudeSettingsEntriesAt(settingsPath) {
361
403
  if (Object.keys(mcpServers).length === 0)
362
404
  delete settings.mcpServers;
363
405
  }
364
- // ─── memtrace-rail ─── Remove the Rail PreToolUse hook on uninstall.
365
- if (isRecord(settings.hooks) && Array.isArray(settings.hooks.PreToolUse)) {
366
- const pre = settings.hooks.PreToolUse;
367
- const after = pre.filter((entry) => !isRailHookEntry(entry));
368
- if (after.length !== pre.length) {
369
- changed = true;
370
- if (after.length === 0) {
371
- delete settings.hooks.PreToolUse;
372
- }
373
- else {
374
- settings.hooks.PreToolUse = after;
375
- }
376
- if (Object.keys(settings.hooks).length === 0)
377
- delete settings.hooks;
378
- }
406
+ // ─── memtrace-rail ─── Remove current Rail and legacy telemetry hooks on uninstall.
407
+ if (isRecord(settings.hooks)) {
408
+ changed = pruneMemtraceOwnedHookEntries(settings.hooks) || changed;
409
+ if (!hasHookSettings(settings.hooks))
410
+ settings.hooks = undefined;
379
411
  }
380
412
  for (const containerName of MARKETPLACE_SETTING_CONTAINERS) {
381
413
  const container = settings[containerName];
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "memtrace-skills",
3
- "version": "0.1.0",
3
+ "version": "0.7.20",
4
4
  "description": "Memtrace skills for AI coding agents — codebase exploration, temporal evolution, impact analysis, and more.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "memtrace-skills": "./dist/index.js"
7
+ "memtrace-skills": "dist/index.js"
8
8
  },
9
9
  "files": [
10
10
  "dist/",
@@ -37,7 +37,7 @@
37
37
  "license": "SEE LICENSE IN LICENSE",
38
38
  "repository": {
39
39
  "type": "git",
40
- "url": "https://github.com/syncable-dev/memtrace"
40
+ "url": "git+https://github.com/syncable-dev/memtrace.git"
41
41
  },
42
42
  "keywords": [
43
43
  "memtrace",
@@ -87,6 +87,72 @@ const CLAUDE_MD_BREADCRUMB = CLAUDE_MD_BREADCRUMB_LINES.join("\n");
87
87
 
88
88
  const HOOK_MANAGED_TAG = "memtrace";
89
89
 
90
+ // ── Pure functions: hook command string (cross-platform) ──────────────
91
+
92
+ /**
93
+ * Build the `command` string Claude Code runs for one of our hook
94
+ * scripts. Pure — the platform is injected so the win32 branch is
95
+ * unit-testable on any host.
96
+ *
97
+ * Claude Code executes hook commands through Git Bash on Windows. Two
98
+ * things break a bare `path.join(hooksDir, script)` value there
99
+ * (issue #34):
100
+ *
101
+ * 1. A bare `.sh` path is not directly executable on Windows — it has
102
+ * to be handed to `bash`.
103
+ * 2. `path.join` yields backslashes (`C:\Users\…`), and bash eats
104
+ * backslashes as escape sequences, mangling the path into a
105
+ * "No such file or directory" on every UserPromptSubmit and every
106
+ * `mcp__memtrace__*` PostToolUse.
107
+ *
108
+ * The form that works under Git Bash is:
109
+ * bash "C:/Users/…/userprompt-claude.sh"
110
+ * (forward slashes, double-quoted, `bash ` prefix).
111
+ *
112
+ * On non-win32 (mac/linux) we keep the existing bare absolute path —
113
+ * those scripts are executable via their shebang and existing installs
114
+ * must not regress.
115
+ *
116
+ * @param {string} hooksDir absolute directory containing the hook scripts
117
+ * @param {string} scriptName e.g. `"userprompt-claude.sh"`
118
+ * @param {string} [platform] defaults to `process.platform`; pass
119
+ * `"win32"` to exercise the Windows branch in
120
+ * tests without running on Windows
121
+ * @returns {string}
122
+ */
123
+ function hookCommandString(hooksDir, scriptName, platform = process.platform) {
124
+ if (platform === "win32") {
125
+ // Join with the win32 semantics so a test on POSIX still produces
126
+ // a Windows-shaped path, then normalise every separator to '/'.
127
+ const joined = path.win32.join(hooksDir, scriptName).replace(/\\/g, "/");
128
+ return `bash "${joined}"`;
129
+ }
130
+ return path.join(hooksDir, scriptName);
131
+ }
132
+
133
+ /**
134
+ * Pull the hook script basename out of a `command` string, robust to
135
+ * BOTH a bare path AND a `bash "<path>"`-wrapped command, with either
136
+ * '/' or '\\' separators and surrounding/embedded quotes (issue #34).
137
+ *
138
+ * `path.basename` alone is not enough once we emit `bash "C:/…/x.sh"`:
139
+ * its basename is `x.sh"` (trailing quote) on POSIX, and it doesn't
140
+ * understand backslash separators off-Windows. Both break the dedup
141
+ * that keeps installs idempotent. We strip quotes, normalise '\\'→'/',
142
+ * and take the final segment so the bare and wrapped forms collapse to
143
+ * the same basename.
144
+ *
145
+ * @param {string} command
146
+ * @returns {string|null}
147
+ */
148
+ function hookScriptBasename(command) {
149
+ if (typeof command !== "string") return null;
150
+ const cleaned = command.replace(/["']/g, "").replace(/\\/g, "/").trim();
151
+ const segments = cleaned.split("/");
152
+ const last = segments[segments.length - 1];
153
+ return last || null;
154
+ }
155
+
90
156
  // ── Pure functions: MEMTRACE.md content ───────────────────────────────
91
157
 
92
158
  /**
@@ -184,13 +250,13 @@ function applySettingsHooks(settings, hooksDir) {
184
250
 
185
251
  const userPromptHook = {
186
252
  type: "command",
187
- command: path.join(hooksDir, "userprompt-claude.sh"),
253
+ command: hookCommandString(hooksDir, "userprompt-claude.sh"),
188
254
  _managed_by: HOOK_MANAGED_TAG,
189
255
  _hook_kind: "userprompt-advisory",
190
256
  };
191
257
  const postToolHook = {
192
258
  type: "command",
193
- command: path.join(hooksDir, "posttool-mcp-telemetry.sh"),
259
+ command: hookCommandString(hooksDir, "posttool-mcp-telemetry.sh"),
194
260
  _managed_by: HOOK_MANAGED_TAG,
195
261
  _hook_kind: "posttool-mcp-telemetry",
196
262
  };
@@ -269,15 +335,19 @@ function applySettingsHooks(settings, hooksDir) {
269
335
  * @returns {Array}
270
336
  */
271
337
  function mergeHookList(existing, ourEntry, ourHook) {
272
- const ourBasename =
273
- typeof ourHook.command === "string" ? path.basename(ourHook.command) : null;
338
+ // Use the quote/separator-robust extractor (not raw path.basename) so
339
+ // a `bash "C:/…/x.sh"`-wrapped Windows command (issue #34) collapses to
340
+ // the same basename as the bare POSIX form — otherwise path.basename
341
+ // would yield `x.sh"` (trailing quote) and the dedup would stop
342
+ // recognising the working Windows form, re-injecting a duplicate.
343
+ const ourBasename = hookScriptBasename(ourHook.command);
274
344
  const isOurs = (h) =>
275
345
  !!h && (
276
346
  (h._managed_by === HOOK_MANAGED_TAG && h._hook_kind === ourHook._hook_kind) ||
277
347
  (typeof h.command === "string" && h.command === ourHook.command) ||
278
348
  (ourBasename !== null &&
279
349
  typeof h.command === "string" &&
280
- path.basename(h.command) === ourBasename)
350
+ hookScriptBasename(h.command) === ourBasename)
281
351
  );
282
352
 
283
353
  // Phase 1: walk every block, strip every "ours" hook, drop blocks
@@ -499,6 +569,8 @@ module.exports = {
499
569
  CLAUDE_MD_BREADCRUMB,
500
570
 
501
571
  // Pure functions
572
+ hookCommandString,
573
+ hookScriptBasename,
502
574
  buildMemtraceMdContent,
503
575
  applyClaudeMdBreadcrumb,
504
576
  removeClaudeMdBreadcrumb,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memtrace",
3
- "version": "0.7.13",
3
+ "version": "0.7.20",
4
4
  "description": "Code intelligence graph — MCP server + AI agent skills + visualization UI",
5
5
  "keywords": [
6
6
  "mcp",
@@ -39,12 +39,12 @@
39
39
  "fs-extra": "^11.0.0"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@memtrace/darwin-arm64": "0.7.13",
43
- "@memtrace/linux-x64": "0.7.13",
44
- "@memtrace/linux-arm64": "0.7.13",
45
- "@memtrace/win32-x64": "0.7.13",
46
- "@memtrace/linux-x64-noavx2": "0.7.13",
47
- "@memtrace/win32-x64-noavx2": "0.7.13"
42
+ "@memtrace/darwin-arm64": "0.7.20",
43
+ "@memtrace/linux-x64": "0.7.20",
44
+ "@memtrace/linux-arm64": "0.7.20",
45
+ "@memtrace/win32-x64": "0.7.20",
46
+ "@memtrace/linux-x64-noavx2": "0.7.20",
47
+ "@memtrace/win32-x64-noavx2": "0.7.20"
48
48
  },
49
49
  "engines": {
50
50
  "node": ">=18"
package/uninstall.js CHANGED
@@ -131,7 +131,12 @@ function isMemtraceHook(h) {
131
131
  if (typeof h.command === "string") {
132
132
  if (h.command.includes(MT_RAIL_HOOK_MARKER)) return true;
133
133
  for (const script of MT_HOOK_SCRIPTS) {
134
- if (h.command.endsWith(script)) return true;
134
+ // `.includes`, not `.endsWith`: the Windows form is wrapped as
135
+ // `bash "C:/…/userprompt-claude.sh"` (issue #34), so the command
136
+ // ends in a double-quote, not the bare script name. `.includes`
137
+ // recognises both the bare POSIX path and the bash-wrapped Windows
138
+ // command so uninstall removes either form.
139
+ if (h.command.includes(script)) return true;
135
140
  }
136
141
  }
137
142
  return false;