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
148 changes: 147 additions & 1 deletion packages/cli/src/lib/__tests__/cloudflare.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CloudflareClient } from "../cloudflare.js";
import {
CloudflareClient,
getBundledWranglerBinPath,
resetWranglerBinPathCache,
} from "../cloudflare.js";
import { ProcessOutput } from "zx";
import path from "path";
import { createRequire } from "node:module";
import { homedir } from "node:os";

const nodeModuleState = vi.hoisted(() => ({
throwOnCreateRequire: false,
failExistsCheck: false,
}));

// Mock the entire zx module
vi.mock("zx", async (importOriginal) => {
const actual = await importOriginal<typeof import("zx")>();
Expand All @@ -13,6 +23,32 @@ vi.mock("zx", async (importOriginal) => {
};
});

vi.mock("node:module", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:module")>();
return {
...actual,
createRequire: vi.fn((...args) => {
if (nodeModuleState.throwOnCreateRequire) {
throw new Error("Simulated resolution failure");
}
return actual.createRequire(...args);
}),
};
});

vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: vi.fn((...args) => {
if (nodeModuleState.failExistsCheck) {
return false;
}
return actual.existsSync(...args);
}),
};
});

// Import and spy on the mocked function
const { $ } = await import("zx");

Expand All @@ -25,6 +61,9 @@ describe("CloudflareClient", () => {

beforeEach(() => {
vi.clearAllMocks();
nodeModuleState.throwOnCreateRequire = false;
nodeModuleState.failExistsCheck = false;
resetWranglerBinPathCache();
client = new CloudflareClient(mockConfigPath);
});

Expand All @@ -45,6 +84,113 @@ describe("CloudflareClient", () => {
});
});

describe("wrangler binary resolution", () => {
const mockAccountId = "1234567890abcdef1234567890abcdef";

// captured[i] holds the interpolated substitution values of the i-th
// `$` invocation. Templates like `${argv} secret list --config ${path}`
// yield multiple substitutions, so argv is itself a nested array.
function captureSubs(): unknown[][] {
const captured: unknown[][] = [];
vi.mocked($).mockImplementation(((...args: unknown[]) => {
const [strings, ...subs] = args;
if (!Array.isArray(strings)) {
return ((
templateStrings: TemplateStringsArray,
...subs: unknown[]
) => {
captured.push(subs);
return {
stdout: `random content ${mockAccountId} \nmore random content`,
stderr: "",
exitCode: 0,
};
}) as any;
}
captured.push(subs);
return {
stdout: JSON.stringify([]),
stderr: "",
exitCode: 0,
} as any;
}) as any);
return captured;
}

it("should resolve the wrangler binary bundled with the CLI", () => {
const binPath = getBundledWranglerBinPath();
expect(binPath).toMatch(/[\\/]bin[\\/]wrangler\.js$/);
expect(binPath).toContain("node_modules");
});

it("should memoize the resolved binary path across calls", () => {
getBundledWranglerBinPath();

const createRequireSpy = vi.mocked(createRequire);
const callsAfterFirstResolution =
createRequireSpy.mock.calls.length;

getBundledWranglerBinPath();

expect(createRequireSpy.mock.calls.length).toBe(
callsAfterFirstResolution,
);
});

it("should spawn the bundled wrangler binary for whoami", async () => {
const captured = captureSubs();

await client.getAccountId();

expect(captured[0]).toEqual([
["node", getBundledWranglerBinPath()],
]);
});

it("should spawn the bundled wrangler binary for secret list", async () => {
const captured = captureSubs();

await client.getCloudflareSecrets();

expect(captured[0]).toEqual([
["node", getBundledWranglerBinPath()],
mockConfigPath,
]);
});

it("should fall back to npx wrangler and warn when the bundled binary cannot be resolved", async () => {
nodeModuleState.throwOnCreateRequire = true;
const warnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});
const captured = captureSubs();

await client.getAccountId();

expect(captured[0]).toEqual([["npx", "wrangler"]]);
expect(warnSpy).toHaveBeenCalledWith(
"Failed to resolve bundled wrangler binary, falling back to npx:",
expect.anything(),
);
warnSpy.mockRestore();
});

it("should warn when the resolved binary path does not exist", () => {
nodeModuleState.failExistsCheck = true;
const warnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});

expect(getBundledWranglerBinPath()).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Bundled wrangler binary not found at .*bin[\\/]wrangler\.js, falling back to npx/,
),
);
warnSpy.mockRestore();
});
});

describe("getAccountId", () => {
it("should extract account ID from whoami command", async () => {
const mockAccountId = "1234567890abcdef1234567890abcdef";
Expand Down
95 changes: 77 additions & 18 deletions packages/cli/src/lib/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { $, ProcessOutput } from "zx";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import path from "path";
import { homedir } from "node:os";

Expand All @@ -7,6 +9,42 @@ interface SecretItem {
type: string;
}

let cachedWranglerBinPath: string | null | undefined;

export function getBundledWranglerBinPath(): string | null {
if (cachedWranglerBinPath !== undefined) {
return cachedWranglerBinPath;
}

try {
const require = createRequire(import.meta.url);
const packageJsonPath = require.resolve("wrangler/package.json");
const binPath = path.join(
path.dirname(packageJsonPath),
"bin",
"wrangler.js",
);
cachedWranglerBinPath = existsSync(binPath) ? binPath : null;
if (!cachedWranglerBinPath) {
console.warn(
`Bundled wrangler binary not found at ${binPath}, falling back to npx`,
);
}
} catch (err) {
console.warn(
"Failed to resolve bundled wrangler binary, falling back to npx:",
err,
);
cachedWranglerBinPath = null;
}

return cachedWranglerBinPath;
}

export function resetWranglerBinPathCache(): void {
cachedWranglerBinPath = undefined;
}

interface AccountInfo {
id: string;
name: string;
Expand Down Expand Up @@ -41,9 +79,16 @@ export class CloudflareClient {
path.join(homedir(), ".counterscale", "wrangler.json");
}

private wranglerArgv(): string[] {
const binPath = getBundledWranglerBinPath();
return binPath ? ["node", binPath] : ["npx", "wrangler"];
}

async getAccountId(): Promise<string | null> {
try {
const result = await $({ quiet: true })`npx wrangler whoami`;
const result = await $({
quiet: true,
})`${this.wranglerArgv()} whoami`;
const match = result.stdout.match(/([0-9a-f]{32})/);
return match ? match[0] : null;
} catch (error) {
Expand All @@ -56,17 +101,24 @@ export class CloudflareClient {

async getAccounts(): Promise<AccountInfo[]> {
try {
const result = await $({ quiet: true })`npx wrangler whoami`;
const result = await $({
quiet: true,
})`${this.wranglerArgv()} whoami`;
const accounts = this.parseAccountsFromTable(result.stdout);

// If table parsing failed, fall back to single account
if (accounts.length === 0) {
const accountId = await this.getAccountId();
if (accountId) {
return [{ id: accountId, name: `Account ${accountId.slice(-6)}` }];
return [
{
id: accountId,
name: `Account ${accountId.slice(-6)}`,
},
];
}
}

return accounts;
} catch (error) {
if (error instanceof ProcessOutput) {
Expand All @@ -78,33 +130,40 @@ export class CloudflareClient {

private parseAccountsFromTable(output: string): AccountInfo[] {
const accounts: AccountInfo[] = [];
const lines = output.split('\n');
const lines = output.split("\n");

for (const line of lines) {
// Skip header and separator lines
if (!line.includes('│') || line.includes('Account Name') || line.includes('─')) {
if (
!line.includes("│") ||
line.includes("Account Name") ||
line.includes("─")
) {
continue;
}

const parts = line.split('│').map(part => part.trim()).filter(Boolean);


const parts = line
.split("│")
.map((part) => part.trim())
.filter(Boolean);

if (parts.length >= 2) {
const [name, id] = parts;

// Validate account ID format (32 hex characters)
if (/^[0-9a-f]{32}$/.test(id)) {
accounts.push({ id, name });
}
}
}

return accounts;
}

private async fetchCloudflareSecrets(): Promise<string> {
try {
const result =
await $`npx wrangler secret list --config ${this.configPath}`;
await $`${this.wranglerArgv()} secret list --config ${this.configPath}`;
return result.stdout;
} catch (error) {
throw error instanceof ProcessOutput
Expand Down Expand Up @@ -147,7 +206,8 @@ export class CloudflareClient {
const data: TokenValidationResponse = await response.json();

if (!data.success) {
const errorMessage = data.errors?.[0]?.message || "Token validation failed";
const errorMessage =
data.errors?.[0]?.message || "Token validation failed";
return { valid: false, error: errorMessage };
}

Expand Down Expand Up @@ -192,7 +252,7 @@ export class CloudflareClient {
): Promise<boolean> {
for (const [key, value] of Object.entries(secrets)) {
try {
await $`echo ${value} | npx wrangler secret put ${key} --config ${this.configPath}`;
await $`echo ${value} | ${this.wranglerArgv()} secret put ${key} --config ${this.configPath}`;
} catch {
return false;
}
Expand All @@ -204,7 +264,7 @@ export class CloudflareClient {
try {
const p = $({
quiet: true,
})`npx wrangler deploy --config ${this.configPath} --var VERSION:${version}`;
})`${this.wranglerArgv()} deploy --config ${this.configPath} --var VERSION:${version}`;

let output = "";
for await (const text of p) {
Expand All @@ -226,4 +286,3 @@ export class CloudflareClient {
}
}
}

Loading