Skip to content

Commit d74e561

Browse files
committed
refactor: refactor project root resolution
- Remove code duplication - Add tests
1 parent 22e7903 commit d74e561

2 files changed

Lines changed: 276 additions & 23 deletions

File tree

packages/swarm/src/mcp/cli/server-manager.test.ts

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { existsSync } from 'node:fs';
2+
import path from 'node:path';
13
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
24
import { ServerManager } from './server-manager';
35

@@ -6,6 +8,15 @@ vi.mock('../server/mcp-manager', () => ({
68
MCPManager: vi.fn(),
79
}));
810

11+
// Mock node:fs
12+
vi.mock('node:fs', async (importOriginal) => {
13+
const actual = await importOriginal<typeof import('node:fs')>();
14+
return {
15+
...actual,
16+
existsSync: vi.fn(),
17+
};
18+
});
19+
920
// Mock logger
1021
vi.mock('../server/utils', async (importOriginal) => ({
1122
...(await importOriginal()),
@@ -222,4 +233,257 @@ describe('ServerManager', () => {
222233
await expect(serverManager.stop()).resolves.toBeUndefined();
223234
});
224235
});
236+
237+
describe('resolveProjectRoot', () => {
238+
let originalCwd: string;
239+
let originalArgv: string[];
240+
const mockedExistsSync = vi.mocked(existsSync);
241+
242+
beforeEach(() => {
243+
originalCwd = process.cwd();
244+
originalArgv = [...process.argv];
245+
mockedExistsSync.mockReset();
246+
});
247+
248+
afterEach(() => {
249+
vi.spyOn(process, 'cwd').mockRestore();
250+
process.argv = originalArgv;
251+
});
252+
253+
/**
254+
* Helper to invoke private resolveProjectRoot method
255+
*/
256+
function invokeResolveProjectRoot(manager: ServerManager): string {
257+
return (manager as any).resolveProjectRoot();
258+
}
259+
260+
describe('when cwd is a valid project root', () => {
261+
it('should return cwd when swarm.config.json exists', () => {
262+
const projectDir = '/users/dev/my-project';
263+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
264+
265+
mockedExistsSync.mockImplementation((filePath) => {
266+
return filePath === path.join(projectDir, 'swarm.config.json');
267+
});
268+
269+
const result = invokeResolveProjectRoot(serverManager);
270+
271+
expect(result).toBe(projectDir);
272+
});
273+
274+
it('should return cwd when package.json exists', () => {
275+
const projectDir = '/users/dev/my-project';
276+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
277+
278+
mockedExistsSync.mockImplementation((filePath) => {
279+
return filePath === path.join(projectDir, 'package.json');
280+
});
281+
282+
const result = invokeResolveProjectRoot(serverManager);
283+
284+
expect(result).toBe(projectDir);
285+
});
286+
287+
it('should prioritize swarm.config.json check (short-circuit)', () => {
288+
const projectDir = '/users/dev/my-project';
289+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
290+
291+
mockedExistsSync.mockImplementation((filePath) => {
292+
// Both files exist
293+
return (
294+
filePath === path.join(projectDir, 'swarm.config.json') ||
295+
filePath === path.join(projectDir, 'package.json')
296+
);
297+
});
298+
299+
const result = invokeResolveProjectRoot(serverManager);
300+
301+
expect(result).toBe(projectDir);
302+
});
303+
});
304+
305+
describe('when cwd is not a project root (binary path fallback)', () => {
306+
it('should resolve from node_modules parent when binary is locally installed', () => {
307+
const cwdDir = '/tmp/random-dir';
308+
const projectRoot = '/users/dev/my-project';
309+
const binaryPath = `${projectRoot}/node_modules/.bin/swarm`;
310+
311+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
312+
process.argv = ['node', binaryPath];
313+
314+
mockedExistsSync.mockImplementation((filePath) => {
315+
// cwd has no project files
316+
if (
317+
filePath === path.join(cwdDir, 'swarm.config.json') ||
318+
filePath === path.join(cwdDir, 'package.json')
319+
) {
320+
return false;
321+
}
322+
// project root has swarm.config.json
323+
return filePath === path.join(projectRoot, 'swarm.config.json');
324+
});
325+
326+
const result = invokeResolveProjectRoot(serverManager);
327+
328+
expect(result).toBe(projectRoot);
329+
});
330+
331+
it('should resolve from deep node_modules path', () => {
332+
const cwdDir = '/tmp/random-dir';
333+
const projectRoot = '/users/dev/monorepo/packages/my-app';
334+
const binaryPath = `${projectRoot}/node_modules/@ingenyus/swarm/bin/swarm`;
335+
336+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
337+
process.argv = ['node', binaryPath];
338+
339+
mockedExistsSync.mockImplementation((filePath) => {
340+
// cwd has no project files
341+
if (
342+
filePath === path.join(cwdDir, 'swarm.config.json') ||
343+
filePath === path.join(cwdDir, 'package.json')
344+
) {
345+
return false;
346+
}
347+
// project root has package.json
348+
return filePath === path.join(projectRoot, 'package.json');
349+
});
350+
351+
const result = invokeResolveProjectRoot(serverManager);
352+
353+
expect(result).toBe(projectRoot);
354+
});
355+
356+
it('should use lastIndexOf to find the correct node_modules (nested case)', () => {
357+
const cwdDir = '/tmp/random-dir';
358+
// Simulates a monorepo where binary is in a nested package's node_modules
359+
const projectRoot = '/users/dev/monorepo/packages/app';
360+
const binaryPath = `${projectRoot}/node_modules/.pnpm/@ingenyus+swarm@1.0.0/node_modules/@ingenyus/swarm/bin/swarm`;
361+
362+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
363+
process.argv = ['node', binaryPath];
364+
365+
mockedExistsSync.mockImplementation((filePath) => {
366+
if (
367+
filePath === path.join(cwdDir, 'swarm.config.json') ||
368+
filePath === path.join(cwdDir, 'package.json')
369+
) {
370+
return false;
371+
}
372+
// The project root (before the last node_modules) has swarm.config.json
373+
return filePath === path.join(projectRoot, 'swarm.config.json');
374+
});
375+
376+
const result = invokeResolveProjectRoot(serverManager);
377+
378+
// Should find the parent of the LAST node_modules (pnpm structure)
379+
// In this case: /users/dev/monorepo/packages/app/node_modules/.pnpm/@ingenyus+swarm@1.0.0
380+
// But that's not a valid project root, so it falls back to cwd
381+
expect(result).toBe(cwdDir);
382+
});
383+
});
384+
385+
describe('when binary path does not contain node_modules', () => {
386+
it('should fall back to cwd when binary is run directly (development)', () => {
387+
const cwdDir = '/users/dev/swarm';
388+
const binaryPath = '/users/dev/swarm/bin/swarm';
389+
390+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
391+
process.argv = ['node', binaryPath];
392+
393+
mockedExistsSync.mockReturnValue(false);
394+
395+
const result = invokeResolveProjectRoot(serverManager);
396+
397+
expect(result).toBe(cwdDir);
398+
});
399+
400+
it('should fall back to cwd when using npx remotely', () => {
401+
const cwdDir = '/users/dev/my-project';
402+
// npx downloads to a temp cache location without node_modules in project path
403+
const binaryPath =
404+
'/home/user/.npm/_npx/abc123/node_modules/@ingenyus/swarm/bin/swarm';
405+
406+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
407+
process.argv = ['node', binaryPath];
408+
409+
mockedExistsSync.mockImplementation((filePath) => {
410+
// cwd has no project files
411+
if (
412+
filePath === path.join(cwdDir, 'swarm.config.json') ||
413+
filePath === path.join(cwdDir, 'package.json')
414+
) {
415+
return false;
416+
}
417+
// npx cache parent is not a valid project root
418+
return false;
419+
});
420+
421+
const result = invokeResolveProjectRoot(serverManager);
422+
423+
expect(result).toBe(cwdDir);
424+
});
425+
});
426+
427+
describe('when binary path candidate is not a valid project root', () => {
428+
it('should fall back to cwd when node_modules parent has no project files', () => {
429+
const cwdDir = '/users/dev/working-dir';
430+
const notProjectRoot = '/some/random/location';
431+
const binaryPath = `${notProjectRoot}/node_modules/@ingenyus/swarm/bin/swarm`;
432+
433+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
434+
process.argv = ['node', binaryPath];
435+
436+
// Neither cwd nor the node_modules parent have project files
437+
mockedExistsSync.mockReturnValue(false);
438+
439+
const result = invokeResolveProjectRoot(serverManager);
440+
441+
expect(result).toBe(cwdDir);
442+
});
443+
});
444+
445+
describe('edge cases', () => {
446+
it('should handle missing argv[1]', () => {
447+
const cwdDir = '/users/dev/my-project';
448+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
449+
process.argv = ['node']; // No argv[1]
450+
451+
mockedExistsSync.mockReturnValue(false);
452+
453+
const result = invokeResolveProjectRoot(serverManager);
454+
455+
expect(result).toBe(cwdDir);
456+
});
457+
458+
it('should handle empty argv', () => {
459+
const cwdDir = '/users/dev/my-project';
460+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
461+
process.argv = [];
462+
463+
mockedExistsSync.mockReturnValue(false);
464+
465+
const result = invokeResolveProjectRoot(serverManager);
466+
467+
expect(result).toBe(cwdDir);
468+
});
469+
470+
it('should handle Windows-style paths', () => {
471+
const cwdDir = 'C:\\Users\\dev\\my-project';
472+
vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
473+
474+
mockedExistsSync.mockImplementation((filePath) => {
475+
// Normalize for comparison (path.join handles this)
476+
const normalizedPath = String(filePath);
477+
return (
478+
normalizedPath === path.join(cwdDir, 'swarm.config.json') ||
479+
normalizedPath === path.join(cwdDir, 'package.json')
480+
);
481+
});
482+
483+
const result = invokeResolveProjectRoot(serverManager);
484+
485+
expect(result).toBe(cwdDir);
486+
});
487+
});
488+
});
225489
});

packages/swarm/src/mcp/cli/server-manager.ts

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -115,28 +115,28 @@ export class ServerManager {
115115
return this.isRunning;
116116
}
117117

118+
private isProjectRoot(dir: string): boolean {
119+
const pathExists = (dirPath: string, fileName: string) =>
120+
existsSync(path.join(dirPath, fileName));
121+
122+
return (
123+
pathExists(dir, 'swarm.config.json') || pathExists(dir, 'package.json')
124+
);
125+
}
126+
118127
private resolveProjectRoot(): string {
119128
// Strategy: Try process.cwd() first, then fall back to binary path resolution
120129
// This handles all scenarios:
121130
// 1. Local direct path: cwd() is project root
122131
// 2. External direct path: cwd() is target project root
123132
// 3. Local npx: cwd() is project root (fixed)
124133
// 4. Remote npx: cwd() is where command was invoked, then fallback to binary path
125-
126134
const cwd = process.cwd();
127135

128-
// Check if cwd() looks like a valid project directory
129-
const hasSwarmConfig = existsSync(path.join(cwd, 'swarm.config.json'));
130-
const hasPackageJson = existsSync(path.join(cwd, 'package.json'));
131-
132-
if (hasSwarmConfig || hasPackageJson) {
136+
if (this.isProjectRoot(cwd)) {
133137
return cwd;
134138
}
135139

136-
// Fallback: Try to infer project root from binary location
137-
// This helps when:
138-
// - Binary is installed locally in project/node_modules (scenario 1)
139-
// - Remote npx but binary happens to be in a project's node_modules
140140
const binPath = process.argv[1];
141141

142142
if (binPath) {
@@ -148,23 +148,12 @@ export class ServerManager {
148148
const rootSegments = segments.slice(0, nodeModulesIndex);
149149
const candidate = rootSegments.join(path.sep);
150150

151-
// Verify the candidate actually looks like a project root
152-
if (candidate) {
153-
const candidateHasConfig = existsSync(
154-
path.join(candidate, 'swarm.config.json')
155-
);
156-
const candidateHasPackage = existsSync(
157-
path.join(candidate, 'package.json')
158-
);
159-
160-
if (candidateHasConfig || candidateHasPackage) {
161-
return candidate;
162-
}
151+
if (this.isProjectRoot(candidate)) {
152+
return candidate;
163153
}
164154
}
165155
}
166156

167-
// Final fallback: use cwd() (will be used by findProjectRoot() to search upward)
168157
return cwd;
169158
}
170159
}

0 commit comments

Comments
 (0)