From 2d77ab6b106b5bd084294d1080066268ba4865ca Mon Sep 17 00:00:00 2001 From: stordahl Date: Sun, 26 Oct 2025 12:45:47 -0500 Subject: [PATCH 1/7] chore: pull api token prompt into lib --- .../src/commands/__tests__/install.test.ts | 113 -------------- packages/cli/src/commands/install.ts | 70 ++------- packages/cli/src/lib/__tests__/ui.test.ts | 138 +++++++++++++++++- packages/cli/src/lib/ui.ts | 62 +++++++- 4 files changed, 210 insertions(+), 173 deletions(-) diff --git a/packages/cli/src/commands/__tests__/install.test.ts b/packages/cli/src/commands/__tests__/install.test.ts index 6b69efac..d928f6f2 100644 --- a/packages/cli/src/commands/__tests__/install.test.ts +++ b/packages/cli/src/commands/__tests__/install.test.ts @@ -41,7 +41,6 @@ import { isCancel } from "@clack/prompts"; // Import after mocks are set up import { - promptApiToken, promptDeploy, promptProjectConfig, promptAccountSelection, @@ -66,118 +65,6 @@ describe("install prompts", () => { mockExit.mockRestore(); }); - describe("promptApiToken", () => { - let mockSpinner: { - start: ReturnType; - stop: ReturnType; - }; - - beforeEach(async () => { - mockSpinner = { - start: vi.fn(), - stop: vi.fn(), - }; - const mockPrompts = await import("@clack/prompts"); - ( - mockPrompts.spinner as unknown as ReturnType - ).mockReturnValue(mockSpinner); - }); - - it("should return valid API token when validation passes", async () => { - const mockToken = "a".repeat(40); - (isCancel as unknown as ReturnType).mockReturnValue( - false, - ); - const mockPrompts = await import("@clack/prompts"); - ( - mockPrompts.password as unknown as ReturnType - ).mockResolvedValue(mockToken); - - const mockCloudflare = await import("../../lib/cloudflare.js"); - vi.mocked( - mockCloudflare.CloudflareClient.validateToken, - ).mockResolvedValue({ valid: true }); - - const result = await promptApiToken(); - expect(result).toBe(mockToken); - }); - - it("should throw error if user cancels", async () => { - (isCancel as unknown as ReturnType).mockReturnValue( - true, - ); - const mockPrompts = await import("@clack/prompts"); - ( - mockPrompts.password as unknown as ReturnType - ).mockResolvedValue("token"); - - await expect(promptApiToken()).rejects.toThrow( - "Operation canceled", - ); - }); - - it("should throw error if token validation fails", async () => { - const mockToken = "a".repeat(40); - (isCancel as unknown as ReturnType).mockReturnValue( - false, - ); - const mockPrompts = await import("@clack/prompts"); - ( - mockPrompts.password as unknown as ReturnType - ).mockResolvedValue(mockToken); - - const mockCloudflare = await import("../../lib/cloudflare.js"); - vi.mocked( - mockCloudflare.CloudflareClient.validateToken, - ).mockResolvedValue({ - valid: false, - error: "Invalid token or insufficient permissions", - }); - - await expect(promptApiToken()).rejects.toThrow( - "Invalid token or insufficient permissions", - ); - }); - - it("should throw error if validation throws", async () => { - const mockToken = "a".repeat(40); - (isCancel as unknown as ReturnType).mockReturnValue( - false, - ); - const mockPrompts = await import("@clack/prompts"); - ( - mockPrompts.password as unknown as ReturnType - ).mockResolvedValue(mockToken); - - const mockCloudflare = await import("../../lib/cloudflare.js"); - vi.mocked( - mockCloudflare.CloudflareClient.validateToken, - ).mockRejectedValue(new Error("Network error")); - - await expect(promptApiToken()).rejects.toThrow("Network error"); - }); - - it("should throw generic error if validation throws non-Error", async () => { - const mockToken = "a".repeat(40); - (isCancel as unknown as ReturnType).mockReturnValue( - false, - ); - const mockPrompts = await import("@clack/prompts"); - ( - mockPrompts.password as unknown as ReturnType - ).mockResolvedValue(mockToken); - - const mockCloudflare = await import("../../lib/cloudflare.js"); - vi.mocked( - mockCloudflare.CloudflareClient.validateToken, - ).mockRejectedValue("string error"); - - await expect(promptApiToken()).rejects.toThrow( - "Failed to validate token. Please check your internet connection.", - ); - }); - }); - describe("promptDeploy", () => { it("should return true when user confirms", async () => { const mockPrompts = await import("@clack/prompts"); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 4c62efbe..a98bc3eb 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -4,7 +4,6 @@ import os from "node:os"; import { intro, - password, text, log, confirm, @@ -31,6 +30,7 @@ import { getPackageSnippet, CLI_COLORS, promptForPassword, + promptApiToken, } from "../lib/ui.js"; import { generateJWTSecret, generatePasswordHash } from "../lib/auth.js"; @@ -42,57 +42,6 @@ export function bail() { process.exit(0); } -export async function promptApiToken(): Promise { - const cfApiToken = await password({ - message: "Enter your Cloudflare API Token", - mask: "*", - validate: (value) => { - if (typeof value !== "string" || value.length === 0) { - return "API token is required"; - } - - if (value.length < 40) { - return "Token appears to be too short"; - } - - return undefined; - }, - }); - - if (isCancel(cfApiToken)) { - bail(); - } - - if (typeof cfApiToken !== "string" || cfApiToken.length === 0) { - throw new Error("API token is required"); - } - - const s = spinner(); - s.start("Validating token..."); - - try { - const result = await CloudflareClient.validateToken(cfApiToken); - s.stop("Token Validated"); - - if (!result.valid) { - throw new Error( - result.error || - "Invalid token or insufficient permissions. Please verify your token has 'Account Analytics: Read' permission.", - ); - } - } catch (error) { - s.stop(); - if (error instanceof Error) { - throw error; - } - throw new Error( - "Failed to validate token. Please check your internet connection.", - ); - } - - return cfApiToken; -} - export async function promptPasswordProtection(): Promise { const enableAuth = await confirm({ message: "Do you want to protect your dashboard with a password?", @@ -331,18 +280,25 @@ Your token needs these permissions: } } - if (!secrets?.CF_AUTH_ENABLED || !secrets?.CF_PASSWORD_HASH || !secrets?.CF_JWT_SECRET) { + if ( + !secrets?.CF_AUTH_ENABLED || + !secrets?.CF_PASSWORD_HASH || + !secrets?.CF_JWT_SECRET + ) { const enableAuth = await promptPasswordProtection(); - + const s = spinner(); s.start(`Setting CounterScale Authentication Settings ...`); - + if (enableAuth) { // If auth is enabled, prompt for password and set all required secrets - const appPassword = await promptForPassword("Enter the password you will use to access the Counterscale Dashboard"); + const appPassword = await promptForPassword( + "Enter the password you will use to access the Counterscale Dashboard", + ); if (appPassword) { const jwtSecret = generateJWTSecret(); - const passwordHash = await generatePasswordHash(appPassword); + const passwordHash = + await generatePasswordHash(appPassword); if ( await cloudflare.setCloudflareSecrets({ diff --git a/packages/cli/src/lib/__tests__/ui.test.ts b/packages/cli/src/lib/__tests__/ui.test.ts index 11e48e5c..30c717a2 100644 --- a/packages/cli/src/lib/__tests__/ui.test.ts +++ b/packages/cli/src/lib/__tests__/ui.test.ts @@ -1,13 +1,34 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import type { PasswordOptions } from "@clack/prompts"; -import { getTitle, getScriptSnippet, getPackageSnippet, promptForPassword } from "../ui.js"; +import { + getTitle, + getScriptSnippet, + getPackageSnippet, + promptForPassword, + promptApiToken, +} from "../ui.js"; vi.mock("@clack/prompts", () => ({ isCancel: vi.fn(), cancel: vi.fn(), password: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + })), })); +vi.mock("../cloudflare.js", () => { + const mockValidateToken = vi.fn(); + const MockCloudflareClient = vi.fn().mockImplementation(() => ({})) as any; + + MockCloudflareClient.validateToken = mockValidateToken; + + return { + CloudflareClient: MockCloudflareClient, + }; +}); + function stripAnsiEscapeCodes(str: string) { return str.replace( // https://stackoverflow.com/questions/25245716/remove-all-ansi-colors-styles-from-strings @@ -194,4 +215,119 @@ describe("UI module", () => { } }); }); + + describe("promptApiToken", () => { + let mockSpinner: { + start: ReturnType; + stop: ReturnType; + }; + + beforeEach(async () => { + // Set NODE_ENV to test to avoid process.exit calls + process.env.NODE_ENV = "test"; + + mockSpinner = { + start: vi.fn(), + stop: vi.fn(), + }; + const mockPrompts = await import("@clack/prompts"); + ( + mockPrompts.spinner as unknown as ReturnType + ).mockReturnValue(mockSpinner); + }); + + it("should return valid API token when validation passes", async () => { + const mockToken = "a".repeat(40); + (isCancel as unknown as ReturnType).mockReturnValue( + false, + ); + const mockPrompts = await import("@clack/prompts"); + ( + mockPrompts.password as unknown as ReturnType + ).mockResolvedValue(mockToken); + + const mockCloudflare = await import("../cloudflare.js"); + vi.mocked( + mockCloudflare.CloudflareClient.validateToken, + ).mockResolvedValue({ valid: true }); + + const result = await promptApiToken(); + expect(result).toBe(mockToken); + }); + + it("should throw error if user cancels", async () => { + (isCancel as unknown as ReturnType).mockReturnValue( + true, + ); + const mockPrompts = await import("@clack/prompts"); + ( + mockPrompts.password as unknown as ReturnType + ).mockResolvedValue("token"); + + await expect(promptApiToken()).rejects.toThrow( + "Operation canceled", + ); + }); + + it("should throw error if token validation fails", async () => { + const mockToken = "a".repeat(40); + (isCancel as unknown as ReturnType).mockReturnValue( + false, + ); + const mockPrompts = await import("@clack/prompts"); + ( + mockPrompts.password as unknown as ReturnType + ).mockResolvedValue(mockToken); + + const mockCloudflare = await import("../cloudflare.js"); + vi.mocked( + mockCloudflare.CloudflareClient.validateToken, + ).mockResolvedValue({ + valid: false, + error: "Invalid token or insufficient permissions", + }); + + await expect(promptApiToken()).rejects.toThrow( + "Invalid token or insufficient permissions", + ); + }); + + it("should throw error if validation throws", async () => { + const mockToken = "a".repeat(40); + (isCancel as unknown as ReturnType).mockReturnValue( + false, + ); + const mockPrompts = await import("@clack/prompts"); + ( + mockPrompts.password as unknown as ReturnType + ).mockResolvedValue(mockToken); + + const mockCloudflare = await import("../cloudflare.js"); + vi.mocked( + mockCloudflare.CloudflareClient.validateToken, + ).mockRejectedValue(new Error("Network error")); + + await expect(promptApiToken()).rejects.toThrow("Network error"); + }); + + it("should throw generic error if validation throws non-Error", async () => { + const mockToken = "a".repeat(40); + (isCancel as unknown as ReturnType).mockReturnValue( + false, + ); + const mockPrompts = await import("@clack/prompts"); + ( + mockPrompts.password as unknown as ReturnType + ).mockResolvedValue(mockToken); + + const mockCloudflare = await import("../cloudflare.js"); + vi.mocked( + mockCloudflare.CloudflareClient.validateToken, + ).mockRejectedValue("string error"); + + await expect(promptApiToken()).rejects.toThrow( + "Failed to validate token. Please check your internet connection.", + ); + }); + }); }); diff --git a/packages/cli/src/lib/ui.ts b/packages/cli/src/lib/ui.ts index 23f0166a..1ca7583b 100644 --- a/packages/cli/src/lib/ui.ts +++ b/packages/cli/src/lib/ui.ts @@ -1,7 +1,8 @@ import figlet from "figlet"; import chalk from "chalk"; import { highlight } from "cli-highlight"; -import { password, isCancel, cancel } from "@clack/prompts"; +import { password, isCancel, cancel, spinner } from "@clack/prompts"; +import { CloudflareClient } from "./cloudflare.js"; export const CLI_COLORS: Record = { orange: [245, 107, 61], @@ -72,7 +73,9 @@ Counterscale.init({ export const MIN_PASSWORD_LENGTH = 8; -export async function promptForPassword(message: string = "Enter a password for authentication:"): Promise { +export async function promptForPassword( + message: string = "Enter a password for authentication:", +): Promise { const userPassword = await password({ message, mask: "*", @@ -97,3 +100,58 @@ export async function promptForPassword(message: string = "Enter a password for return userPassword; } + +export async function promptApiToken(): Promise { + const cfApiToken = await password({ + message: "Enter your Cloudflare API Token", + mask: "*", + validate: (value) => { + if (typeof value !== "string" || value.length === 0) { + return "API token is required"; + } + + if (value.length < 40) { + return "Token appears to be too short"; + } + + return undefined; + }, + }); + + if (isCancel(cfApiToken)) { + cancel("Operation canceled."); + if (process.env.NODE_ENV === "test") { + throw new Error("Operation canceled"); + } + process.exit(0); + } + + if (typeof cfApiToken !== "string" || cfApiToken.length === 0) { + throw new Error("API token is required"); + } + + const s = spinner(); + s.start("Validating token..."); + + try { + const result = await CloudflareClient.validateToken(cfApiToken); + s.stop("Token Validated"); + + if (!result.valid) { + throw new Error( + result.error || + "Invalid token or insufficient permissions. Please verify your token has 'Account Analytics: Read' permission.", + ); + } + } catch (error) { + s.stop(); + if (error instanceof Error) { + throw error; + } + throw new Error( + "Failed to validate token. Please check your internet connection.", + ); + } + + return cfApiToken; +} From c2faed329c9f5253a31378be1d36183843fead1a Mon Sep 17 00:00:00 2001 From: stordahl Date: Sun, 26 Oct 2025 13:02:13 -0500 Subject: [PATCH 2/7] feat: add env cli command --- packages/cli/src/commands/env.ts | 94 ++++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 19 ++++++- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/env.ts diff --git a/packages/cli/src/commands/env.ts b/packages/cli/src/commands/env.ts new file mode 100644 index 00000000..b1cfdc3f --- /dev/null +++ b/packages/cli/src/commands/env.ts @@ -0,0 +1,94 @@ +import { CloudflareClient } from "../lib/cloudflare.js"; +import { getServerPkgDir } from "../lib/config.js"; +import { promptApiToken } from "../lib/ui.js"; +import { select, isCancel, cancel } from "@clack/prompts"; +import path from "path"; + +type SupportedSecret = "CF_BEARER_TOKEN"; + +interface SecretConfig { + key: SupportedSecret; + name: string; + description: string; + prompt: () => Promise; +} + +export const SECRETS_BY_ALIAS = new Map([ + [ + "token", + { + key: "CF_BEARER_TOKEN", + name: "Cloudflare API Token", + description: "Token used to authenticate with Cloudflare API", + prompt: promptApiToken, + }, + ], +]); + +export async function envCommand(secretKey?: string) { + const serverPkgDir = getServerPkgDir(); + const configPath = path.join(serverPkgDir, "wrangler.json"); + const cloudflare = new CloudflareClient(configPath); + + try { + let selectedSecret: SecretConfig; + + if (secretKey) { + const matchingSecret = SECRETS_BY_ALIAS.get(secretKey); + + if (!matchingSecret) { + console.error(`❌ Unknown secret: ${secretKey}`); + console.log("Supported secrets:"); + SECRETS_BY_ALIAS.forEach((secret, alias) => { + console.log(` - ${secret.name} (alias: ${alias})`); + }); + process.exit(1); + } + selectedSecret = matchingSecret; + } else { + const selection = await select({ + message: "Which secret would you like to update?", + options: Array.from(SECRETS_BY_ALIAS.entries()).map( + ([alias, secret]) => ({ + value: alias, + label: `${secret.name} (${alias})`, + hint: secret.description, + }), + ), + }); + + if (isCancel(selection)) { + cancel("Operation canceled."); + process.exit(0); + } + + const selectedSecretConfig = SECRETS_BY_ALIAS.get( + selection as string, + ); + if (!selectedSecretConfig) { + throw new Error( + `Secret configuration not found for selection: ${selection}`, + ); + } + selectedSecret = selectedSecretConfig; + } + + console.log(`Updating ${selectedSecret.name}...`); + + const secretValue = await selectedSecret.prompt(); + + const success = await cloudflare.setCloudflareSecrets({ + [selectedSecret.key]: secretValue, + }); + + if (success) { + console.log(`✅ ${selectedSecret.name} updated successfully!`); + } else { + console.error(`❌ Failed to update ${selectedSecret.name}`); + process.exit(1); + } + } catch (error) { + console.error("❌ Error updating secret:", error); + process.exit(1); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 58973cb6..4ea620f3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import { getTitle } from "./lib/ui.js"; import { getServerPkgDir } from "./lib/config.js"; import { install } from "./commands/install.js"; import { enableAuth, disableAuth, updatePassword } from "./commands/auth.js"; +import { envCommand, SECRETS_BY_ALIAS } from "./commands/env.js"; import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; @@ -90,7 +91,23 @@ const parser = yargs(hideBin(process.argv)) .help(); authYargs.showHelp(); } - } + }, + ) + .command( + "env [secret]", + "Update environment secrets", + (yargs) => { + const supportedSecrets = Array.from(SECRETS_BY_ALIAS.keys()).join( + ", ", + ); + yargs.positional("secret", { + describe: `The secret to update. Supported: ${supportedSecrets}`, + type: "string", + }); + }, + async (argv) => { + await envCommand(argv.secret as string | undefined); + }, ) .options({ verbose: { From 882d33eda28b276fa9a6adbcf9f25d0c2060c1c1 Mon Sep 17 00:00:00 2001 From: stordahl Date: Sun, 26 Oct 2025 13:32:26 -0500 Subject: [PATCH 3/7] feat: add tracker-script to env command --- packages/cli/src/commands/env.ts | 26 +++++++++++++++-- packages/cli/src/lib/ui.ts | 49 ++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/env.ts b/packages/cli/src/commands/env.ts index b1cfdc3f..a1bf345f 100644 --- a/packages/cli/src/commands/env.ts +++ b/packages/cli/src/commands/env.ts @@ -1,10 +1,14 @@ import { CloudflareClient } from "../lib/cloudflare.js"; import { getServerPkgDir } from "../lib/config.js"; -import { promptApiToken } from "../lib/ui.js"; +import { + promptApiToken, + promptTrackerScriptName, + getScriptSnippet, +} from "../lib/ui.js"; import { select, isCancel, cancel } from "@clack/prompts"; import path from "path"; -type SupportedSecret = "CF_BEARER_TOKEN"; +type SupportedSecret = "CF_BEARER_TOKEN" | "CF_TRACKER_SCRIPT_NAME"; interface SecretConfig { key: SupportedSecret; @@ -23,6 +27,16 @@ export const SECRETS_BY_ALIAS = new Map([ prompt: promptApiToken, }, ], + [ + "tracker-script", + { + key: "CF_TRACKER_SCRIPT_NAME", + name: "Tracker Script Name", + description: + "Custom name for the tracker.js file served from the server", + prompt: promptTrackerScriptName, + }, + ], ]); export async function envCommand(secretKey?: string) { @@ -83,6 +97,14 @@ export async function envCommand(secretKey?: string) { if (success) { console.log(`✅ ${selectedSecret.name} updated successfully!`); + + // Show HTML snippet for tracker script configuration + if (selectedSecret.key === "CF_TRACKER_SCRIPT_NAME") { + console.log( + "\n📝 Use this HTML snippet in your website (replace YOUR_DEPLOY_URL with your actual deployment URL):", + ); + console.log(getScriptSnippet("YOUR_DEPLOY_URL", secretValue)); + } } else { console.error(`❌ Failed to update ${selectedSecret.name}`); process.exit(1); diff --git a/packages/cli/src/lib/ui.ts b/packages/cli/src/lib/ui.ts index 1ca7583b..68c6f014 100644 --- a/packages/cli/src/lib/ui.ts +++ b/packages/cli/src/lib/ui.ts @@ -1,7 +1,7 @@ import figlet from "figlet"; import chalk from "chalk"; import { highlight } from "cli-highlight"; -import { password, isCancel, cancel, spinner } from "@clack/prompts"; +import { password, text, isCancel, cancel, spinner } from "@clack/prompts"; import { CloudflareClient } from "./cloudflare.js"; export const CLI_COLORS: Record = { @@ -40,13 +40,16 @@ export function getTitle( return `${title}\n${subtitle}`; } -export function getScriptSnippet(deployUrl: string) { +export function getScriptSnippet( + deployUrl: string, + scriptName: string = "tracker", +) { return highlight( ` `, { language: "html", theme: highlightTheme }, @@ -155,3 +158,43 @@ export async function promptApiToken(): Promise { return cfApiToken; } + +export async function promptTrackerScriptName(): Promise { + const scriptName = await text({ + message: + "Enter the tracker script name (e.g., tracker, analytics, counter, etc):", + placeholder: "tracker", + defaultValue: "tracker", + validate: (value) => { + if (typeof value !== "string" || value.length === 0) { + return "Script name is required"; + } + + // Check for invalid filename characters + const invalidChars = /[<>:"/\\|?*]/; + if (invalidChars.test(value)) { + return "Script name contains invalid characters"; + } + + if (value.length > 100) { + return "Script name is too long (max 100 characters)"; + } + + return undefined; + }, + }); + + if (isCancel(scriptName)) { + cancel("Operation canceled."); + if (process.env.NODE_ENV === "test") { + throw new Error("Operation canceled"); + } + process.exit(0); + } + + if (typeof scriptName !== "string" || scriptName.length === 0) { + throw new Error("Script name is required"); + } + + return scriptName; +} From c1ee0a2d508b0385a83a5fb177df542f7b6ab9f6 Mon Sep 17 00:00:00 2001 From: stordahl Date: Wed, 29 Oct 2025 16:15:41 -0500 Subject: [PATCH 4/7] feat: allow script name to be configured via secret --- packages/server/app/routes/$script.ts | 33 ++++ .../app/routes/__tests__/$script.test.tsx | 160 ++++++++++++++++++ packages/server/worker-configuration.d.ts | 2 + packages/server/wrangler.json | 1 + 4 files changed, 196 insertions(+) create mode 100644 packages/server/app/routes/$script.ts create mode 100644 packages/server/app/routes/__tests__/$script.test.tsx diff --git a/packages/server/app/routes/$script.ts b/packages/server/app/routes/$script.ts new file mode 100644 index 00000000..997e24c5 --- /dev/null +++ b/packages/server/app/routes/$script.ts @@ -0,0 +1,33 @@ +import type { LoaderFunctionArgs } from "react-router"; + +export async function loader({ params, context, request }: LoaderFunctionArgs) { + const requestedScript = params.script; + + if (!requestedScript || !requestedScript.endsWith(".js")) { + return new Response("Not Found", { status: 404 }); + } + + const customScriptName = context.cloudflare.env.CF_TRACKER_SCRIPT_NAME; + const defaultScriptName = "tracker"; + + // Extract the base name without extension for comparison + const requestedBaseName = requestedScript.replace(".js", ""); + + // Check if requested script matches either default or custom name + const isDefaultScript = requestedBaseName === defaultScriptName; + const isCustomScript = + customScriptName && requestedBaseName === customScriptName; + + if (!isDefaultScript && !isCustomScript) { + return new Response("Script not found", { status: 404 }); + } + + try { + const url = new URL(request.url); + const trackerUrl = `${url.protocol}//${url.host}/tracker.js`; + return await context.cloudflare.env.ASSETS.fetch(trackerUrl) + } catch (error) { + console.error("Error serving tracker script:", error); + return new Response("Error serving script", { status: 500 }); + } +} diff --git a/packages/server/app/routes/__tests__/$script.test.tsx b/packages/server/app/routes/__tests__/$script.test.tsx new file mode 100644 index 00000000..1abdfa73 --- /dev/null +++ b/packages/server/app/routes/__tests__/$script.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { loader } from "../$script"; + +// Mock fetch globally +global.fetch = vi.fn(); + +describe("Dynamic script route", () => { + const mockRequest = { + url: "https://example.com/analytics.js", + } as Request; + + const createMockContext = (customScriptName?: string) => ({ + cloudflare: { + env: { + CF_TRACKER_SCRIPT_NAME: customScriptName, + }, + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("loader", () => { + it("should return 404 for non-JS files", async () => { + const response = await loader({ + params: { script: "test.txt" }, + context: createMockContext(), + request: mockRequest, + } as any); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Not Found"); + }); + + it("should return 404 for undefined script param", async () => { + const response = await loader({ + params: { script: undefined }, + context: createMockContext(), + request: mockRequest, + } as any); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Not Found"); + }); + + it("should serve default tracker.js", async () => { + const mockTrackerContent = "console.log('tracker script');"; + (global.fetch as any).mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockTrackerContent), + }); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext(), + request: mockRequest, + } as any); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(mockTrackerContent); + expect(response.headers.get("Content-Type")).toBe( + "application/javascript", + ); + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/tracker.js", + ); + }); + + it("should serve custom script name when env variable is set", async () => { + const mockTrackerContent = "console.log('custom script');"; + (global.fetch as any).mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockTrackerContent), + }); + + const response = await loader({ + params: { script: "analytics.js" }, + context: createMockContext("analytics"), + request: mockRequest, + } as any); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(mockTrackerContent); + expect(response.headers.get("Content-Type")).toBe( + "application/javascript", + ); + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/tracker.js", + ); + }); + + it("should return 404 for unmatched script names", async () => { + const response = await loader({ + params: { script: "unknown.js" }, + context: createMockContext("analytics"), + request: mockRequest, + } as any); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Script not found"); + }); + + it("should handle fetch errors gracefully", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 404, + }); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext(), + request: mockRequest, + } as any); + + expect(response.status).toBe(500); + expect(await response.text()).toBe("Error serving script"); + }); + + it("should handle network errors", async () => { + (global.fetch as any).mockRejectedValue(new Error("Network error")); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext(), + request: mockRequest, + } as any); + + expect(response.status).toBe(500); + expect(await response.text()).toBe("Error serving script"); + }); + + it("should set correct headers", async () => { + const mockTrackerContent = "console.log('tracker script');"; + (global.fetch as any).mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockTrackerContent), + }); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext(), + request: mockRequest, + } as any); + + expect(response.headers.get("Content-Type")).toBe( + "application/javascript", + ); + expect(response.headers.get("Cache-Control")).toBe( + "public, max-age=3600", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "*", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "GET", + ); + }); + }); +}); diff --git a/packages/server/worker-configuration.d.ts b/packages/server/worker-configuration.d.ts index 957d8efe..ef8bc5d6 100644 --- a/packages/server/worker-configuration.d.ts +++ b/packages/server/worker-configuration.d.ts @@ -7,5 +7,7 @@ interface Env { CF_PASSWORD_HASH: string; CF_JWT_SECRET: string; CF_AUTH_ENABLED: string; + CF_TRACKER_SCRIPT_NAME?: string; WEB_COUNTER_AE: AnalyticsEngineDataset; + ASSETS: Fetcher; } diff --git a/packages/server/wrangler.json b/packages/server/wrangler.json index f8b693c1..03170c2a 100644 --- a/packages/server/wrangler.json +++ b/packages/server/wrangler.json @@ -6,6 +6,7 @@ ], "compatibility_date": "2024-12-13", "assets": { + "binding": "ASSETS", "directory": "./build/client" }, "analytics_engine_datasets": [ From e00c212ce2d3713a4465fb36ae03ff0e44bc7644 Mon Sep 17 00:00:00 2001 From: stordahl Date: Wed, 29 Oct 2025 16:40:55 -0500 Subject: [PATCH 5/7] fix: refactor tests to expect asset binding fetch --- .../app/routes/__tests__/$script.test.tsx | 83 +++++++++---------- 1 file changed, 40 insertions(+), 43 deletions(-) diff --git a/packages/server/app/routes/__tests__/$script.test.tsx b/packages/server/app/routes/__tests__/$script.test.tsx index 1abdfa73..6876c114 100644 --- a/packages/server/app/routes/__tests__/$script.test.tsx +++ b/packages/server/app/routes/__tests__/$script.test.tsx @@ -1,24 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { loader } from "../$script"; -// Mock fetch globally -global.fetch = vi.fn(); - describe("Dynamic script route", () => { const mockRequest = { url: "https://example.com/analytics.js", } as Request; + const mockAssetsFetch = vi.fn(); + const createMockContext = (customScriptName?: string) => ({ cloudflare: { env: { CF_TRACKER_SCRIPT_NAME: customScriptName, + ASSETS: { + fetch: mockAssetsFetch, + }, }, }, }); beforeEach(() => { vi.clearAllMocks(); + mockAssetsFetch.mockClear(); }); describe("loader", () => { @@ -45,11 +48,14 @@ describe("Dynamic script route", () => { }); it("should serve default tracker.js", async () => { - const mockTrackerContent = "console.log('tracker script');"; - (global.fetch as any).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(mockTrackerContent), - }); + const mockResponse = new Response( + "console.log('tracker script');", + { + status: 200, + headers: { "Content-Type": "application/javascript" }, + }, + ); + mockAssetsFetch.mockResolvedValue(mockResponse); const response = await loader({ params: { script: "tracker.js" }, @@ -58,21 +64,20 @@ describe("Dynamic script route", () => { } as any); expect(response.status).toBe(200); - expect(await response.text()).toBe(mockTrackerContent); - expect(response.headers.get("Content-Type")).toBe( - "application/javascript", + expect(await response.text()).toBe( + "console.log('tracker script');", ); - expect(global.fetch).toHaveBeenCalledWith( + expect(mockAssetsFetch).toHaveBeenCalledWith( "https://example.com/tracker.js", ); }); it("should serve custom script name when env variable is set", async () => { - const mockTrackerContent = "console.log('custom script');"; - (global.fetch as any).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(mockTrackerContent), + const mockResponse = new Response("console.log('custom script');", { + status: 200, + headers: { "Content-Type": "application/javascript" }, }); + mockAssetsFetch.mockResolvedValue(mockResponse); const response = await loader({ params: { script: "analytics.js" }, @@ -81,11 +86,8 @@ describe("Dynamic script route", () => { } as any); expect(response.status).toBe(200); - expect(await response.text()).toBe(mockTrackerContent); - expect(response.headers.get("Content-Type")).toBe( - "application/javascript", - ); - expect(global.fetch).toHaveBeenCalledWith( + expect(await response.text()).toBe("console.log('custom script');"); + expect(mockAssetsFetch).toHaveBeenCalledWith( "https://example.com/tracker.js", ); }); @@ -102,10 +104,7 @@ describe("Dynamic script route", () => { }); it("should handle fetch errors gracefully", async () => { - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 404, - }); + mockAssetsFetch.mockRejectedValue(new Error("Fetch failed")); const response = await loader({ params: { script: "tracker.js" }, @@ -118,7 +117,7 @@ describe("Dynamic script route", () => { }); it("should handle network errors", async () => { - (global.fetch as any).mockRejectedValue(new Error("Network error")); + mockAssetsFetch.mockRejectedValue(new Error("Network error")); const response = await loader({ params: { script: "tracker.js" }, @@ -130,12 +129,18 @@ describe("Dynamic script route", () => { expect(await response.text()).toBe("Error serving script"); }); - it("should set correct headers", async () => { - const mockTrackerContent = "console.log('tracker script');"; - (global.fetch as any).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(mockTrackerContent), - }); + it("should return response from ASSETS fetch", async () => { + const mockResponse = new Response( + "console.log('tracker script');", + { + status: 200, + headers: { + "Content-Type": "application/javascript", + "Cache-Control": "public, max-age=3600", + }, + }, + ); + mockAssetsFetch.mockResolvedValue(mockResponse); const response = await loader({ params: { script: "tracker.js" }, @@ -143,17 +148,9 @@ describe("Dynamic script route", () => { request: mockRequest, } as any); - expect(response.headers.get("Content-Type")).toBe( - "application/javascript", - ); - expect(response.headers.get("Cache-Control")).toBe( - "public, max-age=3600", - ); - expect(response.headers.get("Access-Control-Allow-Origin")).toBe( - "*", - ); - expect(response.headers.get("Access-Control-Allow-Methods")).toBe( - "GET", + expect(response).toBe(mockResponse); + expect(mockAssetsFetch).toHaveBeenCalledWith( + "https://example.com/tracker.js", ); }); }); From cdd6aa0dcfc519a6c6bccb025dc81e51eea37004 Mon Sep 17 00:00:00 2001 From: stordahl Date: Mon, 17 Nov 2025 14:33:41 -0600 Subject: [PATCH 6/7] add test file for env command --- .../cli/src/commands/__tests__/env.test.ts | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 packages/cli/src/commands/__tests__/env.test.ts diff --git a/packages/cli/src/commands/__tests__/env.test.ts b/packages/cli/src/commands/__tests__/env.test.ts new file mode 100644 index 00000000..b3e36d76 --- /dev/null +++ b/packages/cli/src/commands/__tests__/env.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { envCommand, SECRETS_BY_ALIAS } from "../env.js"; +import { CloudflareClient } from "../../lib/cloudflare.js"; +import { getServerPkgDir } from "../../lib/config.js"; +import { select, isCancel, cancel } from "@clack/prompts"; +import { + promptApiToken, + promptTrackerScriptName, + getScriptSnippet, +} from "../../lib/ui.js"; + +// Mock dependencies +vi.mock("../../lib/cloudflare.js"); +vi.mock("../../lib/config.js"); +vi.mock("@clack/prompts"); +vi.mock("../../lib/ui.js"); +vi.mock("path"); + +describe("env.ts", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getServerPkgDir).mockReturnValue("/mock/server/dir"); + vi.mocked(CloudflareClient).mockImplementation( + () => + ({ + setCloudflareSecrets: vi.fn().mockResolvedValue(true), + }) as any, + ); + }); + + describe("SECRETS_BY_ALIAS", () => { + it("should contain token secret configuration", () => { + const tokenSecret = SECRETS_BY_ALIAS.get("token"); + expect(tokenSecret).toBeDefined(); + expect(tokenSecret?.key).toBe("CF_BEARER_TOKEN"); + expect(tokenSecret?.name).toBe("Cloudflare API Token"); + expect(tokenSecret?.description).toBe( + "Token used to authenticate with Cloudflare API", + ); + expect(tokenSecret?.prompt).toBe(promptApiToken); + }); + + it("should contain tracker-script secret configuration", () => { + const trackerSecret = SECRETS_BY_ALIAS.get("tracker-script"); + expect(trackerSecret).toBeDefined(); + expect(trackerSecret?.key).toBe("CF_TRACKER_SCRIPT_NAME"); + expect(trackerSecret?.name).toBe("Tracker Script Name"); + expect(trackerSecret?.description).toBe( + "Custom name for the tracker.js file served from the server", + ); + expect(trackerSecret?.prompt).toBe(promptTrackerScriptName); + }); + }); + + describe("envCommand", () => { + it("should update secret when valid secret key is provided", async () => { + vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); + const mockSetSecrets = vi.fn().mockResolvedValue(true); + vi.mocked(CloudflareClient).mockImplementation( + () => + ({ + setCloudflareSecrets: mockSetSecrets, + }) as any, + ); + + await envCommand("token"); + + expect(mockSetSecrets).toHaveBeenCalledWith({ + CF_BEARER_TOKEN: "mock-api-token", + }); + }); + + it("should show script snippet when updating tracker script", async () => { + vi.mocked(promptTrackerScriptName).mockResolvedValue( + "custom-tracker", + ); + vi.mocked(getScriptSnippet).mockReturnValue("mock-snippet"); + const mockSetSecrets = vi.fn().mockResolvedValue(true); + vi.mocked(CloudflareClient).mockImplementation( + () => + ({ + setCloudflareSecrets: mockSetSecrets, + }) as any, + ); + + const consoleSpy = vi + .spyOn(console, "log") + .mockImplementation(() => {}); + + await envCommand("tracker-script"); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Use this HTML snippet"), + ); + expect(consoleSpy).toHaveBeenCalledWith("mock-snippet"); + + consoleSpy.mockRestore(); + }); + + it("should exit with error when invalid secret key is provided", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const processSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect(envCommand("invalid-secret")).rejects.toThrow( + "process.exit", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "❌ Unknown secret: invalid-secret", + ); + expect(processSpy).toHaveBeenCalledWith(1); + + consoleSpy.mockRestore(); + processSpy.mockRestore(); + }); + + it("should prompt for secret selection when no secret key provided", async () => { + vi.mocked(select).mockResolvedValue("token"); + vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); + const mockSetSecrets = vi.fn().mockResolvedValue(true); + vi.mocked(CloudflareClient).mockImplementation( + () => + ({ + setCloudflareSecrets: mockSetSecrets, + }) as any, + ); + + await envCommand(); + + expect(select).toHaveBeenCalledWith({ + message: "Which secret would you like to update?", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "token", + label: "Cloudflare API Token (token)", + }), + expect.objectContaining({ + value: "tracker-script", + label: "Tracker Script Name (tracker-script)", + }), + ]), + }); + }); + + it("should cancel operation when user cancels selection", async () => { + vi.mocked(select).mockResolvedValue(undefined); + vi.mocked(isCancel).mockReturnValue(true); + const processSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect(envCommand()).rejects.toThrow("process.exit"); + + expect(cancel).toHaveBeenCalledWith("Operation canceled."); + expect(processSpy).toHaveBeenCalledWith(0); + + processSpy.mockRestore(); + }); + + it("should handle secret update failure", async () => { + vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); + const mockSetSecrets = vi.fn().mockResolvedValue(false); + vi.mocked(CloudflareClient).mockImplementation( + () => + ({ + setCloudflareSecrets: mockSetSecrets, + }) as any, + ); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const processSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect(envCommand("token")).rejects.toThrow("process.exit"); + + expect(consoleSpy).toHaveBeenCalledWith( + "❌ Failed to update Cloudflare API Token", + ); + expect(processSpy).toHaveBeenCalledWith(1); + + consoleSpy.mockRestore(); + processSpy.mockRestore(); + }); + + it("should handle errors during secret update", async () => { + vi.mocked(promptApiToken).mockRejectedValue( + new Error("Prompt error"), + ); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const processSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect(envCommand("token")).rejects.toThrow("process.exit"); + + expect(consoleSpy).toHaveBeenCalledWith( + "❌ Error updating secret:", + expect.any(Error), + ); + expect(processSpy).toHaveBeenCalledWith(1); + + consoleSpy.mockRestore(); + processSpy.mockRestore(); + }); + + it("should throw error when secret configuration not found", async () => { + vi.mocked(select).mockResolvedValue("nonexistent"); + vi.mocked(isCancel).mockReturnValue(false); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect(envCommand()).rejects.toThrow("process.exit"); + }); + }); +}); From c3bd5b2d24081b815a87e888bb22e991f8935e7f Mon Sep 17 00:00:00 2001 From: stordahl Date: Mon, 17 Nov 2025 18:51:14 -0600 Subject: [PATCH 7/7] refactor env test file for new vitest version --- .../cli/src/commands/__tests__/env.test.ts | 59 +++++++++---------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/commands/__tests__/env.test.ts b/packages/cli/src/commands/__tests__/env.test.ts index b3e36d76..974428c1 100644 --- a/packages/cli/src/commands/__tests__/env.test.ts +++ b/packages/cli/src/commands/__tests__/env.test.ts @@ -10,7 +10,10 @@ import { } from "../../lib/ui.js"; // Mock dependencies -vi.mock("../../lib/cloudflare.js"); +vi.mock("../../lib/cloudflare.js", () => ({ + CloudflareClient: vi.fn(), +})); + vi.mock("../../lib/config.js"); vi.mock("@clack/prompts"); vi.mock("../../lib/ui.js"); @@ -20,12 +23,6 @@ describe("env.ts", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getServerPkgDir).mockReturnValue("/mock/server/dir"); - vi.mocked(CloudflareClient).mockImplementation( - () => - ({ - setCloudflareSecrets: vi.fn().mockResolvedValue(true), - }) as any, - ); }); describe("SECRETS_BY_ALIAS", () => { @@ -56,12 +53,12 @@ describe("env.ts", () => { it("should update secret when valid secret key is provided", async () => { vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); const mockSetSecrets = vi.fn().mockResolvedValue(true); - vi.mocked(CloudflareClient).mockImplementation( - () => - ({ - setCloudflareSecrets: mockSetSecrets, - }) as any, - ); + + vi.mocked(CloudflareClient).mockImplementation(function () { + return { + setCloudflareSecrets: mockSetSecrets, + } as any; + }); await envCommand("token"); @@ -76,12 +73,12 @@ describe("env.ts", () => { ); vi.mocked(getScriptSnippet).mockReturnValue("mock-snippet"); const mockSetSecrets = vi.fn().mockResolvedValue(true); - vi.mocked(CloudflareClient).mockImplementation( - () => - ({ - setCloudflareSecrets: mockSetSecrets, - }) as any, - ); + + vi.mocked(CloudflareClient).mockImplementation(function () { + return { + setCloudflareSecrets: mockSetSecrets, + } as any; + }); const consoleSpy = vi .spyOn(console, "log") @@ -124,12 +121,12 @@ describe("env.ts", () => { vi.mocked(select).mockResolvedValue("token"); vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); const mockSetSecrets = vi.fn().mockResolvedValue(true); - vi.mocked(CloudflareClient).mockImplementation( - () => - ({ - setCloudflareSecrets: mockSetSecrets, - }) as any, - ); + + vi.mocked(CloudflareClient).mockImplementation(function () { + return { + setCloudflareSecrets: mockSetSecrets, + } as any; + }); await envCommand(); @@ -168,12 +165,12 @@ describe("env.ts", () => { it("should handle secret update failure", async () => { vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); const mockSetSecrets = vi.fn().mockResolvedValue(false); - vi.mocked(CloudflareClient).mockImplementation( - () => - ({ - setCloudflareSecrets: mockSetSecrets, - }) as any, - ); + + vi.mocked(CloudflareClient).mockImplementation(function () { + return { + setCloudflareSecrets: mockSetSecrets, + } as any; + }); const consoleSpy = vi .spyOn(console, "error")