diff --git a/Dockerfile.cli b/Dockerfile.cli index 1488bee54..eb3cfb5ea 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -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 diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 2f6132c88..6d847ff40 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -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(null); const [serverDisconnected, setServerDisconnected] = useState(false); + const [serverInfo, setServerInfo] = useState(null); + const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState(() => { + try { + return localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY); + } catch { + return null; + } + }); + const wasDisconnectedRef = useRef(false); + const refreshSeqRef = useRef(0); + + const refreshServerInfo = useCallback(async (): Promise => { + 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 => { @@ -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 = () => { { // 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 = () => { {serverDisconnected && ( -
+
{t('errors:backend.reconnecting')}
)} + {!serverDisconnected && + serverInfo?.updateAvailable === true && + !!serverInfo.latestVersion && + dismissedUpdateVersion !== serverInfo.latestVersion && ( +
+ + {t('common:updateBanner', { + latest: serverInfo.latestVersion, + installed: serverInfo.version, + })} + + +
+ )} + {/* Settings Panel (modal) */} => { const response = await fetchWithTimeout(`${_backendUrl}/api/info`); await assertOk(response); diff --git a/gitnexus-web/test/unit/update-banner.test.tsx b/gitnexus-web/test/unit/update-banner.test.tsx new file mode 100644 index 000000000..a9703b7fb --- /dev/null +++ b/gitnexus-web/test/unit/update-banner.test.tsx @@ -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>(), + fetchRepos: vi.fn(async () => []), + connectHeartbeat: vi.fn(), +})); + +vi.mock('../../src/hooks/useAppState', async () => { + const React = await import('react'); + const AppStateContext = React.createContext | null>(null); + + return { + AppStateProvider: ({ children }: { children: React.ReactNode }) => { + const [viewMode, setViewMode] = React.useState(appStateConfig.initialViewMode); + const [serverBaseUrl, setServerBaseUrl] = React.useState(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 ( + + {children} + + ); + }, + 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; + }) => ( + + ), +})); +vi.mock('../../src/components/LoadingOverlay', () => ({ + LoadingOverlay: () =>
Loading view
, +})); +vi.mock('../../src/components/Header', () => ({ Header: () =>
Header
})); +vi.mock('../../src/components/GraphCanvas', async () => { + const React = await import('react'); + return { GraphCanvas: React.forwardRef(() =>
Graph
) }; +}); +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(); + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled(); + + onboarding.unmount(); + appStateConfig.initialViewMode = 'loading'; + render(); + 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(); + 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(); + + 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((resolve) => deferred.push(resolve)), + ); + render(); + 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(); + 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(); + 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(); + } + }); +}); diff --git a/gitnexus/README.md b/gitnexus/README.md index c871a853d..d247483bf 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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@`) # Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed gitnexus query "" # 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@` (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` diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 44e45fbbb..79169214b 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -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', diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 52930c54a..78d13de39 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -788,8 +788,6 @@ const analyzeCommandImpl = async ( cliOptions?: AnalyzeOptions, runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity, ): Promise => { - 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 diff --git a/gitnexus/src/cli/command-banner.ts b/gitnexus/src/cli/command-banner.ts new file mode 100644 index 000000000..83fe65383 --- /dev/null +++ b/gitnexus/src/cli/command-banner.ts @@ -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 = { + 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); +} diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index 990e1c102..0c8e72e9e 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -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 diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 01a44a206..ede409763 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -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', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 73c2b7362..a560fa23e 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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@`).', '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.', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index e18d0d3de..536b8ffb9 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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@`)。', 'help.command.embeddings.description': '管理按需安装的本地嵌入运行时', 'help.command.embeddings.install.description': '按需安装本地嵌入组件(@huggingface/transformers + onnxruntime-node)。修复 npm 跳过可选包的安装(例如在 HTTP 代理后,#2370)。仅从你配置的 npm registry 下载 — 镜像和代理均生效。', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index ee575e47d..08694bc4c 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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@`).') + .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 ', '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); diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index 9ad39268d..b1e5dae60 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -29,6 +29,71 @@ import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js'; +import type { UpdateState } from '../core/update-check.js'; + +interface McpUpdateLogger { + info(bindings: Record, message: string): unknown; +} + +interface McpUpdateChecker { + evaluate(): Promise; + armUpdateRefreshScheduler(onState: (state: UpdateState | null) => void): () => void; +} + +type LoadMcpUpdateChecker = () => Promise; + +const announcedUpdateVersions = new Set(); + +/** + * 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 { + 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(() => {}); }; diff --git a/gitnexus/src/cli/update-notice.ts b/gitnexus/src/cli/update-notice.ts new file mode 100644 index 000000000..8c0da20e2 --- /dev/null +++ b/gitnexus/src/cli/update-notice.ts @@ -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, + }); +} diff --git a/gitnexus/src/cli/update.ts b/gitnexus/src/cli/update.ts new file mode 100644 index 000000000..edd2596ef --- /dev/null +++ b/gitnexus/src/cli/update.ts @@ -0,0 +1,126 @@ +/** + * Explicit `gitnexus update`: refresh the latest dist-tag, then install + * `gitnexus@` 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; + writeStdout: (line: string) => void; + runInstall: (version: string) => Promise; + 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 { + return ( + typeof value === 'object' && + value !== null && + ('refresh' in value || 'runInstall' in value || 'writeStdout' in value) + ); +} + +function defaultRunInstall(version: string): Promise { + 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 { + 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')); +} diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index e196095ef..9d214d138 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -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 { diff --git a/gitnexus/src/core/install-context.ts b/gitnexus/src/core/install-context.ts new file mode 100644 index 000000000..79bd09a00 --- /dev/null +++ b/gitnexus/src/core/install-context.ts @@ -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 { + 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 { + 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; + } +} diff --git a/gitnexus/src/core/net/url-guard.ts b/gitnexus/src/core/net/url-guard.ts new file mode 100644 index 000000000..7eb38fa12 --- /dev/null +++ b/gitnexus/src/core/net/url-guard.ts @@ -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'); + } +} diff --git a/gitnexus/src/core/update-cache.ts b/gitnexus/src/core/update-cache.ts new file mode 100644 index 000000000..c055faa9c --- /dev/null +++ b/gitnexus/src/core/update-cache.ts @@ -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}`; + // `//latest` is the small dist-tag document. The full packument at + // `/` 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; + 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; + } +} diff --git a/gitnexus/src/core/update-check.ts b/gitnexus/src/core/update-check.ts new file mode 100644 index 000000000..fa2364490 --- /dev/null +++ b/gitnexus/src/core/update-check.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | null = null; + +/** Run one locked, fail-open registry refresh. */ +export function refresh(options: UpdateCheckOptions = {}): Promise { + if (refreshInFlight) return refreshInFlight; + const run = async (): Promise => { + let release: (() => Promise) | 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 => { + 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); + }; +} diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index e91d40d17..0843d0b63 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -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(); diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 1cd204761..6fb1e25c9 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -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; diff --git a/gitnexus/src/server/update-controller.ts b/gitnexus/src/server/update-controller.ts new file mode 100644 index 000000000..fdc0ed70c --- /dev/null +++ b/gitnexus/src/server/update-controller.ts @@ -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; + armScheduler: (onState: (state: UpdateState | null) => void) => () => void; +} + +export interface ServeUpdateController { + start: () => Promise; + 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); +}; diff --git a/gitnexus/src/storage/global-dir.ts b/gitnexus/src/storage/global-dir.ts new file mode 100644 index 000000000..c60e679d3 --- /dev/null +++ b/gitnexus/src/storage/global-dir.ts @@ -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'); +}; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 383f25dcc..c8d9ad487 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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 => { // ─── 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 */ diff --git a/gitnexus/test/integration/cli/update-notice.test.ts b/gitnexus/test/integration/cli/update-notice.test.ts new file mode 100644 index 000000000..ed49d3204 --- /dev/null +++ b/gitnexus/test/integration/cli/update-notice.test.ts @@ -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@/); + 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((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'); + }); +}); diff --git a/gitnexus/test/integration/mcp/update-notice.test.ts b/gitnexus/test/integration/mcp/update-notice.test.ts new file mode 100644 index 000000000..fee79fc1d --- /dev/null +++ b/gitnexus/test/integration/mcp/update-notice.test.ts @@ -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; + armUpdateRefreshScheduler: (onState: (state: UpdateState | null) => void) => () => void; +} + +interface FakeLogger { + info: ReturnType; +} + +function checker(initial: UpdateState | null): { + service: FakeChecker; + publish: (state: UpdateState | null) => void; + stop: ReturnType; +} { + 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 { + 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((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((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: '' }); + }); +}); diff --git a/gitnexus/test/unit/cli-command-banner.test.ts b/gitnexus/test/unit/cli-command-banner.test.ts new file mode 100644 index 000000000..65bb5bc97 --- /dev/null +++ b/gitnexus/test/unit/cli-command-banner.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index e414c6cb6..4911af27f 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -42,6 +42,7 @@ const allHelpCommands = [ ['list'], ['status'], ['doctor'], + ['update'], ['clean'], ['remove'], ['wiki'], diff --git a/gitnexus/test/unit/cli-update-notice.test.ts b/gitnexus/test/unit/cli-update-notice.test.ts new file mode 100644 index 000000000..7602bd79f --- /dev/null +++ b/gitnexus/test/unit/cli-update-notice.test.ts @@ -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 { + // 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(); + }, + ); +}); diff --git a/gitnexus/test/unit/cli-update.test.ts b/gitnexus/test/unit/cli-update.test.ts new file mode 100644 index 000000000..3b3b5e3c1 --- /dev/null +++ b/gitnexus/test/unit/cli-update.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 0c365e359..715955d74 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -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)', () => { diff --git a/gitnexus/test/unit/server-info.test.ts b/gitnexus/test/unit/server-info.test.ts new file mode 100644 index 000000000..9e46baf76 --- /dev/null +++ b/gitnexus/test/unit/server-info.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/update-check.test.ts b/gitnexus/test/unit/update-check.test.ts new file mode 100644 index 000000000..673653e21 --- /dev/null +++ b/gitnexus/test/unit/update-check.test.ts @@ -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 { + 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 { + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(cachePath(home), JSON.stringify(body)); +} + +async function readCache(home: string): Promise> { + return JSON.parse(await fs.readFile(cachePath(home), 'utf8')) as Record; +} + +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((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((_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((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); + }); +});