Skip to content

Commit

Permalink
fix: correctly find the latest version of create-cloudflare (#4771)
Browse files Browse the repository at this point in the history
* 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

* Log C3 "more info" to console.error rather than console.log

The yargs help info is set to error, so this makes it consistent
and simpler to spy on in tests.
  • Loading branch information
petebacondarwin authored Jan 24, 2024
1 parent 6eb2b9d commit f4f38fc
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 40 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
67 changes: 66 additions & 1 deletion packages/create-cloudflare/src/__tests__/common.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: T) {
return new Promise<T>((res) => res(value));
Expand Down Expand Up @@ -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" },
}
);
}
});
41 changes: 10 additions & 31 deletions packages/create-cloudflare/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,17 @@ 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,
rectifyPmMismatch,
runCommand,
} from "helpers/command";
import { detectPackageManager } from "helpers/packages";
import semver from "semver";
import { version } from "../package.json";
import {
gitCommit,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion packages/create-cloudflare/src/helpers/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const parseArgs = async (argv: string[]): Promise<Partial<C3Args>> => {
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}`
);
};
26 changes: 26 additions & 0 deletions packages/create-cloudflare/src/helpers/cli.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -18,6 +23,27 @@ export async function openInBrowser(url: string): Promise<void> {
});
}

// 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",
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,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";

Expand Down Expand Up @@ -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 ?? [];
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";

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

0 comments on commit f4f38fc

Please sign in to comment.