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

539 lines
19 KiB
TypeScript

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