mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
* feat(core): add cache-first npm update-check service Shared fail-open checker: validated 24h cache under GITNEXUS_HOME, acquireFileLock-guarded refresh, monotonic publication, hardened registry fetch (no credentials, private-address redirects refused, body-capped), strict x.y.z comparator, install-eligibility classification, and an unref'd refresh scheduler for long-lived processes. Extracts getGlobalDir into storage/global-dir.ts with a repo-manager re-export (no caller changes). Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): notify on available updates via stderr and doctor One i18n'd stderr line on interactive invocations when the validated cache holds a newer version (TTY-gated, CI/opt-out/eligibility-gated, hook and help/version command identities excluded). Stale cache spawns a detached hidden __update-check refresh child so command exit latency is unchanged. doctor prints the cached latest version when known. Dockerfile.cli sets GITNEXUS_NO_UPDATE_NOTIFIER=1. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(mcp): emit one stderr update notice per process per version Process-scoped adapter in mcpCommand (stdio and --http), dynamically imported after the stdout sentinel, started only after connect, fully catch-isolated. Arms the shared refresh scheduler with cleanup on process exit. Protocol payloads stay free of update state (R15). Co-authored-by: Cursor <cursoragent@cursor.com> * feat(serve): expose update state on /api/info Serve-scoped controller owns an in-memory update snapshot: one staleness evaluation after listen, then the shared unref'd scheduler, stopped on close/shutdown. /api/info reads only the snapshot and gains optional latestVersion/updateAvailable fields for eligible installs; the existing three fields are byte-compatible. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): dismissible update-available banner from /api/info Fetches server info after backend connect and on reconnect, renders a fixed banner in the exploring view only when updateAvailable is true and the version is undismissed, hides while the reconnect banner is active, and fails open on fetch errors. role=status + aria-live with a keyboard-focusable dismiss; dismissal persists per version in localStorage. Copy in en/zh-CN common.json with version interpolation. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(cli): document update notifications and opt-outs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): apply review findings and simplify pass Review: gate the detached refresh spawn on a live lock-owner probe so parallel CLI invocations coalesce to one refresh child (validated P2, three-reviewer agreement); poll /api/info on a slow cadence while exploring so post-load server-side discoveries surface (validated P1); add a monotonic sequence guard so overlapping server-info fetches commit in order. Simplify (behavior-preserving): shared truthy-env/opt-out/freshness helpers in update-cache.ts, shared cachedUpdateNoticeLine for CLI and doctor, extracted install-eligibility core with per-process memo, memoized registry parsing, single evaluation per scheduler cycle, cache-only startup evaluate in serve, flattened MCP exit handler, shared bottom-banner shell, storage keys in ui-constants. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(update-notifier): address residual review tickets on this PR Stop the lock-busy 1ms scheduler spin, replace clock-skewed cache entries, move the outbound URL guard into core, and extract the serve update controller. Pin the startup/guard/single-flight/MCP/CLI contracts those tickets called out. Fixes #3167 #3168 #3169 #3170 #3171 #3172 #3173 #3174 Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) Fetch the npm /latest document instead of the full packument so the 64KiB cap can succeed, and treat reused lock PIDs as stale so refresh is not suppressed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) Register the CLI spawn suite on the OS matrix, pin MCP opt-out env, and compare versions without IEEE-754 rounding. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): add gitnexus update install and versioned command banners Give an explicit Claude/Codex-style upgrade (`npm i -g gitnexus@version`) and print `GitNexus <Name> (version)` on every command so the running build is obvious without silent self-update. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) - Document the pinned install as npm i -g gitnexus@<x.y.z>, not a copyable @version tag - Wait for wall-clock-future cache repair to publish before asserting - Restore the stdout spy if the TTY notice assertions fail Co-authored-by: Cursor <cursoragent@cursor.com> * fix(update-notifier): keep last known latestVersion on a failed refresh A later offline check was wiping the pin and hiding a known update for 24h. gitnexus update still treats a failed live fetch as checkFailed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) - Word update.current so a newer-than-latest install is not called the latest stable version. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): hide the detached update-check spawn on Windows The refresh child was spawned without windowsHide, so Windows CI could stall before writing the cache and then fail cleanup with EBUSY. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
import { createMCPServer } from '../../../src/mcp/server.js';
|
|
import type { UpdateState } from '../../../src/core/update-check.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
|
|
interface FakeChecker {
|
|
evaluate: () => Promise<UpdateState | null>;
|
|
armUpdateRefreshScheduler: (onState: (state: UpdateState | null) => void) => () => void;
|
|
}
|
|
|
|
interface FakeLogger {
|
|
info: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function checker(initial: UpdateState | null): {
|
|
service: FakeChecker;
|
|
publish: (state: UpdateState | null) => void;
|
|
stop: ReturnType<typeof vi.fn>;
|
|
} {
|
|
let subscriber: ((state: UpdateState | null) => void) | undefined;
|
|
const stop = vi.fn();
|
|
return {
|
|
service: {
|
|
evaluate: vi.fn().mockResolvedValue(initial),
|
|
armUpdateRefreshScheduler: vi.fn((onState) => {
|
|
subscriber = onState;
|
|
return stop;
|
|
}),
|
|
},
|
|
publish: (state) => subscriber?.(state),
|
|
stop,
|
|
};
|
|
}
|
|
|
|
function mockBackend() {
|
|
return {
|
|
callTool: vi
|
|
.fn()
|
|
.mockImplementation(async (name: string) =>
|
|
name === 'list_repos'
|
|
? { repositories: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false } }
|
|
: { ok: true },
|
|
),
|
|
listRepos: vi.fn().mockResolvedValue([]),
|
|
resolveRepo: vi
|
|
.fn()
|
|
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
|
|
selectToolRepository: vi
|
|
.fn()
|
|
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
|
|
getContext: vi.fn().mockReturnValue(null),
|
|
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
|
|
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
|
|
queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
|
|
queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
|
|
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
async function protocolSnapshot(pendingUpdate: boolean): Promise<string> {
|
|
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
|
const backend = mockBackend();
|
|
const server = createMCPServer(backend as never);
|
|
const client = new Client({ name: 'update-snapshot', version: '0.0.0' });
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const log = { info: vi.fn() };
|
|
const fake = checker(pendingUpdate ? { updateAvailable: true, latestVersion: '99.0.0' } : null);
|
|
|
|
try {
|
|
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
await startMcpUpdateNotifier(log, async () => fake.service);
|
|
|
|
const snapshot = {
|
|
initialize: {
|
|
serverInfo: client.getServerVersion(),
|
|
capabilities: client.getServerCapabilities(),
|
|
},
|
|
tools: await client.listTools(),
|
|
resources: await client.listResources(),
|
|
resource: await client.readResource({ uri: 'gitnexus://repos' }),
|
|
prompts: await client.listPrompts(),
|
|
call: await client.callTool({ name: 'list_repos', arguments: { limit: 5 } }),
|
|
};
|
|
return JSON.stringify(snapshot);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.resetModules();
|
|
vi.doUnmock('../../../src/mcp/server.js');
|
|
vi.doUnmock('../../../src/mcp/local/local-backend.js');
|
|
vi.doUnmock('../../../src/mcp/repository-policy.js');
|
|
vi.doUnmock('../../../src/mcp/http-transport.js');
|
|
vi.doUnmock('../../../src/core/logger.js');
|
|
vi.doUnmock('../../../src/core/update-check.js');
|
|
});
|
|
|
|
describe('MCP process update notice', () => {
|
|
it('keeps the full protocol surface byte-identical with and without a cached update', async () => {
|
|
expect(await protocolSnapshot(true)).toBe(await protocolSnapshot(false));
|
|
});
|
|
|
|
it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER'])(
|
|
'emits no log and performs no fetch when %s is set',
|
|
async (name) => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-guard-'));
|
|
fs.writeFileSync(
|
|
path.join(home, 'update-check.json'),
|
|
`${JSON.stringify({
|
|
lastCheckAt: new Date().toISOString(),
|
|
registry: 'https://registry.npmjs.org',
|
|
latestVersion: '99.0.0',
|
|
})}\n`,
|
|
);
|
|
const previousHome = process.env.GITNEXUS_HOME;
|
|
const previousCi = process.env.CI;
|
|
const previousOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
|
const fetchStub = vi.fn();
|
|
vi.stubGlobal('fetch', fetchStub);
|
|
process.env.GITNEXUS_HOME = home;
|
|
process.env[name] = '1';
|
|
if (name !== 'CI') delete process.env.CI;
|
|
const actualChecker = await vi.importActual<
|
|
typeof import('../../../src/core/update-check.js')
|
|
>('../../../src/core/update-check.js');
|
|
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
|
const log: FakeLogger = { info: vi.fn() };
|
|
|
|
try {
|
|
await startMcpUpdateNotifier(log, async () => actualChecker);
|
|
expect(log.info).not.toHaveBeenCalled();
|
|
expect(fetchStub).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = previousHome;
|
|
if (previousCi === undefined) delete process.env.CI;
|
|
else process.env.CI = previousCi;
|
|
if (previousOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
|
else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousOptOut;
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('emits one structured stderr logger event per process per newer version', async () => {
|
|
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
|
const log: FakeLogger = { info: vi.fn() };
|
|
const first = checker({ updateAvailable: true, latestVersion: '9.0.0' });
|
|
const second = checker({ updateAvailable: true, latestVersion: '9.0.0' });
|
|
|
|
await startMcpUpdateNotifier(log, async () => first.service);
|
|
first.publish({ updateAvailable: true, latestVersion: '9.0.0' });
|
|
await startMcpUpdateNotifier(log, async () => second.service);
|
|
second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
|
|
second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
|
|
|
|
expect(log.info).toHaveBeenCalledTimes(2);
|
|
expect(log.info).toHaveBeenNthCalledWith(
|
|
1,
|
|
{ event: 'gitnexus.update_available', latestVersion: '9.0.0' },
|
|
'GitNexus update available',
|
|
);
|
|
expect(log.info).toHaveBeenNthCalledWith(
|
|
2,
|
|
{ event: 'gitnexus.update_available', latestVersion: '10.0.0' },
|
|
'GitNexus update available',
|
|
);
|
|
});
|
|
|
|
it('uses only the logger channel and never writes directly to stdout', async () => {
|
|
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
|
const stdout = vi.spyOn(process.stdout, 'write');
|
|
const log: FakeLogger = { info: vi.fn() };
|
|
const fake = checker({ updateAvailable: true, latestVersion: '11.0.0' });
|
|
|
|
await startMcpUpdateNotifier(log, async () => fake.service);
|
|
|
|
expect(stdout).not.toHaveBeenCalled();
|
|
expect(log.info).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('catch-isolates checker import, evaluation, logger, and scheduler failures', async () => {
|
|
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
|
|
|
await expect(
|
|
startMcpUpdateNotifier({ info: vi.fn() }, async () => {
|
|
throw new Error('import failed');
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
|
|
await expect(
|
|
startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
|
|
evaluate: vi.fn().mockRejectedValue(new Error('evaluation failed')),
|
|
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
|
})),
|
|
).resolves.toBeUndefined();
|
|
|
|
await expect(
|
|
startMcpUpdateNotifier(
|
|
{
|
|
info: vi.fn(() => {
|
|
throw new Error('logger failed');
|
|
}),
|
|
},
|
|
async () => ({
|
|
evaluate: vi.fn().mockResolvedValue({
|
|
updateAvailable: true,
|
|
latestVersion: '12.0.0',
|
|
}),
|
|
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
|
}),
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
await expect(
|
|
startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
|
|
evaluate: vi.fn().mockResolvedValue(null),
|
|
armUpdateRefreshScheduler: vi.fn(() => {
|
|
throw new Error('scheduler failed');
|
|
}),
|
|
})),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
['stdio', 'hang'],
|
|
['http', 'fail'],
|
|
] as const)(
|
|
'starts %s notifier work only after its startup boundary and never awaits a registry %s',
|
|
async (transport, registryBehavior) => {
|
|
const order: string[] = [];
|
|
let evaluateStarted!: () => void;
|
|
const started = new Promise<void>((resolve) => {
|
|
evaluateStarted = resolve;
|
|
});
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-'));
|
|
const previousHome = process.env.GITNEXUS_HOME;
|
|
const previousCi = process.env.CI;
|
|
const previousGitnexusOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
|
const previousNoUpdate = process.env.NO_UPDATE_NOTIFIER;
|
|
let releaseFetch: ((response: Response) => void) | undefined;
|
|
const fetchStub = vi.fn(() =>
|
|
registryBehavior === 'hang'
|
|
? new Promise<Response>((resolve) => {
|
|
releaseFetch = resolve;
|
|
})
|
|
: Promise.reject(new Error('registry unavailable')),
|
|
);
|
|
vi.stubGlobal('fetch', fetchStub);
|
|
process.env.GITNEXUS_HOME = home;
|
|
delete process.env.CI;
|
|
delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
|
delete process.env.NO_UPDATE_NOTIFIER;
|
|
const actualChecker = await vi.importActual<
|
|
typeof import('../../../src/core/update-check.js')
|
|
>('../../../src/core/update-check.js');
|
|
|
|
vi.doMock('../../../src/mcp/server.js', () => ({
|
|
startMCPServer: vi.fn(async () => {
|
|
order.push('stdio-connected');
|
|
}),
|
|
}));
|
|
vi.doMock('../../../src/mcp/local/local-backend.js', () => ({
|
|
LocalBackend: class {
|
|
async init() {}
|
|
async listRepos() {
|
|
return [];
|
|
}
|
|
},
|
|
}));
|
|
vi.doMock('../../../src/mcp/repository-policy.js', () => ({
|
|
createMcpRepositoryPolicy: vi.fn(async () => ({
|
|
scopeBackend: (backend: unknown) => backend,
|
|
})),
|
|
}));
|
|
vi.doMock('../../../src/core/logger.js', () => ({
|
|
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
|
}));
|
|
vi.doMock('../../../src/mcp/http-transport.js', () => ({
|
|
resolveAuthToken: vi.fn(),
|
|
startMcpHttpServer: vi.fn(async () => {
|
|
order.push('http-listening');
|
|
}),
|
|
}));
|
|
vi.doMock('../../../src/core/update-check.js', () => ({
|
|
...actualChecker,
|
|
evaluate: vi.fn(() => {
|
|
order.push('evaluate');
|
|
evaluateStarted();
|
|
return actualChecker.evaluate({ eligible: true });
|
|
}),
|
|
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
|
}));
|
|
|
|
try {
|
|
const { mcpCommand } = await import('../../../src/cli/mcp.js');
|
|
await expect(
|
|
mcpCommand(transport === 'http' ? { http: true, port: '3000' } : undefined),
|
|
).resolves.toBeUndefined();
|
|
await started;
|
|
await vi.waitFor(() => expect(fetchStub).toHaveBeenCalledOnce());
|
|
|
|
expect(order).toEqual([
|
|
transport === 'http' ? 'http-listening' : 'stdio-connected',
|
|
'evaluate',
|
|
]);
|
|
} finally {
|
|
if (releaseFetch) {
|
|
releaseFetch(new Response('', { status: 503 }));
|
|
await actualChecker.refresh({ eligible: true });
|
|
}
|
|
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = previousHome;
|
|
if (previousCi === undefined) delete process.env.CI;
|
|
else process.env.CI = previousCi;
|
|
if (previousGitnexusOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
|
else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousGitnexusOptOut;
|
|
if (previousNoUpdate === undefined) delete process.env.NO_UPDATE_NOTIFIER;
|
|
else process.env.NO_UPDATE_NOTIFIER = previousNoUpdate;
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('wires the scheduler stop function into process exit', async () => {
|
|
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
|
const fake = checker(null);
|
|
const before = new Set(process.listeners('exit'));
|
|
|
|
await startMcpUpdateNotifier({ info: vi.fn() }, async () => fake.service);
|
|
const added = process.listeners('exit').filter((listener) => !before.has(listener));
|
|
expect(added).toHaveLength(1);
|
|
|
|
added[0](0);
|
|
expect(fake.stop).toHaveBeenCalledOnce();
|
|
process.removeListener('exit', added[0]);
|
|
});
|
|
|
|
it('uses an unrefd scheduler timer so an opted-out MCP process can exit', async () => {
|
|
const script = [
|
|
"import { startMcpUpdateNotifier } from './dist/cli/mcp.js';",
|
|
'await startMcpUpdateNotifier({ info() {} });',
|
|
].join('\n');
|
|
const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
|
|
cwd: REPO_ROOT,
|
|
env: { ...process.env, GITNEXUS_NO_UPDATE_NOTIFIER: '1', NODE_OPTIONS: '' },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const result = await new Promise<{ code: number | null; stderr: string }>((resolve, reject) => {
|
|
let stderr = '';
|
|
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
|
const timeout = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
reject(new Error('MCP notifier scheduler kept the child process alive'));
|
|
}, 2_000);
|
|
child.once('error', reject);
|
|
child.once('close', (code) => {
|
|
clearTimeout(timeout);
|
|
resolve({ code, stderr });
|
|
});
|
|
});
|
|
|
|
expect(result).toEqual({ code: 0, stderr: '' });
|
|
});
|
|
});
|