GitNexus/gitnexus/test/unit/cli-index-help.test.ts
Gergő Magyar 9bf307123e
feat: notify users when a newer gitnexus version is available (#3175)
* feat(core): add cache-first npm update-check service

Shared fail-open checker: validated 24h cache under GITNEXUS_HOME,
acquireFileLock-guarded refresh, monotonic publication, hardened
registry fetch (no credentials, private-address redirects refused,
body-capped), strict x.y.z comparator, install-eligibility
classification, and an unref'd refresh scheduler for long-lived
processes. Extracts getGlobalDir into storage/global-dir.ts with a
repo-manager re-export (no caller changes).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(cli): notify on available updates via stderr and doctor

One i18n'd stderr line on interactive invocations when the validated
cache holds a newer version (TTY-gated, CI/opt-out/eligibility-gated,
hook and help/version command identities excluded). Stale cache spawns
a detached hidden __update-check refresh child so command exit latency
is unchanged. doctor prints the cached latest version when known.
Dockerfile.cli sets GITNEXUS_NO_UPDATE_NOTIFIER=1.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(mcp): emit one stderr update notice per process per version

Process-scoped adapter in mcpCommand (stdio and --http), dynamically
imported after the stdout sentinel, started only after connect, fully
catch-isolated. Arms the shared refresh scheduler with cleanup on
process exit. Protocol payloads stay free of update state (R15).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(serve): expose update state on /api/info

Serve-scoped controller owns an in-memory update snapshot: one
staleness evaluation after listen, then the shared unref'd scheduler,
stopped on close/shutdown. /api/info reads only the snapshot and gains
optional latestVersion/updateAvailable fields for eligible installs;
the existing three fields are byte-compatible.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): dismissible update-available banner from /api/info

Fetches server info after backend connect and on reconnect, renders a
fixed banner in the exploring view only when updateAvailable is true
and the version is undismissed, hides while the reconnect banner is
active, and fails open on fetch errors. role=status + aria-live with a
keyboard-focusable dismiss; dismissal persists per version in
localStorage. Copy in en/zh-CN common.json with version interpolation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(cli): document update notifications and opt-outs

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): apply review findings and simplify pass

Review: gate the detached refresh spawn on a live lock-owner probe so
parallel CLI invocations coalesce to one refresh child (validated P2,
three-reviewer agreement); poll /api/info on a slow cadence while
exploring so post-load server-side discoveries surface (validated P1);
add a monotonic sequence guard so overlapping server-info fetches
commit in order.

Simplify (behavior-preserving): shared truthy-env/opt-out/freshness
helpers in update-cache.ts, shared cachedUpdateNoticeLine for CLI and
doctor, extracted install-eligibility core with per-process memo,
memoized registry parsing, single evaluation per scheduler cycle,
cache-only startup evaluate in serve, flattened MCP exit handler,
shared bottom-banner shell, storage keys in ui-constants.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(update-notifier): address residual review tickets on this PR

Stop the lock-busy 1ms scheduler spin, replace clock-skewed cache
entries, move the outbound URL guard into core, and extract the serve
update controller. Pin the startup/guard/single-flight/MCP/CLI contracts
those tickets called out.

Fixes #3167 #3168 #3169 #3170 #3171 #3172 #3173 #3174

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3175)

Fetch the npm /latest document instead of the full packument so the 64KiB cap can succeed, and treat reused lock PIDs as stale so refresh is not suppressed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3175)

Register the CLI spawn suite on the OS matrix, pin MCP opt-out env, and compare versions without IEEE-754 rounding.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(cli): add gitnexus update install and versioned command banners

Give an explicit Claude/Codex-style upgrade (`npm i -g gitnexus@version`) and print `GitNexus <Name> (version)` on every command so the running build is obvious without silent self-update.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3175)

- Document the pinned install as npm i -g gitnexus@<x.y.z>, not a copyable @version tag
- Wait for wall-clock-future cache repair to publish before asserting
- Restore the stdout spy if the TTY notice assertions fail

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(update-notifier): keep last known latestVersion on a failed refresh

A later offline check was wiping the pin and hiding a known update for 24h.
gitnexus update still treats a failed live fetch as checkFailed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3175)

- Word update.current so a newer-than-latest install is not called the latest stable version.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): hide the detached update-check spawn on Windows

The refresh child was spawned without windowsHide, so Windows CI could stall
before writing the cache and then fail cleanup with EBUSY.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 18:50:03 +01:00

403 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { spawnSync } 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 { Command, Option } from 'commander';
import * as ts from 'typescript';
import { afterEach, describe, expect, it } from 'vitest';
import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js';
import { localizeCliHelp } from '../../src/cli/help-i18n.js';
import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../..');
function runHelp(command: string, env: NodeJS.ProcessEnv = {}) {
return runHelpArgs([command], env);
}
function runHelpArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
return runCliArgs([...args, '--help'], env);
}
function runCliArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, ...env },
});
}
function runRootHelp(env: NodeJS.ProcessEnv = {}) {
return runHelpArgs([], env);
}
const allHelpCommands = [
[],
['setup'],
['analyze'],
['index'],
['serve'],
['mcp'],
['list'],
['status'],
['doctor'],
['update'],
['clean'],
['remove'],
['wiki'],
['augment'],
['publish'],
['query'],
['context'],
['impact'],
['cypher'],
['detect-changes'],
['eval-server'],
['embeddings'],
['embeddings', 'install'],
['group'],
['group', 'create'],
['group', 'add'],
['group', 'remove'],
['group', 'list'],
['group', 'status'],
['group', 'sync'],
['group', 'impact'],
['group', 'query'],
['group', 'contracts'],
];
function staticStringValue(node: ts.Node | undefined): string | undefined {
if (!node) return undefined;
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
const left = staticStringValue(node.left);
const right = staticStringValue(node.right);
if (left !== undefined && right !== undefined) return `${left}${right}`;
}
return undefined;
}
function extractRegisteredHelpDescriptions(): string[] {
const descriptions = new Set<string>();
const sourceFiles = ['src/cli/index.ts', 'src/cli/group.ts'];
for (const relativePath of sourceFiles) {
const filePath = path.join(repoRoot, relativePath);
const source = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
function visit(node: ts.Node): void {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const method = node.expression.name.text;
const description =
method === 'description'
? staticStringValue(node.arguments[0])
: method === 'option' || method === 'requiredOption'
? staticStringValue(node.arguments[1])
: undefined;
if (description && /[A-Za-z]/.test(description)) {
descriptions.add(description.replace(/\s+/g, ' ').trim());
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
return [...descriptions].filter((description) => description.length > 0).sort();
}
function metadataHelp(language: SupportedCliLanguage) {
setCliLanguage(language);
const command = new Command('probe');
command.addOption(new Option('--mode <mode>', 'Mode').choices(['fast', 'safe']));
command.addOption(new Option('--limit <n>', 'Limit').default('5'));
command.addOption(new Option('--level [name]', 'Level').preset('auto'));
command.addOption(new Option('--token <token>', 'Token').env('GITNEXUS_TOKEN'));
localizeCliHelp(command);
return command.helpInformation();
}
describe('CLI help surface', () => {
afterEach(() => setCliLanguage(null));
it('root help localizes commander headings, options, and command descriptions', () => {
const result = runRootHelp({ GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('用法: gitnexus [options] [command]');
expect(result.stdout).toContain('GitNexus 本地 CLI 和 MCP 服务器');
expect(result.stdout).toContain('选项:');
expect(result.stdout).toContain('-V, --version 输出版本号');
expect(result.stdout).toContain('-h, --help 显示命令帮助');
expect(result.stdout).toContain('命令:');
expect(result.stdout).toContain('setup');
// Stable fragments rather than the full editor roster: the roster grows
// over time (see PR #2368), and the dynamic test below ("localizes every
// registered CLI command...") already fails on any untranslated
// description, so freezing the roster here only creates churn.
expect(result.stdout).toContain('一次性设置');
expect(result.stdout).toContain('配置 MCP');
expect(result.stdout).toContain('detect-changes|detect_changes [options]');
expect(result.stdout).toContain('将 git diff hunk 映射到已索引符号和受影响执行流程');
expect(result.stdout).not.toContain('GitNexus local CLI and MCP server');
expect(result.stdout).not.toContain('display help for command');
});
it('command help localizes option descriptions and help suffix text', () => {
const result = runHelp('query', { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('用法: gitnexus query [options] [search_query]');
expect(result.stdout).toContain('搜索知识图谱中与概念相关的执行流程');
expect(result.stdout).toContain('-r, --repo <name> 目标仓库(仅有一个已索引仓库时可省略)');
expect(result.stdout).toContain('-l, --limit <n> 最多返回的流程数(默认:5)');
expect(result.stdout).toContain('-h, --help 显示命令帮助');
expect(result.stdout).not.toContain('Target repository (omit if only one indexed)');
});
it('setup help exposes selective coding-agent configuration', () => {
const result = runHelp('setup');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus setup [options]');
expect(result.stdout).toContain('-c, --coding-agent <agents>');
});
it('localizes every registered CLI command and option description in zh-CN help', () => {
const zhHelpOutput = allHelpCommands
.map((args) => {
const result = runHelpArgs(args, { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status, `gitnexus ${args.join(' ')} --help`).toBe(0);
return result.stdout;
})
.join('\n');
const untranslated = extractRegisteredHelpDescriptions().filter((description) =>
zhHelpOutput.includes(description),
);
expect(untranslated).toEqual([]);
});
it('analyze help localizes custom environment variable help text', () => {
const result = runHelp('analyze', { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('环境变量:');
expect(result.stdout).toContain('当参数和对应环境变量同时提供时,参数优先。');
expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。');
expect(result.stdout).not.toContain('Environment variables:');
expect(result.stdout).not.toContain('Flags override the corresponding env vars');
});
it('query help keeps advanced search options without importing analyze deps', () => {
const result = runHelp('query');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--context <text>');
expect(result.stdout).toContain('--goal <text>');
expect(result.stdout).toContain('--content');
expect(result.stderr).not.toContain('tree-sitter-kotlin');
});
it('context help keeps optional name and disambiguation flags', () => {
const result = runHelp('context');
expect(result.status).toBe(0);
expect(result.stdout).toContain('context [options] [name]');
expect(result.stdout).toContain('--uid <uid>');
expect(result.stdout).toContain('--file <path>');
});
it('impact help keeps repo, include-tests, and disambiguation flags', () => {
const result = runHelp('impact');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--depth <n>');
expect(result.stdout).toContain('--include-tests');
expect(result.stdout).toContain('--repo <name>');
// Disambiguation flags (#1907) — mirror the context help test so a
// missing-flag regression on impact is caught here too.
expect(result.stdout).toContain('--uid <uid>');
expect(result.stdout).toContain('--file <path>');
expect(result.stdout).toContain('--kind <kind>');
});
it('detect-changes help exposes compare scope and base-ref flags', () => {
const result = runHelp('detect-changes');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus detect-changes|detect_changes [options]');
expect(result.stdout).toContain('--scope <scope>');
expect(result.stdout).toContain('--base-ref <ref>');
expect(result.stdout).toContain('--repo <name>');
});
it('query-family commands expose the --branch scope flag (#2106)', () => {
for (const cmd of ['query', 'context', 'impact', 'cypher', 'detect-changes']) {
const result = runHelp(cmd);
expect(result.status, cmd).toBe(0);
expect(result.stdout, cmd).toContain('--branch <name>');
}
});
it('auto-sync help exposes lifecycle actions and state files', () => {
const result = runHelp('auto-sync');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus auto-sync [options] [action]');
expect(result.stdout).toContain('Actions: init, start (default), restart, stop, status, reset');
expect(result.stdout).toContain('GITNEXUS_HOME/watch_config.yml');
expect(result.stdout).toContain('GITNEXUS_HOME/watch/watch.pid');
expect(result.stdout).toContain('GITNEXUS_HOME/watch/project_commit_info.txt');
});
it('watch is reserved and does not start auto-sync or local watch', () => {
const help = runHelp('watch');
expect(help.status).toBe(0);
expect(help.stdout).toContain('gitnexus watch [options] [action]');
expect(help.stdout).toContain('gitnexus analyze --watch');
expect(help.stdout).toContain('gitnexus auto-sync start');
expect(help.stdout).not.toContain('GITNEXUS_HOME/watch_config.yml');
const started = runCliArgs(['watch'], {});
expect(started.status).toBe(1);
expect(started.stderr).toContain('gitnexus watch');
expect(started.stderr).toContain('gitnexus analyze --watch');
expect(started.stderr).toContain('gitnexus auto-sync start');
const startAction = runCliArgs(['watch', 'start'], {});
expect(startAction.status).toBe(1);
expect(startAction.stderr).toContain('gitnexus auto-sync start');
});
it('auto-sync init creates the default watch_config.yml and does not overwrite it', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-init-'));
try {
const first = runCliArgs(['auto-sync', 'init'], { GITNEXUS_HOME: home });
const configPath = path.join(home, 'watch_config.yml');
expect(first.status).toBe(0);
expect(first.stdout).toContain(`Created ${configPath}`);
const config = fs.readFileSync(configPath, 'utf8');
expect(config).toContain('sync_interval_minutes: 10');
expect(config).toContain('analyze_failure_threshold: 3');
expect(config).toContain('analyze_timeout: 5m');
expect(config).toContain('overwrite_local_changes: false');
expect(config).toContain(`local_path: ${path.join(home, 'repos')}`);
expect(config).not.toContain('/abs/path/to/repos');
expect(config).toContain('git@github.com:owner/repo.git');
expect(config).not.toContain('group_name:');
const second = runCliArgs(['auto-sync', 'init'], { GITNEXUS_HOME: home });
expect(second.status).toBe(1);
expect(second.stderr).toContain(`Config already exists: ${configPath}`);
expect(fs.readFileSync(configPath, 'utf8')).toBe(config);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync reset removes only derived auto-sync state files', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-reset-'));
const watchDir = path.join(home, 'watch');
const cloneMarker = path.join(home, 'repos', 'repo', 'keep.txt');
try {
fs.mkdirSync(path.dirname(cloneMarker), { recursive: true });
fs.writeFileSync(cloneMarker, 'keep');
fs.mkdirSync(watchDir, { recursive: true });
fs.writeFileSync(path.join(watchDir, 'auto-sync-state.json'), '{}');
fs.writeFileSync(path.join(watchDir, 'project_commit_info.txt'), 'derived');
const result = runCliArgs(['auto-sync', 'reset'], { GITNEXUS_HOME: home });
expect(result.status).toBe(0);
expect(result.stdout).toContain('Reset analysis state');
expect(fs.existsSync(path.join(watchDir, 'auto-sync-state.json'))).toBe(false);
expect(fs.existsSync(path.join(watchDir, 'project_commit_info.txt'))).toBe(false);
expect(fs.readFileSync(cloneMarker, 'utf8')).toBe('keep');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync stop exits non-zero when no watch was stopped', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-stop-'));
try {
const result = runCliArgs(['auto-sync', 'stop'], { GITNEXUS_HOME: home });
expect(result.status).toBe(1);
expect(result.stderr).toContain('Watch is not running');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync restart starts when the watch is not running', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-restart-'));
try {
const result = runCliArgs(['auto-sync', 'restart'], { GITNEXUS_HOME: home });
expect(result.status).toBe(1);
expect(result.stderr).toContain('Watch is not running');
expect(result.stderr).toContain('Missing config file');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('wiki help shows provider, review, and verbose flags', () => {
const result = runHelp('wiki');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--provider <provider>');
expect(result.stdout).toContain('claude');
expect(result.stdout).toContain('codex');
expect(result.stdout).toContain('grok');
expect(result.stdout).toContain('--review');
expect(result.stdout).toContain('-v, --verbose');
expect(result.stdout).toContain('--model <model>');
expect(result.stdout).toContain('--gist');
});
it('publish help names the registry, the token env var, and the opt-out behaviour', () => {
const result = runHelp('publish');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--id <owner/repo>');
expect(result.stdout).toContain('--skip-git');
// Discoverability contract: a contributor scanning `--help` must see
// (a) which registry this dispatches to, and (b) the env var that
// gates the opt-in. Both are part of the no-token contract.
expect(result.stdout).toContain('understand-quickly');
expect(result.stdout).toContain('UNDERSTAND_QUICKLY_TOKEN');
});
it('analyze help includes the FTS repair option', () => {
const result = runHelp('analyze');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--repair-fts');
});
it('localizes commander-generated option metadata labels', () => {
const english = metadataHelp('en');
const chinese = metadataHelp('zh-CN');
expect(english).toContain('choices: "fast", "safe"');
expect(english).toContain('default: "5"');
expect(english).toContain('preset: "auto"');
expect(english).toContain('env: GITNEXUS_TOKEN');
expect(chinese).toContain('可选值: "fast", "safe"');
expect(chinese).toContain('默认: "5"');
expect(chinese).toContain('预设: "auto"');
expect(chinese).toContain('环境变量: GITNEXUS_TOKEN');
});
});