vercel/next.js · #99001

Enable AI upgrade for canary

devjiwonchoi · merged Sep 22, 20267 files · 1166 + / 297 −
docs/01-app/02-guides/upgrading/agentic-upgrade.mdx5 + / 1 −
@@ -13,7 +13,11 @@ as instructed so the original task can continue with a warning. Tell the user about the finding and ask whether they want to run the upgrade. Continue independent work while waiting. If approved, complete the upgrade. -For `latest`, the CLI pins npm's latest stable Next.js release as the target.+For `latest`, the CLI pins the release from the app's channel: npm's `latest`+dist-tag for stable installations and `canary` for canary installations.++Security upgrades and advisory checks are supported only for stable installations.+Canary installations support `latest` and `future` without advisory assessment.  ## 1. Check for duplicates 
packages/next/src/cli/next-upgrade.ts5 + / 3 −
@@ -2,7 +2,7 @@ import { spawn } from 'child_process' import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { dirname, join } from 'path'-import { valid } from 'next/dist/compiled/semver'+import { prerelease, valid } from 'next/dist/compiled/semver' import * as Log from '../build/output/log' import createSpinner from '../build/spinner' import { findDir } from '../lib/find-pages-dir'@@ -351,12 +351,14 @@ export async function spawnNextUpgrade(       const references = result.references         .map((reference) => `- ${reference}`)         .join('\n')+      const releaseKind =+        prerelease(result.targetVersion)?.[0] === 'canary' ? 'canary' : 'stable'       const reason =         upgradeType === 'security'           ? 'the installed version is affected by a published security advisory'           : upgradeType === 'latest'-            ? 'a newer stable Next.js release is available'-            : 'the Future policy applies the latest stable release and adopts its Future Defaults'+            ? `a newer ${releaseKind} Next.js release is available`+            : `the Future policy applies the latest ${releaseKind} release and adopts its Future Defaults`       const futureDefaultsPrompt = preparedFutureDefaults.length         ? ` ${needsVersionMigration ? 'After completing and verifying the version migration, adopt' : 'Adopt'} these Future Defaults in order:
packages/next/src/lib/upgrade/nudge.ts63 + / 63 −
@@ -8,6 +8,7 @@ import * as Log from '../../build/output/log' import type { NextConfigComplete } from '../../server/config-shared' import { getAgentName } from '../../telemetry/agent-name' import { futureDefaults } from './future-defaults'+import type { UpgradeAssessment } from './prepare-upgrade'  type SecurityNudgeOptions = {   directory: string@@ -128,92 +129,64 @@ async function showNudge(   throw error } -async function getLatestUpgrade(-  installedVersion: string = process.env.__NEXT_VERSION || 'unknown'-): Promise<{ installedVersion: string; latestVersion: string } | null> {-  try {-    if (!(await getAgentName())) {-      return null-    }--    const { getLatestUpgradeVersion } =-      require('./prepare-upgrade') as typeof import('./prepare-upgrade')-    const latestVersion = await getLatestUpgradeVersion(installedVersion)--    if (!latestVersion) {-      return null-    }--    return { installedVersion, latestVersion }-  } catch {-    // A release reminder is best-effort; lookup failures should stay quiet.-    return null-  }-}- async function nudgeForSecurity(   options: SecurityNudgeOptions,-  policy: 'security' | 'latest' | 'future'+  policy: 'security' | 'latest' | 'future',+  assessment: UpgradeAssessment ): Promise<boolean> {-  let advisory-  const version = process.env.__NEXT_VERSION || 'unknown'--  try {-    if (!(await getAgentName())) {-      return false-    }--    // Reuse upgrade's advisory readers only after detecting an agent.-    const { getSecurityAdvisory } =-      require('./prepare-upgrade') as typeof import('./prepare-upgrade')-    advisory = await getSecurityAdvisory(version)-  } catch {-    Log.warn(-      'Could not check Next.js security advisories. Continuing without an upgrade assessment.'-    )-    return false-  }--  if (!advisory) {+  if (!assessment.affected) {     return false   }+  const version = process.env.__NEXT_VERSION || 'unknown'+  const { reference, upgrade } = assessment+  const action =+    upgrade.status === 'ready'+      ? `At the end of the task, ask the user whether they want you to run the upgrade using the configured '${policy}' policy in the background from this app's directory: -  const { reference } = advisory+\`\`\`+next upgrade --ai+\`\`\``+      : `${upgrade.status === 'unknown' ? 'Upgrade availability could not be checked.' : 'No safe newer target is available for the configured upgrade policy.'} ${upgrade.reason}`   await showNudge(     options,     version,     'security',-    `Your version of Next.js is affected by a published security advisory and can be automatically upgraded.+    `Your version of Next.js is affected by a published security advisory.  **We strongly recommend you upgrade Next.js.**  This command stopped so the reminder you configured is not missed. Retry the same command to continue the original task. -At the end of the task, ask the user whether they want you to run the security upgrade in the background from this app's directory:--\`\`\`-next upgrade --ai-\`\`\`+${action}  Reference: ${reference}  Note: This security gate is enabled by \`experimental.agenticAutoUpgrade: '${policy}'\`.`,-    `Your version of Next.js is affected by a published security advisory. This command is continuing after the reminder you configured.\nReference: ${reference}`,+    `Your version of Next.js is affected by a published security advisory. This command is continuing after the reminder you configured.${upgrade.status === 'ready' ? '' : `\n${action}`}\nReference: ${reference}`,     'SecurityFatalError'   )   return true }  async function nudgeForLatest(   options: SecurityNudgeOptions,-  policy: 'latest' | 'future'+  policy: 'latest' | 'future',+  assessment: UpgradeAssessment ): Promise<boolean> {+  if (assessment.upgrade.status !== 'ready') {+    return false+  }   const version = process.env.__NEXT_VERSION || 'unknown'-  const latest = await getLatestUpgrade(version)-  if (!latest) return false--  const { installedVersion, latestVersion } = latest-  const reference = 'https://registry.npmjs.org/next/latest'+  const { getLatestUpgradeVersion } =+    require('./prepare-upgrade') as typeof import('./prepare-upgrade')+  const { installedVersion, targetVersion } = assessment.upgrade+  const latestVersion = getLatestUpgradeVersion(installedVersion, targetVersion)+  if (!latestVersion) {+    return false+  }+  const distTag =+    semver.prerelease(latestVersion)?.[0] === 'canary' ? 'canary' : 'latest'+  const reference = `https://registry.npmjs.org/next/${distTag}`   await showNudge(     options,     version,@@ -247,7 +220,8 @@ export async function getFutureUpgrade(     if (       !(await getAgentName()) ||       !semver.valid(installedVersion) ||-      semver.prerelease(installedVersion)+      (semver.prerelease(installedVersion) &&+        semver.prerelease(installedVersion)?.[0] !== 'canary')     ) {       return null     }@@ -315,13 +289,39 @@ export async function nudgeForUpgrade(     return   } -  const options = { directory, distDir: config.distDir, command }-  if (await nudgeForSecurity(options, policy)) return+  const version = process.env.__NEXT_VERSION || 'unknown'+  let assessment: UpgradeAssessment+  try {+    if (+      !(await getAgentName()) ||+      !semver.valid(version) ||+      (semver.prerelease(version) &&+        semver.prerelease(version)?.[0] !== 'canary')+    ) {+      return+    }+    const { getUpgradeAssessment } =+      require('./prepare-upgrade') as typeof import('./prepare-upgrade')+    assessment = await getUpgradeAssessment(version, policy)+  } catch {+    Log.warn(+      'Could not check Next.js security advisories. Continuing without an upgrade assessment.'+    )+    return+  } +  const options = { directory, distDir: config.distDir, command }+  if (await nudgeForSecurity(options, policy, assessment)) {+    return+  }+  if (assessment.upgrade.status !== 'ready') {+    return+  }   if (policy === 'latest' || policy === 'future') {-    if (await nudgeForLatest(options, policy)) return+    if (await nudgeForLatest(options, policy, assessment)) {+      return+    }   }-   if (policy === 'future') {     await nudgeForFuture(options, config)   }
packages/next/src/lib/upgrade/prepare-upgrade.ts270 + / 178 −
@@ -44,121 +44,212 @@ export async function prepareUpgrade(     throw new Error('Could not determine the installed Next.js version.')   } -  // TODO: Handle prereleases-  if (semver.prerelease(installedVersion)) {+  if (semver.prerelease(installedVersion) && !isCanary(installedVersion)) {     throw new Error(       'AI upgrades are not available for prerelease versions of Next.js yet.'     )   } -  if (targetRequest === 'latest' || targetRequest === 'future') {-    const url = `${NPM_REGISTRY}next/latest`-    const { value } = await fetchJSON(url)-    const release = value as { version: string } | null+  const { upgrade } = await getUpgradeAssessment(+    installedVersion,+    targetRequest+  )+  if (upgrade.status === 'blocked' || upgrade.status === 'unknown') {+    throw new Error(upgrade.reason)+  }+  if (upgrade.status !== 'ready' || targetRequest !== 'future') {+    return upgrade+  } -    if (-      !release ||-      !semver.valid(release.version) ||-      semver.prerelease(release.version)-    ) {-      throw new Error('Could not determine the latest stable Next.js version.')+  const config = await loadConfig(PHASE_INFO, directory, {+    silent: true,+  }).finally(resetEnv)+  const pendingFutureDefaults = futureDefaults.filter(+    (futureDefault) =>+      semver.gte(upgrade.targetVersion, futureDefault.availableSince) &&+      !futureDefault.isAdopted(config)+  )++  if (+    upgrade.targetVersion === installedVersion &&+    pendingFutureDefaults.length === 0+  ) {+    return {+      status: 'unaffected',+      reason: `Next.js ${installedVersion} is current and all available Future Defaults are enabled.`,     }+  } -    const targetVersion =-      targetRequest === 'future' && semver.gt(installedVersion, release.version)-        ? installedVersion-        : release.version+  return { ...upgrade, futureDefaults: pendingFutureDefaults }+} -    if (-      targetRequest === 'latest' &&-      semver.eq(release.version, installedVersion)-    ) {-      return {-        status: 'unaffected',-        reason: `Next.js ${installedVersion} is already the latest stable release.`,-      }+export type UpgradeAssessment = {+  // null means advisory assessment is unsupported for this release channel.+  affected: boolean | null+  reference: string | null+  upgrade:+    | UpgradePreparation+    | { status: 'blocked'; reason: string }+    | { status: 'unknown'; reason: string }+}++// TODO: Cache assessments briefly by installed version, policy, and resolved+// target to avoid repeating nudge lookups. New advisories can affect the same+// target, so expire cached results and refresh metadata before execution.+export async function getUpgradeAssessment(+  installedVersion: string,+  policy: 'security' | 'latest' | 'future'+): Promise<UpgradeAssessment> {+  if (!semver.valid(installedVersion)) {+    throw new Error('The running Next.js version is not valid semver.')+  }+  if (semver.prerelease(installedVersion) && !isCanary(installedVersion)) {+    throw new Error(+      'AI upgrades are not available for prerelease versions of Next.js yet.'+    )+  }+  const canary = isCanary(installedVersion)+  if (canary && policy === 'security') {+    return {+      affected: null,+      reference: null,+      upgrade: {+        status: 'blocked',+        reason:+          'Security upgrades are not supported for canary versions of Next.js. Use --ai=latest or --ai=future to upgrade on the canary channel.',+      },+    }+  }+  // Canary version ordering does not establish which security fixes it contains.+  // Keep canary latest/Future upgrades independent of advisory assessment.+  const snapshot = canary ? null : await readAdvisorySnapshot(installedVersion)+  const affected = snapshot+    ? snapshot.ranges.some((range) => semver.satisfies(installedVersion, range))+    : null+  const assessment = { affected, reference: snapshot?.reference ?? null }++  if (snapshot && snapshot.targetError !== null) {+    return {+      ...assessment,+      upgrade: { status: 'unknown', reason: snapshot.targetError },     }+  } -    if (-      targetRequest === 'latest' &&-      semver.lt(release.version, installedVersion)-    ) {-      return {+  if (policy === 'security' && !affected) {+    return {+      ...assessment,+      upgrade: {         status: 'unaffected',-        reason: `Next.js ${installedVersion} is newer than the latest stable release ${release.version}.`,-      }+        reason: `No security update is needed for Next.js ${installedVersion}.`,+      },     }+  } -    let pendingFutureDefaults: FutureDefaultEntry[] = []--    if (targetRequest === 'future') {-      const securitySnapshot = await readSecuritySnapshot(targetVersion)+  // Keep a confirmed advisory even when target metadata cannot be read.+  try {+    let targetVersion: string+    let references: string[]+    if (policy === 'security' && snapshot) {+      const registryURL = `${NPM_REGISTRY}next`+      const registry = snapshot.registry ?? (await fetchJSON(registryURL)).value+      const releases = parseReleases(registry)+      references = [snapshot.reference, registryURL]+      try {+        targetVersion = selectSecurityTarget(installedVersion, {+          ranges: snapshot.ranges,+          releases,+          references,+        })!.version+      } catch (error) {+        return {+          ...assessment,+          upgrade: { status: 'blocked', reason: (error as Error).message },+        }+      }+    } else {+      const release = await fetchLatestRelease(installedVersion)+      if (!release) {+        throw new Error(+          canary+            ? 'Could not determine the latest Next.js version on the canary dist-tag.'+            : 'Could not determine the latest stable Next.js version.'+        )+      }+      targetVersion =+        policy === 'future' && semver.gt(installedVersion, release.version)+          ? installedVersion+          : release.version+      references = [release.reference]+      const releaseKind = canary ? 'canary release' : 'stable release'++      if (policy === 'latest' && semver.lt(targetVersion, installedVersion)) {+        return {+          ...assessment,+          upgrade: {+            status: 'unaffected',+            reason: `Next.js ${installedVersion} is newer than the latest ${releaseKind} ${targetVersion}.`,+          },+        }+      } +      // The tag can advance after npm's published-version snapshot was read.+      // Include that exact target in the fallback query as well.       if (-        securitySnapshot?.ranges.some((range) =>-          semver.satisfies(targetVersion, range)+        snapshot &&+        snapshot.registry !== null &&+        !advisoryVersions(snapshot.registry, installedVersion).includes(+          targetVersion         )       ) {-        throw new Error(-          `Next.js ${targetVersion} is affected by an active advisory.`+        snapshot.ranges = affectedRanges(+          await readNpmAdvisories([+            ...advisoryVersions(snapshot.registry, installedVersion),+            targetVersion,+          ])+        )+        assessment.affected ||= snapshot.ranges.some((range) =>+          semver.satisfies(installedVersion, range)         )       }--      const config = await loadConfig(PHASE_INFO, directory, {-        silent: true,-      }).finally(resetEnv)--      pendingFutureDefaults = futureDefaults.filter(-        (futureDefault) =>-          semver.gte(targetVersion, futureDefault.availableSince) &&-          !futureDefault.isAdopted(config)-      )-       if (-        targetVersion === installedVersion &&-        pendingFutureDefaults.length === 0+        snapshot?.ranges.some((range) => semver.satisfies(targetVersion, range))       ) {         return {-          status: 'unaffected',-          reason: `Next.js ${installedVersion} is current and all available Future Defaults are enabled.`,+          ...assessment,+          upgrade: {+            status: 'blocked',+            reason: `Next.js ${targetVersion} is affected by an active advisory.`,+          },         }       }-    } -    return {-      status: 'ready',-      installedVersion,-      targetVersion,-      references: [url],-      futureDefaults: pendingFutureDefaults,+      if (policy === 'latest' && semver.eq(targetVersion, installedVersion)) {+        return {+          ...assessment,+          upgrade: {+            status: 'unaffected',+            reason: `Next.js ${installedVersion} is already the latest ${releaseKind}.`,+          },+        }+      }     }-  }--  const snapshot = await readSecuritySnapshot(installedVersion) -  if (!snapshot) {     return {-      status: 'unaffected',-      reason: `No security update is needed for Next.js ${installedVersion}.`,+      ...assessment,+      upgrade: {+        status: 'ready',+        installedVersion,+        targetVersion,+        references,+        futureDefaults: [],+      },     }-  }--  const selected = selectSecurityTarget(installedVersion, snapshot)--  if (!selected) {+  } catch (error) {     return {-      status: 'unaffected',-      reason: `No security update is needed for Next.js ${installedVersion}.`,+      ...assessment,+      upgrade: { status: 'unknown', reason: (error as Error).message },     }   }--  return {-    status: 'ready',-    installedVersion,-    targetVersion: selected.version,-    references: snapshot.references,-    futureDefaults: [],-  } }  type Advisory = {@@ -208,6 +299,10 @@ async function fetchJSON(   } } +function isCanary(version: string): boolean {+  return semver.prerelease(version)?.[0] === 'canary'+}+ function parseReleases(value: unknown): SecuritySnapshot['releases'] {   const data = value as {     versions:@@ -289,68 +384,50 @@ function affectedRanges(advisories: Advisory[]): string[] {   return ranges } -export async function getLatestUpgradeVersion(version: string) {-  // TODO: Support prerelease upgrade policies once their target selection is-  // defined for explicit upgrades and background reminders.-  if (!semver.valid(version) || semver.prerelease(version)) {-    return null-  }--  const { value } = await fetchJSON(`${NPM_REGISTRY}next/latest`)-  const release = value as { version: string } | null-+export function getLatestUpgradeVersion(+  version: string,+  targetVersion: string+) {   if (-    !release ||-    !semver.valid(release.version) ||-    semver.prerelease(release.version) !== null ||-    !semver.gt(release.version, version)+    !semver.valid(version) ||+    !semver.valid(targetVersion) ||+    (semver.prerelease(version) && !isCanary(version)) ||+    (isCanary(version)+      ? !isCanary(targetVersion)+      : semver.prerelease(targetVersion)) ||+    !semver.gt(targetVersion, version)   ) {     return null   }--  // Patch releases remain available to explicit upgrades without a reminder.+  // Patches and consecutive canaries remain available to explicit upgrades+  // without a reminder.   if (-    semver.major(release.version) === semver.major(version) &&-    semver.minor(release.version) === semver.minor(version)+    semver.major(targetVersion) === semver.major(version) &&+    semver.minor(targetVersion) === semver.minor(version)   ) {     return null   }--  return release.version+  return targetVersion } -// Count only advisories affecting the running version for the startup prompt.-// Full release selection remains in the explicit upgrade command.-export async function getSecurityAdvisory(version: string) {-  if (!semver.valid(version)) {-    throw new Error('The running Next.js version is not valid semver.')-  }--  if (semver.prerelease(version)) {-    return null-  }--  let advisories: Advisory[]-  let reference: string--  try {-    const result = await readGitHubAdvisories(version)-    advisories = result.advisories-    reference = result.reference-  } catch {-    advisories = await readNpmAdvisories([version])-    reference = NPM_ADVISORIES-  }+async function fetchLatestRelease(installedVersion: string): Promise<{+  version: string+  reference: string+} | null> {+  const canary = isCanary(installedVersion)+  const reference = `${NPM_REGISTRY}next/${canary ? 'canary' : 'latest'}`+  const { value } = await fetchJSON(reference)+  const release = value as { version: string } | null    if (-    !affectedRanges(advisories).some((range) =>-      semver.satisfies(version, range)-    )+    !release ||+    !semver.valid(release.version) ||+    (canary ? !isCanary(release.version) : semver.prerelease(release.version))   ) {     return null   } -  return { reference }+  return { version: release.version, reference } }  async function readGitHubAdvisories(version: string | null) {@@ -446,64 +523,80 @@ async function readNpmAdvisories(versions: string[]): Promise<Advisory[]> {   return advisories } -// TODO: Replace provider-specific requests with a Next.js-maintained endpoint-// that returns advisory ranges and exact safe targets for each major.-async function readSecuritySnapshot(-  installedVersion: string-): Promise<SecuritySnapshot | undefined> {-  // Accept GitHub evidence only after every page succeeds. On failure, npm-  // replaces the entire advisory set rather than supplementing partial results.-  let githubRanges: string[] | undefined-  let githubFailure: unknown--  try {-    githubRanges = affectedRanges((await readGitHubAdvisories(null)).advisories)--    if (-      !githubRanges.some((range) => semver.satisfies(installedVersion, range))-    ) {-      return-    }-  } catch (error) {-    githubFailure = error+function advisoryVersions(value: unknown, checkedVersion: string): string[] {+  const data = value as { versions: Record<string, unknown> | undefined }+  if (+    !data?.versions ||+    typeof data.versions !== 'object' ||+    Array.isArray(data.versions)+  ) {+    throw new Error('Could not check for security updates.')   } -  const registryURL = `${NPM_REGISTRY}next`-  let releases: SecuritySnapshot['releases']-  let ranges: string[]-  let advisoryReference: string+  // Include all published versions to retrieve advisories affecting candidates,+  // not only the installed version.+  const versions = Object.keys(data.versions).filter((version) =>+    semver.valid(version)+  )+  if (!versions.includes(checkedVersion)) {+    versions.push(checkedVersion)+  }+  return versions+} +// TODO: Replace provider-specific requests with a Next.js-maintained endpoint+// that returns advisory ranges and exact safe targets for each major.+async function readAdvisorySnapshot(installedVersion: string): Promise<{+  ranges: string[]+  reference: string+  registry: unknown | null+  targetError: string | null+}> {+  // Accept GitHub evidence only after every page and range validates. On+  // failure, npm replaces the entire set, including candidate advisories.   try {-    const { value } = await fetchJSON(registryURL)-    releases = parseReleases(value)--    if (githubRanges) {-      ranges = githubRanges-      advisoryReference = ADVISORIES-    } else {-      // Query every published version, including prereleases: querying only the-      // installed version could miss advisories affecting a candidate target.-      const versions = Object.keys(-        (value as { versions: Record<string, unknown> }).versions-      ).filter((version) => semver.valid(version))-      ranges = affectedRanges(await readNpmAdvisories(versions))-      advisoryReference = NPM_ADVISORIES+    const { advisories, reference } = await readGitHubAdvisories(null)+    return {+      ranges: affectedRanges(advisories),+      reference,+      registry: null,+      targetError: null,     }-  } catch (error) {-    if (!githubRanges) {+  } catch (githubFailure) {+    try {+      const { value } = await fetchJSON(`${NPM_REGISTRY}next`)+      const ranges = affectedRanges(+        await readNpmAdvisories(advisoryVersions(value, installedVersion))+      )+      return {+        ranges,+        reference: NPM_ADVISORIES,+        registry: value,+        targetError: null,+      }+    } catch (error) {+      // A source-only response can still establish a warning, but cannot+      // establish target safety.+      try {+        const ranges = affectedRanges(+          await readNpmAdvisories([installedVersion])+        )+        if (ranges.some((range) => semver.satisfies(installedVersion, range))) {+          return {+            ranges,+            reference: NPM_ADVISORIES,+            registry: null,+            targetError: 'Could not assess upgrade targets. Please try again.',+          }+        }+      } catch {+        // Neither provider could establish even a source-only warning.+      }       throw new Error(         'Could not check for security updates. Please try again.',         { cause: [githubFailure, error] }       )     }--    throw error-  }--  return {-    ranges,-    releases,-    references: [advisoryReference, registryURL],   } } @@ -518,8 +611,7 @@ function selectSecurityTarget(   }    const releases = snapshot.releases-  // Consider only the latest stable release of each major, not an older patch-  // that happens to be safe while that major's latest release is affected.+  // Consider only the latest stable release of each major, not an older safe patch.   const latest = new Map<number, PackageRelease>()    for (const release of releases) {
test/unit/agentic-upgrade-prompts.test.ts34 + / 0 −
@@ -547,6 +547,40 @@ describe('agentic upgrade prompts', () => {     `)   }) +  it.each(['latest', 'future'] as const)(+    'hands off the exact canary target for %s upgrades',+    async (policy) => {+      jest.mocked(prepareUpgrade).mockResolvedValue({+        status: 'ready',+        installedVersion: '17.2.0-canary.4',+        targetVersion: '17.2.0-canary.9',+        references: ['https://registry.npmjs.org/next/canary'],+        futureDefaults: [],+      })++      await spawnNextUpgrade('/workspace/app', {+        revision: 'latest',+        verbose: false,+        ai: policy,+      })++      expect(prepareUpgrade).toHaveBeenCalledWith('/workspace/app', policy)+      const prompt = normalizedBootstrapCalls().flat().join('\n')+      expect(prompt).toContain(+        'from Next.js 17.2.0-canary.4 to 17.2.0-canary.9'+      )+      expect(prompt).toContain(+        policy === 'latest'+          ? 'newer canary Next.js release'+          : 'latest canary release'+      )+      expect(prompt).toContain('https://registry.npmjs.org/next/canary')+      expect(String(jest.mocked(writeFile).mock.calls[0][1])).toContain(+        'upgrade 17.2.0-canary.9 --yes --skip-adoption'+      )+    }+  )+   it('adds temporary Future Default instructions to the migration prompt', async () => {     jest.mocked(prepareUpgrade).mockResolvedValue({       status: 'ready',
test/unit/prepare-latest-upgrade.test.ts551 + / 12 −
@@ -1,7 +1,12 @@ import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path'-import { prepareUpgrade } from 'next/dist/lib/upgrade/prepare-upgrade'+import semver from 'next/dist/compiled/semver'+import {+  getLatestUpgradeVersion,+  getUpgradeAssessment,+  prepareUpgrade,+} from 'next/dist/lib/upgrade/prepare-upgrade' import loadConfig from 'next/dist/server/config'  jest.mock('next/dist/server/config', () => ({@@ -26,14 +31,16 @@ describe('prepare latest upgrade', () => {   }    function mockLatestVersion(version: string) {-    global.fetch = jest.fn().mockResolvedValue(-      new Response(-        JSON.stringify({-          version,-          engines: { node: '>=18' },-        }),-        { status: 200 }-      )+    global.fetch = jest.fn(async (input) =>+      String(input).startsWith('https://api.github.com/')+        ? Response.json([])+        : new Response(+            JSON.stringify({+              version,+              engines: { node: '>=18' },+            }),+            { status: 200 }+          )     )   } @@ -86,6 +93,287 @@ describe('prepare latest upgrade', () => {     } as never)   }) +  function mockSecurityMetadata({+    ranges = ['>=17.2.0-canary.0 <17.2.0-canary.5'],+    target = '17.2.0-canary.5' as string | null,+    published = ['17.2.0', '17.2.0-canary.5', '17.3.0-canary.0'],+    fallback = false,+    npmFailure = false,+  } = {}) {+    global.fetch = jest.fn(async (input, init) => {+      const url = String(input)+      if (url.startsWith('https://api.github.com/advisories?')) {+        return fallback+          ? new Response(null, { status: 500 })+          : Response.json(+              ranges.map((range) => ({+                withdrawn_at: null,+                vulnerabilities: [+                  {+                    package: { ecosystem: 'npm', name: 'next' },+                    vulnerable_version_range: range,+                  },+                ],+              }))+            )+      }+      if (url === 'https://registry.npmjs.org/next') {+        return Response.json({+          'dist-tags': target === null ? {} : { canary: target },+          versions: Object.fromEntries(+            published.map((version) => [version, { version }])+          ),+        })+      }+      if (+        url === 'https://registry.npmjs.org/next/canary' ||+        url === 'https://registry.npmjs.org/next/latest'+      ) {+        return Response.json({ version: target })+      }+      if (+        url === 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'+      ) {+        if (npmFailure) {+          return new Response(null, { status: 500 })+        }+        const { next: versions } = JSON.parse(String(init?.body)) as {+          next: string[]+        }+        // Model npm's default prerelease exclusion instead of returning every+        // advisory regardless of the versions submitted in the request.+        return Response.json({+          next: ranges+            .filter((range) =>+              versions.some((version) => semver.satisfies(version, range))+            )+            .map((range) => ({ vulnerable_versions: range })),+        })+      }+      throw new Error(`Unexpected request: ${url}`)+    })+  }++  it('rejects canary security upgrades without querying advisories or releases', async () => {+    const directory = await createApp('17.2.0-canary.4')+    global.fetch = jest.fn()+    await expect(+      getUpgradeAssessment('17.2.0-canary.4', 'security')+    ).resolves.toMatchObject({+      affected: null,+      reference: null,+      upgrade: {+        status: 'blocked',+        reason: expect.stringContaining(+          'Security upgrades are not supported for canary'+        ),+      },+    })+    await expect(prepareUpgrade(directory, 'security')).rejects.toThrow(+      'Security upgrades are not supported for canary'+    )+    expect(global.fetch).toHaveBeenCalledTimes(0)+  })++  it('does not treat failed stable advisory providers as an unaffected version', async () => {+    const directory = await createApp('17.2.0')+    mockSecurityMetadata({ fallback: true, npmFailure: true })+    await expect(getUpgradeAssessment('17.2.0', 'security')).rejects.toThrow()+    await expect(prepareUpgrade(directory, 'security')).rejects.toThrow(+      'Could not check for security updates.'+    )+  })++  it.each(['17.2.0', '17.1.9'])(+    'blocks a stable security upgrade without a newer safe target: %s',+    async (target) => {+      const directory = await createApp('17.2.0')+      mockSecurityMetadata({ ranges: ['17.2.0'], published: [target] })+      await expect(+        getUpgradeAssessment('17.2.0', 'security')+      ).resolves.toMatchObject({+        affected: true,+        upgrade: { status: 'blocked' },+      })+      await expect(prepareUpgrade(directory, 'security')).rejects.toThrow(+        'No safe Next.js update is currently available.'+      )+    }+  )++  describe('shared stable eligibility', () => {+    const installed = '17.2.0'+    it.each(['security', 'latest', 'future'] as const)(+      'uses the same safe target in the %s nudge and command',+      async (policy) => {+        const directory = await createApp(installed)+        const target = '17.3.0'+        mockSecurityMetadata({+          ranges: ['>=17.0.0 <17.2.1'],+          target,+          published: [installed, target],+        })+        const advisory = await getUpgradeAssessment(installed, policy)+        expect(advisory).toMatchObject({+          upgrade: { status: 'ready', targetVersion: target },+        })+        expect(await prepareUpgrade(directory, policy)).toMatchObject({+          status: 'ready',+          targetVersion: target,+        })+      }+    )++    it.each(['security', 'latest', 'future'] as const)(+      'retains the advisory when the %s target metadata cannot be fetched',+      async (policy) => {+        const directory = await createApp(installed)+        mockSecurityMetadata({ ranges: ['>=17.0.0 <17.2.1'] })+        const fetchMetadata = global.fetch+        global.fetch = jest.fn(async (input, init) => {+          if (String(input).startsWith('https://registry.npmjs.org/next')) {+            return new Response(null, { status: 503 })+          }+          return fetchMetadata(input, init)+        })+        await expect(+          getUpgradeAssessment(installed, policy)+        ).resolves.toMatchObject({+          upgrade: {+            status: 'unknown',+            reason: expect.stringContaining('Could not fetch upgrade metadata'),+          },+        })+        await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+          'Could not fetch upgrade metadata'+        )+      }+    )++    it.each(['latest', 'future'] as const)(+      'checks the %s target even when the installed version is unaffected',+      async (policy) => {+        const directory = await createApp(installed)+        const target = '17.3.0'+        mockSecurityMetadata({+          ranges: [target],+          target,+          published: [installed, target],+        })+        await expect(+          getUpgradeAssessment(installed, policy)+        ).resolves.toMatchObject({+          affected: false,+          upgrade: { status: 'blocked' },+        })+        await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+          `${target} is affected by an active advisory`+        )+      }+    )++    it.each(['security', 'latest', 'future'] as const)(+      'retains npm advisory warnings without release metadata for %s',+      async (policy) => {+        const directory = await createApp(installed)+        mockSecurityMetadata({ fallback: true, ranges: [installed] })+        const fetchMetadata = global.fetch+        global.fetch = jest.fn(async (input, init) => {+          if (String(input).startsWith('https://registry.npmjs.org/next')) {+            return new Response(null, { status: 503 })+          }+          return fetchMetadata(input, init)+        })+        await expect(+          getUpgradeAssessment(installed, policy)+        ).resolves.toMatchObject({+          affected: true,+          reference:+            'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk',+          upgrade: { status: 'unknown' },+        })+        await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+          'Could not assess upgrade targets.'+        )+      }+    )+  })++  it('includes a newly published tag in npm fallback assessment', async () => {+    const target = '17.3.0'+    const directory = await createApp('17.2.0')+    mockSecurityMetadata({+      fallback: true,+      ranges: [target],+      target,+      published: ['17.2.0'],+    })+    await expect(+      getUpgradeAssessment('17.2.0', 'latest')+    ).resolves.toMatchObject({+      affected: false,+      upgrade: {+        status: 'blocked',+        reason: `Next.js ${target} is affected by an active advisory.`,+      },+    })+    await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+      'affected by an active advisory'+    )+  })++  it.each([false, true])(+    'assesses the configured policy rather than a different safe security target, fallback=%s',+    async (fallback) => {+      const directory = await createApp('17.2.0')+      mockSecurityMetadata({+        fallback,+        ranges: ['17.2.0', '17.3.0'],+        target: '17.3.0',+        published: ['17.2.0', '17.3.0', '18.0.0'],+      })+      await expect(+        getUpgradeAssessment('17.2.0', 'security')+      ).resolves.toMatchObject({+        upgrade: { status: 'ready', targetVersion: '18.0.0' },+      })+      for (const policy of ['latest', 'future'] as const) {+        await expect(+          getUpgradeAssessment('17.2.0', policy)+        ).resolves.toMatchObject({ upgrade: { status: 'blocked' } })+        await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+          '17.3.0 is affected by an active advisory'+        )+      }+    }+  )++  it('preserves stable security target selection', async () => {+    const directory = await createApp('17.2.0')+    mockSecurityMetadata({+      ranges: ['>=17.0.0 <17.2.1'],+      published: ['17.2.0', '17.2.1', '18.0.0', '17.3.0-canary.0'],+    })+    await expect(prepareUpgrade(directory, 'security')).resolves.toEqual(+      expect.objectContaining({ targetVersion: '17.2.1' })+    )+  })++  it.each(['17.2.0-rc.1', '17.2.0-beta.1'])(+    'keeps other prerelease channels unsupported: %s',+    async (version) => {+      const directory = await createApp(version)+      mockSecurityMetadata()+      await expect(prepareUpgrade(directory, 'security')).rejects.toThrow(+        'AI upgrades are not available for prerelease versions'+      )+      await expect(getUpgradeAssessment(version, 'security')).rejects.toThrow(+        'prerelease versions'+      )+      expect(global.fetch).toHaveBeenCalledTimes(0)+    }+  )+   afterEach(async () => {     global.fetch = originalFetch     await Promise.all(@@ -95,6 +383,154 @@ describe('prepare latest upgrade', () => {     )   }) +  describe.each(['latest', 'future'] as const)(+    '%s canary upgrades',+    (policy) => {+      it.each([+        '17.2.0-canary.5',+        '17.2.1-canary.0',+        '17.3.0-canary.0',+        '18.0.0-canary.0',+      ])(+        'selects the exact canary tag %s for an explicit upgrade',+        async (target) => {+          const directory = await createApp('17.2.0-canary.4')+          mockSecurityMetadata({ target, ranges: [] })+          await expect(prepareUpgrade(directory, policy)).resolves.toEqual(+            expect.objectContaining({+              status: 'ready',+              targetVersion: target,+              references: ['https://registry.npmjs.org/next/canary'],+            })+          )+          expect(global.fetch).toHaveBeenCalledWith(+            'https://registry.npmjs.org/next/canary',+            expect.any(Object)+          )+        }+      )++      it.each(['17.2.0-canary.4', '17.2.0-canary.3'])(+        'does not change versions when the tag is equal or older: %s',+        async (target) => {+          const directory = await createApp('17.2.0-canary.4')+          jest+            .mocked(loadConfig)+            .mockResolvedValue({ cacheComponents: true } as never)+          mockSecurityMetadata({ target, ranges: [] })+          await expect(prepareUpgrade(directory, policy)).resolves.toEqual(+            expect.objectContaining({ status: 'unaffected' })+          )+        }+      )++      it.each([null, 'invalid', '17.3.0', '17.3.0-rc.1'])(+        'rejects invalid or wrong-channel tag metadata: %s',+        async (target) => {+          const directory = await createApp('17.2.0-canary.4')+          mockSecurityMetadata({ target })+          await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+            'Could not determine the latest Next.js version on the canary dist-tag.'+          )+        }+      )++      it.each(['17.2.0-rc.1', '17.2.0-beta.1', '17.2.0-preview.1'])(+        'keeps other prereleases unsupported: %s',+        async (installed) => {+          const directory = await createApp(installed)+          mockSecurityMetadata()+          await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+            'AI upgrades are not available for prerelease versions'+          )+          expect(global.fetch).toHaveBeenCalledTimes(0)+        }+      )+    }+  )++  it.each(['latest', 'future'] as const)(+    'runs canary %s without advisory assessment even when providers are unavailable',+    async (policy) => {+      const directory = await createApp('17.2.0-canary.4')+      global.fetch = jest.fn(async (input) => {+        if (String(input) === 'https://registry.npmjs.org/next/canary') {+          return Response.json({ version: '17.2.0-canary.5' })+        }+        throw new Error('Advisory lookup should not run for canaries')+      })+      await expect(+        getUpgradeAssessment('17.2.0-canary.4', policy)+      ).resolves.toMatchObject({+        affected: null,+        reference: null,+        upgrade: { status: 'ready', targetVersion: '17.2.0-canary.5' },+      })+      await expect(prepareUpgrade(directory, policy)).resolves.toMatchObject({+        status: 'ready',+        targetVersion: '17.2.0-canary.5',+      })+      expect(+        jest.mocked(global.fetch).mock.calls.map(([url]) => String(url))+      ).toEqual([+        'https://registry.npmjs.org/next/canary',+        'https://registry.npmjs.org/next/canary',+      ])+    }+  )++  it.each(['17.2.0-canary.4', '17.2.0-canary.3'])(+    'adopts available defaults without a version change when the tag is %s',+    async (target) => {+      const directory = await createApp('17.2.0-canary.4')+      mockSecurityMetadata({ target, ranges: [] })+      await expect(prepareUpgrade(directory, 'future')).resolves.toEqual(+        expect.objectContaining({+          status: 'ready',+          targetVersion: '17.2.0-canary.4',+          futureDefaults: [+            expect.objectContaining({ name: 'Cache Components' }),+          ],+        })+      )+    }+  )++  it('keeps the stable Future availability boundary for a same-base canary', async () => {+    const directory = await createApp('16.3.0-canary.1')+    mockSecurityMetadata({ target: '16.3.0-canary.1', ranges: [] })+    await expect(prepareUpgrade(directory, 'future')).resolves.toEqual(+      expect.objectContaining({ status: 'unaffected' })+    )+  })++  it.each([false, true])(+    'assesses a retained Future version missing from registry metadata with npm fallback=%s',+    async (fallback) => {+      const directory = await createApp('17.2.9')+      mockSecurityMetadata({+        fallback,+        ranges: ['17.2.9'],+        target: '17.2.1',+        published: ['17.2.0', '17.2.1'],+      })+      await expect(prepareUpgrade(directory, 'future')).rejects.toThrow(+        '17.2.9 is affected by an active advisory.'+      )+    }+  )++  it.each(['latest', 'future'] as const)(+    'fails a %s assessment when both advisory providers are unavailable',+    async (policy) => {+      const directory = await createApp('17.2.0')+      mockSecurityMetadata({ fallback: true, npmFailure: true })+      await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+        'Could not check for security updates.'+      )+    }+  )+   it('selects the exact latest stable release', async () => {     const directory = await createApp('16.2.1')     mockLatestVersion('17.1.0')@@ -107,10 +543,65 @@ describe('prepare latest upgrade', () => {         references: ['https://registry.npmjs.org/next/latest'],       })     )-    expect(global.fetch).toHaveBeenCalledTimes(1)-    expect(jest.mocked(global.fetch).mock.calls[0][0]).toBe(-      'https://registry.npmjs.org/next/latest'+    expect(global.fetch).toHaveBeenCalledTimes(2)+    expect(global.fetch).toHaveBeenCalledWith(+      'https://registry.npmjs.org/next/latest',+      expect.any(Object)+    )+  })++  it('allows an explicit patch upgrade without a reminder', async () => {+    const directory = await createApp('17.1.0')+    mockLatestVersion('17.1.1')++    await expect(prepareUpgrade(directory, 'latest')).resolves.toEqual(+      expect.objectContaining({ status: 'ready', targetVersion: '17.1.1' })     )+    expect(getLatestUpgradeVersion('17.1.0', '17.1.1')).toBeNull()+  })++  it.each([null, {}, { version: 'invalid' }, { version: '17.2.0-canary.1' }])(+    'reports invalid metadata for explicit upgrades but suppresses reminders: %j',+    async (metadata) => {+      const directory = await createApp('17.1.0')+      global.fetch = jest.fn(async (input) =>+        Response.json(+          String(input).startsWith('https://api.github.com/') ? [] : metadata+        )+      )++      await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+        'Could not determine the latest stable Next.js version.'+      )+      expect(+        getLatestUpgradeVersion(+          '17.1.0',+          String(metadata && 'version' in metadata ? metadata.version : '')+        )+      ).toBeNull()+    }+  )++  it('preserves target lookup failures in the shared assessment and command', async () => {+    const directory = await createApp('17.1.0')+    global.fetch = jest.fn(async (input) => {+      if (String(input).startsWith('https://api.github.com/')) {+        return Response.json([])+      }+      throw new Error('Network unavailable')+    })++    await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+      'Could not fetch upgrade metadata.'+    )+    await expect(+      getUpgradeAssessment('17.1.0', 'latest')+    ).resolves.toMatchObject({+      upgrade: {+        status: 'unknown',+        reason: expect.stringContaining('Could not fetch upgrade metadata.'),+      },+    })   })    it('does nothing when the app already uses latest', async () => {@@ -133,6 +624,54 @@ describe('prepare latest upgrade', () => {     })   }) +  it.each([false, true])(+    'ignores an affected older latest target with npm fallback=%s',+    async (fallback) => {+      const directory = await createApp('17.3.0')+      mockSecurityMetadata({+        fallback,+        ranges: ['17.2.0'],+        target: '17.2.0',+        published: ['17.2.0', '17.3.0'],+      })++      await expect(+        getUpgradeAssessment('17.3.0', 'latest')+      ).resolves.toMatchObject({+        affected: false,+        upgrade: { status: 'unaffected' },+      })+      await expect(prepareUpgrade(directory, 'latest')).resolves.toEqual({+        status: 'unaffected',+        reason:+          'Next.js 17.3.0 is newer than the latest stable release 17.2.0.',+      })+    }+  )++  it.each([false, true])(+    'still blocks an affected equal latest target with npm fallback=%s',+    async (fallback) => {+      const directory = await createApp('17.3.0')+      mockSecurityMetadata({+        fallback,+        ranges: ['17.3.0'],+        target: '17.3.0',+        published: ['17.3.0'],+      })++      await expect(+        getUpgradeAssessment('17.3.0', 'latest')+      ).resolves.toMatchObject({+        affected: true,+        upgrade: { status: 'blocked' },+      })+      await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+        'Next.js 17.3.0 is affected by an active advisory.'+      )+    }+  )+   it('allows a Future target outside npm fallback advisory ranges', async () => {     const directory = await createApp('16.2.0')     mockFutureMetadata('<16.3.0')
test/unit/security-upgrade-nudge.test.ts238 + / 40 −
@@ -4,24 +4,39 @@ import { join } from 'path'  import { getFutureUpgrade, nudgeForUpgrade } from 'next/dist/lib/upgrade/nudge' import { getAgentName } from 'next/dist/telemetry/agent-name'-import {-  getLatestUpgradeVersion,-  getSecurityAdvisory,-} from 'next/dist/lib/upgrade/prepare-upgrade'+import { getUpgradeAssessment } from 'next/dist/lib/upgrade/prepare-upgrade' import { warn } from 'next/dist/build/output/log'  jest.mock('next/dist/telemetry/agent-name', () => ({   getAgentName: jest.fn(), })) jest.mock('next/dist/lib/upgrade/prepare-upgrade', () => ({-  getLatestUpgradeVersion: jest.fn(),-  getSecurityAdvisory: jest.fn(),+  getLatestUpgradeVersion: jest.requireActual(+    'next/dist/lib/upgrade/prepare-upgrade'+  ).getLatestUpgradeVersion,+  getUpgradeAssessment: jest.fn(), })) jest.mock('next/dist/build/output/log', () => ({   warn: jest.fn(), })) +function mockUpgrade(targetVersion = process.env.__NEXT_VERSION || '16.4.0') {+  const canary = process.env.__NEXT_VERSION?.includes('-canary.')+  jest.mocked(getUpgradeAssessment).mockResolvedValue({+    affected: canary ? null : false,+    reference: canary ? null : 'https://api.github.com/advisories?affects=next',+    upgrade: {+      status: 'ready',+      installedVersion: process.env.__NEXT_VERSION || '16.4.0',+      targetVersion,+      references: [],+      futureDefaults: [],+    },+  })+}+ let directory: string+const initialNextVersion = process.env.__NEXT_VERSION  const config = (   policy: 'security' | 'latest' | 'future',@@ -34,10 +49,16 @@ const config = (   }) as never  beforeEach(async () => {+  process.env.__NEXT_VERSION = '16.4.0'   directory = await mkdtemp(join(tmpdir(), 'security-upgrade-nudge-')) })  afterEach(async () => {+  if (initialNextVersion === undefined) {+    delete process.env.__NEXT_VERSION+  } else {+    process.env.__NEXT_VERSION = initialNextVersion+  }   await rm(directory, { recursive: true, force: true }) }) @@ -51,7 +72,7 @@ describe('security upgrade nudge', () => {     jest.resetAllMocks()     process.env.__NEXT_VERSION = '13.0.0'     jest.mocked(getAgentName).mockResolvedValue('codex')-    jest.mocked(getSecurityAdvisory).mockResolvedValue(null)+    mockUpgrade()   })    afterAll(() => {@@ -63,22 +84,38 @@ describe('security upgrade nudge', () => {   })    it('shows a security advisory and the upgrade command', async () => {-    jest.mocked(getSecurityAdvisory).mockResolvedValue({+    jest.mocked(getUpgradeAssessment).mockResolvedValue({+      affected: true,+      upgrade: {+        status: 'ready',+        installedVersion: '13.0.0',+        targetVersion: '17.2.1',+        references: [],+        futureDefaults: [],+      },       reference: 'https://api.github.com/advisories?affects=next%4013.0.0',     })      await expect(run()).rejects.toMatchObject({       name: 'SecurityFatalError',       exitCode: 1,       message: expect.stringMatching(-        /affected by a published security advisory[\s\S]*\*\*We strongly recommend you upgrade Next\.js\.\*\*[\s\S]*reminder you configured is not missed\. Retry the same command to continue the original task\.[\s\S]*At the end of the task, ask the user whether they want you to run the security upgrade in the background from this app's directory:[\s\S]*```\nnext upgrade --ai\n```/+        /affected by a published security advisory[\s\S]*\*\*We strongly recommend you upgrade Next\.js\.\*\*[\s\S]*reminder you configured is not missed\. Retry the same command to continue the original task\.[\s\S]*At the end of the task, ask the user whether they want you to run the upgrade using the configured 'security' policy in the background from this app's directory:[\s\S]*```\nnext upgrade --ai\n```/       ),     })     expect(warn).not.toHaveBeenCalled()   })    it('shows the npm fallback reference', async () => {-    jest.mocked(getSecurityAdvisory).mockResolvedValue({+    jest.mocked(getUpgradeAssessment).mockResolvedValue({+      affected: true,+      upgrade: {+        status: 'ready',+        installedVersion: '13.0.0',+        targetVersion: '17.2.1',+        references: [],+        futureDefaults: [],+      },       reference: 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk',     }) @@ -92,15 +129,61 @@ describe('security upgrade nudge', () => {     expect(warn).not.toHaveBeenCalled()   }) +  it.each(['blocked', 'unknown'] as const)(+    'preserves the advisory and retry behavior when target eligibility is %s',+    async (status) => {+      jest.mocked(getUpgradeAssessment).mockResolvedValue({+        reference: 'https://api.github.com/advisories?affects=next',+        affected: true,+        upgrade: { status, reason: 'Target assessment detail.' },+      })+      let message = ''+      try {+        await run()+      } catch (error) {+        expect(error).toMatchObject({ name: 'SecurityFatalError', exitCode: 1 })+        message = (error as Error).message+      }+      expect(message).toContain('affected by a published security advisory')+      expect(message).toContain('Target assessment detail.')+      expect(message.includes('next upgrade --ai')).toBe(false)+      expect(message.includes('can be automatically upgraded')).toBe(false)+      expect(message).toContain(+        status === 'unknown'+          ? 'Upgrade availability could not be checked.'+          : 'No safe newer target is available'+      )+      await expect(run()).resolves.toBeUndefined()+      expect(warn).toHaveBeenCalledWith(+        expect.stringContaining('Target assessment detail.')+      )+    }+  )+   it('stays silent when the version is unaffected', async () => {     await run() -    expect(getSecurityAdvisory).toHaveBeenCalledTimes(1)+    expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)     expect(warn).not.toHaveBeenCalled()   }) -  it('stays silent when prerelease security assessment is deferred', async () => {-    jest.mocked(getSecurityAdvisory).mockResolvedValue(null)+  it('stays silent when canary security assessment is unsupported', async () => {+    jest.mocked(getUpgradeAssessment).mockResolvedValue({+      affected: null,+      reference: null,+      upgrade: {+        status: 'blocked',+        reason:+          'Security upgrades are not supported for canary versions of Next.js.',+      },+    })+    await expect(run()).resolves.toBeUndefined()+    expect(warn).toHaveBeenCalledTimes(0)+    expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)+  })++  it('stays silent when no security advisory matches', async () => {+    mockUpgrade()      await run() @@ -112,13 +195,13 @@ describe('security upgrade nudge', () => {      await run() -    expect(getSecurityAdvisory).not.toHaveBeenCalled()+    expect(getUpgradeAssessment).not.toHaveBeenCalled()     expect(warn).not.toHaveBeenCalled()   })    it('warns without rejecting when advisory lookup fails', async () => {     jest-      .mocked(getSecurityAdvisory)+      .mocked(getUpgradeAssessment)       .mockRejectedValue(new Error('Advisory service unavailable'))      await expect(run()).resolves.toBeUndefined()@@ -133,7 +216,15 @@ describe('security upgrade nudge', () => {   })    it('allows one matching retry with a warning', async () => {-    jest.mocked(getSecurityAdvisory).mockResolvedValue({+    jest.mocked(getUpgradeAssessment).mockResolvedValue({+      affected: true,+      upgrade: {+        status: 'ready',+        installedVersion: '13.0.0',+        targetVersion: '17.2.1',+        references: [],+        futureDefaults: [],+      },       reference: 'https://api.github.com/advisories?affects=next%4013.0.0',     }) @@ -150,7 +241,15 @@ describe('security upgrade nudge', () => {   })    it('keeps dev and build retry receipts independent', async () => {-    jest.mocked(getSecurityAdvisory).mockResolvedValue({+    jest.mocked(getUpgradeAssessment).mockResolvedValue({+      affected: true,+      upgrade: {+        status: 'ready',+        installedVersion: '13.0.0',+        targetVersion: '17.2.1',+        references: [],+        futureDefaults: [],+      },       reference: 'https://api.github.com/advisories?affects=next%4013.0.0',     }) @@ -182,14 +281,19 @@ describe('latest nudge release selection', () => {     ['16.1.0', '17.0.0-canary.1', null],     ['16.1.0-canary.1', '16.1.0', null],     ['16.0.0-canary.1', '16.1.0', null],+    ['17.2.0-canary.4', '17.2.0-canary.9', null],+    ['17.2.0-canary.9', '17.2.0-canary.10', null],+    ['17.2.0-canary.4', '17.2.1-canary.0', null],+    ['17.2.0-canary.4', '17.3.0-canary.0', '17.3.0-canary.0'],+    ['17.2.0-canary.4', '18.0.0-canary.0', '18.0.0-canary.0'],+    ['17.2.0-canary.4', '17.2.0-canary.4', null],+    ['17.2.0-canary.4', '17.1.0-canary.99', null],+    ['17.2.0-canary.4', '17.3.0-rc.1', null],+    ['17.2.0-rc.1', '17.3.0', null],   ])(     'selects %s → %s for a nudge only across major/minor versions',     async (installed, latest, expected) => {-      jest-        .spyOn(global, 'fetch')-        .mockResolvedValue(new Response(JSON.stringify({ version: latest })))--      await expect(readLatestUpgradeVersion(installed)).resolves.toBe(expected)+      expect(readLatestUpgradeVersion(installed, latest)).toBe(expected)     }   ) })@@ -198,12 +302,35 @@ describe('latest upgrade nudge', () => {   beforeEach(() => {     jest.resetAllMocks()     jest.mocked(getAgentName).mockResolvedValue('codex')-    jest.mocked(getSecurityAdvisory).mockResolvedValue(null)-    jest.mocked(getLatestUpgradeVersion).mockResolvedValue(null)+    mockUpgrade()   }) +  it.each(['latest', 'future'] as const)(+    'links a canary %s reminder to the selected channel',+    async (policy) => {+      process.env.__NEXT_VERSION = '17.2.0-canary.4'+      mockUpgrade('17.3.0-canary.1')+      await expect(+        nudgeForUpgrade(directory, config(policy), 'build')+      ).rejects.toMatchObject({+        name: 'UpgradeNudgeError',+        message: expect.stringContaining(+          'Reference: https://registry.npmjs.org/next/canary'+        ),+      })+      await expect(+        nudgeForUpgrade(directory, config(policy), 'build')+      ).resolves.toBeUndefined()+      expect(warn).toHaveBeenCalledWith(+        expect.stringContaining(+          'Reference: https://registry.npmjs.org/next/canary'+        )+      )+    }+  )+   it('stops once and allows a matching retry with a warning', async () => {-    jest.mocked(getLatestUpgradeVersion).mockResolvedValue('17.0.0')+    mockUpgrade('17.0.0')      await expect(       nudgeForUpgrade(directory, config('latest'), 'build')@@ -218,18 +345,38 @@ describe('latest upgrade nudge', () => {       nudgeForUpgrade(directory, config('latest'), 'build')     ).resolves.toBeUndefined() -    expect(getLatestUpgradeVersion).toHaveBeenCalledTimes(2)+    expect(getUpgradeAssessment).toHaveBeenCalledTimes(2)     expect(warn).toHaveBeenCalledWith(       expect.stringMatching(         /Next\.js 17\.0\.0 is available\.[\s\S]*continuing after the reminder you configured[\s\S]*registry\.npmjs\.org/       )     )   }) +  it.each(['latest', 'future'] as const)(+    'does not offer an unsafe %s target or fall through to Future adoption',+    async (policy) => {+      jest.mocked(getUpgradeAssessment).mockResolvedValue({+        affected: false,+        reference: 'https://api.github.com/advisories',+        upgrade: { status: 'blocked', reason: 'The target is affected.' },+      })+      await expect(+        nudgeForUpgrade(+          directory,+          config(policy, { cacheComponents: false }),+          'build'+        )+      ).resolves.toBeUndefined()+      expect(warn).toHaveBeenCalledTimes(0)+      expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)+    }+  )+   it('stays silent when there is no newer stable release', async () => {     await nudgeForUpgrade(directory, config('latest'), 'build') -    expect(getLatestUpgradeVersion).toHaveBeenCalledTimes(1)+    expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)     expect(warn).not.toHaveBeenCalled()   }) @@ -238,14 +385,16 @@ describe('latest upgrade nudge', () => {      await nudgeForUpgrade(directory, config('latest'), 'build') -    expect(getLatestUpgradeVersion).not.toHaveBeenCalled()+    expect(getUpgradeAssessment).not.toHaveBeenCalled()     expect(warn).not.toHaveBeenCalled()   })    it('stays silent without rejecting when release lookup fails', async () => {-    jest-      .mocked(getLatestUpgradeVersion)-      .mockRejectedValue(new Error('Registry unavailable'))+    jest.mocked(getUpgradeAssessment).mockResolvedValue({+      affected: false,+      reference: 'https://api.github.com/advisories',+      upgrade: { status: 'unknown', reason: 'Registry unavailable' },+    })      await expect(       nudgeForUpgrade(directory, config('latest'), 'build')@@ -259,14 +408,21 @@ describe('composed latest nudge', () => {   beforeEach(() => {     jest.resetAllMocks()     jest.mocked(getAgentName).mockResolvedValue('codex')-    jest.mocked(getSecurityAdvisory).mockResolvedValue(null)-    jest.mocked(getLatestUpgradeVersion).mockResolvedValue('16.0.0')+    mockUpgrade('17.0.0')   })    it.each(['latest', 'future'] as const)(     'preserves the %s policy when security takes priority',     async (policy) => {-      jest.mocked(getSecurityAdvisory).mockResolvedValue({+      jest.mocked(getUpgradeAssessment).mockResolvedValue({+        affected: true,+        upgrade: {+          status: 'ready',+          installedVersion: '13.0.0',+          targetVersion: '17.2.1',+          references: [],+          futureDefaults: [],+        },         reference: 'https://api.github.com/advisories?affects=next%4015.0.0',       }) @@ -282,8 +438,12 @@ describe('composed latest nudge', () => {         ),       }) +      expect(getUpgradeAssessment).toHaveBeenCalledWith(+        expect.any(String),+        policy+      )       expect(warn).not.toHaveBeenCalled()-      expect(getLatestUpgradeVersion).not.toHaveBeenCalled()+      expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)     }   ) })@@ -292,19 +452,57 @@ describe('composed future nudge', () => {   beforeEach(() => {     jest.resetAllMocks()     jest.mocked(getAgentName).mockResolvedValue('codex')-    jest.mocked(getSecurityAdvisory).mockResolvedValue(null)-    jest.mocked(getLatestUpgradeVersion).mockResolvedValue(null)+    mockUpgrade()   }) -  it('does not offer Future Defaults from a prerelease', async () => {+  it.each(['16.3.0-canary.1', '16.4.0-rc.1', '16.4.0-beta.1'])(+    'does not offer defaults before stable availability or for another prerelease: %s',+    async (version) => {+      await expect(+        getFutureUpgrade(config('future', { cacheComponents: false }), version)+      ).resolves.toBeNull()+    }+  )++  it('offers available defaults on canary without a version reminder', async () => {     await expect(       getFutureUpgrade(         config('future', { cacheComponents: false }),         '16.4.0-canary.1'       )+    ).resolves.toEqual({+      installedVersion: '16.4.0-canary.1',+      names: ['Cache Components'],+    })+  })++  it('does not remind about defaults already adopted on canary', async () => {+    await expect(+      getFutureUpgrade(+        config('future', { cacheComponents: true }),+        '16.4.0-canary.1'+      )     ).resolves.toBeNull()   }) +  it('offers canary Future adoption when advisory assessment is skipped', async () => {+    process.env.__NEXT_VERSION = '17.2.0-canary.4'+    mockUpgrade()+    await expect(+      nudgeForUpgrade(+        directory,+        config('future', { cacheComponents: false }),+        'build'+      )+    ).rejects.toMatchObject({+      name: 'UpgradeNudgeError',+      message: expect.stringContaining(+        'We recommend you adopt these Future Defaults.'+      ),+    })+    expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)+  })+   it('names available Future Defaults using the adapter', async () => {     await expect(       getFutureUpgrade(config('future', { cacheComponents: false }), '16.4.0')@@ -321,7 +519,7 @@ describe('composed future nudge', () => {   })    it('stops for a required latest upgrade before Future Defaults', async () => {-    jest.mocked(getLatestUpgradeVersion).mockResolvedValue('17.0.0')+    mockUpgrade('17.0.0')      await expect(       nudgeForUpgrade(@@ -332,7 +530,7 @@ describe('composed future nudge', () => {     ).rejects.toMatchObject({       name: 'UpgradeNudgeError',       message: expect.stringMatching(-        /next upgrade --ai\n```[\s\S]*agenticAutoUpgrade: 'future'/+        /Next\.js 17\.0\.0 is available[\s\S]*next upgrade --ai\n```[\s\S]*agenticAutoUpgrade: 'future'/       ),     })   })