Skip to content
Open
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
14 changes: 14 additions & 0 deletions .changeset/autoconfig-bump-next-and-auto-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@cloudflare/autoconfig": minor
"wrangler": minor
---

Bump Next.js minimum versions and provide an automatic upgrade path

`@opennextjs/cloudflare` declares a Next.js peer range of `>=15.5.21 <16 || >=16.2.11`, so projects on earlier 15.x or 16.x releases sit outside the versions the adapter supports.

Autoconfig previously ran `@opennextjs/cloudflare migrate --force-install`. That flag just passes `--force` to the package manager. The peer dependency error becomes a warning buried in the install output, Next.js stays on its unsupported version, and setup finishes with a success message.

Autoconfig now recognises the supported floors and offers to update an unsupported project in place, staying within its existing major version. The update is listed in the setup summary before you confirm, and is applied before any other project changes are made. `--force-install` is no longer passed, so a real dependency conflict is reported rather than forced.

Two cases are not updated automatically and ask you to update Next.js yourself. Next.js 14 now falls outside the adapter's peer range entirely, and Next.js 15.0.x cannot be updated in place because `create-next-app` pinned React to a 19 prerelease before Next.js 15.1, which no supported Next.js version accepts as a peer.
2 changes: 2 additions & 0 deletions packages/autoconfig/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@
"@netlify/build-info": "^10.5.1",
"@types/esprima": "^4.0.3",
"@types/node": "catalog:default",
"@types/semver": "^7.5.1",
"chalk": "catalog:default",
"empathic": "^2.0.0",
"esprima": "4.0.1",
"recast": "0.23.11",
"semiver": "^1.1.0",
"semver": "^7.7.1",
"ts-dedent": "^2.2.0",
"tsup": "8.3.0",
"typescript": "catalog:default",
Expand Down
15 changes: 12 additions & 3 deletions packages/autoconfig/src/frameworks/all-frameworks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,19 @@ export const allKnownFrameworks = [
class: NextJs,
frameworkPackageInfo: {
name: "next",
// 14.2.35 is the earliest version of Next.js officially supported by open-next
// see: https://github.com/cloudflare/workers-sdk/pull/11704#discussion_r2634519440
minimumVersion: "14.2.35",
// 15.5.21 is the earliest version of Next.js officially supported by open-next
// see: https://github.com/opennextjs/opennextjs-cloudflare/pull/1313
minimumVersion: "15.5.21",
maximumKnownMajorVersion: "16",
// Next.js 15 and 16 installations that OpenNext doesn't support are upgraded in
// place, staying within their existing major version.
// 15.0.x is deliberately excluded: `create-next-app` pinned React to a 19 prerelease
// until Next.js 15.1, which no supported Next.js version accepts as a peer, so the
// upgrade cannot succeed without the user changing React first.
upgradeRequired: {
">=15.1.0 <15.5.21": "15.5.21",
">=16 <16.2.11": "16.2.11",
},
},
supported: true,
},
Expand Down
48 changes: 46 additions & 2 deletions packages/autoconfig/src/frameworks/framework-class.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from "node:assert";
import semiver from "semiver";
import semverSatisfies from "semver/functions/satisfies.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we not use semiver which is already installed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

can do but it can't handle satisfies strings, only individual comparisons, so configuration would have to be a bit more wordy

does something like this work, and then semiver can be used:

    upgradeRequired: [
       { from: "15.1.0", to: "15.5.20", upgradeTo: "15.5.21" },
       { from: "16.0.0", to: "16.2.10", upgradeTo: "16.2.11" }
    ]

import { AutoConfigFrameworkConfigurationError } from "../errors";
import { getInstalledPackageVersion } from "./utils/packages";
import type { AutoConfigFrameworkPackageInfo, FrameworkInfo } from ".";
Expand Down Expand Up @@ -35,22 +36,44 @@ export abstract class Framework {

configurationDescription?: string;

/**
* Upgrades the framework to a version that autoconfig supports.
*
* Only called for installed versions matched by the package's `upgradeRequired` ranges, so
* frameworks that don't declare any never need to implement this.
*
* @param _options - The installed version, the version to upgrade to, the package
* manager, and the workspace root flag.
*/
upgradeFrameworkVersion(
_options: FrameworkVersionUpgradeOptions
): Promise<void> {
throw new AutoConfigFrameworkConfigurationError(
`${this.name} requires an upgrade before your project can be automatically configured.`,
{ telemetryMessage: "autoconfig framework version upgrade unavailable" }
);
}

/**
* Validates the installed framework version against the supported range and
* stores it for later access via the `frameworkVersion` getter.
* Warns via the context logger if the version exceeds `maximumKnownMajorVersion`.
*
* Versions matched by `upgradeRequired` are returned as an upgrade for the caller to apply,
* rather than being rejected for being below `minimumVersion`.
*
* @param projectPath - Path to the project root used to resolve the installed version.
* @param frameworkPackageInfo - Package metadata including name and version bounds.
* @param context - The autoconfig context providing logger and other dependencies.
* @throws {AssertionError} If the installed version cannot be determined.
* @throws {AutoConfigFrameworkConfigurationError} If the version is below `minimumVersion`.
* @throws {AutoConfigFrameworkConfigurationError} If the version is below `minimumVersion` and no upgrade is available.
* @returns The upgrade needed to reach a supported version, or `undefined` if none is needed.
*/
validateFrameworkVersion(
projectPath: string,
frameworkPackageInfo: AutoConfigFrameworkPackageInfo,
context: AutoConfigContext
) {
): FrameworkVersionUpgrade | undefined {
const frameworkVersion = getInstalledPackageVersion(
frameworkPackageInfo.name,
projectPath
Expand All @@ -61,6 +84,19 @@ export abstract class Framework {
`Unable to detect the version of the \`${frameworkPackageInfo.name}\` package`
);

for (const [versionRange, upgradeTo] of Object.entries(
frameworkPackageInfo.upgradeRequired ?? {}
)) {
if (
semverSatisfies(frameworkVersion, versionRange, {
includePrerelease: true,
})
) {
this.#frameworkVersion = frameworkVersion;
return { installedVersion: frameworkVersion, upgradeTo };
}
}

if (semiver(frameworkVersion, frameworkPackageInfo.minimumVersion) < 0) {
throw new AutoConfigFrameworkConfigurationError(
`The version of ${this.name} used in the project (${JSON.stringify(
Expand Down Expand Up @@ -89,6 +125,14 @@ export abstract class Framework {
}
}

export type FrameworkVersionUpgrade = {
installedVersion: string;
upgradeTo: string;
};

export type FrameworkVersionUpgradeOptions = FrameworkVersionUpgrade &
Pick<ConfigurationOptions, "isWorkspaceRoot" | "packageManager">;

export type ConfigurationOptions = {
outputDir: string;
projectPath: string;
Expand Down
5 changes: 5 additions & 0 deletions packages/autoconfig/src/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,9 @@ export type AutoConfigFrameworkPackageInfo = {
minimumVersion: string;
/** The latest major version of the package/framework that autoconfig supports */
maximumKnownMajorVersion: string;
/**
* Maps ranges of installed versions that autoconfig can upgrade in place to the version each
* range should be upgraded to. Ranges are checked in order, and the first match wins.
*/
upgradeRequired?: Record<string, string>;
};
32 changes: 18 additions & 14 deletions packages/autoconfig/src/frameworks/next.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import { brandColor, dim } from "@cloudflare/cli-shared-helpers/colors";
import { runCommand } from "@cloudflare/cli-shared-helpers/command";
import { installPackages } from "@cloudflare/cli-shared-helpers/packages";
import { Framework } from "./framework-class";
import type {
ConfigurationOptions,
ConfigurationResults,
FrameworkVersionUpgradeOptions,
} from "./framework-class";

export class NextJs extends Framework {
async upgradeFrameworkVersion({
upgradeTo,
packageManager,
isWorkspaceRoot,
}: FrameworkVersionUpgradeOptions): Promise<void> {
await installPackages(packageManager.type, [`next@${upgradeTo}`], {
isWorkspaceRoot,
startText: `Updating Next.js to ${upgradeTo}`,
doneText: `${brandColor("updated")} ${dim(`Next.js to ${upgradeTo}`)}`,
});
}
Comment on lines +18 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Framework version update can be applied to the wrong directory when the project is not the current directory

The Next.js update is installed in whatever directory the command happens to be running in (installPackages at packages/autoconfig/src/frameworks/next.ts:18) instead of the detected project directory, so when the two differ the project is left unchanged and setup aborts with a "update it manually" error.
Impact: Users who run setup from outside their project folder get a failed setup and an unrelated directory's dependencies modified.

Mechanism: installPackages resolves the install target from process.cwd(), while the rest of autoconfig uses autoConfigDetails.projectPath

installPackages (packages/cli/packages.ts:27-124) never passes a cwd to runCommand for the non-empty package list branch, and its npm package.json fix-up reads path.join(process.cwd(), "package.json"). Every other autoconfig step keys off autoConfigDetails.projectPath (e.g. version detection at packages/autoconfig/src/run.ts:114-120, package.json rewrite at packages/autoconfig/src/run.ts:229-252), and projectPath is only defaulted to process.cwd() (packages/autoconfig/src/details/index.ts:90) — callers may pass a different path. When they differ, the install lands elsewhere, the post-upgrade re-validation at packages/autoconfig/src/run.ts:192-208 still reads the old version, and setup throws "…but the version installed in the project is still …". Passing the project path through to the install (or forwarding it as cwd) would make the upgrade consistent with the rest of the flow.

Prompt for agents
In packages/autoconfig/src/frameworks/next.ts, upgradeFrameworkVersion calls installPackages, which (see packages/cli/packages.ts) runs the package manager in process.cwd() and rewrites process.cwd()/package.json for npm. All other autoconfig operations operate on autoConfigDetails.projectPath, which defaults to process.cwd() but can be passed explicitly by callers (packages/autoconfig/src/details/index.ts). If projectPath differs from the process cwd, the Next.js upgrade is applied to the wrong directory, and the post-upgrade re-validation in packages/autoconfig/src/run.ts then throws an 'update it manually' error. Consider threading projectPath into FrameworkVersionUpgradeOptions and having the install helper accept/forward a cwd so the upgrade targets the same directory as the rest of the flow.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Think this is how it's done in all the other framework files – they use don't use projectPath? Might be a separate issue


async configure({
dryRun,
projectPath,
Expand All @@ -14,20 +29,9 @@ export class NextJs extends Framework {
const { npx, dlx } = packageManager;

if (!dryRun) {
await runCommand(
[
...dlx,
"@opennextjs/cloudflare",
"migrate",
// Note: we force-install so that even if an incompatible version of
// Next.js is used this installation still succeeds, moving users

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

given the intent of this comment, should we fallback to force installing if someone is on a version of nextjs that we can't upgrade for them? this way they'll still have all the cloudflare config files etc. and we can tell them to sort out the framework version later

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

For next 14 and 15.0.x?

can do, but I think that will require a bit of a refactor as currently anything below minimumVersion is blocked from proceeding. and I think it might require a change in opennext as well to more clearly warn people that they shouldn't deploy that old version. at the moment because of force-install, wrangler deploy autoconfigures the old unsupported version and then happily deploys it 😅

I feel like an alternative might be a custom error message when next 14 is detected which tells people how to use the next codemod to upgrade. then, they upgrade first and wrangler deploy all in one go. don't think this is too difficult to add - just involves overriding the global error in frameworks/next.ts 🙂

// (hopefully) in right direction (instead of failing at this step)
"--force-install",
],
{
cwd: projectPath,
}
);
await runCommand([...dlx, "@opennextjs/cloudflare", "migrate"], {
cwd: projectPath,
});
}

return {
Expand Down
60 changes: 51 additions & 9 deletions packages/autoconfig/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
confirmAutoConfigDetails,
displayAutoConfigDetails,
} from "./details";
import { AutoConfigFrameworkConfigurationError } from "./errors";
import {
isFrameworkSupported,
isKnownFramework,
Expand Down Expand Up @@ -110,13 +111,13 @@ export async function runAutoConfig(
const frameworkPackageInfo = getFrameworkPackageInfo(
autoConfigDetails.framework.id
);
if (frameworkPackageInfo) {
autoConfigDetails.framework.validateFrameworkVersion(
autoConfigDetails.projectPath,
frameworkPackageInfo,
context
);
}
const frameworkVersionUpgrade = frameworkPackageInfo
? autoConfigDetails.framework.validateFrameworkVersion(
autoConfigDetails.projectPath,
frameworkPackageInfo,
context
)
: undefined;

const dryRunConfigurationResults =
await autoConfigDetails.framework.configure({
Expand Down Expand Up @@ -151,7 +152,12 @@ export async function runAutoConfig(
`${npx} wrangler versions upload`,
},
context,
dryRunConfigurationResults.packageJsonScriptsOverrides
dryRunConfigurationResults.packageJsonScriptsOverrides,
frameworkVersionUpgrade
? `Upgrade ${autoConfigDetails.framework.name} from ${JSON.stringify(
frameworkVersionUpgrade.installedVersion
)} to ${JSON.stringify(frameworkVersionUpgrade.upgradeTo)}`
: undefined
);

if (
Expand All @@ -174,6 +180,34 @@ export async function runAutoConfig(
return autoConfigSummary;
}

if (frameworkVersionUpgrade) {
await autoConfigDetails.framework.upgradeFrameworkVersion({
...frameworkVersionUpgrade,
packageManager,
isWorkspaceRoot,
});

assert(frameworkPackageInfo);
// Hold the newly installed version to the same bounds as any other supported version
const remainingUpgrade =
autoConfigDetails.framework.validateFrameworkVersion(
autoConfigDetails.projectPath,
frameworkPackageInfo,
context
);

if (remainingUpgrade) {
throw new AutoConfigFrameworkConfigurationError(
`${autoConfigDetails.framework.name} was updated to ${JSON.stringify(
frameworkVersionUpgrade.upgradeTo
)}, but the version installed in the project is still ${JSON.stringify(
remainingUpgrade.installedVersion
)}. Update it manually and try again.`,
{ telemetryMessage: "autoconfig framework version upgrade incomplete" }
);
}
}

logger.debug(
`Running autoconfig with:\n${JSON.stringify(autoConfigDetails, null, 2)}...`
);
Expand Down Expand Up @@ -320,6 +354,7 @@ async function saveWranglerJsonc(
* @param projectCommands - The build, deploy, and version commands for the project.
* @param context - The autoconfig context providing logger and other dependencies.
* @param packageJsonScriptsOverrides - Optional overrides for package.json script entries.
* @param frameworkVersionUpgradeDescription - Optional framework upgrade shown before other setup operations.
* @returns A summary object describing all planned operations.
*/
export async function buildOperationsSummary(
Expand All @@ -333,7 +368,8 @@ export async function buildOperationsSummary(
version?: string;
},
context: AutoConfigContext,
packageJsonScriptsOverrides?: PackageJsonScriptsOverrides
packageJsonScriptsOverrides?: PackageJsonScriptsOverrides,
frameworkVersionUpgradeDescription?: string
): Promise<AutoConfigSummary> {
const { logger } = context;
logger.log("");
Expand Down Expand Up @@ -411,6 +447,12 @@ export async function buildOperationsSummary(
logger.log("");
}

if (frameworkVersionUpgradeDescription) {
summary.frameworkVersionUpgrade = frameworkVersionUpgradeDescription;
logger.log(`⬆️ ${summary.frameworkVersionUpgrade}`);
logger.log("");
}

if (
autoConfigDetails.framework &&
!(autoConfigDetails.framework instanceof Static) &&
Expand Down
1 change: 1 addition & 0 deletions packages/autoconfig/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export type AutoConfigSummary = {
wranglerInstall: boolean;
wranglerConfig?: RawConfig;
frameworkConfiguration?: string;
frameworkVersionUpgrade?: string;
outputDir: string;
frameworkId?: string;
buildCommand?: string;
Expand Down
Loading
Loading