Merge branch 'main' into copilot/chore-remediate-gitnexus-web-devdep-advisories

This commit is contained in:
Gergő Magyar 2026-05-19 10:37:25 +01:00 committed by GitHub
commit fa13265a6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 307 additions and 608 deletions

File diff suppressed because it is too large Load diff

View file

@ -60,7 +60,7 @@
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"cors": "^2.8.5",
"express": "^4.19.2",
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"glob": "^13.0.6",
"graphology": "^0.26.0",
@ -100,7 +100,7 @@
"devDependencies": {
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/uuid": "^11.0.0",

View file

@ -61,11 +61,13 @@ function resolveGitnexusBin(): string | null {
.filter(Boolean);
if (isWin) {
// On Windows, `where` returns multiple entries (e.g. the POSIX shell
// script AND the .cmd/.bat wrapper). Prefer the wrapper because
// child_process.spawn() cannot execute a shell script directly.
// On Windows, npm global installs can surface multiple launchers for the
// same package (e.g. a POSIX shell shim plus .cmd/.bat wrappers). Claude
// and the other MCP hosts need a directly spawnable command path, so only
// accept the Windows wrapper. If it is missing, fall back to the slower
// npx entry instead of persisting a non-spawnable shim path.
const cmdLine = lines.find((l) => /\.(cmd|bat)$/i.test(l));
return cmdLine || lines[0] || null;
return cmdLine || null;
}
return lines[0] || null;

View file

@ -448,7 +448,14 @@ export const streamGraphNdjson = async (
*/
const mountSSEProgress = (app: express.Express, routePath: string, jm: JobManager) => {
app.get(routePath, (req, res) => {
const job = jm.getJob(req.params.jobId);
let jobId: string;
try {
jobId = assertString(req.params.jobId, 'jobId');
} catch (err: any) {
res.status(err.status ?? 400).json({ error: err.message });
return;
}
const job = jm.getJob(jobId);
if (!job) {
res.status(404).json({ error: 'Job not found' });
return;
@ -494,7 +501,7 @@ const mountSSEProgress = (app: express.Express, routePath: string, jm: JobManage
try {
eventId++;
if (progress.phase === 'complete' || progress.phase === 'failed') {
const eventJob = jm.getJob(req.params.jobId);
const eventJob = jm.getJob(jobId);
res.write(
`id: ${eventId}\nevent: ${progress.phase}\ndata: ${JSON.stringify({
repoName: eventJob?.repoName,

View file

@ -15,8 +15,13 @@ const execFileMock = vi.fn((...args: any[]) => {
}
});
const execFileSyncMock = vi.fn(() => {
throw new Error('not found');
});
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock,
}));
describe('setupCommand codex execution', () => {
@ -74,6 +79,21 @@ describe('setupCommand codex execution', () => {
);
});
it('uses Windows npx fallback arguments when where returns only a non-wrapper shim', async () => {
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
expect(execFileMock).toHaveBeenCalledWith(
'codex',
['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
{ shell: true },
expect.any(Function),
);
});
it('invokes codex mcp add without shell on non-Windows and does not write fallback config', async () => {
setPlatform('darwin');

View file

@ -241,6 +241,50 @@ describe('setupOpenCode — JSONC preservation', () => {
});
});
it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');
const jsonc = `{
"model": "test",
"mcp": {}
}`;
await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
const config = parseJsonc(raw);
expect(config.mcp.gitnexus).toEqual({
type: 'local',
command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
it('uses Windows npx fallback when where returns only a .ps1 path', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n');
const jsonc = `{
"model": "test",
"mcp": {}
}`;
await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
const config = parseJsonc(raw);
expect(config.mcp.gitnexus).toEqual({
type: 'local',
command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
it('preserves tab indentation in existing file', async () => {
const tabbed = `{\n\t"model": "test"\n}`;
await fs.writeFile(opencodeJsonPath(), tabbed, 'utf-8');
@ -360,6 +404,22 @@ describe('setupCursor — JSONC preservation', () => {
const raw = await fs.readFile(mcpPath(), 'utf-8');
expect(raw).toBe(corrupt);
});
it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath(), 'utf-8');
const config = parseJsonc(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
});
describe('setupClaudeCode — JSONC preservation', () => {

View file

@ -282,9 +282,9 @@ describe('setupClaudeCode', () => {
).resolves.toBeUndefined();
});
it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => {
it('falls back to npx on Windows when no .cmd/.bat wrapper is found', async () => {
setPlatform('win32');
// Edge case: where returns only the POSIX script (no .cmd wrapper)
// Edge case: where returns only a non-spawnable shim (no .cmd wrapper)
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');
const { setupCommand } = await import('../../src/cli/setup.js');
@ -294,8 +294,24 @@ describe('setupClaudeCode', () => {
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus',
args: ['mcp'],
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
it('falls back to npx on Windows when where returns only a .ps1 path', async () => {
setPlatform('win32');
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
});