mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge branch 'abhigyanpatwari:main' into main
This commit is contained in:
commit
98f87ed30c
88 changed files with 5052 additions and 460 deletions
|
|
@ -6,7 +6,7 @@
|
|||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.10",
|
||||
"version": "1.6.11",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./gitnexus-claude-plugin"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
17
.github/actions/setup-gitnexus-web/action.yml
vendored
17
.github/actions/setup-gitnexus-web/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
31
.github/actions/setup-gitnexus/action.yml
vendored
31
.github/actions/setup-gitnexus/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
17
.github/workflows/ci-quality.yml
vendored
17
.github/workflows/ci-quality.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
29
.github/workflows/ci-tests.yml
vendored
29
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
.github/workflows/publish.yml
vendored
11
.github/workflows/publish.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
3
.github/workflows/skill-sync.yml
vendored
3
.github/workflows/skill-sync.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.11", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,17 +14,27 @@ import { buildGraphFromConnectResult } from './lib/apply-connect-result';
|
|||
import {
|
||||
connectToServer,
|
||||
fetchRepos,
|
||||
fetchServerInfo,
|
||||
normalizeServerUrl,
|
||||
connectHeartbeat,
|
||||
BackendError,
|
||||
type ConnectResult,
|
||||
type BackendRepo,
|
||||
type ServerInfo,
|
||||
} from './services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
|
||||
import {
|
||||
ERROR_RESET_DELAY_MS,
|
||||
UPDATE_DISMISSED_VERSION_KEY,
|
||||
UPDATE_INFO_REFETCH_MS,
|
||||
} from './config/ui-constants';
|
||||
import { parseSkipGraphParam } from './lib/graph-load-decision';
|
||||
import { formatBackendError } from './i18n/error-messages';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Positional shell shared by the fixed bottom banners (reconnect, update). */
|
||||
const BOTTOM_BANNER_CLASS =
|
||||
'fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border px-4 py-2 text-sm shadow-lg backdrop-blur';
|
||||
|
||||
/**
|
||||
* Restore-param preference for the auto-connect effect: `repo` carries the
|
||||
* server-resolved path identity (restores the exact repo even when duplicate
|
||||
|
|
@ -65,6 +75,47 @@ const AppContent = () => {
|
|||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
const [serverDisconnected, setServerDisconnected] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() => {
|
||||
try {
|
||||
return localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const wasDisconnectedRef = useRef(false);
|
||||
const refreshSeqRef = useRef(0);
|
||||
|
||||
const refreshServerInfo = useCallback(async (): Promise<void> => {
|
||||
const seq = ++refreshSeqRef.current;
|
||||
try {
|
||||
const next = await fetchServerInfo();
|
||||
// A newer fetch is already in flight; never commit an older payload.
|
||||
if (seq !== refreshSeqRef.current) return;
|
||||
// Keep the previous state (and banner) when nothing changed, so
|
||||
// reconnect refetches don't flicker the UI.
|
||||
setServerInfo((prev) =>
|
||||
prev?.version === next.version &&
|
||||
prev?.latestVersion === next.latestVersion &&
|
||||
prev?.updateAvailable === next.updateAvailable
|
||||
? prev
|
||||
: next,
|
||||
);
|
||||
} catch {
|
||||
// Update state is informational; unavailable server info must not affect the app.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
const latestVersion = serverInfo?.latestVersion;
|
||||
if (!latestVersion) return;
|
||||
setDismissedUpdateVersion(latestVersion);
|
||||
try {
|
||||
localStorage.setItem(UPDATE_DISMISSED_VERSION_KEY, latestVersion);
|
||||
} catch {
|
||||
// The in-memory dismissal still applies when storage is unavailable.
|
||||
}
|
||||
}, [serverInfo?.latestVersion]);
|
||||
|
||||
const handleServerConnect = useCallback(
|
||||
async (result: ConnectResult): Promise<void> => {
|
||||
|
|
@ -202,6 +253,7 @@ const AppContent = () => {
|
|||
// anyway" button) and then awaits agent init, leaving a window where
|
||||
// loadGraphAnyway would silently no-op on a still-null serverBaseUrl.
|
||||
setServerBaseUrl(baseUrl);
|
||||
void refreshServerInfo();
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
fetchRepos()
|
||||
|
|
@ -221,7 +273,14 @@ const AppContent = () => {
|
|||
setProgress(null);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
});
|
||||
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
|
||||
}, [
|
||||
handleServerConnect,
|
||||
refreshServerInfo,
|
||||
setProgress,
|
||||
setViewMode,
|
||||
setServerBaseUrl,
|
||||
setAvailableRepos,
|
||||
]);
|
||||
|
||||
const handleFocusNode = useCallback((nodeId: string) => {
|
||||
graphCanvasRef.current?.focusNode(nodeId);
|
||||
|
|
@ -234,6 +293,14 @@ const AppContent = () => {
|
|||
initializeAgent();
|
||||
}, [refreshLLMSettings, initializeAgent]);
|
||||
|
||||
// While exploring, re-read server info on a slow cadence so an update the
|
||||
// server discovers after page load surfaces without a manual reload.
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'exploring' || serverDisconnected) return;
|
||||
const interval = setInterval(() => void refreshServerInfo(), UPDATE_INFO_REFETCH_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [viewMode, serverDisconnected, refreshServerInfo]);
|
||||
|
||||
// ── Server heartbeat: detect when server goes down while exploring ────────
|
||||
// Uses SSE (EventSource) for instant detection — no polling delay.
|
||||
// On disconnect: show a reconnecting banner instead of resetting to onboarding.
|
||||
|
|
@ -242,12 +309,21 @@ const AppContent = () => {
|
|||
if (viewMode !== 'exploring') return;
|
||||
|
||||
const cleanup = connectHeartbeat(
|
||||
() => setServerDisconnected(false),
|
||||
() => setServerDisconnected(true),
|
||||
() => {
|
||||
setServerDisconnected(false);
|
||||
if (wasDisconnectedRef.current) {
|
||||
wasDisconnectedRef.current = false;
|
||||
void refreshServerInfo();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
wasDisconnectedRef.current = true;
|
||||
setServerDisconnected(true);
|
||||
},
|
||||
);
|
||||
|
||||
return cleanup;
|
||||
}, [viewMode]);
|
||||
}, [viewMode, refreshServerInfo]);
|
||||
|
||||
// Render based on view mode
|
||||
if (viewMode === 'onboarding') {
|
||||
|
|
@ -255,6 +331,7 @@ const AppContent = () => {
|
|||
<DropZone
|
||||
onServerConnect={async (result, serverUrl) => {
|
||||
// Refresh repo list before transitioning so it's ready in the header
|
||||
void refreshServerInfo();
|
||||
const repos = await fetchRepos().catch(() => [] as BackendRepo[]);
|
||||
setAvailableRepos(repos);
|
||||
await handleServerConnect(result);
|
||||
|
|
@ -344,11 +421,39 @@ const AppContent = () => {
|
|||
<StatusBar />
|
||||
|
||||
{serverDisconnected && (
|
||||
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
|
||||
<div
|
||||
className={`${BOTTOM_BANNER_CLASS} border-yellow-500/30 bg-yellow-900/80 text-yellow-200`}
|
||||
>
|
||||
{t('errors:backend.reconnecting')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!serverDisconnected &&
|
||||
serverInfo?.updateAvailable === true &&
|
||||
!!serverInfo.latestVersion &&
|
||||
dismissedUpdateVersion !== serverInfo.latestVersion && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`${BOTTOM_BANNER_CLASS} flex items-center gap-3 border-accent/30 bg-surface/95 text-text-primary`}
|
||||
>
|
||||
<span>
|
||||
{t('common:updateBanner', {
|
||||
latest: serverInfo.latestVersion,
|
||||
installed: serverInfo.version,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissUpdate}
|
||||
aria-label={t('common:updateBannerDismiss')}
|
||||
className="rounded px-2 py-1 text-text-secondary hover:bg-white/10 hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
||||
>
|
||||
{t('common:actions.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Panel (modal) */}
|
||||
<SettingsPanel
|
||||
isOpen={isSettingsPanelOpen}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|||
*/
|
||||
export const AUTH_TOKEN_STORAGE_KEY = 'gitnexus-auth-token';
|
||||
|
||||
/** localStorage key holding the version whose update banner the user dismissed. */
|
||||
export const UPDATE_DISMISSED_VERSION_KEY = 'gitnexus.updateDismissedVersion';
|
||||
|
||||
/** How often the exploring view re-reads /api/info so server-side update discoveries surface. */
|
||||
export const UPDATE_INFO_REFETCH_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Default node-count above which the WebUI connects in chat-only mode (skips
|
||||
* the full graph download). Grounded in sigma.js/graphology prior art: ~10K
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"name": "GitNexus",
|
||||
"nexusAI": "Nexus AI"
|
||||
},
|
||||
"updateBanner": "GitNexus {{latest}} is available — this server runs {{installed}}.",
|
||||
"updateBannerDismiss": "Dismiss update notification",
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"dismiss": "Dismiss",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"name": "GitNexus",
|
||||
"nexusAI": "Nexus AI"
|
||||
},
|
||||
"updateBanner": "GitNexus {{latest}} 已发布 — 此服务器运行 {{installed}}。",
|
||||
"updateBannerDismiss": "关闭更新通知",
|
||||
"actions": {
|
||||
"cancel": "取消",
|
||||
"dismiss": "关闭",
|
||||
|
|
|
|||
|
|
@ -575,9 +575,11 @@ export interface ServerInfo {
|
|||
version: string;
|
||||
launchContext: 'npx' | 'global' | 'local';
|
||||
nodeVersion: string;
|
||||
latestVersion?: string;
|
||||
updateAvailable?: boolean;
|
||||
}
|
||||
|
||||
/** Fetch server info (version, launch context). */
|
||||
/** Fetch server info (version, launch context, and optional update state). */
|
||||
export const fetchServerInfo = async (): Promise<ServerInfo> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/info`);
|
||||
await assertOk(response);
|
||||
|
|
|
|||
349
gitnexus-web/test/unit/update-banner.test.tsx
Normal file
349
gitnexus-web/test/unit/update-banner.test.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../../src/App';
|
||||
import i18n, { i18nReady } from '../../src/i18n';
|
||||
import {
|
||||
UPDATE_DISMISSED_VERSION_KEY,
|
||||
UPDATE_INFO_REFETCH_MS,
|
||||
} from '../../src/config/ui-constants';
|
||||
import type { ConnectResult, ServerInfo } from '../../src/services/backend-client';
|
||||
|
||||
const appStateConfig = vi.hoisted(() => ({
|
||||
initialViewMode: 'onboarding' as 'onboarding' | 'loading' | 'exploring',
|
||||
}));
|
||||
|
||||
const backendMocks = vi.hoisted(() => ({
|
||||
fetchServerInfo: vi.fn<() => Promise<ServerInfo>>(),
|
||||
fetchRepos: vi.fn(async () => []),
|
||||
connectHeartbeat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/hooks/useAppState', async () => {
|
||||
const React = await import('react');
|
||||
const AppStateContext = React.createContext<Record<string, unknown> | null>(null);
|
||||
|
||||
return {
|
||||
AppStateProvider: ({ children }: { children: React.ReactNode }) => {
|
||||
const [viewMode, setViewMode] = React.useState(appStateConfig.initialViewMode);
|
||||
const [serverBaseUrl, setServerBaseUrl] = React.useState<string | null>(null);
|
||||
const stable = React.useRef({
|
||||
setGraph: vi.fn(),
|
||||
setGraphMode: vi.fn(),
|
||||
setChatOnlyNodeCount: vi.fn(),
|
||||
setProgress: vi.fn(),
|
||||
setProjectName: vi.fn(),
|
||||
setSettingsPanelOpen: vi.fn(),
|
||||
refreshLLMSettings: vi.fn(),
|
||||
initializeAgent: vi.fn(async () => {}),
|
||||
startEmbeddingsWithFallback: vi.fn(),
|
||||
setAvailableRepos: vi.fn(),
|
||||
switchRepo: vi.fn(async () => {}),
|
||||
setCurrentRepo: vi.fn(),
|
||||
}).current;
|
||||
|
||||
return (
|
||||
<AppStateContext.Provider
|
||||
value={{
|
||||
...stable,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
progress:
|
||||
viewMode === 'loading'
|
||||
? { phase: 'extracting', percent: 1, message: 'Loading' }
|
||||
: null,
|
||||
isRightPanelOpen: false,
|
||||
isSettingsPanelOpen: false,
|
||||
codeReferences: [],
|
||||
selectedNode: null,
|
||||
isCodePanelOpen: false,
|
||||
serverBaseUrl,
|
||||
setServerBaseUrl,
|
||||
availableRepos: [],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppStateContext.Provider>
|
||||
);
|
||||
},
|
||||
useAppState: () => {
|
||||
const value = React.useContext(AppStateContext);
|
||||
if (!value) throw new Error('Missing test AppStateProvider');
|
||||
return value;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const connectResult: ConnectResult = {
|
||||
nodes: [],
|
||||
relationships: [],
|
||||
repoInfo: {
|
||||
name: 'demo',
|
||||
path: '/workspace/demo',
|
||||
repoPath: '/workspace/demo',
|
||||
indexedAt: '2026-09-04T00:00:00.000Z',
|
||||
},
|
||||
graphSkipped: false,
|
||||
};
|
||||
|
||||
vi.mock('../../src/components/DropZone', () => ({
|
||||
DropZone: ({
|
||||
onServerConnect,
|
||||
}: {
|
||||
onServerConnect: (result: ConnectResult, serverUrl: string) => Promise<void>;
|
||||
}) => (
|
||||
<button onClick={() => void onServerConnect(connectResult, 'http://localhost:4747')}>
|
||||
Connect test backend
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../src/components/LoadingOverlay', () => ({
|
||||
LoadingOverlay: () => <div>Loading view</div>,
|
||||
}));
|
||||
vi.mock('../../src/components/Header', () => ({ Header: () => <header>Header</header> }));
|
||||
vi.mock('../../src/components/GraphCanvas', async () => {
|
||||
const React = await import('react');
|
||||
return { GraphCanvas: React.forwardRef(() => <div>Graph</div>) };
|
||||
});
|
||||
vi.mock('../../src/components/RightPanel', () => ({ RightPanel: () => null }));
|
||||
vi.mock('../../src/components/SettingsPanel', () => ({ SettingsPanel: () => null }));
|
||||
vi.mock('../../src/components/StatusBar', () => ({ StatusBar: () => null }));
|
||||
vi.mock('../../src/components/FileTreePanel', () => ({ FileTreePanel: () => null }));
|
||||
vi.mock('../../src/components/CodeReferencesPanel', () => ({
|
||||
CodeReferencesPanel: () => null,
|
||||
}));
|
||||
vi.mock('../../src/core/llm/settings-service', () => ({
|
||||
getActiveProviderConfig: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/backend-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/backend-client')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchServerInfo: backendMocks.fetchServerInfo,
|
||||
fetchRepos: backendMocks.fetchRepos,
|
||||
connectHeartbeat: backendMocks.connectHeartbeat,
|
||||
};
|
||||
});
|
||||
|
||||
const updateInfo = (latestVersion = '2.0.0'): ServerInfo => ({
|
||||
version: '1.0.0',
|
||||
launchContext: 'global',
|
||||
nodeVersion: 'v22.0.0',
|
||||
latestVersion,
|
||||
updateAvailable: true,
|
||||
});
|
||||
|
||||
async function connectBackend() {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
|
||||
}
|
||||
|
||||
describe('update banner', () => {
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
await i18n.changeLanguage('en');
|
||||
appStateConfig.initialViewMode = 'onboarding';
|
||||
localStorage.removeItem(UPDATE_DISMISSED_VERSION_KEY);
|
||||
backendMocks.fetchServerInfo.mockReset();
|
||||
backendMocks.fetchRepos.mockClear();
|
||||
backendMocks.connectHeartbeat.mockReset();
|
||||
backendMocks.connectHeartbeat.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('fetches only after a backend is selected and renders interpolated update copy', async () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
|
||||
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
|
||||
await connectBackend();
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||
'GitNexus 2.0.0 is available — this server runs 1.0.0.',
|
||||
);
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['false', { ...updateInfo(), updateAvailable: false }],
|
||||
['absent', { version: '1.0.0', launchContext: 'global', nodeVersion: 'v22.0.0' }],
|
||||
])('stays hidden when update state is %s', async (_label, info) => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(info as ServerInfo);
|
||||
render(<App />);
|
||||
|
||||
await connectBackend();
|
||||
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives the reconnect banner priority and refetches after reconnect', async () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
await connectBackend();
|
||||
expect(await screen.findByRole('status')).toBeInTheDocument();
|
||||
|
||||
const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
|
||||
act(() => onReconnecting());
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/reconnect/i)).toBeInTheDocument();
|
||||
|
||||
await act(async () => onConnect());
|
||||
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists dismissal across remounts', async () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
const first = render(<App />);
|
||||
await connectBackend();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Dismiss update notification' }),
|
||||
);
|
||||
expect(localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY)).toBe('2.0.0');
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
|
||||
first.unmount();
|
||||
window.history.replaceState(null, '', '/');
|
||||
render(<App />);
|
||||
await connectBackend();
|
||||
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-shows after a newer version than the dismissed one appears', async () => {
|
||||
localStorage.setItem(UPDATE_DISMISSED_VERSION_KEY, '2.0.0');
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo('2.1.0'));
|
||||
render(<App />);
|
||||
|
||||
await connectBackend();
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
|
||||
});
|
||||
|
||||
it('fails open without rendering an error UI', async () => {
|
||||
backendMocks.fetchServerInfo.mockRejectedValue(new Error('offline'));
|
||||
render(<App />);
|
||||
|
||||
await connectBackend();
|
||||
await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/offline/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('never mounts on onboarding or loading views', () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
const onboarding = render(<App />);
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
|
||||
|
||||
onboarding.unmount();
|
||||
appStateConfig.initialViewMode = 'loading';
|
||||
render(<App />);
|
||||
expect(screen.getByText('Loading view')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has a keyboard-focusable dismiss control with an accessible label', async () => {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
await connectBackend();
|
||||
|
||||
const dismiss = await screen.findByRole('button', { name: 'Dismiss update notification' });
|
||||
dismiss.focus();
|
||||
expect(dismiss).toHaveFocus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders translated copy in zh-CN', async () => {
|
||||
await i18n.changeLanguage('zh-CN');
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
|
||||
await connectBackend();
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||
'GitNexus 2.0.0 已发布 — 此服务器运行 1.0.0。',
|
||||
);
|
||||
});
|
||||
|
||||
it('never commits an older fetch response over a newer one', async () => {
|
||||
const deferred: Array<(value: ServerInfo) => void> = [];
|
||||
backendMocks.fetchServerInfo.mockImplementation(
|
||||
() => new Promise<ServerInfo>((resolve) => deferred.push(resolve)),
|
||||
);
|
||||
render(<App />);
|
||||
await connectBackend();
|
||||
|
||||
// A reconnect refetch starts while the connect fetch is still in flight.
|
||||
const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
|
||||
act(() => onReconnecting());
|
||||
await act(async () => onConnect());
|
||||
expect(deferred).toHaveLength(2);
|
||||
|
||||
// The newer fetch resolves first with 2.1.0; the older fetch resolves late with 2.0.0.
|
||||
await act(async () => deferred[1](updateInfo('2.1.0')));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
|
||||
|
||||
await act(async () => deferred[0](updateInfo('2.0.0')));
|
||||
expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
|
||||
});
|
||||
|
||||
it('refetches server info on the slow exploring cadence', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
|
||||
});
|
||||
expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.0.0 is available');
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS);
|
||||
});
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS);
|
||||
});
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not poll server info while the exploring session is disconnected', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
|
||||
render(<App />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
|
||||
});
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
|
||||
act(() => onReconnecting());
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS * 2);
|
||||
});
|
||||
expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
16
gitnexus/package-lock.json
generated
16
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
35
gitnexus/scripts/assert-web-assets.mjs
Normal file
35
gitnexus/scripts/assert-web-assets.mjs
Normal 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)`);
|
||||
72
gitnexus/scripts/build-web.js
Normal file
72
gitnexus/scripts/build-web.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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.`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
gitnexus/src/cli/command-banner.ts
Normal file
68
gitnexus/src/cli/command-banner.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -25,7 +25,10 @@ import {
|
|||
} from '../core/lbug/lbug-config.js';
|
||||
import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js';
|
||||
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
|
||||
import { updateEligibleInstallSync } from '../core/install-context.js';
|
||||
import { readValidatedUpdateCacheSync, type ValidatedUpdateCache } from '../core/update-cache.js';
|
||||
import { t } from './i18n/index.js';
|
||||
import { cachedUpdateNoticeLine } from './update-notice.js';
|
||||
|
||||
function isCombiningMark(codePoint: number): boolean {
|
||||
return (
|
||||
|
|
@ -202,6 +205,15 @@ function nativeStatusText(check: NativeCheckResult): string {
|
|||
}
|
||||
}
|
||||
|
||||
export function cachedUpdateDoctorLine(options: {
|
||||
installedVersion: string;
|
||||
eligible: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
readCache: () => ValidatedUpdateCache | null;
|
||||
}): string | null {
|
||||
return cachedUpdateNoticeLine(options);
|
||||
}
|
||||
|
||||
export const doctorCommand = async () => {
|
||||
const fingerprint = getRuntimeFingerprint();
|
||||
const capabilities = getRuntimeCapabilities();
|
||||
|
|
@ -212,6 +224,13 @@ export const doctorCommand = async () => {
|
|||
console.log(` ${label('doctor.labels.os', 10)}${fingerprint.platform}/${fingerprint.arch}`);
|
||||
console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`);
|
||||
console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`);
|
||||
const updateLine = cachedUpdateDoctorLine({
|
||||
installedVersion: fingerprint.gitnexus,
|
||||
eligible: updateEligibleInstallSync(),
|
||||
env: process.env,
|
||||
readCache: () => readValidatedUpdateCacheSync(),
|
||||
});
|
||||
if (updateLine) console.log(` ${updateLine}`);
|
||||
console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`);
|
||||
// OS page size next to the LadybugDB version because the two interact:
|
||||
// @ladybugdb/core < 0.18.0 assumed 4 KiB pages in its buffer manager and
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const COMMAND_DESCRIPTION_KEYS = {
|
|||
list: 'help.command.list.description',
|
||||
status: 'help.command.status.description',
|
||||
doctor: 'help.command.doctor.description',
|
||||
update: 'help.command.update.description',
|
||||
embeddings: 'help.command.embeddings.description',
|
||||
'embeddings install': 'help.command.embeddings.install.description',
|
||||
clean: 'help.command.clean.description',
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 可完全禁用上限。',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
128
gitnexus/src/cli/update-notice.ts
Normal file
128
gitnexus/src/cli/update-notice.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import { updateEligibleInstallSync } from '../core/install-context.js';
|
||||
import {
|
||||
isNewerVersion,
|
||||
readValidatedUpdateCacheSync,
|
||||
updateNotifierOptedOut,
|
||||
updateRefreshInProgress,
|
||||
type ValidatedUpdateCache,
|
||||
} from '../core/update-cache.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
const EXCLUDED_COMMANDS = new Set([
|
||||
'augment',
|
||||
'mcp',
|
||||
'serve',
|
||||
'eval-server',
|
||||
'update',
|
||||
'__update-check',
|
||||
]);
|
||||
const EXCLUDED_FLAGS = new Set(['--help', '-h', '--version', '-V']);
|
||||
|
||||
type SpawnResult = { unref(): void };
|
||||
type SpawnLike = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { detached: true; stdio: 'ignore'; windowsHide: true },
|
||||
) => SpawnResult;
|
||||
|
||||
export interface CliUpdateNoticeDependencies {
|
||||
argv: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
installedVersion: string;
|
||||
isTTY: boolean | undefined;
|
||||
eligible: boolean;
|
||||
now: number;
|
||||
readCache: (options: { env: NodeJS.ProcessEnv; now: number }) => ValidatedUpdateCache | null;
|
||||
writeStderr: (line: string) => unknown;
|
||||
spawn: SpawnLike;
|
||||
}
|
||||
|
||||
function excludedInvocation(argv: string[]): boolean {
|
||||
const args = argv.slice(2);
|
||||
if (args.some((arg) => EXCLUDED_FLAGS.has(arg))) return true;
|
||||
const command = args.find((arg) => !arg.startsWith('-'));
|
||||
return command !== undefined && EXCLUDED_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
export function updateNoticeText(installedVersion: string, latestVersion: string): string {
|
||||
return t('update.available', { installedVersion, latestVersion });
|
||||
}
|
||||
|
||||
/** Shared cache-gated notice line used by the CLI banner and `doctor`. */
|
||||
export function cachedUpdateNoticeLine(options: {
|
||||
installedVersion: string;
|
||||
eligible: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
readCache: () => ValidatedUpdateCache | null;
|
||||
}): string | null {
|
||||
try {
|
||||
if (!options.eligible || updateNotifierOptedOut(options.env)) return null;
|
||||
const cache = options.readCache();
|
||||
if (!cache?.latestVersion || !isNewerVersion(options.installedVersion, cache.latestVersion)) {
|
||||
return null;
|
||||
}
|
||||
return updateNoticeText(options.installedVersion, cache.latestVersion);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function runCliUpdateNotice(deps: CliUpdateNoticeDependencies): void {
|
||||
try {
|
||||
if (
|
||||
deps.isTTY !== true ||
|
||||
updateNotifierOptedOut(deps.env) ||
|
||||
!deps.eligible ||
|
||||
excludedInvocation(deps.argv)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = deps.readCache({ env: deps.env, now: deps.now });
|
||||
if (cache?.latestVersion && isNewerVersion(deps.installedVersion, cache.latestVersion)) {
|
||||
deps.writeStderr(`${updateNoticeText(deps.installedVersion, cache.latestVersion)}\n`);
|
||||
}
|
||||
|
||||
if (cache === null || cache.stale) {
|
||||
// Coalesce parallel invocations: when a live process holds the refresh
|
||||
// lock, its refresh covers us, so don't fork another CLI.
|
||||
if (!updateRefreshInProgress(deps.env)) {
|
||||
try {
|
||||
deps
|
||||
.spawn(process.execPath, [deps.argv[1] ?? '', '__update-check'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
})
|
||||
.unref();
|
||||
} catch {
|
||||
// Update refresh is best-effort and must never reach Commander parsing.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache reads and all adapter logic fail open.
|
||||
}
|
||||
}
|
||||
|
||||
export function runProcessCliUpdateNotice(installedVersion: string): void {
|
||||
if (
|
||||
process.stderr.isTTY !== true ||
|
||||
updateNotifierOptedOut(process.env) ||
|
||||
excludedInvocation(process.argv)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
runCliUpdateNotice({
|
||||
argv: process.argv,
|
||||
env: process.env,
|
||||
installedVersion,
|
||||
isTTY: process.stderr.isTTY,
|
||||
eligible: updateEligibleInstallSync(),
|
||||
now: Date.now(),
|
||||
readCache: readValidatedUpdateCacheSync,
|
||||
writeStderr: (line) => process.stderr.write(line),
|
||||
spawn: nodeSpawn as unknown as SpawnLike,
|
||||
});
|
||||
}
|
||||
123
gitnexus/src/cli/update.ts
Normal file
123
gitnexus/src/cli/update.ts
Normal 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'));
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
170
gitnexus/src/core/install-context.ts
Normal file
170
gitnexus/src/core/install-context.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import fsSync from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const EPHEMERAL_SEGMENTS = new Set(['_npx', '_cacache']);
|
||||
const EPHEMERAL_DLX_OWNERS = new Set(['pnpm', 'yarn']);
|
||||
|
||||
function isInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function hasEphemeralMarker(candidate: string): boolean {
|
||||
const normalized = candidate.replaceAll('\\', '/').toLowerCase();
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
if (segments.some((segment) => EPHEMERAL_SEGMENTS.has(segment))) return true;
|
||||
// `dlx` is only ephemeral next to a package-manager owner (pnpm dlx / yarn
|
||||
// dlx). A project directory that happens to be named `dlx` is a normal install.
|
||||
if (segments.includes('dlx') && segments.some((segment) => EPHEMERAL_DLX_OWNERS.has(segment))) {
|
||||
return true;
|
||||
}
|
||||
return normalized.includes('/.bun/install/cache/') || normalized.includes('/bun/install/cache/');
|
||||
}
|
||||
|
||||
function findPackageDir(entryPath: string): string | null {
|
||||
let current = path.dirname(path.resolve(entryPath));
|
||||
for (;;) {
|
||||
if (
|
||||
path.basename(current) === 'gitnexus' &&
|
||||
path.basename(path.dirname(current)) === 'node_modules'
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
interface EligibilityProbes {
|
||||
realEntry: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** Resolved npm cache dir, when npm_config_cache is set. */
|
||||
realCache: string | null;
|
||||
packageDir: string | null;
|
||||
/** Whether the resolved package directory carries a .git checkout marker. */
|
||||
packageDirHasGit: boolean;
|
||||
/** Resolved npm prefix, when npm_config_prefix is set. */
|
||||
realPrefix: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure classification shared by the async and sync variants. Realpath
|
||||
* resolution deliberately makes a linked node_modules entry point at its
|
||||
* development checkout, which is therefore ineligible.
|
||||
*/
|
||||
function classifyEligibility(probes: EligibilityProbes): boolean {
|
||||
const { realEntry, env, realCache, packageDir, packageDirHasGit, realPrefix } = probes;
|
||||
const corroboratingPaths = [
|
||||
realEntry,
|
||||
env.npm_execpath,
|
||||
env.npm_config_cache && path.resolve(env.npm_config_cache),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (corroboratingPaths.some(hasEphemeralMarker)) return false;
|
||||
|
||||
if (realCache && isInside(realCache, realEntry)) return false;
|
||||
|
||||
// Published packages do not carry .git. This also rejects unusual installs
|
||||
// copied wholesale from a checkout.
|
||||
if (!packageDir || packageDirHasGit) return false;
|
||||
|
||||
if (realPrefix && isInside(realPrefix, realEntry)) return true;
|
||||
|
||||
// A package rooted at node_modules/gitnexus is a persistent project-local
|
||||
// install after ephemeral/cache layouts have been excluded above.
|
||||
return true;
|
||||
}
|
||||
|
||||
let memoizedEligible: boolean | undefined;
|
||||
|
||||
/** True only for a persistent npm global or project-local installation. */
|
||||
export async function updateEligibleInstall(
|
||||
entryPath?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<boolean> {
|
||||
const useMemo = entryPath === undefined && env === process.env;
|
||||
if (useMemo && memoizedEligible !== undefined) return memoizedEligible;
|
||||
const result = await classifyAsync(entryPath ?? process.argv[1] ?? '', env);
|
||||
if (useMemo) memoizedEligible = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function classifyAsync(entryPath: string, env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
if (!entryPath) return false;
|
||||
try {
|
||||
const realEntry = await fs.realpath(entryPath);
|
||||
const realCache = env.npm_config_cache
|
||||
? await fs
|
||||
.realpath(env.npm_config_cache)
|
||||
.catch(() => path.resolve(env.npm_config_cache as string))
|
||||
: null;
|
||||
const packageDir = findPackageDir(realEntry);
|
||||
const packageDirHasGit = packageDir
|
||||
? await fs
|
||||
.access(path.join(packageDir, '.git'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
: false;
|
||||
const realPrefix = env.npm_config_prefix
|
||||
? await fs.realpath(env.npm_config_prefix).catch(() => path.resolve(env.npm_config_prefix))
|
||||
: null;
|
||||
return classifyEligibility({
|
||||
realEntry,
|
||||
env,
|
||||
realCache,
|
||||
packageDir,
|
||||
packageDirHasGit,
|
||||
realPrefix,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous entry-point variant for pre-Commander startup checks. */
|
||||
export function updateEligibleInstallSync(
|
||||
entryPath?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
const useMemo = entryPath === undefined && env === process.env;
|
||||
if (useMemo && memoizedEligible !== undefined) return memoizedEligible;
|
||||
const result = classifySync(entryPath ?? process.argv[1] ?? '', env);
|
||||
if (useMemo) memoizedEligible = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function classifySync(entryPath: string, env: NodeJS.ProcessEnv): boolean {
|
||||
if (!entryPath) return false;
|
||||
try {
|
||||
const realEntry = fsSync.realpathSync(entryPath);
|
||||
let realCache: string | null = null;
|
||||
if (env.npm_config_cache) {
|
||||
try {
|
||||
realCache = fsSync.realpathSync(env.npm_config_cache);
|
||||
} catch {
|
||||
realCache = path.resolve(env.npm_config_cache);
|
||||
}
|
||||
}
|
||||
const packageDir = findPackageDir(realEntry);
|
||||
const packageDirHasGit = packageDir ? fsSync.existsSync(path.join(packageDir, '.git')) : false;
|
||||
let realPrefix: string | null = null;
|
||||
if (env.npm_config_prefix) {
|
||||
try {
|
||||
realPrefix = fsSync.realpathSync(env.npm_config_prefix);
|
||||
} catch {
|
||||
realPrefix = path.resolve(env.npm_config_prefix);
|
||||
}
|
||||
}
|
||||
return classifyEligibility({
|
||||
realEntry,
|
||||
env,
|
||||
realCache,
|
||||
packageDir,
|
||||
packageDirHasGit,
|
||||
realPrefix,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
174
gitnexus/src/core/net/url-guard.ts
Normal file
174
gitnexus/src/core/net/url-guard.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { isIP } from 'net';
|
||||
|
||||
// Cloud metadata hostnames that must never be reachable via user-supplied URLs
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
'metadata.google.internal',
|
||||
'metadata.azure.com',
|
||||
'metadata.internal',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate an outbound http(s) URL to prevent SSRF.
|
||||
* Only allows https:// and http:// schemes. Blocks private/internal addresses,
|
||||
* IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
|
||||
*/
|
||||
export function validateGitUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
|
||||
if (!['https:', 'http:'].includes(parsed.protocol)) {
|
||||
throw new Error('Only https:// and http:// git URLs are allowed');
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error('Git URLs must not include query strings or fragments');
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block known dangerous hostnames (cloud metadata services)
|
||||
if (BLOCKED_HOSTNAMES.has(host)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
|
||||
let normalizedHost = host;
|
||||
if (host.startsWith('[') && host.endsWith(']')) {
|
||||
normalizedHost = host.slice(1, -1);
|
||||
}
|
||||
|
||||
// Check if this is an IPv6 address
|
||||
// Use manual colon detection as fallback since isIP may return 0 for some
|
||||
// normalized IPv6 forms (e.g. ::ffff:7f00:1)
|
||||
const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
|
||||
if (isIPv6) {
|
||||
assertNotPrivateIPv6(normalizedHost);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an IPv4 address (including numeric encodings)
|
||||
if (isIP(normalizedHost) === 4) {
|
||||
assertNotPrivateIPv4(normalizedHost);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-IP hostnames, check for numeric IP tricks
|
||||
// Decimal encoding: 2130706433 = 127.0.0.1
|
||||
// Hex encoding: 0x7f000001 = 127.0.0.1
|
||||
if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Standard IPv4 regex checks for dotted notation
|
||||
if (
|
||||
/^127\./.test(host) ||
|
||||
/^10\./.test(host) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
||||
/^192\.168\./.test(host) ||
|
||||
/^169\.254\./.test(host) ||
|
||||
/^0\./.test(host) ||
|
||||
host === '0.0.0.0' ||
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
|
||||
/^198\.1[89]\./.test(host)
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotPrivateIPv6(ip: string): void {
|
||||
// Expand common compressed forms for comparison
|
||||
const lower = ip.toLowerCase();
|
||||
|
||||
// IPv6 loopback
|
||||
if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Unspecified address
|
||||
if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv6 link-local (fe80::/10)
|
||||
if (
|
||||
lower.startsWith('fe80') ||
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
|
||||
// Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
|
||||
if (lower.startsWith('::ffff:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Expanded IPv4-mapped form only (0:0:0:0:0:ffff:…). A public address that
|
||||
// merely contains a `ffff` hextet is not mapped.
|
||||
if (lower.startsWith('0:0:0:0:0:ffff:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
|
||||
// Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
|
||||
// is hidden in the last 32 bits without the ::ffff: marker, so the check
|
||||
// above misses it. The form is still routable to the embedded IPv4 on most
|
||||
// network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
|
||||
if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
|
||||
// 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
|
||||
// ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
|
||||
// e.g. 64:ff9b::7f00:1 → 127.0.0.1.
|
||||
// The check intentionally covers the full 64:ff9b::/32 block (broader than
|
||||
// the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
|
||||
// blocking the whole prefix is defensively sound and prevents a narrower
|
||||
// CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
|
||||
// future translation assignment.
|
||||
if (lower.startsWith('64:ff9b:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
|
||||
// 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
|
||||
// protocol was deprecated by RFC 7526 and the public relay anycast
|
||||
// (192.88.99.1) has been retired, so broad-blocking the prefix has near-
|
||||
// zero false-positive cost while closing the IPv4-embedded bypass.
|
||||
// Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
|
||||
// impractical and is out of scope here.
|
||||
if (lower.startsWith('2002:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotPrivateIPv4(ip: string): void {
|
||||
const parts = ip.split('.').map(Number);
|
||||
const [a, b] = parts;
|
||||
if (
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254) ||
|
||||
a === 0 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 198 && (b === 18 || b === 19))
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
12
gitnexus/src/core/package-version.ts
Normal file
12
gitnexus/src/core/package-version.ts
Normal 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 : '';
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
172
gitnexus/src/core/update-cache.ts
Normal file
172
gitnexus/src/core/update-cache.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getGlobalDir } from '../storage/global-dir.js';
|
||||
import { isProcessAlive, readProcessStartTime } from '../utils/process-identity.js';
|
||||
|
||||
export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
|
||||
export const STRICT_UPDATE_VERSION = /^\d+\.\d+\.\d+$/;
|
||||
export const DEFAULT_UPDATE_REGISTRY = 'https://registry.npmjs.org';
|
||||
|
||||
export interface UpdateCacheEntry {
|
||||
lastCheckAt: string;
|
||||
registry: string;
|
||||
latestVersion?: string;
|
||||
}
|
||||
|
||||
export interface ValidatedUpdateCache {
|
||||
lastCheckAt: number;
|
||||
latestVersion?: string;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
/** Broad truthy parsing shared by the update-notifier guards. */
|
||||
export function isTruthyEnv(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
return !['', '0', 'false', 'no', 'off'].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
/** The update notifier is disabled by either opt-out env or a CI environment. */
|
||||
export function updateNotifierOptedOut(env: NodeJS.ProcessEnv): boolean {
|
||||
return (
|
||||
isTruthyEnv(env.GITNEXUS_NO_UPDATE_NOTIFIER) ||
|
||||
isTruthyEnv(env.NO_UPDATE_NOTIFIER) ||
|
||||
isTruthyEnv(env.CI)
|
||||
);
|
||||
}
|
||||
|
||||
/** Freshness gate for the 24h TTL; future-dated timestamps are stale. */
|
||||
export function isUpdateCacheFresh(lastCheckAt: string, now: number): boolean {
|
||||
const checkedAt = Date.parse(lastCheckAt);
|
||||
return checkedAt <= now && now - checkedAt < UPDATE_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/** Strict x.y.z numeric comparison. Invalid or prerelease versions are silent. */
|
||||
export function isNewerVersion(installedVersion: string, latestVersion: string): boolean {
|
||||
if (!STRICT_UPDATE_VERSION.test(installedVersion) || !STRICT_UPDATE_VERSION.test(latestVersion)) {
|
||||
return false;
|
||||
}
|
||||
const installed = installedVersion.split('.').map(BigInt);
|
||||
const latest = latestVersion.split('.').map(BigInt);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (latest[index] !== installed[index]) return latest[index] > installed[index];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let registryMemo: { key: string; value: { identity: string; packageUrl: string } } | undefined;
|
||||
|
||||
export function normalizedUpdateRegistry(env: NodeJS.ProcessEnv = process.env): {
|
||||
identity: string;
|
||||
packageUrl: string;
|
||||
} {
|
||||
const key = env.npm_config_registry ?? '';
|
||||
if (registryMemo?.key === key) return registryMemo.value;
|
||||
const value = buildUpdateRegistry(key);
|
||||
registryMemo = { key, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildUpdateRegistry(rawRegistry: string): { identity: string; packageUrl: string } {
|
||||
const parsed = new URL(rawRegistry || DEFAULT_UPDATE_REGISTRY);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error('Unsupported npm registry protocol');
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error('Registry URL cannot contain query or fragment');
|
||||
}
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
const identity = `${parsed.protocol}//${parsed.host}${pathname}`;
|
||||
// `/<pkg>/latest` is the small dist-tag document. The full packument at
|
||||
// `/<pkg>` is multi-megabyte on this package and cannot fit the fetch cap.
|
||||
const packagePath = `${pathname}/gitnexus/latest`.replace(/\/{2,}/g, '/');
|
||||
return { identity, packageUrl: `${parsed.protocol}//${parsed.host}${packagePath}` };
|
||||
}
|
||||
|
||||
export function updateCheckCachePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(env.GITNEXUS_HOME || getGlobalDir(), 'update-check.json');
|
||||
}
|
||||
|
||||
export function updateCheckLockPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(env.GITNEXUS_HOME || getGlobalDir(), 'update-check.lock');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort synchronous probe: is a refresh plausibly in flight? Used to
|
||||
* coalesce detached refresh spawns. A dead same-host owner returns false so
|
||||
* the spawned child can reclaim the stale lock; a foreign-host owner returns
|
||||
* false and lets the child's full lock logic decide.
|
||||
*/
|
||||
export function updateRefreshInProgress(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
try {
|
||||
const owner = JSON.parse(fs.readFileSync(updateCheckLockPath(env), 'utf8')) as {
|
||||
pid?: unknown;
|
||||
hostname?: unknown;
|
||||
processStartTime?: unknown;
|
||||
};
|
||||
if (typeof owner.pid !== 'number' || owner.pid <= 0) return false;
|
||||
if (owner.hostname !== os.hostname()) return false;
|
||||
if (!isProcessAlive(owner.pid)) return false;
|
||||
// Same rule as acquireFileLock: a live PID with a different start time is
|
||||
// reuse, not the lock owner. Unreadable start time stays conservative
|
||||
// (treat as in progress) so we don't spawn a racing child.
|
||||
if (typeof owner.processStartTime === 'string' && owner.processStartTime) {
|
||||
const currentStartTime = readProcessStartTime(owner.pid);
|
||||
if (currentStartTime && currentStartTime !== owner.processStartTime) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUpdateCache(raw: string, registry: string): UpdateCacheEntry | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<UpdateCacheEntry>;
|
||||
if (
|
||||
typeof parsed.lastCheckAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(parsed.lastCheckAt)) ||
|
||||
parsed.registry !== registry ||
|
||||
(parsed.latestVersion !== undefined &&
|
||||
(typeof parsed.latestVersion !== 'string' ||
|
||||
!STRICT_UPDATE_VERSION.test(parsed.latestVersion)))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
lastCheckAt: parsed.lastCheckAt,
|
||||
registry: parsed.registry,
|
||||
...(parsed.latestVersion === undefined ? {} : { latestVersion: parsed.latestVersion }),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readValidatedUpdateCacheSync(
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: number;
|
||||
} = {},
|
||||
): ValidatedUpdateCache | null {
|
||||
try {
|
||||
const env = options.env ?? process.env;
|
||||
const registry = normalizedUpdateRegistry(env);
|
||||
const raw = fs.readFileSync(updateCheckCachePath(env), 'utf8');
|
||||
const entry = parseUpdateCache(raw, registry.identity);
|
||||
if (!entry) return null;
|
||||
const lastCheckAt = Date.parse(entry.lastCheckAt);
|
||||
const now = options.now ?? Date.now();
|
||||
return {
|
||||
lastCheckAt,
|
||||
...(entry.latestVersion === undefined ? {} : { latestVersion: entry.latestVersion }),
|
||||
stale: !isUpdateCacheFresh(entry.lastCheckAt, now),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
328
gitnexus/src/core/update-check.ts
Normal file
328
gitnexus/src/core/update-check.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import express from 'express';
|
|||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import {
|
||||
canonicalizePath,
|
||||
cloneDirBelongsToEntry,
|
||||
|
|
@ -80,9 +79,19 @@ import { UPLOAD_ROOT } from './upload-paths.js';
|
|||
import { sweepStaleUploads } from './upload-sweep.js';
|
||||
import { isRfc1918PrivateIpv4 } from './private-ip.js';
|
||||
import { logger, flushLoggerSync } from '../core/logger.js';
|
||||
import {
|
||||
bindServeUpdateControllerLifecycle,
|
||||
buildServerInfo,
|
||||
createServeUpdateController,
|
||||
} from './update-controller.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json');
|
||||
export {
|
||||
bindServeUpdateControllerLifecycle,
|
||||
buildServerInfo,
|
||||
createServeUpdateController,
|
||||
type ServerInfoResponse,
|
||||
type ServeUpdateController,
|
||||
} from './update-controller.js';
|
||||
|
||||
/**
|
||||
* Determine whether an HTTP Origin header value is allowed by CORS policy.
|
||||
|
|
@ -844,6 +853,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
await backend.init();
|
||||
const cleanupMcp = await mountMCPEndpoints(app, backend);
|
||||
const jobManager = new JobManager();
|
||||
const updateController = createServeUpdateController();
|
||||
|
||||
// Backstop: remove any upload staging dirs orphaned by a previous crash.
|
||||
void sweepStaleUploads().catch(() => {});
|
||||
|
|
@ -981,21 +991,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
|
||||
// Server info: version and launch context (npx / global / local dev)
|
||||
app.get('/api/info', (_req, res) => {
|
||||
const execPath = process.env.npm_execpath ?? '';
|
||||
const argv0 = process.argv[1] ?? '';
|
||||
let launchContext: 'npx' | 'global' | 'local';
|
||||
if (
|
||||
execPath.includes('npx') ||
|
||||
argv0.includes('_npx') ||
|
||||
process.env.npm_config_prefix?.includes('_npx')
|
||||
) {
|
||||
launchContext = 'npx';
|
||||
} else if (argv0.includes('node_modules')) {
|
||||
launchContext = 'local';
|
||||
} else {
|
||||
launchContext = 'global';
|
||||
}
|
||||
res.json({ version: pkg.version, launchContext, nodeVersion: process.version });
|
||||
res.json(buildServerInfo(updateController.snapshot()));
|
||||
});
|
||||
|
||||
// List all registered repos
|
||||
|
|
@ -2060,12 +2056,15 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
resolve();
|
||||
});
|
||||
server.on('error', (err) => reject(err));
|
||||
// `listening` is the successful startup boundary for notifier work.
|
||||
bindServeUpdateControllerLifecycle(server, updateController);
|
||||
|
||||
// Graceful shutdown — close Express + LadybugDB cleanly. Pino's default
|
||||
// destination is `sync: false` (buffered); `flushLoggerSync()` before
|
||||
// `process.exit` so records emitted during cleanup reach stderr.
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down...');
|
||||
updateController.stop();
|
||||
server.close();
|
||||
jobManager.dispose();
|
||||
embedJobManager.dispose();
|
||||
|
|
|
|||
|
|
@ -8,17 +8,19 @@
|
|||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { isIP } from 'net';
|
||||
import os from 'node:os';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { getGlobalDir } from '../storage/repo-manager.js';
|
||||
import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js';
|
||||
import { validateGitUrl } from '../core/net/url-guard.js';
|
||||
import {
|
||||
assertDirectoryOwnerAndPermissions,
|
||||
quarantineAutoSyncPartial,
|
||||
} from '../core/auto-sync/path-security.js';
|
||||
import { validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
|
||||
|
||||
export { validateGitUrl };
|
||||
|
||||
/**
|
||||
* Root directory for all cloned repositories. Targets must resolve inside this.
|
||||
*
|
||||
|
|
@ -92,178 +94,6 @@ export function getCloneDir(repoName: string): string {
|
|||
return path.join(CLONE_ROOT, repoName);
|
||||
}
|
||||
|
||||
// Cloud metadata hostnames that must never be reachable via user-supplied URLs
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
'metadata.google.internal',
|
||||
'metadata.azure.com',
|
||||
'metadata.internal',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate a git URL to prevent SSRF attacks.
|
||||
* Only allows https:// and http:// schemes. Blocks private/internal addresses,
|
||||
* IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
|
||||
*/
|
||||
export function validateGitUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
|
||||
if (!['https:', 'http:'].includes(parsed.protocol)) {
|
||||
throw new Error('Only https:// and http:// git URLs are allowed');
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error('Git URLs must not include query strings or fragments');
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block known dangerous hostnames (cloud metadata services)
|
||||
if (BLOCKED_HOSTNAMES.has(host)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
|
||||
let normalizedHost = host;
|
||||
if (host.startsWith('[') && host.endsWith(']')) {
|
||||
normalizedHost = host.slice(1, -1);
|
||||
}
|
||||
|
||||
// Check if this is an IPv6 address
|
||||
// Use manual colon detection as fallback since isIP may return 0 for some
|
||||
// normalized IPv6 forms (e.g. ::ffff:7f00:1)
|
||||
const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
|
||||
if (isIPv6) {
|
||||
assertNotPrivateIPv6(normalizedHost);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an IPv4 address (including numeric encodings)
|
||||
if (isIP(normalizedHost) === 4) {
|
||||
assertNotPrivateIPv4(normalizedHost);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-IP hostnames, check for numeric IP tricks
|
||||
// Decimal encoding: 2130706433 = 127.0.0.1
|
||||
// Hex encoding: 0x7f000001 = 127.0.0.1
|
||||
if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Standard IPv4 regex checks for dotted notation
|
||||
if (
|
||||
/^127\./.test(host) ||
|
||||
/^10\./.test(host) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
||||
/^192\.168\./.test(host) ||
|
||||
/^169\.254\./.test(host) ||
|
||||
/^0\./.test(host) ||
|
||||
host === '0.0.0.0' ||
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
|
||||
/^198\.1[89]\./.test(host)
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotPrivateIPv6(ip: string): void {
|
||||
// Expand common compressed forms for comparison
|
||||
const lower = ip.toLowerCase();
|
||||
|
||||
// IPv6 loopback
|
||||
if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Unspecified address
|
||||
if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv6 link-local (fe80::/10)
|
||||
if (
|
||||
lower.startsWith('fe80') ||
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
|
||||
// Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
|
||||
if (lower.startsWith('::ffff:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// Also catch the expanded form: 0:0:0:0:0:ffff:
|
||||
if (lower.includes(':ffff:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
|
||||
// Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
|
||||
// is hidden in the last 32 bits without the ::ffff: marker, so the check
|
||||
// above misses it. The form is still routable to the embedded IPv4 on most
|
||||
// network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
|
||||
if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
|
||||
// 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
|
||||
// ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
|
||||
// e.g. 64:ff9b::7f00:1 → 127.0.0.1.
|
||||
// The check intentionally covers the full 64:ff9b::/32 block (broader than
|
||||
// the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
|
||||
// blocking the whole prefix is defensively sound and prevents a narrower
|
||||
// CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
|
||||
// future translation assignment.
|
||||
if (lower.startsWith('64:ff9b:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
|
||||
// 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
|
||||
// protocol was deprecated by RFC 7526 and the public relay anycast
|
||||
// (192.88.99.1) has been retired, so broad-blocking the prefix has near-
|
||||
// zero false-positive cost while closing the IPv4-embedded bypass.
|
||||
// Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
|
||||
// impractical and is out of scope here.
|
||||
if (lower.startsWith('2002:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotPrivateIPv4(ip: string): void {
|
||||
const parts = ip.split('.').map(Number);
|
||||
const [a, b] = parts;
|
||||
if (
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254) ||
|
||||
a === 0 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 198 && (b === 18 || b === 19))
|
||||
) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloneProgress {
|
||||
phase: 'cloning' | 'pulling';
|
||||
message: string;
|
||||
|
|
|
|||
109
gitnexus/src/server/update-controller.ts
Normal file
109
gitnexus/src/server/update-controller.ts
Normal 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);
|
||||
};
|
||||
7
gitnexus/src/storage/global-dir.ts
Normal file
7
gitnexus/src/storage/global-dir.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Get the path to the global GitNexus directory. */
|
||||
export const getGlobalDir = (): string => {
|
||||
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
228
gitnexus/test/integration/cli/update-notice.test.ts
Normal file
228
gitnexus/test/integration/cli/update-notice.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
379
gitnexus/test/integration/mcp/update-notice.test.ts
Normal file
379
gitnexus/test/integration/mcp/update-notice.test.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { createMCPServer } from '../../../src/mcp/server.js';
|
||||
import type { UpdateState } from '../../../src/core/update-check.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
|
||||
interface FakeChecker {
|
||||
evaluate: () => Promise<UpdateState | null>;
|
||||
armUpdateRefreshScheduler: (onState: (state: UpdateState | null) => void) => () => void;
|
||||
}
|
||||
|
||||
interface FakeLogger {
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function checker(initial: UpdateState | null): {
|
||||
service: FakeChecker;
|
||||
publish: (state: UpdateState | null) => void;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
let subscriber: ((state: UpdateState | null) => void) | undefined;
|
||||
const stop = vi.fn();
|
||||
return {
|
||||
service: {
|
||||
evaluate: vi.fn().mockResolvedValue(initial),
|
||||
armUpdateRefreshScheduler: vi.fn((onState) => {
|
||||
subscriber = onState;
|
||||
return stop;
|
||||
}),
|
||||
},
|
||||
publish: (state) => subscriber?.(state),
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
function mockBackend() {
|
||||
return {
|
||||
callTool: vi
|
||||
.fn()
|
||||
.mockImplementation(async (name: string) =>
|
||||
name === 'list_repos'
|
||||
? { repositories: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false } }
|
||||
: { ok: true },
|
||||
),
|
||||
listRepos: vi.fn().mockResolvedValue([]),
|
||||
resolveRepo: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
|
||||
selectToolRepository: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
|
||||
getContext: vi.fn().mockReturnValue(null),
|
||||
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
|
||||
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
|
||||
queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
|
||||
queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function protocolSnapshot(pendingUpdate: boolean): Promise<string> {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const backend = mockBackend();
|
||||
const server = createMCPServer(backend as never);
|
||||
const client = new Client({ name: 'update-snapshot', version: '0.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const log = { info: vi.fn() };
|
||||
const fake = checker(pendingUpdate ? { updateAvailable: true, latestVersion: '99.0.0' } : null);
|
||||
|
||||
try {
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
await startMcpUpdateNotifier(log, async () => fake.service);
|
||||
|
||||
const snapshot = {
|
||||
initialize: {
|
||||
serverInfo: client.getServerVersion(),
|
||||
capabilities: client.getServerCapabilities(),
|
||||
},
|
||||
tools: await client.listTools(),
|
||||
resources: await client.listResources(),
|
||||
resource: await client.readResource({ uri: 'gitnexus://repos' }),
|
||||
prompts: await client.listPrompts(),
|
||||
call: await client.callTool({ name: 'list_repos', arguments: { limit: 5 } }),
|
||||
};
|
||||
return JSON.stringify(snapshot);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock('../../../src/mcp/server.js');
|
||||
vi.doUnmock('../../../src/mcp/local/local-backend.js');
|
||||
vi.doUnmock('../../../src/mcp/repository-policy.js');
|
||||
vi.doUnmock('../../../src/mcp/http-transport.js');
|
||||
vi.doUnmock('../../../src/core/logger.js');
|
||||
vi.doUnmock('../../../src/core/update-check.js');
|
||||
});
|
||||
|
||||
describe('MCP process update notice', () => {
|
||||
it('keeps the full protocol surface byte-identical with and without a cached update', async () => {
|
||||
expect(await protocolSnapshot(true)).toBe(await protocolSnapshot(false));
|
||||
});
|
||||
|
||||
it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER'])(
|
||||
'emits no log and performs no fetch when %s is set',
|
||||
async (name) => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-guard-'));
|
||||
fs.writeFileSync(
|
||||
path.join(home, 'update-check.json'),
|
||||
`${JSON.stringify({
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
registry: 'https://registry.npmjs.org',
|
||||
latestVersion: '99.0.0',
|
||||
})}\n`,
|
||||
);
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const previousCi = process.env.CI;
|
||||
const previousOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
const fetchStub = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchStub);
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
process.env[name] = '1';
|
||||
if (name !== 'CI') delete process.env.CI;
|
||||
const actualChecker = await vi.importActual<
|
||||
typeof import('../../../src/core/update-check.js')
|
||||
>('../../../src/core/update-check.js');
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const log: FakeLogger = { info: vi.fn() };
|
||||
|
||||
try {
|
||||
await startMcpUpdateNotifier(log, async () => actualChecker);
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
expect(fetchStub).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousHome;
|
||||
if (previousCi === undefined) delete process.env.CI;
|
||||
else process.env.CI = previousCi;
|
||||
if (previousOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousOptOut;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('emits one structured stderr logger event per process per newer version', async () => {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const log: FakeLogger = { info: vi.fn() };
|
||||
const first = checker({ updateAvailable: true, latestVersion: '9.0.0' });
|
||||
const second = checker({ updateAvailable: true, latestVersion: '9.0.0' });
|
||||
|
||||
await startMcpUpdateNotifier(log, async () => first.service);
|
||||
first.publish({ updateAvailable: true, latestVersion: '9.0.0' });
|
||||
await startMcpUpdateNotifier(log, async () => second.service);
|
||||
second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
|
||||
second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
|
||||
|
||||
expect(log.info).toHaveBeenCalledTimes(2);
|
||||
expect(log.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ event: 'gitnexus.update_available', latestVersion: '9.0.0' },
|
||||
'GitNexus update available',
|
||||
);
|
||||
expect(log.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ event: 'gitnexus.update_available', latestVersion: '10.0.0' },
|
||||
'GitNexus update available',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses only the logger channel and never writes directly to stdout', async () => {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const stdout = vi.spyOn(process.stdout, 'write');
|
||||
const log: FakeLogger = { info: vi.fn() };
|
||||
const fake = checker({ updateAvailable: true, latestVersion: '11.0.0' });
|
||||
|
||||
await startMcpUpdateNotifier(log, async () => fake.service);
|
||||
|
||||
expect(stdout).not.toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('catch-isolates checker import, evaluation, logger, and scheduler failures', async () => {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
|
||||
await expect(
|
||||
startMcpUpdateNotifier({ info: vi.fn() }, async () => {
|
||||
throw new Error('import failed');
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
|
||||
evaluate: vi.fn().mockRejectedValue(new Error('evaluation failed')),
|
||||
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
||||
})),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
startMcpUpdateNotifier(
|
||||
{
|
||||
info: vi.fn(() => {
|
||||
throw new Error('logger failed');
|
||||
}),
|
||||
},
|
||||
async () => ({
|
||||
evaluate: vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '12.0.0',
|
||||
}),
|
||||
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
|
||||
evaluate: vi.fn().mockResolvedValue(null),
|
||||
armUpdateRefreshScheduler: vi.fn(() => {
|
||||
throw new Error('scheduler failed');
|
||||
}),
|
||||
})),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['stdio', 'hang'],
|
||||
['http', 'fail'],
|
||||
] as const)(
|
||||
'starts %s notifier work only after its startup boundary and never awaits a registry %s',
|
||||
async (transport, registryBehavior) => {
|
||||
const order: string[] = [];
|
||||
let evaluateStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
evaluateStarted = resolve;
|
||||
});
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-'));
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const previousCi = process.env.CI;
|
||||
const previousGitnexusOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
const previousNoUpdate = process.env.NO_UPDATE_NOTIFIER;
|
||||
let releaseFetch: ((response: Response) => void) | undefined;
|
||||
const fetchStub = vi.fn(() =>
|
||||
registryBehavior === 'hang'
|
||||
? new Promise<Response>((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
})
|
||||
: Promise.reject(new Error('registry unavailable')),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchStub);
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
delete process.env.CI;
|
||||
delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
delete process.env.NO_UPDATE_NOTIFIER;
|
||||
const actualChecker = await vi.importActual<
|
||||
typeof import('../../../src/core/update-check.js')
|
||||
>('../../../src/core/update-check.js');
|
||||
|
||||
vi.doMock('../../../src/mcp/server.js', () => ({
|
||||
startMCPServer: vi.fn(async () => {
|
||||
order.push('stdio-connected');
|
||||
}),
|
||||
}));
|
||||
vi.doMock('../../../src/mcp/local/local-backend.js', () => ({
|
||||
LocalBackend: class {
|
||||
async init() {}
|
||||
async listRepos() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../../src/mcp/repository-policy.js', () => ({
|
||||
createMcpRepositoryPolicy: vi.fn(async () => ({
|
||||
scopeBackend: (backend: unknown) => backend,
|
||||
})),
|
||||
}));
|
||||
vi.doMock('../../../src/core/logger.js', () => ({
|
||||
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.doMock('../../../src/mcp/http-transport.js', () => ({
|
||||
resolveAuthToken: vi.fn(),
|
||||
startMcpHttpServer: vi.fn(async () => {
|
||||
order.push('http-listening');
|
||||
}),
|
||||
}));
|
||||
vi.doMock('../../../src/core/update-check.js', () => ({
|
||||
...actualChecker,
|
||||
evaluate: vi.fn(() => {
|
||||
order.push('evaluate');
|
||||
evaluateStarted();
|
||||
return actualChecker.evaluate({ eligible: true });
|
||||
}),
|
||||
armUpdateRefreshScheduler: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const { mcpCommand } = await import('../../../src/cli/mcp.js');
|
||||
await expect(
|
||||
mcpCommand(transport === 'http' ? { http: true, port: '3000' } : undefined),
|
||||
).resolves.toBeUndefined();
|
||||
await started;
|
||||
await vi.waitFor(() => expect(fetchStub).toHaveBeenCalledOnce());
|
||||
|
||||
expect(order).toEqual([
|
||||
transport === 'http' ? 'http-listening' : 'stdio-connected',
|
||||
'evaluate',
|
||||
]);
|
||||
} finally {
|
||||
if (releaseFetch) {
|
||||
releaseFetch(new Response('', { status: 503 }));
|
||||
await actualChecker.refresh({ eligible: true });
|
||||
}
|
||||
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousHome;
|
||||
if (previousCi === undefined) delete process.env.CI;
|
||||
else process.env.CI = previousCi;
|
||||
if (previousGitnexusOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
|
||||
else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousGitnexusOptOut;
|
||||
if (previousNoUpdate === undefined) delete process.env.NO_UPDATE_NOTIFIER;
|
||||
else process.env.NO_UPDATE_NOTIFIER = previousNoUpdate;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('wires the scheduler stop function into process exit', async () => {
|
||||
const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
|
||||
const fake = checker(null);
|
||||
const before = new Set(process.listeners('exit'));
|
||||
|
||||
await startMcpUpdateNotifier({ info: vi.fn() }, async () => fake.service);
|
||||
const added = process.listeners('exit').filter((listener) => !before.has(listener));
|
||||
expect(added).toHaveLength(1);
|
||||
|
||||
added[0](0);
|
||||
expect(fake.stop).toHaveBeenCalledOnce();
|
||||
process.removeListener('exit', added[0]);
|
||||
});
|
||||
|
||||
it('uses an unrefd scheduler timer so an opted-out MCP process can exit', async () => {
|
||||
const script = [
|
||||
"import { startMcpUpdateNotifier } from './dist/cli/mcp.js';",
|
||||
'await startMcpUpdateNotifier({ info() {} });',
|
||||
].join('\n');
|
||||
const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, GITNEXUS_NO_UPDATE_NOTIFIER: '1', NODE_OPTIONS: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const result = await new Promise<{ code: number | null; stderr: string }>((resolve, reject) => {
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('MCP notifier scheduler kept the child process alive'));
|
||||
}, 2_000);
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, stderr });
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toEqual({ code: 0, stderr: '' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
346
gitnexus/test/unit/build-web-optin.test.ts
Normal file
346
gitnexus/test/unit/build-web-optin.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
63
gitnexus/test/unit/cli-command-banner.test.ts
Normal file
63
gitnexus/test/unit/cli-command-banner.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
commandBannerTitle,
|
||||
commandDisplayName,
|
||||
formatCommandBanner,
|
||||
writeCommandBanner,
|
||||
} from '../../src/cli/command-banner.js';
|
||||
|
||||
function commandAt(path: string[]): Command {
|
||||
let current = new Command('gitnexus');
|
||||
for (const name of path) {
|
||||
current = current.command(name);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
describe('commandDisplayName', () => {
|
||||
it('uses Analyzer for analyze and MCP for mcp', () => {
|
||||
expect(commandDisplayName('analyze')).toBe('Analyzer');
|
||||
expect(commandDisplayName('mcp')).toBe('MCP');
|
||||
});
|
||||
|
||||
it('title-cases hyphenated command names', () => {
|
||||
expect(commandDisplayName('detect-changes')).toBe('Detect Changes');
|
||||
expect(commandDisplayName('eval-server')).toBe('Eval Server');
|
||||
expect(commandDisplayName('list')).toBe('List');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandBannerTitle', () => {
|
||||
it('joins nested group commands', () => {
|
||||
expect(commandBannerTitle(commandAt(['group', 'list']))).toBe('Group List');
|
||||
expect(commandBannerTitle(commandAt(['embeddings', 'install']))).toBe('Embeddings Install');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCommandBanner', () => {
|
||||
it('puts the version in the title', () => {
|
||||
expect(formatCommandBanner('Analyzer', '1.6.10')).toBe('\n GitNexus Analyzer (1.6.10)\n');
|
||||
expect(formatCommandBanner('Query', '1.6.10')).toBe('\n GitNexus Query (1.6.10)\n');
|
||||
});
|
||||
|
||||
it('keeps the unversioned title when version is missing', () => {
|
||||
expect(formatCommandBanner('Analyzer', '')).toBe('\n GitNexus Analyzer\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeCommandBanner', () => {
|
||||
it('writes the title for a normal command', () => {
|
||||
const write = vi.fn();
|
||||
writeCommandBanner(commandAt(['status']), { write, version: '1.6.10' });
|
||||
expect(write).toHaveBeenCalledWith('\n GitNexus Status (1.6.10)\n');
|
||||
});
|
||||
|
||||
it('skips hidden refresh and help', () => {
|
||||
const write = vi.fn();
|
||||
writeCommandBanner(commandAt(['__update-check']), { write, version: '1.6.10' });
|
||||
writeCommandBanner(commandAt(['help']), { write, version: '1.6.10' });
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,7 @@ const allHelpCommands = [
|
|||
['list'],
|
||||
['status'],
|
||||
['doctor'],
|
||||
['update'],
|
||||
['clean'],
|
||||
['remove'],
|
||||
['wiki'],
|
||||
|
|
|
|||
333
gitnexus/test/unit/cli-update-notice.test.ts
Normal file
333
gitnexus/test/unit/cli-update-notice.test.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
runCliUpdateNotice,
|
||||
type CliUpdateNoticeDependencies,
|
||||
} from '../../src/cli/update-notice.js';
|
||||
import { cachedUpdateDoctorLine } from '../../src/cli/doctor.js';
|
||||
import { setCliLanguage } from '../../src/cli/i18n/index.js';
|
||||
import { readProcessStartTime } from '../../src/utils/process-identity.js';
|
||||
|
||||
const tempHomes: string[] = [];
|
||||
|
||||
function dependencies(
|
||||
overrides: Partial<CliUpdateNoticeDependencies> = {},
|
||||
): CliUpdateNoticeDependencies {
|
||||
// Isolate the refresh-lock probe from the real GITNEXUS_HOME.
|
||||
const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'update-notice-test-'));
|
||||
tempHomes.push(gitnexusHome);
|
||||
return {
|
||||
argv: ['/usr/bin/node', '/prefix/lib/node_modules/gitnexus/dist/cli/index.js', 'status'],
|
||||
env: { GITNEXUS_HOME: gitnexusHome },
|
||||
installedVersion: '1.6.10',
|
||||
isTTY: true,
|
||||
eligible: true,
|
||||
now: 2_000,
|
||||
readCache: vi.fn(() => ({
|
||||
lastCheckAt: 1_500,
|
||||
latestVersion: '1.7.0',
|
||||
stale: false,
|
||||
})),
|
||||
writeStderr: vi.fn(),
|
||||
spawn: vi.fn(() => ({ unref: vi.fn() })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CLI cached update notice', () => {
|
||||
beforeEach(() => {
|
||||
setCliLanguage('en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCliLanguage(null);
|
||||
for (const dir of tempHomes.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes exactly one localized line to stderr for a TTY and keeps stdout untouched', () => {
|
||||
const writeStderr = vi.fn();
|
||||
const stdoutWrite = vi.spyOn(process.stdout, 'write');
|
||||
try {
|
||||
const deps = dependencies({ writeStderr });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(writeStderr).toHaveBeenCalledOnce();
|
||||
expect(writeStderr).toHaveBeenCalledWith(
|
||||
'GitNexus 1.7.0 is available (you are running 1.6.10).\n',
|
||||
);
|
||||
expect(stdoutWrite).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stdoutWrite.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('displays stale valid state and starts one detached, ignored, unrefd refresh child', () => {
|
||||
const unref = vi.fn();
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => ({
|
||||
lastCheckAt: 0,
|
||||
latestVersion: '1.7.0',
|
||||
stale: true,
|
||||
})),
|
||||
spawn: vi.fn(() => ({ unref })),
|
||||
});
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.writeStderr).toHaveBeenCalledOnce();
|
||||
expect(deps.spawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
['/prefix/lib/node_modules/gitnexus/dist/cli/index.js', '__update-check'],
|
||||
{ detached: true, stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
expect(unref).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refreshes a stale unknown/current cache without printing a notice', () => {
|
||||
for (const latestVersion of [undefined, '1.6.10', '1.5.0']) {
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion, stale: true })),
|
||||
});
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('spawns one refresh child when the cache is missing entirely', () => {
|
||||
const unref = vi.fn();
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => null),
|
||||
spawn: vi.fn(() => ({ unref })),
|
||||
});
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).toHaveBeenCalledOnce();
|
||||
expect(deps.spawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
['/prefix/lib/node_modules/gitnexus/dist/cli/index.js', '__update-check'],
|
||||
{ detached: true, stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
expect(unref).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('skips the refresh spawn when a live process holds the refresh lock', () => {
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion: '1.7.0', stale: true })),
|
||||
});
|
||||
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
`${JSON.stringify({ pid: process.pid, ownerId: 'test', processStartTime: readProcessStartTime(process.pid), hostname: os.hostname() })}\n`,
|
||||
);
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
// The live holder's refresh covers this invocation.
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
// Display from the stale-but-valid cache is unaffected.
|
||||
expect(deps.writeStderr).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('spawns when a live PID is reuse with a different process start time', () => {
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion: '1.7.0', stale: true })),
|
||||
});
|
||||
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
`${JSON.stringify({ pid: process.pid, ownerId: 'reused', processStartTime: 'not-this-process', hostname: os.hostname() })}\n`,
|
||||
);
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.spawn).toHaveBeenCalledOnce();
|
||||
expect(deps.writeStderr).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('spawns when the lock owner is dead so the child can reclaim it', () => {
|
||||
const deps = dependencies({
|
||||
readCache: vi.fn(() => null),
|
||||
});
|
||||
const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
`${JSON.stringify({ pid: 99999999, ownerId: 'stale', processStartTime: 'x', hostname: os.hostname() })}\n`,
|
||||
);
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.spawn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does nothing for non-TTY stderr, including no cache read or child spawn', () => {
|
||||
const deps = dependencies({ isTTY: false });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.readCache).not.toHaveBeenCalled();
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER'])(
|
||||
'does nothing when %s is truthy',
|
||||
(name) => {
|
||||
const deps = dependencies({ env: { [name]: '1' } });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.readCache).not.toHaveBeenCalled();
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('uses truthy-env semantics rather than treating "0" as opted out', () => {
|
||||
const deps = dependencies({
|
||||
env: {
|
||||
CI: '0',
|
||||
GITNEXUS_NO_UPDATE_NOTIFIER: 'false',
|
||||
NO_UPDATE_NOTIFIER: 'off',
|
||||
},
|
||||
});
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.writeStderr).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does nothing for ineligible dev and Docker contexts', () => {
|
||||
for (const env of [{}, { GITNEXUS_NO_UPDATE_NOTIFIER: '1' }]) {
|
||||
const deps = dependencies({ eligible: false, env });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.readCache).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['augment'],
|
||||
['--help'],
|
||||
['status', '--help'],
|
||||
['--version'],
|
||||
['mcp'],
|
||||
['serve'],
|
||||
['eval-server'],
|
||||
['update'],
|
||||
['__update-check'],
|
||||
])('excludes command identity %j from display and refresh', (...args) => {
|
||||
const deps = dependencies({ argv: ['/usr/bin/node', '/entry.js', ...args] });
|
||||
|
||||
runCliUpdateNotice(deps);
|
||||
|
||||
expect(deps.readCache).not.toHaveBeenCalled();
|
||||
expect(deps.writeStderr).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows cache and spawn failures before Commander parsing', () => {
|
||||
expect(() =>
|
||||
runCliUpdateNotice(
|
||||
dependencies({
|
||||
readCache: () => {
|
||||
throw new Error('cache unavailable');
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
runCliUpdateNotice(
|
||||
dependencies({
|
||||
readCache: () => ({ lastCheckAt: 0, stale: true }),
|
||||
spawn: () => {
|
||||
throw new Error('spawn unavailable');
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('doctor cached update line', () => {
|
||||
beforeEach(() => {
|
||||
setCliLanguage('en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCliLanguage(null);
|
||||
});
|
||||
|
||||
it('shows installed and latest versions from cache without triggering refresh', () => {
|
||||
const readCache = vi.fn(() => ({
|
||||
lastCheckAt: 0,
|
||||
latestVersion: '1.7.0',
|
||||
stale: true,
|
||||
}));
|
||||
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: true,
|
||||
env: {},
|
||||
readCache,
|
||||
}),
|
||||
).toBe('GitNexus 1.7.0 is available (you are running 1.6.10).');
|
||||
expect(readCache).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('is silent for current, invalid, opted-out, and ineligible states', () => {
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: true,
|
||||
env: {},
|
||||
readCache: () => ({ lastCheckAt: 0, latestVersion: '1.6.10', stale: false }),
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: false,
|
||||
env: {},
|
||||
readCache: vi.fn(),
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: true,
|
||||
env: { CI: '1' },
|
||||
readCache: vi.fn(),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['v1.7.0', '1.7.0-rc.1'])(
|
||||
'is silent for non-strict latestVersion %s',
|
||||
(latestVersion) => {
|
||||
expect(
|
||||
cachedUpdateDoctorLine({
|
||||
installedVersion: '1.6.10',
|
||||
eligible: true,
|
||||
env: {},
|
||||
readCache: () => ({ lastCheckAt: 0, latestVersion, stale: false }),
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
174
gitnexus/test/unit/cli-update.test.ts
Normal file
174
gitnexus/test/unit/cli-update.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { updateCommand, updateInstallArgs, updateInstallCommand } from '../../src/cli/update.js';
|
||||
import { setCliLanguage } from '../../src/cli/i18n/index.js';
|
||||
|
||||
describe('gitnexus update', () => {
|
||||
afterEach(() => {
|
||||
setCliLanguage(null);
|
||||
});
|
||||
|
||||
it('pins npm i -g to a stable x.y.z spec', () => {
|
||||
expect(updateInstallArgs('1.7.0')).toEqual(['i', '-g', 'gitnexus@1.7.0']);
|
||||
expect(updateInstallCommand('1.7.0')).toBe('npm i -g gitnexus@1.7.0');
|
||||
});
|
||||
|
||||
it('installs the discovered version when a newer release exists', async () => {
|
||||
setCliLanguage('en');
|
||||
const refresh = vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const runInstall = vi.fn().mockResolvedValue(0);
|
||||
const writeStdout = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh,
|
||||
runInstall,
|
||||
writeStdout,
|
||||
setExitCode,
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledWith({
|
||||
eligible: true,
|
||||
ignoreOptOut: true,
|
||||
installedVersion: '1.6.10',
|
||||
});
|
||||
expect(runInstall).toHaveBeenCalledWith('1.7.0');
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'GitNexus 1.7.0 is available (you are running 1.6.10).',
|
||||
);
|
||||
expect(writeStdout).toHaveBeenCalledWith('Installing with npm i -g gitnexus@1.7.0…');
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'Installed gitnexus@1.7.0. Restart long-running mcp/serve processes.',
|
||||
);
|
||||
expect(setExitCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not install when already current', async () => {
|
||||
setCliLanguage('en');
|
||||
const runInstall = vi.fn();
|
||||
const writeStdout = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.7.0',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: false,
|
||||
latestVersion: '1.7.0',
|
||||
}),
|
||||
runInstall,
|
||||
writeStdout,
|
||||
});
|
||||
|
||||
expect(runInstall).not.toHaveBeenCalled();
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'GitNexus 1.7.0 is current or newer than the latest stable version.',
|
||||
);
|
||||
expect(writeStdout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install when newer than the registry latest', async () => {
|
||||
setCliLanguage('en');
|
||||
const runInstall = vi.fn();
|
||||
const writeStdout = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.7.0',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: false,
|
||||
latestVersion: '1.6.10',
|
||||
}),
|
||||
runInstall,
|
||||
writeStdout,
|
||||
});
|
||||
|
||||
expect(runInstall).not.toHaveBeenCalled();
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'GitNexus 1.7.0 is current or newer than the latest stable version.',
|
||||
);
|
||||
expect(writeStdout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install a non x.y.z spec', async () => {
|
||||
setCliLanguage('en');
|
||||
const runInstall = vi.fn();
|
||||
const writeStdout = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '1.7.0-rc.1',
|
||||
}),
|
||||
runInstall,
|
||||
writeStdout,
|
||||
});
|
||||
|
||||
expect(runInstall).not.toHaveBeenCalled();
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'Could not check for updates (offline, private registry, or the check failed open).',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not install when the check fails open', async () => {
|
||||
setCliLanguage('en');
|
||||
const runInstall = vi.fn();
|
||||
const writeStdout = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh: vi.fn().mockResolvedValue(null),
|
||||
runInstall,
|
||||
writeStdout,
|
||||
});
|
||||
|
||||
expect(runInstall).not.toHaveBeenCalled();
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'Could not check for updates (offline, private registry, or the check failed open).',
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards a non-zero npm exit code', async () => {
|
||||
setCliLanguage('en');
|
||||
const writeStdout = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '1.7.0',
|
||||
}),
|
||||
runInstall: vi.fn().mockResolvedValue(7),
|
||||
writeStdout,
|
||||
setExitCode,
|
||||
});
|
||||
|
||||
expect(writeStdout).toHaveBeenCalledWith(
|
||||
'npm install failed. You can retry: npm i -g gitnexus@1.7.0',
|
||||
);
|
||||
expect(setExitCode).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('fails open when npm cannot be spawned', async () => {
|
||||
setCliLanguage('en');
|
||||
const writeStdout = vi.fn();
|
||||
const setExitCode = vi.fn();
|
||||
|
||||
await updateCommand({
|
||||
installedVersion: '1.6.10',
|
||||
refresh: vi.fn().mockResolvedValue({
|
||||
updateAvailable: true,
|
||||
latestVersion: '1.7.0',
|
||||
}),
|
||||
runInstall: vi.fn().mockRejectedValue(new Error('spawn npm ENOENT')),
|
||||
writeStdout,
|
||||
setExitCode,
|
||||
});
|
||||
|
||||
expect(writeStdout).toHaveBeenCalledWith('Could not run npm: spawn npm ENOENT');
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -326,6 +326,8 @@ describe('git-clone', () => {
|
|||
// chosen because their prefixes don't collide with any block above.
|
||||
expect(() => validateGitUrl('https://[2606:4700:4700::1111]/repo.git')).not.toThrow();
|
||||
expect(() => validateGitUrl('https://[2001:4860:4860::8888]/repo.git')).not.toThrow();
|
||||
// A public address that merely contains a `ffff` hextet is not IPv4-mapped.
|
||||
expect(() => validateGitUrl('https://[2001:4860:ffff::1]/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
it('blocks CGN range (100.64.0.0/10)', () => {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
13
gitnexus/test/unit/package-version.test.ts
Normal file
13
gitnexus/test/unit/package-version.test.ts
Normal 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+/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
212
gitnexus/test/unit/server-info.test.ts
Normal file
212
gitnexus/test/unit/server-info.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
539
gitnexus/test/unit/update-check.test.ts
Normal file
539
gitnexus/test/unit/update-check.test.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { updateEligibleInstall } from '../../src/core/install-context.js';
|
||||
import { isNewerVersion } from '../../src/core/update-cache.js';
|
||||
import { armUpdateRefreshScheduler, evaluate, refresh } from '../../src/core/update-check.js';
|
||||
import { acquireFileLock } from '../../src/storage/file-lock.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1_000;
|
||||
const NOW = Date.parse('2026-09-04T05:00:00.000Z');
|
||||
const REGISTRY = 'https://registry.npmjs.org';
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function tempHome(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-check-'));
|
||||
tempDirs.push(dir);
|
||||
vi.stubEnv('GITNEXUS_HOME', dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cachePath(home: string): string {
|
||||
return path.join(home, 'update-check.json');
|
||||
}
|
||||
|
||||
async function writeCache(
|
||||
home: string,
|
||||
body: { lastCheckAt: string; registry: string; latestVersion?: string },
|
||||
): Promise<void> {
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
await fs.writeFile(cachePath(home), JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function readCache(home: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await fs.readFile(cachePath(home), 'utf8')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function registryResponse(version = '1.7.0'): Response {
|
||||
return new Response(JSON.stringify({ version }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('npm_config_registry', REGISTRY);
|
||||
// GitHub Actions sets CI=true; the checker treats that as a hard opt-out.
|
||||
vi.stubEnv('CI', '');
|
||||
vi.stubEnv('GITNEXUS_NO_UPDATE_NOTIFIER', '');
|
||||
vi.stubEnv('NO_UPDATE_NOTIFIER', '');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('update check cache and versions', () => {
|
||||
it('returns a newer version from a fresh cache', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
});
|
||||
|
||||
it('returns stale valid state and starts one stale-while-revalidate refresh', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(registryResponse('1.8.0'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.8.0'));
|
||||
});
|
||||
|
||||
it.each(['1.6.10', '1.6.9'])('is silent for fresh equal/lower latest %s', async (latest) => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: latest,
|
||||
});
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toEqual({ updateAvailable: false, latestVersion: latest });
|
||||
});
|
||||
|
||||
it('treats corrupt JSON as a miss and refresh overwrites it', async () => {
|
||||
const home = await tempHome();
|
||||
await fs.writeFile(cachePath(home), '{broken');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse()));
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toBeNull();
|
||||
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.7.0'));
|
||||
});
|
||||
|
||||
it('never propagates a latestVersion with a non-strict format', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: 'v1.7.0',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
|
||||
await expect(
|
||||
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toBeNull();
|
||||
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBeUndefined());
|
||||
});
|
||||
|
||||
it('treats a future timestamp as stale', async () => {
|
||||
const home = await tempHome();
|
||||
// Wall-clock-future lastCheckAt is monotonic poison; NOW+1ms is still in
|
||||
// the past on a later wall clock and would not be overwritten.
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.6.0',
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
||||
await vi.waitFor(async () =>
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a negative entry after failure and suppresses retries inside the TTL', async () => {
|
||||
const home = await tempHome();
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
});
|
||||
await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW + 1 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves a known latestVersion when a later refresh fails', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
refresh({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
||||
).resolves.toBeNull();
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
await expect(
|
||||
evaluate({
|
||||
eligible: true,
|
||||
installedVersion: '1.6.10',
|
||||
now: NOW + 1,
|
||||
refreshIfStale: false,
|
||||
}),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips fetch when another process holds the refresh lock', async () => {
|
||||
const home = await tempHome();
|
||||
const release = await acquireFileLock(path.join(home, 'update-check.lock'));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns stale cache state without fetching when refreshIfStale is false', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
evaluate({
|
||||
eligible: true,
|
||||
installedVersion: '1.6.10',
|
||||
now: NOW,
|
||||
refreshIfStale: false,
|
||||
}),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces a future-dated cache timestamp instead of preserving it', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: '2099-01-01T00:00:00.000Z',
|
||||
registry: REGISTRY,
|
||||
latestVersion: '9.9.9',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse('1.8.0')));
|
||||
|
||||
await refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
||||
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.8.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('dedupes concurrent refresh() callers onto one fetch', async () => {
|
||||
await tempHome();
|
||||
let resolveFetch!: (response: Response) => void;
|
||||
const fetchMock = vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const first = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
||||
const second = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
resolveFetch(registryResponse('1.8.0'));
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ updateAvailable: true, latestVersion: '1.8.0' },
|
||||
{ updateAvailable: true, latestVersion: '1.8.0' },
|
||||
]);
|
||||
expect(first).toBe(second);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let a late failed attempt overwrite a newer success', async () => {
|
||||
const home = await tempHome();
|
||||
let rejectFetch!: (error: Error) => void;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
rejectFetch = reject;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Relative to the real clock: publishMonotonically only preserves a newer
|
||||
// on-disk timestamp when it is not in the future (`currentAt <= Date.now()`).
|
||||
const wall = Date.now();
|
||||
const olderAttempt = wall - 10_000;
|
||||
const newerSuccess = wall - 1;
|
||||
const pending = refresh({ eligible: true, now: olderAttempt });
|
||||
await vi.waitFor(() => expect(rejectFetch).toBeTypeOf('function'));
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(newerSuccess).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.8.0',
|
||||
});
|
||||
rejectFetch(new Error('late failure'));
|
||||
await pending;
|
||||
|
||||
expect(await readCache(home)).toEqual({
|
||||
lastCheckAt: new Date(newerSuccess).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.8.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a cache from another registry as a miss', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW).toISOString(),
|
||||
registry: 'https://registry.example.test/custom',
|
||||
latestVersion: '9.0.0',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
|
||||
await expect(
|
||||
evaluate({
|
||||
eligible: true,
|
||||
installedVersion: '1.6.10',
|
||||
now: NOW,
|
||||
refreshIfStale: false,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('handles prerelease and lower versions with the strict comparator', () => {
|
||||
expect(isNewerVersion('1.7.0-rc.1', '1.6.10')).toBe(false);
|
||||
expect(isNewerVersion('1.7.0', '1.6.10')).toBe(false);
|
||||
expect(isNewerVersion('1.6.10', '1.6.9')).toBe(false);
|
||||
expect(isNewerVersion('1.6.10', '1.7.0')).toBe(true);
|
||||
expect(isNewerVersion('1.0.9007199254740992', '1.0.9007199254740993')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hardened registry request', () => {
|
||||
it('strips registry userinfo and sends no credentials', async () => {
|
||||
await tempHome();
|
||||
vi.stubEnv('npm_config_registry', 'https://user:secret@registry.example.test/custom/');
|
||||
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://registry.example.test/custom/gitnexus/latest');
|
||||
expect(url).not.toContain('user');
|
||||
expect(new Headers(init.headers).has('authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a redirect to loopback/private addresses', async () => {
|
||||
const home = await tempHome();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/latest' } }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(await readCache(home)).not.toHaveProperty('latestVersion');
|
||||
});
|
||||
|
||||
it('caps an oversized response body', async () => {
|
||||
const home = await tempHome();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response('x'.repeat(70_000), { status: 200 })),
|
||||
);
|
||||
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
|
||||
expect(await readCache(home)).not.toHaveProperty('latestVersion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guards and scheduler', () => {
|
||||
it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
|
||||
'%s skips cache refresh and network with truthy-env semantics',
|
||||
async (name) => {
|
||||
await tempHome();
|
||||
vi.stubEnv(name, 'yes');
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
|
||||
await refresh({ eligible: true, now: NOW });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('ignoreOptOut still fetches when CI/opt-out env is set', async () => {
|
||||
await tempHome();
|
||||
vi.stubEnv('CI', 'true');
|
||||
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
refresh({
|
||||
eligible: true,
|
||||
ignoreOptOut: true,
|
||||
installedVersion: '1.6.10',
|
||||
now: NOW,
|
||||
}),
|
||||
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('an ineligible install skips network', async () => {
|
||||
await tempHome();
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(evaluate({ eligible: false, now: NOW })).resolves.toBeNull();
|
||||
await refresh({ eligible: false, now: NOW });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an unwritable cache path fails open', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-unwritable-'));
|
||||
tempDirs.push(root);
|
||||
const blocker = path.join(root, 'not-a-directory');
|
||||
await fs.writeFile(blocker, 'x');
|
||||
vi.stubEnv('GITNEXUS_HOME', path.join(blocker, 'child'));
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
|
||||
await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
|
||||
await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('arms an unrefd, clearable, single-flight scheduler', async () => {
|
||||
await tempHome();
|
||||
let resolveFetch!: (response: Response) => void;
|
||||
const fetchMock = vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onState = vi.fn();
|
||||
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
|
||||
const clearA = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
||||
const clearB = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const immediateHandles = timeoutSpy.mock.results
|
||||
.map((result) => result.value as NodeJS.Timeout)
|
||||
.filter((handle) => typeof handle?.hasRef === 'function');
|
||||
expect(immediateHandles.some((handle) => !handle.hasRef())).toBe(true);
|
||||
resolveFetch(registryResponse());
|
||||
await vi.waitFor(() => expect(onState).toHaveBeenCalled());
|
||||
clearA();
|
||||
clearB();
|
||||
});
|
||||
|
||||
it('backs off when refresh is lock-busy instead of spinning at 1ms', async () => {
|
||||
const home = await tempHome();
|
||||
await writeCache(home, {
|
||||
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
||||
registry: REGISTRY,
|
||||
latestVersion: '1.7.0',
|
||||
});
|
||||
const release = await acquireFileLock(path.join(home, 'update-check.lock'));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
const onState = vi.fn();
|
||||
try {
|
||||
const clear = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
||||
await vi.waitFor(() => expect(onState).toHaveBeenCalled());
|
||||
const retryDelay = timeoutSpy.mock.calls
|
||||
.map(([, delay]) => delay)
|
||||
.find(
|
||||
(delay): delay is number =>
|
||||
typeof delay === 'number' && delay >= 30_000 && delay <= 60_000,
|
||||
);
|
||||
expect(retryDelay).toBeDefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
clear();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEligibleInstall', () => {
|
||||
async function entry(relative: string): Promise<{ root: string; entry: string }> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-install-context-'));
|
||||
tempDirs.push(root);
|
||||
const entryPath = path.join(root, relative);
|
||||
await fs.mkdir(path.dirname(entryPath), { recursive: true });
|
||||
await fs.writeFile(entryPath, '');
|
||||
return { root, entry: entryPath };
|
||||
}
|
||||
|
||||
it('classifies global, local, ephemeral, dev, and Docker layouts', async () => {
|
||||
const global = await entry('prefix/lib/node_modules/gitnexus/dist/cli/index.js');
|
||||
expect(
|
||||
await updateEligibleInstall(global.entry, {
|
||||
npm_config_prefix: path.join(global.root, 'prefix'),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const local = await entry('project/node_modules/gitnexus/dist/cli/index.js');
|
||||
expect(await updateEligibleInstall(local.entry, {})).toBe(true);
|
||||
|
||||
const namedDlx = await entry('dlx/project/node_modules/gitnexus/dist/cli/index.js');
|
||||
expect(await updateEligibleInstall(namedDlx.entry, {})).toBe(true);
|
||||
|
||||
for (const relative of [
|
||||
'cache/_npx/123/node_modules/gitnexus/dist/cli/index.js',
|
||||
'cache/_cacache/tmp/node_modules/gitnexus/dist/cli/index.js',
|
||||
'pnpm/dlx/123/node_modules/gitnexus/dist/cli/index.js',
|
||||
'.bun/install/cache/gitnexus@1.0.0/node_modules/gitnexus/dist/cli/index.js',
|
||||
]) {
|
||||
const ephemeral = await entry(relative);
|
||||
expect(
|
||||
await updateEligibleInstall(ephemeral.entry, {
|
||||
npm_config_cache: path.join(ephemeral.root, 'cache'),
|
||||
npm_execpath: path.join(ephemeral.root, relative),
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
const dev = await entry('checkout/gitnexus/src/cli/index.ts');
|
||||
await fs.mkdir(path.join(dev.root, 'checkout', '.git'));
|
||||
expect(await updateEligibleInstall(dev.entry, {})).toBe(false);
|
||||
|
||||
const docker = await entry('usr/local/lib/node_modules/gitnexus/dist/cli/index.js');
|
||||
expect(
|
||||
await updateEligibleInstall(docker.entry, {
|
||||
npm_config_prefix: path.join(docker.root, 'usr/local'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue