@senoldogann/context-manager 0.2.0 → 0.3.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/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  **Node.js wrapper for Cognitive Codebase Matrix (CCM)** - Enables AI agents to understand and navigate your codebase with surgical precision.
6
6
 
7
+ **v0.2.1** fixes UTF-8 indexing crashes, stale Codex MCP entries, interrupted binary downloads, and missing cross-file class/import usages.
8
+
7
9
  [![npm](https://img.shields.io/npm/v/@senoldogann/context-manager?color=orange)](https://www.npmjs.com/package/@senoldogann/context-manager)
8
10
  [![MCP](https://img.shields.io/badge/MCP-Compatible-blue)](https://modelcontextprotocol.io/)
9
11
  [![Rust](https://img.shields.io/badge/Built%20With-Rust-orange.svg)](https://www.rust-lang.org/)
@@ -45,6 +47,8 @@ npx @senoldogann/context-manager mcp
45
47
 
46
48
  If the wrapper is healthy, the query command should return code context and the MCP process should stay open waiting for stdio messages.
47
49
 
50
+ The first index is complete; subsequent runs are incremental and process only added, changed, renamed, or deleted files. An unchanged project returns a clean no-op.
51
+
48
52
  ---
49
53
 
50
54
  ## 📖 What is CCM?
@@ -67,6 +71,7 @@ The npm wrapper downloads pre-built binaries and passes commands through:
67
71
  | `npx @senoldogann/context-manager index --path <dir>` | Index a project |
68
72
  | `npx @senoldogann/context-manager query --text "..."` | Search codebase |
69
73
  | `npx @senoldogann/context-manager mcp` | Run MCP server directly |
74
+ | `npx @senoldogann/context-manager doctor --path <dir>` | Diagnose config and index health |
70
75
  | `npx @senoldogann/context-manager eval --tasks <file>` | Run evaluation tasks |
71
76
 
72
77
  ### Supported Hosts
@@ -138,6 +143,10 @@ CCM_EMBED_DATA_FILES=0
138
143
 
139
144
  # Binary checksum verification (0 = enforce, 1 = bypass)
140
145
  CCM_ALLOW_UNVERIFIED_BINARIES=0
146
+
147
+ # Optional binary download tuning
148
+ CCM_DOWNLOAD_TIMEOUT_MS=120000
149
+ CCM_DOWNLOAD_ATTEMPTS=3
141
150
  ```
142
151
 
143
152
  Advanced overrides:
@@ -167,16 +176,27 @@ Once configured, ask your AI agent:
167
176
  This package handles:
168
177
 
169
178
  1. OS/architecture detection
170
- 2. Binary download from GitHub Releases
179
+ 2. Resumable, retrying compressed binary download from GitHub Releases
171
180
  3. Global persistence in `~/.ccm`
172
181
 
182
+ For a manual maintainer release, run the commands from this `npm/` directory:
183
+
184
+ ```bash
185
+ npm test
186
+ npm pack --dry-run
187
+ npm publish --access public --provenance
188
+ ```
189
+
190
+ Running `npm publish` from the repository root fails because the root intentionally has no `package.json`.
191
+
173
192
  ### ✅ Release Reliability
174
193
 
175
194
  - GitHub Releases publish platform binaries plus `checksums.txt`
176
195
  - The npm wrapper verifies checksums before using downloaded binaries
196
+ - Interrupted downloads resume from their partial file and use a bounded timeout/retry policy
177
197
  - Redirects are restricted to approved GitHub release hosts
178
198
  - Release builds run for Linux, macOS, and Windows before assets are attached
179
- - npm publication is a manual step from `npm/` after the GitHub Release assets are attached
199
+ - Codex configuration is updated directly and atomically, even when the `codex` wrapper is broken
180
200
  - The same smoke path is documented here and in the main repository README
181
201
 
182
202
  ### ✅ Binary Integrity
@@ -197,6 +217,15 @@ Enable `CCM_EMBED_DATA_FILES=1` to include them in semantic search.
197
217
 
198
218
  ## 📝 Changelog
199
219
 
220
+ ### v0.2.1
221
+ - ✅ UTF-8-safe semantic chunking prevents panics on Turkish, Finnish, and emoji content
222
+ - ✅ Cross-file imports, constructors, and type annotations contribute to usage and impact analysis
223
+ - ✅ `get_context` returns the enclosing function/class instead of only a leaf assignment
224
+ - ✅ Incremental `diff_context` includes staged, unstaged, and untracked worktree files
225
+ - ✅ Compressed release binaries, download resume/retry/timeout, Linux ARM64 detection
226
+ - ✅ Codex MCP entries are repaired and enabled without depending on the Codex CLI binary
227
+ - ✅ Release tags run tests, lint, npm tests, indexing, and golden tasks v3 before publishing assets
228
+
200
229
  ### v0.1.31
201
230
  - ✅ `index_project` is idempotent again and ignores CCM's own generated index files
202
231
  - ✅ `search_code` falls back cleanly when embeddings are disabled
package/bin/ccm.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn, spawnSync } = require('child_process');
3
+ const { spawn } = require('child_process');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
7
  const https = require('https');
8
8
  const crypto = require('crypto');
9
+ const zlib = require('zlib');
10
+ const { pipeline } = require('stream/promises');
9
11
 
10
12
  function resolvePackageVersion() {
11
13
  if (process.env.CCM_BINARY_VERSION && process.env.CCM_BINARY_VERSION.trim() !== '') {
@@ -33,16 +35,15 @@ const VERSION = resolvePackageVersion();
33
35
  const REPO = 'senoldogann/LLM-Context-Manager';
34
36
  const BIN_DIR = path.join(os.homedir(), '.ccm', 'bin');
35
37
  const CHECKSUMS_FILE = 'checksums.txt';
38
+ const DOWNLOAD_TIMEOUT_MS = positiveInteger(process.env.CCM_DOWNLOAD_TIMEOUT_MS, 120_000);
39
+ const DOWNLOAD_ATTEMPTS = positiveInteger(process.env.CCM_DOWNLOAD_ATTEMPTS, 3);
36
40
  let checksumCache = null;
37
41
 
38
42
  const MCP_SERVER_NAME = 'context-manager';
39
43
  const MCP_COMMAND = 'npx';
40
44
  const MCP_ARGS = ['-y', `@senoldogann/context-manager@${VERSION}`, 'mcp'];
41
45
  const MCP_ENV = {
42
- RUST_LOG: 'info',
43
- CCM_PROJECT_ROOT: process.cwd(),
44
- CCM_ALLOWED_ROOTS: process.cwd(),
45
- CCM_REQUIRE_ALLOWED_ROOTS: '1'
46
+ RUST_LOG: 'info'
46
47
  };
47
48
  const ALLOWED_REDIRECT_HOSTS = new Set([
48
49
  'github.com',
@@ -50,6 +51,11 @@ const ALLOWED_REDIRECT_HOSTS = new Set([
50
51
  'release-assets.githubusercontent.com'
51
52
  ]);
52
53
 
54
+ function positiveInteger(raw, fallback) {
55
+ const value = Number(raw);
56
+ return Number.isSafeInteger(value) && value > 0 ? value : fallback;
57
+ }
58
+
53
59
  function allowUnverifiedBinaries() {
54
60
  const raw = process.env.CCM_ALLOW_UNVERIFIED_BINARIES || process.env.CCM_SKIP_CHECKSUM || '';
55
61
  return ['1', 'true', 'yes'].includes(raw.toLowerCase());
@@ -156,82 +162,92 @@ function writeJsonAtomic(configPath, value) {
156
162
  }
157
163
 
158
164
  function installCodexConfig() {
159
- const listResult = spawnSync('codex', ['mcp', 'list', '--json'], {
160
- encoding: 'utf8'
161
- });
162
-
163
- if (listResult.error) {
164
- return false;
165
- }
166
-
167
- if (listResult.status !== 0) {
168
- console.warn(`[CCM] Codex MCP inspection failed: ${listResult.stderr.trim()}`);
169
- return false;
170
- }
171
-
172
- let existingServers = [];
173
- try {
174
- existingServers = JSON.parse(listResult.stdout);
175
- } catch (error) {
176
- console.warn('[CCM] Could not parse Codex MCP list output.');
165
+ const codexDirectory = path.join(os.homedir(), '.codex');
166
+ if (!fs.existsSync(codexDirectory)) {
177
167
  return false;
178
168
  }
169
+ const configPath = path.join(codexDirectory, 'config.toml');
170
+ installCodexTomlConfig(configPath, process.cwd(), VERSION);
171
+ console.log('[CCM] ✓ Successfully updated: ~/.codex/config.toml');
172
+ return true;
173
+ }
179
174
 
180
- const existing = existingServers.find((server) => server.name === MCP_SERVER_NAME);
181
- if (existing) {
182
- console.log('[CCM] Codex MCP entry already exists; leaving it unchanged.');
183
- return true;
175
+ function installCodexTomlConfig(configPath, projectRoot, version) {
176
+ let content = '';
177
+ if (fs.existsSync(configPath)) {
178
+ content = fs.readFileSync(configPath, 'utf8');
179
+ fs.copyFileSync(configPath, `${configPath}.bak`);
184
180
  }
185
181
 
186
- const addArgs = [
187
- 'mcp',
188
- 'add',
189
- MCP_SERVER_NAME,
190
- '--env',
191
- 'RUST_LOG=info',
192
- '--env',
193
- `CCM_PROJECT_ROOT=${process.cwd()}`,
194
- '--env',
195
- `CCM_ALLOWED_ROOTS=${process.cwd()}`,
196
- '--env',
197
- 'CCM_REQUIRE_ALLOWED_ROOTS=1',
198
- '--',
199
- MCP_COMMAND,
200
- ...MCP_ARGS
201
- ];
202
- const addResult = spawnSync('codex', addArgs, {
203
- encoding: 'utf8'
204
- });
205
-
206
- if (addResult.status !== 0) {
207
- console.warn(`[CCM] Codex MCP install failed: ${addResult.stderr.trim()}`);
208
- return false;
182
+ const sectionPrefix = `mcp_servers.${MCP_SERVER_NAME}`;
183
+ const lines = content.split(/\r?\n/);
184
+ const preserved = [];
185
+ let removing = false;
186
+ for (const line of lines) {
187
+ const header = line.trim().match(/^\[([^\]]+)\]$/);
188
+ if (header) {
189
+ removing = header[1] === sectionPrefix || header[1].startsWith(`${sectionPrefix}.`);
190
+ }
191
+ if (!removing) {
192
+ preserved.push(line);
193
+ }
209
194
  }
210
195
 
211
- console.log('[CCM] Successfully updated: ~/.codex/config.toml');
212
- return true;
196
+ const quote = (value) => JSON.stringify(String(value));
197
+ const block = [
198
+ `[${sectionPrefix}]`,
199
+ `command = ${quote(MCP_COMMAND)}`,
200
+ `args = [${['-y', `@senoldogann/context-manager@${version}`, 'mcp'].map(quote).join(', ')}]`,
201
+ 'enabled = true',
202
+ '',
203
+ `[${sectionPrefix}.env]`,
204
+ `RUST_LOG = ${quote('info')}`,
205
+ `CCM_PROJECT_ROOT = ${quote(projectRoot)}`,
206
+ `CCM_ALLOWED_ROOTS = ${quote(projectRoot)}`,
207
+ `CCM_REQUIRE_ALLOWED_ROOTS = ${quote('1')}`
208
+ ].join('\n');
209
+
210
+ const next = `${preserved.join('\n').trimEnd()}\n\n${block}\n`.replace(/^\n+/, '');
211
+ writeTextAtomic(configPath, next);
213
212
  }
214
213
 
215
214
  function createUniqueTmpPath(binPath) {
216
215
  return `${binPath}.${process.pid}.${Date.now()}.tmp`;
217
216
  }
218
217
 
218
+ function writeTextAtomic(filePath, content) {
219
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
220
+ try {
221
+ fs.writeFileSync(tempPath, content, { encoding: 'utf8', mode: 0o600 });
222
+ fs.renameSync(tempPath, filePath);
223
+ } catch (error) {
224
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
225
+ throw error;
226
+ }
227
+ }
228
+
219
229
  async function getBinaryFor(commandName) {
220
230
  const platform = os.platform();
221
231
  const arch = os.arch();
222
232
 
223
- let target = '';
233
+ let target;
224
234
  if (platform === 'darwin') {
225
- target = arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
235
+ if (arch === 'arm64') target = 'aarch64-apple-darwin';
236
+ if (arch === 'x64') target = 'x86_64-apple-darwin';
226
237
  } else if (platform === 'linux') {
227
- target = 'x86_64-unknown-linux-gnu';
238
+ if (arch === 'arm64') target = 'aarch64-unknown-linux-gnu';
239
+ if (arch === 'x64') target = 'x86_64-unknown-linux-gnu';
228
240
  } else if (platform === 'win32') {
229
- target = 'x86_64-pc-windows-msvc.exe';
241
+ if (arch === 'x64') target = 'x86_64-pc-windows-msvc.exe';
242
+ }
243
+ if (!target) {
244
+ throw new Error(`Unsupported platform: ${platform}/${arch}`);
230
245
  }
231
246
 
232
247
  const binFilename = `${commandName}-v${VERSION}-${target}`;
233
248
  const binPath = path.join(BIN_DIR, binFilename);
234
249
  const remoteFilename = `${commandName}-${target}`;
250
+ const compressedFilename = `${remoteFilename}.gz`;
235
251
 
236
252
  // If file exists, ensure it is executable
237
253
  if (fs.existsSync(binPath)) {
@@ -249,12 +265,29 @@ async function getBinaryFor(commandName) {
249
265
  fs.mkdirSync(BIN_DIR, { recursive: true });
250
266
  }
251
267
 
252
- const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${commandName}-${target}`;
253
268
  const tmpPath = createUniqueTmpPath(binPath);
269
+ const compressedPath = `${binPath}.gz.download`;
270
+ const rawPath = `${binPath}.download`;
254
271
 
255
272
  try {
256
- await downloadFile(url, tmpPath);
257
- await verifyChecksum(tmpPath, [remoteFilename, binFilename]);
273
+ const compressedUrl =
274
+ `https://github.com/${REPO}/releases/download/v${VERSION}/${compressedFilename}`;
275
+ try {
276
+ await downloadFileWithRetry(compressedUrl, compressedPath);
277
+ await verifyChecksum(compressedPath, [compressedFilename]);
278
+ await extractGzip(compressedPath, tmpPath);
279
+ fs.unlinkSync(compressedPath);
280
+ } catch (error) {
281
+ if (error.statusCode !== 404) {
282
+ throw error;
283
+ }
284
+ if (fs.existsSync(compressedPath)) fs.unlinkSync(compressedPath);
285
+ const rawUrl =
286
+ `https://github.com/${REPO}/releases/download/v${VERSION}/${remoteFilename}`;
287
+ await downloadFileWithRetry(rawUrl, rawPath);
288
+ await verifyChecksum(rawPath, [remoteFilename, binFilename]);
289
+ fs.renameSync(rawPath, tmpPath);
290
+ }
258
291
  fs.chmodSync(tmpPath, '755');
259
292
  if (fs.existsSync(binPath)) {
260
293
  fs.unlinkSync(tmpPath);
@@ -263,12 +296,24 @@ async function getBinaryFor(commandName) {
263
296
  fs.renameSync(tmpPath, binPath);
264
297
  } catch (err) {
265
298
  if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
299
+ if (/checksum|gzip|header|unexpected end/i.test(err.message || '')) {
300
+ if (fs.existsSync(compressedPath)) fs.unlinkSync(compressedPath);
301
+ if (fs.existsSync(rawPath)) fs.unlinkSync(rawPath);
302
+ }
266
303
  throw err;
267
304
  }
268
305
 
269
306
  return binPath;
270
307
  }
271
308
 
309
+ async function extractGzip(source, destination) {
310
+ await pipeline(
311
+ fs.createReadStream(source),
312
+ zlib.createGunzip(),
313
+ fs.createWriteStream(destination)
314
+ );
315
+ }
316
+
272
317
  async function getBinary() {
273
318
  // Detect which tool to run
274
319
  let commandName = 'ccm-cli';
@@ -287,50 +332,87 @@ async function getBinary() {
287
332
  return await getBinaryFor(commandName);
288
333
  }
289
334
 
290
- function downloadFile(url, dest) {
335
+ function downloadFile(url, dest, redirectsRemaining = 5) {
291
336
  return new Promise((resolve, reject) => {
292
337
  fs.mkdirSync(path.dirname(dest), { recursive: true });
293
- https.get(url, (response) => {
338
+ const existingBytes = fs.existsSync(dest) ? fs.statSync(dest).size : 0;
339
+ const headers = existingBytes > 0 ? { Range: `bytes=${existingBytes}-` } : {};
340
+ const request = https.get(url, { headers }, (response) => {
294
341
  if (response.statusCode === 302 || response.statusCode === 301) {
342
+ response.resume();
343
+ if (redirectsRemaining <= 0) {
344
+ return reject(new Error('Too many redirects while downloading binary'));
345
+ }
295
346
  try {
296
347
  const redirectUrl = resolveRedirectUrl(url, response.headers.location);
297
- return downloadFile(redirectUrl, dest).then(resolve).catch(reject);
348
+ return downloadFile(redirectUrl, dest, redirectsRemaining - 1)
349
+ .then(resolve)
350
+ .catch(reject);
298
351
  } catch (error) {
299
352
  return reject(error);
300
353
  }
301
354
  }
302
- if (response.statusCode !== 200) {
303
- return reject(new Error(`Failed to download: ${response.statusCode}`));
355
+ if (response.statusCode === 416 && existingBytes > 0) {
356
+ response.resume();
357
+ return resolve();
304
358
  }
305
- const file = fs.createWriteStream(dest);
306
- response.pipe(file);
307
- file.on('finish', () => {
308
- file.close(resolve);
309
- });
310
- file.on('error', (err) => {
311
- fs.unlink(dest, () => { });
312
- reject(err);
313
- });
314
- }).on('error', (err) => {
315
- fs.unlink(dest, () => { });
359
+ if (response.statusCode !== 200 && response.statusCode !== 206) {
360
+ response.resume();
361
+ const error = new Error(`Failed to download: ${response.statusCode}`);
362
+ error.statusCode = response.statusCode;
363
+ return reject(error);
364
+ }
365
+ const append = response.statusCode === 206 && existingBytes > 0;
366
+ const file = fs.createWriteStream(dest, { flags: append ? 'a' : 'w' });
367
+ pipeline(response, file).then(resolve).catch(reject);
368
+ });
369
+ request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
370
+ request.destroy(new Error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS}ms`));
371
+ });
372
+ request.on('error', (err) => {
316
373
  reject(err);
317
374
  });
318
375
  });
319
376
  }
320
377
 
321
- function downloadText(url) {
378
+ async function downloadFileWithRetry(url, dest, attempts = DOWNLOAD_ATTEMPTS) {
379
+ let lastError;
380
+ for (let attempt = 1; attempt <= attempts; attempt++) {
381
+ try {
382
+ await downloadFile(url, dest);
383
+ return;
384
+ } catch (error) {
385
+ lastError = error;
386
+ if (error.statusCode === 404 || attempt === attempts) break;
387
+ console.warn(`[CCM] Download interrupted; retrying (${attempt}/${attempts})...`);
388
+ await new Promise((resolve) => setTimeout(resolve, attempt * 500));
389
+ }
390
+ }
391
+ throw lastError;
392
+ }
393
+
394
+ function downloadText(url, redirectsRemaining = 5) {
322
395
  return new Promise((resolve, reject) => {
323
- https.get(url, (response) => {
396
+ const request = https.get(url, (response) => {
324
397
  if (response.statusCode === 302 || response.statusCode === 301) {
398
+ response.resume();
399
+ if (redirectsRemaining <= 0) {
400
+ return reject(new Error('Too many redirects while downloading checksums'));
401
+ }
325
402
  try {
326
403
  const redirectUrl = resolveRedirectUrl(url, response.headers.location);
327
- return downloadText(redirectUrl).then(resolve).catch(reject);
404
+ return downloadText(redirectUrl, redirectsRemaining - 1)
405
+ .then(resolve)
406
+ .catch(reject);
328
407
  } catch (error) {
329
408
  return reject(error);
330
409
  }
331
410
  }
332
411
  if (response.statusCode !== 200) {
333
- return reject(new Error(`Failed to download: ${response.statusCode}`));
412
+ response.resume();
413
+ const error = new Error(`Failed to download: ${response.statusCode}`);
414
+ error.statusCode = response.statusCode;
415
+ return reject(error);
334
416
  }
335
417
  let data = '';
336
418
  response.setEncoding('utf8');
@@ -338,7 +420,11 @@ function downloadText(url) {
338
420
  data += chunk;
339
421
  });
340
422
  response.on('end', () => resolve(data));
341
- }).on('error', reject);
423
+ });
424
+ request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
425
+ request.destroy(new Error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS}ms`));
426
+ });
427
+ request.on('error', reject);
342
428
  });
343
429
  }
344
430
 
@@ -453,6 +539,8 @@ if (require.main === module) {
453
539
  module.exports = {
454
540
  MCP_ARGS,
455
541
  MCP_ENV,
542
+ extractGzip,
543
+ installCodexTomlConfig,
456
544
  installJsonConfig,
457
545
  parseChecksums,
458
546
  resolveRedirectUrl,
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@senoldogann/context-manager",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "LLM Context Manager MCP Server & CLI wrapper using npx",
5
- "repository": "https://github.com/senoldogann/LLM-Context-Manager",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/senoldogann/LLM-Context-Manager.git"
8
+ },
6
9
  "homepage": "https://github.com/senoldogann/LLM-Context-Manager",
7
10
  "bugs": {
8
11
  "url": "https://github.com/senoldogann/LLM-Context-Manager/issues"
9
12
  },
10
13
  "main": "bin/ccm.js",
14
+ "files": [
15
+ "bin",
16
+ "README.md"
17
+ ],
11
18
  "bin": {
12
19
  "ccm-cli": "bin/ccm.js",
13
20
  "ccm-mcp": "bin/ccm.js"
@@ -1,69 +0,0 @@
1
- const assert = require('node:assert/strict');
2
- const fs = require('node:fs');
3
- const os = require('node:os');
4
- const path = require('node:path');
5
- const test = require('node:test');
6
-
7
- const {
8
- MCP_ARGS,
9
- MCP_ENV,
10
- installJsonConfig,
11
- writeJsonAtomic
12
- } = require('../bin/ccm.js');
13
-
14
- function tempConfig() {
15
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ccm-installer-'));
16
- return {
17
- directory,
18
- configPath: path.join(directory, 'mcp.json')
19
- };
20
- }
21
-
22
- test('installer merges MCP config and preserves existing settings', () => {
23
- const { configPath } = tempConfig();
24
- fs.writeFileSync(
25
- configPath,
26
- JSON.stringify({ theme: 'dark', mcpServers: { existing: { command: 'safe' } } })
27
- );
28
-
29
- const changed = installJsonConfig(configPath, {
30
- command: 'npx',
31
- args: MCP_ARGS,
32
- env: MCP_ENV
33
- });
34
-
35
- assert.equal(changed, true);
36
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
37
- assert.equal(config.theme, 'dark');
38
- assert.equal(config.mcpServers.existing.command, 'safe');
39
- assert.equal(config.mcpServers['context-manager'].env.CCM_REQUIRE_ALLOWED_ROOTS, '1');
40
- assert.ok(fs.existsSync(`${configPath}.bak`));
41
- });
42
-
43
- test('installer never overwrites malformed JSON', () => {
44
- const { configPath } = tempConfig();
45
- const malformed = '{"mcpServers":';
46
- fs.writeFileSync(configPath, malformed);
47
-
48
- assert.throws(
49
- () => installJsonConfig(configPath, { command: 'npx', args: [], env: {} }),
50
- /Could not parse/
51
- );
52
- assert.equal(fs.readFileSync(configPath, 'utf8'), malformed);
53
- assert.equal(fs.readFileSync(`${configPath}.bak`, 'utf8'), malformed);
54
- });
55
-
56
- test('atomic writer leaves valid JSON and no temporary file', () => {
57
- const { directory, configPath } = tempConfig();
58
- writeJsonAtomic(configPath, { ok: true });
59
-
60
- assert.deepEqual(JSON.parse(fs.readFileSync(configPath, 'utf8')), { ok: true });
61
- assert.deepEqual(fs.readdirSync(directory), ['mcp.json']);
62
- });
63
-
64
- test('generated MCP command pins package version and narrows project access', () => {
65
- assert.match(MCP_ARGS[1], /^@senoldogann\/context-manager@\d+\.\d+\.\d+$/);
66
- assert.equal(MCP_ENV.CCM_REQUIRE_ALLOWED_ROOTS, '1');
67
- assert.equal(MCP_ENV.CCM_ALLOWED_ROOTS, process.cwd());
68
- assert.equal(MCP_ENV.CCM_PROJECT_ROOT, process.cwd());
69
- });