mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
* 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>
333 lines
9.6 KiB
TypeScript
333 lines
9.6 KiB
TypeScript
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();
|
|
},
|
|
);
|
|
});
|