Merge branch 'abhigyanpatwari:main' into main

This commit is contained in:
Иван 2026-09-05 11:44:29 +03:00 • committed by GitHub
commit 98f87ed30c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 5052 additions and 460 deletions

View file

@ -6,7 +6,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.10",
"version": "1.6.11",
"source": {
"source": "local",
"path": "./gitnexus-claude-plugin"

View file

@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.10",
"version": "1.6.11",
"source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}

View file

@ -11,12 +11,19 @@ runs:
cache: npm
cache-dependency-path: gitnexus-web/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
shell: bash
working-directory: gitnexus-shared
- name: Install web dependencies
run: npm ci
shell: bash
working-directory: gitnexus-web
env:
# Browsers are installed explicitly by e2e. Typecheck only needs types.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
# Compile shared with the web package's TypeScript 5. Do not npm-ci
# gitnexus-shared (TypeScript 7 optional-platform install, ~7 minutes).
- name: Build gitnexus-shared
# node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc
# (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell).
run: node ../gitnexus-web/node_modules/typescript/lib/tsc.js
shell: bash
working-directory: gitnexus-shared

View file

@ -6,6 +6,13 @@ inputs:
description: Whether to run npm run build after install
required: false
default: 'false'
lifecycle-scripts:
description: >
Run npm lifecycle scripts (prepare/postinstall) during gitnexus npm ci.
Typecheck-only and pack-only jobs should set this to false: they do not
need dist/ or native grammar builds.
required: false
default: 'true'
runs:
using: composite
@ -16,16 +23,30 @@ runs:
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
shell: bash
working-directory: gitnexus-shared
# Do not npm-ci gitnexus-shared. Its TypeScript 7 install is a 7-minute
# stall (optional platform packages) and is not in the CLI npm cache.
# prepare/build.js compiles shared with gitnexus's tsc; typecheck does
# the same below after an ignore-scripts install.
- name: Install dependencies
if: ${{ inputs.lifecycle-scripts != 'false' }}
run: npm ci
shell: bash
working-directory: gitnexus
- name: Install dependencies
if: ${{ inputs.lifecycle-scripts == 'false' }}
run: npm ci --ignore-scripts
shell: bash
working-directory: gitnexus
- name: Build gitnexus-shared
if: ${{ inputs.lifecycle-scripts == 'false' }}
# node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc
# (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell).
run: node ../gitnexus/node_modules/typescript/lib/tsc.js
shell: bash
working-directory: gitnexus-shared
- name: Build
if: ${{ inputs.build == 'true' }}
run: npm run build

View file

@ -9,7 +9,9 @@ permissions:
jobs:
format:
runs-on: ubuntu-latest
timeout-minutes: 5
# Same root npm ci as lint. A cold install already took 4m19s here and
# canceled prettier at the 5-minute job cap; lint needed 7m41s the same run.
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
@ -19,7 +21,7 @@ jobs:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm ci --ignore-scripts
- run: npx prettier --check .
lint:
@ -34,7 +36,7 @@ jobs:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm ci --ignore-scripts
- run: npx eslint .
typecheck:
@ -44,13 +46,20 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# tsc --noEmit reads source + gitnexus-shared/dist. Skip prepare/postinstall
# so a cold shared install cannot eat the 10-minute budget on a second tsc.
- uses: ./.github/actions/setup-gitnexus
with:
lifecycle-scripts: 'false'
- run: npx tsc --noEmit
working-directory: gitnexus
typecheck-web:
runs-on: ubuntu-latest
timeout-minutes: 10
# Cold gitnexus-web npm ci is several minutes (mermaid/langchain/playwright).
# A 10-minute cancel prevents setup-node from saving the cache, so the next
# run is cold again.
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:

View file

@ -307,7 +307,9 @@ jobs:
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
# Windows pack + web install regularly exceeds 15 minutes when setup also
# runs prepare/postinstall/build before prepack compiles the same tree again.
timeout-minutes: 20
steps:
# persist-credentials: false — this job runs npm pack + npm install -g
# from a tarball and never pushes back; the token in .git/config would
@ -316,9 +318,20 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Skip prepare/postinstall/build here. `npm pack` runs prepack, which
# compiles CLI + web into the tarball this job actually installs.
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
lifecycle-scripts: 'false'
# `npm pack` runs prepack, which builds the web UI into gitnexus/web/
# so the tarball matches what `npm publish` ships. Install those deps
# here, in their own visible step, rather than letting build.js do it
# from inside an execSync.
- name: Install gitnexus-web dependencies
shell: bash
run: npm ci
working-directory: gitnexus-web
- name: Pack gitnexus tarball
shell: bash
@ -360,6 +373,10 @@ jobs:
fi
echo "Installed package at: $INSTALLED"
# The npm package contract includes the built web UI. Validate the
# installed artifact, not just the source workflow that produced it.
node "$INSTALLED/scripts/assert-web-assets.mjs" "$INSTALLED/web"
# #836 invariant: no node_modules/ or build/ under any vendor/*.
BAD=$(find "$INSTALLED/vendor" \( -name node_modules -o -name build \) -print 2>/dev/null || true)
if [ -n "$BAD" ]; then
@ -419,9 +436,6 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm ci && npm run build
working-directory: gitnexus-shared
- name: Install and build gitnexus
shell: bash
run: |
@ -856,11 +870,6 @@ jobs:
"${canary_runtime}/node_modules/@anthropic-ai/claude-code/package.json"
test "$("${canary_runtime}/node_modules/@anthropic-ai/claude-code-linux-x64/claude" --version)" = \
'2.1.214 (Claude Code)'
- name: Build pinned shared runtime
run: |
npm ci
npm run build
working-directory: gitnexus-shared
- name: Install and build pinned GitNexus runtime
run: |
npm ci

View file

@ -396,14 +396,17 @@ jobs:
# cache-poisoning audit). ~30s slower per release; runs rarely.
package-manager-cache: false
- name: Build gitnexus-shared
run: npm ci && npm run build
working-directory: gitnexus-shared
- name: Install gitnexus dependencies
run: npm ci
working-directory: gitnexus
# The published tarball ships the web UI (`files: [... "web"]`), built
# by prepack during `npm publish`. Install its deps in their own step
# so a slow install is visible here instead of dying inside build.js.
- name: Install gitnexus-web dependencies
run: npm ci
working-directory: gitnexus-web
# ── Stable-only: verify the tag and package.json agree ───────────────
- name: Verify version consistency (stable)
if: needs.route.outputs.mode == 'stable'

View file

@ -55,9 +55,6 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm ci && npm run build
working-directory: gitnexus-shared
- name: Install gitnexus
run: npm ci
working-directory: gitnexus

View file

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

View file

@ -37,7 +37,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Index seems corrupt or "incremental" is misbehaving
- **Trigger:** `analyze` produces unexpected results, or `incrementalInProgress` is set in the index metadata (`.gitnexus/gitnexus.json` / legacy `meta.json`), or the index is in a half-state after a crash.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. A dirty-flag recovery rebuild parks the interrupted run's sidecars beside the DB as `lbug.wal.dirty-recovery` / `lbug.shadow.dirty-recovery` for post-mortem debugging — harmless, and removable with `npx gitnexus clean --lbug-sidecars`. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
- **Do:** `npx gitnexus analyze --force` to rebuild the graph and FTS indexes. This may reuse unchanged parser output; when debugging parser/capture changes, use `npx gitnexus analyze --no-parse-cache` to rebuild that output too. The dirty-flag check forces the graph rebuild automatically when a previous incremental run didn't complete cleanly. A dirty-flag recovery rebuild parks the interrupted run's sidecars beside the DB as `lbug.wal.dirty-recovery` / `lbug.shadow.dirty-recovery` for post-mortem debugging — harmless, and removable with `npx gitnexus clean --lbug-sidecars`. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
### Embeddings vanished after analyze

View file

@ -436,7 +436,8 @@ The token may be set in the shell, `.env.local`, or `.env` in the working direct
<summary><strong>All <code>analyze</code> flags</strong></summary>
```bash
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
gitnexus analyze --force # Full graph + FTS rebuild (reuses unchanged parser output)
gitnexus analyze --no-parse-cache # Full rebuild that re-parses every source file
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)

View file

@ -189,11 +189,13 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
assert claude_lock["packages"]["node_modules/@anthropic-ai/claude-code"]["integrity"].startswith("sha512-")
assert "if(p.version!=='2.1.214') process.exit(1)" in workflow
assert "'2.1.214 (Claude Code)'" in workflow
assert containment_steps["Build pinned shared runtime"]["working-directory"] == "gitnexus-shared"
assert containment_steps["Build pinned shared runtime"]["run"].splitlines() == [
"npm ci",
"npm run build",
]
# Shared is compiled by gitnexus `npm run build` (scripts/build.js runTsc).
# A dedicated npm ci in gitnexus-shared pulls TypeScript 7 and stalls CI.
assert "Build pinned shared runtime" not in containment_steps
assert not any(
step.get("working-directory") == "gitnexus-shared" and "npm ci" in str(step.get("run", ""))
for step in containment["steps"]
)
assert containment_steps["Install and build pinned GitNexus runtime"]["working-directory"] == "gitnexus"
assert containment_steps["Install and build pinned GitNexus runtime"]["run"].splitlines() == [
"npm ci",

View file

@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.10",
"version": "1.6.11",
"author": {
"name": "GitNexus"
},

View file

@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.10",
"version": "1.6.11",
"skills": "./skills",
"mcpServers": "./.mcp.json",
"hooks": "./hooks/hooks.json",

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.10", "mcp"]
"args": ["-y", "gitnexus@1.6.11", "mcp"]
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,50 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.11] - 2026-09-04
### Added
- **Zig is a supported language** — functions, methods, structs/enums/unions, relative and `build.zig` / `build.zig.zon` imports, and CALLS including receiver-bound dispatch. `comptime`-false branches mark CALLS as `staticGated`. The grammar is an optional dependency, so a missing native binding skips `.zig` files instead of failing install (#1432, #3161)
- **Update notifications** — cache-first npm `latest` check with one stderr line on interactive CLI, a `doctor` line, one MCP process log, and a dismissible web banner from `/api/info`. Silent for npx, dev checkouts, and Docker; opt out with `GITNEXUS_NO_UPDATE_NOTIFIER` (#3175)
- **`gitnexus auto-sync`** — scheduled SSH clone/pull and analyze from `GITNEXUS_HOME/watch_config.yml`. `gitnexus watch` is reserved and prints the split with `analyze --watch` (#2493)
- **`analyze --watch`** — local incremental re-index with debounce, serialized writers, and last-good graph on failure (#3072)
- **`--no-parse-cache` forces a cold parser rebuild** on analyze (#3153)
- **Spring / JVM modeling** — vendor-suffix mapping annotations (#2883), messaging destinations (#3132), handler annotation arguments and template publishes (#3128), Kotlin decorator routes and config consumers (#3133, #3126), optional Actuator runtime import (#3107), `SpringContextUtil.getBeans` dynamic lookups (#2886), Lombok and Kotlin accessor synthesis (#2885), and Java static-wildcard / Kotlin star-import folding for route constants (#3110, #3059)
- **More contract and route surfaces** — AsyncAPI 3.x Destination nodes (#3140), wrapped-client HTTP consumers with leading-prefix template stripping (#3111), GraphQL cross-repo contracts (#3070), and PHP generated-client `Request(method, host + resourcePath)` consumers (#3079)
- **Wiki `grok` local CLI provider** (#3069)
- **Optional bearer auth on the `serve` MCP route** (#3100)
### Fixed
- **Web UI is built from `prepack`**, not on every `npm ci` (#3166)
- **`analyze --watch` no longer drops events** across a gitignore reload (#3159)
- **`detect_changes` does not call a zero-symbol result a clean tree** (#3138)
- **`includeTests` is honored** for C#, Java, Swift and PHP test paths (#2866)
- **Decorator routes connect to their handler function** (#2865)
- **Analyze no longer clobbers customized GitNexus skills** (#3124)
- **Generated agent block requires graph tools** on structural reads (#3125)
- **`group` degraded links, sync warnings and UID-only impact actually work** (#3113); Maven child coordinates parse independently of parent POMs (#3108); ambiguous sync names fail closed and `analyze --name` is honored (#3094)
- **Grep tool honors regex, `fileFilter`, and `caseSensitive`** in serve and the web UI (#3109)
- **Razor ViewComponent names bind to in-repo classes** (#3104)
- **Incremental analyze skips derived layers it can reuse** (#3016, #3102)
- **Parse-cache chunks are stable** and ParsedFile loads are cheaper (#3093)
- **MCP omitted `repo` resolves from cwd** (#3085)
- **`status` judges freshness by covered files**, not a dirty working tree (#3083)
- **`impact` File risk is comparable via shared axes** (#3075, #3082), repo-relative paths resolve through `filePath` (#3074, #3084), and scope-extraction omissions are reported (#3071)
- **Cursor hooks preserve quoted shell search patterns** (#2938)
### Performance
- **Six equivalent redundancies dropped** on the Java-scale emit path (#3129)
- **V8 sidecars plus hardlinked ParsedFile restore** (#3099)
### Chore / Dependencies
- **Transitive CVE patches** (#3095)
- **Major runtime bumps** — `chokidar` 4 → 5 (#3117), `graphql` 16 → 17 (#3119)
- **Dependency bumps** across gitnexus (`js-yaml`, `qs`, `fast-uri`, `ignore`, `tsx`, `@types/node`, `onnxruntime-node`), gitnexus-web (`axios`, `mermaid`, Playwright, Vitest, Testing Library), and the CodeQL action group (#3115, #3118, #3135, #3141–#3151, #3154, #3155, #3158)
## [1.6.10] - 2026-08-27
### Added

View file

@ -236,7 +236,8 @@ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
gitnexus analyze --force # Rebuild graph + FTS; may reuse unchanged parser output
gitnexus analyze --no-parse-cache # Re-parse every source file, then rebuild graph + FTS
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
@ -263,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
@ -491,6 +493,40 @@ bigger cycle) and `N` increments per published rc. Example sequence:
`1.6.3-rc.1`. See the [Releases page](https://github.com/abhigyanpatwari/GitNexus/releases)
for the full list; stable `latest` is unaffected.
## Update notifications
GitNexus checks the npm registry's `latest` dist-tag at most once every 24
hours per installation and tells you when a newer stable version exists. The
result is cached under `$GITNEXUS_HOME` (`~/.gitnexus` by default), so the
check never runs on the command's hot path and never blocks output. Where the
notice appears:
- **CLI** — one line on stderr when you run a command interactively (never on
stdout, so `gitnexus query … | jq` and other piped output stay clean), a
line in `gitnexus doctor` when an update is known. Automatic notices never
install. `gitnexus update` checks even when notices are opted out, then
runs `npm i -g gitnexus@<x.y.z>` (same idea as `claude update` /
`codex update`).
- **MCP server** — one structured log record on the server's stderr per
process per version (visible in your host's MCP log panel). Tool results,
resources, prompts, and server instructions never carry update text.
- **Web UI** — a dismissible banner when the server reports a newer version;
dismissal persists per version.
The check is skipped entirely (no network request, no output) when `CI` is
truthy, when the install is not an npm global/local install (npx cache, dev
checkout, Docker image — the Docker CLI image sets the opt-out itself), or
when opted out:
| Variable | Effect |
| --- | --- |
| `GITNEXUS_NO_UPDATE_NOTIFIER` | Truthy (`1`, `true`, …) disables the update check on every surface. |
| `NO_UPDATE_NOTIFIER` | Cross-tool convention; honored the same way. |
| `npm_config_registry` | The check reads the `latest` dist-tag from this registry instead of `https://registry.npmjs.org`. Credentials are never sent, and registries that require authentication are not supported (the check silently skips). |
Eval harnesses running a global install can set `GITNEXUS_NO_UPDATE_NOTIFIER`
for a quiet registry.
## Troubleshooting
### `Cannot destructure property 'package' of 'node.target' as it is null`

View file

@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.10",
"version": "1.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.10",
"version": "1.6.11",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
@ -1909,9 +1909,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
"integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -3513,9 +3513,9 @@
}
},
"node_modules/ignore": {
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.7.tgz",
"integrity": "sha512-dML0wP6oak21rsNYCJpJB6O1BJIEwNpGrTw0URPfAk4hm0e3pRfCtzkfB6olBcXcVlU2rouCyz7lCyRB0OMVCA==",
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
"license": "MIT",
"engines": {
"node": ">= 4"

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.10",
"version": "1.6.11",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@ -40,6 +40,7 @@
],
"scripts": {
"build": "node scripts/build.js",
"build:web": "node scripts/build.js --web",
"serve": "tsx src/cli/index.ts serve",
"dev": "tsx watch src/cli/index.ts",
"test": "vitest run",
@ -52,7 +53,7 @@
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js",
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js --web && node scripts/assert-web-assets.mjs web",
"version": "node scripts/sync-plugin-manifests.mjs"
},
"dependencies": {

View file

@ -0,0 +1,35 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
const webDir = path.resolve(process.argv[2] ?? 'web');
const indexPath = path.join(webDir, 'index.html');
let html;
try {
html = fs.readFileSync(indexPath, 'utf8');
} catch (err) {
if (err?.code === 'ENOENT') {
console.error(`[web-assets] missing ${indexPath}`);
process.exit(1);
}
throw err;
}
const localRefs = [...html.matchAll(/(?:src|href)=["']([^"'#]+)["']/g)]
.map((match) => match[1])
.filter((ref) => !/^(?:[a-z]+:|\/\/|data:)/i.test(ref))
.map((ref) => ref.split(/[?#]/, 1)[0].replace(/^\/+/, ''));
const assetRefs = localRefs.filter((ref) => ref.startsWith('assets/'));
if (assetRefs.length === 0) {
console.error(`[web-assets] ${indexPath} references no assets`);
process.exit(1);
}
const missing = assetRefs.filter((ref) => !fs.existsSync(path.join(webDir, ref)));
if (missing.length > 0) {
console.error(`[web-assets] ${indexPath} references missing assets:\n${missing.join('\n')}`);
process.exit(1);
}
console.log(`[web-assets] verified index.html and ${assetRefs.length} referenced asset(s)`);

View file

@ -0,0 +1,72 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
export function shouldBuildWeb(argv = process.argv, env = process.env) {
return argv.includes('--web') || env.GITNEXUS_BUILD_WEB === '1';
}
export function shouldPreserveWebOutput(env = process.env) {
return (
env.npm_lifecycle_event === 'prepare' &&
(env.npm_command === 'pack' || env.npm_command === 'publish')
);
}
/** Build and copy the web UI when `--web` / GITNEXUS_BUILD_WEB=1 is set. */
export function runWebBuild({
root,
dist,
timeoutMs,
argv = process.argv,
env = process.env,
fsImpl = fs,
exec = execSync,
}) {
const webRoot = path.resolve(root, '..', 'gitnexus-web');
const webDest = path.join(dist, '..', 'web');
if (!shouldBuildWeb(argv, env)) {
if (shouldPreserveWebOutput(env)) {
console.log('[build] preserving prepack web UI during npm prepare');
} else {
fsImpl.rmSync(webDest, { recursive: true, force: true });
console.log(
'[build] skipping web UI and removed stale output ' +
'(pass --web or set GITNEXUS_BUILD_WEB=1 to include it)',
);
}
return { status: 'skipped', webDest };
}
if (!fsImpl.existsSync(path.join(webRoot, 'package.json'))) {
throw new Error(
`[build] web UI requested, but gitnexus-web was not found at ${webRoot}. ` +
'Run this command from the complete monorepo checkout.',
);
}
console.log('[build] building gitnexus-web…');
if (!fsImpl.existsSync(path.join(webRoot, 'node_modules'))) {
// Deliberately untimed: this is a full second install, and killing it
// partway through leaves a broken tree and a misleading ETIMEDOUT.
// CI should install gitnexus-web itself (cached, its own step) so this
// fallback only fires for a local `npm pack` / `npm publish`.
console.log('[build] installing gitnexus-web dependencies (no local node_modules)…');
// String form uses the platform shell (cmd.exe / sh) so `npm` resolves to
// npm.cmd on Windows. execFileSync('npm') / execFileSync('npm.cmd')
// without a shell fails on Windows.
exec('npm ci', { cwd: webRoot, stdio: 'inherit' });
}
exec('npm run build', { cwd: webRoot, stdio: 'inherit', timeout: timeoutMs });
const builtIndex = path.join(webRoot, 'dist', 'index.html');
if (!fsImpl.existsSync(builtIndex)) {
throw new Error(`[build] gitnexus-web build completed without ${builtIndex}`);
}
fsImpl.rmSync(webDest, { recursive: true, force: true });
fsImpl.cpSync(path.join(webRoot, 'dist'), webDest, { recursive: true });
console.log('[build] copied web UI → gitnexus/web/');
return { status: 'built', webDest };
}

View file

@ -8,17 +8,18 @@
* 3. Copy gitnexus-shared/dist → dist/_shared
* 4. Rewrite bare 'gitnexus-shared' specifiers → relative paths
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runWebBuild } from './build-web.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared');
const DIST = path.join(ROOT, 'dist');
const SHARED_DEST = path.join(DIST, '_shared');
const DEFAULT_BUILD_TIMEOUT_MS = 300_000;
const DEFAULT_BUILD_TIMEOUT_MS = 600_000;
function getBuildTimeoutMs() {
const raw = process.env.GITNEXUS_BUILD_TIMEOUT_MS;
@ -51,17 +52,29 @@ if (!fs.existsSync(SHARED_ROOT)) {
process.exit(1);
}
// Launch tsc as `node typescript/lib/tsc.js` on every OS. The `.bin/tsc` /
// `tsc.cmd` shims are Windows-only wrappers; `execFileSync` cannot spawn a
// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared pulls
// TypeScript 7 optional platform packages (7+ minutes in CI).
const tscJs = path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js');
if (!fs.existsSync(tscJs)) {
console.error(
`[build] missing ${tscJs}. Install gitnexus dependencies first (npm ci in gitnexus/).`,
);
process.exit(1);
}
function runTsc(cwd) {
execFileSync(process.execPath, [tscJs], { cwd, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
}
// ── 1. Build gitnexus-shared ───────────────────────────────────────
console.log('[build] compiling gitnexus-shared…');
const tscCmd =
process.platform === 'win32'
? path.join('node_modules', '.bin', 'tsc.cmd')
: path.join('node_modules', '.bin', 'tsc');
execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
runTsc(SHARED_ROOT);
// ── 2. Build gitnexus ──────────────────────────────────────────────
console.log('[build] compiling gitnexus…');
execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
runTsc(ROOT);
// ── 3. Copy shared dist ────────────────────────────────────────────
console.log('[build] copying shared module into dist/_shared…');
@ -104,26 +117,16 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile);
// ── 5. Make CLI entry executable ────────────────────────────────────
const cliEntry = path.join(DIST, 'cli', 'index.js');
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
// ── 6. Build & copy web UI ──────────────────────────────────────────
const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web');
const WEB_DEST = path.join(DIST, '..', 'web');
if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) {
console.log('[build] building gitnexus-web…');
if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) {
console.log('[build] installing gitnexus-web dependencies…');
execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
}
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
// Copy dist → gitnexus/web/ (shipped in the npm package)
fs.rmSync(WEB_DEST, { recursive: true, force: true });
fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true });
console.log('[build] copied web UI → gitnexus/web/');
} else {
console.log('[build] skipping web UI (gitnexus-web not found)');
if (process.platform !== 'win32' && fs.existsSync(cliEntry)) {
fs.chmodSync(cliEntry, 0o755);
}
// ── 6. Build & copy web UI (opt-in) ─────────────────────────────────
// Web UI is a separate package and is only required in the published
// tarball, so it is built by `prepack --web`, not by `prepare`. Serve
// falls back to the landing page when web/ is absent. CLI-only builds
// delete stale web/ except during npm pack/publish prepare, which must
// keep the prepack output.
runWebBuild({ root: ROOT, dist: DIST, timeoutMs: BUILD_TIMEOUT_MS });
console.log(`[build] done — rewrote ${rewritten} files.`);

View file

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

View file

@ -20,6 +20,8 @@ export interface AnalyzeOptions {
/** Watch quiet period in milliseconds. */
debounce?: string;
force?: boolean;
/** Commander negated flag: false only when --no-parse-cache is passed. */
parseCache?: boolean;
repairFts?: boolean;
/**
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:

View file

@ -104,6 +104,7 @@ export async function resolveWatchOptions(
const merged = mergeAnalyzeOptions(cli, config);
const unsupported = [
['--force', cli.force],
['--no-parse-cache', cli.parseCache === false],
['--repair-fts', cli.repairFts],
['--embeddings', cli.embeddings],
['--drop-embeddings', cli.dropEmbeddings],

View file

@ -788,8 +788,6 @@ const analyzeCommandImpl = async (
cliOptions?: AnalyzeOptions,
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
): Promise<void> => {
console.log('\n GitNexus Analyzer\n');
// ── Resolve the target repo root ──────────────────────────────────
// Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
// caller's cwd), and config can set defaults that the validation below
@ -1184,10 +1182,10 @@ const analyzeCommandImpl = async (
}
}
if (options.repairFts && options.force) {
if (options.repairFts && (options.force || options.parseCache === false)) {
cliError(
' Cannot combine `--repair-fts` with `--force`. ' +
'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n',
' Cannot combine `--repair-fts` with a full rebuild. ' +
'Use `--repair-fts` alone for fast FTS-only repair.\n',
);
process.exitCode = 1;
return;
@ -1345,10 +1343,11 @@ const analyzeCommandImpl = async (
const skipAgentsMd = skipAll || options.skipAgentsMd;
const skipSkills = skipAll || options.skipSkills;
const runOptions = {
// Pipeline re-index — OR'd with --skills because skill generation
// needs a fresh pipelineResult. Has no bearing on the registry
// collision guard (see allowDuplicateName below).
force: options.force || options.skills,
// Pipeline re-index — OR'd with --skills because skill generation needs
// a fresh pipelineResult, and with --no-parse-cache because bypassing
// parser output is meaningful only when the pipeline runs.
force: options.force || options.skills || options.parseCache === false,
useParseCache: options.parseCache !== false,
repairFts: options.repairFts,
embeddings: embeddingsEnabled,
embeddingsNodeLimit,

View file

@ -0,0 +1,68 @@
/**
* 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 type { Command } from 'commander';
import { packageVersion } from '../core/package-version.js';
const SKIP_COMMAND_BANNER = new Set(['__update-check', 'help']);
const SPECIAL_TITLES: Record<string, string> = {
analyze: 'Analyzer',
mcp: 'MCP',
};
export function installedCliVersion(): string {
return packageVersion();
}
export function commandDisplayName(name: string): string {
return (
SPECIAL_TITLES[name] ??
name
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
);
}
export function commandBannerTitle(command: Command): string {
const names: string[] = [];
for (
let current: Command | null | undefined = command;
current?.parent;
current = current.parent
) {
names.unshift(current.name());
}
return names.map(commandDisplayName).join(' ');
}
export function formatCommandBanner(
title: string,
version: string = installedCliVersion(),
): string {
if (!title) return '';
return version ? `\n GitNexus ${title} (${version})\n` : `\n GitNexus ${title}\n`;
}
export interface WriteCommandBannerDependencies {
write?: (text: string) => void;
version?: string;
}
export function writeCommandBanner(
command: Command,
deps: WriteCommandBannerDependencies = {},
): void {
if (SKIP_COMMAND_BANNER.has(command.name())) return;
const text = formatCommandBanner(
commandBannerTitle(command),
deps.version ?? installedCliVersion(),
);
if (!text) return;
(deps.write ?? ((line) => process.stderr.write(line)))(text);
}

View file

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

View file

@ -22,6 +22,7 @@ const COMMAND_DESCRIPTION_KEYS = {
list: 'help.command.list.description',
status: 'help.command.status.description',
doctor: 'help.command.doctor.description',
update: 'help.command.update.description',
embeddings: 'help.command.embeddings.description',
'embeddings install': 'help.command.embeddings.install.description',
clean: 'help.command.clean.description',
@ -53,6 +54,7 @@ const OPTION_DESCRIPTION_KEYS = {
'|-V, --version': 'help.option.version',
'setup|-c, --coding-agent <agents>': 'help.option.setup.codingAgent',
'analyze|-f, --force': 'help.option.analyze.force',
'analyze|--no-parse-cache': 'help.option.analyze.noParseCache',
'analyze|--repair-fts': 'help.option.analyze.repairFts',
'analyze|--embeddings [limit]': 'help.option.analyze.embeddings',
'analyze|--drop-embeddings': 'help.option.analyze.dropEmbeddings',

View file

@ -7,6 +7,16 @@ export const en = {
'common.storage': 'Storage',
'common.deleted': 'Deleted: {{target}}',
'common.error': 'Error: {{message}}',
'update.available':
'GitNexus {{latestVersion}} is available (you are running {{installedVersion}}).',
'update.current':
'GitNexus {{installedVersion}} is current or newer than the latest stable version.',
'update.installing': 'Installing with {{command}}…',
'update.installed': 'Installed gitnexus@{{version}}. Restart long-running mcp/serve processes.',
'update.installFailed': 'npm install failed. You can retry: {{command}}',
'update.installError': 'Could not run npm: {{message}}',
'update.checkFailed':
'Could not check for updates (offline, private registry, or the check failed open).',
'list.title': 'Indexed Repositories ({{count}})',
'list.indexed': 'Indexed',
'list.commit': 'Commit',
@ -165,6 +175,8 @@ export const en = {
'help.command.status.description': 'Show index status for current repo',
'help.command.doctor.description':
'Show runtime platform capabilities and embedding configuration',
'help.command.update.description':
'Install the latest published GitNexus globally (`npm i -g gitnexus@<x.y.z>`).',
'help.command.embeddings.description': 'Manage the on-demand local embedding runtime',
'help.command.embeddings.install.description':
'Install the local embedding stack (@huggingface/transformers + onnxruntime-node) on demand. Heals installs where npm skipped the optional packages (e.g. behind an HTTP proxy, #2370). Downloads only from your configured npm registry — mirrors and proxies apply.',
@ -204,7 +216,9 @@ export const en = {
'help.command.group.contracts.description': 'Inspect Contract Registry',
'help.option.setup.codingAgent':
'Configure only these coding agents (comma-separated or repeatable)',
'help.option.analyze.force': 'Force full re-index even if up to date',
'help.option.analyze.force': 'Force graph and FTS rebuild; unchanged parser output may be reused',
'help.option.analyze.noParseCache':
'Re-parse every source file instead of replaying cached parser output',
'help.option.analyze.repairFts': 'Repair/rebuild search FTS indexes without full re-analysis',
'help.option.analyze.embeddings':
'Enable embedding generation for semantic search (off by default). Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.',

View file

@ -11,6 +11,13 @@ export const zhCN = {
'common.storage': '存储',
'common.deleted': '已删除:{{target}}',
'common.error': '错误:{{message}}',
'update.available': 'GitNexus {{latestVersion}} 已发布(当前运行 {{installedVersion}})。',
'update.current': 'GitNexus {{installedVersion}} 已是最新稳定版或不低于该版本。',
'update.installing': '正在执行 {{command}}…',
'update.installed': '已安装 gitnexus@{{version}}。请重启仍在运行的 mcp/serve 进程。',
'update.installFailed': 'npm 安装失败。可重试:{{command}}',
'update.installError': '无法运行 npm:{{message}}',
'update.checkFailed': '无法检查更新(离线、私有仓库,或检查失败)。',
'list.title': '已索引仓库({{count}})',
'list.indexed': '索引时间',
'list.commit': '提交',
@ -162,6 +169,8 @@ export const zhCN = {
'help.command.list.description': '列出所有已索引仓库',
'help.command.status.description': '显示当前仓库的索引状态',
'help.command.doctor.description': '显示运行平台能力和嵌入配置',
'help.command.update.description':
'通过 npm 全局安装最新发布的 GitNexus(`npm i -g gitnexus@<x.y.z>`)。',
'help.command.embeddings.description': '管理按需安装的本地嵌入运行时',
'help.command.embeddings.install.description':
'按需安装本地嵌入组件(@huggingface/transformers + onnxruntime-node)。修复 npm 跳过可选包的安装(例如在 HTTP 代理后,#2370)。仅从你配置的 npm registry 下载 — 镜像和代理均生效。',
@ -192,7 +201,8 @@ export const zhCN = {
'help.command.group.query.description': '跨仓库组所有仓库搜索执行流程',
'help.command.group.contracts.description': '查看 Contract Registry',
'help.option.setup.codingAgent': '仅配置这些编码代理(逗号分隔或重复传入)',
'help.option.analyze.force': '即使已是最新也强制完整重建索引',
'help.option.analyze.force': '强制重建图和 FTS;未更改的解析器输出可能被复用',
'help.option.analyze.noParseCache': '重新解析每个源文件,不重放缓存的解析器输出',
'help.option.analyze.repairFts': '修复/重建搜索 FTS 索引,不执行完整重新分析',
'help.option.analyze.embeddings':
'启用语义搜索的嵌入生成(默认关闭)。可选 [limit] 覆盖 50,000 节点安全上限;传 0 可完全禁用上限。',

View file

@ -4,7 +4,7 @@
// Removing it from here improves MCP server startup time significantly.
import { Command } from 'commander';
import { createRequire } from 'node:module';
import { packageVersion } from '../core/package-version.js';
import {
createAnalyzerLbugLazyAction,
createLazyAction,
@ -14,16 +14,16 @@ 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');
const program = new Command();
function collectCodingAgents(value: string, previous: string[] | undefined): string[] {
return [...(previous ?? []), ...value.split(',')];
}
program.name('gitnexus').description('GitNexus local CLI and MCP server').version(pkg.version);
program.name('gitnexus').description('GitNexus local CLI and MCP server').version(packageVersion());
program
.command('setup')
@ -75,7 +75,11 @@ program
.description('Index a repository (full analysis)')
.option('--watch', 'Keep the index current with serialized incremental refreshes')
.option('--debounce <ms>', 'Watch quiet period before refreshing (default: 300 milliseconds)')
.option('-f, --force', 'Force full re-index even if up to date')
.option('-f, --force', 'Force graph and FTS rebuild; unchanged parser output may be reused')
.option(
'--no-parse-cache',
'Re-parse every source file instead of replaying cached parser output',
)
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
.option(
'--embeddings [limit]',
@ -295,6 +299,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')
@ -498,7 +507,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(packageVersion());
program.parse(process.argv);

View file

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

View file

@ -10,10 +10,10 @@ import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { execFile, execFileSync } from 'child_process';
import { createRequire } from 'module';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
import { packageVersion } from '../core/package-version.js';
import { getGlobalDir } from '../storage/repo-manager.js';
import {
getEditorTargets,
@ -37,9 +37,8 @@ const execFileAsync = promisify(execFile);
// re-stamped every release by scripts/sync-plugin-manifests.mjs (#2445),
// since they too execute `gitnexus@<version>` on connect. Only the READMEs
// stay on `gitnexus@latest` — they're quickstart docs, not executed state.
const _require = createRequire(import.meta.url);
const _pkg = _require('../../package.json') as { version?: unknown };
if (typeof _pkg.version !== 'string' || !_pkg.version) {
const PKG_VERSION = packageVersion();
if (!PKG_VERSION) {
throw new Error(
'gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.',
);
@ -47,7 +46,7 @@ if (typeof _pkg.version !== 'string' || !_pkg.version) {
// Version-pinned ref for the persisted MCP entry — deliberately distinct from
// the cjs's exported `gitnexus@latest` hint ref (resolve-analyze-cmd.cjs); the
// two are not unified (see the comment above and that file's MCP_PINNED_REF).
const MCP_PINNED_REF = `gitnexus@${_pkg.version}`;
const MCP_PINNED_REF = `gitnexus@${PKG_VERSION}`;
/**
* Build the `command` string written into an editor's hook settings, which the

View file

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

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

@ -0,0 +1,123 @@
/**
* 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 { homedir } from 'node:os';
import { composeWin32NpmCommand } from '../core/embeddings/runtime-install.js';
import { packageVersion } from '../core/package-version.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';
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 packageVersion();
}
export function updateInstallArgs(version: string): string[] {
return ['i', '-g', `${UPDATE_PACKAGE}@${version}`];
}
export function updateInstallCommand(version: string): string {
return `npm ${updateInstallArgs(version).join(' ')}`;
}
function isTestDeps(value: unknown): value is Partial<UpdateCommandDependencies> {
return (
typeof value === 'object' &&
value !== null &&
('refresh' in value || 'runInstall' in value || 'writeStdout' in value)
);
}
function defaultRunInstall(version: string): Promise<number> {
const args = updateInstallArgs(version);
return new Promise((resolve, reject) => {
const child =
process.platform === 'win32'
? spawn(composeWin32NpmCommand(args), {
cwd: homedir(),
windowsHide: true,
shell: true,
stdio: 'inherit',
})
: spawn('npm', args, {
cwd: homedir(),
windowsHide: true,
stdio: 'inherit',
});
child.on('error', reject);
child.on('exit', (code, signal) => {
resolve(signal ? 1 : (code ?? 1));
});
});
}
export async function updateCommand(maybeDeps?: unknown): Promise<void> {
const deps = isTestDeps(maybeDeps) ? maybeDeps : {};
const installedVersion = deps.installedVersion ?? defaultInstalledVersion();
const runRefresh = deps.refresh ?? refresh;
const writeStdout = deps.writeStdout ?? ((line: string) => console.log(line));
const runInstall = deps.runInstall ?? defaultRunInstall;
const setExitCode =
deps.setExitCode ??
((code: number) => {
process.exitCode = code;
});
const state = await runRefresh({
eligible: true,
ignoreOptOut: true,
installedVersion,
});
const latestVersion =
state?.latestVersion && STRICT_UPDATE_VERSION.test(state.latestVersion)
? state.latestVersion
: undefined;
if (state?.updateAvailable && latestVersion) {
writeStdout(t('update.available', { installedVersion, latestVersion }));
writeStdout(t('update.installing', { command: updateInstallCommand(latestVersion) }));
try {
const code = await runInstall(latestVersion);
if (code !== 0) {
writeStdout(t('update.installFailed', { command: updateInstallCommand(latestVersion) }));
setExitCode(code);
return;
}
} catch (error) {
writeStdout(
t('update.installError', {
message: error instanceof Error ? error.message : String(error),
}),
);
setExitCode(1);
return;
}
writeStdout(t('update.installed', { version: latestVersion }));
return;
}
if (latestVersion) {
writeStdout(t('update.current', { installedVersion }));
return;
}
writeStdout(t('update.checkFailed'));
}

View file

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

View file

@ -457,6 +457,8 @@ export async function runChunkedParseAndResolve(
usedWorkerPool: boolean;
/** Files dispatched to parser workers after parse-cache lookup. */
reparsedFileCount: number;
/** Files restored from parse-cache chunks without parser-worker dispatch. */
parseCacheHitFileCount: number;
/** Worker-produced ParsedFile artifacts aggregated across chunks.
* Threaded into scope-resolution as a re-extract cache so the warm-
* cache analyze run can skip the dominant `extractParsedFile` cost
@ -752,6 +754,7 @@ export async function runChunkedParseAndResolve(
: new Map<string, ReadonlySet<string>>();
let chunkCacheHits = 0;
let chunkCacheMisses = 0;
let parseCacheHitFileCount = 0;
let reparsedFileCount = 0;
try {
@ -1044,6 +1047,7 @@ export async function runChunkedParseAndResolve(
pendingWorkerChunk = null;
}
chunkCacheHits++;
parseCacheHitFileCount += chunkFiles.length;
const chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw, exportedTypeMap);
if (isDev) {
logger.info(
@ -1611,6 +1615,7 @@ export async function runChunkedParseAndResolve(
// is intentionally measured at dispatch time rather than inferred from
// the git/hash diff.
reparsedFileCount,
parseCacheHitFileCount,
// Per-file ParsedFile artifacts produced by workers' calls to
// `extractParsedFile`. Consumed by scope-resolution as a re-extraction
// cache: when the file's ParsedFile is here, scope-resolution skips its own

View file

@ -411,13 +411,19 @@ export const runPipelineFromRepo = async (
}
// Extract final results for the PipelineResult contract
const { totalFiles, usedWorkerPool, reparsedFileCount, unavailableScopeLanguageFiles } =
getPhaseOutput<{
totalFiles: number;
usedWorkerPool: boolean;
reparsedFileCount: number;
unavailableScopeLanguageFiles: number;
}>(results, 'parse');
const {
totalFiles,
usedWorkerPool,
reparsedFileCount,
parseCacheHitFileCount,
unavailableScopeLanguageFiles,
} = getPhaseOutput<{
totalFiles: number;
usedWorkerPool: boolean;
reparsedFileCount: number;
parseCacheHitFileCount: number;
unavailableScopeLanguageFiles: number;
}>(results, 'parse');
let communityResult: CommunitiesOutput['communityResult'] | undefined;
let processResult: ProcessesOutput['processResult'] | undefined;
@ -470,6 +476,7 @@ export const runPipelineFromRepo = async (
undecidedSatisfaction,
usedWorkerPool,
reparsedFileCount,
parseCacheHitFileCount,
scopeExtractionFailures,
unavailableScopeLanguageFiles,
pdgEmitManifest,

View file

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

View file

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

View file

@ -0,0 +1,12 @@
import { createRequire } from 'node:module';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json') as { version?: unknown };
/**
* Published version from this package's `package.json`.
* Empty string if the field is missing or not a string.
*/
export function packageVersion(): string {
return typeof pkg.version === 'string' ? pkg.version : '';
}

View file

@ -157,11 +157,11 @@ import {
saveParseCache,
pruneCache,
PARSE_CACHE_VERSION,
createColdParseRebuildDir,
emptyParseCache,
forgetCreatedParseCacheDir,
} from '../storage/parse-cache.js';
import {
getDurableParsedFileDir,
pruneAndSaveDurableParsedFileStore,
} from '../storage/parsedfile-store.js';
import { mergeStagedDurableParsedFileStore } from '../storage/parsedfile-store.js';
import {
getCurrentCommit,
getCurrentBranch,
@ -332,12 +332,19 @@ export interface AnalyzeCallbacks {
export interface AnalyzeOptions {
/**
* Force a full re-index of the pipeline. Callers may OR this with
* other flags that imply re-analysis (e.g. `--skills`), so the value
* here is the PIPELINE-force signal, NOT the registry-collision
* bypass. See `allowDuplicateName` below.
* Rebuild the graph and FTS. Parser output is still reused from the
* content-addressed parse cache unless `useParseCache` is false.
* Callers may OR this with other flags that imply re-analysis
* (e.g. `--skills`), so the value here is the PIPELINE-force signal,
* NOT the registry-collision bypass. See `allowDuplicateName` below.
*/
force?: boolean;
/**
* Reuse content-addressed parser output. Defaults to true. When false,
* analysis reparses every file and publishes a new parse-cache generation
* only after a successful run (live shards stay untouched if the run fails).
*/
useParseCache?: boolean;
/** Repair only search indexes without re-running full parsing/indexing. */
repairFts?: boolean;
/** Emit per-index FTS create logs. */
@ -1029,6 +1036,18 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
};
}
async function removeColdParseRebuildDir(
dir: string | undefined,
ignoreErrors: boolean,
): Promise<void> {
if (!dir) return;
try {
await fs.rm(dir, { recursive: true, force: true });
} catch (err) {
if (!ignoreErrors) throw err;
}
}
/**
* Run the full analysis under an exclusive, index-directory-scoped write lock
* (#2658). A second concurrent `analyze` on the same slot waits here for the
@ -1132,6 +1151,7 @@ async function runFullAnalysisInner(
// does not own the flat slot. See resolveWriteTarget for the full contract.
const { storagePath, repoHasGit, currentCommit, branchLabel, placement, lbugPath, metaDir } =
writeTarget;
let coldParseRebuildDir: string | undefined;
// Start each analyze with a clean buffer-pool hint: any pre-pipeline DB open
// (e.g. the embeddings-cache open) falls back to the default until the hint is
@ -1742,6 +1762,13 @@ async function runFullAnalysisInner(
options = { ...options, force: true };
}
// Programmatic `useParseCache: false` must set force or the up-to-date
// guard returns before the empty-cache construction below.
if (options.useParseCache === false && !options.force) {
log('Parser cache bypass requested; forcing a full rebuild so unchanged files are re-parsed.');
options = { ...options, force: true };
}
// ── Early-return: already up to date ──────────────────────────────
if (
existingMeta &&
@ -1964,11 +1991,18 @@ async function runFullAnalysisInner(
}
// ── Load incremental parse cache ──────────────────────────────────
// Content-addressed: safe to reuse across `--force` runs (chunks whose
// file contents haven't changed produce identical worker output).
// Loaded into a single ParseCache object that the pipeline mutates
// in-place (cache hits leave entries unchanged; misses add new ones).
const parseCache = await loadParseCache(storagePath);
// Content-addressed: `--force` reuses parser shards; `useParseCache: false`
// stages a new generation under a run-unique parse-rebuild.* dir and publishes
// after success. Unique because index locks are per branch slot while this
// cache root is shared across branches.
if (options.useParseCache === false) {
coldParseRebuildDir = await createColdParseRebuildDir(storagePath);
forgetCreatedParseCacheDir(coldParseRebuildDir);
}
const parseCache =
options.useParseCache === false
? emptyParseCache(coldParseRebuildDir)
: await loadParseCache(storagePath);
// Streamed structural emit (#2680). Resolved ONCE, so the pipeline flag and
// the CSV-dir resolution below cannot disagree — and resolved HERE, not at
@ -2006,53 +2040,69 @@ async function runFullAnalysisInner(
!schemaFingerprintMismatch(existingMeta.schemaFingerprint);
// ── Phase 1: Full Pipeline (0–60%) ────────────────────────────────
const pipelineResult = await runPipelineFromRepo(
repoPath,
(p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
const message = p.detail
? `${p.message || phaseLabel} (${p.detail})`
: p.message || phaseLabel;
progress(p.phase, scaled, message);
},
{
parseCache,
workerPoolSize: options.workerPoolSize,
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
// build gate (workerData.pdg) and the scope-resolution emit gate.
pdg: options.pdg === true,
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: options.pdgMaxTaintHops,
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
// (force === true) so the incremental writeback never reads back an
// offloaded BasicBlock layer. Memory-only; byte-identical output.
streamPdgEmit: resolveStreamPdgEmit(options),
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
// Streamed structural emit (#2680) — same full-rebuild gate as the PDG
// toggle above, for the same incremental-writeback reason.
streamGraphEmit: streamGraphEmitActive,
// Resolved ONLY when streaming is active: on a Windows non-ASCII storage
// path this helper mkdtempSyncs a real directory, so evaluating it
// unconditionally would leak one temp dir per analyze even with the flag
// off. The PDG sibling resolves inside its guard for the same reason.
graphEmitCsvDir: streamGraphEmitActive
? resolveNativeSafeStorageDir(storagePath, 'graph-csv')
: undefined,
fetchWrappers: options.fetchWrappers,
skipDerivedGraphPhases,
springActuatorPath: options.springActuatorPath,
asyncApiSpecPath: options.asyncApiSpecPath,
springActuatorScanExclusions,
},
);
let pipelineResult;
try {
pipelineResult = await runPipelineFromRepo(
repoPath,
(p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
const message = p.detail
? `${p.message || phaseLabel} (${p.detail})`
: p.message || phaseLabel;
progress(p.phase, scaled, message);
},
{
parseCache,
workerPoolSize: options.workerPoolSize,
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
// build gate (workerData.pdg) and the scope-resolution emit gate.
pdg: options.pdg === true,
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: options.pdgMaxTaintHops,
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
// (force === true) so the incremental writeback never reads back an
// offloaded BasicBlock layer. Memory-only; byte-identical output.
streamPdgEmit: resolveStreamPdgEmit(options),
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
// Streamed structural emit (#2680) — same full-rebuild gate as the PDG
// toggle above, for the same incremental-writeback reason.
streamGraphEmit: streamGraphEmitActive,
// Resolved ONLY when streaming is active: on a Windows non-ASCII storage
// path this helper mkdtempSyncs a real directory, so evaluating it
// unconditionally would leak one temp dir per analyze even with the flag
// off. The PDG sibling resolves inside its guard for the same reason.
graphEmitCsvDir: streamGraphEmitActive
? resolveNativeSafeStorageDir(storagePath, 'graph-csv')
: undefined,
fetchWrappers: options.fetchWrappers,
skipDerivedGraphPhases,
springActuatorPath: options.springActuatorPath,
asyncApiSpecPath: options.asyncApiSpecPath,
springActuatorScanExclusions,
},
);
} catch (err) {
await removeColdParseRebuildDir(coldParseRebuildDir, true);
throw err;
}
if (options.force && (pipelineResult.parseCacheHitFileCount ?? 0) > 0) {
log(
`Rebuilt the graph and FTS while reusing cached parser output for ` +
`${pipelineResult.parseCacheHitFileCount} file(s) ` +
`(parse cache ${PARSE_CACHE_VERSION}). ` +
`For same-version capture/query development changes, increment SCHEMA_BUMP in ` +
`src/storage/parse-cache.ts to invalidate parser output.`,
);
}
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
progress('lbug', 60, 'Loading into LadybugDB...');
@ -3994,51 +4044,8 @@ async function runFullAnalysisInner(
// meta.indexedAt = T_new while lbugPath still resolves to the pre-swap
// inode (which latched the reader on the stale index permanently). The meta
// object is fully computed at this point; only its write is deferred.
// Persist the incremental parse cache for the next run. Wraps in
// try/catch so a cache-write failure never breaks an otherwise
// successful indexing run. Prune stale chunk-hash entries first so
// the cache file size stays bounded across runs (chunks whose
// composition no longer matches anything in the current scan are
// dead weight; the parse phase populates `usedKeys` as it processes
// chunks).
try {
// #2106 R6: the parse cache + durable store are shared across branches.
// Before pruning to this run's keys, fold in the OTHER branches' recorded
// chunk keys so a branch switch doesn't evict their still-live shards.
// Adding to usedKeys makes them survive pruneCache AND land in the saved
// index (saveParseCache builds the index from usedKeys). Excludes this
// run's own meta dir, so a single-branch repo folds in nothing → prune
// set byte-identical to today.
const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir);
if (complete) {
for (const k of siblingKeys) parseCache.usedKeys.add(k);
} else {
// Fail-safe toward retention: a sibling meta was unreadable, so keep
// everything currently loaded rather than evict on incomplete info.
log('Parse cache: a branch meta was unreadable — retaining all cached chunks (#2106).');
for (const k of parseCache.entries.keys()) parseCache.usedKeys.add(k);
}
const pruned = pruneCache(parseCache, parseCache.usedKeys);
if (pruned > 0) {
log(`Parse cache: pruned ${pruned} stale chunk entries`);
}
const savedKeys = await saveParseCache(storagePath, parseCache);
// Prune the durable ParsedFile store to EXACTLY the parse cache's
// surviving keys (#2038 warm-cache coverage), so the two content-addressed
// stores stay coherent: a chunk is "cached" iff both its parse-cache shard
// and its durable shards exist. A quarantined chunk (in usedKeys but with
// no parse-cache shard) drops its durable subdir here and re-dispatches
// next run. Same try/catch — a durable-store write failure must never
// break an otherwise successful run (next run treats it as a miss).
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storagePath),
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
} catch (e) {
log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`);
}
// Parse-cache publish waits until after that swap + saveMeta so a failed
// registerRepo / close / swap cannot replace live shards (#3153).
// Forward the --name alias and the registry-collision bypass bit.
// `allowDuplicateName` is its own concern — independent from the
@ -4061,8 +4068,8 @@ async function runFullAnalysisInner(
// ── #2354: the flat workspace slot has adopted this run's branch ──────
// Drop a now-shadowed `branches/<slug>/` sub-index for the same label
// (unreachable once the flat slot serves it) and align the registry's
// top-level branch label. Best-effort like the parse-cache save above
// (#2364 review F5): the index is complete and registered, and a failure
// top-level branch label. Best-effort (#2364 review F5): the index is
// complete and registered, and a failure
// here leaves only a stale registry label / undeleted shadowed dir —
// never wrong routing, because the flat meta this run already stamped is
// what applyBranchScope trusts. Retried by the next content-changing run
@ -4190,8 +4197,55 @@ async function runFullAnalysisInner(
// live and the next run recovers via the full-rebuild path.
await saveMeta(metaDir, meta);
// Persist the incremental parse cache only after a successful graph
// publish (#3153). try/catch so a cache-write failure never breaks an
// otherwise successful indexing run. Prune stale chunk-hash entries first
// so the cache file size stays bounded across runs (chunks whose
// composition no longer matches anything in the current scan are dead
// weight; the parse phase populates `usedKeys` as it processes chunks).
try {
// #2106 R6: the parse cache + durable store are shared across branches.
// Before pruning to this run's keys, fold in the OTHER branches' recorded
// chunk keys so a branch switch doesn't evict their still-live shards.
// Adding to usedKeys makes them survive pruneCache AND land in the saved
// index (saveParseCache builds the index from usedKeys). Excludes this
// run's own meta dir, so a single-branch repo folds in nothing → prune
// set byte-identical to today.
const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir);
if (complete) {
for (const k of siblingKeys) parseCache.usedKeys.add(k);
} else {
// Fail-safe toward retention: a sibling meta was unreadable, so keep
// everything currently loaded rather than evict on incomplete info.
log('Parse cache: a branch meta was unreadable — retaining all cached chunks (#2106).');
for (const k of parseCache.entries.keys()) parseCache.usedKeys.add(k);
}
const pruned = pruneCache(parseCache, parseCache.usedKeys);
if (pruned > 0) {
log(`Parse cache: pruned ${pruned} stale chunk entries`);
}
const savedKeys = await saveParseCache(storagePath, parseCache);
// Prune the durable ParsedFile store to EXACTLY the parse cache's
// surviving keys (#2038 warm-cache coverage), so the two content-addressed
// stores stay coherent: a chunk is "cached" iff both its parse-cache shard
// and its durable shards exist. A quarantined chunk (in usedKeys but with
// no parse-cache shard) drops its durable subdir here and re-dispatches
// next run. Same try/catch — a durable-store write failure must never
// break an otherwise successful run (next run treats it as a miss).
await mergeStagedDurableParsedFileStore(
storagePath,
parseCache.storagePath ?? storagePath,
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
} catch (e) {
log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`);
}
progress('done', 100, 'Done');
await removeColdParseRebuildDir(coldParseRebuildDir, true);
return {
repoName: projectName,
repoPath,
@ -4245,6 +4299,7 @@ async function runFullAnalysisInner(
/* swallow — orphan reclamation must never mask the real failure */
}
}
await removeColdParseRebuildDir(coldParseRebuildDir, true);
if (liveIndexMutationStarted) {
// Preserve the original error identity/prototype: callers distinguish
// IndexLockTimeoutError and other domain failures with `instanceof`.

View file

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

View file

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

View file

@ -11,8 +11,8 @@
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
*/
import { createRequire } from 'module';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { packageVersion } from '../core/package-version.js';
import { CompatibleStdioServerTransport } from './compatible-stdio-transport.js';
import {
CallToolRequestSchema,
@ -106,12 +106,10 @@ export function createMCPServer(
}
const repositoryPolicy = options.repositoryPolicy ?? McpRepositoryPolicy.unrestricted();
const scopedBackend = repositoryPolicy.scopeBackend(backend);
const require = createRequire(import.meta.url);
const pkgVersion: string = require('../../package.json').version;
const server = new Server(
{
name: 'gitnexus',
version: pkgVersion,
version: packageVersion(),
},
{
capabilities: {

View file

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

View file

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

View file

@ -0,0 +1,109 @@
import { packageVersion } from '../core/package-version.js';
import { armUpdateRefreshScheduler, evaluate, type UpdateState } from '../core/update-check.js';
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: packageVersion(),
launchContext,
nodeVersion: process.version,
...(updateState?.updateAvailable && updateState.latestVersion
? { latestVersion: updateState.latestVersion, updateAvailable: true }
: {}),
};
};
export const bindServeUpdateControllerLifecycle = (
server: {
once(event: 'listening' | 'close', listener: () => void): unknown;
},
controller: ServeUpdateController,
): void => {
server.once('listening', () => void controller.start());
server.once('close', controller.stop);
};

View file

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

View file

@ -19,9 +19,9 @@
* - Chunk-level invalidation gives a useful speedup floor (98% on a single
* 1-of-50 invalidated chunk) without touching the worker.
*
* Survives `--force` because it's content-addressed: the same bytes always
* produce the same key. `--force` only matters for the LadybugDB writeback;
* the cache itself is always safe to reuse.
* `--force` still reuses content-addressed shards (it only rebuilds graph/FTS).
* `useParseCache: false` reparses every file, writes a staging generation, and
* publishes onto this cache only after a successful analysis.
*/
import { createHash } from 'crypto';
@ -815,6 +815,27 @@ export const packParseCacheChunks = (
const LEGACY_CACHE_FILENAME = 'parse-cache.json';
const CACHE_DIRNAME = 'parse-cache';
/**
* Per-run staging root for `useParseCache: false`. Parse-cache shards and the
* ParsedFile stores write here so a crash cannot mix a new generation into the
* live `.gitnexus/parse-cache` / `parsedfile-cache` trees. `saveParseCache`
* publishes onto the live `storagePath` only after a successful analysis.
*/
export const COLD_PARSE_REBUILD_DIRNAME = 'parse-rebuild';
/** Deterministic staging path — tests only. Production uses {@link createColdParseRebuildDir}. */
export const getColdParseRebuildDir = (storagePath: string): string =>
path.join(storagePath, COLD_PARSE_REBUILD_DIRNAME);
/**
* Unique per analyze process so concurrent `--no-parse-cache` runs on
* different branch slots (shared `.gitnexus`, separate index locks) do not
* delete each other's staging tree.
*/
export const createColdParseRebuildDir = async (storagePath: string): Promise<string> => {
await fs.mkdir(storagePath, { recursive: true });
return fs.mkdtemp(path.join(storagePath, `${COLD_PARSE_REBUILD_DIRNAME}.`));
};
const CACHE_INDEX_FILENAME = 'index.json';
/** Keys on disk always come from `computeChunkHash` — 64-char lowercase hex. */
@ -851,6 +872,8 @@ export interface ParseCache {
* When set, chunk payloads are loaded from / flushed to sharded files on
* demand instead of retaining every chunk in `entries` for the whole run
* (#1983 — Linux kernel OOM from duplicate in-memory cache + graph).
* May be a per-run staging directory (`getColdParseRebuildDir`) while the
* live index root is passed separately to `saveParseCache`.
*/
storagePath?: string;
/** Index of chunk hashes known to exist under `storagePath/parse-cache/`. */
@ -1034,6 +1057,11 @@ export const loadParseCacheChunk = async (
*/
const createdCacheDirs = new Set<string>();
/** Drop the mkdir memo after the staging tree is wiped so the next persist recreates it. */
export const forgetCreatedParseCacheDir = (storagePath: string): void => {
createdCacheDirs.delete(getCacheDirPath(storagePath));
};
/**
* Persist one chunk shard and avoid retaining it in RAM for the rest of the
* run. Falls back to `cache.entries` when `storagePath` is unset (unit tests).
@ -1171,8 +1199,18 @@ export const saveParseCache = async (storagePath: string, cache: ParseCache): Pr
}
continue;
}
const existingPath = getCacheChunkPath(storagePath, chunkHash);
if (await copyV8CacheIfPresent(existingPath, chunkPath)) {
// Cold rebuilds persist mid-run under `cache.storagePath` (staging). Prefer
// that generation over a same-hash shard still sitting in the live dir so
// we never publish a mixed old/new pair. Sibling-branch keys (#2106) that
// this run did not rewrite still copy from the live path.
const stagedPath =
cache.storagePath !== undefined && cache.storagePath !== storagePath
? getCacheChunkPath(cache.storagePath, chunkHash)
: undefined;
const livePath = getCacheChunkPath(storagePath, chunkHash);
const fromStaged = Boolean(stagedPath && cache.onDiskKeys?.has(chunkHash));
const sourcePath = fromStaged && stagedPath ? stagedPath : livePath;
if (await copyV8CacheIfPresent(sourcePath, chunkPath)) {
writtenKeys.push(chunkHash);
}
}
@ -1216,10 +1254,12 @@ export const pruneCache = (cache: ParseCache, usedHashes: ReadonlySet<string>):
return removed;
};
const emptyCache = (storagePath?: string): ParseCache => ({
export const emptyParseCache = (storagePath?: string): ParseCache => ({
version: PARSE_CACHE_VERSION,
entries: new Map<string, ParseWorkerResult[]>(),
usedKeys: new Set<string>(),
storagePath,
onDiskKeys: storagePath ? new Set<string>() : undefined,
});
const emptyCache = emptyParseCache;

View file

@ -722,3 +722,74 @@ export const pruneAndSaveDurableParsedFileStore = async (
await fs.writeFile(tmp, JSON.stringify(idx), 'utf-8');
await fs.rename(tmp, path.join(durableDir, DURABLE_INDEX_FILENAME));
};
/**
* Overlay this run's staged durable ParsedFile chunks onto the live store,
* then prune the live tree to `keepKeys`. Live chunks this run did not rewrite
* (other branches, unused hashes) stay until prune. No-op overlay when the
* staged dir is missing.
*/
export const mergeStagedDurableParsedFileStore = async (
liveStoragePath: string,
stagedStoragePath: string,
version: string,
keepKeys: ReadonlySet<string>,
): Promise<void> => {
const liveDir = getDurableParsedFileDir(liveStoragePath);
if (stagedStoragePath === liveStoragePath) {
await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys);
return;
}
const stagedDir = getDurableParsedFileDir(stagedStoragePath);
await fs.mkdir(liveDir, { recursive: true });
let stagedEntries: string[] = [];
try {
stagedEntries = await fs.readdir(stagedDir);
} catch {
await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys);
return;
}
for (const name of stagedEntries) {
if (name === DURABLE_INDEX_FILENAME) continue;
const from = path.join(stagedDir, name);
const to = path.join(liveDir, name);
await replaceDurableChunkDir(from, to);
}
await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys);
};
/** Move `from` onto `to` without deleting `to` until the new tree is in place. */
const replaceDurableChunkDir = async (from: string, to: string): Promise<void> => {
try {
await fs.rename(from, to);
return;
} catch {
/* dest exists, or the rename is cross-device */
}
const backup = `${to}.replacing`;
await fs.rm(backup, { recursive: true, force: true });
let backedUp = false;
try {
await fs.rename(to, backup);
backedUp = true;
} catch {
/* dest was missing */
}
try {
try {
await fs.rename(from, to);
} catch {
await fs.cp(from, to, { recursive: true });
await fs.rm(from, { recursive: true, force: true });
}
} catch (err) {
if (backedUp) {
await fs.rm(to, { recursive: true, force: true }).catch(() => {});
await fs.rename(backup, to).catch(() => {});
}
throw err;
}
if (backedUp) {
await fs.rm(backup, { recursive: true, force: true });
}
};

View file

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

View file

@ -54,6 +54,8 @@ export interface PipelineResult {
usedWorkerPool: boolean;
/** Files actually dispatched to parser workers after parse-cache lookup. */
reparsedFileCount: number;
/** Files restored from parse-cache chunks without parser-worker dispatch. */
parseCacheHitFileCount?: number;
/** Files omitted from scope-resolution while the rest of analysis continued. */
scopeExtractionFailures: readonly string[];
/** Files scope resolution could not inspect because their parser was unavailable. */

View file

@ -0,0 +1,228 @@
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';
import { packageVersion } from '../../../src/core/package-version.js';
const repoRoot = path.resolve(import.meta.dirname, '../../..');
const installedVersion = packageVersion();
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 ${installedVersion}).`,
);
expect(result.stdout).not.toContain('99.0.0 is available');
});
});

View file

@ -0,0 +1,379 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createMCPServer } from '../../../src/mcp/server.js';
import type { UpdateState } from '../../../src/core/update-check.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
interface FakeChecker {
evaluate: () => Promise<UpdateState | null>;
armUpdateRefreshScheduler: (onState: (state: UpdateState | null) => void) => () => void;
}
interface FakeLogger {
info: ReturnType<typeof vi.fn>;
}
function checker(initial: UpdateState | null): {
service: FakeChecker;
publish: (state: UpdateState | null) => void;
stop: ReturnType<typeof vi.fn>;
} {
let subscriber: ((state: UpdateState | null) => void) | undefined;
const stop = vi.fn();
return {
service: {
evaluate: vi.fn().mockResolvedValue(initial),
armUpdateRefreshScheduler: vi.fn((onState) => {
subscriber = onState;
return stop;
}),
},
publish: (state) => subscriber?.(state),
stop,
};
}
function mockBackend() {
return {
callTool: vi
.fn()
.mockImplementation(async (name: string) =>
name === 'list_repos'
? { repositories: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false } }
: { ok: true },
),
listRepos: vi.fn().mockResolvedValue([]),
resolveRepo: vi
.fn()
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
selectToolRepository: vi
.fn()
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
getContext: vi.fn().mockReturnValue(null),
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
disconnect: vi.fn().mockResolvedValue(undefined),
};
}
async function protocolSnapshot(pendingUpdate: boolean): Promise<string> {
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
const backend = mockBackend();
const server = createMCPServer(backend as never);
const client = new Client({ name: 'update-snapshot', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const log = { info: vi.fn() };
const fake = checker(pendingUpdate ? { updateAvailable: true, latestVersion: '99.0.0' } : null);
try {
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
await startMcpUpdateNotifier(log, async () => fake.service);
const snapshot = {
initialize: {
serverInfo: client.getServerVersion(),
capabilities: client.getServerCapabilities(),
},
tools: await client.listTools(),
resources: await client.listResources(),
resource: await client.readResource({ uri: 'gitnexus://repos' }),
prompts: await client.listPrompts(),
call: await client.callTool({ name: 'list_repos', arguments: { limit: 5 } }),
};
return JSON.stringify(snapshot);
} finally {
await client.close();
await server.close();
}
}
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock('../../../src/mcp/server.js');
vi.doUnmock('../../../src/mcp/local/local-backend.js');
vi.doUnmock('../../../src/mcp/repository-policy.js');
vi.doUnmock('../../../src/mcp/http-transport.js');
vi.doUnmock('../../../src/core/logger.js');
vi.doUnmock('../../../src/core/update-check.js');
});
describe('MCP process update notice', () => {
it('keeps the full protocol surface byte-identical with and without a cached update', async () => {
expect(await protocolSnapshot(true)).toBe(await protocolSnapshot(false));
});
it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER'])(
'emits no log and performs no fetch when %s is set',
async (name) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-guard-'));
fs.writeFileSync(
path.join(home, 'update-check.json'),
`${JSON.stringify({
lastCheckAt: new Date().toISOString(),
registry: 'https://registry.npmjs.org',
latestVersion: '99.0.0',
})}\n`,
);
const previousHome = process.env.GITNEXUS_HOME;
const previousCi = process.env.CI;
const previousOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
const fetchStub = vi.fn();
vi.stubGlobal('fetch', fetchStub);
process.env.GITNEXUS_HOME = home;
process.env[name] = '1';
if (name !== 'CI') delete process.env.CI;
const actualChecker = await vi.importActual<
typeof import('../../../src/core/update-check.js')
>('../../../src/core/update-check.js');
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
const log: FakeLogger = { info: vi.fn() };
try {
await startMcpUpdateNotifier(log, async () => actualChecker);
expect(log.info).not.toHaveBeenCalled();
expect(fetchStub).not.toHaveBeenCalled();
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
if (previousCi === undefined) delete process.env.CI;
else process.env.CI = previousCi;
if (previousOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousOptOut;
fs.rmSync(home, { recursive: true, force: true });
}
},
);
it('emits one structured stderr logger event per process per newer version', async () => {
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
const log: FakeLogger = { info: vi.fn() };
const first = checker({ updateAvailable: true, latestVersion: '9.0.0' });
const second = checker({ updateAvailable: true, latestVersion: '9.0.0' });
await startMcpUpdateNotifier(log, async () => first.service);
first.publish({ updateAvailable: true, latestVersion: '9.0.0' });
await startMcpUpdateNotifier(log, async () => second.service);
second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
expect(log.info).toHaveBeenCalledTimes(2);
expect(log.info).toHaveBeenNthCalledWith(
1,
{ event: 'gitnexus.update_available', latestVersion: '9.0.0' },
'GitNexus update available',
);
expect(log.info).toHaveBeenNthCalledWith(
2,
{ event: 'gitnexus.update_available', latestVersion: '10.0.0' },
'GitNexus update available',
);
});
it('uses only the logger channel and never writes directly to stdout', async () => {
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
const stdout = vi.spyOn(process.stdout, 'write');
const log: FakeLogger = { info: vi.fn() };
const fake = checker({ updateAvailable: true, latestVersion: '11.0.0' });
await startMcpUpdateNotifier(log, async () => fake.service);
expect(stdout).not.toHaveBeenCalled();
expect(log.info).toHaveBeenCalledOnce();
});
it('catch-isolates checker import, evaluation, logger, and scheduler failures', async () => {
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
await expect(
startMcpUpdateNotifier({ info: vi.fn() }, async () => {
throw new Error('import failed');
}),
).resolves.toBeUndefined();
await expect(
startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
evaluate: vi.fn().mockRejectedValue(new Error('evaluation failed')),
armUpdateRefreshScheduler: vi.fn(() => () => {}),
})),
).resolves.toBeUndefined();
await expect(
startMcpUpdateNotifier(
{
info: vi.fn(() => {
throw new Error('logger failed');
}),
},
async () => ({
evaluate: vi.fn().mockResolvedValue({
updateAvailable: true,
latestVersion: '12.0.0',
}),
armUpdateRefreshScheduler: vi.fn(() => () => {}),
}),
),
).resolves.toBeUndefined();
await expect(
startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
evaluate: vi.fn().mockResolvedValue(null),
armUpdateRefreshScheduler: vi.fn(() => {
throw new Error('scheduler failed');
}),
})),
).resolves.toBeUndefined();
});
it.each([
['stdio', 'hang'],
['http', 'fail'],
] as const)(
'starts %s notifier work only after its startup boundary and never awaits a registry %s',
async (transport, registryBehavior) => {
const order: string[] = [];
let evaluateStarted!: () => void;
const started = new Promise<void>((resolve) => {
evaluateStarted = resolve;
});
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-'));
const previousHome = process.env.GITNEXUS_HOME;
const previousCi = process.env.CI;
const previousGitnexusOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
const previousNoUpdate = process.env.NO_UPDATE_NOTIFIER;
let releaseFetch: ((response: Response) => void) | undefined;
const fetchStub = vi.fn(() =>
registryBehavior === 'hang'
? new Promise<Response>((resolve) => {
releaseFetch = resolve;
})
: Promise.reject(new Error('registry unavailable')),
);
vi.stubGlobal('fetch', fetchStub);
process.env.GITNEXUS_HOME = home;
delete process.env.CI;
delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
delete process.env.NO_UPDATE_NOTIFIER;
const actualChecker = await vi.importActual<
typeof import('../../../src/core/update-check.js')
>('../../../src/core/update-check.js');
vi.doMock('../../../src/mcp/server.js', () => ({
startMCPServer: vi.fn(async () => {
order.push('stdio-connected');
}),
}));
vi.doMock('../../../src/mcp/local/local-backend.js', () => ({
LocalBackend: class {
async init() {}
async listRepos() {
return [];
}
},
}));
vi.doMock('../../../src/mcp/repository-policy.js', () => ({
createMcpRepositoryPolicy: vi.fn(async () => ({
scopeBackend: (backend: unknown) => backend,
})),
}));
vi.doMock('../../../src/core/logger.js', () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.doMock('../../../src/mcp/http-transport.js', () => ({
resolveAuthToken: vi.fn(),
startMcpHttpServer: vi.fn(async () => {
order.push('http-listening');
}),
}));
vi.doMock('../../../src/core/update-check.js', () => ({
...actualChecker,
evaluate: vi.fn(() => {
order.push('evaluate');
evaluateStarted();
return actualChecker.evaluate({ eligible: true });
}),
armUpdateRefreshScheduler: vi.fn(() => () => {}),
}));
try {
const { mcpCommand } = await import('../../../src/cli/mcp.js');
await expect(
mcpCommand(transport === 'http' ? { http: true, port: '3000' } : undefined),
).resolves.toBeUndefined();
await started;
await vi.waitFor(() => expect(fetchStub).toHaveBeenCalledOnce());
expect(order).toEqual([
transport === 'http' ? 'http-listening' : 'stdio-connected',
'evaluate',
]);
} finally {
if (releaseFetch) {
releaseFetch(new Response('', { status: 503 }));
await actualChecker.refresh({ eligible: true });
}
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
if (previousCi === undefined) delete process.env.CI;
else process.env.CI = previousCi;
if (previousGitnexusOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousGitnexusOptOut;
if (previousNoUpdate === undefined) delete process.env.NO_UPDATE_NOTIFIER;
else process.env.NO_UPDATE_NOTIFIER = previousNoUpdate;
fs.rmSync(home, { recursive: true, force: true });
}
},
);
it('wires the scheduler stop function into process exit', async () => {
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
const fake = checker(null);
const before = new Set(process.listeners('exit'));
await startMcpUpdateNotifier({ info: vi.fn() }, async () => fake.service);
const added = process.listeners('exit').filter((listener) => !before.has(listener));
expect(added).toHaveLength(1);
added[0](0);
expect(fake.stop).toHaveBeenCalledOnce();
process.removeListener('exit', added[0]);
});
it('uses an unrefd scheduler timer so an opted-out MCP process can exit', async () => {
const script = [
"import { startMcpUpdateNotifier } from './dist/cli/mcp.js';",
'await startMcpUpdateNotifier({ info() {} });',
].join('\n');
const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
cwd: REPO_ROOT,
env: { ...process.env, GITNEXUS_NO_UPDATE_NOTIFIER: '1', NODE_OPTIONS: '' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const result = await new Promise<{ code: number | null; stderr: string }>((resolve, reject) => {
let stderr = '';
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('MCP notifier scheduler kept the child process alive'));
}, 2_000);
child.once('error', reject);
child.once('close', (code) => {
clearTimeout(timeout);
resolve({ code, stderr });
});
});
expect(result).toEqual({ code: 0, stderr: '' });
});
});

View file

@ -137,6 +137,25 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
expect(opts.repairFts).toBe(true);
});
it('maps --no-parse-cache to a cold parser run', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { parseCache: false });
const opts = runFullAnalysisMock.mock.calls[0][1];
expect(opts.useParseCache).toBe(false);
expect(opts.force).toBe(true);
});
it('reuses parser output by default', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
const opts = runFullAnalysisMock.mock.calls[0][1];
expect(opts.useParseCache).toBe(true);
});
it('rejects combining --repair-fts with --force', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
@ -144,11 +163,20 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
expect(process.exitCode).toBe(1);
expect(cliErrorMock).toHaveBeenCalledWith(
expect.stringMatching(/cannot combine `--repair-fts` with `--force`/i),
expect.stringMatching(/cannot combine `--repair-fts` with a full rebuild/i),
);
expect(runFullAnalysisMock).not.toHaveBeenCalled();
});
it('rejects combining --repair-fts with --no-parse-cache', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { repairFts: true, parseCache: false });
expect(process.exitCode).toBe(1);
expect(runFullAnalysisMock).not.toHaveBeenCalled();
});
it('passes stats:false as noStats to generateAIContextFiles on the --skills regeneration path (#1477)', async () => {
runFullAnalysisMock.mockResolvedValueOnce({
repoName: 'repo',

View file

@ -0,0 +1,346 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { load } from 'js-yaml';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runWebBuild, shouldBuildWeb, shouldPreserveWebOutput } from '../../scripts/build-web.js';
/** Default prepare/build stay CLI-only; the web UI ships only via prepack --web. */
const REPO_ROOT = path.resolve(__dirname, '../../..');
const PACKAGE_JSON = JSON.parse(
readFileSync(path.join(REPO_ROOT, 'gitnexus/package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
const tempDirs: string[] = [];
interface WorkflowStep {
name?: string;
run?: unknown;
uses?: string;
with?: Record<string, unknown>;
env?: Record<string, unknown>;
if?: string;
'working-directory'?: string;
}
interface WorkflowJob {
'timeout-minutes'?: number;
steps?: WorkflowStep[];
}
function jobs(workflowPath: string): Record<string, WorkflowJob> {
const doc = load(readFileSync(path.join(REPO_ROOT, workflowPath), 'utf8')) as {
jobs?: Record<string, WorkflowJob>;
};
return doc.jobs ?? {};
}
function compositeAction(actionPath: string): {
inputs?: Record<string, { default?: string }>;
runs?: { steps?: WorkflowStep[] };
} {
return load(readFileSync(path.join(REPO_ROOT, actionPath), 'utf8')) as {
inputs?: Record<string, { default?: string }>;
runs?: { steps?: WorkflowStep[] };
};
}
const ciJobs = jobs('.github/workflows/ci-tests.yml');
const publishJobs = jobs('.github/workflows/publish.yml');
const qualityJobs = jobs('.github/workflows/ci-quality.yml');
const setupGitnexus = compositeAction('.github/actions/setup-gitnexus/action.yml');
const setupGitnexusWeb = compositeAction('.github/actions/setup-gitnexus-web/action.yml');
function stepIndex(steps: WorkflowStep[], predicate: (step: WorkflowStep) => boolean): number {
return steps.findIndex(predicate);
}
const installsWeb = (step: WorkflowStep) =>
step['working-directory'] === 'gitnexus-web' && String(step.run ?? '').includes('npm ci');
function runWeb(
fixture: ReturnType<typeof buildFixture>,
overrides: {
timeoutMs?: number;
argv?: string[];
env?: NodeJS.Dict<string>;
exec?: (...args: unknown[]) => unknown;
} = {},
) {
return runWebBuild({
root: fixture.root,
dist: fixture.dist,
timeoutMs: 600_000,
argv: ['node', 'build.js'],
env: {},
exec: vi.fn(),
...overrides,
});
}
function buildFixture({ withWeb = true, withNodeModules = true } = {}) {
const workspace = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-build-web-'));
tempDirs.push(workspace);
const root = path.join(workspace, 'gitnexus');
const dist = path.join(root, 'dist');
const webRoot = path.join(workspace, 'gitnexus-web');
mkdirSync(dist, { recursive: true });
if (withWeb) {
mkdirSync(path.join(webRoot, 'dist', 'assets'), { recursive: true });
writeFileSync(path.join(webRoot, 'package.json'), '{}');
writeFileSync(
path.join(webRoot, 'dist', 'index.html'),
'<script src="/assets/app.js"></script>',
);
writeFileSync(path.join(webRoot, 'dist', 'assets', 'app.js'), 'export {};');
if (withNodeModules) mkdirSync(path.join(webRoot, 'node_modules'));
}
return { root, dist, webRoot, webDest: path.join(root, 'web') };
}
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe('gitnexus build scripts', () => {
it('keeps the default build CLI-only', () => {
expect(PACKAGE_JSON.scripts?.build).toBe('node scripts/build.js');
expect(PACKAGE_JSON.scripts?.prepare).toBe('node scripts/build.js');
expect(PACKAGE_JSON.scripts?.prepare).not.toContain('--web');
});
it('compiles gitnexus-shared with gitnexus TypeScript, not a separate TypeScript 7 install', () => {
const src = readFileSync(path.join(REPO_ROOT, 'gitnexus/scripts/build.js'), 'utf8');
expect(src).toContain("path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js')");
expect(src).toContain('execFileSync(process.execPath, [tscJs]');
expect(src).not.toMatch(/node_modules['"]?, ['"]\.bin/);
expect(src).not.toMatch(/execFileSync\([^)]*tsc\.cmd/);
expect(src).not.toContain("typescript', 'bin', 'tsc'");
});
it.skipIf(!existsSync(path.join(REPO_ROOT, 'gitnexus/node_modules/typescript/lib/tsc.js')))(
'can launch TypeScript via node + lib/tsc.js on this OS',
() => {
const probe = spawnSync(
process.execPath,
[path.join(REPO_ROOT, 'gitnexus/node_modules/typescript/lib/tsc.js'), '--version'],
{ encoding: 'utf8' },
);
expect(probe.status).toBe(0);
expect(probe.stdout).toMatch(/Version \d+/);
},
);
it('builds the web UI from prepack, which is what ships the tarball', () => {
expect(PACKAGE_JSON.scripts?.prepack).toContain('scripts/build.js --web');
expect(PACKAGE_JSON.scripts?.prepack).toContain('scripts/assert-web-assets.mjs web');
expect(PACKAGE_JSON.scripts?.['build:web']).toBe('node scripts/build.js --web');
});
it('recognizes only explicit CLI or environment opt-ins', () => {
expect(shouldBuildWeb(['node', 'build.js'], {})).toBe(false);
expect(shouldBuildWeb(['node', 'build.js', '--web'], {})).toBe(true);
expect(shouldBuildWeb(['node', 'build.js'], { GITNEXUS_BUILD_WEB: '1' })).toBe(true);
expect(shouldBuildWeb(['node', 'build.js'], { GITNEXUS_BUILD_WEB: 'true' })).toBe(false);
});
it('removes stale packaged output from a default build', () => {
const fixture = buildFixture();
mkdirSync(fixture.webDest, { recursive: true });
writeFileSync(path.join(fixture.webDest, 'index.html'), 'stale');
const exec = vi.fn();
const result = runWeb(fixture, { exec });
expect(result.status).toBe('skipped');
expect(exec).not.toHaveBeenCalled();
expect(existsSync(fixture.webDest)).toBe(false);
});
it('preserves prepack output during npm prepare for pack and publish', () => {
for (const npmCommand of ['pack', 'publish']) {
const fixture = buildFixture();
mkdirSync(fixture.webDest, { recursive: true });
writeFileSync(path.join(fixture.webDest, 'index.html'), npmCommand);
expect(
shouldPreserveWebOutput({
npm_lifecycle_event: 'prepare',
npm_command: npmCommand,
}),
).toBe(true);
runWeb(fixture, {
env: { npm_lifecycle_event: 'prepare', npm_command: npmCommand },
});
expect(readFileSync(path.join(fixture.webDest, 'index.html'), 'utf8')).toBe(npmCommand);
}
});
it('fails closed when an explicit web build has no web package', () => {
const fixture = buildFixture({ withWeb: false });
expect(() => runWeb(fixture, { argv: ['node', 'build.js', '--web'] })).toThrow(
'web UI requested, but gitnexus-web was not found',
);
});
it('builds and copies the web UI with an untimed fallback install', () => {
const fixture = buildFixture({ withNodeModules: false });
const exec = vi.fn();
const result = runWeb(fixture, {
timeoutMs: 123_456,
argv: ['node', 'build.js', '--web'],
exec,
});
expect(exec).toHaveBeenNthCalledWith(1, 'npm ci', {
cwd: fixture.webRoot,
stdio: 'inherit',
});
expect(exec).toHaveBeenNthCalledWith(2, 'npm run build', {
cwd: fixture.webRoot,
stdio: 'inherit',
timeout: 123_456,
});
expect(result.status).toBe('built');
expect(readFileSync(path.join(fixture.webDest, 'index.html'), 'utf8')).toContain('app.js');
});
it('rejects a packaged web UI with missing referenced assets', () => {
const fixture = buildFixture();
const checker = path.join(REPO_ROOT, 'gitnexus/scripts/assert-web-assets.mjs');
expect(spawnSync(process.execPath, [checker, path.join(fixture.webRoot, 'dist')]).status).toBe(
0,
);
rmSync(path.join(fixture.webRoot, 'dist', 'assets', 'app.js'));
const invalid = spawnSync(process.execPath, [checker, path.join(fixture.webRoot, 'dist')], {
encoding: 'utf8',
});
expect(invalid.status).toBe(1);
expect(invalid.stderr).toContain('references missing assets');
const missingIndex = spawnSync(
process.execPath,
[checker, path.join(fixture.webRoot, 'none')],
{
encoding: 'utf8',
},
);
expect(missingIndex.status).toBe(1);
expect(missingIndex.stderr).toContain('missing');
});
});
describe('workflows that need the web UI install it themselves', () => {
it('packaged install smoke installs gitnexus-web before npm pack', () => {
const steps = ciJobs['packaged-install-smoke']?.steps ?? [];
const webIdx = stepIndex(steps, installsWeb);
const packIdx = stepIndex(steps, (step) => String(step.run ?? '').includes('npm pack'));
expect(webIdx).toBeGreaterThanOrEqual(0);
expect(packIdx).toBeGreaterThan(webIdx);
});
it('packaged install smoke validates web assets in the installed tarball', () => {
const steps = ciJobs['packaged-install-smoke']?.steps ?? [];
const artifactCheck = steps.find((step) =>
String(step.run ?? '').includes('scripts/assert-web-assets.mjs'),
);
expect(artifactCheck).toBeTruthy();
expect(String(artifactCheck?.run)).toContain('$INSTALLED/web');
});
it('publish installs gitnexus-web before it packs the tarball', () => {
const steps = publishJobs['publish']?.steps ?? [];
const webIdx = stepIndex(steps, installsWeb);
const publishIdx = stepIndex(steps, (step) =>
String(step.run ?? '').includes('npm publish --dry-run'),
);
expect(webIdx).toBeGreaterThanOrEqual(0);
expect(publishIdx).toBeGreaterThan(webIdx);
});
it('node floor compat stays CLI-only — it never installs the web tree', () => {
const steps = ciJobs['node-floor-compat']?.steps ?? [];
expect(steps.length).toBeGreaterThan(0);
expect(steps.filter(installsWeb)).toHaveLength(0);
});
it('packaged install smoke skips a pre-pack CLI build and keeps a 20-minute budget', () => {
const job = ciJobs['packaged-install-smoke'];
const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus');
expect(job?.['timeout-minutes']).toBe(20);
expect(setup?.with?.['lifecycle-scripts']).toBe('false');
expect(setup?.with?.build).toBeUndefined();
});
});
describe('setup-gitnexus job budget', () => {
it('does not npm-ci gitnexus-shared (TypeScript 7 optional-platform install stalls CI)', () => {
const shared = setupGitnexus.runs?.steps?.find((step) => step.name === 'Build gitnexus-shared');
expect(String(shared?.run)).toBe('node ../gitnexus/node_modules/typescript/lib/tsc.js');
expect(String(shared?.run)).not.toContain('.bin');
expect(shared?.if).toContain("lifecycle-scripts == 'false'");
expect(
setupGitnexus.runs?.steps?.some(
(step) =>
step['working-directory'] === 'gitnexus-shared' &&
String(step.run ?? '').includes('npm ci'),
),
).toBe(false);
expect(setupGitnexus.inputs?.['lifecycle-scripts']?.default).toBe('true');
expect(
setupGitnexus.runs?.steps?.some((step) =>
String(step.run ?? '').includes('--ignore-scripts'),
),
).toBe(true);
});
it('setup-gitnexus-web compiles shared with the web TypeScript and skips Playwright browsers', () => {
const setupNode = setupGitnexusWeb.runs?.steps?.find((step) =>
String(step.uses ?? '').startsWith('actions/setup-node@'),
);
const shared = setupGitnexusWeb.runs?.steps?.find(
(step) => step.name === 'Build gitnexus-shared',
);
const webInstall = setupGitnexusWeb.runs?.steps?.find(
(step) => step.name === 'Install web dependencies',
);
expect(String(setupNode?.with?.['cache-dependency-path'])).toBe(
'gitnexus-web/package-lock.json',
);
expect(String(shared?.run)).toBe('node ../gitnexus-web/node_modules/typescript/lib/tsc.js');
expect(String(shared?.run)).not.toContain('.bin');
expect(String(shared?.run)).not.toContain('npm ci');
expect(webInstall?.env?.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD).toBe('1');
});
it('quality typecheck skips prepare/postinstall so tsc --noEmit fits in 10 minutes', () => {
const job = qualityJobs.typecheck;
const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus');
expect(job?.['timeout-minutes']).toBe(10);
expect(setup?.with?.['lifecycle-scripts']).toBe('false');
});
it('quality typecheck-web can finish a cold web install instead of canceling before cache save', () => {
expect(qualityJobs['typecheck-web']?.['timeout-minutes']).toBe(15);
});
it('quality format matches lint budget and skips husky during npm ci', () => {
const formatCi = qualityJobs.format?.steps?.find((step) =>
String(step.run ?? '').includes('npm ci'),
);
const lintCi = qualityJobs.lint?.steps?.find((step) =>
String(step.run ?? '').includes('npm ci'),
);
expect(qualityJobs.format?.['timeout-minutes']).toBe(10);
expect(qualityJobs.lint?.['timeout-minutes']).toBe(10);
expect(String(formatCi?.run)).toContain('--ignore-scripts');
expect(String(lintCi?.run)).toContain('--ignore-scripts');
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -579,6 +579,28 @@ describe('runFullAnalysis — incremental orchestration', () => {
}
}, 300_000);
it('useParseCache:false bypasses the alreadyUpToDate fast path without --force', async () => {
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
const logs: string[] = [];
const cold = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, useParseCache: false },
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
);
expect(cold.alreadyUpToDate).toBeUndefined();
expect(cold.pipelineResult?.parseCacheHitFileCount ?? 0).toBe(0);
expect(cold.pipelineResult?.reparsedFileCount).toBe(7);
expect(logs.join('\n')).toContain('Parser cache bypass requested');
} finally {
await repo.cleanup();
}
}, 300_000);
it('rebuilds for Actuator snapshots and once more when runtime enrichment is disabled', async () => {
const repo = await setupMiniRepo();
const runtimeInput = 'runtime-actuator';
@ -663,12 +685,17 @@ describe('runFullAnalysis — incremental orchestration', () => {
);
expect(steady.alreadyUpToDate).toBe(true);
const forceLogs: string[] = [];
const forcedSteady = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, force: true },
{ onProgress: () => {} },
{ onProgress: () => {}, onLog: (message) => forceLogs.push(message) },
);
expect(forcedSteady.alreadyUpToDate).toBeUndefined();
expect(forceLogs.join('\n')).toContain(
'Rebuilt the graph and FTS while reusing cached parser output',
);
expect(forceLogs.join('\n')).toContain('increment SCHEMA_BUMP');
expect(
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
).toEqual([]);

View file

@ -15,6 +15,8 @@ import {
saveParseCache,
pruneCache,
slimParseWorkerResultsForCache,
getColdParseRebuildDir,
createColdParseRebuildDir,
type ParseCache,
} from '../../src/storage/parse-cache.js';
import { writeV8CacheFile } from '../../src/storage/v8-sidecar.js';
@ -947,4 +949,78 @@ describe('loadParseCache / saveParseCache (round-trip)', () => {
await rm(dir, { recursive: true, force: true });
}
});
it('persists cold-rebuild shards under staging without touching the live parse-cache dir', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-stage-'));
try {
const liveKey = 'a'.repeat(64);
const stagedKey = 'b'.repeat(64);
await saveParseCache(dir, {
version: PARSE_CACHE_VERSION,
entries: new Map([[liveKey, [minimalResult({ fileCount: 1 })]]]),
usedKeys: new Set([liveKey]),
});
const staging = getColdParseRebuildDir(dir);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([liveKey, stagedKey]),
storagePath: staging,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, stagedKey, [minimalResult({ fileCount: 99 })]);
const liveNames = await readdir(path.join(dir, 'parse-cache'));
expect(liveNames).toContain(`${liveKey}.v8`);
expect(liveNames).not.toContain(`${stagedKey}.v8`);
const stagedNames = await readdir(path.join(staging, 'parse-cache'));
expect(stagedNames).toContain(`${stagedKey}.v8`);
const saved = await saveParseCache(dir, cache);
expect(saved.sort()).toEqual([liveKey, stagedKey].sort());
const loaded = await loadParseCache(dir);
expect((await loadParseCacheChunk(loaded, liveKey))?.[0]?.fileCount).toBe(1);
expect((await loadParseCacheChunk(loaded, stagedKey))?.[0]?.fileCount).toBe(99);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('prefers a staged shard over a same-hash live shard when publishing', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-pref-'));
try {
const key = 'c'.repeat(64);
await saveParseCache(dir, {
version: PARSE_CACHE_VERSION,
entries: new Map([[key, [minimalResult({ fileCount: 1 })]]]),
usedKeys: new Set([key]),
});
const staging = getColdParseRebuildDir(dir);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([key]),
storagePath: staging,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 7 })]);
await saveParseCache(dir, cache);
const loaded = await loadParseCache(dir);
expect((await loadParseCacheChunk(loaded, key))?.[0]?.fileCount).toBe(7);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('createColdParseRebuildDir returns distinct directories under the same storage root', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-uniq-'));
try {
const a = await createColdParseRebuildDir(dir);
const b = await createColdParseRebuildDir(dir);
expect(a).not.toBe(b);
expect(a.startsWith(path.join(dir, 'parse-rebuild.'))).toBe(true);
expect(b.startsWith(path.join(dir, 'parse-rebuild.'))).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,13 @@
import { createRequire } from 'node:module';
import { describe, expect, it } from 'vitest';
import { packageVersion } from '../../src/core/package-version.js';
const fromManifest = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
describe('packageVersion', () => {
it('returns the same string as gitnexus/package.json#version', () => {
expect(packageVersion()).toBe(fromManifest);
expect(packageVersion()).toMatch(/^\d+\.\d+\.\d+/);
});
});

View file

@ -15,6 +15,9 @@ import {
getParsedFileStoreDir,
getDurableParsedFileDir,
parsedFileLoadGc,
prepareDurableParsedFileChunk,
pruneAndSaveDurableParsedFileStore,
mergeStagedDurableParsedFileStore,
} from '../../src/storage/parsedfile-store.js';
/**
@ -846,4 +849,45 @@ describe('parsedfile-store receiverChain sanitation', () => {
await rm(dir, { recursive: true, force: true });
}
});
it('overlays staged durable chunks onto the live store without dropping live-only keys', async () => {
const live = await mkdtemp(path.join(tmpdir(), 'pf-live-'));
const staged = await mkdtemp(path.join(tmpdir(), 'pf-stg-'));
try {
const liveOnly = '1'.repeat(64);
const rewritten = '2'.repeat(64);
await prepareDurableParsedFileChunk(getDurableParsedFileDir(live), liveOnly);
persistDurableParsedFileShardSync(getDurableParsedFileDir(live), liveOnly, 1, 0, [
makeParsedFile('keep.c'),
]);
await prepareDurableParsedFileChunk(getDurableParsedFileDir(live), rewritten);
persistDurableParsedFileShardSync(getDurableParsedFileDir(live), rewritten, 1, 0, [
makeParsedFile('old.c'),
]);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(live),
'v-test',
new Set([liveOnly, rewritten]),
);
await prepareDurableParsedFileChunk(getDurableParsedFileDir(staged), rewritten);
persistDurableParsedFileShardSync(getDurableParsedFileDir(staged), rewritten, 1, 0, [
makeParsedFile('new.c'),
]);
await mergeStagedDurableParsedFileStore(
live,
staged,
'v-test',
new Set([liveOnly, rewritten]),
);
expect(await durableChunkHasShards(live, liveOnly, new Set(['keep.c']))).toBe(true);
expect(await durableChunkHasShards(live, rewritten, new Set(['new.c']))).toBe(true);
expect(await durableChunkHasShards(live, rewritten, new Set(['old.c']))).toBe(false);
} finally {
await rm(live, { recursive: true, force: true });
await rm(staged, { recursive: true, force: true });
}
});
});

View file

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

View file

@ -20,11 +20,10 @@ import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import { createRequire } from 'module';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
import { packageVersion } from '../../src/core/package-version.js';
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
const PKG_VERSION = packageVersion();
const NPX_REF = `gitnexus@${PKG_VERSION}`;
// vi.hoisted lets the mock factory below (which is hoisted by Vitest) see

View file

@ -2,10 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createRequire } from 'module';
import { packageVersion } from '../../src/core/package-version.js';
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
const PKG_VERSION = packageVersion();
const NPX_REF = `gitnexus@${PKG_VERSION}`;
const execFileMock = vi.fn((...args: any[]) => {

View file

@ -3,10 +3,9 @@ import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { parse as parseJsonc } from 'jsonc-parser';
import { createRequire } from 'module';
import { packageVersion } from '../../src/core/package-version.js';
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
const PKG_VERSION = packageVersion();
const NPX_REF = `gitnexus@${PKG_VERSION}`;
const execFileMock = vi.fn((...args: any[]) => {

View file

@ -2,12 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createRequire } from 'module';
import { packageVersion } from '../../src/core/package-version.js';
// Match what setup.ts emits — read the version from the same package.json
// so the test never goes stale on a release bump.
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
const PKG_VERSION = packageVersion();
const MCP_PINNED_REF = `gitnexus@${PKG_VERSION}`;
/** Flatten the spied console.log calls into one searchable string. */

View file

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