feat: notify users when a newer gitnexus version is available (#3175)

* 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>
This commit is contained in:
Gergő Magyar 2026-09-04 18:50:03 +01:00 committed by GitHub
parent a348bc3957
commit 9bf307123e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3876 additions and 210 deletions

View file

@ -121,6 +121,7 @@ USER node
# The web UI defaults to http://localhost:4747 - keep that contract.
ENV GITNEXUS_HOME=/data/gitnexus \
GITNEXUS_NO_UPDATE_NOTIFIER=1 \
NODE_ENV=production \
PORT=4747

View file

@ -14,17 +14,27 @@ import { buildGraphFromConnectResult } from './lib/apply-connect-result';
import {
connectToServer,
fetchRepos,
fetchServerInfo,
normalizeServerUrl,
connectHeartbeat,
BackendError,
type ConnectResult,
type BackendRepo,
type ServerInfo,
} from './services/backend-client';
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
import {
ERROR_RESET_DELAY_MS,
UPDATE_DISMISSED_VERSION_KEY,
UPDATE_INFO_REFETCH_MS,
} from './config/ui-constants';
import { parseSkipGraphParam } from './lib/graph-load-decision';
import { formatBackendError } from './i18n/error-messages';
import { useTranslation } from 'react-i18next';
/** Positional shell shared by the fixed bottom banners (reconnect, update). */
const BOTTOM_BANNER_CLASS =
'fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border px-4 py-2 text-sm shadow-lg backdrop-blur';
/**
* Restore-param preference for the auto-connect effect: `repo` carries the
* server-resolved path identity (restores the exact repo even when duplicate
@ -65,6 +75,47 @@ const AppContent = () => {
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
const [serverDisconnected, setServerDisconnected] = useState(false);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() => {
try {
return localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY);
} catch {
return null;
}
});
const wasDisconnectedRef = useRef(false);
const refreshSeqRef = useRef(0);
const refreshServerInfo = useCallback(async (): Promise<void> => {
const seq = ++refreshSeqRef.current;
try {
const next = await fetchServerInfo();
// A newer fetch is already in flight; never commit an older payload.
if (seq !== refreshSeqRef.current) return;
// Keep the previous state (and banner) when nothing changed, so
// reconnect refetches don't flicker the UI.
setServerInfo((prev) =>
prev?.version === next.version &&
prev?.latestVersion === next.latestVersion &&
prev?.updateAvailable === next.updateAvailable
? prev
: next,
);
} catch {
// Update state is informational; unavailable server info must not affect the app.
}
}, []);
const dismissUpdate = useCallback(() => {
const latestVersion = serverInfo?.latestVersion;
if (!latestVersion) return;
setDismissedUpdateVersion(latestVersion);
try {
localStorage.setItem(UPDATE_DISMISSED_VERSION_KEY, latestVersion);
} catch {
// The in-memory dismissal still applies when storage is unavailable.
}
}, [serverInfo?.latestVersion]);
const handleServerConnect = useCallback(
async (result: ConnectResult): Promise<void> => {
@ -202,6 +253,7 @@ const AppContent = () => {
// anyway" button) and then awaits agent init, leaving a window where
// loadGraphAnyway would silently no-op on a still-null serverBaseUrl.
setServerBaseUrl(baseUrl);
void refreshServerInfo();
await handleServerConnect(result);
setProgress(null);
fetchRepos()
@ -221,7 +273,14 @@ const AppContent = () => {
setProgress(null);
}, ERROR_RESET_DELAY_MS);
});
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
}, [
handleServerConnect,
refreshServerInfo,
setProgress,
setViewMode,
setServerBaseUrl,
setAvailableRepos,
]);
const handleFocusNode = useCallback((nodeId: string) => {
graphCanvasRef.current?.focusNode(nodeId);
@ -234,6 +293,14 @@ const AppContent = () => {
initializeAgent();
}, [refreshLLMSettings, initializeAgent]);
// While exploring, re-read server info on a slow cadence so an update the
// server discovers after page load surfaces without a manual reload.
useEffect(() => {
if (viewMode !== 'exploring' || serverDisconnected) return;
const interval = setInterval(() => void refreshServerInfo(), UPDATE_INFO_REFETCH_MS);
return () => clearInterval(interval);
}, [viewMode, serverDisconnected, refreshServerInfo]);
// ── Server heartbeat: detect when server goes down while exploring ────────
// Uses SSE (EventSource) for instant detection — no polling delay.
// On disconnect: show a reconnecting banner instead of resetting to onboarding.
@ -242,12 +309,21 @@ const AppContent = () => {
if (viewMode !== 'exploring') return;
const cleanup = connectHeartbeat(
() => setServerDisconnected(false),
() => setServerDisconnected(true),
() => {
setServerDisconnected(false);
if (wasDisconnectedRef.current) {
wasDisconnectedRef.current = false;
void refreshServerInfo();
}
},
() => {
wasDisconnectedRef.current = true;
setServerDisconnected(true);
},
);
return cleanup;
}, [viewMode]);
}, [viewMode, refreshServerInfo]);
// Render based on view mode
if (viewMode === 'onboarding') {
@ -255,6 +331,7 @@ const AppContent = () => {
<DropZone
onServerConnect={async (result, serverUrl) => {
// Refresh repo list before transitioning so it's ready in the header
void refreshServerInfo();
const repos = await fetchRepos().catch(() => [] as BackendRepo[]);
setAvailableRepos(repos);
await handleServerConnect(result);
@ -344,11 +421,39 @@ const AppContent = () => {
<StatusBar />
{serverDisconnected && (
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
<div
className={`${BOTTOM_BANNER_CLASS} border-yellow-500/30 bg-yellow-900/80 text-yellow-200`}
>
{t('errors:backend.reconnecting')}
</div>
)}
{!serverDisconnected &&
serverInfo?.updateAvailable === true &&
!!serverInfo.latestVersion &&
dismissedUpdateVersion !== serverInfo.latestVersion && (
<div
role="status"
aria-live="polite"
className={`${BOTTOM_BANNER_CLASS} flex items-center gap-3 border-accent/30 bg-surface/95 text-text-primary`}
>
<span>
{t('common:updateBanner', {
latest: serverInfo.latestVersion,
installed: serverInfo.version,
})}
</span>
<button
type="button"
onClick={dismissUpdate}
aria-label={t('common:updateBannerDismiss')}
className="rounded px-2 py-1 text-text-secondary hover:bg-white/10 hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
{t('common:actions.dismiss')}
</button>
</div>
)}
{/* Settings Panel (modal) */}
<SettingsPanel
isOpen={isSettingsPanelOpen}

View file

@ -23,6 +23,12 @@ export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
*/
export const AUTH_TOKEN_STORAGE_KEY = 'gitnexus-auth-token';
/** localStorage key holding the version whose update banner the user dismissed. */
export const UPDATE_DISMISSED_VERSION_KEY = 'gitnexus.updateDismissedVersion';
/** How often the exploring view re-reads /api/info so server-side update discoveries surface. */
export const UPDATE_INFO_REFETCH_MS = 5 * 60 * 1000;
/**
* Default node-count above which the WebUI connects in chat-only mode (skips
* the full graph download). Grounded in sigma.js/graphology prior art: ~10K

View file

@ -3,6 +3,8 @@
"name": "GitNexus",
"nexusAI": "Nexus AI"
},
"updateBanner": "GitNexus {{latest}} is available — this server runs {{installed}}.",
"updateBannerDismiss": "Dismiss update notification",
"actions": {
"cancel": "Cancel",
"dismiss": "Dismiss",

View file

@ -3,6 +3,8 @@
"name": "GitNexus",
"nexusAI": "Nexus AI"
},
"updateBanner": "GitNexus {{latest}} 已发布 — 此服务器运行 {{installed}}。",
"updateBannerDismiss": "关闭更新通知",
"actions": {
"cancel": "取消",
"dismiss": "关闭",

View file

@ -575,9 +575,11 @@ export interface ServerInfo {
version: string;
launchContext: 'npx' | 'global' | 'local';
nodeVersion: string;
latestVersion?: string;
updateAvailable?: boolean;
}
/** Fetch server info (version, launch context). */
/** Fetch server info (version, launch context, and optional update state). */
export const fetchServerInfo = async (): Promise<ServerInfo> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/info`);
await assertOk(response);

View file

@ -0,0 +1,349 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import App from '../../src/App';
import i18n, { i18nReady } from '../../src/i18n';
import {
UPDATE_DISMISSED_VERSION_KEY,
UPDATE_INFO_REFETCH_MS,
} from '../../src/config/ui-constants';
import type { ConnectResult, ServerInfo } from '../../src/services/backend-client';
const appStateConfig = vi.hoisted(() => ({
initialViewMode: 'onboarding' as 'onboarding' | 'loading' | 'exploring',
}));
const backendMocks = vi.hoisted(() => ({
fetchServerInfo: vi.fn<() => Promise<ServerInfo>>(),
fetchRepos: vi.fn(async () => []),
connectHeartbeat: vi.fn(),
}));
vi.mock('../../src/hooks/useAppState', async () => {
const React = await import('react');
const AppStateContext = React.createContext<Record<string, unknown> | null>(null);
return {
AppStateProvider: ({ children }: { children: React.ReactNode }) => {
const [viewMode, setViewMode] = React.useState(appStateConfig.initialViewMode);
const [serverBaseUrl, setServerBaseUrl] = React.useState<string | null>(null);
const stable = React.useRef({
setGraph: vi.fn(),
setGraphMode: vi.fn(),
setChatOnlyNodeCount: vi.fn(),
setProgress: vi.fn(),
setProjectName: vi.fn(),
setSettingsPanelOpen: vi.fn(),
refreshLLMSettings: vi.fn(),
initializeAgent: vi.fn(async () => {}),
startEmbeddingsWithFallback: vi.fn(),
setAvailableRepos: vi.fn(),
switchRepo: vi.fn(async () => {}),
setCurrentRepo: vi.fn(),
}).current;
return (
<AppStateContext.Provider
value={{
...stable,
viewMode,
setViewMode,
progress:
viewMode === 'loading'
? { phase: 'extracting', percent: 1, message: 'Loading' }
: null,
isRightPanelOpen: false,
isSettingsPanelOpen: false,
codeReferences: [],
selectedNode: null,
isCodePanelOpen: false,
serverBaseUrl,
setServerBaseUrl,
availableRepos: [],
}}
>
{children}
</AppStateContext.Provider>
);
},
useAppState: () => {
const value = React.useContext(AppStateContext);
if (!value) throw new Error('Missing test AppStateProvider');
return value;
},
};
});
const connectResult: ConnectResult = {
nodes: [],
relationships: [],
repoInfo: {
name: 'demo',
path: '/workspace/demo',
repoPath: '/workspace/demo',
indexedAt: '2026-09-04T00:00:00.000Z',
},
graphSkipped: false,
};
vi.mock('../../src/components/DropZone', () => ({
DropZone: ({
onServerConnect,
}: {
onServerConnect: (result: ConnectResult, serverUrl: string) => Promise<void>;
}) => (
<button onClick={() => void onServerConnect(connectResult, 'http://localhost:4747')}>
Connect test backend
</button>
),
}));
vi.mock('../../src/components/LoadingOverlay', () => ({
LoadingOverlay: () => <div>Loading view</div>,
}));
vi.mock('../../src/components/Header', () => ({ Header: () => <header>Header</header> }));
vi.mock('../../src/components/GraphCanvas', async () => {
const React = await import('react');
return { GraphCanvas: React.forwardRef(() => <div>Graph</div>) };
});
vi.mock('../../src/components/RightPanel', () => ({ RightPanel: () => null }));
vi.mock('../../src/components/SettingsPanel', () => ({ SettingsPanel: () => null }));
vi.mock('../../src/components/StatusBar', () => ({ StatusBar: () => null }));
vi.mock('../../src/components/FileTreePanel', () => ({ FileTreePanel: () => null }));
vi.mock('../../src/components/CodeReferencesPanel', () => ({
CodeReferencesPanel: () => null,
}));
vi.mock('../../src/core/llm/settings-service', () => ({
getActiveProviderConfig: () => null,
}));
vi.mock('../../src/services/backend-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/backend-client')>();
return {
...actual,
fetchServerInfo: backendMocks.fetchServerInfo,
fetchRepos: backendMocks.fetchRepos,
connectHeartbeat: backendMocks.connectHeartbeat,
};
});
const updateInfo = (latestVersion = '2.0.0'): ServerInfo => ({
version: '1.0.0',
launchContext: 'global',
nodeVersion: 'v22.0.0',
latestVersion,
updateAvailable: true,
});
async function connectBackend() {
await userEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
}
describe('update banner', () => {
beforeEach(async () => {
await i18nReady;
await i18n.changeLanguage('en');
appStateConfig.initialViewMode = 'onboarding';
localStorage.removeItem(UPDATE_DISMISSED_VERSION_KEY);
backendMocks.fetchServerInfo.mockReset();
backendMocks.fetchRepos.mockClear();
backendMocks.connectHeartbeat.mockReset();
backendMocks.connectHeartbeat.mockReturnValue(() => {});
});
afterEach(() => {
cleanup();
window.history.replaceState(null, '', '/');
});
it('fetches only after a backend is selected and renders interpolated update copy', async () => {
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
render(<App />);
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
await connectBackend();
expect(await screen.findByRole('status')).toHaveTextContent(
'GitNexus 2.0.0 is available — this server runs 1.0.0.',
);
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
});
it.each([
['false', { ...updateInfo(), updateAvailable: false }],
['absent', { version: '1.0.0', launchContext: 'global', nodeVersion: 'v22.0.0' }],
])('stays hidden when update state is %s', async (_label, info) => {
backendMocks.fetchServerInfo.mockResolvedValue(info as ServerInfo);
render(<App />);
await connectBackend();
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('gives the reconnect banner priority and refetches after reconnect', async () => {
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
render(<App />);
await connectBackend();
expect(await screen.findByRole('status')).toBeInTheDocument();
const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
act(() => onReconnecting());
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByText(/reconnect/i)).toBeInTheDocument();
await act(async () => onConnect());
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2));
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('persists dismissal across remounts', async () => {
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
const first = render(<App />);
await connectBackend();
await userEvent.click(
await screen.findByRole('button', { name: 'Dismiss update notification' }),
);
expect(localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY)).toBe('2.0.0');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
first.unmount();
window.history.replaceState(null, '', '/');
render(<App />);
await connectBackend();
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('re-shows after a newer version than the dismissed one appears', async () => {
localStorage.setItem(UPDATE_DISMISSED_VERSION_KEY, '2.0.0');
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo('2.1.0'));
render(<App />);
await connectBackend();
expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
});
it('fails open without rendering an error UI', async () => {
backendMocks.fetchServerInfo.mockRejectedValue(new Error('offline'));
render(<App />);
await connectBackend();
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByText(/offline/i)).not.toBeInTheDocument();
});
it('never mounts on onboarding or loading views', () => {
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
const onboarding = render(<App />);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
onboarding.unmount();
appStateConfig.initialViewMode = 'loading';
render(<App />);
expect(screen.getByText('Loading view')).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
});
it('has a keyboard-focusable dismiss control with an accessible label', async () => {
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
render(<App />);
await connectBackend();
const dismiss = await screen.findByRole('button', { name: 'Dismiss update notification' });
dismiss.focus();
expect(dismiss).toHaveFocus();
await userEvent.keyboard('{Enter}');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('renders translated copy in zh-CN', async () => {
await i18n.changeLanguage('zh-CN');
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
render(<App />);
await connectBackend();
expect(await screen.findByRole('status')).toHaveTextContent(
'GitNexus 2.0.0 已发布 — 此服务器运行 1.0.0。',
);
});
it('never commits an older fetch response over a newer one', async () => {
const deferred: Array<(value: ServerInfo) => void> = [];
backendMocks.fetchServerInfo.mockImplementation(
() => new Promise<ServerInfo>((resolve) => deferred.push(resolve)),
);
render(<App />);
await connectBackend();
// A reconnect refetch starts while the connect fetch is still in flight.
const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
act(() => onReconnecting());
await act(async () => onConnect());
expect(deferred).toHaveLength(2);
// The newer fetch resolves first with 2.1.0; the older fetch resolves late with 2.0.0.
await act(async () => deferred[1](updateInfo('2.1.0')));
expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
await act(async () => deferred[0](updateInfo('2.0.0')));
expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
});
it('refetches server info on the slow exploring cadence', async () => {
vi.useFakeTimers();
try {
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
render(<App />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
});
expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.0.0 is available');
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS);
});
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2);
await act(async () => {
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS);
});
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
});
it('does not poll server info while the exploring session is disconnected', async () => {
vi.useFakeTimers();
try {
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
render(<App />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
});
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
const [, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
act(() => onReconnecting());
await act(async () => {
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS * 2);
});
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});

View file

@ -264,6 +264,7 @@ gitnexus wiki --provider grok # Local Grok Build CLI (uses `grok login`, no A
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
gitnexus doctor # Show runtime platform capabilities and embedding configuration
gitnexus update # Install the latest published GitNexus (`npm i -g gitnexus@<x.y.z>`)
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
gitnexus query "<concept>" # Process-grouped hybrid search
@ -492,6 +493,40 @@ bigger cycle) and `N` increments per published rc. Example sequence:
`1.6.3-rc.1`. See the [Releases page](https://github.com/abhigyanpatwari/GitNexus/releases)
for the full list; stable `latest` is unaffected.
## Update notifications
GitNexus checks the npm registry's `latest` dist-tag at most once every 24
hours per installation and tells you when a newer stable version exists. The
result is cached under `$GITNEXUS_HOME` (`~/.gitnexus` by default), so the
check never runs on the command's hot path and never blocks output. Where the
notice appears:
- **CLI** — one line on stderr when you run a command interactively (never on
stdout, so `gitnexus query … | jq` and other piped output stay clean), a
line in `gitnexus doctor` when an update is known. Automatic notices never
install. `gitnexus update` checks even when notices are opted out, then
runs `npm i -g gitnexus@<x.y.z>` (same idea as `claude update` /
`codex update`).
- **MCP server** — one structured log record on the server's stderr per
process per version (visible in your host's MCP log panel). Tool results,
resources, prompts, and server instructions never carry update text.
- **Web UI** — a dismissible banner when the server reports a newer version;
dismissal persists per version.
The check is skipped entirely (no network request, no output) when `CI` is
truthy, when the install is not an npm global/local install (npx cache, dev
checkout, Docker image — the Docker CLI image sets the opt-out itself), or
when opted out:
| Variable | Effect |
| --- | --- |
| `GITNEXUS_NO_UPDATE_NOTIFIER` | Truthy (`1`, `true`, …) disables the update check on every surface. |
| `NO_UPDATE_NOTIFIER` | Cross-tool convention; honored the same way. |
| `npm_config_registry` | The check reads the `latest` dist-tag from this registry instead of `https://registry.npmjs.org`. Credentials are never sent, and registries that require authentication are not supported (the check silently skips). |
Eval harnesses running a global install can set `GITNEXUS_NO_UPDATE_NOTIFIER`
for a quiet registry.
## Troubleshooting
### `Cannot destructure property 'package' of 'node.target' as it is null`

View file

@ -200,6 +200,8 @@ const SPAWN_CLI = [
'test/integration/analyze-heap-oom-e2e.test.ts',
'test/integration/group/group-cli.test.ts',
'test/integration/cli/tool-no-index-stderr.test.ts',
// Real CLI spawn + directory symlinks for the update-notice parent/child path.
'test/integration/cli/update-notice.test.ts',
'test/integration/setup-skills.test.ts',
'test/integration/setup-antigravity.test.ts',
'test/integration/antigravity-hook-e2e.test.ts',

View file

@ -788,8 +788,6 @@ const analyzeCommandImpl = async (
cliOptions?: AnalyzeOptions,
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
): Promise<void> => {
console.log('\n GitNexus Analyzer\n');
// ── Resolve the target repo root ──────────────────────────────────
// Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
// caller's cwd), and config can set defaults that the validation below

View file

@ -0,0 +1,71 @@
/**
* One-line identity printed before every CLI action:
* `GitNexus Analyzer (1.6.10)`. Goes to stderr so stdout stays
* pipe/JSON-safe (`gitnexus query … | jq`, `status --json`).
*/
import { createRequire } from 'node:module';
import type { Command } from 'commander';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json') as { version?: unknown };
const SKIP_COMMAND_BANNER = new Set(['__update-check', 'help']);
const SPECIAL_TITLES: Record<string, string> = {
analyze: 'Analyzer',
mcp: 'MCP',
};
export function installedCliVersion(): string {
return typeof pkg.version === 'string' ? pkg.version : '';
}
export function commandDisplayName(name: string): string {
return (
SPECIAL_TITLES[name] ??
name
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
);
}
export function commandBannerTitle(command: Command): string {
const names: string[] = [];
for (
let current: Command | null | undefined = command;
current?.parent;
current = current.parent
) {
names.unshift(current.name());
}
return names.map(commandDisplayName).join(' ');
}
export function formatCommandBanner(
title: string,
version: string = installedCliVersion(),
): string {
if (!title) return '';
return version ? `\n GitNexus ${title} (${version})\n` : `\n GitNexus ${title}\n`;
}
export interface WriteCommandBannerDependencies {
write?: (text: string) => void;
version?: string;
}
export function writeCommandBanner(
command: Command,
deps: WriteCommandBannerDependencies = {},
): void {
if (SKIP_COMMAND_BANNER.has(command.name())) return;
const text = formatCommandBanner(
commandBannerTitle(command),
deps.version ?? installedCliVersion(),
);
if (!text) return;
(deps.write ?? ((line) => process.stderr.write(line)))(text);
}

View file

@ -25,7 +25,10 @@ import {
} from '../core/lbug/lbug-config.js';
import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js';
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
import { updateEligibleInstallSync } from '../core/install-context.js';
import { readValidatedUpdateCacheSync, type ValidatedUpdateCache } from '../core/update-cache.js';
import { t } from './i18n/index.js';
import { cachedUpdateNoticeLine } from './update-notice.js';
function isCombiningMark(codePoint: number): boolean {
return (
@ -202,6 +205,15 @@ function nativeStatusText(check: NativeCheckResult): string {
}
}
export function cachedUpdateDoctorLine(options: {
installedVersion: string;
eligible: boolean;
env: NodeJS.ProcessEnv;
readCache: () => ValidatedUpdateCache | null;
}): string | null {
return cachedUpdateNoticeLine(options);
}
export const doctorCommand = async () => {
const fingerprint = getRuntimeFingerprint();
const capabilities = getRuntimeCapabilities();
@ -212,6 +224,13 @@ export const doctorCommand = async () => {
console.log(` ${label('doctor.labels.os', 10)}${fingerprint.platform}/${fingerprint.arch}`);
console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`);
console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`);
const updateLine = cachedUpdateDoctorLine({
installedVersion: fingerprint.gitnexus,
eligible: updateEligibleInstallSync(),
env: process.env,
readCache: () => readValidatedUpdateCacheSync(),
});
if (updateLine) console.log(` ${updateLine}`);
console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`);
// OS page size next to the LadybugDB version because the two interact:
// @ladybugdb/core < 0.18.0 assumed 4 KiB pages in its buffer manager and

View file

@ -22,6 +22,7 @@ const COMMAND_DESCRIPTION_KEYS = {
list: 'help.command.list.description',
status: 'help.command.status.description',
doctor: 'help.command.doctor.description',
update: 'help.command.update.description',
embeddings: 'help.command.embeddings.description',
'embeddings install': 'help.command.embeddings.install.description',
clean: 'help.command.clean.description',

View file

@ -7,6 +7,16 @@ export const en = {
'common.storage': 'Storage',
'common.deleted': 'Deleted: {{target}}',
'common.error': 'Error: {{message}}',
'update.available':
'GitNexus {{latestVersion}} is available (you are running {{installedVersion}}).',
'update.current':
'GitNexus {{installedVersion}} is current or newer than the latest stable version.',
'update.installing': 'Installing with {{command}}…',
'update.installed': 'Installed gitnexus@{{version}}. Restart long-running mcp/serve processes.',
'update.installFailed': 'npm install failed. You can retry: {{command}}',
'update.installError': 'Could not run npm: {{message}}',
'update.checkFailed':
'Could not check for updates (offline, private registry, or the check failed open).',
'list.title': 'Indexed Repositories ({{count}})',
'list.indexed': 'Indexed',
'list.commit': 'Commit',
@ -165,6 +175,8 @@ export const en = {
'help.command.status.description': 'Show index status for current repo',
'help.command.doctor.description':
'Show runtime platform capabilities and embedding configuration',
'help.command.update.description':
'Install the latest published GitNexus globally (`npm i -g gitnexus@<x.y.z>`).',
'help.command.embeddings.description': 'Manage the on-demand local embedding runtime',
'help.command.embeddings.install.description':
'Install the local embedding stack (@huggingface/transformers + onnxruntime-node) on demand. Heals installs where npm skipped the optional packages (e.g. behind an HTTP proxy, #2370). Downloads only from your configured npm registry — mirrors and proxies apply.',

View file

@ -11,6 +11,13 @@ export const zhCN = {
'common.storage': '存储',
'common.deleted': '已删除:{{target}}',
'common.error': '错误:{{message}}',
'update.available': 'GitNexus {{latestVersion}} 已发布(当前运行 {{installedVersion}})。',
'update.current': 'GitNexus {{installedVersion}} 已是最新稳定版或不低于该版本。',
'update.installing': '正在执行 {{command}}…',
'update.installed': '已安装 gitnexus@{{version}}。请重启仍在运行的 mcp/serve 进程。',
'update.installFailed': 'npm 安装失败。可重试:{{command}}',
'update.installError': '无法运行 npm{{message}}',
'update.checkFailed': '无法检查更新(离线、私有仓库,或检查失败)。',
'list.title': '已索引仓库({{count}}',
'list.indexed': '索引时间',
'list.commit': '提交',
@ -162,6 +169,8 @@ export const zhCN = {
'help.command.list.description': '列出所有已索引仓库',
'help.command.status.description': '显示当前仓库的索引状态',
'help.command.doctor.description': '显示运行平台能力和嵌入配置',
'help.command.update.description':
'通过 npm 全局安装最新发布的 GitNexus`npm i -g gitnexus@<x.y.z>`)。',
'help.command.embeddings.description': '管理按需安装的本地嵌入运行时',
'help.command.embeddings.install.description':
'按需安装本地嵌入组件(@huggingface/transformers + onnxruntime-node。修复 npm 跳过可选包的安装(例如在 HTTP 代理后,#2370。仅从你配置的 npm registry 下载 — 镜像和代理均生效。',

View file

@ -14,6 +14,8 @@ import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.j
import { registerGroupCommands } from './group.js';
import { localizeCliHelp } from './help-i18n.js';
import { t } from './i18n/index.js';
import { writeCommandBanner } from './command-banner.js';
import { runProcessCliUpdateNotice } from './update-notice.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
@ -299,6 +301,11 @@ program
.description('Show runtime platform capabilities and embedding configuration')
.action(createLazyAction(() => import('./doctor.js'), 'doctorCommand'));
program
.command('update')
.description('Install the latest published GitNexus globally (`npm i -g gitnexus@<x.y.z>`).')
.action(createLazyAction(() => import('./update.js'), 'updateCommand'));
program
.command('embeddings')
.description('Manage the on-demand local embedding runtime')
@ -502,7 +509,17 @@ program
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
.action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
program.command('__update-check', { hidden: true }).action(async () => {
const { refresh } = await import('../core/update-check.js');
await refresh();
});
registerGroupCommands(program);
localizeCliHelp(program);
program.hook('preAction', (_thisCommand, actionCommand) => {
writeCommandBanner(actionCommand);
});
runProcessCliUpdateNotice(typeof pkg.version === 'string' ? pkg.version : '');
program.parse(process.argv);

View file

@ -29,6 +29,71 @@
import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js';
import type { UpdateState } from '../core/update-check.js';
interface McpUpdateLogger {
info(bindings: Record<string, unknown>, message: string): unknown;
}
interface McpUpdateChecker {
evaluate(): Promise<UpdateState | null>;
armUpdateRefreshScheduler(onState: (state: UpdateState | null) => void): () => void;
}
type LoadMcpUpdateChecker = () => Promise<McpUpdateChecker>;
const announcedUpdateVersions = new Set<string>();
/**
* Start the process-scoped MCP update adapter after its transport startup
* boundary. All failures stay inside this best-effort side channel.
*/
export async function startMcpUpdateNotifier(
logger: McpUpdateLogger,
loadChecker: LoadMcpUpdateChecker = () => import('../core/update-check.js'),
): Promise<void> {
let checker: McpUpdateChecker;
try {
checker = await loadChecker();
} catch {
return;
}
const announce = (state: UpdateState | null): void => {
try {
if (
!state?.updateAvailable ||
!state.latestVersion ||
announcedUpdateVersions.has(state.latestVersion)
) {
return;
}
announcedUpdateVersions.add(state.latestVersion);
logger.info(
{ event: 'gitnexus.update_available', latestVersion: state.latestVersion },
'GitNexus update available',
);
} catch {
// Logging must never escape into MCP startup or scheduler promises.
}
};
try {
announce(await checker.evaluate());
} catch {
// Cache evaluation and any detached refresh are best-effort.
}
let stop: (() => void) | undefined;
try {
stop = checker.armUpdateRefreshScheduler(announce);
} catch {
return;
}
process.once('exit', () => stop?.());
}
export const mcpCommand = async (options?: {
http?: boolean;
port?: string;
@ -117,9 +182,11 @@ export const mcpCommand = async (options?: {
);
process.exit(1);
}
void startMcpUpdateNotifier(logger).catch(() => {});
return;
}
// Start MCP server (serves all repos, discovers new ones lazily)
await startMCPServer(backend, repositoryPolicy);
void startMcpUpdateNotifier(logger).catch(() => {});
};

View file

@ -0,0 +1,128 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { updateEligibleInstallSync } from '../core/install-context.js';
import {
isNewerVersion,
readValidatedUpdateCacheSync,
updateNotifierOptedOut,
updateRefreshInProgress,
type ValidatedUpdateCache,
} from '../core/update-cache.js';
import { t } from './i18n/index.js';
const EXCLUDED_COMMANDS = new Set([
'augment',
'mcp',
'serve',
'eval-server',
'update',
'__update-check',
]);
const EXCLUDED_FLAGS = new Set(['--help', '-h', '--version', '-V']);
type SpawnResult = { unref(): void };
type SpawnLike = (
command: string,
args: readonly string[],
options: { detached: true; stdio: 'ignore'; windowsHide: true },
) => SpawnResult;
export interface CliUpdateNoticeDependencies {
argv: string[];
env: NodeJS.ProcessEnv;
installedVersion: string;
isTTY: boolean | undefined;
eligible: boolean;
now: number;
readCache: (options: { env: NodeJS.ProcessEnv; now: number }) => ValidatedUpdateCache | null;
writeStderr: (line: string) => unknown;
spawn: SpawnLike;
}
function excludedInvocation(argv: string[]): boolean {
const args = argv.slice(2);
if (args.some((arg) => EXCLUDED_FLAGS.has(arg))) return true;
const command = args.find((arg) => !arg.startsWith('-'));
return command !== undefined && EXCLUDED_COMMANDS.has(command);
}
export function updateNoticeText(installedVersion: string, latestVersion: string): string {
return t('update.available', { installedVersion, latestVersion });
}
/** Shared cache-gated notice line used by the CLI banner and `doctor`. */
export function cachedUpdateNoticeLine(options: {
installedVersion: string;
eligible: boolean;
env: NodeJS.ProcessEnv;
readCache: () => ValidatedUpdateCache | null;
}): string | null {
try {
if (!options.eligible || updateNotifierOptedOut(options.env)) return null;
const cache = options.readCache();
if (!cache?.latestVersion || !isNewerVersion(options.installedVersion, cache.latestVersion)) {
return null;
}
return updateNoticeText(options.installedVersion, cache.latestVersion);
} catch {
return null;
}
}
export function runCliUpdateNotice(deps: CliUpdateNoticeDependencies): void {
try {
if (
deps.isTTY !== true ||
updateNotifierOptedOut(deps.env) ||
!deps.eligible ||
excludedInvocation(deps.argv)
) {
return;
}
const cache = deps.readCache({ env: deps.env, now: deps.now });
if (cache?.latestVersion && isNewerVersion(deps.installedVersion, cache.latestVersion)) {
deps.writeStderr(`${updateNoticeText(deps.installedVersion, cache.latestVersion)}\n`);
}
if (cache === null || cache.stale) {
// Coalesce parallel invocations: when a live process holds the refresh
// lock, its refresh covers us, so don't fork another CLI.
if (!updateRefreshInProgress(deps.env)) {
try {
deps
.spawn(process.execPath, [deps.argv[1] ?? '', '__update-check'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
})
.unref();
} catch {
// Update refresh is best-effort and must never reach Commander parsing.
}
}
}
} catch {
// Cache reads and all adapter logic fail open.
}
}
export function runProcessCliUpdateNotice(installedVersion: string): void {
if (
process.stderr.isTTY !== true ||
updateNotifierOptedOut(process.env) ||
excludedInvocation(process.argv)
) {
return;
}
runCliUpdateNotice({
argv: process.argv,
env: process.env,
installedVersion,
isTTY: process.stderr.isTTY,
eligible: updateEligibleInstallSync(),
now: Date.now(),
readCache: readValidatedUpdateCacheSync,
writeStderr: (line) => process.stderr.write(line),
spawn: nodeSpawn as unknown as SpawnLike,
});
}

126
gitnexus/src/cli/update.ts Normal file
View file

@ -0,0 +1,126 @@
/**
* Explicit `gitnexus update`: refresh the latest dist-tag, then install
* `gitnexus@<version>` globally with npm the same shape as `claude update`
* / `codex update`. Other commands only notify; they never spawn npm.
*/
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { composeWin32NpmCommand } from '../core/embeddings/runtime-install.js';
import { STRICT_UPDATE_VERSION } from '../core/update-cache.js';
import { refresh, type UpdateState } from '../core/update-check.js';
import { t } from './i18n/index.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json') as { version?: unknown };
export const UPDATE_PACKAGE = 'gitnexus';
export interface UpdateCommandDependencies {
installedVersion: string;
refresh: (options: {
eligible: true;
ignoreOptOut: true;
installedVersion: string;
}) => Promise<UpdateState | null>;
writeStdout: (line: string) => void;
runInstall: (version: string) => Promise<number>;
setExitCode: (code: number) => void;
}
function defaultInstalledVersion(): string {
return typeof pkg.version === 'string' ? pkg.version : '';
}
export function updateInstallArgs(version: string): string[] {
return ['i', '-g', `${UPDATE_PACKAGE}@${version}`];
}
export function updateInstallCommand(version: string): string {
return `npm ${updateInstallArgs(version).join(' ')}`;
}
function isTestDeps(value: unknown): value is Partial<UpdateCommandDependencies> {
return (
typeof value === 'object' &&
value !== null &&
('refresh' in value || 'runInstall' in value || 'writeStdout' in value)
);
}
function defaultRunInstall(version: string): Promise<number> {
const args = updateInstallArgs(version);
return new Promise((resolve, reject) => {
const child =
process.platform === 'win32'
? spawn(composeWin32NpmCommand(args), {
cwd: homedir(),
windowsHide: true,
shell: true,
stdio: 'inherit',
})
: spawn('npm', args, {
cwd: homedir(),
windowsHide: true,
stdio: 'inherit',
});
child.on('error', reject);
child.on('exit', (code, signal) => {
resolve(signal ? 1 : (code ?? 1));
});
});
}
export async function updateCommand(maybeDeps?: unknown): Promise<void> {
const deps = isTestDeps(maybeDeps) ? maybeDeps : {};
const installedVersion = deps.installedVersion ?? defaultInstalledVersion();
const runRefresh = deps.refresh ?? refresh;
const writeStdout = deps.writeStdout ?? ((line: string) => console.log(line));
const runInstall = deps.runInstall ?? defaultRunInstall;
const setExitCode =
deps.setExitCode ??
((code: number) => {
process.exitCode = code;
});
const state = await runRefresh({
eligible: true,
ignoreOptOut: true,
installedVersion,
});
const latestVersion =
state?.latestVersion && STRICT_UPDATE_VERSION.test(state.latestVersion)
? state.latestVersion
: undefined;
if (state?.updateAvailable && latestVersion) {
writeStdout(t('update.available', { installedVersion, latestVersion }));
writeStdout(t('update.installing', { command: updateInstallCommand(latestVersion) }));
try {
const code = await runInstall(latestVersion);
if (code !== 0) {
writeStdout(t('update.installFailed', { command: updateInstallCommand(latestVersion) }));
setExitCode(code);
return;
}
} catch (error) {
writeStdout(
t('update.installError', {
message: error instanceof Error ? error.message : String(error),
}),
);
setExitCode(1);
return;
}
writeStdout(t('update.installed', { version: latestVersion }));
return;
}
if (latestVersion) {
writeStdout(t('update.current', { installedVersion }));
return;
}
writeStdout(t('update.checkFailed'));
}

View file

@ -1,14 +1,14 @@
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { ContractRegistry } from './types.js';
import { writeFileAtomic } from '../../storage/fs-atomic.js';
import { getGlobalDir } from '../../storage/global-dir.js';
export const CONTRACTS_FILE = 'contracts.json';
export function getDefaultGitnexusDir(): string {
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
return getGlobalDir();
}
export function getGroupsBaseDir(gitnexusDir?: string): string {

View file

@ -0,0 +1,170 @@
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
const EPHEMERAL_SEGMENTS = new Set(['_npx', '_cacache']);
const EPHEMERAL_DLX_OWNERS = new Set(['pnpm', 'yarn']);
function isInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function hasEphemeralMarker(candidate: string): boolean {
const normalized = candidate.replaceAll('\\', '/').toLowerCase();
const segments = normalized.split('/').filter(Boolean);
if (segments.some((segment) => EPHEMERAL_SEGMENTS.has(segment))) return true;
// `dlx` is only ephemeral next to a package-manager owner (pnpm dlx / yarn
// dlx). A project directory that happens to be named `dlx` is a normal install.
if (segments.includes('dlx') && segments.some((segment) => EPHEMERAL_DLX_OWNERS.has(segment))) {
return true;
}
return normalized.includes('/.bun/install/cache/') || normalized.includes('/bun/install/cache/');
}
function findPackageDir(entryPath: string): string | null {
let current = path.dirname(path.resolve(entryPath));
for (;;) {
if (
path.basename(current) === 'gitnexus' &&
path.basename(path.dirname(current)) === 'node_modules'
) {
return current;
}
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
interface EligibilityProbes {
realEntry: string;
env: NodeJS.ProcessEnv;
/** Resolved npm cache dir, when npm_config_cache is set. */
realCache: string | null;
packageDir: string | null;
/** Whether the resolved package directory carries a .git checkout marker. */
packageDirHasGit: boolean;
/** Resolved npm prefix, when npm_config_prefix is set. */
realPrefix: string | null;
}
/**
* Pure classification shared by the async and sync variants. Realpath
* resolution deliberately makes a linked node_modules entry point at its
* development checkout, which is therefore ineligible.
*/
function classifyEligibility(probes: EligibilityProbes): boolean {
const { realEntry, env, realCache, packageDir, packageDirHasGit, realPrefix } = probes;
const corroboratingPaths = [
realEntry,
env.npm_execpath,
env.npm_config_cache && path.resolve(env.npm_config_cache),
].filter((value): value is string => Boolean(value));
if (corroboratingPaths.some(hasEphemeralMarker)) return false;
if (realCache && isInside(realCache, realEntry)) return false;
// Published packages do not carry .git. This also rejects unusual installs
// copied wholesale from a checkout.
if (!packageDir || packageDirHasGit) return false;
if (realPrefix && isInside(realPrefix, realEntry)) return true;
// A package rooted at node_modules/gitnexus is a persistent project-local
// install after ephemeral/cache layouts have been excluded above.
return true;
}
let memoizedEligible: boolean | undefined;
/** True only for a persistent npm global or project-local installation. */
export async function updateEligibleInstall(
entryPath?: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<boolean> {
const useMemo = entryPath === undefined && env === process.env;
if (useMemo && memoizedEligible !== undefined) return memoizedEligible;
const result = await classifyAsync(entryPath ?? process.argv[1] ?? '', env);
if (useMemo) memoizedEligible = result;
return result;
}
async function classifyAsync(entryPath: string, env: NodeJS.ProcessEnv): Promise<boolean> {
if (!entryPath) return false;
try {
const realEntry = await fs.realpath(entryPath);
const realCache = env.npm_config_cache
? await fs
.realpath(env.npm_config_cache)
.catch(() => path.resolve(env.npm_config_cache as string))
: null;
const packageDir = findPackageDir(realEntry);
const packageDirHasGit = packageDir
? await fs
.access(path.join(packageDir, '.git'))
.then(() => true)
.catch(() => false)
: false;
const realPrefix = env.npm_config_prefix
? await fs.realpath(env.npm_config_prefix).catch(() => path.resolve(env.npm_config_prefix))
: null;
return classifyEligibility({
realEntry,
env,
realCache,
packageDir,
packageDirHasGit,
realPrefix,
});
} catch {
return false;
}
}
/** Synchronous entry-point variant for pre-Commander startup checks. */
export function updateEligibleInstallSync(
entryPath?: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const useMemo = entryPath === undefined && env === process.env;
if (useMemo && memoizedEligible !== undefined) return memoizedEligible;
const result = classifySync(entryPath ?? process.argv[1] ?? '', env);
if (useMemo) memoizedEligible = result;
return result;
}
function classifySync(entryPath: string, env: NodeJS.ProcessEnv): boolean {
if (!entryPath) return false;
try {
const realEntry = fsSync.realpathSync(entryPath);
let realCache: string | null = null;
if (env.npm_config_cache) {
try {
realCache = fsSync.realpathSync(env.npm_config_cache);
} catch {
realCache = path.resolve(env.npm_config_cache);
}
}
const packageDir = findPackageDir(realEntry);
const packageDirHasGit = packageDir ? fsSync.existsSync(path.join(packageDir, '.git')) : false;
let realPrefix: string | null = null;
if (env.npm_config_prefix) {
try {
realPrefix = fsSync.realpathSync(env.npm_config_prefix);
} catch {
realPrefix = path.resolve(env.npm_config_prefix);
}
}
return classifyEligibility({
realEntry,
env,
realCache,
packageDir,
packageDirHasGit,
realPrefix,
});
} catch {
return false;
}
}

View file

@ -0,0 +1,174 @@
import { isIP } from 'net';
// Cloud metadata hostnames that must never be reachable via user-supplied URLs
const BLOCKED_HOSTNAMES = new Set([
'localhost',
'metadata.google.internal',
'metadata.azure.com',
'metadata.internal',
]);
/**
* Validate an outbound http(s) URL to prevent SSRF.
* Only allows https:// and http:// schemes. Blocks private/internal addresses,
* IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
*/
export function validateGitUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error('Invalid URL');
}
if (!['https:', 'http:'].includes(parsed.protocol)) {
throw new Error('Only https:// and http:// git URLs are allowed');
}
if (parsed.search || parsed.hash) {
throw new Error('Git URLs must not include query strings or fragments');
}
const host = parsed.hostname.toLowerCase();
// Block known dangerous hostnames (cloud metadata services)
if (BLOCKED_HOSTNAMES.has(host)) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
let normalizedHost = host;
if (host.startsWith('[') && host.endsWith(']')) {
normalizedHost = host.slice(1, -1);
}
// Check if this is an IPv6 address
// Use manual colon detection as fallback since isIP may return 0 for some
// normalized IPv6 forms (e.g. ::ffff:7f00:1)
const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
if (isIPv6) {
assertNotPrivateIPv6(normalizedHost);
return;
}
// Check if this is an IPv4 address (including numeric encodings)
if (isIP(normalizedHost) === 4) {
assertNotPrivateIPv4(normalizedHost);
return;
}
// For non-IP hostnames, check for numeric IP tricks
// Decimal encoding: 2130706433 = 127.0.0.1
// Hex encoding: 0x7f000001 = 127.0.0.1
if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// Standard IPv4 regex checks for dotted notation
if (
/^127\./.test(host) ||
/^10\./.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
/^192\.168\./.test(host) ||
/^169\.254\./.test(host) ||
/^0\./.test(host) ||
host === '0.0.0.0' ||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
/^198\.1[89]\./.test(host)
) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
}
function assertNotPrivateIPv6(ip: string): void {
// Expand common compressed forms for comparison
const lower = ip.toLowerCase();
// IPv6 loopback
if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// Unspecified address
if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
if (lower.startsWith('fc') || lower.startsWith('fd')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv6 link-local (fe80::/10)
if (
lower.startsWith('fe80') ||
lower.startsWith('fe8') ||
lower.startsWith('fe9') ||
lower.startsWith('fea') ||
lower.startsWith('feb')
) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
// Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
if (lower.startsWith('::ffff:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// Expanded IPv4-mapped form only (0:0:0:0:0:ffff:…). A public address that
// merely contains a `ffff` hextet is not mapped.
if (lower.startsWith('0:0:0:0:0:ffff:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
// Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
// is hidden in the last 32 bits without the ::ffff: marker, so the check
// above misses it. The form is still routable to the embedded IPv4 on most
// network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
// 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
// ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
// e.g. 64:ff9b::7f00:1 → 127.0.0.1.
// The check intentionally covers the full 64:ff9b::/32 block (broader than
// the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
// blocking the whole prefix is defensively sound and prevents a narrower
// CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
// future translation assignment.
if (lower.startsWith('64:ff9b:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
// 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
// protocol was deprecated by RFC 7526 and the public relay anycast
// (192.88.99.1) has been retired, so broad-blocking the prefix has near-
// zero false-positive cost while closing the IPv4-embedded bypass.
// Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
// impractical and is out of scope here.
if (lower.startsWith('2002:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
}
function assertNotPrivateIPv4(ip: string): void {
const parts = ip.split('.').map(Number);
const [a, b] = parts;
if (
a === 127 ||
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
a === 0 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 198 && (b === 18 || b === 19))
) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
}

View file

@ -0,0 +1,172 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getGlobalDir } from '../storage/global-dir.js';
import { isProcessAlive, readProcessStartTime } from '../utils/process-identity.js';
export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
export const STRICT_UPDATE_VERSION = /^\d+\.\d+\.\d+$/;
export const DEFAULT_UPDATE_REGISTRY = 'https://registry.npmjs.org';
export interface UpdateCacheEntry {
lastCheckAt: string;
registry: string;
latestVersion?: string;
}
export interface ValidatedUpdateCache {
lastCheckAt: number;
latestVersion?: string;
stale: boolean;
}
/** Broad truthy parsing shared by the update-notifier guards. */
export function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
return !['', '0', 'false', 'no', 'off'].includes(value.toLowerCase());
}
/** The update notifier is disabled by either opt-out env or a CI environment. */
export function updateNotifierOptedOut(env: NodeJS.ProcessEnv): boolean {
return (
isTruthyEnv(env.GITNEXUS_NO_UPDATE_NOTIFIER) ||
isTruthyEnv(env.NO_UPDATE_NOTIFIER) ||
isTruthyEnv(env.CI)
);
}
/** Freshness gate for the 24h TTL; future-dated timestamps are stale. */
export function isUpdateCacheFresh(lastCheckAt: string, now: number): boolean {
const checkedAt = Date.parse(lastCheckAt);
return checkedAt <= now && now - checkedAt < UPDATE_CACHE_TTL_MS;
}
/** Strict x.y.z numeric comparison. Invalid or prerelease versions are silent. */
export function isNewerVersion(installedVersion: string, latestVersion: string): boolean {
if (!STRICT_UPDATE_VERSION.test(installedVersion) || !STRICT_UPDATE_VERSION.test(latestVersion)) {
return false;
}
const installed = installedVersion.split('.').map(BigInt);
const latest = latestVersion.split('.').map(BigInt);
for (let index = 0; index < 3; index += 1) {
if (latest[index] !== installed[index]) return latest[index] > installed[index];
}
return false;
}
let registryMemo: { key: string; value: { identity: string; packageUrl: string } } | undefined;
export function normalizedUpdateRegistry(env: NodeJS.ProcessEnv = process.env): {
identity: string;
packageUrl: string;
} {
const key = env.npm_config_registry ?? '';
if (registryMemo?.key === key) return registryMemo.value;
const value = buildUpdateRegistry(key);
registryMemo = { key, value };
return value;
}
function buildUpdateRegistry(rawRegistry: string): { identity: string; packageUrl: string } {
const parsed = new URL(rawRegistry || DEFAULT_UPDATE_REGISTRY);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error('Unsupported npm registry protocol');
}
if (parsed.search || parsed.hash) {
throw new Error('Registry URL cannot contain query or fragment');
}
parsed.username = '';
parsed.password = '';
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
const identity = `${parsed.protocol}//${parsed.host}${pathname}`;
// `/<pkg>/latest` is the small dist-tag document. The full packument at
// `/<pkg>` is multi-megabyte on this package and cannot fit the fetch cap.
const packagePath = `${pathname}/gitnexus/latest`.replace(/\/{2,}/g, '/');
return { identity, packageUrl: `${parsed.protocol}//${parsed.host}${packagePath}` };
}
export function updateCheckCachePath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(env.GITNEXUS_HOME || getGlobalDir(), 'update-check.json');
}
export function updateCheckLockPath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(env.GITNEXUS_HOME || getGlobalDir(), 'update-check.lock');
}
/**
* Best-effort synchronous probe: is a refresh plausibly in flight? Used to
* coalesce detached refresh spawns. A dead same-host owner returns false so
* the spawned child can reclaim the stale lock; a foreign-host owner returns
* false and lets the child's full lock logic decide.
*/
export function updateRefreshInProgress(env: NodeJS.ProcessEnv = process.env): boolean {
try {
const owner = JSON.parse(fs.readFileSync(updateCheckLockPath(env), 'utf8')) as {
pid?: unknown;
hostname?: unknown;
processStartTime?: unknown;
};
if (typeof owner.pid !== 'number' || owner.pid <= 0) return false;
if (owner.hostname !== os.hostname()) return false;
if (!isProcessAlive(owner.pid)) return false;
// Same rule as acquireFileLock: a live PID with a different start time is
// reuse, not the lock owner. Unreadable start time stays conservative
// (treat as in progress) so we don't spawn a racing child.
if (typeof owner.processStartTime === 'string' && owner.processStartTime) {
const currentStartTime = readProcessStartTime(owner.pid);
if (currentStartTime && currentStartTime !== owner.processStartTime) return false;
}
return true;
} catch {
return false;
}
}
export function parseUpdateCache(raw: string, registry: string): UpdateCacheEntry | null {
try {
const parsed = JSON.parse(raw) as Partial<UpdateCacheEntry>;
if (
typeof parsed.lastCheckAt !== 'string' ||
!Number.isFinite(Date.parse(parsed.lastCheckAt)) ||
parsed.registry !== registry ||
(parsed.latestVersion !== undefined &&
(typeof parsed.latestVersion !== 'string' ||
!STRICT_UPDATE_VERSION.test(parsed.latestVersion)))
) {
return null;
}
return {
lastCheckAt: parsed.lastCheckAt,
registry: parsed.registry,
...(parsed.latestVersion === undefined ? {} : { latestVersion: parsed.latestVersion }),
};
} catch {
return null;
}
}
export function readValidatedUpdateCacheSync(
options: {
env?: NodeJS.ProcessEnv;
now?: number;
} = {},
): ValidatedUpdateCache | null {
try {
const env = options.env ?? process.env;
const registry = normalizedUpdateRegistry(env);
const raw = fs.readFileSync(updateCheckCachePath(env), 'utf8');
const entry = parseUpdateCache(raw, registry.identity);
if (!entry) return null;
const lastCheckAt = Date.parse(entry.lastCheckAt);
const now = options.now ?? Date.now();
return {
lastCheckAt,
...(entry.latestVersion === undefined ? {} : { latestVersion: entry.latestVersion }),
stale: !isUpdateCacheFresh(entry.lastCheckAt, now),
};
} catch {
return null;
}
}

View file

@ -0,0 +1,331 @@
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import { getGlobalDir } from '../storage/global-dir.js';
import { writeFileAtomic } from '../storage/fs-atomic.js';
import { acquireFileLock, FileLockBusyError } from '../storage/file-lock.js';
import { validateGitUrl } from './net/url-guard.js';
import { updateEligibleInstall } from './install-context.js';
import { createLogger } from './logger.js';
import {
isNewerVersion,
isUpdateCacheFresh,
normalizedUpdateRegistry,
parseUpdateCache,
STRICT_UPDATE_VERSION,
UPDATE_CACHE_TTL_MS,
updateCheckCachePath,
updateCheckLockPath,
updateNotifierOptedOut,
type UpdateCacheEntry,
} from './update-cache.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json') as { version?: unknown };
const FETCH_TIMEOUT_MS = 3_000;
const MAX_RESPONSE_BYTES = 64 * 1024;
const MAX_REDIRECTS = 5;
/** Backoff when refresh cannot publish (lock-busy or still-stale cache). */
const LOCK_BUSY_RETRY_MIN_MS = 30_000;
const LOCK_BUSY_RETRY_JITTER_MS = 30_000;
const updateLogger = createLogger('update-check');
function defaultInstalledVersion(): string {
return typeof pkg.version === 'string' ? pkg.version : '';
}
function nextSchedulerDelay(entry: UpdateCacheEntry | null, now: number): number {
if (entry && isUpdateCacheFresh(entry.lastCheckAt, now)) {
return Math.max(1, Date.parse(entry.lastCheckAt) + UPDATE_CACHE_TTL_MS - now);
}
// Missing, future-dated, or still stale after a lock-busy skip: back off
// from now instead of deriving 1ms from a past-due timestamp.
return LOCK_BUSY_RETRY_MIN_MS + Math.floor(Math.random() * LOCK_BUSY_RETRY_JITTER_MS);
}
export interface UpdateState {
updateAvailable: boolean;
latestVersion?: string;
}
export interface UpdateCheckOptions {
/** Test/adapter override; omitted means classify process.argv[1]. */
eligible?: boolean;
/** Test override; omitted means this package's installed version. */
installedVersion?: string;
/** Test override in epoch milliseconds. */
now?: number;
/** Cache-only consumers can suppress stale-while-revalidate. */
refreshIfStale?: boolean;
/** Explicit `gitnexus update`: check even when CI/opt-out env is set. */
ignoreOptOut?: boolean;
}
export interface UpdateRefreshSchedulerOptions extends Omit<
UpdateCheckOptions,
'now' | 'refreshIfStale'
> {
now?: () => number;
}
function isOptedOut(): boolean {
return updateNotifierOptedOut(process.env);
}
async function isEligible(override: boolean | undefined): Promise<boolean> {
return override ?? (await updateEligibleInstall());
}
function cacheFile(): string {
return updateCheckCachePath();
}
function lockFile(): string {
return updateCheckLockPath();
}
function normalizedRegistry(): { identity: string; packageUrl: string } {
const registry = normalizedUpdateRegistry();
validateGitUrl(registry.packageUrl);
return registry;
}
async function readCache(registry: string): Promise<UpdateCacheEntry | null> {
try {
return parseUpdateCache(await fs.readFile(cacheFile(), 'utf8'), registry);
} catch {
return null;
}
}
function stateFrom(entry: UpdateCacheEntry, installedVersion: string): UpdateState {
return {
updateAvailable:
entry.latestVersion !== undefined && isNewerVersion(installedVersion, entry.latestVersion),
...(entry.latestVersion === undefined ? {} : { latestVersion: entry.latestVersion }),
};
}
function installedVersionOf(options: { installedVersion?: string }): string {
return options.installedVersion ?? defaultInstalledVersion();
}
async function isNotifierActive(
options: {
eligible?: boolean;
ignoreOptOut?: boolean;
} = {},
): Promise<boolean> {
return (options.ignoreOptOut === true || !isOptedOut()) && (await isEligible(options.eligible));
}
/**
* Read update state cache-first. Every invalid/missing/stale cache starts one
* catch-isolated refresh unless the caller explicitly requests cache-only.
*/
export async function evaluate(options: UpdateCheckOptions = {}): Promise<UpdateState | null> {
try {
if (!(await isNotifierActive(options))) return null;
const registry = normalizedRegistry();
const now = options.now ?? Date.now();
const entry = await readCache(registry.identity);
if (
(!entry || !isUpdateCacheFresh(entry.lastCheckAt, now)) &&
options.refreshIfStale !== false
) {
void refresh(options).catch(() => {});
}
if (!entry) return null;
return stateFrom(entry, installedVersionOf(options));
} catch {
return null;
}
}
async function readResponseBody(response: Response): Promise<string> {
const advertised = Number(response.headers.get('content-length'));
if (Number.isFinite(advertised) && advertised > MAX_RESPONSE_BYTES) {
await response.body?.cancel().catch(() => {});
throw new Error('Registry response too large');
}
if (!response.body) return '';
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_RESPONSE_BYTES) throw new Error('Registry response too large');
chunks.push(value);
}
} finally {
if (bytes > MAX_RESPONSE_BYTES) await reader.cancel().catch(() => {});
reader.releaseLock();
}
return Buffer.concat(chunks).toString('utf8');
}
function sanitizedHttpUrl(input: string | URL, base?: string): URL {
const parsed = new URL(input, base);
parsed.username = '';
parsed.password = '';
validateGitUrl(parsed.toString());
return parsed;
}
async function fetchLatest(packageUrl: string): Promise<string> {
let url = sanitizedHttpUrl(packageUrl);
for (let redirects = 0; ; redirects += 1) {
const response = await fetch(url.toString(), {
method: 'GET',
redirect: 'manual',
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (response.status >= 300 && response.status < 400) {
if (redirects >= MAX_REDIRECTS) throw new Error('Too many registry redirects');
const location = response.headers.get('location');
await response.body?.cancel().catch(() => {});
if (!location) throw new Error('Registry redirect missing location');
url = sanitizedHttpUrl(location, url.toString());
continue;
}
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(`Registry returned ${response.status}`);
}
const parsed = JSON.parse(await readResponseBody(response)) as {
version?: unknown;
'dist-tags'?: { latest?: unknown };
};
const latest =
(typeof parsed.version === 'string' ? parsed.version : undefined) ??
(typeof parsed['dist-tags']?.latest === 'string' ? parsed['dist-tags'].latest : undefined);
if (typeof latest !== 'string' || !STRICT_UPDATE_VERSION.test(latest)) {
throw new Error('Registry latest version is invalid');
}
return latest;
}
}
async function publishMonotonically(
entry: UpdateCacheEntry,
attemptStartedAt: number,
): Promise<void> {
const current = await readCache(entry.registry);
const currentAt = current ? Date.parse(current.lastCheckAt) : Number.NaN;
// A later in-the-past write wins. Future-dated entries (wall-clock) are
// clock-skew poison and must stay replaceable so a later holder can repair them.
if (Number.isFinite(currentAt) && currentAt <= Date.now() && currentAt > attemptStartedAt) {
return;
}
await fs.mkdir(getGlobalDir(), { recursive: true });
await writeFileAtomic(cacheFile(), `${JSON.stringify(entry)}\n`, 1);
}
let refreshInFlight: Promise<UpdateState | null> | null = null;
/** Run one locked, fail-open registry refresh. */
export function refresh(options: UpdateCheckOptions = {}): Promise<UpdateState | null> {
if (refreshInFlight) return refreshInFlight;
const run = async (): Promise<UpdateState | null> => {
let release: (() => Promise<void>) | undefined;
try {
if (!(await isNotifierActive(options))) return null;
const registry = normalizedRegistry();
const attemptStartedAt = options.now ?? Date.now();
try {
release = await acquireFileLock(lockFile(), { retries: 0 });
} catch (error) {
if (error instanceof FileLockBusyError) return null;
throw error;
}
let fetched: string | undefined;
try {
fetched = await fetchLatest(registry.packageUrl);
} catch {
// Negative entries enforce the same TTL on offline/authenticated-only
// registries as successful checks. A known same-identity latestVersion
// must survive a later failed refresh so notices do not go silent
// for a day; only a first-ever miss stays version-less.
}
const latestVersion = fetched ?? (await readCache(registry.identity))?.latestVersion;
const entry: UpdateCacheEntry = {
lastCheckAt: new Date(attemptStartedAt).toISOString(),
registry: registry.identity,
...(latestVersion === undefined ? {} : { latestVersion }),
};
await publishMonotonically(entry, attemptStartedAt);
// Live fetch failed: keep the on-disk pin for notices, but do not
// return it as a confirmed refresh so `gitnexus update` cannot install
// from an unconfirmed cache.
if (fetched === undefined) return null;
return stateFrom(entry, installedVersionOf(options));
} catch (error) {
updateLogger.debug(
{ code: (error as NodeJS.ErrnoException).code },
'Update check failed open',
);
return null;
} finally {
if (release) await release().catch(() => {});
}
};
refreshInFlight = run().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
/**
* Start an immediate evaluation and repeat on the cache TTL cadence. Timers
* never keep the process alive; refresh() supplies process-wide single-flight.
*/
export function armUpdateRefreshScheduler(
onState: (state: UpdateState | null) => void,
options: UpdateRefreshSchedulerOptions = {},
): () => void {
let stopped = false;
let timer: NodeJS.Timeout | undefined;
const cycle = async (): Promise<void> => {
if (stopped) return;
const now = options.now?.() ?? Date.now();
let entry: UpdateCacheEntry | null = null;
try {
const registry = normalizedRegistry();
entry = await readCache(registry.identity);
if (!entry || !isUpdateCacheFresh(entry.lastCheckAt, now)) {
await refresh({ ...options, now });
entry = await readCache(registry.identity);
}
} catch {
// The public scheduler shares the service's fail-open contract.
}
// Derive state from the entry already read above; a full evaluate() here
// would re-run guards and re-read the cache on every tick.
let state: UpdateState | null = null;
try {
state =
!entry || !(await isNotifierActive(options))
? null
: stateFrom(entry, installedVersionOf(options));
} catch {
state = null;
}
if (!stopped) onState(state);
if (!stopped) {
timer = setTimeout(() => void cycle(), nextSchedulerDelay(entry, now));
timer.unref();
}
};
timer = setTimeout(() => void cycle(), 0);
timer.unref();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
};
}

View file

@ -12,7 +12,6 @@ import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs/promises';
import { createRequire } from 'node:module';
import {
canonicalizePath,
cloneDirBelongsToEntry,
@ -80,9 +79,19 @@ import { UPLOAD_ROOT } from './upload-paths.js';
import { sweepStaleUploads } from './upload-sweep.js';
import { isRfc1918PrivateIpv4 } from './private-ip.js';
import { logger, flushLoggerSync } from '../core/logger.js';
import {
bindServeUpdateControllerLifecycle,
buildServerInfo,
createServeUpdateController,
} from './update-controller.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
export {
bindServeUpdateControllerLifecycle,
buildServerInfo,
createServeUpdateController,
type ServerInfoResponse,
type ServeUpdateController,
} from './update-controller.js';
/**
* Determine whether an HTTP Origin header value is allowed by CORS policy.
@ -844,6 +853,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
await backend.init();
const cleanupMcp = await mountMCPEndpoints(app, backend);
const jobManager = new JobManager();
const updateController = createServeUpdateController();
// Backstop: remove any upload staging dirs orphaned by a previous crash.
void sweepStaleUploads().catch(() => {});
@ -981,21 +991,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Server info: version and launch context (npx / global / local dev)
app.get('/api/info', (_req, res) => {
const execPath = process.env.npm_execpath ?? '';
const argv0 = process.argv[1] ?? '';
let launchContext: 'npx' | 'global' | 'local';
if (
execPath.includes('npx') ||
argv0.includes('_npx') ||
process.env.npm_config_prefix?.includes('_npx')
) {
launchContext = 'npx';
} else if (argv0.includes('node_modules')) {
launchContext = 'local';
} else {
launchContext = 'global';
}
res.json({ version: pkg.version, launchContext, nodeVersion: process.version });
res.json(buildServerInfo(updateController.snapshot()));
});
// List all registered repos
@ -2060,12 +2056,15 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
resolve();
});
server.on('error', (err) => reject(err));
// `listening` is the successful startup boundary for notifier work.
bindServeUpdateControllerLifecycle(server, updateController);
// Graceful shutdown — close Express + LadybugDB cleanly. Pino's default
// destination is `sync: false` (buffered); `flushLoggerSync()` before
// `process.exit` so records emitted during cleanup reach stderr.
const shutdown = async () => {
console.log('\nShutting down...');
updateController.stop();
server.close();
jobManager.dispose();
embedJobManager.dispose();

View file

@ -8,17 +8,19 @@
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs/promises';
import { isIP } from 'net';
import os from 'node:os';
import { logger } from '../core/logger.js';
import { getGlobalDir } from '../storage/repo-manager.js';
import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js';
import { validateGitUrl } from '../core/net/url-guard.js';
import {
assertDirectoryOwnerAndPermissions,
quarantineAutoSyncPartial,
} from '../core/auto-sync/path-security.js';
import { validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
export { validateGitUrl };
/**
* Root directory for all cloned repositories. Targets must resolve inside this.
*
@ -92,178 +94,6 @@ export function getCloneDir(repoName: string): string {
return path.join(CLONE_ROOT, repoName);
}
// Cloud metadata hostnames that must never be reachable via user-supplied URLs
const BLOCKED_HOSTNAMES = new Set([
'localhost',
'metadata.google.internal',
'metadata.azure.com',
'metadata.internal',
]);
/**
* Validate a git URL to prevent SSRF attacks.
* Only allows https:// and http:// schemes. Blocks private/internal addresses,
* IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
*/
export function validateGitUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error('Invalid URL');
}
if (!['https:', 'http:'].includes(parsed.protocol)) {
throw new Error('Only https:// and http:// git URLs are allowed');
}
if (parsed.search || parsed.hash) {
throw new Error('Git URLs must not include query strings or fragments');
}
const host = parsed.hostname.toLowerCase();
// Block known dangerous hostnames (cloud metadata services)
if (BLOCKED_HOSTNAMES.has(host)) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
let normalizedHost = host;
if (host.startsWith('[') && host.endsWith(']')) {
normalizedHost = host.slice(1, -1);
}
// Check if this is an IPv6 address
// Use manual colon detection as fallback since isIP may return 0 for some
// normalized IPv6 forms (e.g. ::ffff:7f00:1)
const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
if (isIPv6) {
assertNotPrivateIPv6(normalizedHost);
return;
}
// Check if this is an IPv4 address (including numeric encodings)
if (isIP(normalizedHost) === 4) {
assertNotPrivateIPv4(normalizedHost);
return;
}
// For non-IP hostnames, check for numeric IP tricks
// Decimal encoding: 2130706433 = 127.0.0.1
// Hex encoding: 0x7f000001 = 127.0.0.1
if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// Standard IPv4 regex checks for dotted notation
if (
/^127\./.test(host) ||
/^10\./.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
/^192\.168\./.test(host) ||
/^169\.254\./.test(host) ||
/^0\./.test(host) ||
host === '0.0.0.0' ||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
/^198\.1[89]\./.test(host)
) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
}
function assertNotPrivateIPv6(ip: string): void {
// Expand common compressed forms for comparison
const lower = ip.toLowerCase();
// IPv6 loopback
if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// Unspecified address
if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
if (lower.startsWith('fc') || lower.startsWith('fd')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv6 link-local (fe80::/10)
if (
lower.startsWith('fe80') ||
lower.startsWith('fe8') ||
lower.startsWith('fe9') ||
lower.startsWith('fea') ||
lower.startsWith('feb')
) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
// Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
if (lower.startsWith('::ffff:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// Also catch the expanded form: 0:0:0:0:0:ffff:
if (lower.includes(':ffff:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
// Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
// is hidden in the last 32 bits without the ::ffff: marker, so the check
// above misses it. The form is still routable to the embedded IPv4 on most
// network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
// 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
// ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
// e.g. 64:ff9b::7f00:1 → 127.0.0.1.
// The check intentionally covers the full 64:ff9b::/32 block (broader than
// the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
// blocking the whole prefix is defensively sound and prevents a narrower
// CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
// future translation assignment.
if (lower.startsWith('64:ff9b:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
// 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
// protocol was deprecated by RFC 7526 and the public relay anycast
// (192.88.99.1) has been retired, so broad-blocking the prefix has near-
// zero false-positive cost while closing the IPv4-embedded bypass.
// Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
// impractical and is out of scope here.
if (lower.startsWith('2002:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
}
function assertNotPrivateIPv4(ip: string): void {
const parts = ip.split('.').map(Number);
const [a, b] = parts;
if (
a === 127 ||
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
a === 0 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 198 && (b === 18 || b === 19))
) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
}
export interface CloneProgress {
phase: 'cloning' | 'pulling';
message: string;

View file

@ -0,0 +1,112 @@
import { createRequire } from 'node:module';
import { armUpdateRefreshScheduler, evaluate, type UpdateState } from '../core/update-check.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
export interface ServerInfoResponse {
version: string;
launchContext: 'npx' | 'global' | 'local';
nodeVersion: string;
latestVersion?: string;
updateAvailable?: boolean;
}
interface ServeUpdateControllerDependencies {
evaluate: (options?: { refreshIfStale?: boolean }) => Promise<UpdateState | null>;
armScheduler: (onState: (state: UpdateState | null) => void) => () => void;
}
export interface ServeUpdateController {
start: () => Promise<void>;
stop: () => void;
snapshot: () => UpdateState | null;
}
/**
* Own the update state for one `serve` process. The route reads snapshot()
* synchronously; all cache and network work stays on the startup/scheduler path.
*/
export const createServeUpdateController = (
dependencies: ServeUpdateControllerDependencies = {
evaluate,
armScheduler: armUpdateRefreshScheduler,
},
): ServeUpdateController => {
let updateState: UpdateState | null = null;
let stopScheduler: (() => void) | undefined;
let started = false;
let stopped = false;
return {
start: async () => {
if (started || stopped) return;
started = true;
try {
// Cache-only: the scheduler's first cycle owns any stale refresh.
updateState = await dependencies.evaluate({ refreshIfStale: false });
} catch {
updateState = null;
}
if (stopped) return;
try {
stopScheduler = dependencies.armScheduler((state) => {
if (
state?.updateAvailable !== updateState?.updateAvailable ||
state?.latestVersion !== updateState?.latestVersion
) {
updateState = state;
}
});
} catch {
// Update checks are best-effort and never affect HTTP availability.
}
},
stop: () => {
if (stopped) return;
stopped = true;
try {
stopScheduler?.();
} catch {
// Shutdown must continue even if notifier cleanup unexpectedly fails.
}
},
snapshot: () => updateState,
};
};
export const buildServerInfo = (updateState: UpdateState | null): ServerInfoResponse => {
const execPath = process.env.npm_execpath ?? '';
const argv0 = process.argv[1] ?? '';
let launchContext: 'npx' | 'global' | 'local';
if (
execPath.includes('npx') ||
argv0.includes('_npx') ||
process.env.npm_config_prefix?.includes('_npx')
) {
launchContext = 'npx';
} else if (argv0.includes('node_modules')) {
launchContext = 'local';
} else {
launchContext = 'global';
}
return {
version: pkg.version,
launchContext,
nodeVersion: process.version,
...(updateState?.updateAvailable && updateState.latestVersion
? { latestVersion: updateState.latestVersion, updateAvailable: true }
: {}),
};
};
export const bindServeUpdateControllerLifecycle = (
server: {
once(event: 'listening' | 'close', listener: () => void): unknown;
},
controller: ServeUpdateController,
): void => {
server.once('listening', () => void controller.start());
server.once('close', controller.stop);
};

View file

@ -0,0 +1,7 @@
import os from 'node:os';
import path from 'node:path';
/** Get the path to the global GitNexus directory. */
export const getGlobalDir = (): string => {
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
};

View file

@ -17,10 +17,10 @@
import fs from 'fs/promises';
import { realpathSync } from 'fs';
import path from 'path';
import os from 'os';
import { getInferredRepoName, resolveRepoIdentityRoot, stripUrlCredentials } from './git.js';
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
import { writeFileAtomic } from './fs-atomic.js';
import { getGlobalDir } from './global-dir.js';
import { logger } from '../core/logger.js';
import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js';
import {
@ -53,6 +53,7 @@ export type { BranchSummary };
// `tryReadMetaFile` stay module-private here, exactly as before.
export { getStoragePath, INDEX_METADATA_FILE, isMissingFilesystemError, loadMeta };
export type { AnalyzerRunnerIdentity, RepoMeta };
export { getGlobalDir } from './global-dir.js';
/**
* Normalise a repo path for registry comparison across platforms
@ -517,13 +518,6 @@ const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
// ─── Global Registry (~/.gitnexus/registry.json) ───────────────────────
/**
* Get the path to the global GitNexus directory
*/
export const getGlobalDir = (): string => {
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
};
/**
* Get the path to the global registry file
*/

View file

@ -0,0 +1,224 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../../helpers/cli-entry.js';
import { cleanupTempDirSync } from '../../helpers/test-db.js';
const repoRoot = path.resolve(import.meta.dirname, '../../..');
const tempDirs: string[] = [];
function tempHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cli-update-notice-'));
tempDirs.push(dir);
return dir;
}
function seededCache(home: string): string {
const file = path.join(home, 'update-check.json');
fs.writeFileSync(
file,
`${JSON.stringify({
lastCheckAt: '2000-01-01T00:00:00.000Z',
registry: 'https://registry.npmjs.org',
latestVersion: '99.0.0',
})}\n`,
);
return file;
}
function localeEnv(home: string): NodeJS.ProcessEnv {
return {
...process.env,
GITNEXUS_HOME: home,
CI: '',
GITNEXUS_NO_UPDATE_NOTIFIER: '',
NO_UPDATE_NOTIFIER: '',
GITNEXUS_LANG: 'en',
LC_ALL: '',
LC_MESSAGES: '',
LANG: 'C',
};
}
function cli(args: string[], home: string) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: localeEnv(home),
});
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
cleanupTempDirSync(dir);
}
});
describe('CLI update notice subprocess behavior', () => {
it('keeps non-TTY stdout byte-clean and neither emits nor spawns a refresh child', () => {
const home = tempHome();
const cache = seededCache(home);
const before = fs.readFileSync(cache, 'utf8');
// `list` is a normal command (not --version/--help, which skip the notifier).
const result = cli(['list'], home);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain('is available');
expect(result.stdout).not.toContain('99.0.0 is available');
expect(fs.readFileSync(cache, 'utf8')).toBe(before);
expect(fs.existsSync(path.join(home, 'update-check.lock'))).toBe(false);
});
it('keeps help output unchanged and hides the internal refresh command', () => {
const result = cli(['--help'], tempHome());
expect(result.status).toBe(0);
expect(result.stdout).toContain('Usage: gitnexus [options] [command]');
expect(result.stdout).toContain('update');
expect(result.stdout).not.toContain('__update-check');
expect(result.stderr).toBe('');
});
it('prints a versioned command banner on stderr for a normal command', () => {
const result = cli(['list'], tempHome());
expect(result.stderr).toMatch(/GitNexus List \([^)]+\)/);
expect(result.stdout).not.toMatch(/GitNexus List \(/);
});
it('documents that gitnexus update installs via npm i -g', () => {
const result = cli(['update', '--help'], tempHome());
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/npm i -g gitnexus@<x\.y\.z>/);
expect(result.stderr).toBe('');
});
it('runs the hidden refresh command without writing stdout', () => {
const result = cli(['__update-check'], tempHome());
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
});
it('lets the parent exit without waiting for a detached refresh child', async () => {
const home = tempHome();
const project = path.join(home, 'project');
const installedPackage = path.join(project, 'node_modules', 'gitnexus');
fs.mkdirSync(installedPackage, { recursive: true });
fs.cpSync(path.join(repoRoot, 'src'), path.join(installedPackage, 'src'), {
recursive: true,
});
fs.copyFileSync(
path.join(repoRoot, 'package.json'),
path.join(installedPackage, 'package.json'),
);
fs.symlinkSync(
path.join(repoRoot, 'node_modules'),
path.join(installedPackage, 'node_modules'),
'dir',
);
const preload = path.join(home, 'mock-refresh.mjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
globalThis.fetch = async () => {
await new Promise((resolve) => setTimeout(resolve, 750));
return new Response(JSON.stringify({ version: '99.0.0' }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
};
`,
);
const startedAt = Date.now();
await new Promise<void>((resolve, reject) => {
const parent = spawn(
process.execPath,
[path.join(installedPackage, 'src', 'cli', 'index.ts'), 'list'],
{
cwd: project,
stdio: 'ignore',
env: {
...localeEnv(home),
NODE_OPTIONS:
`--import ${tsxLoaderUrl()} --import ${pathToFileURL(preload).href}`.trim(),
},
},
);
parent.once('error', reject);
parent.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`notifier parent exited ${String(code)}`));
});
});
const elapsed = Date.now() - startedAt;
expect(elapsed).toBeLessThan(1_800);
const cache = path.join(home, 'update-check.json');
expect(fs.existsSync(cache)).toBe(false);
await expect.poll(() => fs.existsSync(cache), { timeout: 15_000, interval: 100 }).toBe(true);
expect(JSON.parse(fs.readFileSync(cache, 'utf8'))).toMatchObject({
latestVersion: '99.0.0',
registry: 'https://registry.npmjs.org',
});
}, 20_000);
it('prints the localized notice on a forced-TTY stderr and keeps stdout clean', () => {
const home = tempHome();
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 project = path.join(home, 'project');
const installedPackage = path.join(project, 'node_modules', 'gitnexus');
fs.mkdirSync(installedPackage, { recursive: true });
fs.cpSync(path.join(repoRoot, 'src'), path.join(installedPackage, 'src'), {
recursive: true,
});
fs.copyFileSync(
path.join(repoRoot, 'package.json'),
path.join(installedPackage, 'package.json'),
);
fs.symlinkSync(
path.join(repoRoot, 'node_modules'),
path.join(installedPackage, 'node_modules'),
'dir',
);
const preload = path.join(home, 'force-tty.mjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });\n`,
);
const result = spawnSync(
process.execPath,
[path.join(installedPackage, 'src', 'cli', 'index.ts'), 'list'],
{
cwd: project,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...localeEnv(home),
NODE_OPTIONS: `--import ${tsxLoaderUrl()} --import ${pathToFileURL(preload).href}`.trim(),
},
},
);
expect(result.stderr).toContain('GitNexus 99.0.0 is available (you are running 1.6.10).');
expect(result.stdout).not.toContain('99.0.0 is available');
});
});

View file

@ -0,0 +1,379 @@
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: '' });
});
});

View file

@ -0,0 +1,63 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import {
commandBannerTitle,
commandDisplayName,
formatCommandBanner,
writeCommandBanner,
} from '../../src/cli/command-banner.js';
function commandAt(path: string[]): Command {
let current = new Command('gitnexus');
for (const name of path) {
current = current.command(name);
}
return current;
}
describe('commandDisplayName', () => {
it('uses Analyzer for analyze and MCP for mcp', () => {
expect(commandDisplayName('analyze')).toBe('Analyzer');
expect(commandDisplayName('mcp')).toBe('MCP');
});
it('title-cases hyphenated command names', () => {
expect(commandDisplayName('detect-changes')).toBe('Detect Changes');
expect(commandDisplayName('eval-server')).toBe('Eval Server');
expect(commandDisplayName('list')).toBe('List');
});
});
describe('commandBannerTitle', () => {
it('joins nested group commands', () => {
expect(commandBannerTitle(commandAt(['group', 'list']))).toBe('Group List');
expect(commandBannerTitle(commandAt(['embeddings', 'install']))).toBe('Embeddings Install');
});
});
describe('formatCommandBanner', () => {
it('puts the version in the title', () => {
expect(formatCommandBanner('Analyzer', '1.6.10')).toBe('\n GitNexus Analyzer (1.6.10)\n');
expect(formatCommandBanner('Query', '1.6.10')).toBe('\n GitNexus Query (1.6.10)\n');
});
it('keeps the unversioned title when version is missing', () => {
expect(formatCommandBanner('Analyzer', '')).toBe('\n GitNexus Analyzer\n');
});
});
describe('writeCommandBanner', () => {
it('writes the title for a normal command', () => {
const write = vi.fn();
writeCommandBanner(commandAt(['status']), { write, version: '1.6.10' });
expect(write).toHaveBeenCalledWith('\n GitNexus Status (1.6.10)\n');
});
it('skips hidden refresh and help', () => {
const write = vi.fn();
writeCommandBanner(commandAt(['__update-check']), { write, version: '1.6.10' });
writeCommandBanner(commandAt(['help']), { write, version: '1.6.10' });
expect(write).not.toHaveBeenCalled();
});
});

View file

@ -42,6 +42,7 @@ const allHelpCommands = [
['list'],
['status'],
['doctor'],
['update'],
['clean'],
['remove'],
['wiki'],

View file

@ -0,0 +1,333 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
runCliUpdateNotice,
type CliUpdateNoticeDependencies,
} from '../../src/cli/update-notice.js';
import { cachedUpdateDoctorLine } from '../../src/cli/doctor.js';
import { setCliLanguage } from '../../src/cli/i18n/index.js';
import { readProcessStartTime } from '../../src/utils/process-identity.js';
const tempHomes: string[] = [];
function dependencies(
overrides: Partial<CliUpdateNoticeDependencies> = {},
): CliUpdateNoticeDependencies {
// Isolate the refresh-lock probe from the real GITNEXUS_HOME.
const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'update-notice-test-'));
tempHomes.push(gitnexusHome);
return {
argv: ['/usr/bin/node', '/prefix/lib/node_modules/gitnexus/dist/cli/index.js', 'status'],
env: { GITNEXUS_HOME: gitnexusHome },
installedVersion: '1.6.10',
isTTY: true,
eligible: true,
now: 2_000,
readCache: vi.fn(() => ({
lastCheckAt: 1_500,
latestVersion: '1.7.0',
stale: false,
})),
writeStderr: vi.fn(),
spawn: vi.fn(() => ({ unref: vi.fn() })),
...overrides,
};
}
describe('CLI cached update notice', () => {
beforeEach(() => {
setCliLanguage('en');
});
afterEach(() => {
setCliLanguage(null);
for (const dir of tempHomes.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('writes exactly one localized line to stderr for a TTY and keeps stdout untouched', () => {
const writeStderr = vi.fn();
const stdoutWrite = vi.spyOn(process.stdout, 'write');
try {
const deps = dependencies({ writeStderr });
runCliUpdateNotice(deps);
expect(writeStderr).toHaveBeenCalledOnce();
expect(writeStderr).toHaveBeenCalledWith(
'GitNexus 1.7.0 is available (you are running 1.6.10).\n',
);
expect(stdoutWrite).not.toHaveBeenCalled();
expect(deps.spawn).not.toHaveBeenCalled();
} finally {
stdoutWrite.mockRestore();
}
});
it('displays stale valid state and starts one detached, ignored, unrefd refresh child', () => {
const unref = vi.fn();
const deps = dependencies({
readCache: vi.fn(() => ({
lastCheckAt: 0,
latestVersion: '1.7.0',
stale: true,
})),
spawn: vi.fn(() => ({ unref })),
});
runCliUpdateNotice(deps);
expect(deps.writeStderr).toHaveBeenCalledOnce();
expect(deps.spawn).toHaveBeenCalledWith(
process.execPath,
['/prefix/lib/node_modules/gitnexus/dist/cli/index.js', '__update-check'],
{ detached: true, stdio: 'ignore', windowsHide: true },
);
expect(unref).toHaveBeenCalledOnce();
});
it('refreshes a stale unknown/current cache without printing a notice', () => {
for (const latestVersion of [undefined, '1.6.10', '1.5.0']) {
const deps = dependencies({
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion, stale: true })),
});
runCliUpdateNotice(deps);
expect(deps.writeStderr).not.toHaveBeenCalled();
expect(deps.spawn).toHaveBeenCalledOnce();
}
});
it('spawns one refresh child when the cache is missing entirely', () => {
const unref = vi.fn();
const deps = dependencies({
readCache: vi.fn(() => null),
spawn: vi.fn(() => ({ unref })),
});
runCliUpdateNotice(deps);
expect(deps.writeStderr).not.toHaveBeenCalled();
expect(deps.spawn).toHaveBeenCalledOnce();
expect(deps.spawn).toHaveBeenCalledWith(
process.execPath,
['/prefix/lib/node_modules/gitnexus/dist/cli/index.js', '__update-check'],
{ detached: true, stdio: 'ignore', windowsHide: true },
);
expect(unref).toHaveBeenCalledOnce();
});
it('skips the refresh spawn when a live process holds the refresh lock', () => {
const deps = dependencies({
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion: '1.7.0', stale: true })),
});
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
fs.writeFileSync(
lockPath,
`${JSON.stringify({ pid: process.pid, ownerId: 'test', processStartTime: readProcessStartTime(process.pid), hostname: os.hostname() })}\n`,
);
runCliUpdateNotice(deps);
// The live holder's refresh covers this invocation.
expect(deps.spawn).not.toHaveBeenCalled();
// Display from the stale-but-valid cache is unaffected.
expect(deps.writeStderr).toHaveBeenCalledOnce();
});
it('spawns when a live PID is reuse with a different process start time', () => {
const deps = dependencies({
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion: '1.7.0', stale: true })),
});
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
fs.writeFileSync(
lockPath,
`${JSON.stringify({ pid: process.pid, ownerId: 'reused', processStartTime: 'not-this-process', hostname: os.hostname() })}\n`,
);
runCliUpdateNotice(deps);
expect(deps.spawn).toHaveBeenCalledOnce();
expect(deps.writeStderr).toHaveBeenCalledOnce();
});
it('spawns when the lock owner is dead so the child can reclaim it', () => {
const deps = dependencies({
readCache: vi.fn(() => null),
});
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
fs.writeFileSync(
lockPath,
`${JSON.stringify({ pid: 99999999, ownerId: 'stale', processStartTime: 'x', hostname: os.hostname() })}\n`,
);
runCliUpdateNotice(deps);
expect(deps.spawn).toHaveBeenCalledOnce();
});
it('does nothing for non-TTY stderr, including no cache read or child spawn', () => {
const deps = dependencies({ isTTY: false });
runCliUpdateNotice(deps);
expect(deps.readCache).not.toHaveBeenCalled();
expect(deps.writeStderr).not.toHaveBeenCalled();
expect(deps.spawn).not.toHaveBeenCalled();
});
it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER'])(
'does nothing when %s is truthy',
(name) => {
const deps = dependencies({ env: { [name]: '1' } });
runCliUpdateNotice(deps);
expect(deps.readCache).not.toHaveBeenCalled();
expect(deps.writeStderr).not.toHaveBeenCalled();
expect(deps.spawn).not.toHaveBeenCalled();
},
);
it('uses truthy-env semantics rather than treating "0" as opted out', () => {
const deps = dependencies({
env: {
CI: '0',
GITNEXUS_NO_UPDATE_NOTIFIER: 'false',
NO_UPDATE_NOTIFIER: 'off',
},
});
runCliUpdateNotice(deps);
expect(deps.writeStderr).toHaveBeenCalledOnce();
});
it('does nothing for ineligible dev and Docker contexts', () => {
for (const env of [{}, { GITNEXUS_NO_UPDATE_NOTIFIER: '1' }]) {
const deps = dependencies({ eligible: false, env });
runCliUpdateNotice(deps);
expect(deps.readCache).not.toHaveBeenCalled();
expect(deps.spawn).not.toHaveBeenCalled();
}
});
it.each([
['augment'],
['--help'],
['status', '--help'],
['--version'],
['mcp'],
['serve'],
['eval-server'],
['update'],
['__update-check'],
])('excludes command identity %j from display and refresh', (...args) => {
const deps = dependencies({ argv: ['/usr/bin/node', '/entry.js', ...args] });
runCliUpdateNotice(deps);
expect(deps.readCache).not.toHaveBeenCalled();
expect(deps.writeStderr).not.toHaveBeenCalled();
expect(deps.spawn).not.toHaveBeenCalled();
});
it('swallows cache and spawn failures before Commander parsing', () => {
expect(() =>
runCliUpdateNotice(
dependencies({
readCache: () => {
throw new Error('cache unavailable');
},
}),
),
).not.toThrow();
expect(() =>
runCliUpdateNotice(
dependencies({
readCache: () => ({ lastCheckAt: 0, stale: true }),
spawn: () => {
throw new Error('spawn unavailable');
},
}),
),
).not.toThrow();
});
});
describe('doctor cached update line', () => {
beforeEach(() => {
setCliLanguage('en');
});
afterEach(() => {
setCliLanguage(null);
});
it('shows installed and latest versions from cache without triggering refresh', () => {
const readCache = vi.fn(() => ({
lastCheckAt: 0,
latestVersion: '1.7.0',
stale: true,
}));
expect(
cachedUpdateDoctorLine({
installedVersion: '1.6.10',
eligible: true,
env: {},
readCache,
}),
).toBe('GitNexus 1.7.0 is available (you are running 1.6.10).');
expect(readCache).toHaveBeenCalledOnce();
});
it('is silent for current, invalid, opted-out, and ineligible states', () => {
expect(
cachedUpdateDoctorLine({
installedVersion: '1.6.10',
eligible: true,
env: {},
readCache: () => ({ lastCheckAt: 0, latestVersion: '1.6.10', stale: false }),
}),
).toBeNull();
expect(
cachedUpdateDoctorLine({
installedVersion: '1.6.10',
eligible: false,
env: {},
readCache: vi.fn(),
}),
).toBeNull();
expect(
cachedUpdateDoctorLine({
installedVersion: '1.6.10',
eligible: true,
env: { CI: '1' },
readCache: vi.fn(),
}),
).toBeNull();
});
it.each(['v1.7.0', '1.7.0-rc.1'])(
'is silent for non-strict latestVersion %s',
(latestVersion) => {
expect(
cachedUpdateDoctorLine({
installedVersion: '1.6.10',
eligible: true,
env: {},
readCache: () => ({ lastCheckAt: 0, latestVersion, stale: false }),
}),
).toBeNull();
},
);
});

View file

@ -0,0 +1,174 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { updateCommand, updateInstallArgs, updateInstallCommand } from '../../src/cli/update.js';
import { setCliLanguage } from '../../src/cli/i18n/index.js';
describe('gitnexus update', () => {
afterEach(() => {
setCliLanguage(null);
});
it('pins npm i -g to a stable x.y.z spec', () => {
expect(updateInstallArgs('1.7.0')).toEqual(['i', '-g', 'gitnexus@1.7.0']);
expect(updateInstallCommand('1.7.0')).toBe('npm i -g gitnexus@1.7.0');
});
it('installs the discovered version when a newer release exists', async () => {
setCliLanguage('en');
const refresh = vi.fn().mockResolvedValue({
updateAvailable: true,
latestVersion: '1.7.0',
});
const runInstall = vi.fn().mockResolvedValue(0);
const writeStdout = vi.fn();
const setExitCode = vi.fn();
await updateCommand({
installedVersion: '1.6.10',
refresh,
runInstall,
writeStdout,
setExitCode,
});
expect(refresh).toHaveBeenCalledWith({
eligible: true,
ignoreOptOut: true,
installedVersion: '1.6.10',
});
expect(runInstall).toHaveBeenCalledWith('1.7.0');
expect(writeStdout).toHaveBeenCalledWith(
'GitNexus 1.7.0 is available (you are running 1.6.10).',
);
expect(writeStdout).toHaveBeenCalledWith('Installing with npm i -g gitnexus@1.7.0…');
expect(writeStdout).toHaveBeenCalledWith(
'Installed gitnexus@1.7.0. Restart long-running mcp/serve processes.',
);
expect(setExitCode).not.toHaveBeenCalled();
});
it('does not install when already current', async () => {
setCliLanguage('en');
const runInstall = vi.fn();
const writeStdout = vi.fn();
await updateCommand({
installedVersion: '1.7.0',
refresh: vi.fn().mockResolvedValue({
updateAvailable: false,
latestVersion: '1.7.0',
}),
runInstall,
writeStdout,
});
expect(runInstall).not.toHaveBeenCalled();
expect(writeStdout).toHaveBeenCalledWith(
'GitNexus 1.7.0 is current or newer than the latest stable version.',
);
expect(writeStdout).toHaveBeenCalledTimes(1);
});
it('does not install when newer than the registry latest', async () => {
setCliLanguage('en');
const runInstall = vi.fn();
const writeStdout = vi.fn();
await updateCommand({
installedVersion: '1.7.0',
refresh: vi.fn().mockResolvedValue({
updateAvailable: false,
latestVersion: '1.6.10',
}),
runInstall,
writeStdout,
});
expect(runInstall).not.toHaveBeenCalled();
expect(writeStdout).toHaveBeenCalledWith(
'GitNexus 1.7.0 is current or newer than the latest stable version.',
);
expect(writeStdout).toHaveBeenCalledTimes(1);
});
it('does not install a non x.y.z spec', async () => {
setCliLanguage('en');
const runInstall = vi.fn();
const writeStdout = vi.fn();
await updateCommand({
installedVersion: '1.6.10',
refresh: vi.fn().mockResolvedValue({
updateAvailable: true,
latestVersion: '1.7.0-rc.1',
}),
runInstall,
writeStdout,
});
expect(runInstall).not.toHaveBeenCalled();
expect(writeStdout).toHaveBeenCalledWith(
'Could not check for updates (offline, private registry, or the check failed open).',
);
});
it('does not install when the check fails open', async () => {
setCliLanguage('en');
const runInstall = vi.fn();
const writeStdout = vi.fn();
await updateCommand({
installedVersion: '1.6.10',
refresh: vi.fn().mockResolvedValue(null),
runInstall,
writeStdout,
});
expect(runInstall).not.toHaveBeenCalled();
expect(writeStdout).toHaveBeenCalledWith(
'Could not check for updates (offline, private registry, or the check failed open).',
);
});
it('forwards a non-zero npm exit code', async () => {
setCliLanguage('en');
const writeStdout = vi.fn();
const setExitCode = vi.fn();
await updateCommand({
installedVersion: '1.6.10',
refresh: vi.fn().mockResolvedValue({
updateAvailable: true,
latestVersion: '1.7.0',
}),
runInstall: vi.fn().mockResolvedValue(7),
writeStdout,
setExitCode,
});
expect(writeStdout).toHaveBeenCalledWith(
'npm install failed. You can retry: npm i -g gitnexus@1.7.0',
);
expect(setExitCode).toHaveBeenCalledWith(7);
});
it('fails open when npm cannot be spawned', async () => {
setCliLanguage('en');
const writeStdout = vi.fn();
const setExitCode = vi.fn();
await updateCommand({
installedVersion: '1.6.10',
refresh: vi.fn().mockResolvedValue({
updateAvailable: true,
latestVersion: '1.7.0',
}),
runInstall: vi.fn().mockRejectedValue(new Error('spawn npm ENOENT')),
writeStdout,
setExitCode,
});
expect(writeStdout).toHaveBeenCalledWith('Could not run npm: spawn npm ENOENT');
expect(setExitCode).toHaveBeenCalledWith(1);
});
});

View file

@ -326,6 +326,8 @@ describe('git-clone', () => {
// chosen because their prefixes don't collide with any block above.
expect(() => validateGitUrl('https://[2606:4700:4700::1111]/repo.git')).not.toThrow();
expect(() => validateGitUrl('https://[2001:4860:4860::8888]/repo.git')).not.toThrow();
// A public address that merely contains a `ffff` hextet is not IPv4-mapped.
expect(() => validateGitUrl('https://[2001:4860:ffff::1]/repo.git')).not.toThrow();
});
it('blocks CGN range (100.64.0.0/10)', () => {

View file

@ -0,0 +1,210 @@
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
bindServeUpdateControllerLifecycle,
buildServerInfo,
createServeUpdateController,
} from '../../src/server/update-controller.js';
import { evaluate } from '../../src/core/update-check.js';
import type { UpdateState } from '../../src/core/update-check.js';
const baseKeys = ['version', 'launchContext', 'nodeVersion'];
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe('GET /api/info update state', () => {
it('keeps the existing three fields unchanged when no update is available', () => {
const response = buildServerInfo(null);
expect(Object.keys(response)).toEqual(baseKeys);
expect(response).toEqual({
version: '1.6.10',
launchContext: expect.stringMatching(/^(npx|global|local)$/),
nodeVersion: process.version,
});
});
it('adds optional update fields only for an available version', () => {
expect(buildServerInfo({ updateAvailable: true, latestVersion: '9.8.7' })).toEqual({
version: '1.6.10',
launchContext: expect.stringMatching(/^(npx|global|local)$/),
nodeVersion: process.version,
latestVersion: '9.8.7',
updateAvailable: true,
});
expect(
Object.keys(buildServerInfo({ updateAvailable: false, latestVersion: '1.6.10' })),
).toEqual(baseKeys);
});
it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
'omits update fields after start when %s is set',
async (name) => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-serve-update-'));
tempDirs.push(home);
vi.stubEnv('GITNEXUS_HOME', home);
vi.stubEnv('npm_config_registry', 'https://registry.npmjs.org');
vi.stubEnv(name, '1');
await fs.writeFile(
path.join(home, 'update-check.json'),
JSON.stringify({
lastCheckAt: new Date().toISOString(),
registry: 'https://registry.npmjs.org',
latestVersion: '9.9.9',
}),
);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const evaluateSpy = vi.fn((options?: { refreshIfStale?: boolean }) =>
evaluate({ ...options, eligible: true }),
);
const controller = createServeUpdateController({
evaluate: evaluateSpy,
armScheduler: vi.fn(() => vi.fn()),
});
await controller.start();
expect(evaluateSpy).toHaveBeenCalledWith({ refreshIfStale: false });
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
expect(fetchMock).not.toHaveBeenCalled();
},
);
it('omits update fields after start for an ineligible install', async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-serve-update-'));
tempDirs.push(home);
vi.stubEnv('GITNEXUS_HOME', home);
vi.stubEnv('npm_config_registry', 'https://registry.npmjs.org');
vi.stubEnv('CI', '');
await fs.writeFile(
path.join(home, 'update-check.json'),
JSON.stringify({
lastCheckAt: new Date().toISOString(),
registry: 'https://registry.npmjs.org',
latestVersion: '9.9.9',
}),
);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const evaluateSpy = vi.fn((options?: { refreshIfStale?: boolean }) =>
evaluate({ ...options, eligible: false }),
);
const controller = createServeUpdateController({
evaluate: evaluateSpy,
armScheduler: vi.fn(() => vi.fn()),
});
await controller.start();
expect(evaluateSpy).toHaveBeenCalledWith({ refreshIfStale: false });
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
expect(fetchMock).not.toHaveBeenCalled();
});
it('stays assignable to the web client ServerInfo contract', () => {
// Mirrors gitnexus-web/src/services/backend-client.ts without creating a
// cross-package import that would couple either package's build graph.
interface WebClientServerInfo {
version: string;
launchContext: 'npx' | 'global' | 'local';
nodeVersion: string;
latestVersion?: string;
updateAvailable?: boolean;
}
const response: WebClientServerInfo = buildServerInfo({
updateAvailable: true,
latestVersion: '9.8.7',
});
expect(response.updateAvailable).toBe(true);
});
});
describe('serve update controller lifecycle', () => {
it('starts only after successful listen and stops whenever the server closes', async () => {
const server = new EventEmitter();
const controller = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
snapshot: vi.fn().mockReturnValue(null),
};
bindServeUpdateControllerLifecycle(server, controller);
expect(controller.start).not.toHaveBeenCalled();
server.emit('listening');
expect(controller.start).toHaveBeenCalledOnce();
server.emit('close');
expect(controller.stop).toHaveBeenCalledOnce();
});
it('evaluates once before arming the scheduler and serves memory-only snapshots', async () => {
const calls: string[] = [];
let publish: ((state: UpdateState | null) => void) | undefined;
const evaluate = vi.fn(async () => {
calls.push('evaluate');
return { updateAvailable: true, latestVersion: '2.0.0' };
});
const armScheduler = vi.fn((onState) => {
calls.push('arm');
publish = onState;
return vi.fn();
});
const controller = createServeUpdateController({ evaluate, armScheduler });
expect(controller.snapshot()).toBeNull();
await controller.start();
expect(calls).toEqual(['evaluate', 'arm']);
expect(controller.snapshot()).toEqual({
updateAvailable: true,
latestVersion: '2.0.0',
});
publish?.({ updateAvailable: true, latestVersion: '2.1.0' });
expect(controller.snapshot()).toEqual({
updateAvailable: true,
latestVersion: '2.1.0',
});
expect(evaluate).toHaveBeenCalledTimes(1);
expect(evaluate).toHaveBeenCalledWith({ refreshIfStale: false });
});
it('fails open and still arms the long-lived refresh scheduler', async () => {
const armScheduler = vi.fn(() => vi.fn());
const controller = createServeUpdateController({
evaluate: vi.fn().mockRejectedValue(new Error('checker failed')),
armScheduler,
});
await expect(controller.start()).resolves.toBeUndefined();
expect(controller.snapshot()).toBeNull();
expect(armScheduler).toHaveBeenCalledOnce();
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
});
it('stops the scheduler on shutdown and is idempotent', async () => {
const stop = vi.fn();
const controller = createServeUpdateController({
evaluate: vi.fn().mockResolvedValue(null),
armScheduler: vi.fn(() => stop),
});
await controller.start();
controller.stop();
controller.stop();
expect(stop).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,539 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { updateEligibleInstall } from '../../src/core/install-context.js';
import { isNewerVersion } from '../../src/core/update-cache.js';
import { armUpdateRefreshScheduler, evaluate, refresh } from '../../src/core/update-check.js';
import { acquireFileLock } from '../../src/storage/file-lock.js';
const DAY_MS = 24 * 60 * 60 * 1_000;
const NOW = Date.parse('2026-09-04T05:00:00.000Z');
const REGISTRY = 'https://registry.npmjs.org';
const tempDirs: string[] = [];
async function tempHome(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-check-'));
tempDirs.push(dir);
vi.stubEnv('GITNEXUS_HOME', dir);
return dir;
}
function cachePath(home: string): string {
return path.join(home, 'update-check.json');
}
async function writeCache(
home: string,
body: { lastCheckAt: string; registry: string; latestVersion?: string },
): Promise<void> {
await fs.mkdir(home, { recursive: true });
await fs.writeFile(cachePath(home), JSON.stringify(body));
}
async function readCache(home: string): Promise<Record<string, unknown>> {
return JSON.parse(await fs.readFile(cachePath(home), 'utf8')) as Record<string, unknown>;
}
function registryResponse(version = '1.7.0'): Response {
return new Response(JSON.stringify({ version }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
beforeEach(() => {
vi.stubEnv('npm_config_registry', REGISTRY);
// GitHub Actions sets CI=true; the checker treats that as a hard opt-out.
vi.stubEnv('CI', '');
vi.stubEnv('GITNEXUS_NO_UPDATE_NOTIFIER', '');
vi.stubEnv('NO_UPDATE_NOTIFIER', '');
});
afterEach(async () => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe('update check cache and versions', () => {
it('returns a newer version from a fresh cache', async () => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: new Date(NOW).toISOString(),
registry: REGISTRY,
latestVersion: '1.7.0',
});
await expect(
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
});
it('returns stale valid state and starts one stale-while-revalidate refresh', async () => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
registry: REGISTRY,
latestVersion: '1.7.0',
});
const fetchMock = vi.fn().mockResolvedValue(registryResponse('1.8.0'));
vi.stubGlobal('fetch', fetchMock);
await expect(
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.8.0'));
});
it.each(['1.6.10', '1.6.9'])('is silent for fresh equal/lower latest %s', async (latest) => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: new Date(NOW).toISOString(),
registry: REGISTRY,
latestVersion: latest,
});
await expect(
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
).resolves.toEqual({ updateAvailable: false, latestVersion: latest });
});
it('treats corrupt JSON as a miss and refresh overwrites it', async () => {
const home = await tempHome();
await fs.writeFile(cachePath(home), '{broken');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse()));
await expect(
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
).resolves.toBeNull();
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.7.0'));
});
it('never propagates a latestVersion with a non-strict format', async () => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: new Date(NOW).toISOString(),
registry: REGISTRY,
latestVersion: 'v1.7.0',
});
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
).resolves.toBeNull();
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBeUndefined());
});
it('treats a future timestamp as stale', async () => {
const home = await tempHome();
// Wall-clock-future lastCheckAt is monotonic poison; NOW+1ms is still in
// the past on a later wall clock and would not be overwritten.
await writeCache(home, {
lastCheckAt: new Date(Date.now() + 60_000).toISOString(),
registry: REGISTRY,
latestVersion: '1.6.0',
});
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
vi.stubGlobal('fetch', fetchMock);
await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW });
await vi.waitFor(async () =>
expect(await readCache(home)).toEqual({
lastCheckAt: new Date(NOW).toISOString(),
registry: REGISTRY,
latestVersion: '1.7.0',
}),
);
});
it('writes a negative entry after failure and suppresses retries inside the TTL', async () => {
const home = await tempHome();
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
vi.stubGlobal('fetch', fetchMock);
await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
expect(await readCache(home)).toEqual({
lastCheckAt: new Date(NOW).toISOString(),
registry: REGISTRY,
});
await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW + 1 });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('preserves a known latestVersion when a later refresh fails', async () => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
registry: REGISTRY,
latestVersion: '1.7.0',
});
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
vi.stubGlobal('fetch', fetchMock);
await expect(
refresh({ eligible: true, installedVersion: '1.6.10', now: NOW }),
).resolves.toBeNull();
expect(await readCache(home)).toEqual({
lastCheckAt: new Date(NOW).toISOString(),
registry: REGISTRY,
latestVersion: '1.7.0',
});
await expect(
evaluate({
eligible: true,
installedVersion: '1.6.10',
now: NOW + 1,
refreshIfStale: false,
}),
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('skips fetch when another process holds the refresh lock', async () => {
const home = await tempHome();
const release = await acquireFileLock(path.join(home, 'update-check.lock'));
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
try {
await refresh({ eligible: true, now: NOW });
expect(fetchMock).not.toHaveBeenCalled();
} finally {
await release();
}
});
it('returns stale cache state without fetching when refreshIfStale is false', async () => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
registry: REGISTRY,
latestVersion: '1.7.0',
});
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(
evaluate({
eligible: true,
installedVersion: '1.6.10',
now: NOW,
refreshIfStale: false,
}),
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
expect(fetchMock).not.toHaveBeenCalled();
});
it('replaces a future-dated cache timestamp instead of preserving it', async () => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: '2099-01-01T00:00:00.000Z',
registry: REGISTRY,
latestVersion: '9.9.9',
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse('1.8.0')));
await refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
expect(await readCache(home)).toEqual({
lastCheckAt: new Date(NOW).toISOString(),
registry: REGISTRY,
latestVersion: '1.8.0',
});
});
it('dedupes concurrent refresh() callers onto one fetch', async () => {
await tempHome();
let resolveFetch!: (response: Response) => void;
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
vi.stubGlobal('fetch', fetchMock);
const first = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
const second = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
resolveFetch(registryResponse('1.8.0'));
await expect(Promise.all([first, second])).resolves.toEqual([
{ updateAvailable: true, latestVersion: '1.8.0' },
{ updateAvailable: true, latestVersion: '1.8.0' },
]);
expect(first).toBe(second);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not let a late failed attempt overwrite a newer success', async () => {
const home = await tempHome();
let rejectFetch!: (error: Error) => void;
vi.stubGlobal(
'fetch',
vi.fn(
() =>
new Promise<Response>((_resolve, reject) => {
rejectFetch = reject;
}),
),
);
// Relative to the real clock: publishMonotonically only preserves a newer
// on-disk timestamp when it is not in the future (`currentAt <= Date.now()`).
const wall = Date.now();
const olderAttempt = wall - 10_000;
const newerSuccess = wall - 1;
const pending = refresh({ eligible: true, now: olderAttempt });
await vi.waitFor(() => expect(rejectFetch).toBeTypeOf('function'));
await writeCache(home, {
lastCheckAt: new Date(newerSuccess).toISOString(),
registry: REGISTRY,
latestVersion: '1.8.0',
});
rejectFetch(new Error('late failure'));
await pending;
expect(await readCache(home)).toEqual({
lastCheckAt: new Date(newerSuccess).toISOString(),
registry: REGISTRY,
latestVersion: '1.8.0',
});
});
it('treats a cache from another registry as a miss', async () => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: new Date(NOW).toISOString(),
registry: 'https://registry.example.test/custom',
latestVersion: '9.0.0',
});
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(
evaluate({
eligible: true,
installedVersion: '1.6.10',
now: NOW,
refreshIfStale: false,
}),
).resolves.toBeNull();
});
it('handles prerelease and lower versions with the strict comparator', () => {
expect(isNewerVersion('1.7.0-rc.1', '1.6.10')).toBe(false);
expect(isNewerVersion('1.7.0', '1.6.10')).toBe(false);
expect(isNewerVersion('1.6.10', '1.6.9')).toBe(false);
expect(isNewerVersion('1.6.10', '1.7.0')).toBe(true);
expect(isNewerVersion('1.0.9007199254740992', '1.0.9007199254740993')).toBe(true);
});
});
describe('hardened registry request', () => {
it('strips registry userinfo and sends no credentials', async () => {
await tempHome();
vi.stubEnv('npm_config_registry', 'https://user:secret@registry.example.test/custom/');
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
vi.stubGlobal('fetch', fetchMock);
await refresh({ eligible: true, now: NOW });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://registry.example.test/custom/gitnexus/latest');
expect(url).not.toContain('user');
expect(new Headers(init.headers).has('authorization')).toBe(false);
});
it('refuses a redirect to loopback/private addresses', async () => {
const home = await tempHome();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/latest' } }),
);
vi.stubGlobal('fetch', fetchMock);
await refresh({ eligible: true, now: NOW });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(await readCache(home)).not.toHaveProperty('latestVersion');
});
it('caps an oversized response body', async () => {
const home = await tempHome();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('x'.repeat(70_000), { status: 200 })),
);
await refresh({ eligible: true, now: NOW });
expect(await readCache(home)).not.toHaveProperty('latestVersion');
});
});
describe('guards and scheduler', () => {
it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
'%s skips cache refresh and network with truthy-env semantics',
async (name) => {
await tempHome();
vi.stubEnv(name, 'yes');
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
await refresh({ eligible: true, now: NOW });
expect(fetchMock).not.toHaveBeenCalled();
},
);
it('ignoreOptOut still fetches when CI/opt-out env is set', async () => {
await tempHome();
vi.stubEnv('CI', 'true');
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
vi.stubGlobal('fetch', fetchMock);
await expect(
refresh({
eligible: true,
ignoreOptOut: true,
installedVersion: '1.6.10',
now: NOW,
}),
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('an ineligible install skips network', async () => {
await tempHome();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(evaluate({ eligible: false, now: NOW })).resolves.toBeNull();
await refresh({ eligible: false, now: NOW });
expect(fetchMock).not.toHaveBeenCalled();
});
it('an unwritable cache path fails open', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-unwritable-'));
tempDirs.push(root);
const blocker = path.join(root, 'not-a-directory');
await fs.writeFile(blocker, 'x');
vi.stubEnv('GITNEXUS_HOME', path.join(blocker, 'child'));
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
});
it('arms an unrefd, clearable, single-flight scheduler', async () => {
await tempHome();
let resolveFetch!: (response: Response) => void;
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
vi.stubGlobal('fetch', fetchMock);
const onState = vi.fn();
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const clearA = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
const clearB = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const immediateHandles = timeoutSpy.mock.results
.map((result) => result.value as NodeJS.Timeout)
.filter((handle) => typeof handle?.hasRef === 'function');
expect(immediateHandles.some((handle) => !handle.hasRef())).toBe(true);
resolveFetch(registryResponse());
await vi.waitFor(() => expect(onState).toHaveBeenCalled());
clearA();
clearB();
});
it('backs off when refresh is lock-busy instead of spinning at 1ms', async () => {
const home = await tempHome();
await writeCache(home, {
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
registry: REGISTRY,
latestVersion: '1.7.0',
});
const release = await acquireFileLock(path.join(home, 'update-check.lock'));
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const onState = vi.fn();
try {
const clear = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
await vi.waitFor(() => expect(onState).toHaveBeenCalled());
const retryDelay = timeoutSpy.mock.calls
.map(([, delay]) => delay)
.find(
(delay): delay is number =>
typeof delay === 'number' && delay >= 30_000 && delay <= 60_000,
);
expect(retryDelay).toBeDefined();
expect(fetchMock).not.toHaveBeenCalled();
clear();
} finally {
await release();
}
});
});
describe('updateEligibleInstall', () => {
async function entry(relative: string): Promise<{ root: string; entry: string }> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-install-context-'));
tempDirs.push(root);
const entryPath = path.join(root, relative);
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(entryPath, '');
return { root, entry: entryPath };
}
it('classifies global, local, ephemeral, dev, and Docker layouts', async () => {
const global = await entry('prefix/lib/node_modules/gitnexus/dist/cli/index.js');
expect(
await updateEligibleInstall(global.entry, {
npm_config_prefix: path.join(global.root, 'prefix'),
}),
).toBe(true);
const local = await entry('project/node_modules/gitnexus/dist/cli/index.js');
expect(await updateEligibleInstall(local.entry, {})).toBe(true);
const namedDlx = await entry('dlx/project/node_modules/gitnexus/dist/cli/index.js');
expect(await updateEligibleInstall(namedDlx.entry, {})).toBe(true);
for (const relative of [
'cache/_npx/123/node_modules/gitnexus/dist/cli/index.js',
'cache/_cacache/tmp/node_modules/gitnexus/dist/cli/index.js',
'pnpm/dlx/123/node_modules/gitnexus/dist/cli/index.js',
'.bun/install/cache/gitnexus@1.0.0/node_modules/gitnexus/dist/cli/index.js',
]) {
const ephemeral = await entry(relative);
expect(
await updateEligibleInstall(ephemeral.entry, {
npm_config_cache: path.join(ephemeral.root, 'cache'),
npm_execpath: path.join(ephemeral.root, relative),
}),
).toBe(false);
}
const dev = await entry('checkout/gitnexus/src/cli/index.ts');
await fs.mkdir(path.join(dev.root, 'checkout', '.git'));
expect(await updateEligibleInstall(dev.entry, {})).toBe(false);
const docker = await entry('usr/local/lib/node_modules/gitnexus/dist/cli/index.js');
expect(
await updateEligibleInstall(docker.entry, {
npm_config_prefix: path.join(docker.root, 'usr/local'),
}),
).toBe(true);
});
});