mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
feat: notify users when a newer gitnexus version is available (#3175)
* feat(core): add cache-first npm update-check service Shared fail-open checker: validated 24h cache under GITNEXUS_HOME, acquireFileLock-guarded refresh, monotonic publication, hardened registry fetch (no credentials, private-address redirects refused, body-capped), strict x.y.z comparator, install-eligibility classification, and an unref'd refresh scheduler for long-lived processes. Extracts getGlobalDir into storage/global-dir.ts with a repo-manager re-export (no caller changes). Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): notify on available updates via stderr and doctor One i18n'd stderr line on interactive invocations when the validated cache holds a newer version (TTY-gated, CI/opt-out/eligibility-gated, hook and help/version command identities excluded). Stale cache spawns a detached hidden __update-check refresh child so command exit latency is unchanged. doctor prints the cached latest version when known. Dockerfile.cli sets GITNEXUS_NO_UPDATE_NOTIFIER=1. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(mcp): emit one stderr update notice per process per version Process-scoped adapter in mcpCommand (stdio and --http), dynamically imported after the stdout sentinel, started only after connect, fully catch-isolated. Arms the shared refresh scheduler with cleanup on process exit. Protocol payloads stay free of update state (R15). Co-authored-by: Cursor <cursoragent@cursor.com> * feat(serve): expose update state on /api/info Serve-scoped controller owns an in-memory update snapshot: one staleness evaluation after listen, then the shared unref'd scheduler, stopped on close/shutdown. /api/info reads only the snapshot and gains optional latestVersion/updateAvailable fields for eligible installs; the existing three fields are byte-compatible. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): dismissible update-available banner from /api/info Fetches server info after backend connect and on reconnect, renders a fixed banner in the exploring view only when updateAvailable is true and the version is undismissed, hides while the reconnect banner is active, and fails open on fetch errors. role=status + aria-live with a keyboard-focusable dismiss; dismissal persists per version in localStorage. Copy in en/zh-CN common.json with version interpolation. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(cli): document update notifications and opt-outs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): apply review findings and simplify pass Review: gate the detached refresh spawn on a live lock-owner probe so parallel CLI invocations coalesce to one refresh child (validated P2, three-reviewer agreement); poll /api/info on a slow cadence while exploring so post-load server-side discoveries surface (validated P1); add a monotonic sequence guard so overlapping server-info fetches commit in order. Simplify (behavior-preserving): shared truthy-env/opt-out/freshness helpers in update-cache.ts, shared cachedUpdateNoticeLine for CLI and doctor, extracted install-eligibility core with per-process memo, memoized registry parsing, single evaluation per scheduler cycle, cache-only startup evaluate in serve, flattened MCP exit handler, shared bottom-banner shell, storage keys in ui-constants. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(update-notifier): address residual review tickets on this PR Stop the lock-busy 1ms scheduler spin, replace clock-skewed cache entries, move the outbound URL guard into core, and extract the serve update controller. Pin the startup/guard/single-flight/MCP/CLI contracts those tickets called out. Fixes #3167 #3168 #3169 #3170 #3171 #3172 #3173 #3174 Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) Fetch the npm /latest document instead of the full packument so the 64KiB cap can succeed, and treat reused lock PIDs as stale so refresh is not suppressed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) Register the CLI spawn suite on the OS matrix, pin MCP opt-out env, and compare versions without IEEE-754 rounding. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): add gitnexus update install and versioned command banners Give an explicit Claude/Codex-style upgrade (`npm i -g gitnexus@version`) and print `GitNexus <Name> (version)` on every command so the running build is obvious without silent self-update. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) - Document the pinned install as npm i -g gitnexus@<x.y.z>, not a copyable @version tag - Wait for wall-clock-future cache repair to publish before asserting - Restore the stdout spy if the TTY notice assertions fail Co-authored-by: Cursor <cursoragent@cursor.com> * fix(update-notifier): keep last known latestVersion on a failed refresh A later offline check was wiping the pin and hiding a known update for 24h. gitnexus update still treats a failed live fetch as checkFailed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) - Word update.current so a newer-than-latest install is not called the latest stable version. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): hide the detached update-check spawn on Windows The refresh child was spawned without windowsHide, so Windows CI could stall before writing the cache and then fail cleanup with EBUSY. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a348bc3957
commit
9bf307123e
38 changed files with 3876 additions and 210 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -14,17 +14,27 @@ import { buildGraphFromConnectResult } from './lib/apply-connect-result';
|
|||
import {
|
||||
connectToServer,
|
||||
fetchRepos,
|
||||
fetchServerInfo,
|
||||
normalizeServerUrl,
|
||||
connectHeartbeat,
|
||||
BackendError,
|
||||
type ConnectResult,
|
||||
type BackendRepo,
|
||||
type ServerInfo,
|
||||
} from './services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
|
||||
import {
|
||||
ERROR_RESET_DELAY_MS,
|
||||
UPDATE_DISMISSED_VERSION_KEY,
|
||||
UPDATE_INFO_REFETCH_MS,
|
||||
} from './config/ui-constants';
|
||||
import { parseSkipGraphParam } from './lib/graph-load-decision';
|
||||
import { formatBackendError } from './i18n/error-messages';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Positional shell shared by the fixed bottom banners (reconnect, update). */
|
||||
const BOTTOM_BANNER_CLASS =
|
||||
'fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border px-4 py-2 text-sm shadow-lg backdrop-blur';
|
||||
|
||||
/**
|
||||
* Restore-param preference for the auto-connect effect: `repo` carries the
|
||||
* server-resolved path identity (restores the exact repo even when duplicate
|
||||
|
|
@ -65,6 +75,47 @@ const AppContent = () => {
|
|||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
const [serverDisconnected, setServerDisconnected] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() => {
|
||||
try {
|
||||
return localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const wasDisconnectedRef = useRef(false);
|
||||
const refreshSeqRef = useRef(0);
|
||||
|
||||
const refreshServerInfo = useCallback(async (): Promise<void> => {
|
||||
const seq = ++refreshSeqRef.current;
|
||||
try {
|
||||
const next = await fetchServerInfo();
|
||||
// A newer fetch is already in flight; never commit an older payload.
|
||||
if (seq !== refreshSeqRef.current) return;
|
||||
// Keep the previous state (and banner) when nothing changed, so
|
||||
// reconnect refetches don't flicker the UI.
|
||||
setServerInfo((prev) =>
|
||||
prev?.version === next.version &&
|
||||
prev?.latestVersion === next.latestVersion &&
|
||||
prev?.updateAvailable === next.updateAvailable
|
||||
? prev
|
||||
: next,
|
||||
);
|
||||
} catch {
|
||||
// Update state is informational; unavailable server info must not affect the app.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
const latestVersion = serverInfo?.latestVersion;
|
||||
if (!latestVersion) return;
|
||||
setDismissedUpdateVersion(latestVersion);
|
||||
try {
|
||||
localStorage.setItem(UPDATE_DISMISSED_VERSION_KEY, latestVersion);
|
||||
} catch {
|
||||
// The in-memory dismissal still applies when storage is unavailable.
|
||||
}
|
||||
}, [serverInfo?.latestVersion]);
|
||||
|
||||
const handleServerConnect = useCallback(
|
||||
async (result: ConnectResult): Promise<void> => {
|
||||
|
|
@ -202,6 +253,7 @@ const AppContent = () => {
|
|||
// anyway" button) and then awaits agent init, leaving a window where
|
||||
// loadGraphAnyway would silently no-op on a still-null serverBaseUrl.
|
||||
setServerBaseUrl(baseUrl);
|
||||
void refreshServerInfo();
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
fetchRepos()
|
||||
|
|
@ -221,7 +273,14 @@ const AppContent = () => {
|
|||
setProgress(null);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
});
|
||||
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
|
||||
}, [
|
||||
handleServerConnect,
|
||||
refreshServerInfo,
|
||||
setProgress,
|
||||
setViewMode,
|
||||
setServerBaseUrl,
|
||||
setAvailableRepos,
|
||||
]);
|
||||
|
||||
const handleFocusNode = useCallback((nodeId: string) => {
|
||||
graphCanvasRef.current?.focusNode(nodeId);
|
||||
|
|
@ -234,6 +293,14 @@ const AppContent = () => {
|
|||
initializeAgent();
|
||||
}, [refreshLLMSettings, initializeAgent]);
|
||||
|
||||
// While exploring, re-read server info on a slow cadence so an update the
|
||||
// server discovers after page load surfaces without a manual reload.
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'exploring' || serverDisconnected) return;
|
||||
const interval = setInterval(() => void refreshServerInfo(), UPDATE_INFO_REFETCH_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [viewMode, serverDisconnected, refreshServerInfo]);
|
||||
|
||||
// ── Server heartbeat: detect when server goes down while exploring ────────
|
||||
// Uses SSE (EventSource) for instant detection — no polling delay.
|
||||
// On disconnect: show a reconnecting banner instead of resetting to onboarding.
|
||||
|
|
@ -242,12 +309,21 @@ const AppContent = () => {
|
|||
if (viewMode !== 'exploring') return;
|
||||
|
||||
const cleanup = connectHeartbeat(
|
||||
() => setServerDisconnected(false),
|
||||
() => setServerDisconnected(true),
|
||||
() => {
|
||||
setServerDisconnected(false);
|
||||
if (wasDisconnectedRef.current) {
|
||||
wasDisconnectedRef.current = false;
|
||||
void refreshServerInfo();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
wasDisconnectedRef.current = true;
|
||||
setServerDisconnected(true);
|
||||
},
|
||||
);
|
||||
|
||||
return cleanup;
|
||||
}, [viewMode]);
|
||||
}, [viewMode, refreshServerInfo]);
|
||||
|
||||
// Render based on view mode
|
||||
if (viewMode === 'onboarding') {
|
||||
|
|
@ -255,6 +331,7 @@ const AppContent = () => {
|
|||
<DropZone
|
||||
onServerConnect={async (result, serverUrl) => {
|
||||
// Refresh repo list before transitioning so it's ready in the header
|
||||
void refreshServerInfo();
|
||||
const repos = await fetchRepos().catch(() => [] as BackendRepo[]);
|
||||
setAvailableRepos(repos);
|
||||
await handleServerConnect(result);
|
||||
|
|
@ -344,11 +421,39 @@ const AppContent = () => {
|
|||
<StatusBar />
|
||||
|
||||
{serverDisconnected && (
|
||||
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
|
||||
<div
|
||||
className={`${BOTTOM_BANNER_CLASS} border-yellow-500/30 bg-yellow-900/80 text-yellow-200`}
|
||||
>
|
||||
{t('errors:backend.reconnecting')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!serverDisconnected &&
|
||||
serverInfo?.updateAvailable === true &&
|
||||
!!serverInfo.latestVersion &&
|
||||
dismissedUpdateVersion !== serverInfo.latestVersion && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`${BOTTOM_BANNER_CLASS} flex items-center gap-3 border-accent/30 bg-surface/95 text-text-primary`}
|
||||
>
|
||||
<span>
|
||||
{t('common:updateBanner', {
|
||||
latest: serverInfo.latestVersion,
|
||||
installed: serverInfo.version,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissUpdate}
|
||||
aria-label={t('common:updateBannerDismiss')}
|
||||
className="rounded px-2 py-1 text-text-secondary hover:bg-white/10 hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
||||
>
|
||||
{t('common:actions.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Panel (modal) */}
|
||||
<SettingsPanel
|
||||
isOpen={isSettingsPanelOpen}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|||
*/
|
||||
export const AUTH_TOKEN_STORAGE_KEY = 'gitnexus-auth-token';
|
||||
|
||||
/** localStorage key holding the version whose update banner the user dismissed. */
|
||||
export const UPDATE_DISMISSED_VERSION_KEY = 'gitnexus.updateDismissedVersion';
|
||||
|
||||
/** How often the exploring view re-reads /api/info so server-side update discoveries surface. */
|
||||
export const UPDATE_INFO_REFETCH_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Default node-count above which the WebUI connects in chat-only mode (skips
|
||||
* the full graph download). Grounded in sigma.js/graphology prior art: ~10K
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"name": "GitNexus",
|
||||
"nexusAI": "Nexus AI"
|
||||
},
|
||||
"updateBanner": "GitNexus {{latest}} is available — this server runs {{installed}}.",
|
||||
"updateBannerDismiss": "Dismiss update notification",
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"dismiss": "Dismiss",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"name": "GitNexus",
|
||||
"nexusAI": "Nexus AI"
|
||||
},
|
||||
"updateBanner": "GitNexus {{latest}} 已发布 — 此服务器运行 {{installed}}。",
|
||||
"updateBannerDismiss": "关闭更新通知",
|
||||
"actions": {
|
||||
"cancel": "取消",
|
||||
"dismiss": "关闭",
|
||||
|
|
|
|||
|
|
@ -575,9 +575,11 @@ export interface ServerInfo {
|
|||
version: string;
|
||||
launchContext: 'npx' | 'global' | 'local';
|
||||
nodeVersion: string;
|
||||
latestVersion?: string;
|
||||
updateAvailable?: boolean;
|
||||
}
|
||||
|
||||
/** Fetch server info (version, launch context). */
|
||||
/** Fetch server info (version, launch context, and optional update state). */
|
||||
export const fetchServerInfo = async (): Promise<ServerInfo> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/info`);
|
||||
await assertOk(response);
|
||||
|
|
|
|||
349
gitnexus-web/test/unit/update-banner.test.tsx
Normal file
349
gitnexus-web/test/unit/update-banner.test.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../../src/App';
|
||||
import i18n, { i18nReady } from '../../src/i18n';
|
||||
import {
|
||||
UPDATE_DISMISSED_VERSION_KEY,
|
||||
UPDATE_INFO_REFETCH_MS,
|
||||
} from '../../src/config/ui-constants';
|
||||
import type { ConnectResult, ServerInfo } from '../../src/services/backend-client';
|
||||
|
||||
const appStateConfig = vi.hoisted(() => ({
|
||||
initialViewMode: 'onboarding' as 'onboarding' | 'loading' | 'exploring',
|
||||
}));
|
||||
|
||||
const backendMocks = vi.hoisted(() => ({
|
||||
fetchServerInfo: vi.fn<() => Promise<ServerInfo>>(),
|
||||
fetchRepos: vi.fn(async () => []),
|
||||
connectHeartbeat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/hooks/useAppState', async () => {
|
||||
const React = await import('react');
|
||||
const AppStateContext = React.createContext<Record<string, unknown> | null>(null);
|
||||
|
||||
return {
|
||||
AppStateProvider: ({ children }: { children: React.ReactNode }) => {
|
||||
const [viewMode, setViewMode] = React.useState(appStateConfig.initialViewMode);
|
||||
const [serverBaseUrl, setServerBaseUrl] = React.useState<string | null>(null);
|
||||
const stable = React.useRef({
|
||||
setGraph: vi.fn(),
|
||||
setGraphMode: vi.fn(),
|
||||
setChatOnlyNodeCount: vi.fn(),
|
||||
setProgress: vi.fn(),
|
||||
setProjectName: vi.fn(),
|
||||
setSettingsPanelOpen: vi.fn(),
|
||||
refreshLLMSettings: vi.fn(),
|
||||
initializeAgent: vi.fn(async () => {}),
|
||||
startEmbeddingsWithFallback: vi.fn(),
|
||||
setAvailableRepos: vi.fn(),
|
||||
switchRepo: vi.fn(async () => {}),
|
||||
setCurrentRepo: vi.fn(),
|
||||
}).current;
|
||||
|
||||
return (
|
||||
<AppStateContext.Provider
|
||||
value={{
|
||||
...stable,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
progress:
|
||||
viewMode === 'loading'
|
||||
? { phase: 'extracting', percent: 1, message: 'Loading' }
|
||||
: null,
|
||||
isRightPanelOpen: false,
|
||||
isSettingsPanelOpen: false,
|
||||
codeReferences: [],
|
||||
selectedNode: null,
|
||||
isCodePanelOpen: false,
|
||||
serverBaseUrl,
|
||||
setServerBaseUrl,
|
||||
availableRepos: [],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppStateContext.Provider>
|
||||
);
|
||||
},
|
||||
useAppState: () => {
|
||||
const value = React.useContext(AppStateContext);
|
||||
if (!value) throw new Error('Missing test AppStateProvider');
|
||||
return value;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const connectResult: ConnectResult = {
|
||||
nodes: [],
|
||||
relationships: [],
|
||||
repoInfo: {
|
||||
name: 'demo',
|
||||
path: '/workspace/demo',
|
||||
repoPath: '/workspace/demo',
|
||||
indexedAt: '2026-09-04T00:00:00.000Z',
|
||||
},
|
||||
graphSkipped: false,
|
||||
};
|
||||
|
||||
vi.mock('../../src/components/DropZone', () => ({
|
||||
DropZone: ({
|
||||
onServerConnect,
|
||||
}: {
|
||||
onServerConnect: (result: ConnectResult, serverUrl: string) => Promise<void>;
|
||||
}) => (
|
||||
<button onClick={() => void onServerConnect(connectResult, 'http://localhost:4747')}>
|
||||
Connect test backend
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../src/components/LoadingOverlay', () => ({
|
||||
LoadingOverlay: () => <div>Loading view</div>,
|
||||
}));
|
||||
vi.mock('../../src/components/Header', () => ({ Header: () => <header>Header</header> }));
|
||||
vi.mock('../../src/components/GraphCanvas', async () => {
|
||||
const React = await import('react');
|
||||
return { GraphCanvas: React.forwardRef(() => <div>Graph</div>) };
|
||||
});
|
||||
vi.mock('../../src/components/RightPanel', () => ({ RightPanel: () => null }));
|
||||
vi.mock('../../src/components/SettingsPanel', () => ({ SettingsPanel: () => null }));
|
||||
vi.mock('../../src/components/StatusBar', () => ({ StatusBar: () => null }));
|
||||
vi.mock('../../src/components/FileTreePanel', () => ({ FileTreePanel: () => null }));
|
||||
vi.mock('../../src/components/CodeReferencesPanel', () => ({
|
||||
CodeReferencesPanel: () => null,
|
||||
}));
|
||||
vi.mock('../../src/core/llm/settings-service', () => ({
|
||||
getActiveProviderConfig: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/backend-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/backend-client')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchServerInfo: backendMocks.fetchServerInfo,
|
||||
fetchRepos: backendMocks.fetchRepos,
|
||||
connectHeartbeat: backendMocks.connectHeartbeat,
|
||||
};
|
||||
});
|
||||
|
||||
const updateInfo = (latestVersion = '2.0.0'): ServerInfo => ({
|
||||
version: '1.0.0',
|
||||
launchContext: 'global',
|
||||
nodeVersion: 'v22.0.0',
|
||||
latestVersion,
|
||||
updateAvailable: true,
|
||||
});
|
||||
|
||||
async function connectBackend() {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
|
||||
}
|
||||
|
||||
describe('update banner', () => {
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
await i18n.changeLanguage('en');
|
||||
appStateConfig.initialViewMode = 'onboarding';
|
||||
localStorage.removeItem(UPDATE_DISMISSED_VERSION_KEY);
|
||||
backendMocks.fetchServerInfo.mockReset();
|
||||
backendMocks.fetchRepos.mockClear();
|
||||
backendMocks.connectHeartbeat.mockReset();
|
||||
backendMocks.connectHeartbeat.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('fetches only after a backend is selected and renders interpolated update copy', async () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
|
||||
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
|
||||
await connectBackend();
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||
'GitNexus 2.0.0 is available — this server runs 1.0.0.',
|
||||
);
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['false', { ...updateInfo(), updateAvailable: false }],
|
||||
['absent', { version: '1.0.0', launchContext: 'global', nodeVersion: 'v22.0.0' }],
|
||||
])('stays hidden when update state is %s', async (_label, info) => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(info as ServerInfo);
|
||||
render(<App />);
|
||||
|
||||
await connectBackend();
|
||||
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives the reconnect banner priority and refetches after reconnect', async () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
await connectBackend();
|
||||
expect(await screen.findByRole('status')).toBeInTheDocument();
|
||||
|
||||
const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
|
||||
act(() => onReconnecting());
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/reconnect/i)).toBeInTheDocument();
|
||||
|
||||
await act(async () => onConnect());
|
||||
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists dismissal across remounts', async () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
const first = render(<App />);
|
||||
await connectBackend();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Dismiss update notification' }),
|
||||
);
|
||||
expect(localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY)).toBe('2.0.0');
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
|
||||
first.unmount();
|
||||
window.history.replaceState(null, '', '/');
|
||||
render(<App />);
|
||||
await connectBackend();
|
||||
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-shows after a newer version than the dismissed one appears', async () => {
|
||||
localStorage.setItem(UPDATE_DISMISSED_VERSION_KEY, '2.0.0');
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo('2.1.0'));
|
||||
render(<App />);
|
||||
|
||||
await connectBackend();
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
|
||||
});
|
||||
|
||||
it('fails open without rendering an error UI', async () => {
|
||||
backendMocks.fetchServerInfo.mockRejectedValue(new Error('offline'));
|
||||
render(<App />);
|
||||
|
||||
await connectBackend();
|
||||
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/offline/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('never mounts on onboarding or loading views', () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
const onboarding = render(<App />);
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
|
||||
|
||||
onboarding.unmount();
|
||||
appStateConfig.initialViewMode = 'loading';
|
||||
render(<App />);
|
||||
expect(screen.getByText('Loading view')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has a keyboard-focusable dismiss control with an accessible label', async () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
await connectBackend();
|
||||
|
||||
const dismiss = await screen.findByRole('button', { name: 'Dismiss update notification' });
|
||||
dismiss.focus();
|
||||
expect(dismiss).toHaveFocus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders translated copy in zh-CN', async () => {
|
||||
await i18n.changeLanguage('zh-CN');
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
|
||||
await connectBackend();
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||
'GitNexus 2.0.0 已发布 — 此服务器运行 1.0.0。',
|
||||
);
|
||||
});
|
||||
|
||||
it('never commits an older fetch response over a newer one', async () => {
|
||||
const deferred: Array<(value: ServerInfo) => void> = [];
|
||||
backendMocks.fetchServerInfo.mockImplementation(
|
||||
() => new Promise<ServerInfo>((resolve) => deferred.push(resolve)),
|
||||
);
|
||||
render(<App />);
|
||||
await connectBackend();
|
||||
|
||||
// A reconnect refetch starts while the connect fetch is still in flight.
|
||||
const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
|
||||
act(() => onReconnecting());
|
||||
await act(async () => onConnect());
|
||||
expect(deferred).toHaveLength(2);
|
||||
|
||||
// The newer fetch resolves first with 2.1.0; the older fetch resolves late with 2.0.0.
|
||||
await act(async () => deferred[1](updateInfo('2.1.0')));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
|
||||
|
||||
await act(async () => deferred[0](updateInfo('2.0.0')));
|
||||
expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
|
||||
});
|
||||
|
||||
it('refetches server info on the slow exploring cadence', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
|
||||
});
|
||||
expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.0.0 is available');
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS);
|
||||
});
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS);
|
||||
});
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not poll server info while the exploring session is disconnected', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
|
||||
});
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
|
||||
act(() => onReconnecting());
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS * 2);
|
||||
});
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -264,6 +264,7 @@ gitnexus wiki --provider grok # Local Grok Build CLI (uses `grok login`, no A
|
|||
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
|
||||
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
|
||||
gitnexus doctor # Show runtime platform capabilities and embedding configuration
|
||||
gitnexus update # Install the latest published GitNexus (`npm i -g gitnexus@<x.y.z>`)
|
||||
|
||||
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
|
||||
gitnexus query "<concept>" # Process-grouped hybrid search
|
||||
|
|
@ -492,6 +493,40 @@ bigger cycle) and `N` increments per published rc. Example sequence:
|
|||
`1.6.3-rc.1`. See the [Releases page](https://github.com/abhigyanpatwari/GitNexus/releases)
|
||||
for the full list; stable `latest` is unaffected.
|
||||
|
||||
## Update notifications
|
||||
|
||||
GitNexus checks the npm registry's `latest` dist-tag at most once every 24
|
||||
hours per installation and tells you when a newer stable version exists. The
|
||||
result is cached under `$GITNEXUS_HOME` (`~/.gitnexus` by default), so the
|
||||
check never runs on the command's hot path and never blocks output. Where the
|
||||
notice appears:
|
||||
|
||||
- **CLI** — one line on stderr when you run a command interactively (never on
|
||||
stdout, so `gitnexus query … | jq` and other piped output stay clean), a
|
||||
line in `gitnexus doctor` when an update is known. Automatic notices never
|
||||
install. `gitnexus update` checks even when notices are opted out, then
|
||||
runs `npm i -g gitnexus@<x.y.z>` (same idea as `claude update` /
|
||||
`codex update`).
|
||||
- **MCP server** — one structured log record on the server's stderr per
|
||||
process per version (visible in your host's MCP log panel). Tool results,
|
||||
resources, prompts, and server instructions never carry update text.
|
||||
- **Web UI** — a dismissible banner when the server reports a newer version;
|
||||
dismissal persists per version.
|
||||
|
||||
The check is skipped entirely (no network request, no output) when `CI` is
|
||||
truthy, when the install is not an npm global/local install (npx cache, dev
|
||||
checkout, Docker image — the Docker CLI image sets the opt-out itself), or
|
||||
when opted out:
|
||||
|
||||
| Variable | Effect |
|
||||
| --- | --- |
|
||||
| `GITNEXUS_NO_UPDATE_NOTIFIER` | Truthy (`1`, `true`, …) disables the update check on every surface. |
|
||||
| `NO_UPDATE_NOTIFIER` | Cross-tool convention; honored the same way. |
|
||||
| `npm_config_registry` | The check reads the `latest` dist-tag from this registry instead of `https://registry.npmjs.org`. Credentials are never sent, and registries that require authentication are not supported (the check silently skips). |
|
||||
|
||||
Eval harnesses running a global install can set `GITNEXUS_NO_UPDATE_NOTIFIER`
|
||||
for a quiet registry.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `Cannot destructure property 'package' of 'node.target' as it is null`
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -788,8 +788,6 @@ const analyzeCommandImpl = async (
|
|||
cliOptions?: AnalyzeOptions,
|
||||
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
|
||||
): Promise<void> => {
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
// ── Resolve the target repo root ──────────────────────────────────
|
||||
// Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
|
||||
// caller's cwd), and config can set defaults that the validation below
|
||||
|
|
|
|||
71
gitnexus/src/cli/command-banner.ts
Normal file
71
gitnexus/src/cli/command-banner.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* One-line identity printed before every CLI action:
|
||||
* `GitNexus Analyzer (1.6.10)`. Goes to stderr so stdout stays
|
||||
* pipe/JSON-safe (`gitnexus query … | jq`, `status --json`).
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json') as { version?: unknown };
|
||||
|
||||
const SKIP_COMMAND_BANNER = new Set(['__update-check', 'help']);
|
||||
|
||||
const SPECIAL_TITLES: Record<string, string> = {
|
||||
analyze: 'Analyzer',
|
||||
mcp: 'MCP',
|
||||
};
|
||||
|
||||
export function installedCliVersion(): string {
|
||||
return typeof pkg.version === 'string' ? pkg.version : '';
|
||||
}
|
||||
|
||||
export function commandDisplayName(name: string): string {
|
||||
return (
|
||||
SPECIAL_TITLES[name] ??
|
||||
name
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
export function commandBannerTitle(command: Command): string {
|
||||
const names: string[] = [];
|
||||
for (
|
||||
let current: Command | null | undefined = command;
|
||||
current?.parent;
|
||||
current = current.parent
|
||||
) {
|
||||
names.unshift(current.name());
|
||||
}
|
||||
return names.map(commandDisplayName).join(' ');
|
||||
}
|
||||
|
||||
export function formatCommandBanner(
|
||||
title: string,
|
||||
version: string = installedCliVersion(),
|
||||
): string {
|
||||
if (!title) return '';
|
||||
return version ? `\n GitNexus ${title} (${version})\n` : `\n GitNexus ${title}\n`;
|
||||
}
|
||||
|
||||
export interface WriteCommandBannerDependencies {
|
||||
write?: (text: string) => void;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function writeCommandBanner(
|
||||
command: Command,
|
||||
deps: WriteCommandBannerDependencies = {},
|
||||
): void {
|
||||
if (SKIP_COMMAND_BANNER.has(command.name())) return;
|
||||
const text = formatCommandBanner(
|
||||
commandBannerTitle(command),
|
||||
deps.version ?? installedCliVersion(),
|
||||
);
|
||||
if (!text) return;
|
||||
(deps.write ?? ((line) => process.stderr.write(line)))(text);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,16 @@ export const en = {
|
|||
'common.storage': 'Storage',
|
||||
'common.deleted': 'Deleted: {{target}}',
|
||||
'common.error': 'Error: {{message}}',
|
||||
'update.available':
|
||||
'GitNexus {{latestVersion}} is available (you are running {{installedVersion}}).',
|
||||
'update.current':
|
||||
'GitNexus {{installedVersion}} is current or newer than the latest stable version.',
|
||||
'update.installing': 'Installing with {{command}}…',
|
||||
'update.installed': 'Installed gitnexus@{{version}}. Restart long-running mcp/serve processes.',
|
||||
'update.installFailed': 'npm install failed. You can retry: {{command}}',
|
||||
'update.installError': 'Could not run npm: {{message}}',
|
||||
'update.checkFailed':
|
||||
'Could not check for updates (offline, private registry, or the check failed open).',
|
||||
'list.title': 'Indexed Repositories ({{count}})',
|
||||
'list.indexed': 'Indexed',
|
||||
'list.commit': 'Commit',
|
||||
|
|
@ -165,6 +175,8 @@ export const en = {
|
|||
'help.command.status.description': 'Show index status for current repo',
|
||||
'help.command.doctor.description':
|
||||
'Show runtime platform capabilities and embedding configuration',
|
||||
'help.command.update.description':
|
||||
'Install the latest published GitNexus globally (`npm i -g gitnexus@<x.y.z>`).',
|
||||
'help.command.embeddings.description': 'Manage the on-demand local embedding runtime',
|
||||
'help.command.embeddings.install.description':
|
||||
'Install the local embedding stack (@huggingface/transformers + onnxruntime-node) on demand. Heals installs where npm skipped the optional packages (e.g. behind an HTTP proxy, #2370). Downloads only from your configured npm registry — mirrors and proxies apply.',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ export const zhCN = {
|
|||
'common.storage': '存储',
|
||||
'common.deleted': '已删除:{{target}}',
|
||||
'common.error': '错误:{{message}}',
|
||||
'update.available': 'GitNexus {{latestVersion}} 已发布(当前运行 {{installedVersion}})。',
|
||||
'update.current': 'GitNexus {{installedVersion}} 已是最新稳定版或不低于该版本。',
|
||||
'update.installing': '正在执行 {{command}}…',
|
||||
'update.installed': '已安装 gitnexus@{{version}}。请重启仍在运行的 mcp/serve 进程。',
|
||||
'update.installFailed': 'npm 安装失败。可重试:{{command}}',
|
||||
'update.installError': '无法运行 npm:{{message}}',
|
||||
'update.checkFailed': '无法检查更新(离线、私有仓库,或检查失败)。',
|
||||
'list.title': '已索引仓库({{count}})',
|
||||
'list.indexed': '索引时间',
|
||||
'list.commit': '提交',
|
||||
|
|
@ -162,6 +169,8 @@ export const zhCN = {
|
|||
'help.command.list.description': '列出所有已索引仓库',
|
||||
'help.command.status.description': '显示当前仓库的索引状态',
|
||||
'help.command.doctor.description': '显示运行平台能力和嵌入配置',
|
||||
'help.command.update.description':
|
||||
'通过 npm 全局安装最新发布的 GitNexus(`npm i -g gitnexus@<x.y.z>`)。',
|
||||
'help.command.embeddings.description': '管理按需安装的本地嵌入运行时',
|
||||
'help.command.embeddings.install.description':
|
||||
'按需安装本地嵌入组件(@huggingface/transformers + onnxruntime-node)。修复 npm 跳过可选包的安装(例如在 HTTP 代理后,#2370)。仅从你配置的 npm registry 下载 — 镜像和代理均生效。',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.j
|
|||
import { registerGroupCommands } from './group.js';
|
||||
import { localizeCliHelp } from './help-i18n.js';
|
||||
import { t } from './i18n/index.js';
|
||||
import { writeCommandBanner } from './command-banner.js';
|
||||
import { runProcessCliUpdateNotice } from './update-notice.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json');
|
||||
|
|
@ -299,6 +301,11 @@ program
|
|||
.description('Show runtime platform capabilities and embedding configuration')
|
||||
.action(createLazyAction(() => import('./doctor.js'), 'doctorCommand'));
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Install the latest published GitNexus globally (`npm i -g gitnexus@<x.y.z>`).')
|
||||
.action(createLazyAction(() => import('./update.js'), 'updateCommand'));
|
||||
|
||||
program
|
||||
.command('embeddings')
|
||||
.description('Manage the on-demand local embedding runtime')
|
||||
|
|
@ -502,7 +509,17 @@ program
|
|||
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
|
||||
.action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
|
||||
program.command('__update-check', { hidden: true }).action(async () => {
|
||||
const { refresh } = await import('../core/update-check.js');
|
||||
await refresh();
|
||||
});
|
||||
|
||||
registerGroupCommands(program);
|
||||
localizeCliHelp(program);
|
||||
|
||||
program.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
writeCommandBanner(actionCommand);
|
||||
});
|
||||
|
||||
runProcessCliUpdateNotice(typeof pkg.version === 'string' ? pkg.version : '');
|
||||
program.parse(process.argv);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,71 @@
|
|||
|
||||
import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js';
|
||||
|
||||
import type { UpdateState } from '../core/update-check.js';
|
||||
|
||||
interface McpUpdateLogger {
|
||||
info(bindings: Record<string, unknown>, message: string): unknown;
|
||||
}
|
||||
|
||||
interface McpUpdateChecker {
|
||||
evaluate(): Promise<UpdateState | null>;
|
||||
armUpdateRefreshScheduler(onState: (state: UpdateState | null) => void): () => void;
|
||||
}
|
||||
|
||||
type LoadMcpUpdateChecker = () => Promise<McpUpdateChecker>;
|
||||
|
||||
const announcedUpdateVersions = new Set<string>();
|
||||
|
||||
/**
|
||||
* Start the process-scoped MCP update adapter after its transport startup
|
||||
* boundary. All failures stay inside this best-effort side channel.
|
||||
*/
|
||||
export async function startMcpUpdateNotifier(
|
||||
logger: McpUpdateLogger,
|
||||
loadChecker: LoadMcpUpdateChecker = () => import('../core/update-check.js'),
|
||||
): Promise<void> {
|
||||
let checker: McpUpdateChecker;
|
||||
try {
|
||||
checker = await loadChecker();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const announce = (state: UpdateState | null): void => {
|
||||
try {
|
||||
if (
|
||||
!state?.updateAvailable ||
|
||||
!state.latestVersion ||
|
||||
announcedUpdateVersions.has(state.latestVersion)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
announcedUpdateVersions.add(state.latestVersion);
|
||||
logger.info(
|
||||
{ event: 'gitnexus.update_available', latestVersion: state.latestVersion },
|
||||
'GitNexus update available',
|
||||
);
|
||||
} catch {
|
||||
// Logging must never escape into MCP startup or scheduler promises.
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
announce(await checker.evaluate());
|
||||
} catch {
|
||||
// Cache evaluation and any detached refresh are best-effort.
|
||||
}
|
||||
|
||||
let stop: (() => void) | undefined;
|
||||
try {
|
||||
stop = checker.armUpdateRefreshScheduler(announce);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
process.once('exit', () => stop?.());
|
||||
}
|
||||
|
||||
export const mcpCommand = async (options?: {
|
||||
http?: boolean;
|
||||
port?: string;
|
||||
|
|
@ -117,9 +182,11 @@ export const mcpCommand = async (options?: {
|
|||
);
|
||||
process.exit(1);
|
||||
}
|
||||
void startMcpUpdateNotifier(logger).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start MCP server (serves all repos, discovers new ones lazily)
|
||||
await startMCPServer(backend, repositoryPolicy);
|
||||
void startMcpUpdateNotifier(logger).catch(() => {});
|
||||
};
|
||||
|
|
|
|||
128
gitnexus/src/cli/update-notice.ts
Normal file
128
gitnexus/src/cli/update-notice.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import { updateEligibleInstallSync } from '../core/install-context.js';
|
||||
import {
|
||||
isNewerVersion,
|
||||
readValidatedUpdateCacheSync,
|
||||
updateNotifierOptedOut,
|
||||
updateRefreshInProgress,
|
||||
type ValidatedUpdateCache,
|
||||
} from '../core/update-cache.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
const EXCLUDED_COMMANDS = new Set([
|
||||
'augment',
|
||||
'mcp',
|
||||
'serve',
|
||||
'eval-server',
|
||||
'update',
|
||||
'__update-check',
|
||||
]);
|
||||
const EXCLUDED_FLAGS = new Set(['--help', '-h', '--version', '-V']);
|
||||
|
||||
type SpawnResult = { unref(): void };
|
||||
type SpawnLike = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { detached: true; stdio: 'ignore'; windowsHide: true },
|
||||
) => SpawnResult;
|
||||
|
||||
export interface CliUpdateNoticeDependencies {
|
||||
argv: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
installedVersion: string;
|
||||
isTTY: boolean | undefined;
|
||||
eligible: boolean;
|
||||
now: number;
|
||||
readCache: (options: { env: NodeJS.ProcessEnv; now: number }) => ValidatedUpdateCache | null;
|
||||
writeStderr: (line: string) => unknown;
|
||||
spawn: SpawnLike;
|
||||
}
|
||||
|
||||
function excludedInvocation(argv: string[]): boolean {
|
||||
const args = argv.slice(2);
|
||||
if (args.some((arg) => EXCLUDED_FLAGS.has(arg))) return true;
|
||||
const command = args.find((arg) => !arg.startsWith('-'));
|
||||
return command !== undefined && EXCLUDED_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
export function updateNoticeText(installedVersion: string, latestVersion: string): string {
|
||||
return t('update.available', { installedVersion, latestVersion });
|
||||
}
|
||||
|
||||
/** Shared cache-gated notice line used by the CLI banner and `doctor`. */
|
||||
export function cachedUpdateNoticeLine(options: {
|
||||
installedVersion: string;
|
||||
eligible: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
readCache: () => ValidatedUpdateCache | null;
|
||||
}): string | null {
|
||||
try {
|
||||
if (!options.eligible || updateNotifierOptedOut(options.env)) return null;
|
||||
const cache = options.readCache();
|
||||
if (!cache?.latestVersion || !isNewerVersion(options.installedVersion, cache.latestVersion)) {
|
||||
return null;
|
||||
}
|
||||
return updateNoticeText(options.installedVersion, cache.latestVersion);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function runCliUpdateNotice(deps: CliUpdateNoticeDependencies): void {
|
||||
try {
|
||||
if (
|
||||
deps.isTTY !== true ||
|
||||
updateNotifierOptedOut(deps.env) ||
|
||||
!deps.eligible ||
|
||||
excludedInvocation(deps.argv)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = deps.readCache({ env: deps.env, now: deps.now });
|
||||
if (cache?.latestVersion && isNewerVersion(deps.installedVersion, cache.latestVersion)) {
|
||||
deps.writeStderr(`${updateNoticeText(deps.installedVersion, cache.latestVersion)}\n`);
|
||||
}
|
||||
|
||||
if (cache === null || cache.stale) {
|
||||
// Coalesce parallel invocations: when a live process holds the refresh
|
||||
// lock, its refresh covers us, so don't fork another CLI.
|
||||
if (!updateRefreshInProgress(deps.env)) {
|
||||
try {
|
||||
deps
|
||||
.spawn(process.execPath, [deps.argv[1] ?? '', '__update-check'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
})
|
||||
.unref();
|
||||
} catch {
|
||||
// Update refresh is best-effort and must never reach Commander parsing.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache reads and all adapter logic fail open.
|
||||
}
|
||||
}
|
||||
|
||||
export function runProcessCliUpdateNotice(installedVersion: string): void {
|
||||
if (
|
||||
process.stderr.isTTY !== true ||
|
||||
updateNotifierOptedOut(process.env) ||
|
||||
excludedInvocation(process.argv)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
runCliUpdateNotice({
|
||||
argv: process.argv,
|
||||
env: process.env,
|
||||
installedVersion,
|
||||
isTTY: process.stderr.isTTY,
|
||||
eligible: updateEligibleInstallSync(),
|
||||
now: Date.now(),
|
||||
readCache: readValidatedUpdateCacheSync,
|
||||
writeStderr: (line) => process.stderr.write(line),
|
||||
spawn: nodeSpawn as unknown as SpawnLike,
|
||||
});
|
||||
}
|
||||
126
gitnexus/src/cli/update.ts
Normal file
126
gitnexus/src/cli/update.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Explicit `gitnexus update`: refresh the latest dist-tag, then install
|
||||
* `gitnexus@<version>` globally with npm — the same shape as `claude update`
|
||||
* / `codex update`. Other commands only notify; they never spawn npm.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { composeWin32NpmCommand } from '../core/embeddings/runtime-install.js';
|
||||
import { STRICT_UPDATE_VERSION } from '../core/update-cache.js';
|
||||
import { refresh, type UpdateState } from '../core/update-check.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json') as { version?: unknown };
|
||||
|
||||
export const UPDATE_PACKAGE = 'gitnexus';
|
||||
|
||||
export interface UpdateCommandDependencies {
|
||||
installedVersion: string;
|
||||
refresh: (options: {
|
||||
eligible: true;
|
||||
ignoreOptOut: true;
|
||||
installedVersion: string;
|
||||
}) => Promise<UpdateState | null>;
|
||||
writeStdout: (line: string) => void;
|
||||
runInstall: (version: string) => Promise<number>;
|
||||
setExitCode: (code: number) => void;
|
||||
}
|
||||
|
||||
function defaultInstalledVersion(): string {
|
||||
return typeof pkg.version === 'string' ? pkg.version : '';
|
||||
}
|
||||
|
||||
export function updateInstallArgs(version: string): string[] {
|
||||
return ['i', '-g', `${UPDATE_PACKAGE}@${version}`];
|
||||
}
|
||||
|
||||
export function updateInstallCommand(version: string): string {
|
||||
return `npm ${updateInstallArgs(version).join(' ')}`;
|
||||
}
|
||||
|
||||
function isTestDeps(value: unknown): value is Partial<UpdateCommandDependencies> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
('refresh' in value || 'runInstall' in value || 'writeStdout' in value)
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRunInstall(version: string): Promise<number> {
|
||||
const args = updateInstallArgs(version);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child =
|
||||
process.platform === 'win32'
|
||||
? spawn(composeWin32NpmCommand(args), {
|
||||
cwd: homedir(),
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
: spawn('npm', args, {
|
||||
cwd: homedir(),
|
||||
windowsHide: true,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
resolve(signal ? 1 : (code ?? 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCommand(maybeDeps?: unknown): Promise<void> {
|
||||
const deps = isTestDeps(maybeDeps) ? maybeDeps : {};
|
||||
const installedVersion = deps.installedVersion ?? defaultInstalledVersion();
|
||||
const runRefresh = deps.refresh ?? refresh;
|
||||
const writeStdout = deps.writeStdout ?? ((line: string) => console.log(line));
|
||||
const runInstall = deps.runInstall ?? defaultRunInstall;
|
||||
const setExitCode =
|
||||
deps.setExitCode ??
|
||||
((code: number) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
|
||||
const state = await runRefresh({
|
||||
eligible: true,
|
||||
ignoreOptOut: true,
|
||||
installedVersion,
|
||||
});
|
||||
|
||||
const latestVersion =
|
||||
state?.latestVersion && STRICT_UPDATE_VERSION.test(state.latestVersion)
|
||||
? state.latestVersion
|
||||
: undefined;
|
||||
if (state?.updateAvailable && latestVersion) {
|
||||
writeStdout(t('update.available', { installedVersion, latestVersion }));
|
||||
writeStdout(t('update.installing', { command: updateInstallCommand(latestVersion) }));
|
||||
try {
|
||||
const code = await runInstall(latestVersion);
|
||||
if (code !== 0) {
|
||||
writeStdout(t('update.installFailed', { command: updateInstallCommand(latestVersion) }));
|
||||
setExitCode(code);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
writeStdout(
|
||||
t('update.installError', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
writeStdout(t('update.installed', { version: latestVersion }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestVersion) {
|
||||
writeStdout(t('update.current', { installedVersion }));
|
||||
return;
|
||||
}
|
||||
|
||||
writeStdout(t('update.checkFailed'));
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
170
gitnexus/src/core/install-context.ts
Normal file
170
gitnexus/src/core/install-context.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import fsSync from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const EPHEMERAL_SEGMENTS = new Set(['_npx', '_cacache']);
|
||||
const EPHEMERAL_DLX_OWNERS = new Set(['pnpm', 'yarn']);
|
||||
|
||||
function isInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function hasEphemeralMarker(candidate: string): boolean {
|
||||
const normalized = candidate.replaceAll('\\', '/').toLowerCase();
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
if (segments.some((segment) => EPHEMERAL_SEGMENTS.has(segment))) return true;
|
||||
// `dlx` is only ephemeral next to a package-manager owner (pnpm dlx / yarn
|
||||
// dlx). A project directory that happens to be named `dlx` is a normal install.
|
||||
if (segments.includes('dlx') && segments.some((segment) => EPHEMERAL_DLX_OWNERS.has(segment))) {
|
||||
return true;
|
||||
}
|
||||
return normalized.includes('/.bun/install/cache/') || normalized.includes('/bun/install/cache/');
|
||||
}
|
||||
|
||||
function findPackageDir(entryPath: string): string | null {
|
||||
let current = path.dirname(path.resolve(entryPath));
|
||||
for (;;) {
|
||||
if (
|
||||
path.basename(current) === 'gitnexus' &&
|
||||
path.basename(path.dirname(current)) === 'node_modules'
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
interface EligibilityProbes {
|
||||
realEntry: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** Resolved npm cache dir, when npm_config_cache is set. */
|
||||
realCache: string | null;
|
||||
packageDir: string | null;
|
||||
/** Whether the resolved package directory carries a .git checkout marker. */
|
||||
packageDirHasGit: boolean;
|
||||
/** Resolved npm prefix, when npm_config_prefix is set. */
|
||||
realPrefix: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure classification shared by the async and sync variants. Realpath
|
||||
* resolution deliberately makes a linked node_modules entry point at its
|
||||
* development checkout, which is therefore ineligible.
|
||||
*/
|
||||
function classifyEligibility(probes: EligibilityProbes): boolean {
|
||||
const { realEntry, env, realCache, packageDir, packageDirHasGit, realPrefix } = probes;
|
||||
const corroboratingPaths = [
|
||||
realEntry,
|
||||
env.npm_execpath,
|
||||
env.npm_config_cache && path.resolve(env.npm_config_cache),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (corroboratingPaths.some(hasEphemeralMarker)) return false;
|
||||
|
||||
if (realCache && isInside(realCache, realEntry)) return false;
|
||||
|
||||
// Published packages do not carry .git. This also rejects unusual installs
|
||||
// copied wholesale from a checkout.
|
||||
if (!packageDir || packageDirHasGit) return false;
|
||||
|
||||
if (realPrefix && isInside(realPrefix, realEntry)) return true;
|
||||
|
||||
// A package rooted at node_modules/gitnexus is a persistent project-local
|
||||
// install after ephemeral/cache layouts have been excluded above.
|
||||
return true;
|
||||
}
|
||||
|
||||
let memoizedEligible: boolean | undefined;
|
||||
|
||||
/** True only for a persistent npm global or project-local installation. */
|
||||
export async function updateEligibleInstall(
|
||||
entryPath?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<boolean> {
|
||||
const useMemo = entryPath === undefined && env === process.env;
|
||||
if (useMemo && memoizedEligible !== undefined) return memoizedEligible;
|
||||
const result = await classifyAsync(entryPath ?? process.argv[1] ?? '', env);
|
||||
if (useMemo) memoizedEligible = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function classifyAsync(entryPath: string, env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
if (!entryPath) return false;
|
||||
try {
|
||||
const realEntry = await fs.realpath(entryPath);
|
||||
const realCache = env.npm_config_cache
|
||||
? await fs
|
||||
.realpath(env.npm_config_cache)
|
||||
.catch(() => path.resolve(env.npm_config_cache as string))
|
||||
: null;
|
||||
const packageDir = findPackageDir(realEntry);
|
||||
const packageDirHasGit = packageDir
|
||||
? await fs
|
||||
.access(path.join(packageDir, '.git'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
: false;
|
||||
const realPrefix = env.npm_config_prefix
|
||||
? await fs.realpath(env.npm_config_prefix).catch(() => path.resolve(env.npm_config_prefix))
|
||||
: null;
|
||||
return classifyEligibility({
|
||||
realEntry,
|
||||
env,
|
||||
realCache,
|
||||
packageDir,
|
||||
packageDirHasGit,
|
||||
realPrefix,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous entry-point variant for pre-Commander startup checks. */
|
||||
export function updateEligibleInstallSync(
|
||||
entryPath?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
const useMemo = entryPath === undefined && env === process.env;
|
||||
if (useMemo && memoizedEligible !== undefined) return memoizedEligible;
|
||||
const result = classifySync(entryPath ?? process.argv[1] ?? '', env);
|
||||
if (useMemo) memoizedEligible = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function classifySync(entryPath: string, env: NodeJS.ProcessEnv): boolean {
|
||||
if (!entryPath) return false;
|
||||
try {
|
||||
const realEntry = fsSync.realpathSync(entryPath);
|
||||
let realCache: string | null = null;
|
||||
if (env.npm_config_cache) {
|
||||
try {
|
||||
realCache = fsSync.realpathSync(env.npm_config_cache);
|
||||
} catch {
|
||||
realCache = path.resolve(env.npm_config_cache);
|
||||
}
|
||||
}
|
||||
const packageDir = findPackageDir(realEntry);
|
||||
const packageDirHasGit = packageDir ? fsSync.existsSync(path.join(packageDir, '.git')) : false;
|
||||
let realPrefix: string | null = null;
|
||||
if (env.npm_config_prefix) {
|
||||
try {
|
||||
realPrefix = fsSync.realpathSync(env.npm_config_prefix);
|
||||
} catch {
|
||||
realPrefix = path.resolve(env.npm_config_prefix);
|
||||
}
|
||||
}
|
||||
return classifyEligibility({
|
||||
realEntry,
|
||||
env,
|
||||
realCache,
|
||||
packageDir,
|
||||
packageDirHasGit,
|
||||
realPrefix,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
174
gitnexus/src/core/net/url-guard.ts
Normal file
174
gitnexus/src/core/net/url-guard.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { isIP } from 'net';
|
||||
|
||||
// Cloud metadata hostnames that must never be reachable via user-supplied URLs
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
'metadata.google.internal',
|
||||
'metadata.azure.com',
|
||||
'metadata.internal',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate an outbound http(s) URL to prevent SSRF.
|
||||
* Only allows https:// and http:// schemes. Blocks private/internal addresses,
|
||||
* IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
|
||||
*/
|
||||
export function validateGitUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
|
||||
if (!['https:', 'http:'].includes(parsed.protocol)) {
|
||||
throw new Error('Only https:// and http:// git URLs are allowed');
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error('Git URLs must not include query strings or fragments');
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block known dangerous hostnames (cloud metadata services)
|
||||
if (BLOCKED_HOSTNAMES.has(host)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
|
||||
let normalizedHost = host;
|
||||
if (host.startsWith('[') && host.endsWith(']')) {
|
||||
normalizedHost = host.slice(1, -1);
|
||||
}
|
||||
|
||||
// Check if this is an IPv6 address
|
||||
// Use manual colon detection as fallback since isIP may return 0 for some
|
||||
// normalized IPv6 forms (e.g. ::ffff:7f00:1)
|
||||
const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
|
||||
if (isIPv6) {
|
||||
assertNotPrivateIPv6(normalizedHost);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an IPv4 address (including numeric encodings)
|
||||
if (isIP(normalizedHost) === 4) {
|
||||
assertNotPrivateIPv4(normalizedHost);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-IP hostnames, check for numeric IP tricks
|
||||
// Decimal encoding: 2130706433 = 127.0.0.1
|
||||
// Hex encoding: 0x7f000001 = 127.0.0.1
|
||||
if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Standard IPv4 regex checks for dotted notation
|
||||
if (
|
||||
/^127\./.test(host) ||
|
||||
/^10\./.test(host) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
||||
/^192\.168\./.test(host) ||
|
||||
/^169\.254\./.test(host) ||
|
||||
/^0\./.test(host) ||
|
||||
host === '0.0.0.0' ||
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
|
||||
/^198\.1[89]\./.test(host)
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotPrivateIPv6(ip: string): void {
|
||||
// Expand common compressed forms for comparison
|
||||
const lower = ip.toLowerCase();
|
||||
|
||||
// IPv6 loopback
|
||||
if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Unspecified address
|
||||
if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv6 link-local (fe80::/10)
|
||||
if (
|
||||
lower.startsWith('fe80') ||
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
|
||||
// Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
|
||||
if (lower.startsWith('::ffff:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Expanded IPv4-mapped form only (0:0:0:0:0:ffff:…). A public address that
|
||||
// merely contains a `ffff` hextet is not mapped.
|
||||
if (lower.startsWith('0:0:0:0:0:ffff:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
|
||||
// Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
|
||||
// is hidden in the last 32 bits without the ::ffff: marker, so the check
|
||||
// above misses it. The form is still routable to the embedded IPv4 on most
|
||||
// network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
|
||||
if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
|
||||
// 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
|
||||
// ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
|
||||
// e.g. 64:ff9b::7f00:1 → 127.0.0.1.
|
||||
// The check intentionally covers the full 64:ff9b::/32 block (broader than
|
||||
// the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
|
||||
// blocking the whole prefix is defensively sound and prevents a narrower
|
||||
// CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
|
||||
// future translation assignment.
|
||||
if (lower.startsWith('64:ff9b:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
|
||||
// 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
|
||||
// protocol was deprecated by RFC 7526 and the public relay anycast
|
||||
// (192.88.99.1) has been retired, so broad-blocking the prefix has near-
|
||||
// zero false-positive cost while closing the IPv4-embedded bypass.
|
||||
// Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
|
||||
// impractical and is out of scope here.
|
||||
if (lower.startsWith('2002:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotPrivateIPv4(ip: string): void {
|
||||
const parts = ip.split('.').map(Number);
|
||||
const [a, b] = parts;
|
||||
if (
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254) ||
|
||||
a === 0 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 198 && (b === 18 || b === 19))
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
172
gitnexus/src/core/update-cache.ts
Normal file
172
gitnexus/src/core/update-cache.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getGlobalDir } from '../storage/global-dir.js';
|
||||
import { isProcessAlive, readProcessStartTime } from '../utils/process-identity.js';
|
||||
|
||||
export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
|
||||
export const STRICT_UPDATE_VERSION = /^\d+\.\d+\.\d+$/;
|
||||
export const DEFAULT_UPDATE_REGISTRY = 'https://registry.npmjs.org';
|
||||
|
||||
export interface UpdateCacheEntry {
|
||||
lastCheckAt: string;
|
||||
registry: string;
|
||||
latestVersion?: string;
|
||||
}
|
||||
|
||||
export interface ValidatedUpdateCache {
|
||||
lastCheckAt: number;
|
||||
latestVersion?: string;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
/** Broad truthy parsing shared by the update-notifier guards. */
|
||||
export function isTruthyEnv(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
return !['', '0', 'false', 'no', 'off'].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
/** The update notifier is disabled by either opt-out env or a CI environment. */
|
||||
export function updateNotifierOptedOut(env: NodeJS.ProcessEnv): boolean {
|
||||
return (
|
||||
isTruthyEnv(env.GITNEXUS_NO_UPDATE_NOTIFIER) ||
|
||||
isTruthyEnv(env.NO_UPDATE_NOTIFIER) ||
|
||||
isTruthyEnv(env.CI)
|
||||
);
|
||||
}
|
||||
|
||||
/** Freshness gate for the 24h TTL; future-dated timestamps are stale. */
|
||||
export function isUpdateCacheFresh(lastCheckAt: string, now: number): boolean {
|
||||
const checkedAt = Date.parse(lastCheckAt);
|
||||
return checkedAt <= now && now - checkedAt < UPDATE_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/** Strict x.y.z numeric comparison. Invalid or prerelease versions are silent. */
|
||||
export function isNewerVersion(installedVersion: string, latestVersion: string): boolean {
|
||||
if (!STRICT_UPDATE_VERSION.test(installedVersion) || !STRICT_UPDATE_VERSION.test(latestVersion)) {
|
||||
return false;
|
||||
}
|
||||
const installed = installedVersion.split('.').map(BigInt);
|
||||
const latest = latestVersion.split('.').map(BigInt);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (latest[index] !== installed[index]) return latest[index] > installed[index];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let registryMemo: { key: string; value: { identity: string; packageUrl: string } } | undefined;
|
||||
|
||||
export function normalizedUpdateRegistry(env: NodeJS.ProcessEnv = process.env): {
|
||||
identity: string;
|
||||
packageUrl: string;
|
||||
} {
|
||||
const key = env.npm_config_registry ?? '';
|
||||
if (registryMemo?.key === key) return registryMemo.value;
|
||||
const value = buildUpdateRegistry(key);
|
||||
registryMemo = { key, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildUpdateRegistry(rawRegistry: string): { identity: string; packageUrl: string } {
|
||||
const parsed = new URL(rawRegistry || DEFAULT_UPDATE_REGISTRY);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error('Unsupported npm registry protocol');
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error('Registry URL cannot contain query or fragment');
|
||||
}
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
const identity = `${parsed.protocol}//${parsed.host}${pathname}`;
|
||||
// `/<pkg>/latest` is the small dist-tag document. The full packument at
|
||||
// `/<pkg>` is multi-megabyte on this package and cannot fit the fetch cap.
|
||||
const packagePath = `${pathname}/gitnexus/latest`.replace(/\/{2,}/g, '/');
|
||||
return { identity, packageUrl: `${parsed.protocol}//${parsed.host}${packagePath}` };
|
||||
}
|
||||
|
||||
export function updateCheckCachePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(env.GITNEXUS_HOME || getGlobalDir(), 'update-check.json');
|
||||
}
|
||||
|
||||
export function updateCheckLockPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(env.GITNEXUS_HOME || getGlobalDir(), 'update-check.lock');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort synchronous probe: is a refresh plausibly in flight? Used to
|
||||
* coalesce detached refresh spawns. A dead same-host owner returns false so
|
||||
* the spawned child can reclaim the stale lock; a foreign-host owner returns
|
||||
* false and lets the child's full lock logic decide.
|
||||
*/
|
||||
export function updateRefreshInProgress(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
try {
|
||||
const owner = JSON.parse(fs.readFileSync(updateCheckLockPath(env), 'utf8')) as {
|
||||
pid?: unknown;
|
||||
hostname?: unknown;
|
||||
processStartTime?: unknown;
|
||||
};
|
||||
if (typeof owner.pid !== 'number' || owner.pid <= 0) return false;
|
||||
if (owner.hostname !== os.hostname()) return false;
|
||||
if (!isProcessAlive(owner.pid)) return false;
|
||||
// Same rule as acquireFileLock: a live PID with a different start time is
|
||||
// reuse, not the lock owner. Unreadable start time stays conservative
|
||||
// (treat as in progress) so we don't spawn a racing child.
|
||||
if (typeof owner.processStartTime === 'string' && owner.processStartTime) {
|
||||
const currentStartTime = readProcessStartTime(owner.pid);
|
||||
if (currentStartTime && currentStartTime !== owner.processStartTime) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUpdateCache(raw: string, registry: string): UpdateCacheEntry | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<UpdateCacheEntry>;
|
||||
if (
|
||||
typeof parsed.lastCheckAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(parsed.lastCheckAt)) ||
|
||||
parsed.registry !== registry ||
|
||||
(parsed.latestVersion !== undefined &&
|
||||
(typeof parsed.latestVersion !== 'string' ||
|
||||
!STRICT_UPDATE_VERSION.test(parsed.latestVersion)))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
lastCheckAt: parsed.lastCheckAt,
|
||||
registry: parsed.registry,
|
||||
...(parsed.latestVersion === undefined ? {} : { latestVersion: parsed.latestVersion }),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readValidatedUpdateCacheSync(
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: number;
|
||||
} = {},
|
||||
): ValidatedUpdateCache | null {
|
||||
try {
|
||||
const env = options.env ?? process.env;
|
||||
const registry = normalizedUpdateRegistry(env);
|
||||
const raw = fs.readFileSync(updateCheckCachePath(env), 'utf8');
|
||||
const entry = parseUpdateCache(raw, registry.identity);
|
||||
if (!entry) return null;
|
||||
const lastCheckAt = Date.parse(entry.lastCheckAt);
|
||||
const now = options.now ?? Date.now();
|
||||
return {
|
||||
lastCheckAt,
|
||||
...(entry.latestVersion === undefined ? {} : { latestVersion: entry.latestVersion }),
|
||||
stale: !isUpdateCacheFresh(entry.lastCheckAt, now),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
331
gitnexus/src/core/update-check.ts
Normal file
331
gitnexus/src/core/update-check.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { getGlobalDir } from '../storage/global-dir.js';
|
||||
import { writeFileAtomic } from '../storage/fs-atomic.js';
|
||||
import { acquireFileLock, FileLockBusyError } from '../storage/file-lock.js';
|
||||
import { validateGitUrl } from './net/url-guard.js';
|
||||
import { updateEligibleInstall } from './install-context.js';
|
||||
import { createLogger } from './logger.js';
|
||||
import {
|
||||
isNewerVersion,
|
||||
isUpdateCacheFresh,
|
||||
normalizedUpdateRegistry,
|
||||
parseUpdateCache,
|
||||
STRICT_UPDATE_VERSION,
|
||||
UPDATE_CACHE_TTL_MS,
|
||||
updateCheckCachePath,
|
||||
updateCheckLockPath,
|
||||
updateNotifierOptedOut,
|
||||
type UpdateCacheEntry,
|
||||
} from './update-cache.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json') as { version?: unknown };
|
||||
|
||||
const FETCH_TIMEOUT_MS = 3_000;
|
||||
const MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
const MAX_REDIRECTS = 5;
|
||||
/** Backoff when refresh cannot publish (lock-busy or still-stale cache). */
|
||||
const LOCK_BUSY_RETRY_MIN_MS = 30_000;
|
||||
const LOCK_BUSY_RETRY_JITTER_MS = 30_000;
|
||||
const updateLogger = createLogger('update-check');
|
||||
|
||||
function defaultInstalledVersion(): string {
|
||||
return typeof pkg.version === 'string' ? pkg.version : '';
|
||||
}
|
||||
|
||||
function nextSchedulerDelay(entry: UpdateCacheEntry | null, now: number): number {
|
||||
if (entry && isUpdateCacheFresh(entry.lastCheckAt, now)) {
|
||||
return Math.max(1, Date.parse(entry.lastCheckAt) + UPDATE_CACHE_TTL_MS - now);
|
||||
}
|
||||
// Missing, future-dated, or still stale after a lock-busy skip: back off
|
||||
// from now instead of deriving 1ms from a past-due timestamp.
|
||||
return LOCK_BUSY_RETRY_MIN_MS + Math.floor(Math.random() * LOCK_BUSY_RETRY_JITTER_MS);
|
||||
}
|
||||
|
||||
export interface UpdateState {
|
||||
updateAvailable: boolean;
|
||||
latestVersion?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCheckOptions {
|
||||
/** Test/adapter override; omitted means classify process.argv[1]. */
|
||||
eligible?: boolean;
|
||||
/** Test override; omitted means this package's installed version. */
|
||||
installedVersion?: string;
|
||||
/** Test override in epoch milliseconds. */
|
||||
now?: number;
|
||||
/** Cache-only consumers can suppress stale-while-revalidate. */
|
||||
refreshIfStale?: boolean;
|
||||
/** Explicit `gitnexus update`: check even when CI/opt-out env is set. */
|
||||
ignoreOptOut?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateRefreshSchedulerOptions extends Omit<
|
||||
UpdateCheckOptions,
|
||||
'now' | 'refreshIfStale'
|
||||
> {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function isOptedOut(): boolean {
|
||||
return updateNotifierOptedOut(process.env);
|
||||
}
|
||||
|
||||
async function isEligible(override: boolean | undefined): Promise<boolean> {
|
||||
return override ?? (await updateEligibleInstall());
|
||||
}
|
||||
|
||||
function cacheFile(): string {
|
||||
return updateCheckCachePath();
|
||||
}
|
||||
|
||||
function lockFile(): string {
|
||||
return updateCheckLockPath();
|
||||
}
|
||||
|
||||
function normalizedRegistry(): { identity: string; packageUrl: string } {
|
||||
const registry = normalizedUpdateRegistry();
|
||||
validateGitUrl(registry.packageUrl);
|
||||
return registry;
|
||||
}
|
||||
|
||||
async function readCache(registry: string): Promise<UpdateCacheEntry | null> {
|
||||
try {
|
||||
return parseUpdateCache(await fs.readFile(cacheFile(), 'utf8'), registry);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stateFrom(entry: UpdateCacheEntry, installedVersion: string): UpdateState {
|
||||
return {
|
||||
updateAvailable:
|
||||
entry.latestVersion !== undefined && isNewerVersion(installedVersion, entry.latestVersion),
|
||||
...(entry.latestVersion === undefined ? {} : { latestVersion: entry.latestVersion }),
|
||||
};
|
||||
}
|
||||
|
||||
function installedVersionOf(options: { installedVersion?: string }): string {
|
||||
return options.installedVersion ?? defaultInstalledVersion();
|
||||
}
|
||||
|
||||
async function isNotifierActive(
|
||||
options: {
|
||||
eligible?: boolean;
|
||||
ignoreOptOut?: boolean;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
return (options.ignoreOptOut === true || !isOptedOut()) && (await isEligible(options.eligible));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read update state cache-first. Every invalid/missing/stale cache starts one
|
||||
* catch-isolated refresh unless the caller explicitly requests cache-only.
|
||||
*/
|
||||
export async function evaluate(options: UpdateCheckOptions = {}): Promise<UpdateState | null> {
|
||||
try {
|
||||
if (!(await isNotifierActive(options))) return null;
|
||||
const registry = normalizedRegistry();
|
||||
const now = options.now ?? Date.now();
|
||||
const entry = await readCache(registry.identity);
|
||||
if (
|
||||
(!entry || !isUpdateCacheFresh(entry.lastCheckAt, now)) &&
|
||||
options.refreshIfStale !== false
|
||||
) {
|
||||
void refresh(options).catch(() => {});
|
||||
}
|
||||
if (!entry) return null;
|
||||
return stateFrom(entry, installedVersionOf(options));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponseBody(response: Response): Promise<string> {
|
||||
const advertised = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(advertised) && advertised > MAX_RESPONSE_BYTES) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error('Registry response too large');
|
||||
}
|
||||
if (!response.body) return '';
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_RESPONSE_BYTES) throw new Error('Registry response too large');
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
if (bytes > MAX_RESPONSE_BYTES) await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
function sanitizedHttpUrl(input: string | URL, base?: string): URL {
|
||||
const parsed = new URL(input, base);
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
validateGitUrl(parsed.toString());
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function fetchLatest(packageUrl: string): Promise<string> {
|
||||
let url = sanitizedHttpUrl(packageUrl);
|
||||
for (let redirects = 0; ; redirects += 1) {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
headers: { accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
if (redirects >= MAX_REDIRECTS) throw new Error('Too many registry redirects');
|
||||
const location = response.headers.get('location');
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (!location) throw new Error('Registry redirect missing location');
|
||||
url = sanitizedHttpUrl(location, url.toString());
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error(`Registry returned ${response.status}`);
|
||||
}
|
||||
const parsed = JSON.parse(await readResponseBody(response)) as {
|
||||
version?: unknown;
|
||||
'dist-tags'?: { latest?: unknown };
|
||||
};
|
||||
const latest =
|
||||
(typeof parsed.version === 'string' ? parsed.version : undefined) ??
|
||||
(typeof parsed['dist-tags']?.latest === 'string' ? parsed['dist-tags'].latest : undefined);
|
||||
if (typeof latest !== 'string' || !STRICT_UPDATE_VERSION.test(latest)) {
|
||||
throw new Error('Registry latest version is invalid');
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
|
||||
async function publishMonotonically(
|
||||
entry: UpdateCacheEntry,
|
||||
attemptStartedAt: number,
|
||||
): Promise<void> {
|
||||
const current = await readCache(entry.registry);
|
||||
const currentAt = current ? Date.parse(current.lastCheckAt) : Number.NaN;
|
||||
// A later in-the-past write wins. Future-dated entries (wall-clock) are
|
||||
// clock-skew poison and must stay replaceable so a later holder can repair them.
|
||||
if (Number.isFinite(currentAt) && currentAt <= Date.now() && currentAt > attemptStartedAt) {
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(getGlobalDir(), { recursive: true });
|
||||
await writeFileAtomic(cacheFile(), `${JSON.stringify(entry)}\n`, 1);
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<UpdateState | null> | null = null;
|
||||
|
||||
/** Run one locked, fail-open registry refresh. */
|
||||
export function refresh(options: UpdateCheckOptions = {}): Promise<UpdateState | null> {
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
const run = async (): Promise<UpdateState | null> => {
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
if (!(await isNotifierActive(options))) return null;
|
||||
const registry = normalizedRegistry();
|
||||
const attemptStartedAt = options.now ?? Date.now();
|
||||
try {
|
||||
release = await acquireFileLock(lockFile(), { retries: 0 });
|
||||
} catch (error) {
|
||||
if (error instanceof FileLockBusyError) return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let fetched: string | undefined;
|
||||
try {
|
||||
fetched = await fetchLatest(registry.packageUrl);
|
||||
} catch {
|
||||
// Negative entries enforce the same TTL on offline/authenticated-only
|
||||
// registries as successful checks. A known same-identity latestVersion
|
||||
// must survive a later failed refresh so notices do not go silent
|
||||
// for a day; only a first-ever miss stays version-less.
|
||||
}
|
||||
const latestVersion = fetched ?? (await readCache(registry.identity))?.latestVersion;
|
||||
const entry: UpdateCacheEntry = {
|
||||
lastCheckAt: new Date(attemptStartedAt).toISOString(),
|
||||
registry: registry.identity,
|
||||
...(latestVersion === undefined ? {} : { latestVersion }),
|
||||
};
|
||||
await publishMonotonically(entry, attemptStartedAt);
|
||||
// Live fetch failed: keep the on-disk pin for notices, but do not
|
||||
// return it as a confirmed refresh so `gitnexus update` cannot install
|
||||
// from an unconfirmed cache.
|
||||
if (fetched === undefined) return null;
|
||||
return stateFrom(entry, installedVersionOf(options));
|
||||
} catch (error) {
|
||||
updateLogger.debug(
|
||||
{ code: (error as NodeJS.ErrnoException).code },
|
||||
'Update check failed open',
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (release) await release().catch(() => {});
|
||||
}
|
||||
};
|
||||
refreshInFlight = run().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an immediate evaluation and repeat on the cache TTL cadence. Timers
|
||||
* never keep the process alive; refresh() supplies process-wide single-flight.
|
||||
*/
|
||||
export function armUpdateRefreshScheduler(
|
||||
onState: (state: UpdateState | null) => void,
|
||||
options: UpdateRefreshSchedulerOptions = {},
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const cycle = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
const now = options.now?.() ?? Date.now();
|
||||
let entry: UpdateCacheEntry | null = null;
|
||||
try {
|
||||
const registry = normalizedRegistry();
|
||||
entry = await readCache(registry.identity);
|
||||
if (!entry || !isUpdateCacheFresh(entry.lastCheckAt, now)) {
|
||||
await refresh({ ...options, now });
|
||||
entry = await readCache(registry.identity);
|
||||
}
|
||||
} catch {
|
||||
// The public scheduler shares the service's fail-open contract.
|
||||
}
|
||||
// Derive state from the entry already read above; a full evaluate() here
|
||||
// would re-run guards and re-read the cache on every tick.
|
||||
let state: UpdateState | null = null;
|
||||
try {
|
||||
state =
|
||||
!entry || !(await isNotifierActive(options))
|
||||
? null
|
||||
: stateFrom(entry, installedVersionOf(options));
|
||||
} catch {
|
||||
state = null;
|
||||
}
|
||||
if (!stopped) onState(state);
|
||||
if (!stopped) {
|
||||
timer = setTimeout(() => void cycle(), nextSchedulerDelay(entry, now));
|
||||
timer.unref();
|
||||
}
|
||||
};
|
||||
timer = setTimeout(() => void cycle(), 0);
|
||||
timer.unref();
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
112
gitnexus/src/server/update-controller.ts
Normal file
112
gitnexus/src/server/update-controller.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { createRequire } from 'node:module';
|
||||
import { armUpdateRefreshScheduler, evaluate, type UpdateState } from '../core/update-check.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json');
|
||||
|
||||
export interface ServerInfoResponse {
|
||||
version: string;
|
||||
launchContext: 'npx' | 'global' | 'local';
|
||||
nodeVersion: string;
|
||||
latestVersion?: string;
|
||||
updateAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface ServeUpdateControllerDependencies {
|
||||
evaluate: (options?: { refreshIfStale?: boolean }) => Promise<UpdateState | null>;
|
||||
armScheduler: (onState: (state: UpdateState | null) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface ServeUpdateController {
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
snapshot: () => UpdateState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Own the update state for one `serve` process. The route reads snapshot()
|
||||
* synchronously; all cache and network work stays on the startup/scheduler path.
|
||||
*/
|
||||
export const createServeUpdateController = (
|
||||
dependencies: ServeUpdateControllerDependencies = {
|
||||
evaluate,
|
||||
armScheduler: armUpdateRefreshScheduler,
|
||||
},
|
||||
): ServeUpdateController => {
|
||||
let updateState: UpdateState | null = null;
|
||||
let stopScheduler: (() => void) | undefined;
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
|
||||
return {
|
||||
start: async () => {
|
||||
if (started || stopped) return;
|
||||
started = true;
|
||||
try {
|
||||
// Cache-only: the scheduler's first cycle owns any stale refresh.
|
||||
updateState = await dependencies.evaluate({ refreshIfStale: false });
|
||||
} catch {
|
||||
updateState = null;
|
||||
}
|
||||
if (stopped) return;
|
||||
try {
|
||||
stopScheduler = dependencies.armScheduler((state) => {
|
||||
if (
|
||||
state?.updateAvailable !== updateState?.updateAvailable ||
|
||||
state?.latestVersion !== updateState?.latestVersion
|
||||
) {
|
||||
updateState = state;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Update checks are best-effort and never affect HTTP availability.
|
||||
}
|
||||
},
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
stopScheduler?.();
|
||||
} catch {
|
||||
// Shutdown must continue even if notifier cleanup unexpectedly fails.
|
||||
}
|
||||
},
|
||||
snapshot: () => updateState,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildServerInfo = (updateState: UpdateState | null): ServerInfoResponse => {
|
||||
const execPath = process.env.npm_execpath ?? '';
|
||||
const argv0 = process.argv[1] ?? '';
|
||||
let launchContext: 'npx' | 'global' | 'local';
|
||||
if (
|
||||
execPath.includes('npx') ||
|
||||
argv0.includes('_npx') ||
|
||||
process.env.npm_config_prefix?.includes('_npx')
|
||||
) {
|
||||
launchContext = 'npx';
|
||||
} else if (argv0.includes('node_modules')) {
|
||||
launchContext = 'local';
|
||||
} else {
|
||||
launchContext = 'global';
|
||||
}
|
||||
|
||||
return {
|
||||
version: pkg.version,
|
||||
launchContext,
|
||||
nodeVersion: process.version,
|
||||
...(updateState?.updateAvailable && updateState.latestVersion
|
||||
? { latestVersion: updateState.latestVersion, updateAvailable: true }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const bindServeUpdateControllerLifecycle = (
|
||||
server: {
|
||||
once(event: 'listening' | 'close', listener: () => void): unknown;
|
||||
},
|
||||
controller: ServeUpdateController,
|
||||
): void => {
|
||||
server.once('listening', () => void controller.start());
|
||||
server.once('close', controller.stop);
|
||||
};
|
||||
7
gitnexus/src/storage/global-dir.ts
Normal file
7
gitnexus/src/storage/global-dir.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Get the path to the global GitNexus directory. */
|
||||
export const getGlobalDir = (): string => {
|
||||
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
};
|
||||
|
|
@ -17,10 +17,10 @@
|
|||
import fs from 'fs/promises';
|
||||
import { realpathSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { getInferredRepoName, resolveRepoIdentityRoot, stripUrlCredentials } from './git.js';
|
||||
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
||||
import { writeFileAtomic } from './fs-atomic.js';
|
||||
import { getGlobalDir } from './global-dir.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js';
|
||||
import {
|
||||
|
|
@ -53,6 +53,7 @@ export type { BranchSummary };
|
|||
// `tryReadMetaFile` stay module-private here, exactly as before.
|
||||
export { getStoragePath, INDEX_METADATA_FILE, isMissingFilesystemError, loadMeta };
|
||||
export type { AnalyzerRunnerIdentity, RepoMeta };
|
||||
export { getGlobalDir } from './global-dir.js';
|
||||
|
||||
/**
|
||||
* Normalise a repo path for registry comparison across platforms
|
||||
|
|
@ -517,13 +518,6 @@ const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
|
|||
|
||||
// ─── Global Registry (~/.gitnexus/registry.json) ───────────────────────
|
||||
|
||||
/**
|
||||
* Get the path to the global GitNexus directory
|
||||
*/
|
||||
export const getGlobalDir = (): string => {
|
||||
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the path to the global registry file
|
||||
*/
|
||||
|
|
|
|||
224
gitnexus/test/integration/cli/update-notice.test.ts
Normal file
224
gitnexus/test/integration/cli/update-notice.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../../helpers/cli-entry.js';
|
||||
import { cleanupTempDirSync } from '../../helpers/test-db.js';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../../..');
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function tempHome(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cli-update-notice-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function seededCache(home: string): string {
|
||||
const file = path.join(home, 'update-check.json');
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`${JSON.stringify({
|
||||
lastCheckAt: '2000-01-01T00:00:00.000Z',
|
||||
registry: 'https://registry.npmjs.org',
|
||||
latestVersion: '99.0.0',
|
||||
})}\n`,
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
function localeEnv(home: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
GITNEXUS_HOME: home,
|
||||
CI: '',
|
||||
GITNEXUS_NO_UPDATE_NOTIFIER: '',
|
||||
NO_UPDATE_NOTIFIER: '',
|
||||
GITNEXUS_LANG: 'en',
|
||||
LC_ALL: '',
|
||||
LC_MESSAGES: '',
|
||||
LANG: 'C',
|
||||
};
|
||||
}
|
||||
|
||||
function cli(args: string[], home: string) {
|
||||
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: localeEnv(home),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
cleanupTempDirSync(dir);
|
||||
}
|
||||
});
|
||||
|
||||
describe('CLI update notice subprocess behavior', () => {
|
||||
it('keeps non-TTY stdout byte-clean and neither emits nor spawns a refresh child', () => {
|
||||
const home = tempHome();
|
||||
const cache = seededCache(home);
|
||||
const before = fs.readFileSync(cache, 'utf8');
|
||||
|
||||
// `list` is a normal command (not --version/--help, which skip the notifier).
|
||||
const result = cli(['list'], home);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toContain('is available');
|
||||
expect(result.stdout).not.toContain('99.0.0 is available');
|
||||
expect(fs.readFileSync(cache, 'utf8')).toBe(before);
|
||||
expect(fs.existsSync(path.join(home, 'update-check.lock'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps help output unchanged and hides the internal refresh command', () => {
|
||||
const result = cli(['--help'], tempHome());
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Usage: gitnexus [options] [command]');
|
||||
expect(result.stdout).toContain('update');
|
||||
expect(result.stdout).not.toContain('__update-check');
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('prints a versioned command banner on stderr for a normal command', () => {
|
||||
const result = cli(['list'], tempHome());
|
||||
|
||||
expect(result.stderr).toMatch(/GitNexus List \([^)]+\)/);
|
||||
expect(result.stdout).not.toMatch(/GitNexus List \(/);
|
||||
});
|
||||
|
||||
it('documents that gitnexus update installs via npm i -g', () => {
|
||||
const result = cli(['update', '--help'], tempHome());
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/npm i -g gitnexus@<x\.y\.z>/);
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('runs the hidden refresh command without writing stdout', () => {
|
||||
const result = cli(['__update-check'], tempHome());
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
});
|
||||
|
||||
it('lets the parent exit without waiting for a detached refresh child', async () => {
|
||||
const home = tempHome();
|
||||
const project = path.join(home, 'project');
|
||||
const installedPackage = path.join(project, 'node_modules', 'gitnexus');
|
||||
fs.mkdirSync(installedPackage, { recursive: true });
|
||||
fs.cpSync(path.join(repoRoot, 'src'), path.join(installedPackage, 'src'), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, 'package.json'),
|
||||
path.join(installedPackage, 'package.json'),
|
||||
);
|
||||
fs.symlinkSync(
|
||||
path.join(repoRoot, 'node_modules'),
|
||||
path.join(installedPackage, 'node_modules'),
|
||||
'dir',
|
||||
);
|
||||
|
||||
const preload = path.join(home, 'mock-refresh.mjs');
|
||||
fs.writeFileSync(
|
||||
preload,
|
||||
`Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
|
||||
globalThis.fetch = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
return new Response(JSON.stringify({ version: '99.0.0' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
`,
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const parent = spawn(
|
||||
process.execPath,
|
||||
[path.join(installedPackage, 'src', 'cli', 'index.ts'), 'list'],
|
||||
{
|
||||
cwd: project,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...localeEnv(home),
|
||||
NODE_OPTIONS:
|
||||
`--import ${tsxLoaderUrl()} --import ${pathToFileURL(preload).href}`.trim(),
|
||||
},
|
||||
},
|
||||
);
|
||||
parent.once('error', reject);
|
||||
parent.once('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`notifier parent exited ${String(code)}`));
|
||||
});
|
||||
});
|
||||
const elapsed = Date.now() - startedAt;
|
||||
|
||||
expect(elapsed).toBeLessThan(1_800);
|
||||
const cache = path.join(home, 'update-check.json');
|
||||
expect(fs.existsSync(cache)).toBe(false);
|
||||
await expect.poll(() => fs.existsSync(cache), { timeout: 15_000, interval: 100 }).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(cache, 'utf8'))).toMatchObject({
|
||||
latestVersion: '99.0.0',
|
||||
registry: 'https://registry.npmjs.org',
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it('prints the localized notice on a forced-TTY stderr and keeps stdout clean', () => {
|
||||
const home = tempHome();
|
||||
fs.writeFileSync(
|
||||
path.join(home, 'update-check.json'),
|
||||
`${JSON.stringify({
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
registry: 'https://registry.npmjs.org',
|
||||
latestVersion: '99.0.0',
|
||||
})}\n`,
|
||||
);
|
||||
const project = path.join(home, 'project');
|
||||
const installedPackage = path.join(project, 'node_modules', 'gitnexus');
|
||||
fs.mkdirSync(installedPackage, { recursive: true });
|
||||
fs.cpSync(path.join(repoRoot, 'src'), path.join(installedPackage, 'src'), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, 'package.json'),
|
||||
path.join(installedPackage, 'package.json'),
|
||||
);
|
||||
fs.symlinkSync(
|
||||
path.join(repoRoot, 'node_modules'),
|
||||
path.join(installedPackage, 'node_modules'),
|
||||
'dir',
|
||||
);
|
||||
|
||||
const preload = path.join(home, 'force-tty.mjs');
|
||||
fs.writeFileSync(
|
||||
preload,
|
||||
`Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });\n`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(installedPackage, 'src', 'cli', 'index.ts'), 'list'],
|
||||
{
|
||||
cwd: project,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...localeEnv(home),
|
||||
NODE_OPTIONS: `--import ${tsxLoaderUrl()} --import ${pathToFileURL(preload).href}`.trim(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.stderr).toContain('GitNexus 99.0.0 is available (you are running 1.6.10).');
|
||||
expect(result.stdout).not.toContain('99.0.0 is available');
|
||||
});
|
||||
});
|
||||
379
gitnexus/test/integration/mcp/update-notice.test.ts
Normal file
379
gitnexus/test/integration/mcp/update-notice.test.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { createMCPServer } from '../../../src/mcp/server.js';
|
||||
import type { UpdateState } from '../../../src/core/update-check.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
|
||||
interface FakeChecker {
|
||||
evaluate: () => Promise<UpdateState | null>;
|
||||
armUpdateRefreshScheduler: (onState: (state: UpdateState | null) => void) => () => void;
|
||||
}
|
||||
|
||||
interface FakeLogger {
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function checker(initial: UpdateState | null): {
|
||||
service: FakeChecker;
|
||||
publish: (state: UpdateState | null) => void;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
let subscriber: ((state: UpdateState | null) => void) | undefined;
|
||||
const stop = vi.fn();
|
||||
return {
|
||||
service: {
|
||||
evaluate: vi.fn().mockResolvedValue(initial),
|
||||
armUpdateRefreshScheduler: vi.fn((onState) => {
|
||||
subscriber = onState;
|
||||
return stop;
|
||||
}),
|
||||
},
|
||||
publish: (state) => subscriber?.(state),
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
function mockBackend() {
|
||||
return {
|
||||
callTool: vi
|
||||
.fn()
|
||||
.mockImplementation(async (name: string) =>
|
||||
name === 'list_repos'
|
||||
? { repositories: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false } }
|
||||
: { ok: true },
|
||||
),
|
||||
listRepos: vi.fn().mockResolvedValue([]),
|
||||
resolveRepo: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
|
||||
selectToolRepository: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
|
||||
getContext: vi.fn().mockReturnValue(null),
|
||||
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
|
||||
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
|
||||
queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
|
||||
queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function protocolSnapshot(pendingUpdate: boolean): Promise<string> {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const backend = mockBackend();
|
||||
const server = createMCPServer(backend as never);
|
||||
const client = new Client({ name: 'update-snapshot', version: '0.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const log = { info: vi.fn() };
|
||||
const fake = checker(pendingUpdate ? { updateAvailable: true, latestVersion: '99.0.0' } : null);
|
||||
|
||||
try {
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
await startMcpUpdateNotifier(log, async () => fake.service);
|
||||
|
||||
const snapshot = {
|
||||
initialize: {
|
||||
serverInfo: client.getServerVersion(),
|
||||
capabilities: client.getServerCapabilities(),
|
||||
},
|
||||
tools: await client.listTools(),
|
||||
resources: await client.listResources(),
|
||||
resource: await client.readResource({ uri: 'gitnexus://repos' }),
|
||||
prompts: await client.listPrompts(),
|
||||
call: await client.callTool({ name: 'list_repos', arguments: { limit: 5 } }),
|
||||
};
|
||||
return JSON.stringify(snapshot);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock('../../../src/mcp/server.js');
|
||||
vi.doUnmock('../../../src/mcp/local/local-backend.js');
|
||||
vi.doUnmock('../../../src/mcp/repository-policy.js');
|
||||
vi.doUnmock('../../../src/mcp/http-transport.js');
|
||||
vi.doUnmock('../../../src/core/logger.js');
|
||||
vi.doUnmock('../../../src/core/update-check.js');
|
||||
});
|
||||
|
||||
describe('MCP process update notice', () => {
|
||||
it('keeps the full protocol surface byte-identical with and without a cached update', async () => {
|
||||
expect(await protocolSnapshot(true)).toBe(await protocolSnapshot(false));
|
||||
});
|
||||
|
||||
it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER'])(
|
||||
'emits no log and performs no fetch when %s is set',
|
||||
async (name) => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-guard-'));
|
||||
fs.writeFileSync(
|
||||
path.join(home, 'update-check.json'),
|
||||
`${JSON.stringify({
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
registry: 'https://registry.npmjs.org',
|
||||
latestVersion: '99.0.0',
|
||||
})}\n`,
|
||||
);
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const previousCi = process.env.CI;
|
||||
const previousOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
const fetchStub = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchStub);
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
process.env[name] = '1';
|
||||
if (name !== 'CI') delete process.env.CI;
|
||||
const actualChecker = await vi.importActual<
|
||||
typeof import('../../../src/core/update-check.js')
|
||||
>('../../../src/core/update-check.js');
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const log: FakeLogger = { info: vi.fn() };
|
||||
|
||||
try {
|
||||
await startMcpUpdateNotifier(log, async () => actualChecker);
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
expect(fetchStub).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousHome;
|
||||
if (previousCi === undefined) delete process.env.CI;
|
||||
else process.env.CI = previousCi;
|
||||
if (previousOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousOptOut;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('emits one structured stderr logger event per process per newer version', async () => {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const log: FakeLogger = { info: vi.fn() };
|
||||
const first = checker({ updateAvailable: true, latestVersion: '9.0.0' });
|
||||
const second = checker({ updateAvailable: true, latestVersion: '9.0.0' });
|
||||
|
||||
await startMcpUpdateNotifier(log, async () => first.service);
|
||||
first.publish({ updateAvailable: true, latestVersion: '9.0.0' });
|
||||
await startMcpUpdateNotifier(log, async () => second.service);
|
||||
second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
|
||||
second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
|
||||
|
||||
expect(log.info).toHaveBeenCalledTimes(2);
|
||||
expect(log.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ event: 'gitnexus.update_available', latestVersion: '9.0.0' },
|
||||
'GitNexus update available',
|
||||
);
|
||||
expect(log.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ event: 'gitnexus.update_available', latestVersion: '10.0.0' },
|
||||
'GitNexus update available',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses only the logger channel and never writes directly to stdout', async () => {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const stdout = vi.spyOn(process.stdout, 'write');
|
||||
const log: FakeLogger = { info: vi.fn() };
|
||||
const fake = checker({ updateAvailable: true, latestVersion: '11.0.0' });
|
||||
|
||||
await startMcpUpdateNotifier(log, async () => fake.service);
|
||||
|
||||
expect(stdout).not.toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('catch-isolates checker import, evaluation, logger, and scheduler failures', async () => {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
|
||||
await expect(
|
||||
startMcpUpdateNotifier({ info: vi.fn() }, async () => {
|
||||
throw new Error('import failed');
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
|
||||
evaluate: vi.fn().mockRejectedValue(new Error('evaluation failed')),
|
||||
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
||||
})),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
startMcpUpdateNotifier(
|
||||
{
|
||||
info: vi.fn(() => {
|
||||
throw new Error('logger failed');
|
||||
}),
|
||||
},
|
||||
async () => ({
|
||||
evaluate: vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '12.0.0',
|
||||
}),
|
||||
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
|
||||
evaluate: vi.fn().mockResolvedValue(null),
|
||||
armUpdateRefreshScheduler: vi.fn(() => {
|
||||
throw new Error('scheduler failed');
|
||||
}),
|
||||
})),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['stdio', 'hang'],
|
||||
['http', 'fail'],
|
||||
] as const)(
|
||||
'starts %s notifier work only after its startup boundary and never awaits a registry %s',
|
||||
async (transport, registryBehavior) => {
|
||||
const order: string[] = [];
|
||||
let evaluateStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
evaluateStarted = resolve;
|
||||
});
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-'));
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const previousCi = process.env.CI;
|
||||
const previousGitnexusOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
const previousNoUpdate = process.env.NO_UPDATE_NOTIFIER;
|
||||
let releaseFetch: ((response: Response) => void) | undefined;
|
||||
const fetchStub = vi.fn(() =>
|
||||
registryBehavior === 'hang'
|
||||
? new Promise<Response>((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
})
|
||||
: Promise.reject(new Error('registry unavailable')),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchStub);
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
delete process.env.CI;
|
||||
delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
delete process.env.NO_UPDATE_NOTIFIER;
|
||||
const actualChecker = await vi.importActual<
|
||||
typeof import('../../../src/core/update-check.js')
|
||||
>('../../../src/core/update-check.js');
|
||||
|
||||
vi.doMock('../../../src/mcp/server.js', () => ({
|
||||
startMCPServer: vi.fn(async () => {
|
||||
order.push('stdio-connected');
|
||||
}),
|
||||
}));
|
||||
vi.doMock('../../../src/mcp/local/local-backend.js', () => ({
|
||||
LocalBackend: class {
|
||||
async init() {}
|
||||
async listRepos() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../../src/mcp/repository-policy.js', () => ({
|
||||
createMcpRepositoryPolicy: vi.fn(async () => ({
|
||||
scopeBackend: (backend: unknown) => backend,
|
||||
})),
|
||||
}));
|
||||
vi.doMock('../../../src/core/logger.js', () => ({
|
||||
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.doMock('../../../src/mcp/http-transport.js', () => ({
|
||||
resolveAuthToken: vi.fn(),
|
||||
startMcpHttpServer: vi.fn(async () => {
|
||||
order.push('http-listening');
|
||||
}),
|
||||
}));
|
||||
vi.doMock('../../../src/core/update-check.js', () => ({
|
||||
...actualChecker,
|
||||
evaluate: vi.fn(() => {
|
||||
order.push('evaluate');
|
||||
evaluateStarted();
|
||||
return actualChecker.evaluate({ eligible: true });
|
||||
}),
|
||||
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const { mcpCommand } = await import('../../../src/cli/mcp.js');
|
||||
await expect(
|
||||
mcpCommand(transport === 'http' ? { http: true, port: '3000' } : undefined),
|
||||
).resolves.toBeUndefined();
|
||||
await started;
|
||||
await vi.waitFor(() => expect(fetchStub).toHaveBeenCalledOnce());
|
||||
|
||||
expect(order).toEqual([
|
||||
transport === 'http' ? 'http-listening' : 'stdio-connected',
|
||||
'evaluate',
|
||||
]);
|
||||
} finally {
|
||||
if (releaseFetch) {
|
||||
releaseFetch(new Response('', { status: 503 }));
|
||||
await actualChecker.refresh({ eligible: true });
|
||||
}
|
||||
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousHome;
|
||||
if (previousCi === undefined) delete process.env.CI;
|
||||
else process.env.CI = previousCi;
|
||||
if (previousGitnexusOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousGitnexusOptOut;
|
||||
if (previousNoUpdate === undefined) delete process.env.NO_UPDATE_NOTIFIER;
|
||||
else process.env.NO_UPDATE_NOTIFIER = previousNoUpdate;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('wires the scheduler stop function into process exit', async () => {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const fake = checker(null);
|
||||
const before = new Set(process.listeners('exit'));
|
||||
|
||||
await startMcpUpdateNotifier({ info: vi.fn() }, async () => fake.service);
|
||||
const added = process.listeners('exit').filter((listener) => !before.has(listener));
|
||||
expect(added).toHaveLength(1);
|
||||
|
||||
added[0](0);
|
||||
expect(fake.stop).toHaveBeenCalledOnce();
|
||||
process.removeListener('exit', added[0]);
|
||||
});
|
||||
|
||||
it('uses an unrefd scheduler timer so an opted-out MCP process can exit', async () => {
|
||||
const script = [
|
||||
"import { startMcpUpdateNotifier } from './dist/cli/mcp.js';",
|
||||
'await startMcpUpdateNotifier({ info() {} });',
|
||||
].join('\n');
|
||||
const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, GITNEXUS_NO_UPDATE_NOTIFIER: '1', NODE_OPTIONS: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const result = await new Promise<{ code: number | null; stderr: string }>((resolve, reject) => {
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('MCP notifier scheduler kept the child process alive'));
|
||||
}, 2_000);
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, stderr });
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toEqual({ code: 0, stderr: '' });
|
||||
});
|
||||
});
|
||||
63
gitnexus/test/unit/cli-command-banner.test.ts
Normal file
63
gitnexus/test/unit/cli-command-banner.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
commandBannerTitle,
|
||||
commandDisplayName,
|
||||
formatCommandBanner,
|
||||
writeCommandBanner,
|
||||
} from '../../src/cli/command-banner.js';
|
||||
|
||||
function commandAt(path: string[]): Command {
|
||||
let current = new Command('gitnexus');
|
||||
for (const name of path) {
|
||||
current = current.command(name);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
describe('commandDisplayName', () => {
|
||||
it('uses Analyzer for analyze and MCP for mcp', () => {
|
||||
expect(commandDisplayName('analyze')).toBe('Analyzer');
|
||||
expect(commandDisplayName('mcp')).toBe('MCP');
|
||||
});
|
||||
|
||||
it('title-cases hyphenated command names', () => {
|
||||
expect(commandDisplayName('detect-changes')).toBe('Detect Changes');
|
||||
expect(commandDisplayName('eval-server')).toBe('Eval Server');
|
||||
expect(commandDisplayName('list')).toBe('List');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandBannerTitle', () => {
|
||||
it('joins nested group commands', () => {
|
||||
expect(commandBannerTitle(commandAt(['group', 'list']))).toBe('Group List');
|
||||
expect(commandBannerTitle(commandAt(['embeddings', 'install']))).toBe('Embeddings Install');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCommandBanner', () => {
|
||||
it('puts the version in the title', () => {
|
||||
expect(formatCommandBanner('Analyzer', '1.6.10')).toBe('\n GitNexus Analyzer (1.6.10)\n');
|
||||
expect(formatCommandBanner('Query', '1.6.10')).toBe('\n GitNexus Query (1.6.10)\n');
|
||||
});
|
||||
|
||||
it('keeps the unversioned title when version is missing', () => {
|
||||
expect(formatCommandBanner('Analyzer', '')).toBe('\n GitNexus Analyzer\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeCommandBanner', () => {
|
||||
it('writes the title for a normal command', () => {
|
||||
const write = vi.fn();
|
||||
writeCommandBanner(commandAt(['status']), { write, version: '1.6.10' });
|
||||
expect(write).toHaveBeenCalledWith('\n GitNexus Status (1.6.10)\n');
|
||||
});
|
||||
|
||||
it('skips hidden refresh and help', () => {
|
||||
const write = vi.fn();
|
||||
writeCommandBanner(commandAt(['__update-check']), { write, version: '1.6.10' });
|
||||
writeCommandBanner(commandAt(['help']), { write, version: '1.6.10' });
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,7 @@ const allHelpCommands = [
|
|||
['list'],
|
||||
['status'],
|
||||
['doctor'],
|
||||
['update'],
|
||||
['clean'],
|
||||
['remove'],
|
||||
['wiki'],
|
||||
|
|
|
|||
333
gitnexus/test/unit/cli-update-notice.test.ts
Normal file
333
gitnexus/test/unit/cli-update-notice.test.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
runCliUpdateNotice,
|
||||
type CliUpdateNoticeDependencies,
|
||||
} from '../../src/cli/update-notice.js';
|
||||
import { cachedUpdateDoctorLine } from '../../src/cli/doctor.js';
|
||||
import { setCliLanguage } from '../../src/cli/i18n/index.js';
|
||||
import { readProcessStartTime } from '../../src/utils/process-identity.js';
|
||||
|
||||
const tempHomes: string[] = [];
|
||||
|
||||
function dependencies(
|
||||
overrides: Partial<CliUpdateNoticeDependencies> = {},
|
||||
): CliUpdateNoticeDependencies {
|
||||
// Isolate the refresh-lock probe from the real GITNEXUS_HOME.
|
||||
const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'update-notice-test-'));
|
||||
tempHomes.push(gitnexusHome);
|
||||
return {
|
||||
argv: ['/usr/bin/node', '/prefix/lib/node_modules/gitnexus/dist/cli/index.js', 'status'],
|
||||
env: { GITNEXUS_HOME: gitnexusHome },
|
||||
installedVersion: '1.6.10',
|
||||
isTTY: true,
|
||||
eligible: true,
|
||||
now: 2_000,
|
||||
readCache: vi.fn(() => ({
|
||||
lastCheckAt: 1_500,
|
||||
latestVersion: '1.7.0',
|
||||
stale: false,
|
||||
})),
|
||||
writeStderr: vi.fn(),
|
||||
spawn: vi.fn(() => ({ unref: vi.fn() })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CLI cached update notice', () => {
|
||||
beforeEach(() => {
|
||||
setCliLanguage('en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCliLanguage(null);
|
||||
for (const dir of tempHomes.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes exactly one localized line to stderr for a TTY and keeps stdout untouched', () => {
|
||||
const writeStderr = vi.fn();
|
||||
const stdoutWrite = vi.spyOn(process.stdout, 'write');
|
||||
try {
|
||||
const deps = dependencies({ writeStderr });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(writeStderr).toHaveBeenCalledOnce();
|
||||
expect(writeStderr).toHaveBeenCalledWith(
|
||||
'GitNexus 1.7.0 is available (you are running 1.6.10).\n',
|
||||
);
|
||||
expect(stdoutWrite).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stdoutWrite.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('displays stale valid state and starts one detached, ignored, unrefd refresh child', () => {
|
||||
const unref = vi.fn();
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => ({
|
||||
lastCheckAt: 0,
|
||||
latestVersion: '1.7.0',
|
||||
stale: true,
|
||||
})),
|
||||
spawn: vi.fn(() => ({ unref })),
|
||||
});
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.writeStderr).toHaveBeenCalledOnce();
|
||||
expect(deps.spawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
['/prefix/lib/node_modules/gitnexus/dist/cli/index.js', '__update-check'],
|
||||
{ detached: true, stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
expect(unref).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refreshes a stale unknown/current cache without printing a notice', () => {
|
||||
for (const latestVersion of [undefined, '1.6.10', '1.5.0']) {
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion, stale: true })),
|
||||
});
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('spawns one refresh child when the cache is missing entirely', () => {
|
||||
const unref = vi.fn();
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => null),
|
||||
spawn: vi.fn(() => ({ unref })),
|
||||
});
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).toHaveBeenCalledOnce();
|
||||
expect(deps.spawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
['/prefix/lib/node_modules/gitnexus/dist/cli/index.js', '__update-check'],
|
||||
{ detached: true, stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
expect(unref).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('skips the refresh spawn when a live process holds the refresh lock', () => {
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion: '1.7.0', stale: true })),
|
||||
});
|
||||
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
`${JSON.stringify({ pid: process.pid, ownerId: 'test', processStartTime: readProcessStartTime(process.pid), hostname: os.hostname() })}\n`,
|
||||
);
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
// The live holder's refresh covers this invocation.
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
// Display from the stale-but-valid cache is unaffected.
|
||||
expect(deps.writeStderr).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('spawns when a live PID is reuse with a different process start time', () => {
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion: '1.7.0', stale: true })),
|
||||
});
|
||||
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
`${JSON.stringify({ pid: process.pid, ownerId: 'reused', processStartTime: 'not-this-process', hostname: os.hostname() })}\n`,
|
||||
);
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.spawn).toHaveBeenCalledOnce();
|
||||
expect(deps.writeStderr).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('spawns when the lock owner is dead so the child can reclaim it', () => {
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => null),
|
||||
});
|
||||
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
`${JSON.stringify({ pid: 99999999, ownerId: 'stale', processStartTime: 'x', hostname: os.hostname() })}\n`,
|
||||
);
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.spawn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does nothing for non-TTY stderr, including no cache read or child spawn', () => {
|
||||
const deps = dependencies({ isTTY: false });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.readCache).not.toHaveBeenCalled();
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER'])(
|
||||
'does nothing when %s is truthy',
|
||||
(name) => {
|
||||
const deps = dependencies({ env: { [name]: '1' } });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.readCache).not.toHaveBeenCalled();
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('uses truthy-env semantics rather than treating "0" as opted out', () => {
|
||||
const deps = dependencies({
|
||||
env: {
|
||||
CI: '0',
|
||||
GITNEXUS_NO_UPDATE_NOTIFIER: 'false',
|
||||
NO_UPDATE_NOTIFIER: 'off',
|
||||
},
|
||||
});
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.writeStderr).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does nothing for ineligible dev and Docker contexts', () => {
|
||||
for (const env of [{}, { GITNEXUS_NO_UPDATE_NOTIFIER: '1' }]) {
|
||||
const deps = dependencies({ eligible: false, env });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.readCache).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['augment'],
|
||||
['--help'],
|
||||
['status', '--help'],
|
||||
['--version'],
|
||||
['mcp'],
|
||||
['serve'],
|
||||
['eval-server'],
|
||||
['update'],
|
||||
['__update-check'],
|
||||
])('excludes command identity %j from display and refresh', (...args) => {
|
||||
const deps = dependencies({ argv: ['/usr/bin/node', '/entry.js', ...args] });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.readCache).not.toHaveBeenCalled();
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows cache and spawn failures before Commander parsing', () => {
|
||||
expect(() =>
|
||||
runCliUpdateNotice(
|
||||
dependencies({
|
||||
readCache: () => {
|
||||
throw new Error('cache unavailable');
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
runCliUpdateNotice(
|
||||
dependencies({
|
||||
readCache: () => ({ lastCheckAt: 0, stale: true }),
|
||||
spawn: () => {
|
||||
throw new Error('spawn unavailable');
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('doctor cached update line', () => {
|
||||
beforeEach(() => {
|
||||
setCliLanguage('en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCliLanguage(null);
|
||||
});
|
||||
|
||||
it('shows installed and latest versions from cache without triggering refresh', () => {
|
||||
const readCache = vi.fn(() => ({
|
||||
lastCheckAt: 0,
|
||||
latestVersion: '1.7.0',
|
||||
stale: true,
|
||||
}));
|
||||
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: true,
|
||||
env: {},
|
||||
readCache,
|
||||
}),
|
||||
).toBe('GitNexus 1.7.0 is available (you are running 1.6.10).');
|
||||
expect(readCache).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('is silent for current, invalid, opted-out, and ineligible states', () => {
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: true,
|
||||
env: {},
|
||||
readCache: () => ({ lastCheckAt: 0, latestVersion: '1.6.10', stale: false }),
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: false,
|
||||
env: {},
|
||||
readCache: vi.fn(),
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: true,
|
||||
env: { CI: '1' },
|
||||
readCache: vi.fn(),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['v1.7.0', '1.7.0-rc.1'])(
|
||||
'is silent for non-strict latestVersion %s',
|
||||
(latestVersion) => {
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: true,
|
||||
env: {},
|
||||
readCache: () => ({ lastCheckAt: 0, latestVersion, stale: false }),
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
174
gitnexus/test/unit/cli-update.test.ts
Normal file
174
gitnexus/test/unit/cli-update.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { updateCommand, updateInstallArgs, updateInstallCommand } from '../../src/cli/update.js';
|
||||
import { setCliLanguage } from '../../src/cli/i18n/index.js';
|
||||
|
||||
describe('gitnexus update', () => {
|
||||
afterEach(() => {
|
||||
setCliLanguage(null);
|
||||
});
|
||||
|
||||
it('pins npm i -g to a stable x.y.z spec', () => {
|
||||
expect(updateInstallArgs('1.7.0')).toEqual(['i', '-g', 'gitnexus@1.7.0']);
|
||||
expect(updateInstallCommand('1.7.0')).toBe('npm i -g gitnexus@1.7.0');
|
||||
});
|
||||
|
||||
it('installs the discovered version when a newer release exists', async () => {
|
||||
setCliLanguage('en');
|
||||
const refresh = vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const runInstall = vi.fn().mockResolvedValue(0);
|
||||
const writeStdout = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh,
|
||||
runInstall,
|
||||
writeStdout,
|
||||
setExitCode,
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledWith({
|
||||
eligible: true,
|
||||
ignoreOptOut: true,
|
||||
installedVersion: '1.6.10',
|
||||
});
|
||||
expect(runInstall).toHaveBeenCalledWith('1.7.0');
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'GitNexus 1.7.0 is available (you are running 1.6.10).',
|
||||
);
|
||||
expect(writeStdout).toHaveBeenCalledWith('Installing with npm i -g gitnexus@1.7.0…');
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'Installed gitnexus@1.7.0. Restart long-running mcp/serve processes.',
|
||||
);
|
||||
expect(setExitCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not install when already current', async () => {
|
||||
setCliLanguage('en');
|
||||
const runInstall = vi.fn();
|
||||
const writeStdout = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.7.0',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: false,
|
||||
latestVersion: '1.7.0',
|
||||
}),
|
||||
runInstall,
|
||||
writeStdout,
|
||||
});
|
||||
|
||||
expect(runInstall).not.toHaveBeenCalled();
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'GitNexus 1.7.0 is current or newer than the latest stable version.',
|
||||
);
|
||||
expect(writeStdout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install when newer than the registry latest', async () => {
|
||||
setCliLanguage('en');
|
||||
const runInstall = vi.fn();
|
||||
const writeStdout = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.7.0',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: false,
|
||||
latestVersion: '1.6.10',
|
||||
}),
|
||||
runInstall,
|
||||
writeStdout,
|
||||
});
|
||||
|
||||
expect(runInstall).not.toHaveBeenCalled();
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'GitNexus 1.7.0 is current or newer than the latest stable version.',
|
||||
);
|
||||
expect(writeStdout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install a non x.y.z spec', async () => {
|
||||
setCliLanguage('en');
|
||||
const runInstall = vi.fn();
|
||||
const writeStdout = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '1.7.0-rc.1',
|
||||
}),
|
||||
runInstall,
|
||||
writeStdout,
|
||||
});
|
||||
|
||||
expect(runInstall).not.toHaveBeenCalled();
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'Could not check for updates (offline, private registry, or the check failed open).',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not install when the check fails open', async () => {
|
||||
setCliLanguage('en');
|
||||
const runInstall = vi.fn();
|
||||
const writeStdout = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh: vi.fn().mockResolvedValue(null),
|
||||
runInstall,
|
||||
writeStdout,
|
||||
});
|
||||
|
||||
expect(runInstall).not.toHaveBeenCalled();
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'Could not check for updates (offline, private registry, or the check failed open).',
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards a non-zero npm exit code', async () => {
|
||||
setCliLanguage('en');
|
||||
const writeStdout = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '1.7.0',
|
||||
}),
|
||||
runInstall: vi.fn().mockResolvedValue(7),
|
||||
writeStdout,
|
||||
setExitCode,
|
||||
});
|
||||
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'npm install failed. You can retry: npm i -g gitnexus@1.7.0',
|
||||
);
|
||||
expect(setExitCode).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('fails open when npm cannot be spawned', async () => {
|
||||
setCliLanguage('en');
|
||||
const writeStdout = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '1.7.0',
|
||||
}),
|
||||
runInstall: vi.fn().mockRejectedValue(new Error('spawn npm ENOENT')),
|
||||
writeStdout,
|
||||
setExitCode,
|
||||
});
|
||||
|
||||
expect(writeStdout).toHaveBeenCalledWith('Could not run npm: spawn npm ENOENT');
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
210
gitnexus/test/unit/server-info.test.ts
Normal file
210
gitnexus/test/unit/server-info.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
bindServeUpdateControllerLifecycle,
|
||||
buildServerInfo,
|
||||
createServeUpdateController,
|
||||
} from '../../src/server/update-controller.js';
|
||||
import { evaluate } from '../../src/core/update-check.js';
|
||||
import type { UpdateState } from '../../src/core/update-check.js';
|
||||
|
||||
const baseKeys = ['version', 'launchContext', 'nodeVersion'];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('GET /api/info update state', () => {
|
||||
it('keeps the existing three fields unchanged when no update is available', () => {
|
||||
const response = buildServerInfo(null);
|
||||
|
||||
expect(Object.keys(response)).toEqual(baseKeys);
|
||||
expect(response).toEqual({
|
||||
version: '1.6.10',
|
||||
launchContext: expect.stringMatching(/^(npx|global|local)$/),
|
||||
nodeVersion: process.version,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds optional update fields only for an available version', () => {
|
||||
expect(buildServerInfo({ updateAvailable: true, latestVersion: '9.8.7' })).toEqual({
|
||||
version: '1.6.10',
|
||||
launchContext: expect.stringMatching(/^(npx|global|local)$/),
|
||||
nodeVersion: process.version,
|
||||
latestVersion: '9.8.7',
|
||||
updateAvailable: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
Object.keys(buildServerInfo({ updateAvailable: false, latestVersion: '1.6.10' })),
|
||||
).toEqual(baseKeys);
|
||||
});
|
||||
|
||||
it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
|
||||
'omits update fields after start when %s is set',
|
||||
async (name) => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-serve-update-'));
|
||||
tempDirs.push(home);
|
||||
vi.stubEnv('GITNEXUS_HOME', home);
|
||||
vi.stubEnv('npm_config_registry', 'https://registry.npmjs.org');
|
||||
vi.stubEnv(name, '1');
|
||||
await fs.writeFile(
|
||||
path.join(home, 'update-check.json'),
|
||||
JSON.stringify({
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
registry: 'https://registry.npmjs.org',
|
||||
latestVersion: '9.9.9',
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const evaluateSpy = vi.fn((options?: { refreshIfStale?: boolean }) =>
|
||||
evaluate({ ...options, eligible: true }),
|
||||
);
|
||||
const controller = createServeUpdateController({
|
||||
evaluate: evaluateSpy,
|
||||
armScheduler: vi.fn(() => vi.fn()),
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
|
||||
expect(evaluateSpy).toHaveBeenCalledWith({ refreshIfStale: false });
|
||||
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('omits update fields after start for an ineligible install', async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-serve-update-'));
|
||||
tempDirs.push(home);
|
||||
vi.stubEnv('GITNEXUS_HOME', home);
|
||||
vi.stubEnv('npm_config_registry', 'https://registry.npmjs.org');
|
||||
vi.stubEnv('CI', '');
|
||||
await fs.writeFile(
|
||||
path.join(home, 'update-check.json'),
|
||||
JSON.stringify({
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
registry: 'https://registry.npmjs.org',
|
||||
latestVersion: '9.9.9',
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const evaluateSpy = vi.fn((options?: { refreshIfStale?: boolean }) =>
|
||||
evaluate({ ...options, eligible: false }),
|
||||
);
|
||||
const controller = createServeUpdateController({
|
||||
evaluate: evaluateSpy,
|
||||
armScheduler: vi.fn(() => vi.fn()),
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
|
||||
expect(evaluateSpy).toHaveBeenCalledWith({ refreshIfStale: false });
|
||||
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays assignable to the web client ServerInfo contract', () => {
|
||||
// Mirrors gitnexus-web/src/services/backend-client.ts without creating a
|
||||
// cross-package import that would couple either package's build graph.
|
||||
interface WebClientServerInfo {
|
||||
version: string;
|
||||
launchContext: 'npx' | 'global' | 'local';
|
||||
nodeVersion: string;
|
||||
latestVersion?: string;
|
||||
updateAvailable?: boolean;
|
||||
}
|
||||
|
||||
const response: WebClientServerInfo = buildServerInfo({
|
||||
updateAvailable: true,
|
||||
latestVersion: '9.8.7',
|
||||
});
|
||||
expect(response.updateAvailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serve update controller lifecycle', () => {
|
||||
it('starts only after successful listen and stops whenever the server closes', async () => {
|
||||
const server = new EventEmitter();
|
||||
const controller = {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
snapshot: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
bindServeUpdateControllerLifecycle(server, controller);
|
||||
|
||||
expect(controller.start).not.toHaveBeenCalled();
|
||||
|
||||
server.emit('listening');
|
||||
expect(controller.start).toHaveBeenCalledOnce();
|
||||
|
||||
server.emit('close');
|
||||
expect(controller.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('evaluates once before arming the scheduler and serves memory-only snapshots', async () => {
|
||||
const calls: string[] = [];
|
||||
let publish: ((state: UpdateState | null) => void) | undefined;
|
||||
const evaluate = vi.fn(async () => {
|
||||
calls.push('evaluate');
|
||||
return { updateAvailable: true, latestVersion: '2.0.0' };
|
||||
});
|
||||
const armScheduler = vi.fn((onState) => {
|
||||
calls.push('arm');
|
||||
publish = onState;
|
||||
return vi.fn();
|
||||
});
|
||||
const controller = createServeUpdateController({ evaluate, armScheduler });
|
||||
|
||||
expect(controller.snapshot()).toBeNull();
|
||||
await controller.start();
|
||||
expect(calls).toEqual(['evaluate', 'arm']);
|
||||
expect(controller.snapshot()).toEqual({
|
||||
updateAvailable: true,
|
||||
latestVersion: '2.0.0',
|
||||
});
|
||||
|
||||
publish?.({ updateAvailable: true, latestVersion: '2.1.0' });
|
||||
expect(controller.snapshot()).toEqual({
|
||||
updateAvailable: true,
|
||||
latestVersion: '2.1.0',
|
||||
});
|
||||
expect(evaluate).toHaveBeenCalledTimes(1);
|
||||
expect(evaluate).toHaveBeenCalledWith({ refreshIfStale: false });
|
||||
});
|
||||
|
||||
it('fails open and still arms the long-lived refresh scheduler', async () => {
|
||||
const armScheduler = vi.fn(() => vi.fn());
|
||||
const controller = createServeUpdateController({
|
||||
evaluate: vi.fn().mockRejectedValue(new Error('checker failed')),
|
||||
armScheduler,
|
||||
});
|
||||
|
||||
await expect(controller.start()).resolves.toBeUndefined();
|
||||
expect(controller.snapshot()).toBeNull();
|
||||
expect(armScheduler).toHaveBeenCalledOnce();
|
||||
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
|
||||
});
|
||||
|
||||
it('stops the scheduler on shutdown and is idempotent', async () => {
|
||||
const stop = vi.fn();
|
||||
const controller = createServeUpdateController({
|
||||
evaluate: vi.fn().mockResolvedValue(null),
|
||||
armScheduler: vi.fn(() => stop),
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
controller.stop();
|
||||
controller.stop();
|
||||
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
539
gitnexus/test/unit/update-check.test.ts
Normal file
539
gitnexus/test/unit/update-check.test.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { updateEligibleInstall } from '../../src/core/install-context.js';
|
||||
import { isNewerVersion } from '../../src/core/update-cache.js';
|
||||
import { armUpdateRefreshScheduler, evaluate, refresh } from '../../src/core/update-check.js';
|
||||
import { acquireFileLock } from '../../src/storage/file-lock.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1_000;
|
||||
const NOW = Date.parse('2026-09-04T05:00:00.000Z');
|
||||
const REGISTRY = 'https://registry.npmjs.org';
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function tempHome(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-check-'));
|
||||
tempDirs.push(dir);
|
||||
vi.stubEnv('GITNEXUS_HOME', dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cachePath(home: string): string {
|
||||
return path.join(home, 'update-check.json');
|
||||
}
|
||||
|
||||
async function writeCache(
|
||||
home: string,
|
||||
body: { lastCheckAt: string; registry: string; latestVersion?: string },
|
||||
): Promise<void> {
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
await fs.writeFile(cachePath(home), JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function readCache(home: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await fs.readFile(cachePath(home), 'utf8')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function registryResponse(version = '1.7.0'): Response {
|
||||
return new Response(JSON.stringify({ version }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('npm_config_registry', REGISTRY);
|
||||
// GitHub Actions sets CI=true; the checker treats that as a hard opt-out.
|
||||
vi.stubEnv('CI', '');
|
||||
vi.stubEnv('GITNEXUS_NO_UPDATE_NOTIFIER', '');
|
||||
vi.stubEnv('NO_UPDATE_NOTIFIER', '');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('update check cache and versions', () => {
|
||||
it('returns a newer version from a fresh cache', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
});
|
||||
|
||||
it('returns stale valid state and starts one stale-while-revalidate refresh', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(registryResponse('1.8.0'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.8.0'));
|
||||
});
|
||||
|
||||
it.each(['1.6.10', '1.6.9'])('is silent for fresh equal/lower latest %s', async (latest) => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: latest,
|
||||
});
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toEqual({ updateAvailable: false, latestVersion: latest });
|
||||
});
|
||||
|
||||
it('treats corrupt JSON as a miss and refresh overwrites it', async () => {
|
||||
const home = await tempHome();
|
||||
await fs.writeFile(cachePath(home), '{broken');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse()));
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toBeNull();
|
||||
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.7.0'));
|
||||
});
|
||||
|
||||
it('never propagates a latestVersion with a non-strict format', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: 'v1.7.0',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toBeNull();
|
||||
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBeUndefined());
|
||||
});
|
||||
|
||||
it('treats a future timestamp as stale', async () => {
|
||||
const home = await tempHome();
|
||||
// Wall-clock-future lastCheckAt is monotonic poison; NOW+1ms is still in
|
||||
// the past on a later wall clock and would not be overwritten.
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.6.0',
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
||||
await vi.waitFor(async () =>
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a negative entry after failure and suppresses retries inside the TTL', async () => {
|
||||
const home = await tempHome();
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
});
|
||||
await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW + 1 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves a known latestVersion when a later refresh fails', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
refresh({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toBeNull();
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
await expect(
|
||||
evaluate({
|
||||
eligible: true,
|
||||
installedVersion: '1.6.10',
|
||||
now: NOW + 1,
|
||||
refreshIfStale: false,
|
||||
}),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips fetch when another process holds the refresh lock', async () => {
|
||||
const home = await tempHome();
|
||||
const release = await acquireFileLock(path.join(home, 'update-check.lock'));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns stale cache state without fetching when refreshIfStale is false', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
evaluate({
|
||||
eligible: true,
|
||||
installedVersion: '1.6.10',
|
||||
now: NOW,
|
||||
refreshIfStale: false,
|
||||
}),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces a future-dated cache timestamp instead of preserving it', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: '2099-01-01T00:00:00.000Z',
|
||||
registry: REGISTRY,
|
||||
latestVersion: '9.9.9',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse('1.8.0')));
|
||||
|
||||
await refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
||||
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.8.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('dedupes concurrent refresh() callers onto one fetch', async () => {
|
||||
await tempHome();
|
||||
let resolveFetch!: (response: Response) => void;
|
||||
const fetchMock = vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const first = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
||||
const second = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
resolveFetch(registryResponse('1.8.0'));
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ updateAvailable: true, latestVersion: '1.8.0' },
|
||||
{ updateAvailable: true, latestVersion: '1.8.0' },
|
||||
]);
|
||||
expect(first).toBe(second);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let a late failed attempt overwrite a newer success', async () => {
|
||||
const home = await tempHome();
|
||||
let rejectFetch!: (error: Error) => void;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
rejectFetch = reject;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Relative to the real clock: publishMonotonically only preserves a newer
|
||||
// on-disk timestamp when it is not in the future (`currentAt <= Date.now()`).
|
||||
const wall = Date.now();
|
||||
const olderAttempt = wall - 10_000;
|
||||
const newerSuccess = wall - 1;
|
||||
const pending = refresh({ eligible: true, now: olderAttempt });
|
||||
await vi.waitFor(() => expect(rejectFetch).toBeTypeOf('function'));
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(newerSuccess).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.8.0',
|
||||
});
|
||||
rejectFetch(new Error('late failure'));
|
||||
await pending;
|
||||
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(newerSuccess).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.8.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a cache from another registry as a miss', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: 'https://registry.example.test/custom',
|
||||
latestVersion: '9.0.0',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
|
||||
await expect(
|
||||
evaluate({
|
||||
eligible: true,
|
||||
installedVersion: '1.6.10',
|
||||
now: NOW,
|
||||
refreshIfStale: false,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('handles prerelease and lower versions with the strict comparator', () => {
|
||||
expect(isNewerVersion('1.7.0-rc.1', '1.6.10')).toBe(false);
|
||||
expect(isNewerVersion('1.7.0', '1.6.10')).toBe(false);
|
||||
expect(isNewerVersion('1.6.10', '1.6.9')).toBe(false);
|
||||
expect(isNewerVersion('1.6.10', '1.7.0')).toBe(true);
|
||||
expect(isNewerVersion('1.0.9007199254740992', '1.0.9007199254740993')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hardened registry request', () => {
|
||||
it('strips registry userinfo and sends no credentials', async () => {
|
||||
await tempHome();
|
||||
vi.stubEnv('npm_config_registry', 'https://user:secret@registry.example.test/custom/');
|
||||
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://registry.example.test/custom/gitnexus/latest');
|
||||
expect(url).not.toContain('user');
|
||||
expect(new Headers(init.headers).has('authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a redirect to loopback/private addresses', async () => {
|
||||
const home = await tempHome();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/latest' } }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(await readCache(home)).not.toHaveProperty('latestVersion');
|
||||
});
|
||||
|
||||
it('caps an oversized response body', async () => {
|
||||
const home = await tempHome();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response('x'.repeat(70_000), { status: 200 })),
|
||||
);
|
||||
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
|
||||
expect(await readCache(home)).not.toHaveProperty('latestVersion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guards and scheduler', () => {
|
||||
it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
|
||||
'%s skips cache refresh and network with truthy-env semantics',
|
||||
async (name) => {
|
||||
await tempHome();
|
||||
vi.stubEnv(name, 'yes');
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('ignoreOptOut still fetches when CI/opt-out env is set', async () => {
|
||||
await tempHome();
|
||||
vi.stubEnv('CI', 'true');
|
||||
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
refresh({
|
||||
eligible: true,
|
||||
ignoreOptOut: true,
|
||||
installedVersion: '1.6.10',
|
||||
now: NOW,
|
||||
}),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('an ineligible install skips network', async () => {
|
||||
await tempHome();
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(evaluate({ eligible: false, now: NOW })).resolves.toBeNull();
|
||||
await refresh({ eligible: false, now: NOW });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an unwritable cache path fails open', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-unwritable-'));
|
||||
tempDirs.push(root);
|
||||
const blocker = path.join(root, 'not-a-directory');
|
||||
await fs.writeFile(blocker, 'x');
|
||||
vi.stubEnv('GITNEXUS_HOME', path.join(blocker, 'child'));
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
|
||||
await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
|
||||
await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('arms an unrefd, clearable, single-flight scheduler', async () => {
|
||||
await tempHome();
|
||||
let resolveFetch!: (response: Response) => void;
|
||||
const fetchMock = vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onState = vi.fn();
|
||||
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
|
||||
const clearA = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
||||
const clearB = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const immediateHandles = timeoutSpy.mock.results
|
||||
.map((result) => result.value as NodeJS.Timeout)
|
||||
.filter((handle) => typeof handle?.hasRef === 'function');
|
||||
expect(immediateHandles.some((handle) => !handle.hasRef())).toBe(true);
|
||||
resolveFetch(registryResponse());
|
||||
await vi.waitFor(() => expect(onState).toHaveBeenCalled());
|
||||
clearA();
|
||||
clearB();
|
||||
});
|
||||
|
||||
it('backs off when refresh is lock-busy instead of spinning at 1ms', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const release = await acquireFileLock(path.join(home, 'update-check.lock'));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
const onState = vi.fn();
|
||||
try {
|
||||
const clear = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
||||
await vi.waitFor(() => expect(onState).toHaveBeenCalled());
|
||||
const retryDelay = timeoutSpy.mock.calls
|
||||
.map(([, delay]) => delay)
|
||||
.find(
|
||||
(delay): delay is number =>
|
||||
typeof delay === 'number' && delay >= 30_000 && delay <= 60_000,
|
||||
);
|
||||
expect(retryDelay).toBeDefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
clear();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEligibleInstall', () => {
|
||||
async function entry(relative: string): Promise<{ root: string; entry: string }> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-install-context-'));
|
||||
tempDirs.push(root);
|
||||
const entryPath = path.join(root, relative);
|
||||
await fs.mkdir(path.dirname(entryPath), { recursive: true });
|
||||
await fs.writeFile(entryPath, '');
|
||||
return { root, entry: entryPath };
|
||||
}
|
||||
|
||||
it('classifies global, local, ephemeral, dev, and Docker layouts', async () => {
|
||||
const global = await entry('prefix/lib/node_modules/gitnexus/dist/cli/index.js');
|
||||
expect(
|
||||
await updateEligibleInstall(global.entry, {
|
||||
npm_config_prefix: path.join(global.root, 'prefix'),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const local = await entry('project/node_modules/gitnexus/dist/cli/index.js');
|
||||
expect(await updateEligibleInstall(local.entry, {})).toBe(true);
|
||||
|
||||
const namedDlx = await entry('dlx/project/node_modules/gitnexus/dist/cli/index.js');
|
||||
expect(await updateEligibleInstall(namedDlx.entry, {})).toBe(true);
|
||||
|
||||
for (const relative of [
|
||||
'cache/_npx/123/node_modules/gitnexus/dist/cli/index.js',
|
||||
'cache/_cacache/tmp/node_modules/gitnexus/dist/cli/index.js',
|
||||
'pnpm/dlx/123/node_modules/gitnexus/dist/cli/index.js',
|
||||
'.bun/install/cache/gitnexus@1.0.0/node_modules/gitnexus/dist/cli/index.js',
|
||||
]) {
|
||||
const ephemeral = await entry(relative);
|
||||
expect(
|
||||
await updateEligibleInstall(ephemeral.entry, {
|
||||
npm_config_cache: path.join(ephemeral.root, 'cache'),
|
||||
npm_execpath: path.join(ephemeral.root, relative),
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
const dev = await entry('checkout/gitnexus/src/cli/index.ts');
|
||||
await fs.mkdir(path.join(dev.root, 'checkout', '.git'));
|
||||
expect(await updateEligibleInstall(dev.entry, {})).toBe(false);
|
||||
|
||||
const docker = await entry('usr/local/lib/node_modules/gitnexus/dist/cli/index.js');
|
||||
expect(
|
||||
await updateEligibleInstall(docker.entry, {
|
||||
npm_config_prefix: path.join(docker.root, 'usr/local'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue