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
2 changes: 2 additions & 0 deletions .github/scripts/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Shared constants for GitHub Actions scripts */
export const APP_NAME = "cloudflare-worker-app";
3 changes: 2 additions & 1 deletion .github/scripts/preview/create-d1-db.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
#!/usr/bin/env bun

import { $ } from "bun";
import { APP_NAME } from "../common";

const prNumber = process.env.PR_NUMBER;
if (!prNumber) {
console.error("PR_NUMBER environment variable is required");
process.exit(1);
}

const dbName = `cloudflare-worker-app-db-pr-${prNumber}`;
const dbName = `${APP_NAME}-db-pr-${prNumber}`;

// Check if database already exists
try {
Expand Down
3 changes: 2 additions & 1 deletion .github/scripts/preview/delete-d1-db.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
#!/usr/bin/env bun

import { $ } from "bun";
import { APP_NAME } from "../common";

const prNumber = process.env.PR_NUMBER;
if (!prNumber) {
console.error("PR_NUMBER environment variable is required");
process.exit(1);
}

const dbName = `cloudflare-worker-app-db-pr-${prNumber}`;
const dbName = `${APP_NAME}-db-pr-${prNumber}`;

// Check if database exists
try {
Expand Down
20 changes: 20 additions & 0 deletions .github/scripts/preview/delete-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bun

import { $ } from "bun";
import { APP_NAME } from "../common";

const prNumber = process.env.PR_NUMBER;
if (!prNumber) {
console.error("PR_NUMBER environment variable is required");
process.exit(1);
}

const workerName = `${APP_NAME}-pr-${prNumber}`;

try {
console.log(`Deleting worker: ${workerName}`);
await $`bun wrangler delete --name ${workerName} --force`.quiet();
console.log("Worker deleted successfully");
} catch {
console.log("Failed to delete worker, it may not exist");
}
3 changes: 2 additions & 1 deletion .github/scripts/preview/get-d1-db-id.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
#!/usr/bin/env bun

import { $ } from "bun";
import { APP_NAME } from "../common";

const prNumber = process.env.PR_NUMBER;
if (!prNumber) {
console.error("PR_NUMBER environment variable is required");
process.exit(1);
}

const dbName = `cloudflare-worker-app-db-pr-${prNumber}`;
const dbName = `${APP_NAME}-db-pr-${prNumber}`;

// Get database ID
let db: { name: string; uuid: string } | undefined;
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/preview/prepare-wrangler-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bun

export {};
import { APP_NAME } from "../common";

const prNumber = process.env.PR_NUMBER;
const dbId = process.env.DB_ID;
Expand All @@ -11,7 +11,7 @@ if (!prNumber || !dbId || !dbName) {
process.exit(1);
}

const workerName = `cloudflare-worker-app-pr-${prNumber}`;
const workerName = `${APP_NAME}-pr-${prNumber}`;

// Read wrangler.jsonc from worker directory
const configPath = "apps/worker/wrangler.jsonc";
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,12 @@ jobs:
run: bun install

- name: 🗑️ Delete Worker
uses: cloudflare/wrangler-action@v3
continue-on-error: true
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: delete --name "cloudflare-worker-app-pr-${{ github.event.pull_request.number }}" --force
run: bun run .github/scripts/preview/delete-worker.ts
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

- name: 🗑️ Delete D1 Database
continue-on-error: true
Expand Down
71 changes: 38 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,38 +26,43 @@ A minimal full-stack TypeScript template for building apps on Cloudflare Workers

## Template Setup

After creating your app from this template, you need to:

1. **Rename app references** - Replace `cloudflare-worker-app` with your app name everywhere:
- `apps/worker/wrangler.jsonc` - `name` and `database_name`
- `apps/worker/package.json` - npm scripts
- `apps/worker/src/features/auth/better-auth.ts` - `modelName`

2. **Update package names** - Replace `@coding-cowboys/cloudflare-worker-app` with your org/name:
- `package.json` - root name
- `apps/worker/package.json` - worker name
- `apps/web/package.json` - web name
- `apps/web/vite.config.ts` - import alias
- `apps/web/src/routes/__root.tsx` - tRPC import
- `apps/web/src/lib/trpc.ts` - tRPC import
- `packages/util/package.json` - util name
- `bun.lock` - workspace references, auto updates with `bun install`

3. **Update preview scripts** - Replace `cloudflare-worker-app` with your app name:
- `.github/scripts/preview/create-d1-db.ts` - `dbName` variable
- `.github/scripts/preview/get-d1-db-id.ts` - `dbName` variable
- `.github/scripts/preview/delete-d1-db.ts` - `dbName` variable
- `.github/scripts/preview/prepare-wrangler-config.ts` - `workerName` variable
- `.github/workflows/preview.yml` - worker delete command name

4. **Configure GitHub Actions for CI/CD** - Add the following secrets in your GitHub repository settings (Settings > Secrets and variables > Actions):
- `CLOUDFLARE_API_TOKEN` - Create an API token at [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens) with "Edit Cloudflare Workers" permissions
- `CLOUDFLARE_ACCOUNT_ID` - Found in Cloudflare Dashboard > Workers & Pages > Overview (right sidebar)
- `CLOUDFLARE_WORKERS_SUBDOMAIN` - Your workers subdomain (e.g., `your-subdomain` from `your-subdomain.workers.dev`)

These secrets are required for:
- **CI workflow**: Auto-deploy to production on push to `main`
- **Preview workflow**: Create isolated preview environments for each PR
After creating your app from this template:

```bash
# Install dependencies
bun install

# Run the interactive setup CLI
bun run setup
```

The setup script will prompt for:

| Prompt | Example | What it replaces |
| ---------------------------- | --------------- | -------------------------------------------------------------- |
| npm org (without @) | `my-org` | `@coding-cowboys/` in all package.json files, imports, configs |
| App name | `my-app` | `cloudflare-worker-app` in wrangler, scripts, DB names |
| GitHub org | `my-github-org` | `thor-coding-cowboys` in repo URLs |
| Display org name | `My Org` | `Coding Cowboys` in LICENSE, SEO meta tags |
| Display app name | `My App` | `Cloudflare App Template` in SEO, header |
| Cloudflare workers subdomain | `my-subdomain` | `coding-cowboys` in `*.workers.dev` URLs |

Press enter on any prompt to skip it and keep the template default.

After setup:

1. Run `bun install` to update the lockfile
2. Create your D1 database: `bunx wrangler d1 create <your-app-name>`
3. Update the `database_id` in `apps/worker/wrangler.jsonc`
4. Set your auth secret: `bunx wrangler secret put BETTER_AUTH_SECRET`

### Configure GitHub Actions

Add the following secrets in your GitHub repository settings (Settings > Secrets and variables > Actions):

- `CLOUDFLARE_API_TOKEN` - Create an API token at [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens) with "Edit Cloudflare Workers" permissions
- `CLOUDFLARE_ACCOUNT_ID` - Found in Cloudflare Dashboard > Workers & Pages > Overview (right sidebar)
- `CLOUDFLARE_WORKERS_SUBDOMAIN` - Your workers subdomain (e.g., `your-subdomain` from `your-subdomain.workers.dev`)

## Getting Started

Expand Down Expand Up @@ -95,7 +100,7 @@ This template uses a hybrid approach for optimal SEO and user experience:
### Key Files

- `apps/worker/src/features/seo/seo-route.ts` - SSR route handler
- `packages/cloudflare-worker-app-components/src/landing-page.ts` - Shared landing page component
- `packages/components/src/landing-page.ts` - Shared landing page component
- `apps/web/public/robots.txt` - Search engine directives
- `apps/web/public/sitemap.xml` - Site structure for crawlers

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
@import "@fontsource-variable/figtree";

/* Tell Tailwind to scan the shared components package */
@source "../../packages/cloudflare-worker-app-components";
@source "../../packages/components";

@custom-variant dark (&:is(.dark *));

Expand Down
2 changes: 1 addition & 1 deletion apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"paths": {
"@/*": ["./src/*"],
"@coding-cowboys/cloudflare-worker-app-components": [
"../../packages/cloudflare-worker-app-components/src/index.ts",
"../../packages/components/src/index.ts",
],
},
},
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/features/seo/seo-route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import { LandingPage } from "@coding-cowboys/cloudflare-worker-app-components";
import { LandingPage } from "../../../../../packages/components/src";
import type { HonoEnv } from "../context";

// Inlined CSS from the build - this is generated at build time and ensures
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"packageManager": "bun@1.3.8",
"private": true,
"scripts": {
"setup": "bun run scripts/setup.ts",
"build": "turbo run build",
"predev": "bun db:migrate",
"dev": "turbo run dev",
Expand Down
149 changes: 149 additions & 0 deletions scripts/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bun

/**
* Setup script for the Cloudflare App Template.
*
* Prompts for project-specific values and replaces template placeholders
* across all files. Press enter to skip a prompt and keep the template default.
*
* Usage: bun run scripts/setup.ts
*/

import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";
import { createInterface } from "node:readline";

// ── Template defaults (the values baked into the template) ──────────────

const DEFAULTS = {
npmOrg: "coding-cowboys",
appName: "cloudflare-worker-app",
githubOrg: "thor-coding-cowboys",
displayOrg: "Coding Cowboys",
displayApp: "Cloudflare App Template",
cfSubdomain: "coding-cowboys",
} as const;

// ── Prompt helper ───────────────────────────────────────────────────────

const rl = createInterface({ input: process.stdin, output: process.stdout });

function ask(question: string, hint: string): Promise<string> {
return new Promise((resolve) => {
rl.question(`${question} (${hint}, enter to skip): `, (answer) => {
resolve(answer.trim());
});
});
}

// ── File walking ────────────────────────────────────────────────────────

const ROOT = join(import.meta.dir, "..");

const SKIP_DIRS = new Set(["node_modules", ".git", ".db", "dist", ".wrangler", ".tanstack"]);

const SKIP_FILES = new Set(["bun.lock", "setup.ts"]);

function walk(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walk(fullPath));
} else if (entry.isFile()) {
if (SKIP_FILES.has(entry.name)) continue;
results.push(fullPath);
}
}
return results;
}

function isBinary(path: string): boolean {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
return ["png", "jpg", "jpeg", "gif", "ico", "woff", "woff2", "ttf", "eot", "sqlite"].includes(
ext
);
}

// ── Main ────────────────────────────────────────────────────────────────

console.log("\nCloudflare App Template Setup\n");
console.log("This will replace template placeholders with your project values.");
console.log("Press enter to skip a prompt and keep the current value.\n");

const npmOrg = (await ask("npm org (without @)", `current: ${DEFAULTS.npmOrg}`)) || "";
const appName = (await ask("App name", `current: ${DEFAULTS.appName}`)) || "";
const githubOrg = (await ask("GitHub org", `current: ${DEFAULTS.githubOrg}`)) || "";
const displayOrg = (await ask("Display org name", `current: ${DEFAULTS.displayOrg}`)) || "";
const displayApp = (await ask("Display app name", `current: ${DEFAULTS.displayApp}`)) || "";
const cfSubdomain =
(await ask("Cloudflare workers subdomain", `current: ${DEFAULTS.cfSubdomain}`)) || "";

rl.close();

// Build replacement pairs (old -> new), skip unchanged
const replacements: [string, string][] = [];

if (npmOrg) {
replacements.push([`@${DEFAULTS.npmOrg}/`, `@${npmOrg}/`]);
}
if (appName) {
// Must run after npmOrg replacement since app name is a substring of package names
replacements.push([DEFAULTS.appName, appName]);
}
if (githubOrg) {
replacements.push([DEFAULTS.githubOrg, githubOrg]);
}
if (displayOrg) {
replacements.push([DEFAULTS.displayOrg, displayOrg]);
}
if (displayApp) {
replacements.push([DEFAULTS.displayApp, displayApp]);
}
if (cfSubdomain) {
// Replace in workers.dev URLs: <app>.<subdomain>.workers.dev
replacements.push([`${DEFAULTS.cfSubdomain}.workers.dev`, `${cfSubdomain}.workers.dev`]);
}

if (replacements.length === 0) {
console.log("\nNo changes requested. Exiting.");
process.exit(0);
}

console.log("\nApplying replacements:\n");
for (const [from, to] of replacements) {
console.log(` ${from} -> ${to}`);
}
console.log();

const files = walk(ROOT);
let filesChanged = 0;

for (const filePath of files) {
if (isBinary(filePath)) continue;

let content: string;
try {
content = readFileSync(filePath, "utf-8");
} catch {
continue;
}

let updated = content;
for (const [from, to] of replacements) {
updated = updated.split(from).join(to);
}

if (updated !== content) {
writeFileSync(filePath, updated);
filesChanged++;
console.log(` updated: ${relative(ROOT, filePath)}`);
}
}

console.log(`\nDone! Updated ${filesChanged} file(s).`);
console.log("\nNext steps:");
console.log(" 1. Run `bun install` to update the lockfile");
console.log(" 2. Update the D1 database_id in apps/worker/wrangler.jsonc");
console.log(" 3. Run `bun run dev` to verify everything works\n");