Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/cli-command-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/cli": minor
---

Add `runCommand` and `quoteShellArgs`, `installPackages` and `installWrangler` utilities to `@cloudflare/cli/command`

These utilities are now available from `@cloudflare/cli` as dedicated sub-path exports: `runCommand` and `quoteShellArgs` via `@cloudflare/cli/command`, and `installPackages` and `installWrangler` via `@cloudflare/cli/packages`. This makes them reusable across packages in the SDK without duplication.
72 changes: 72 additions & 0 deletions packages/cli/__tests__/command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { spawn } from "cross-spawn";
import { afterEach, beforeEach, describe, test, vi } from "vitest";
import { quoteShellArgs, runCommand } from "../command";
import type { ChildProcess } from "node:child_process";

// We can change how the mock spawn works by setting these variables
let spawnResultCode = 0;
let spawnStdout: string | undefined = undefined;
let spawnStderr: string | undefined = undefined;

vi.mock("cross-spawn");

describe("Command Helpers", () => {
afterEach(() => {
spawnResultCode = 0;
spawnStdout = undefined;
spawnStderr = undefined;
});

beforeEach(() => {
vi.mocked(spawn).mockImplementation(() => {
return {
on: vi.fn().mockImplementation((event, cb) => {
if (event === "close") {
cb(spawnResultCode);
}
}),
stdout: {
on(_event: "data", cb: (data: string) => void) {
if (spawnStdout !== undefined) {
cb(spawnStdout);
}
},
},
stderr: {
on(_event: "data", cb: (data: string) => void) {
if (spawnStderr !== undefined) {
cb(spawnStderr);
}
},
},
} as unknown as ChildProcess;
});
});

test("runCommand", async ({ expect }) => {
await runCommand(["ls", "-l"]);
expect(spawn).toHaveBeenCalledWith("ls", ["-l"], {
stdio: "inherit",
env: process.env,
signal: expect.any(AbortSignal),
});
});

describe("quoteShellArgs", () => {
test.runIf(process.platform !== "win32")("mac", async ({ expect }) => {
expect(quoteShellArgs([`pages:dev`])).toEqual("pages:dev");
expect(quoteShellArgs([`24.02 foo-bar`])).toEqual(`'24.02 foo-bar'`);
expect(quoteShellArgs([`foo/10 bar/20-baz/`])).toEqual(
`'foo/10 bar/20-baz/'`
);
});

test.runIf(process.platform === "win32")("windows", async ({ expect }) => {
expect(quoteShellArgs([`pages:dev`])).toEqual("pages:dev");
expect(quoteShellArgs([`24.02 foo-bar`])).toEqual(`"24.02 foo-bar"`);
expect(quoteShellArgs([`foo/10 bar/20-baz/`])).toEqual(
`"foo/10 bar/20-baz/"`
);
});
});
});
130 changes: 130 additions & 0 deletions packages/cli/__tests__/packages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parsePackageJSON, readFileSync } from "@cloudflare/workers-utils";
import { afterEach, describe, test, vi } from "vitest";
import { runCommand } from "../command";
import { installPackages, installWrangler } from "../packages";

vi.mock("../command");
vi.mock("@cloudflare/workers-utils", () => ({
readFileSync: vi.fn(),
parsePackageJSON: vi.fn(),
}));
vi.mock("node:fs/promises", () => ({
writeFile: vi.fn(),
}));

describe("Package Helpers", () => {
afterEach(() => {
vi.clearAllMocks();
});

describe("installPackages", async () => {
type TestCase = {
pm: "npm" | "pnpm" | "yarn" | "bun";
initialArgs: string[];
additionalArgs?: string[];
};

const cases: TestCase[] = [
{ pm: "npm", initialArgs: ["npm", "install"] },
{ pm: "pnpm", initialArgs: ["pnpm", "install"] },
{ pm: "bun", initialArgs: ["bun", "add"] },
{ pm: "yarn", initialArgs: ["yarn", "add"] },
];

test.for(cases)(
"with $pm",
async ({ pm, initialArgs, additionalArgs }, { expect }) => {
const mockPkgJson = {
dependencies: {
foo: "^1.0.0",
bar: "^2.0.0",
baz: "^1.2.3",
},
};
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson));
vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson);

const packages = ["foo", "bar@latest", "baz@1.2.3"];
await installPackages(pm, packages);

expect(vi.mocked(runCommand)).toHaveBeenCalledWith(
[...initialArgs, ...packages, ...(additionalArgs ?? [])],
expect.anything()
);

if (pm === "npm") {
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
expect(writeFileCall[0]).toBe(resolve(process.cwd(), "package.json"));
expect(JSON.parse(writeFileCall[1] as string)).toMatchObject({
dependencies: {
foo: "^1.0.0",
bar: "^2.0.0",
baz: "1.2.3",
},
});
}
}
);

const devCases: TestCase[] = [
{ pm: "npm", initialArgs: ["npm", "install", "--save-dev"] },
{ pm: "pnpm", initialArgs: ["pnpm", "install", "--save-dev"] },
{ pm: "bun", initialArgs: ["bun", "add", "-d"] },
{ pm: "yarn", initialArgs: ["yarn", "add", "-D"] },
];

test.for(devCases)(
"with $pm (dev = true)",
async ({ pm, initialArgs, additionalArgs }, { expect }) => {
const mockPkgJson = {
devDependencies: {
foo: "^1.0.0",
bar: "^2.0.0",
baz: "^1.2.3",
},
};
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson));
vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson);

const packages = ["foo", "bar@latest", "baz@1.2.3"];
await installPackages(pm, packages, { dev: true });

expect(vi.mocked(runCommand)).toHaveBeenCalledWith(
[...initialArgs, ...packages, ...(additionalArgs ?? [])],
expect.anything()
);

if (pm === "npm") {
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
expect(writeFileCall[0]).toBe(resolve(process.cwd(), "package.json"));
expect(JSON.parse(writeFileCall[1] as string)).toMatchObject({
devDependencies: {
foo: "^1.0.0",
bar: "^2.0.0",
baz: "1.2.3",
},
});
}
}
);
});

test("installWrangler", async ({ expect }) => {
const mockPkgJson = {
devDependencies: {
wrangler: "^4.0.0",
},
};
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson));
vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson);

await installWrangler("npm", false);

expect(vi.mocked(runCommand)).toHaveBeenCalledWith(
["npm", "install", "--save-dev", "wrangler@latest"],
expect.anything()
);
});
});
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { stripAnsi } from "@cloudflare/cli";
import { CancelError } from "@cloudflare/cli/error";
import { isInteractive, spinner } from "@cloudflare/cli/interactive";
import { spawn } from "cross-spawn";
import { CancelError } from "./error";
import { isInteractive, spinner } from "./interactive";
import { stripAnsi } from ".";

/**
* Command is a string array, like ['git', 'commit', '-m', '"Initial commit"']
*/
type Command = string[];

type RunOptions = {
export type RunOptions = {
startText?: string;
doneText?: string | ((output: string) => string);
silent?: boolean;
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@
"@clack/core": "^0.3.2",
"@cloudflare/workers-tsconfig": "workspace:*",
"@cloudflare/workers-utils": "workspace:*",
"@types/cross-spawn": "^6.0.2",
"@types/which-pm-runs": "^1.0.0",
"chalk": "^5.2.0",
"log-update": "^5.0.1"
"cross-spawn": "^7.0.3",
"log-update": "^5.0.1",
"which-pm-runs": "^2.0.0"
},
"volta": {
"extends": "../../package.json"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import assert from "node:assert";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { brandColor, dim } from "@cloudflare/cli/colors";
import { parsePackageJSON, readFileSync } from "@cloudflare/workers-utils";
import { brandColor, dim } from "./colors";
import { runCommand } from "./command";
import type { PackageManager } from "../../package-manager";

type InstallConfig = {
startText?: string;
Expand All @@ -25,17 +24,16 @@ type InstallConfig = {
* @param config.force - Whether to install with `--force` or not
*/
export const installPackages = async (
packageManager: PackageManager,
packageManager: "npm" | "pnpm" | "yarn" | "bun",
packages: string[],
config: InstallConfig = {}
) => {
const { type } = packageManager;
const { force, dev, startText, doneText } = config;
const isWorkspaceRoot = config.isWorkspaceRoot ?? false;

if (packages.length === 0) {
let cmd;
switch (type) {
switch (packageManager) {
case "yarn":
break;
case "npm":
Expand All @@ -47,12 +45,12 @@ export const installPackages = async (

await runCommand(
[
type,
packageManager,
...(cmd ? [cmd] : []),
...packages,
...(type === "pnpm" ? ["--no-frozen-lockfile"] : []),
...(packageManager === "pnpm" ? ["--no-frozen-lockfile"] : []),
...(force === true ? ["--force"] : []),
...getWorkspaceInstallRootFlag(type, isWorkspaceRoot),
...getWorkspaceInstallRootFlag(packageManager, isWorkspaceRoot),
],
{
cwd: process.cwd(),
Expand All @@ -66,11 +64,15 @@ export const installPackages = async (

let saveFlag;
let cmd;
switch (type) {
switch (packageManager) {
case "yarn":
cmd = "add";
saveFlag = dev ? "-D" : "";
break;
case "bun":
cmd = "add";
saveFlag = dev ? "-d" : "";
break;
case "npm":
case "pnpm":
default:
Expand All @@ -80,12 +82,12 @@ export const installPackages = async (
}
Comment thread
dario-piotrowicz marked this conversation as resolved.
await runCommand(
[
type,
packageManager,
cmd,
...(saveFlag ? [saveFlag] : []),
...packages,
...(force === true ? ["--force"] : []),
...getWorkspaceInstallRootFlag(type, isWorkspaceRoot),
...getWorkspaceInstallRootFlag(packageManager, isWorkspaceRoot),
],
{
startText,
Expand All @@ -94,7 +96,7 @@ export const installPackages = async (
}
);

if (type === "npm") {
if (packageManager === "npm") {
// Npm install will update the package.json with a caret-range rather than the exact version/range we asked for.
// We can't use `npm install --save-exact` because that always pins to an exact version, and we want to allow ranges too.
// So let's just fix that up now by rewriting the package.json.
Expand All @@ -121,19 +123,19 @@ export const installPackages = async (
* Returns the potential flag(/s) that need to be added to a package manager's install command when it is
* run at the root of a workspace.
*
* @param packageManagerType The type of package manager
* @param packageManager The type of package manager
* @param isWorkspaceRoot Flag indicating whether the install command is being run at the root of a workspace
* @returns an array containing the flag(/s) to use, or an empty array if not supported or not running in the workspace root.
*/
const getWorkspaceInstallRootFlag = (
packageManagerType: PackageManager["type"],
function getWorkspaceInstallRootFlag(
packageManager: "npm" | "pnpm" | "yarn" | "bun",
isWorkspaceRoot: boolean
): string[] => {
): string[] {
if (!isWorkspaceRoot) {
return [];
}

switch (packageManagerType) {
switch (packageManager) {
case "pnpm":
return ["--workspace-root"];
case "yarn":
Expand All @@ -143,17 +145,15 @@ const getWorkspaceInstallRootFlag = (
// npm and bun don't have the workspace check
return [];
}
};
}

/**
* Installs the latest version of wrangler in the project directory if it isn't already.
*/
export const installWrangler = async (
packageManager: PackageManager,
export async function installWrangler(
packageManager: "npm" | "pnpm" | "yarn" | "bun",
isWorkspaceRoot: boolean
) => {
const { type } = packageManager;

) {
// Even if Wrangler is already installed, make sure we install the latest version, as some framework CLIs are pinned to an older version
await installPackages(packageManager, [`wrangler@latest`], {
dev: true,
Expand All @@ -162,7 +162,7 @@ export const installWrangler = async (
"A command line tool for building Cloudflare Workers"
)}`,
doneText: `${brandColor("installed")} ${dim(
`via \`${type} install wrangler --save-dev\``
`via \`${packageManager} install wrangler --save-dev\``
)}`,
});
};
}
Loading
Loading