From e61b3e3278485972c5bfb43fef5d7a7fb497ce92 Mon Sep 17 00:00:00 2001 From: abundantbeing Date: Fri, 19 Jun 2026 05:31:09 +0200 Subject: [PATCH] Surface Windows MCP launcher drift in doctor Windows npm updates can leave older GitNexus MCP Node processes running from npx caches or stale package roots, which makes the next install/update look broken when native files stay locked. The doctor command now reports Windows MCP process sources so users can restart editors/agents or stop current MCP servers before retrying the update. Constraint: Windows package updates can leave node-based MCP servers holding native addon files open Rejected: Auto-stop GitNexus MCP processes from doctor | too side-effectful for a diagnostic command Confidence: medium Scope-risk: narrow Directive: Keep the Windows process scan best-effort and non-fatal; doctor must still work if PowerShell or CIM process lookup is unavailable Tested: npx vitest run test/unit/doctor-format.test.ts; npx tsc --noEmit; npm run build; npx eslint gitnexus/src/cli/doctor.ts gitnexus/test/unit/doctor-format.test.ts gitnexus/scripts/cross-platform-tests.ts; git diff --check; GitNexus detect_changes Not-tested: Full npm test passes on this Windows shell; broader unit and cross-platform suites have unrelated Windows/env failures noted in the PR --- gitnexus/scripts/cross-platform-tests.ts | 1 + gitnexus/src/cli/doctor.ts | 126 +++++++++++++++++++++++ gitnexus/test/unit/doctor-format.test.ts | 54 +++++++++- 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 38f769fd3..7ad7507f6 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -30,6 +30,7 @@ const PLATFORM_LOGIC = [ 'test/unit/setup-jsonc.test.ts', 'test/unit/setup-codex.test.ts', 'test/unit/setup-antigravity.test.ts', + 'test/unit/doctor-format.test.ts', 'test/unit/resolve-invocation.test.ts', 'test/unit/platform-capabilities.test.ts', 'test/unit/worker-pool-windows-quarantine.test.ts', diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index fb83d2e5c..7d685cdc2 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -1,3 +1,6 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js'; import { resolveEmbeddingConfig } from '../core/embeddings/config.js'; import { isHttpMode } from '../core/embeddings/http-client.js'; @@ -6,6 +9,8 @@ import { checkLbugNative } from '../core/lbug/native-check.js'; import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js'; import { t } from './i18n/index.js'; +const execFileAsync = promisify(execFile); + function isCombiningMark(codePoint: number): boolean { return ( (codePoint >= 0x0300 && codePoint <= 0x036f) || @@ -78,6 +83,114 @@ export function localEmbeddingDoctorStatus(opts: { return { status: '✓ local embeddings supported', detail: null }; } +export type WindowsGitNexusMcpProcess = { + pid: number; + commandLine: string; +}; + +export type WindowsGitNexusMcpStatus = { + status: string; + detail: string | null; +}; + +function normalizePathForMatch(value: string): string { + return value.replace(/\\/g, '/').toLowerCase(); +} + +function currentPackageRootFromArgv(argv1: string | undefined): string | null { + if (!argv1) return null; + const normalized = path.resolve(argv1); + const parts = normalized.split(path.sep); + const packageIndex = parts.map((part) => part.toLowerCase()).lastIndexOf('gitnexus'); + if (packageIndex < 0) return null; + return parts.slice(0, packageIndex + 1).join(path.sep); +} + +function parseWindowsProcessJson(stdout: string): WindowsGitNexusMcpProcess[] { + const trimmed = stdout.trim(); + if (!trimmed) return []; + const parsed = JSON.parse(trimmed) as + | { pid?: number; ProcessId?: number; commandLine?: string; CommandLine?: string } + | Array<{ pid?: number; ProcessId?: number; commandLine?: string; CommandLine?: string }>; + const rows = Array.isArray(parsed) ? parsed : [parsed]; + return rows + .map((row) => ({ + pid: Number(row.pid ?? row.ProcessId), + commandLine: String(row.commandLine ?? row.CommandLine ?? ''), + })) + .filter((row) => Number.isFinite(row.pid) && row.commandLine.length > 0); +} + +export async function listWindowsGitNexusMcpProcesses( + opts: { + platform?: NodeJS.Platform; + } = {}, +): Promise { + if ((opts.platform ?? process.platform) !== 'win32') return []; + + const script = [ + "$ErrorActionPreference = 'Stop'", + 'Get-CimInstance Win32_Process -Filter "name = \'node.exe\'"', + " | Where-Object { $_.CommandLine -match 'gitnexus' -and $_.CommandLine -match '\\bmcp\\b' }", + ' | Select-Object @{Name="pid";Expression={$_.ProcessId}}, @{Name="commandLine";Expression={$_.CommandLine}}', + ' | ConvertTo-Json -Compress', + ].join('\n'); + + try { + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { timeout: 1500, windowsHide: true, maxBuffer: 1024 * 1024 }, + ); + return parseWindowsProcessJson(stdout); + } catch { + return []; + } +} + +export function windowsGitNexusMcpDoctorStatus(opts: { + processes: WindowsGitNexusMcpProcess[]; + currentPackageRoot?: string | null; +}): WindowsGitNexusMcpStatus { + if (opts.processes.length === 0) { + return { status: 'none running', detail: null }; + } + + const currentRoot = opts.currentPackageRoot + ? normalizePathForMatch(opts.currentPackageRoot) + : null; + let currentInstall = 0; + let npxCache = 0; + let otherInstall = 0; + + for (const proc of opts.processes) { + const command = normalizePathForMatch(proc.commandLine); + if (currentRoot && command.includes(currentRoot)) { + currentInstall += 1; + } else if (command.includes('/_npx/') || command.includes('/npx-cli.js')) { + npxCache += 1; + } else { + otherInstall += 1; + } + } + + const launchers = [ + currentInstall > 0 ? `${currentInstall} current` : null, + npxCache > 0 ? `${npxCache} npx-cache` : null, + otherInstall > 0 ? `${otherInstall} other` : null, + ].filter(Boolean); + + const detail = + npxCache > 0 || otherInstall > 0 + ? 'Restart editors/agents after updating GitNexus so MCP servers reload the same package version.' + : 'Stop running MCP servers before npm updates if cleanup reports locked native files.'; + + return { + status: `${opts.processes.length} running (${launchers.join(', ')})`, + detail, + }; +} + export const doctorCommand = async () => { const fingerprint = getRuntimeFingerprint(); const capabilities = getRuntimeCapabilities(); @@ -139,4 +252,17 @@ export const doctorCommand = async () => { if (support.detail) { process.stderr.write(`\n${support.detail.replace(/^/gm, ' ')}\n\n`); } + if (process.platform === 'win32') { + const mcpProcesses = await listWindowsGitNexusMcpProcesses(); + const mcpStatus = windowsGitNexusMcpDoctorStatus({ + processes: mcpProcesses, + currentPackageRoot: currentPackageRootFromArgv(process.argv[1]), + }); + console.log(''); + console.log('Windows MCP'); + console.log(` ${padDisplayEnd('Processes:', 18)}${mcpStatus.status}`); + if (mcpStatus.detail) { + console.log(` ${padDisplayEnd('Note:', 18)}${mcpStatus.detail}`); + } + } }; diff --git a/gitnexus/test/unit/doctor-format.test.ts b/gitnexus/test/unit/doctor-format.test.ts index 259061ce9..04e4ace34 100644 --- a/gitnexus/test/unit/doctor-format.test.ts +++ b/gitnexus/test/unit/doctor-format.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { displayWidth, localEmbeddingDoctorStatus, padDisplayEnd } from '../../src/cli/doctor.js'; +import { + displayWidth, + localEmbeddingDoctorStatus, + padDisplayEnd, + windowsGitNexusMcpDoctorStatus, +} from '../../src/cli/doctor.js'; describe('doctor output formatting', () => { it('keeps ASCII padding equivalent to String.padEnd', () => { @@ -55,3 +60,50 @@ describe('doctor embedding-runtime support status', () => { expect(detail).toBeNull(); }); }); + +describe('doctor Windows GitNexus MCP process status', () => { + it('reports no running processes without extra guidance', () => { + const status = windowsGitNexusMcpDoctorStatus({ processes: [] }); + + expect(status).toEqual({ status: 'none running', detail: null }); + }); + + it('recognizes current-install MCP processes and warns about update locks', () => { + const status = windowsGitNexusMcpDoctorStatus({ + currentPackageRoot: 'D:\\home\\jonkomet\\npm-global\\node_modules\\gitnexus', + processes: [ + { + pid: 1234, + commandLine: + '"C:\\Program Files\\nodejs\\node.exe" D:\\home\\jonkomet\\npm-global\\node_modules\\gitnexus\\dist\\cli\\index.js mcp', + }, + ], + }); + + expect(status.status).toBe('1 running (1 current)'); + expect(status.detail).toMatch(/Stop running MCP servers/); + expect(status.detail).toMatch(/locked native files/); + }); + + it('flags mixed current and npx-cache launchers after updates', () => { + const status = windowsGitNexusMcpDoctorStatus({ + currentPackageRoot: 'D:\\home\\jonkomet\\npm-global\\node_modules\\gitnexus', + processes: [ + { + pid: 1234, + commandLine: + '"C:\\Program Files\\nodejs\\node.exe" D:\\home\\jonkomet\\npm-global\\node_modules\\gitnexus\\dist\\cli\\index.js mcp', + }, + { + pid: 5678, + commandLine: + '"node" "C:\\Users\\Jaybo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\.bin\\..\\gitnexus\\dist\\cli\\index.js" mcp', + }, + ], + }); + + expect(status.status).toBe('2 running (1 current, 1 npx-cache)'); + expect(status.detail).toMatch(/Restart editors\/agents/); + expect(status.detail).toMatch(/same package version/); + }); +});