Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ Use nuxi <command> --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 &ndash; powered by [`@bomb.sh/tab`](https://github.com/bombshell-dev/tab).
Expand Down
4 changes: 3 additions & 1 deletion packages/nuxt-cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<any> | undefined
if (command !== '_dev' && provider !== 'stackblitz') {
backgroundTasks = Promise.all([
checkEngines(),
scheduleUpdateNudge(resolve(ctx.args.cwd), command),
]).catch(err => logger.error(String(err)))
}

Expand Down
204 changes: 204 additions & 0 deletions packages/nuxt-cli/src/utils/update.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<NuxtUpdate | undefined> {
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<void> {
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)
}
})
}
Comment on lines +178 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does ofetch's FetchError include request headers or options (e.g., Authorization) when thrown, and are they exposed via its message or properties?

💡 Result:

Yes, ofetch's FetchError includes the request options used for the failed request, and these options (which contain the headers, such as Authorization) are exposed via the options property of the FetchError instance [1][2][3][4]. When a request fails, ofetch throws a FetchError object [1][3]. This object provides access to the request configuration through its options property [1][3]. You can inspect these options—including any headers you originally provided in the headers object—by accessing error.options within a catch block [3][4]. It is important to note that while the headers are available within the options property, they are not directly exposed as individual properties on the FetchError instance itself, nor are they included in the default string representation of the error.message [3][4]. The error.message is formatted specifically as [METHOD] "url": status statusText errorMessage [1][3]. Example usage: try { await ofetch('/api/protected', { method: 'POST', headers: { Authorization: 'Bearer token' } }); } catch (error) { if (error instanceof FetchError) { console.log(error.options.headers); // Access the headers here } }

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Repository files matching update/logger/update versions:\n'
fd -a '^(update|logger)\.(ts|js)$|package\.json$' . | sed 's#^\./##' | head -100

printf '\nupdate.ts outline:\n'
ast-grep outline packages/nuxt-cli/src/utils/update.ts 2>/dev/null || true

printf '\nlogger.ts outline:\n'
fd -a 'logger\.ts$' packages/nuxt-cli/src packages | while read -r f; do
  printf '\n--- %s ---\n' "$f"
  ast-grop outline "$f" 2>/dev/null || ast-grep outline "$f" 2>/dev/null || true
done

printf '\nRelevant update.ts lines:\n'
cat -n packages/nuxt-cli/src/utils/update.ts | sed -n '130,220p'

printf '\nRelevant logger files:\n'
fd -a 'logger\.ts$' packages/nuxt-cli/src | while read -r f; do
  echo "--- $f ---"
  cat -n "$f" | sed -n '1,220p'
done

printf '\nSearch debug/logger usage and ofetch calls with Authorization:\n'
rg -n "debug\\(|ofetch\\(|Authorization|resolveLatestVersion|checkForNuxtUpdate" packages/nuxt-cli/src -S

Repository: nuxt/cli

Length of output: 8022


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'resolveLatestVersion function:\n'
cat -n packages/nuxt-cli/src/utils/update.ts | sed -n '66,97p'

printf '\nPackage metadata for dependencies:\n'
python3 - <<'PY'
import json
from pathlib import Path
p=Path('packages/nuxt-cli/package.json')
j=json.loads(p.read_text())
for k in ('dependencies','devDependencies'):
    print(f'[{k}]')
    for dep in ['ofetch','obug','`@clack/prompts`']:
        if dep in j.get(k,{}):
            print(f'{dep}: {j[k][dep]}')
PY

printf '\nRead package-lock / lockfiles for exact versions if present:\n'
for f in package-lock.json pnpm-lock.yaml yarn.lock; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n '"ofetch"|"obug"|`@nuxt/cli`|nuxt-cli' "$f" | head -80 || true
  fi
done

printf '\nFetchError definition from lockfile references (if accessible via npx package metadata not repo files):\n'
node - <<'JS'
const fs = require('fs');
for (const pkg of ['ofetch','obug','`@clack/prompts`']) {
  console.log(pkg);
  try {
    const p = require.resolve(`${pkg}/package.json`);
    console.log(p);
  } catch (e) {
    console.log('not resolved:', e.message.split('\n')[0]);
  }
}
JS

Repository: nuxt/cli

Length of output: 2062


🌐 Web query:

ofetch 1.5.1 source FetchError options headers property

💡 Result:

In ofetch 1.5.1, the FetchError class provides a structured way to handle request failures [1][2]. When a request fails, the resulting FetchError instance includes an options property, which contains the resolved fetch options that were used for that specific request [3]. The FetchError interface is defined as follows: export interface IFetchError<T = any> extends Error { request?: FetchRequest; options?: FetchOptions; // Resolved request options response?: FetchResponse; data?: T; status?: number; statusText?: string; statusCode?: number; statusMessage?: string; } Within this options property, the headers are present as part of the resolved FetchOptions [4][5]. As of ofetch 1.4 and onwards, these headers are normalized into a Headers object within the request context [6]. If you are inspecting the FetchError, the options.headers property will reflect these normalized headers used during the execution of the request [3][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Check whether npx can retrieve ofetch source from registry:\n'
npm view ofetch@1.5.1 dist.tarball --json 2>&1 | head -20

printf '\nProgrammatic extraction from fetched ofetch source bundle if npm registry access is available:\n'
python3 - <<'PY'
import json, subprocess, tarfile, io
try:
    r = subprocess.run(['npm','view','ofetch@1.5.1','dist.tarball'], text=True, capture_output=True, timeout=20)
    url = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else ''
    if not url:
        print('npm view did not provide dist.tarball')
        raise SystemExit(0)
    print('tarball:', url)
    s = subprocess.run(['curl','-fsSL', url], capture_output=True, check=True)
    tf = tarfile.open(fileobj=io.BytesIO(s.stdout), mode='r:gz')
    for name in tf.getnames():
        if name in ['dist/index.mjs','dist/index.cjs','src/types.ts','src/fetch.ts']:
            f = tf.extractfile(name)
            text = f.read().decode('utf-8','replace')
            print(f'\n--- {name} ---')
            for i, line in enumerate(text.splitlines(), 1):
                if 'class FetchError' in line or 'IFetchError' in line or 'options' in line or 'headers' in line or 'toString' in line:
                    print(f'{i}: {line}')
except Exception as e:
    print(f'could not extract remote source: {type(e).__name__}: {e}')
PY

Repository: nuxt/cli

Length of output: 558


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import subprocess, tarfile, io, re, sys

url = subprocess.check_output(['npm','view','ofetch@1.5.1','dist.tarball'], text=True).strip().splitlines()[-1].splitlines()[-1]
print('tarball:', url)
data = subprocess.check_output(['curl','-fsSL', url])
tf = tarfile.open(fileobj=io.BytesIO(data), mode='r:gz')
files = {n: tf.extractfile(n).read().decode('utf-8','replace') for n in ('dist/index.mjs','dist/index.cjs','src/types.ts','src/fetch.ts')}
for path, text in files.items():
    if not text:
        continue
    print(f'\n--- {path} ---')
    for i, line in enumerate(text.splitlines(), 1):
        if any(tok in line for tok in ['class FetchError', 'throw new FetchError', 'IFetchError', 'request:', 'options:', 'response:', 'data:', 'statusCode:', 'statusMessage:', 'message:', 'toString']):
            print(f'{i}: {line}')
PY

Repository: nuxt/cli

Length of output: 770


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import subprocess, tarfile, io, sys

url = subprocess.check_output(['npm','view','ofetch@1.5.1','dist.tarball'], text=True).strip().splitlines()[-1].splitlines()[-1]
data = subprocess.check_output(['curl','-fsSL', url])
tf = tarfile.open(fileobj=io.BytesIO(data), mode='r:gz')
for name in tf.getnames():
    print(name)
PY

Repository: nuxt/cli

Length of output: 808


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import subprocess, tarfile, io, re

url = subprocess.check_output(['npm','view','ofetch@1.5.1','dist.tarball'], text=True).strip().splitlines()[-1].splitlines()[-1]
data = subprocess.check_output(['curl','-fsSL', url])
tf = tarfile.open(fileobj=io.BytesIO(data), mode='r:gz')

interesting = [
    'package/dist/index.mjs',
    'package/dist/index.cjs',
    'package/dist/index.d.ts',
    'package/dist/shared/ofetch.BbrTaNPp.d.mts',
    'package/dist/shared/ofetch.CWycOUEr.mjs',
]

for name in interesting:
    try:
        text = tf.extractfile(name).read().decode('utf-8','replace')
    except Exception as e:
        print(f'--- {name}: ERROR {e} ---')
        continue
    print(f'\n--- {name} ---')
    for i, line in enumerate(text.splitlines(), 1):
        if any(tok in line for tok in [
            'class FetchError', 'throw new FetchError', 'IFetchError',
            'FetchError', 'request:', 'options:', 'response:', 'statusCode:',
            'statusMessage:', 'getMessage', 'toString', 'headers'
        ]):
            print(f'{i}: {line}')
PY

Repository: nuxt/cli

Length of output: 3620


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import subprocess, tarfile, io

url = subprocess.check_output(['npm','view','ofetch@1.5.1','dist.tarball'], text=True).strip().splitlines()[-1].splitlines()[-1]
data = subprocess.check_output(['curl','-fsSL', url])
tf = tarfile.open(fileobj=io.BytesIO(data), mode='r:gz')
text = tf.extractfile('package/dist/shared/ofetch.CWycOUEr.mjs').read().decode('utf-8','replace').splitlines()

for idx, (start, depth, text) in enumerate([
    (4, 1, 'class FetchError'),
    (13, 0, 'function createFetchError'),
], start=0):
    print(f'\n--- {text} ---')
    base = getattr(range(start, min(start+60, len(text)+1)), '__len__', lambda: range(start, min(start+60, len(text)+1)))
PY

python3 - <<'PY'
import subprocess, tarfile, io

url = subprocess.check_output(['npm','view','ofetch@1.5.1','dist.tarball'], text=True).strip().splitlines()[-1].splitlines()[-1]
data = subprocess.check_output(['curl','-fsSL', url])
tf = tarfile.open(fileobj=io.BytesIO(data), mode='r:.gz')
text = tf.extractfile('package/dist/shared/ofetch.CWycOUEr.mjs').read().decode('utf-8','replace')
lines = text.splitlines()

print('FetchError class:')
for i in range(4, 14):
    print(f'{i}: {lines[i-1]}')

print('\ncreateFetchError function:')
for i in range(13, 36):
    print(f'{i}: {lines[i-1]}')
PY

Repository: nuxt/cli

Length of output: 764


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import subprocess, tarfile, io

url = subprocess.check_output(['npm','view','ofetch@1.5.1','dist.tarball'], text=True).strip().splitlines()[-1].splitlines()[-1]
data = subprocess.check_output(['curl','-fsSL', url])
tf = tarfile.open(fileobj=io.BytesIO(data), mode='r:gz')

lines = tf.extractfile('package/dist/shared/ofetch.CWycOUEr.mjs').read().decode('utf-8','replace').splitlines()
print('FetchError class:')
for i in range(4, 13):
    print(f'{i}: {lines[i-1]}')

print('\nfetchError.message construction:')
for i in range(13, 24):
    print(f'{i}: {lines[i-1]}')

print('\nthrow FetchError call:')
for i in range(185, 210):
    print(f'{i}: {lines[i-1]}')
PY

Repository: nuxt/cli

Length of output: 1985


🌐 Web query:

obug createDebug default log function argument serialization

💡 Result:

In the obug library, argument serialization is primarily handled through the formatArgs function, which is configurable within the DebugOptions object provided to createDebug [1][2]. By default, obug uses an internal mechanism to process and format arguments before they are passed to the log function [1][2]. The default log function typically writes to stderr in Node.js and to the console in browser environments [1][3]. Key points regarding argument handling and serialization: - Custom Serialization: Users can define a custom formatArgs function in the DebugOptions to control how arguments are serialized or transformed before being logged [1][4]. - Formatters: The library also supports a formatters object within DebugOptions, which allows for custom handling of specific format specifiers (e.g., %n) used in debug strings [5][6]. - Default Behavior: When not overridden, obug manages formatting automatically, including adding timestamps and namespace prefixes [1][7]. In Node.js, this default behavior can be further adjusted using inspectOpts, which provides options for object inspection depth and style [1][3]. - Overriding Logging: While formatArgs handles the serialization of the arguments array, the log option itself can be overridden to redirect output or change how the serialized result is ultimately presented (e.g., sending logs to a custom endpoint or file) [1][2]. For most use cases, the default serialization is sufficient, relying on standard console output methods, but the modularity of the library allows for granular control over both the preparation (formatArgs) and the final output (log) of debug messages [1][3].

Citations:


Redact auth headers before logging fetch failures.

resolveLatestVersion() passes Authorization: Bearer ${authToken} to $fetch()/ofetch, whose FetchError is available at error.options.headers. Since debug() receives the full error object, configure the formatter/log to omit request/option headers, or catch FetchError here and log only the sanitized message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxt-cli/src/utils/update.ts` around lines 174 - 200, Sanitize fetch
failure details before they reach debug logging in scheduleUpdateNudge and the
update-check flow, especially errors from resolveLatestVersion whose
options.headers may contain Authorization credentials. Configure the relevant
formatter to omit request/option headers, or catch FetchError and pass only a
sanitized message to debug while preserving the existing nudge behavior.

Loading
Loading