GitNexus/gitnexus/test/unit/cli-update.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

174 lines
5 KiB
TypeScript

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);
});
});