Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/mover.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,13 @@ async function moveMcp(item, toScopeId, scopes) {
return { ok: false, error: `Cannot read source .mcp.json: ${fromMcpJson}` };
}

const serverConfig = fromContent.mcpServers?.[item.name];
// For .claude.json project-scope servers, read from the correct nesting level (#11)
let serverConfig;
if (item.claudeJsonProjectKey) {
serverConfig = fromContent.projects?.[item.claudeJsonProjectKey]?.mcpServers?.[item.name];
} else {
serverConfig = fromContent.mcpServers?.[item.name];
}
if (!serverConfig) {
return { ok: false, error: `Server "${item.name}" not found in ${fromMcpJson}` };
}
Expand All @@ -283,8 +289,12 @@ async function moveMcp(item, toScopeId, scopes) {
// Add to destination
toContent.mcpServers[item.name] = serverConfig;

// Remove from source
delete fromContent.mcpServers[item.name];
// Remove from source — from the correct nesting level
if (item.claudeJsonProjectKey) {
delete fromContent.projects[item.claudeJsonProjectKey].mcpServers[item.name];
} else {
delete fromContent.mcpServers[item.name];
}

// Write both files
await mkdir(dirname(toMcpJson), { recursive: true });
Expand Down
1 change: 1 addition & 0 deletions src/scanner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ async function scanMcpServers(scope) {
ctime: claudeJsonStat ? claudeJsonStat.birthtime.toISOString().slice(0, 16) : "",
path: claudeJsonPath,
mcpConfig: serverConfig,
claudeJsonProjectKey: scope.repoDir, // for moveMcp to find the right nesting level (#11)
});
}
}
Expand Down
13 changes: 7 additions & 6 deletions src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { createServer } from "node:http";
import { readFile, stat, open } from "node:fs/promises";
import { join, extname, resolve, dirname } from "node:path";
import { join, extname, resolve, dirname, sep, isAbsolute } from "node:path";
import { homedir } from "node:os";
import { createRequire } from "node:module";
import https from "node:https";
Expand Down Expand Up @@ -47,9 +47,10 @@ const CLAUDE_DIR = join(HOME, ".claude");
function isPathAllowed(filePath) {
const resolved = resolve(filePath);
// Allow paths under ~/.claude/ or under any discovered project repoDir
if (resolved.startsWith(CLAUDE_DIR + "/") || resolved === CLAUDE_DIR) return true;
// Uses path.sep for cross-platform support (fixes Windows #12)
if (resolved.startsWith(CLAUDE_DIR + sep) || resolved === CLAUDE_DIR) return true;
// Allow paths under HOME (covers repo dirs with .mcp.json, CLAUDE.md etc)
if (resolved.startsWith(HOME + "/")) return true;
if (resolved.startsWith(HOME + sep)) return true;
return false;
}

Expand Down Expand Up @@ -522,7 +523,7 @@ async function handleRequest(req, res) {
// POST /api/restore — restore a deleted file (for undo)
if (path === "/api/restore" && req.method === "POST") {
const { filePath, content, isDir } = await readBody(req);
if (!filePath || !filePath.startsWith("/") || !isPathAllowed(filePath)) {
if (!filePath || !isAbsolute(filePath) || !isPathAllowed(filePath)) {
return json(res, { ok: false, error: "Invalid or disallowed path" }, 400);
}
try {
Expand Down Expand Up @@ -571,7 +572,7 @@ async function handleRequest(req, res) {
// GET /api/file-content?path=... — read file content for detail panel
if (path === "/api/file-content" && req.method === "GET") {
const filePath = url.searchParams.get("path");
if (!filePath || !filePath.startsWith("/") || !isPathAllowed(filePath)) {
if (!filePath || !isAbsolute(filePath) || !isPathAllowed(filePath)) {
return json(res, { ok: false, error: "Invalid or disallowed path" }, 400);
}
try {
Expand Down Expand Up @@ -688,7 +689,7 @@ async function handleRequest(req, res) {
let { exportDir } = await readBody(req);
// Default to ~/.claude/exports/ if no path provided
if (!exportDir) exportDir = join(CLAUDE_DIR, "exports");
if (!exportDir.startsWith("/")) {
if (!isAbsolute(exportDir)) {
return json(res, { ok: false, error: "Invalid exportDir (must be absolute path)" }, 400);
}

Expand Down
17 changes: 17 additions & 0 deletions tests/e2e/playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from '@playwright/test';

export default defineConfig({
testDir: '.',
timeout: 30000,
retries: 0,
workers: 1,
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
use: {
headless: false,
launchOptions: { slowMo: 50 },
// Reuse single browser, close pages between tests
contextOptions: { ignoreHTTPSErrors: true },
},
});
112 changes: 112 additions & 0 deletions tests/pw-windows-fix.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* E2E tests for Issue #12 (Windows path validation) and Issue #11 (moveMcp project scope)
* Run: cd claude-code-organizer && DISPLAY=:0 node tests/pw-windows-fix.cjs
*/
const { chromium } = require('/home/nicole/.nvm/versions/node/v20.19.4/lib/node_modules/playwright');

(async () => {
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
let passed = 0, failed = 0, skipped = 0;

function ok(name) { passed++; console.log(` ✅ ${name}`); }
function fail(name, reason) { failed++; console.log(` ❌ ${name}: ${reason}`); }
function skip(name, reason) { skipped++; console.log(` ⚠️ ${name}: ${reason}`); }

try {
await page.goto('http://localhost:3847');
await page.waitForTimeout(2000);

// Get scan data via API (avoids UI timing issues)
const scanData = await page.evaluate(() => fetch('/api/scan').then(r => r.json()));

// ═══ TEST 1: file-content API works with absolute paths ═══
console.log('\nTEST 1: /api/file-content accepts absolute paths');
const fileItem = scanData.items?.find(i => i.path && i.category !== 'session');
if (fileItem) {
const resp = await page.evaluate(async (p) => {
const r = await fetch(`/api/file-content?path=${encodeURIComponent(p)}`);
return r.json();
}, fileItem.path);
if (resp.ok || resp.content !== undefined) ok('file-content returns data for: ' + fileItem.path.split('/').pop());
else if (resp.error?.includes('Invalid')) fail('file-content rejected valid path', resp.error);
else ok('file-content responded (may be dir/binary): ' + (resp.error || '').slice(0, 50));
} else skip('file-content', 'no items with paths');

// ═══ TEST 2: export API accepts absolute path ═══
console.log('\nTEST 2: /api/export validates absolute paths');
const exportResp = await page.evaluate(async () => {
return fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exportDir: '/tmp/cco-test-export' }),
}).then(r => r.json());
});
if (exportResp.ok) ok('export accepted /tmp/cco-test-export');
else if (exportResp.error?.includes('Invalid')) fail('export rejected valid absolute path', exportResp.error);
else ok('export responded: ' + (exportResp.error || exportResp.message || '').slice(0, 50));

// ═══ TEST 3: export API rejects relative path ═══
console.log('\nTEST 3: /api/export rejects relative paths');
const relResp = await page.evaluate(async () => {
return fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exportDir: 'relative/path' }),
}).then(r => r.json());
});
if (!relResp.ok) ok('export correctly rejected relative path');
else fail('export accepted relative path (should reject)', '');

// ═══ TEST 4: Scanner discovers .claude.json servers with projectKey ═══
console.log('\nTEST 4: Scanner includes claudeJsonProjectKey');
const mcpItems = scanData.items?.filter(i => i.category === 'mcp') || [];
const claudeJsonItems = mcpItems.filter(i => i.fileName === '.claude.json');
const withProjectKey = claudeJsonItems.filter(i => i.claudeJsonProjectKey);

console.log(` MCP items: ${mcpItems.length}, from .claude.json: ${claudeJsonItems.length}, with projectKey: ${withProjectKey.length}`);
if (claudeJsonItems.length > 0) ok(`found ${claudeJsonItems.length} .claude.json servers`);
else skip('claudeJson servers', 'no .claude.json MCP servers found');

if (withProjectKey.length > 0) {
for (const item of withProjectKey) {
console.log(` ${item.name} → projectKey: ${item.claudeJsonProjectKey.slice(-40)}`);
}
ok(`${withProjectKey.length} servers have claudeJsonProjectKey`);
} else {
skip('claudeJsonProjectKey', 'no project-scope servers in .claude.json (need `claude mcp add --scope project`)');
}

// ═══ TEST 5: No duplicate MCP server from same file ═══
console.log('\nTEST 5: No duplicate MCP servers (same scope + same file)');
const seen = new Set();
let dupes = 0;
for (const item of mcpItems) {
// Dupes across DIFFERENT files (e.g. .mcp.json vs .claude.json) are user config issues, not bugs
const key = `${item.scopeId}::${item.name}::${item.path}`;
if (seen.has(key)) { dupes++; console.log(` DUPE: ${item.name} in ${item.scopeId} (${item.path})`); }
seen.add(key);
}
if (dupes === 0) ok('no duplicates from same file');
else fail(`${dupes} duplicate MCP servers from same file`, '');

// ═══ TEST 6: No JS errors ═══
console.log('\nTEST 6: No JavaScript errors');
if (errors.length === 0) ok('zero JS errors');
else fail(`${errors.length} JS errors`, errors.join('; '));

// Summary
await page.screenshot({ path: '/tmp/pw-windows-fix.png' });
console.log(`\n═══ RESULTS: ${passed} passed, ${failed} failed, ${skipped} skipped ═══`);
if (failed > 0) process.exitCode = 1;
} catch (e) {
console.error('❌ FATAL:', e.message);
await page.screenshot({ path: '/tmp/pw-windows-fix-err.png' });
process.exitCode = 1;
} finally {
await page.waitForTimeout(2000);
await browser.close();
}
})();