Skip to content

Commit

Permalink
fix: correctly find the latest version of create-cloudflare
Browse files Browse the repository at this point in the history
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
  • Loading branch information
petebacondarwin committed Jan 23, 2024
1 parent 77b0bce commit 0ce077c
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 20 deletions.
20 changes: 20 additions & 0 deletions .changeset/strange-bikes-run.md
Original file line number Diff line number Diff line change
@@ -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
72 changes: 71 additions & 1 deletion packages/create-cloudflare/src/__tests__/common.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
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 { isUpdateAvailable } from "../cli";
import {
isAllowedExistingFile,
isGitConfigured,
Expand Down Expand Up @@ -93,3 +97,69 @@ describe("isAllowedExistingFile", () => {
expect(isAllowedExistingFile(val)).toBe(false);
});
});

describe("isUpdateAvailable", () => {
const isTTY = process.stdin.isTTY;
const originalDispatcher = getGlobalDispatcher();
let agent: MockAgent;

beforeEach(() => {
// Mock out the undici Agent
agent = new MockAgent();
agent.disableNetConnect();
setGlobalDispatcher(agent);

// Pretend we are interactive
process.stdin.isTTY = true;
});

afterEach(() => {
agent.assertNoPendingInterceptors();
setGlobalDispatcher(originalDispatcher);
process.stdin.isTTY = isTTY;
});

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" },
}
);
}
});
28 changes: 16 additions & 12 deletions packages/create-cloudflare/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { parseArgs } from "helpers/args";
import { C3_DEFAULTS } from "helpers/cli";
import { runCommand } from "helpers/command";
import { getLatestPackageVersion } from "helpers/latestPackageVersion";
import { detectPackageManager } from "helpers/packages";
import semver from "semver";
import { version } from "../package.json";
Expand All @@ -34,24 +35,27 @@ export const main = async (argv: string[]) => {

// 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()) {
export const isUpdateAvailable = async () => {
if (!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);
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();
}
};

// Spawn a separate process running the most recent version of c3
Expand Down
12 changes: 5 additions & 7 deletions packages/create-cloudflare/src/helpers/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ 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 { readFile, writeFile } from "./files";
import { getLatestPackageVersion } from "./latestPackageVersion";
import { detectPackageManager } from "./packages";
import type { PagesGeneratorContext } from "types";

Expand Down Expand Up @@ -395,14 +395,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 ?? [];
Expand Down
14 changes: 14 additions & 0 deletions packages/create-cloudflare/src/helpers/latestPackageVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { fetch } from "undici";

interface 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;
}

0 comments on commit 0ce077c

Please sign in to comment.