diff --git a/.changeset/strange-bikes-run.md b/.changeset/strange-bikes-run.md new file mode 100644 index 000000000000..ebbdfd94df0c --- /dev/null +++ b/.changeset/strange-bikes-run.md @@ -0,0 +1,20 @@ +--- +"create-cloudflare": patch +--- + +fix: correctly find the latest version of create-cloudflare + +When create-cloudflare starts up, it checks to see if the version being run +is the latest available on npm. + +Previously this check used `npm info` to look up the version. +But was prone to failing if that command returned additional unexpected output +such as warnings. + +Now we make a fetch request to the npm REST API directly for the latest version, +which does not have the problem of unexpected warnings. + +Since the same approach is used to compute the latest version of workerd, the +function to do this has been put into a helper. + +Fixes #4729 diff --git a/packages/create-cloudflare/src/__tests__/common.test.ts b/packages/create-cloudflare/src/__tests__/common.test.ts index e8c608c3e2fc..f5048f439ea9 100644 --- a/packages/create-cloudflare/src/__tests__/common.test.ts +++ b/packages/create-cloudflare/src/__tests__/common.test.ts @@ -1,10 +1,14 @@ import * as command from "helpers/command"; -import { describe, expect, test, vi } from "vitest"; +import { SemVer } from "semver"; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from "undici"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { version as currentVersion } from "../../package.json"; import { isAllowedExistingFile, isGitConfigured, validateProjectDirectory, } from "../common"; +import { isUpdateAvailable } from "../helpers/cli"; function promisify(value: T) { return new Promise((res) => res(value)); @@ -93,3 +97,64 @@ describe("isAllowedExistingFile", () => { expect(isAllowedExistingFile(val)).toBe(false); }); }); + +describe("isUpdateAvailable", () => { + const originalDispatcher = getGlobalDispatcher(); + let agent: MockAgent; + + beforeEach(() => { + // Mock out the undici Agent + agent = new MockAgent(); + agent.disableNetConnect(); + setGlobalDispatcher(agent); + }); + + afterEach(() => { + agent.assertNoPendingInterceptors(); + setGlobalDispatcher(originalDispatcher); + }); + + test("is not available if fetch fails", async () => { + agent + .get("https://registry.npmjs.org") + .intercept({ path: "/create-cloudflare" }) + .replyWithError(new Error()); + expect(await isUpdateAvailable()).toBe(false); + }); + + test("is not available if fetched latest version is older by a minor", async () => { + const latestVersion = new SemVer(currentVersion); + latestVersion.minor--; + replyWithLatest(latestVersion); + expect(await isUpdateAvailable()).toBe(false); + }); + + test("is available if fetched latest version is newer by a minor", async () => { + const latestVersion = new SemVer(currentVersion); + latestVersion.minor++; + replyWithLatest(latestVersion); + expect(await isUpdateAvailable()).toBe(true); + }); + + test("is not available if fetched latest version is newer by a major", async () => { + const latestVersion = new SemVer(currentVersion); + latestVersion.major++; + replyWithLatest(latestVersion); + expect(await isUpdateAvailable()).toBe(false); + }); + + function replyWithLatest(version: SemVer) { + agent + .get("https://registry.npmjs.org") + .intercept({ path: "/create-cloudflare" }) + .reply( + 200, + { + "dist-tags": { latest: version.format() }, + }, + { + headers: { "content-type": "application/json" }, + } + ); + } +}); diff --git a/packages/create-cloudflare/src/cli.ts b/packages/create-cloudflare/src/cli.ts index cada5af01e72..a81694925331 100644 --- a/packages/create-cloudflare/src/cli.ts +++ b/packages/create-cloudflare/src/cli.ts @@ -3,14 +3,10 @@ import { dirname } from "path"; import { chdir } from "process"; import { crash, endSection, logRaw, startSection } from "@cloudflare/cli"; import { processArgument } from "@cloudflare/cli/args"; -import { blue, dim } from "@cloudflare/cli/colors"; -import { - isInteractive, - spinner, - spinnerFrames, -} from "@cloudflare/cli/interactive"; +import { dim } from "@cloudflare/cli/colors"; +import { isInteractive } from "@cloudflare/cli/interactive"; import { parseArgs } from "helpers/args"; -import { C3_DEFAULTS } from "helpers/cli"; +import { C3_DEFAULTS, isUpdateAvailable } from "helpers/cli"; import { installWrangler, npmInstall, @@ -18,7 +14,6 @@ import { runCommand, } from "helpers/command"; import { detectPackageManager } from "helpers/packages"; -import semver from "semver"; import { version } from "../package.json"; import { gitCommit, @@ -47,7 +42,13 @@ export const main = async (argv: string[]) => { // Print a newline logRaw(""); - if (args.autoUpdate && (await isUpdateAvailable())) { + if ( + args.autoUpdate && + !process.env.VITEST && + !process.env.CI && + isInteractive() && + (await isUpdateAvailable()) + ) { await runLatest(); } else { await runCli(args); @@ -156,28 +157,6 @@ const deploy = async (ctx: C3Context) => { } }; -// Detects if a newer version of c3 is available by comparing the version -// specified in package.json with the `latest` tag from npm -const isUpdateAvailable = async () => { - if (process.env.VITEST || process.env.CI || !isInteractive()) { - return false; - } - - // Use a spinner when running this check since it may take some time - const s = spinner(spinnerFrames.vertical, blue); - s.start("Checking if a newer version is available"); - const latestVersion = await runCommand( - ["npm", "info", "create-cloudflare@latest", "dist-tags.latest"], - { silent: true, useSpinner: false } - ); - s.stop(); - - // Don't auto-update to major versions - if (semver.diff(latestVersion, version) === "major") return false; - - return semver.gt(latestVersion, version); -}; - const printBanner = () => { logRaw(dim(`using create-cloudflare version ${version}\n`)); startSection(`Create an application with Cloudflare`, "Step 1 of 3"); diff --git a/packages/create-cloudflare/src/helpers/args.ts b/packages/create-cloudflare/src/helpers/args.ts index f6a2a5162675..958df9f63fba 100644 --- a/packages/create-cloudflare/src/helpers/args.ts +++ b/packages/create-cloudflare/src/helpers/args.ts @@ -122,7 +122,7 @@ export const parseArgs = async (argv: string[]): Promise> => { const showMoreInfoNote = () => { const c3CliArgsDocsPage = "https://developers.cloudflare.com/pages/get-started/c3/#cli-arguments"; - console.log( + console.error( `\nFor more information regarding how to invoke C3 please visit ${c3CliArgsDocsPage}` ); }; diff --git a/packages/create-cloudflare/src/helpers/cli.ts b/packages/create-cloudflare/src/helpers/cli.ts index 48726843e707..2b0f102f21a6 100644 --- a/packages/create-cloudflare/src/helpers/cli.ts +++ b/packages/create-cloudflare/src/helpers/cli.ts @@ -1,6 +1,11 @@ import { updateStatus, warn } from "@cloudflare/cli"; +import { blue } from "@cloudflare/cli/colors"; +import { spinner, spinnerFrames } from "@cloudflare/cli/interactive"; import Haikunator from "haikunator"; import open from "open"; +import semver from "semver"; +import { version } from "../../package.json"; +import { getLatestPackageVersion } from "./latestPackageVersion"; /** * An extremely simple wrapper around the open command. @@ -18,6 +23,27 @@ export async function openInBrowser(url: string): Promise { }); } +// Detects if a newer version of c3 is available by comparing the version +// specified in package.json with the `latest` tag from npm +export const isUpdateAvailable = async () => { + // Use a spinner when running this check since it may take some time + const s = spinner(spinnerFrames.vertical, blue); + s.start("Checking if a newer version is available"); + try { + const latestVersion = await getLatestPackageVersion("create-cloudflare"); + return ( + // Don't auto-update to major versions + semver.diff(latestVersion, version) !== "major" && + semver.gt(latestVersion, version) + ); + } catch { + s.update("Failed to read latest version from npm."); + return false; + } finally { + s.stop(); + } +}; + export const C3_DEFAULTS = { projectName: new Haikunator().haikunate({ tokenHex: true }), type: "hello-world", diff --git a/packages/create-cloudflare/src/helpers/command.ts b/packages/create-cloudflare/src/helpers/command.ts index 73392aec33c9..dc18ee7a67da 100644 --- a/packages/create-cloudflare/src/helpers/command.ts +++ b/packages/create-cloudflare/src/helpers/command.ts @@ -5,8 +5,8 @@ import { brandColor, dim } from "@cloudflare/cli/colors"; import { isInteractive, spinner } from "@cloudflare/cli/interactive"; import { spawn } from "cross-spawn"; import { getFrameworkCli } from "frameworks/index"; -import { fetch } from "undici"; import { quoteShellArgs } from "../common"; +import { getLatestPackageVersion } from "./latestPackageVersion"; import { detectPackageManager } from "./packages"; import type { C3Context } from "types"; @@ -406,14 +406,12 @@ export async function getWorkerdCompatibilityDate() { } ${dim(compatDate)}`, async promise() { try { - const resp = await fetch("https://registry.npmjs.org/workerd"); - const workerdNpmInfo = (await resp.json()) as { - "dist-tags": { latest: string }; - }; - const result = workerdNpmInfo["dist-tags"].latest; + const latestWorkerdVersion = await getLatestPackageVersion("workerd"); // The format of the workerd version is `major.yyyymmdd.patch`. - const match = result.match(/\d+\.(\d{4})(\d{2})(\d{2})\.\d+/); + const match = latestWorkerdVersion.match( + /\d+\.(\d{4})(\d{2})(\d{2})\.\d+/ + ); if (match) { const [, year, month, date] = match ?? []; diff --git a/packages/create-cloudflare/src/helpers/latestPackageVersion.ts b/packages/create-cloudflare/src/helpers/latestPackageVersion.ts new file mode 100644 index 000000000000..f3809210a445 --- /dev/null +++ b/packages/create-cloudflare/src/helpers/latestPackageVersion.ts @@ -0,0 +1,14 @@ +import { fetch } from "undici"; + +type NpmInfoResponse = { + "dist-tags": { latest: string }; +}; + +/** + * Get the latest version of an npm package by making a request to the npm REST API. + */ +export async function getLatestPackageVersion(packageSpecifier: string) { + const resp = await fetch(`https://registry.npmjs.org/${packageSpecifier}`); + const npmInfo = (await resp.json()) as NpmInfoResponse; + return npmInfo["dist-tags"].latest; +}