From 5bbd53ceaa3d87b5cd8e78aec02a3868298bf158 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 25 Jul 2026 22:49:08 +0000 Subject: [PATCH 1/2] feat: nudge when a newer nuxt release is available --- README.md | 8 + packages/nuxt-cli/src/main.ts | 4 +- packages/nuxt-cli/src/utils/update.ts | 200 ++++++++++++++ .../nuxt-cli/test/unit/utils/update.spec.ts | 259 ++++++++++++++++++ 4 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 packages/nuxt-cli/src/utils/update.ts create mode 100644 packages/nuxt-cli/test/unit/utils/update.spec.ts diff --git a/README.md b/README.md index f58dd8ae..98f8a9a5 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` 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..006233f5 --- /dev/null +++ b/packages/nuxt-cli/src/utils/update.ts @@ -0,0 +1,200 @@ +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` 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) { + 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.latest && cache.checkedAt && Date.now() - Number(cache.checkedAt) < CACHE_TTL) { + return cache.latest + } + + const { registry, authToken } = await detectNpmRegistry(null) + const { latest } = await $fetch<{ latest?: string }>(joinURL(registry, '-/package/nuxt/dist-tags'), { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, + timeout: FETCH_TIMEOUT, + retry: 0, + }) + + if (!latest) { + return undefined + } + + 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..d036a2ad --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/update.spec.ts @@ -0,0 +1,259 @@ +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 + }) + + afterEach(() => { + process.stdout.isTTY = originalIsTTY + delete process.env.NUXT_IGNORE_UPDATE_CHECK + }) + + 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 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('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('╭') + }) + }) +}) From 8cc5b009185a6b811168ace42fcd4fc30518290a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 26 Jul 2026 00:15:33 +0100 Subject: [PATCH 2/2] fix: respect NO_UPDATE_NOTIFIER + negative checks --- README.md | 2 +- packages/nuxt-cli/src/utils/update.ts | 34 +++++++++++-------- .../nuxt-cli/test/unit/utils/update.spec.ts | 21 ++++++++++++ 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 98f8a9a5..43f34575 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Once a day, `nuxt/cli` checks in the background whether a newer version of Nuxt 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` to skip a single run, or opt out permanently by adding `updateCheck.enabled=false` to your user-level `.nuxtrc`. +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 diff --git a/packages/nuxt-cli/src/utils/update.ts b/packages/nuxt-cli/src/utils/update.ts index 006233f5..8e4a0ea1 100644 --- a/packages/nuxt-cli/src/utils/update.ts +++ b/packages/nuxt-cli/src/utils/update.ts @@ -54,13 +54,13 @@ function writeCache(cache: UpdateCache) { } /** - * `NUXT_IGNORE_UPDATE_CHECK=1` 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). + * `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) { + if (process.env.NUXT_IGNORE_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) { return false } if (readCache().enabled === false) { @@ -71,21 +71,25 @@ export function isUpdateCheckEnabled(): boolean { async function resolveLatestVersion(): Promise { const cache = readCache() - if (cache.latest && cache.checkedAt && Date.now() - Number(cache.checkedAt) < CACHE_TTL) { + if (cache.checkedAt && Date.now() - Number(cache.checkedAt) < CACHE_TTL) { return cache.latest } - const { registry, authToken } = await detectNpmRegistry(null) - const { latest } = await $fetch<{ latest?: string }>(joinURL(registry, '-/package/nuxt/dist-tags'), { - headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, - timeout: FETCH_TIMEOUT, - retry: 0, - }) - - if (!latest) { - return undefined + 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 } diff --git a/packages/nuxt-cli/test/unit/utils/update.spec.ts b/packages/nuxt-cli/test/unit/utils/update.spec.ts index d036a2ad..a6133cc3 100644 --- a/packages/nuxt-cli/test/unit/utils/update.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/update.spec.ts @@ -63,11 +63,13 @@ describe('update check', () => { 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', () => { @@ -95,6 +97,11 @@ describe('update check', () => { 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) @@ -154,6 +161,20 @@ describe('update check', () => { 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' })