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..974428c1 --- /dev/null +++ b/packages/cli/src/commands/__tests__/env.test.ts @@ -0,0 +1,230 @@ +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", () => ({ + CloudflareClient: vi.fn(), +})); + +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"); + }); + + 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(function () { + return { + 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(function () { + return { + 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(function () { + return { + 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(function () { + return { + 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"); + }); + }); +}); diff --git a/packages/cli/src/commands/__tests__/install.test.ts b/packages/cli/src/commands/__tests__/install.test.ts index 7ab536d8..a87c7751 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, @@ -65,118 +64,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/env.ts b/packages/cli/src/commands/env.ts new file mode 100644 index 00000000..a1bf345f --- /dev/null +++ b/packages/cli/src/commands/env.ts @@ -0,0 +1,116 @@ +import { CloudflareClient } from "../lib/cloudflare.js"; +import { getServerPkgDir } from "../lib/config.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" | "CF_TRACKER_SCRIPT_NAME"; + +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, + }, + ], + [ + "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) { + 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!`); + + // 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); + } + } catch (error) { + console.error("❌ Error updating secret:", error); + process.exit(1); + } +} 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/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: { 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..68c6f014 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, text, isCancel, cancel, spinner } from "@clack/prompts"; +import { CloudflareClient } from "./cloudflare.js"; export const CLI_COLORS: Record = { orange: [245, 107, 61], @@ -39,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 }, @@ -72,7 +76,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 +103,98 @@ 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; +} + +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; +} 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..6876c114 --- /dev/null +++ b/packages/server/app/routes/__tests__/$script.test.tsx @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { loader } from "../$script"; + +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", () => { + 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 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" }, + context: createMockContext(), + request: mockRequest, + } as any); + + expect(response.status).toBe(200); + expect(await response.text()).toBe( + "console.log('tracker script');", + ); + expect(mockAssetsFetch).toHaveBeenCalledWith( + "https://example.com/tracker.js", + ); + }); + + it("should serve custom script name when env variable is set", async () => { + 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" }, + context: createMockContext("analytics"), + request: mockRequest, + } as any); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("console.log('custom script');"); + expect(mockAssetsFetch).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 () => { + mockAssetsFetch.mockRejectedValue(new Error("Fetch failed")); + + 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 () => { + mockAssetsFetch.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 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" }, + context: createMockContext(), + request: mockRequest, + } as any); + + expect(response).toBe(mockResponse); + expect(mockAssetsFetch).toHaveBeenCalledWith( + "https://example.com/tracker.js", + ); + }); + }); +}); 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": [