diff --git a/README.md b/README.md index f58dd8ae..43f34575 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,14 @@ Use nuxi --help for more information about a command. All commands are documented on https://nuxt.com/docs/api/commands +## Update Notifications + +Once a day, `nuxt/cli` checks in the background whether a newer version of Nuxt has been released and, if so, prints a short nudge to run `nuxt upgrade` after the command finishes. The `latest` dist-tag is read from the registry configured in your `.npmrc` and cached in your user-level `.nuxtrc`. + +To keep the nudge meaningful, it is not shown for prereleases, and not shown for patch releases within the same minor unless you are at least five patches behind. + +The check is skipped in CI, on StackBlitz, and when the output is not a terminal. Set `NUXT_IGNORE_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to skip a single run, or opt out permanently by adding `updateCheck.enabled=false` to your user-level `.nuxtrc`. + ## Shell Autocompletions `nuxt/cli` provides shell autocompletions for commands, options, and option values – powered by [`@bomb.sh/tab`](https://github.com/bombshell-dev/tab). diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index f71fd06d..2741384c 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -18,6 +18,7 @@ import { checkEngines } from './utils/engines' import { logger } from './utils/logger' import { setupProxySupport } from './utils/network' import { templateNames } from './utils/templates/names' +import { scheduleUpdateNudge } from './utils/update' // Node.js only reads `NODE_USE_ENV_PROXY` during bootstrap, so this cannot make // the current process proxy-aware; it propagates the setting to child processes @@ -42,11 +43,12 @@ const _main = defineCommand({ const command = ctx.args._[0] setupGlobalConsole({ dev: command === 'dev' }) - // Check Node.js version in background + // Check Node.js version and Nuxt updates in background let backgroundTasks: Promise | undefined if (command !== '_dev' && provider !== 'stackblitz') { backgroundTasks = Promise.all([ checkEngines(), + scheduleUpdateNudge(resolve(ctx.args.cwd), command), ]).catch(err => logger.error(String(err))) } diff --git a/packages/nuxt-cli/src/utils/update.ts b/packages/nuxt-cli/src/utils/update.ts new file mode 100644 index 00000000..8e4a0ea1 --- /dev/null +++ b/packages/nuxt-cli/src/utils/update.ts @@ -0,0 +1,204 @@ +import process from 'node:process' + +import { box } from '@clack/prompts' +import { $fetch } from 'ofetch' +import colors from 'picocolors' +import { readUser, updateUser } from 'rc9' +import { isCI, isTest, provider } from 'std-env' +import { joinURL } from 'ufo' +import { isGreater, tryParse } from 'verkit' + +import { debug } from './logger' +import { detectNpmRegistry } from './registry' + +const RC_FILE = '.nuxtrc' +const CACHE_KEY = 'updateCheck' +const CACHE_TTL = 24 * 60 * 60 * 1000 +const FETCH_TIMEOUT = 3000 + +/** + * Patch releases within the same minor are only worth interrupting for once the + * user is meaningfully behind; a single patch is noise for anyone who upgrades + * regularly. + */ +const MIN_PATCH_DISTANCE = 5 + +export interface NuxtUpdate { + current: string + latest: string +} + +interface UpdateCache { + enabled?: boolean + latest?: string + checkedAt?: number +} + +function readCache(): UpdateCache { + try { + return (readUser(RC_FILE)[CACHE_KEY] as UpdateCache | undefined) || {} + } + catch (error) { + debug('Failed to read update check cache:', error) + return {} + } +} + +function writeCache(cache: UpdateCache) { + try { + updateUser({ [CACHE_KEY]: cache }, RC_FILE) + } + catch (error) { + debug('Failed to persist update check cache:', error) + } +} + +/** + * `NUXT_IGNORE_UPDATE_CHECK=1` (or the cross-tool `NO_UPDATE_NOTIFIER`) opts out + * for a single run and `updateCheck.enabled=false` in the user `.nuxtrc` opts + * out permanently. We also stay quiet where the nudge cannot be acted on + * interactively (CI, tests, StackBlitz, no TTY). + */ +export function isUpdateCheckEnabled(): boolean { + if (process.env.NUXT_IGNORE_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) { + return false + } + if (readCache().enabled === false) { + return false + } + return !isCI && !isTest && provider !== 'stackblitz' && Boolean(process.stdout.isTTY) +} + +async function resolveLatestVersion(): Promise { + const cache = readCache() + if (cache.checkedAt && Date.now() - Number(cache.checkedAt) < CACHE_TTL) { + return cache.latest + } + + let latest: string | undefined + try { + const { registry, authToken } = await detectNpmRegistry(null) + latest = (await $fetch<{ latest?: string }>(joinURL(registry, '-/package/nuxt/dist-tags'), { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, + timeout: FETCH_TIMEOUT, + retry: 0, + })).latest + } + catch (error) { + debug('Failed to resolve the latest Nuxt version:', error) + } + + // an unreachable or unauthenticated registry is recorded as a completed check + // so an offline user does not pay the request timeout on every command + writeCache({ latest, checkedAt: Date.now() }) + return latest +} + +/** + * Resolve the installed and latest Nuxt versions, returning nothing when the + * project is already current or when anything at all goes wrong (an offline + * user should never see an error from a check they did not ask for). + */ +export async function checkForNuxtUpdate(cwd: string): Promise { + try { + // imported lazily to keep package resolution off the CLI startup path + const [current, latest] = await Promise.all([ + import('./versions').then(({ getNuxtVersion }) => getNuxtVersion(cwd)).catch(() => undefined), + resolveLatestVersion(), + ]) + + if (!current || !latest) { + return undefined + } + + const installed = tryParse(current) + const published = tryParse(latest) + if (!installed || !published) { + return undefined + } + + // prereleases are compared against `latest`, which is never the right + // baseline for someone tracking nightlies or release candidates + if (installed.prerelease) { + return undefined + } + + if (!isGreater(latest, current)) { + return undefined + } + + if ( + published.major === installed.major + && published.minor === installed.minor + && published.patch - installed.patch < MIN_PATCH_DISTANCE + ) { + return undefined + } + + return { current, latest } + } + catch (error) { + debug('Failed to check for Nuxt updates:', error) + return undefined + } +} + +export function renderUpdateNudge({ current, latest }: NuxtUpdate): void { + const headline = `A new version of Nuxt is available: ${colors.green(latest)} ${colors.gray(`(you are on ${current})`)}` + const action = `Run ${colors.cyan('nuxt upgrade')} to update.` + + process.stdout.write('\n') + + // `box` cannot lay itself out without a known terminal width + const columns = process.stdout.columns + if (!columns || columns < 60) { + process.stdout.write(`${headline}\n${action}\n`) + return + } + + box( + [ + headline, + '', + action, + ].join('\n'), + colors.green(' Update available '), + { + contentAlign: 'left', + titleAlign: 'left', + width: 'auto', + titlePadding: 2, + contentPadding: 2, + rounded: true, + withGuide: false, + }, + ) +} + +/** + * Check for a newer Nuxt release without blocking the command, deferring the + * nudge to process exit so it can never interleave with the command's own + * output (including the long-lived `dev` server's banner and logs). + * + * `upgrade` is excluded because the installed version is read when the command + * starts, so a nudge would still be pending once the upgrade has succeeded. + */ +export async function scheduleUpdateNudge(cwd: string, command?: string): Promise { + if (command === 'upgrade' || !isUpdateCheckEnabled()) { + return + } + + const update = await checkForNuxtUpdate(cwd) + if (!update) { + return + } + + process.once('exit', () => { + try { + renderUpdateNudge(update) + } + catch (error) { + debug('Failed to render update nudge:', error) + } + }) +} diff --git a/packages/nuxt-cli/test/unit/utils/update.spec.ts b/packages/nuxt-cli/test/unit/utils/update.spec.ts new file mode 100644 index 00000000..a6133cc3 --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/update.spec.ts @@ -0,0 +1,280 @@ +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const stdEnv = vi.hoisted(() => ({ isCI: false, isTest: false, provider: 'unknown' as string })) +const rcStore = vi.hoisted(() => ({ current: {} as Record })) +const project = vi.hoisted(() => ({ nuxtVersion: undefined as string | undefined })) +const fetchMock = vi.hoisted(() => vi.fn()) +const registry = vi.hoisted(() => ({ current: { registry: 'https://registry.npmjs.org', authToken: null as string | null } })) + +vi.mock('std-env', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + get isCI() { + return stdEnv.isCI + }, + get isTest() { + return stdEnv.isTest + }, + get provider() { + return stdEnv.provider + }, + } +}) + +vi.mock('rc9', async () => { + const { defu } = await import('defu') + return { + readUser: () => rcStore.current, + updateUser: (value: Record) => { + rcStore.current = defu(value, rcStore.current) + }, + } +}) + +vi.mock('ofetch', () => ({ $fetch: fetchMock })) + +vi.mock('../../../src/utils/registry', () => ({ + detectNpmRegistry: async () => registry.current, +})) + +vi.mock('pkg-types', () => ({ + readPackageJSON: async (id?: string) => { + if (id === 'nuxt') { + return project.nuxtVersion ? { name: 'nuxt', version: project.nuxtVersion } : undefined + } + throw new Error('package.json not found') + }, +})) + +const { checkForNuxtUpdate, isUpdateCheckEnabled, renderUpdateNudge, scheduleUpdateNudge } = await import('../../../src/utils/update') + +describe('update check', () => { + const originalIsTTY = process.stdout.isTTY + + beforeEach(() => { + stdEnv.isCI = false + stdEnv.isTest = false + stdEnv.provider = 'unknown' + rcStore.current = {} + project.nuxtVersion = '4.0.0' + registry.current = { registry: 'https://registry.npmjs.org', authToken: null } + fetchMock.mockReset() + process.stdout.isTTY = true + delete process.env.NUXT_IGNORE_UPDATE_CHECK + delete process.env.NO_UPDATE_NOTIFIER + }) + + afterEach(() => { + process.stdout.isTTY = originalIsTTY + delete process.env.NUXT_IGNORE_UPDATE_CHECK + delete process.env.NO_UPDATE_NOTIFIER + }) + + describe('isUpdateCheckEnabled', () => { + it('is enabled on an interactive local terminal', () => { + expect(isUpdateCheckEnabled()).toBe(true) + }) + + it('is disabled in CI', () => { + stdEnv.isCI = true + expect(isUpdateCheckEnabled()).toBe(false) + }) + + it('is disabled without a TTY', () => { + process.stdout.isTTY = false + expect(isUpdateCheckEnabled()).toBe(false) + }) + + it('is disabled on stackblitz', () => { + stdEnv.provider = 'stackblitz' + expect(isUpdateCheckEnabled()).toBe(false) + }) + + it('is disabled by NUXT_IGNORE_UPDATE_CHECK', () => { + process.env.NUXT_IGNORE_UPDATE_CHECK = '1' + expect(isUpdateCheckEnabled()).toBe(false) + }) + + it('is disabled by NO_UPDATE_NOTIFIER', () => { + process.env.NO_UPDATE_NOTIFIER = '1' + expect(isUpdateCheckEnabled()).toBe(false) + }) + + it('is disabled by a persisted opt-out', () => { + rcStore.current = { updateCheck: { enabled: false } } + expect(isUpdateCheckEnabled()).toBe(false) + }) + }) + + describe('checkForNuxtUpdate', () => { + it('reports a newer release', async () => { + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + await expect(checkForNuxtUpdate('/project')).resolves.toEqual({ current: '4.0.0', latest: '4.1.0' }) + }) + + it('returns nothing when already up to date', async () => { + fetchMock.mockResolvedValue({ latest: '4.0.0' }) + await expect(checkForNuxtUpdate('/project')).resolves.toBeUndefined() + }) + + it('returns nothing when the installed version is ahead', async () => { + project.nuxtVersion = '4.2.0' + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + await expect(checkForNuxtUpdate('/project')).resolves.toBeUndefined() + }) + + it('stays quiet when only a few patches behind', async () => { + fetchMock.mockResolvedValue({ latest: '4.0.3' }) + await expect(checkForNuxtUpdate('/project')).resolves.toBeUndefined() + }) + + it('reports a distant patch release', async () => { + fetchMock.mockResolvedValue({ latest: '4.0.7' }) + await expect(checkForNuxtUpdate('/project')).resolves.toEqual({ current: '4.0.0', latest: '4.0.7' }) + }) + + it('reports a patch release in a newer minor', async () => { + fetchMock.mockResolvedValue({ latest: '4.1.1' }) + await expect(checkForNuxtUpdate('/project')).resolves.toEqual({ current: '4.0.0', latest: '4.1.1' }) + }) + + it('returns nothing when the installed version is a prerelease', async () => { + project.nuxtVersion = '4.1.0-rc.1' + fetchMock.mockResolvedValue({ latest: '4.2.0' }) + await expect(checkForNuxtUpdate('/project')).resolves.toBeUndefined() + }) + + it('queries the configured registry with its auth token', async () => { + registry.current = { registry: 'https://npm.example.com/', authToken: 'secret' } + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + await checkForNuxtUpdate('/project') + expect(fetchMock).toHaveBeenCalledWith( + 'https://npm.example.com/-/package/nuxt/dist-tags', + expect.objectContaining({ headers: { Authorization: 'Bearer secret' } }), + ) + }) + + it('is silent when the network is unavailable', async () => { + fetchMock.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org')) + await expect(checkForNuxtUpdate('/project')).resolves.toBeUndefined() + }) + + it('does not retry the registry until the cache expires after a failure', async () => { + fetchMock.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org')) + await checkForNuxtUpdate('/project') + await checkForNuxtUpdate('/project') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('does not retry the registry until the cache expires when no dist-tag is published', async () => { + fetchMock.mockResolvedValue({}) + await checkForNuxtUpdate('/project') + await checkForNuxtUpdate('/project') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('is silent when the installed version cannot be resolved', async () => { + project.nuxtVersion = undefined + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + await expect(checkForNuxtUpdate('/project')).resolves.toBeUndefined() + }) + + it('persists the result to the user rc file', async () => { + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + await checkForNuxtUpdate('/project') + expect(rcStore.current.updateCheck).toMatchObject({ latest: '4.1.0' }) + }) + + it('preserves a persisted opt-in when caching the result', async () => { + rcStore.current = { updateCheck: { enabled: true } } + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + await checkForNuxtUpdate('/project') + expect(rcStore.current.updateCheck).toMatchObject({ enabled: true, latest: '4.1.0' }) + }) + + it('uses a fresh cache without fetching', async () => { + rcStore.current = { updateCheck: { latest: '4.1.0', checkedAt: Date.now() } } + await expect(checkForNuxtUpdate('/project')).resolves.toEqual({ current: '4.0.0', latest: '4.1.0' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('refetches when the cache is stale', async () => { + rcStore.current = { updateCheck: { latest: '4.1.0', checkedAt: Date.now() - 25 * 60 * 60 * 1000 } } + fetchMock.mockResolvedValue({ latest: '4.2.0' }) + await expect(checkForNuxtUpdate('/project')).resolves.toEqual({ current: '4.0.0', latest: '4.2.0' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('fetches when there is no cache', async () => { + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + await checkForNuxtUpdate('/project') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + }) + + describe('scheduleUpdateNudge', () => { + it('does not check when disabled', async () => { + stdEnv.isCI = true + await scheduleUpdateNudge('/project') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not check when running the upgrade command', async () => { + await scheduleUpdateNudge('/project', 'upgrade') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('defers the nudge to process exit when an update is available', async () => { + const once = vi.spyOn(process, 'once') + fetchMock.mockResolvedValue({ latest: '4.1.0' }) + await scheduleUpdateNudge('/project') + expect(once).toHaveBeenCalledWith('exit', expect.any(Function)) + for (const [event, listener] of once.mock.calls) { + process.off(event as string, listener as () => void) + } + once.mockRestore() + }) + + it('registers nothing when up to date', async () => { + const once = vi.spyOn(process, 'once') + fetchMock.mockResolvedValue({ latest: '4.0.0' }) + await scheduleUpdateNudge('/project') + expect(once).not.toHaveBeenCalled() + once.mockRestore() + }) + }) + + describe('renderUpdateNudge', () => { + const originalColumns = process.stdout.columns + + function captureNudge(columns: number) { + const chunks: string[] = [] + process.stdout.columns = columns + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + chunks.push(String(chunk)) + return true + }) + renderUpdateNudge({ current: '4.0.0', latest: '4.1.0' }) + write.mockRestore() + process.stdout.columns = originalColumns + return chunks.join('') + } + + it('shows both versions and the upgrade command in a box', () => { + const output = captureNudge(100) + expect(output).toContain('4.1.0') + expect(output).toContain('4.0.0') + expect(output).toContain('nuxt upgrade') + expect(output).toContain('╭') + }) + + it('falls back to plain lines in a narrow terminal', () => { + const output = captureNudge(0) + expect(output).toContain('4.1.0') + expect(output).toContain('nuxt upgrade') + expect(output).not.toContain('╭') + }) + }) +})