From 4a0e837ba47f727023959d370ab83e18c9013efc Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Tue, 29 Sep 2026 19:57:50 -0700 Subject: [PATCH 1/4] build: add cf recovery deploy for website Manual recovery deployments use a typed Cloudflare configuration for the built Worker and static assets. Production and preview settings are checked against the Wrangler fallback used by the versioned candidate gate and local tests. Co-authored-by: Codex --- .gitignore | 1 + .lvbt/web-platform.json | 6 +- .../examples/with-astro/.claude/settings.json | 2 +- .../with-astro/.github/workflows/deploy.yml | 2 +- .../examples/with-astro/AGENTS.md | 2 +- .../examples/with-astro/README.md | 2 +- .../with-astro/apps/site/package.json | 8 +- .../examples/with-astro/package.json | 4 +- .../examples/with-astro/pnpm-workspace.yaml | 3 +- .../with-vite-react/.claude/settings.json | 2 +- .../.github/workflows/deploy.yml | 2 +- .../examples/with-vite-react/AGENTS.md | 2 +- .../examples/with-vite-react/README.md | 2 +- .../with-vite-react/apps/app/package.json | 8 +- .../examples/with-vite-react/package.json | 4 +- .../with-vite-react/pnpm-workspace.yaml | 3 +- .lvbt/web-platform/packages/cli/catalog.json | 3 +- .lvbt/web-platform/packages/cli/package.json | 2 +- .../packages/cli/platform.schema.json | 10 + .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .lvbt/web-platform/packages/cli/src/cli.mjs | 4 +- .../packages/cli/src/lib/check/contract.mjs | 1 + .../packages/cli/src/lib/operate.mjs | 123 ++++++-- .../cli/src/lib/platform/apply-resources.mjs | 5 +- .../cli/src/lib/platform/apply-steps.mjs | 11 + .../packages/cli/src/lib/platform/apply.mjs | 52 +++- .../packages/cli/src/lib/platform/guides.mjs | 104 +++++-- .../packages/cli/src/lib/platform/index.mjs | 10 +- .../cli/src/lib/platform/manifest.mjs | 27 +- .../packages/cli/src/lib/platform/observe.mjs | 16 +- .../cli/src/lib/platform/plan-cloudflare.mjs | 18 +- .../cli/src/lib/platform/plan-values.mjs | 18 +- .../cli/src/lib/platform/services.mjs | 2 +- .../packages/eslint-config/base.js | 1 + .../packages/eslint-config/package.json | 2 +- .../packages/playwright-config/package.json | 2 +- .../packages/prettier-config/package.json | 2 +- .../packages/typescript-config/package.json | 2 +- .../packages/vitest-config/package.json | 2 +- .../packages/web-platform/package.json | 2 +- AGENTS.md | 2 +- apps/deploy/cloudflare.config.ts | 75 +++++ apps/deploy/eslint.config.js | 3 + apps/deploy/package.json | 20 ++ apps/deploy/tests/config.test.ts | 86 ++++++ apps/deploy/tsconfig.json | 8 + apps/deploy/wrangler.config.ts | 8 + docs/reference/deployment-pipeline.md | 20 +- package.json | 2 +- pnpm-lock.yaml | 266 ++++++++++++++---- pnpm-workspace.yaml | 3 +- 52 files changed, 789 insertions(+), 180 deletions(-) create mode 100644 apps/deploy/cloudflare.config.ts create mode 100644 apps/deploy/eslint.config.js create mode 100644 apps/deploy/package.json create mode 100644 apps/deploy/tests/config.test.ts create mode 100644 apps/deploy/tsconfig.json create mode 100644 apps/deploy/wrangler.config.ts diff --git a/.gitignore b/.gitignore index a8f9c1df..04bad61f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ blob-report/ **/playwright/.cache/ .turbo/ .wrangler/ +.cloudflare/ .env .env.* !.env.example diff --git a/.lvbt/web-platform.json b/.lvbt/web-platform.json index 6a635d44..77729159 100644 --- a/.lvbt/web-platform.json +++ b/.lvbt/web-platform.json @@ -1,9 +1,9 @@ { "formatVersion": 1, "preset": "lvbt-web", - "release": "v0.5.4", - "commit": "323d5f5f968ed69ebcc29252b183fd65d13dc49a", - "contentHash": "25da315541c7a6f94f1ed1712e7b41c7512318af88033a1b3d485515d350ab83", + "release": null, + "commit": "686b622a33c7c42961b6e4bbe14cfa259df104e3", + "contentHash": "18c12261d6c3291ecf8f9de5274ed02bf9f22d8299d5149c40f13ca16bcd2871", "executables": [ "examples/with-astro/.githooks/commit-msg", "examples/with-astro/.githooks/pre-commit", diff --git a/.lvbt/web-platform/examples/with-astro/.claude/settings.json b/.lvbt/web-platform/examples/with-astro/.claude/settings.json index 6b98fe86..294578bf 100644 --- a/.lvbt/web-platform/examples/with-astro/.claude/settings.json +++ b/.lvbt/web-platform/examples/with-astro/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.5.4" + "ref": "v0.6.0" } } }, diff --git a/.lvbt/web-platform/examples/with-astro/.github/workflows/deploy.yml b/.lvbt/web-platform/examples/with-astro/.github/workflows/deploy.yml index 97d169c5..90ba0491 100644 --- a/.lvbt/web-platform/examples/with-astro/.github/workflows/deploy.yml +++ b/.lvbt/web-platform/examples/with-astro/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -# Deploys every app with a wrangler config on each push to main, after the +# Deploys every configured Cloudflare app on each push to main, after the # same check pull requests run. Needs the CLOUDFLARE_API_TOKEN (Workers Scripts # edit) and CLOUDFLARE_ACCOUNT_ID repository secrets. name: Deploy diff --git a/.lvbt/web-platform/examples/with-astro/AGENTS.md b/.lvbt/web-platform/examples/with-astro/AGENTS.md index b127dba0..0a8344b7 100644 --- a/.lvbt/web-platform/examples/with-astro/AGENTS.md +++ b/.lvbt/web-platform/examples/with-astro/AGENTS.md @@ -17,7 +17,7 @@ Every LVBT repository answers to the same commands: | `pnpm check:fix` | Apply formatting and lint fixes | | `pnpm build` | Build every package | | `pnpm test` | Run every package's tests | -| `pnpm run deploy` | Build, then `wrangler deploy` every app (deployable repos) | +| `pnpm run deploy` | Build, then deploy every configured Cloudflare app | | `turbo gen workspace` | Scaffold a new package or app | ## Create GitHub issues and pull requests diff --git a/.lvbt/web-platform/examples/with-astro/README.md b/.lvbt/web-platform/examples/with-astro/README.md index 98140f72..a7871034 100644 --- a/.lvbt/web-platform/examples/with-astro/README.md +++ b/.lvbt/web-platform/examples/with-astro/README.md @@ -25,7 +25,7 @@ repository's boundaries. `src/styles/global.css`, unit tests under `tests/`, end-to-end tests under `tests/e2e/` - `packages/` for libraries the site shares with other apps -`pnpm run deploy` builds and runs `wrangler deploy` for every app with a wrangler config; +`pnpm run deploy` builds and deploys every app with a `cloudflare.config.ts` or Wrangler config; `.github/workflows/deploy.yml` does the same on every push to `main`. Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/package.json b/.lvbt/web-platform/examples/with-astro/apps/site/package.json index 8398cd7e..153e7bf9 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/package.json +++ b/.lvbt/web-platform/examples/with-astro/apps/site/package.json @@ -21,10 +21,10 @@ }, "devDependencies": { "@astrojs/check": "catalog:", - "@lasvegasfortransit/eslint-config": "0.5.4", - "@lasvegasfortransit/playwright-config": "0.5.4", - "@lasvegasfortransit/typescript-config": "0.5.4", - "@lasvegasfortransit/vitest-config": "0.5.4", + "@lasvegasfortransit/eslint-config": "0.6.0", + "@lasvegasfortransit/playwright-config": "0.6.0", + "@lasvegasfortransit/typescript-config": "0.6.0", + "@lasvegasfortransit/vitest-config": "0.6.0", "@playwright/test": "catalog:", "@types/node": "catalog:", "eslint": "catalog:", diff --git a/.lvbt/web-platform/examples/with-astro/package.json b/.lvbt/web-platform/examples/with-astro/package.json index 0dbfd392..3819e56c 100644 --- a/.lvbt/web-platform/examples/with-astro/package.json +++ b/.lvbt/web-platform/examples/with-astro/package.json @@ -23,8 +23,8 @@ "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.5.4", - "@lasvegasfortransit/prettier-config": "0.5.4", + "@lasvegasfortransit/cli": "0.6.0", + "@lasvegasfortransit/prettier-config": "0.6.0", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml b/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml index c4b29cae..e94d737f 100644 --- a/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml +++ b/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml @@ -61,5 +61,6 @@ catalog: typescript-eslint: 8.69.0 vite: 8.2.2 vitest: 4.1.11 - wrangler: 4.127.1 + cf: 1.0.0-beta.5 + wrangler: 4.141.0 zod: 4.5.4 diff --git a/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json b/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json index 6b98fe86..294578bf 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json +++ b/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.5.4" + "ref": "v0.6.0" } } }, diff --git a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/deploy.yml b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/deploy.yml index 97d169c5..90ba0491 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/deploy.yml +++ b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -# Deploys every app with a wrangler config on each push to main, after the +# Deploys every configured Cloudflare app on each push to main, after the # same check pull requests run. Needs the CLOUDFLARE_API_TOKEN (Workers Scripts # edit) and CLOUDFLARE_ACCOUNT_ID repository secrets. name: Deploy diff --git a/.lvbt/web-platform/examples/with-vite-react/AGENTS.md b/.lvbt/web-platform/examples/with-vite-react/AGENTS.md index b127dba0..0a8344b7 100644 --- a/.lvbt/web-platform/examples/with-vite-react/AGENTS.md +++ b/.lvbt/web-platform/examples/with-vite-react/AGENTS.md @@ -17,7 +17,7 @@ Every LVBT repository answers to the same commands: | `pnpm check:fix` | Apply formatting and lint fixes | | `pnpm build` | Build every package | | `pnpm test` | Run every package's tests | -| `pnpm run deploy` | Build, then `wrangler deploy` every app (deployable repos) | +| `pnpm run deploy` | Build, then deploy every configured Cloudflare app | | `turbo gen workspace` | Scaffold a new package or app | ## Create GitHub issues and pull requests diff --git a/.lvbt/web-platform/examples/with-vite-react/README.md b/.lvbt/web-platform/examples/with-vite-react/README.md index 98140f72..a7871034 100644 --- a/.lvbt/web-platform/examples/with-vite-react/README.md +++ b/.lvbt/web-platform/examples/with-vite-react/README.md @@ -25,7 +25,7 @@ repository's boundaries. `src/styles/global.css`, unit tests under `tests/`, end-to-end tests under `tests/e2e/` - `packages/` for libraries the site shares with other apps -`pnpm run deploy` builds and runs `wrangler deploy` for every app with a wrangler config; +`pnpm run deploy` builds and deploys every app with a `cloudflare.config.ts` or Wrangler config; `.github/workflows/deploy.yml` does the same on every push to `main`. Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json b/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json index 1acf9489..6a301e15 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json @@ -17,10 +17,10 @@ "react-dom": "catalog:" }, "devDependencies": { - "@lasvegasfortransit/eslint-config": "0.5.4", - "@lasvegasfortransit/playwright-config": "0.5.4", - "@lasvegasfortransit/typescript-config": "0.5.4", - "@lasvegasfortransit/vitest-config": "0.5.4", + "@lasvegasfortransit/eslint-config": "0.6.0", + "@lasvegasfortransit/playwright-config": "0.6.0", + "@lasvegasfortransit/typescript-config": "0.6.0", + "@lasvegasfortransit/vitest-config": "0.6.0", "@playwright/test": "catalog:", "@tailwindcss/vite": "catalog:", "@types/node": "catalog:", diff --git a/.lvbt/web-platform/examples/with-vite-react/package.json b/.lvbt/web-platform/examples/with-vite-react/package.json index a9a48da1..de29b0b0 100644 --- a/.lvbt/web-platform/examples/with-vite-react/package.json +++ b/.lvbt/web-platform/examples/with-vite-react/package.json @@ -23,8 +23,8 @@ "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.5.4", - "@lasvegasfortransit/prettier-config": "0.5.4", + "@lasvegasfortransit/cli": "0.6.0", + "@lasvegasfortransit/prettier-config": "0.6.0", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml b/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml index c4b29cae..e94d737f 100644 --- a/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml +++ b/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml @@ -61,5 +61,6 @@ catalog: typescript-eslint: 8.69.0 vite: 8.2.2 vitest: 4.1.11 - wrangler: 4.127.1 + cf: 1.0.0-beta.5 + wrangler: 4.141.0 zod: 4.5.4 diff --git a/.lvbt/web-platform/packages/cli/catalog.json b/.lvbt/web-platform/packages/cli/catalog.json index 4940880f..be56126a 100644 --- a/.lvbt/web-platform/packages/cli/catalog.json +++ b/.lvbt/web-platform/packages/cli/catalog.json @@ -38,7 +38,8 @@ "typescript-eslint": "8.69.0", "vite": "8.2.2", "vitest": "4.1.11", - "wrangler": "4.127.1", + "cf": "1.0.0-beta.5", + "wrangler": "4.141.0", "zod": "4.5.4" } } diff --git a/.lvbt/web-platform/packages/cli/package.json b/.lvbt/web-platform/packages/cli/package.json index 447dc572..497fb584 100644 --- a/.lvbt/web-platform/packages/cli/package.json +++ b/.lvbt/web-platform/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/cli", - "version": "0.5.4", + "version": "0.6.0", "description": "The lvbt command every LVBT repository runs for bootstrap, preflight, and deploy, plus the production platform setup, the shared git hooks, and the lvbt-contributions agent plugin.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/cli/platform.schema.json b/.lvbt/web-platform/packages/cli/platform.schema.json index 5cf923b8..7e5f56fb 100644 --- a/.lvbt/web-platform/packages/cli/platform.schema.json +++ b/.lvbt/web-platform/packages/cli/platform.schema.json @@ -107,6 +107,12 @@ "type": "string", "minLength": 1, "description": "The production wrangler config, relative to this file. Defaults to wrangler.jsonc." + }, + "cloudflareConfig": { + "type": "string", + "minLength": 1, + "pattern": "(^|/)cloudflare\\.config\\.ts$", + "description": "The canonical cloudflare.config.ts project config, relative to this file. When set, preflight and deploy use cf instead of Wrangler." } } }, @@ -240,6 +246,10 @@ "enum": ["resend"], "description": "The sending service. It decides which DNS records are checked." }, + "dnsProfile": { + "enum": ["ses", "forge"], + "description": "The DNS layout Resend shows for this domain. Defaults to the legacy SES MX/SPF layout; choose forge for Resend's rsend and send CNAME records." + }, "region": { "type": "string", "pattern": "^[a-z]{2}-[a-z]+-[0-9]$", diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json index 981b151b..25e1d80b 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lvbt-contributions", - "version": "0.5.4", + "version": "0.6.0", "description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.", "author": { "name": "Las Vegans for Better Transit", diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json index 34a7e6fa..0f21078a 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lvbt-contributions", - "version": "0.5.4", + "version": "0.6.0", "description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.", "author": { "name": "Las Vegans for Better Transit", diff --git a/.lvbt/web-platform/packages/cli/src/cli.mjs b/.lvbt/web-platform/packages/cli/src/cli.mjs index 93fe194d..9886a61b 100755 --- a/.lvbt/web-platform/packages/cli/src/cli.mjs +++ b/.lvbt/web-platform/packages/cli/src/cli.mjs @@ -7,7 +7,7 @@ * lvbt preflight confirm this machine can build and deploy the repository; * with --production, also report production's readiness * lvbt check the shared repository-shape rules (filenames, contract, debt) - * lvbt deploy build, then `wrangler deploy` for every app that has a config + * lvbt deploy build, then deploy every configured Cloudflare app * * A repository's package.json maps its standard scripts to these, so * `pnpm bootstrap`, `pnpm preflight`, `pnpm check`, and `pnpm run deploy` behave @@ -31,7 +31,7 @@ Options: --filter For deploy and --production: only the app directory named (apps/site) --rotate For bootstrap --production: replace the named secrets' stored values on every target. Without it, a value that is already set is kept - --dry-run For deploy: build, then run wrangler deploy --dry-run + --dry-run For deploy: build, then run each Cloudflare CLI with --dry-run `; const commands = { bootstrap, preflight, check, deploy }; diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs index c0b8cf43..e0b0bfaa 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs @@ -21,6 +21,7 @@ const IGNORED = new Set([ 'test-results', '.turbo', '.wrangler', + '.cloudflare', '.astro', ]); diff --git a/.lvbt/web-platform/packages/cli/src/lib/operate.mjs b/.lvbt/web-platform/packages/cli/src/lib/operate.mjs index 3d25dfe3..e50f6b29 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/operate.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/operate.mjs @@ -26,6 +26,7 @@ function satisfies(version, range) { } const WRANGLER_FILES = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']; +const CF_FILE = 'cloudflare.config.ts'; export function wranglerDeployArguments(commit, dryRun) { if (!/^[a-f0-9]{40}$/.test(commit)) @@ -41,6 +42,19 @@ export function wranglerDeployArguments(commit, dryRun) { ]; } +export function cfDeployArguments(commit, dryRun) { + if (!/^[a-f0-9]{40}$/.test(commit)) + throw new CliError('deploy: provenance requires a full Git commit.', 2); + return [ + 'exec', + 'cf', + 'deploy', + '--message', + `Commit ${commit}`, + ...(dryRun ? ['--dry-run'] : []), + ]; +} + function deploymentCommit(cwd) { const commit = output('git', ['rev-parse', 'HEAD'], cwd); if (commit === undefined || !/^[a-f0-9]{40}$/.test(commit)) @@ -61,17 +75,42 @@ async function wranglerConfig(directory) { return undefined; } -/** Every directory with a wrangler config: the root, then each apps/*. */ -async function deployables(cwd) { - const found = []; - if (await wranglerConfig(cwd)) found.push('.'); +async function deployable(cwd, directory) { + const manifestPath = path.join(directory, 'platform.json'); + if (await exists(manifestPath)) { + const manifest = await readJson(manifestPath); + const canonical = manifest.cloudflare?.cloudflareConfig; + if (canonical) { + const configFile = path.resolve(directory, canonical); + if (path.basename(configFile) !== CF_FILE) + throw new CliError(`deploy: canonical cf config must be named ${CF_FILE}.`, 2); + if (!(await exists(configFile))) + throw new CliError(`deploy: canonical cf config ${configFile} is missing.`, 2); + const target = path.relative(cwd, path.dirname(configFile)) || '.'; + if (target.startsWith('..') || path.isAbsolute(target)) + throw new CliError('deploy: canonical cf config must be inside this repository.', 2); + return { directory: target, tool: 'cf', source: path.relative(cwd, directory) || '.' }; + } + } + const relative = path.relative(cwd, directory) || '.'; + if (await exists(path.join(directory, CF_FILE))) return { directory: relative, tool: 'cf' }; + if (await wranglerConfig(directory)) return { directory: relative, tool: 'wrangler' }; + return undefined; +} + +/** Every configured project: the root, then each apps/*. Cf wins when both configs remain. */ +export async function deployables(cwd) { + const found = new Map(); + const rootTarget = await deployable(cwd, cwd); + if (rootTarget) found.set(rootTarget.directory, rootTarget); const apps = await readdir(path.join(cwd, 'apps'), { withFileTypes: true }).catch(() => []); for (const entry of apps) { - if (entry.isDirectory() && (await wranglerConfig(path.join(cwd, 'apps', entry.name)))) { - found.push(`apps/${entry.name}`); - } + if (!entry.isDirectory()) continue; + const target = await deployable(cwd, path.join(cwd, 'apps', entry.name)); + if (target && (!found.has(target.directory) || target.source)) + found.set(target.directory, target); } - return found; + return [...found.values()]; } async function toolchainFindings(cwd, packageJson, report) { @@ -151,13 +190,40 @@ async function repositoryFindings(cwd, report) { async function cloudflareFindings(cwd, report) { const targets = await deployables(cwd); if (targets.length === 0) { - report.pass('Cloudflare', 'no wrangler config; nothing to deploy from here'); + report.pass('Cloudflare', 'no Cloudflare project config; nothing to deploy from here'); return; } - const whoami = output('pnpm', ['exec', 'wrangler', 'whoami'], cwd); - if (whoami && !/not authenticated/i.test(whoami)) - report.pass('Cloudflare', `wrangler is signed in; deployables: ${targets.join(', ')}`); - else report.fail('Cloudflare', 'wrangler is not signed in', 'pnpm exec wrangler login'); + const cfTargets = targets.filter((target) => target.tool === 'cf'); + const wranglerTargets = targets.filter((target) => target.tool === 'wrangler'); + if (cfTargets.length > 0) { + const whoami = output( + 'pnpm', + ['exec', 'cf', 'auth', 'whoami'], + path.join(cwd, cfTargets[0].directory), + ); + let authenticated = false; + try { + authenticated = JSON.parse(whoami).authenticated === true; + } catch { + // An absent CLI or malformed response is not authentication. + } + if (authenticated) + report.pass( + 'Cloudflare cf', + `ready for: ${cfTargets.map((target) => target.directory).join(', ')}`, + ); + else report.fail('Cloudflare cf', 'cf is not signed in', 'pnpm exec cf auth login'); + } + if (wranglerTargets.length > 0) { + const whoami = output('pnpm', ['exec', 'wrangler', 'whoami'], cwd); + if (whoami && !/not authenticated/i.test(whoami)) + report.pass( + 'Cloudflare Wrangler', + `ready for: ${wranglerTargets.map((target) => target.directory).join(', ')}`, + ); + else + report.fail('Cloudflare Wrangler', 'wrangler is not signed in', 'pnpm exec wrangler login'); + } } /** @@ -241,21 +307,23 @@ export async function bootstrap({ cwd, options = {} }) { } /** - * Build, then deploy with Wrangler: the same steps every deployable repository - * runs. Every app with a wrangler config deploys, or just the one `--filter` + * Build, then deploy with the CLI selected by each project's config. Every app + * with a Cloudflare config deploys, or just the one `--filter` * names. */ export async function deploy({ cwd, options }) { let targets = await deployables(cwd); if (options.filter) - targets = targets.filter( - (target) => target === options.filter || target === `apps/${options.filter}`, + targets = targets.filter((target) => + [target.directory, target.source].some( + (directory) => directory === options.filter || directory === `apps/${options.filter}`, + ), ); if (targets.length === 0) { throw new CliError( options.filter - ? `deploy: ${options.filter} has no wrangler.jsonc, wrangler.json, or wrangler.toml.` - : 'deploy: no wrangler.jsonc, wrangler.json, or wrangler.toml at the root or under apps/.', + ? `deploy: ${options.filter} has no wrangler config or cloudflare.config.ts.` + : 'deploy: no wrangler config or cloudflare.config.ts at the root or under apps/.', 2, ); } @@ -269,10 +337,19 @@ export async function deploy({ cwd, options }) { for (const target of targets) { if (deploymentCommit(cwd) !== commit) throw new CliError('deploy: repository changed after the production build.', 2); - const args = wranglerDeployArguments(commit, options.dryRun); - process.stdout.write(`${target}: pnpm ${args.join(' ')}\n`); - const result = spawnSync('pnpm', args, { cwd: path.join(cwd, target), stdio: 'inherit' }); + const args = + target.tool === 'cf' + ? cfDeployArguments(commit, options.dryRun) + : wranglerDeployArguments(commit, options.dryRun); + process.stdout.write(`${target.directory}: pnpm ${args.join(' ')}\n`); + const result = spawnSync('pnpm', args, { + cwd: path.join(cwd, target.directory), + stdio: 'inherit', + }); if (result.status !== 0) - throw new CliError(`deploy: wrangler deploy failed in ${target}`, result.status ?? 1); + throw new CliError( + `deploy: ${target.tool} deploy failed in ${target.directory}`, + result.status ?? 1, + ); } } diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs index dbd90555..e4364403 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs @@ -1,4 +1,5 @@ import { SETUP } from './plan.mjs'; +import { configVarEntry, configVarLocation } from './guides.mjs'; import { paint } from './terminal.mjs'; import { account, manualStep, storeFed, succeeded, targetName, wrangler } from './apply-steps.mjs'; @@ -25,7 +26,7 @@ export async function createWidget(context, action) { context.created.widgets.set(widget.name, created); context.io.write(`${paint('green', 'Created')} the Turnstile widget ${widget.name}.\n`); context.io.write( - `Put "${widget.siteKeyVar}": "${created.sitekey}" in vars in ${context.configPath}, commit it, and deploy. The site key is public.\n`, + `Add ${configVarEntry(widget.siteKeyVar, created.sitekey, context.configPath)} to ${configVarLocation(context.configPath)} in ${context.configPath}, commit it, and deploy. The site key is public.\n`, ); await storeFed(context, widget.secret, created.secret); } @@ -184,7 +185,7 @@ export async function namedInConfig(context, action) { : undefined; if (real && bound?.id === real.id) return true; context.io.write( - `The migrations for ${action.name} wait until ${context.configPath} has database_id ${real?.id ?? 'of the new database'}. Run ${SETUP} again after that pull request merges.\n`, + `The migrations for ${action.name} wait until ${context.configPath} has ${context.manifest.cloudflare.cloudflareConfig ? 'the D1 binding id' : 'database_id'} ${real?.id ?? 'of the new database'}. Run ${SETUP} again after that pull request merges.\n`, ); return false; } diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs index 9acce1b7..73df97f2 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto'; +import path from 'node:path'; import { redact } from './services.mjs'; import { paint } from './terminal.mjs'; @@ -29,6 +30,16 @@ export function wrangler(context, args, options = {}) { }); } +export function cf(context, args, options = {}) { + const config = context.manifest.cloudflare.cloudflareConfig; + if (!config) throw new Error('cf requires cloudflare.cloudflareConfig in platform.json'); + return context.run('pnpm', ['exec', 'cf', ...args], { + cwd: path.resolve(context.directory, path.dirname(config)), + env: { CLOUDFLARE_ACCOUNT_ID: context.manifest.cloudflare.accountId }, + ...options, + }); +} + export function succeeded(result, secret) { if (result.status === 0) return; const output = `${result.stderr}\n${result.stdout}`.trim().split('\n').slice(-3).join(' '); diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs index d504c845..7693ce45 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs @@ -1,7 +1,8 @@ +import path from 'node:path'; import { varGuide } from './guides.mjs'; import { SETUP } from './plan.mjs'; import { paint } from './terminal.mjs'; -import { manualStep, storeSecret, succeeded, wrangler } from './apply-steps.mjs'; +import { cf, manualStep, storeSecret, succeeded, wrangler } from './apply-steps.mjs'; import { createOrUpdateApp, createWidget, @@ -22,24 +23,55 @@ export { rotateSecrets } from './apply-values.mjs'; */ async function createDatabase(context, action) { - succeeded(wrangler(context, ['d1', 'create', action.name])); + succeeded( + context.manifest.cloudflare.cloudflareConfig + ? cf(context, ['d1', 'create', '--name', action.name]) + : wrangler(context, ['d1', 'create', action.name]), + ); context.io.write( - `${paint('green', 'Created')} the D1 database ${action.name}. Put its database_id in ${context.configPath} through a pull request; the final report shows the id.\n`, + `${paint('green', 'Created')} the D1 database ${action.name}. Put its ${context.manifest.cloudflare.cloudflareConfig ? 'D1 id' : 'database_id'} in ${context.configPath} through a pull request; the final report shows the id.\n`, ); } async function migrateDatabase(context, action) { if (action.afterCreate && !(await namedInConfig(context, action))) return; - context.io.write( - `Applying migrations to ${action.name}. Wrangler lists them and asks you to confirm.\n`, - ); - succeeded( - wrangler(context, ['d1', 'migrations', 'apply', action.name, '--remote'], { inherit: true }), - ); + const cfConfig = context.manifest.cloudflare.cloudflareConfig; + if (cfConfig) { + const database = context.manifest.d1.find((entry) => entry.name === action.name); + const latest = action.afterCreate && context.observe ? await context.observe() : context.state; + const id = latest.d1.ok ? latest.d1.value[action.name]?.id : undefined; + if (!id) throw new Error(`cannot apply migrations before ${action.name} has a D1 ID`); + context.io.write(`Applying migrations to ${action.name} with cf.\n`); + succeeded( + cf( + context, + [ + 'd1', + 'migrations', + 'apply', + id, + '--dir', + path.resolve(context.directory, database.migrations), + ], + { inherit: true }, + ), + ); + } else { + context.io.write(`Applying migrations to ${action.name}. Wrangler asks you to confirm.\n`); + succeeded( + wrangler(context, ['d1', 'migrations', 'apply', action.name, '--remote'], { + inherit: true, + }), + ); + } } async function createBucket(context, action) { - succeeded(wrangler(context, ['r2', 'bucket', 'create', action.name])); + succeeded( + context.manifest.cloudflare.cloudflareConfig + ? cf(context, ['r2', 'buckets', 'create-by-name', action.name]) + : wrangler(context, ['r2', 'bucket', 'create', action.name]), + ); context.io.write(`${paint('green', 'Created')} the R2 bucket ${action.name}.\n`); } diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs index 130f7005..3b3e00bd 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs @@ -67,6 +67,51 @@ export function emailRecords(email) { const region = email.region ?? 'us-east-1'; const domain = email.domain; const mailFrom = `feedback-smtp.${region}.amazonses.com`; + const dkimAndDmarc = [ + { + key: 'dkim', + type: 'TXT', + name: `resend._domainkey.${domain}`, + purpose: 'signs every message (DKIM)', + expected: 'TXT resend._domainkey → the p=… value Resend shows', + matches: (data) => dnsText(data).startsWith('p='), + level: 'required', + }, + { + key: 'dmarc', + type: 'TXT', + name: `_dmarc.${domain}`, + purpose: 'tells inboxes what to do with mail that fails the checks (DMARC)', + expected: 'TXT _dmarc → "v=DMARC1; p=none;"', + matches: (data) => dnsText(data).startsWith('v=DMARC1'), + level: 'recommended', + }, + ]; + if (email.dnsProfile === 'forge') { + const cnameMatches = (target) => (data) => + data.trim().replace(/\.$/, '').toLowerCase() === target; + return [ + { + key: 'rsend', + type: 'CNAME', + name: `rsend.${domain}`, + purpose: 'connects this sending domain to Resend', + expected: 'CNAME rsend → rsend.forge.rmta.net', + matches: cnameMatches('rsend.forge.rmta.net'), + level: 'required', + }, + { + key: 'send', + type: 'CNAME', + name: `send.${domain}`, + purpose: 'routes the return path through Resend', + expected: 'CNAME send → send.forge.rmta.net', + matches: cnameMatches('send.forge.rmta.net'), + level: 'required', + }, + ...dkimAndDmarc, + ]; + } return [ { key: 'mx', @@ -87,42 +132,28 @@ export function emailRecords(email) { dnsText(data).startsWith('v=spf1') && dnsText(data).includes('include:amazonses.com'), level: 'required', }, - { - key: 'dkim', - type: 'TXT', - name: `resend._domainkey.${domain}`, - purpose: 'signs every message (DKIM)', - expected: 'TXT resend._domainkey → the p=… value Resend shows', - matches: (data) => dnsText(data).startsWith('p='), - level: 'required', - }, - { - key: 'dmarc', - type: 'TXT', - name: `_dmarc.${domain}`, - purpose: 'tells inboxes what to do with mail that fails the checks (DMARC)', - expected: 'TXT _dmarc → "v=DMARC1; p=none;"', - matches: (data) => dnsText(data).startsWith('v=DMARC1'), - level: 'recommended', - }, + ...dkimAndDmarc, ]; } export function resendDomainGuide(email, cloudflare) { const region = email.region ?? 'us-east-1'; const zone = cloudflare.zone.name; - const [mx, spf, dkim, dmarc] = emailRecords(email).map((record) => ({ - ...record, - short: relativeName(record.name, zone), - })); + const records = Object.fromEntries( + emailRecords(email).map((record) => [record.key, relativeName(record.name, zone)]), + ); + const forge = email.dnsProfile === 'forge'; + const manualRecords = forge + ? `To add them by hand, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records. Add these CNAME records with TTL "Auto" and Proxy status "DNS only": type CNAME, name ${records.rsend}, target rsend.forge.rmta.net; type CNAME, name ${records.send}, target send.forge.rmta.net. Add a third record: type TXT, name ${records.dkim}, content the long p=… value shown on this domain's page in Resend.` + : `To add them by hand instead, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records and add these three, each with TTL "Auto" and Proxy status "DNS only": type MX, name ${records.mx}, mail server feedback-smtp.${region}.amazonses.com, priority 10; type TXT, name ${records.spf}, content v=spf1 include:amazonses.com ~all; type TXT, name ${records.dkim}, content the long p=… value Resend shows for it.`; return { url: 'https://resend.com/domains', steps: [ 'Sign in to Resend at https://resend.com/login. If you have no account, sign up at https://resend.com/signup with your @lasvegasfortransit.org address, then ask a maintainer to invite you to the LVBT team. Everything below belongs in that team, never in a personal one.', - `On the Domains page, if ${email.domain} is listed, click it and go to the next step. Otherwise click "Add Domain", type ${email.domain}, choose the region ${REGIONS[region] ?? region}, and click "Add". Keep that region: platform.json and the DNS records both name it.`, - `The easiest way to add the DNS records is the "Sign in to Cloudflare" button on the domain's page in Resend. Approve the request in the Cloudflare window, and it adds every record for you.`, - `To add them by hand instead, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records and add these three, each with TTL "Auto" and Proxy status "DNS only": type MX, name ${mx.short}, mail server feedback-smtp.${region}.amazonses.com, priority 10; type TXT, name ${spf.short}, content v=spf1 include:amazonses.com ~all; type TXT, name ${dkim.short}, content the long p=… value Resend shows for it.`, - `Add the DMARC record too, which Resend recommends: type TXT, name ${dmarc.short}, content v=DMARC1; p=none;.`, + `On the Domains page, if ${email.domain} is listed, click it and go to the next step. Otherwise click "Add Domain", type ${email.domain}, choose the region ${REGIONS[region] ?? region}, and click "Add". Keep that region: platform.json names it${forge ? '.' : ' and the DNS records do too.'}`, + `If Resend offers a "Sign in to Cloudflare" button on the domain's page, you can use it to add the DNS records. Approve the request in the Cloudflare window, then check the records it added against the values Resend shows.`, + manualRecords, + `Add the DMARC record too, which Resend recommends: type TXT, name ${records.dmarc}, content v=DMARC1; p=none;.`, 'Back in Resend, click "Verify DNS Records". Wait until the domain\'s status says "Verified", usually within a few minutes (DNS can take up to 72 hours). Then run this command again.', ], }; @@ -305,13 +336,15 @@ export function googleGroupGuide(group, apps) { export function turnstileGuide(widget, cloudflare, configPath) { const config = configPath ?? 'the production wrangler config'; + const location = configVarLocation(config); + const entry = configVarEntry(widget.siteKeyVar, '', config); const mode = { managed: 'Managed', 'non-interactive': 'Non-interactive', invisible: 'Invisible' }; const createSteps = [ `Open Turnstile in the Cloudflare dashboard with the LVBT account (${LVBT_CLOUDFLARE_ACCOUNT}). If a widget named "${widget.name}" is already listed, click it and go to the step for the Site Key. ${ACCOUNT_NAME_CHECK}`, `Click "Add widget". Widget name: ${widget.name}.`, `Under "Hostname management", add ${widget.domains.join(', ')}.`, `Widget Mode: "${mode[widget.mode ?? 'managed']}". Leave pre-clearance off, and click "Create".`, - `If "vars" in ${config} already has "${widget.siteKeyVar}" with this widget's Site Key, skip this step. Otherwise copy the Site Key (public, starts with 0x) and paste it into "vars" in ${config} as "${widget.siteKeyVar}" now; save the file and commit it through a pull request later.`, + `If ${location} in ${config} already has "${widget.siteKeyVar}" with this widget's Site Key, skip this step. Otherwise copy the Site Key (public, starts with 0x) and paste it into ${location} in ${config} as ${entry} now; save the file and commit it through a pull request later.`, ]; const secretSteps = [ `Copy the widget's Secret Key (private, also starts with 0x) and paste it at this command's prompt. It is the Worker secret ${widget.secret}.`, @@ -366,16 +399,25 @@ export function setupTokenGuide(manifest) { }; } +export function configVarLocation(configPath) { + return configPath.endsWith('.ts') ? 'worker.env' : '"vars"'; +} + +export function configVarEntry(name, value, configPath) { + const shown = JSON.stringify(value); + return configPath.endsWith('.ts') ? `${name}: bindings.text(${shown})` : `"${name}": ${shown}`; +} + export function varGuide(variable, configPath, value, widget) { const shown = value === undefined ? widget - ? `""` - : '""' - : JSON.stringify(value); + ? `` + : '' + : value; return { steps: [ - `Add "${variable.name}": ${shown} to "vars" in ${configPath}. It is public, so it belongs in the config rather than in a secret.`, + `Add ${configVarEntry(variable.name, shown, configPath)} to ${configVarLocation(configPath)} in ${configPath}. It is public, so it belongs in the config rather than in a secret.`, 'Commit it on a branch and open a pull request. The Worker gets it on the next deploy from main.', ], }; diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs index a6ad1c57..85ee6f54 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs @@ -98,9 +98,15 @@ async function inspect({ cwd, file, services, io, interactive, askForToken }) { const directory = path.join(cwd, path.dirname(file)); const configPath = path.join( path.dirname(file), - manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc', + manifest.cloudflare.cloudflareConfig ?? manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc', ); - const signedIn = wranglerToken(services.run, directory); + // Inventory needs Workers, D1 and R2 read scopes. The narrower deploy token + // is deliberately not used here. Cf beta cannot export its OAuth token. + const apiToken = services.env.LVBT_CLOUDFLARE_INVENTORY_TOKEN?.trim(); + const signedIn = apiToken ? { token: apiToken } : wranglerToken(services.run, directory); + if (!signedIn.token && manifest.cloudflare.cloudflareConfig) + signedIn.reason = + 'cf beta OAuth cannot supply the production observer; set LVBT_CLOUDFLARE_INVENTORY_TOKEN with Workers, D1 and R2 read access, or run pnpm exec wrangler login'; const setupApi = setupApiProvider({ manifest, services, io, interactive }); const apis = { wrangler: signedIn.token ? cloudflareApi(signedIn.token, services.request) : undefined, diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs index 07524d7e..542b9604 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs @@ -1,6 +1,6 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { validateAgainstSchema } from './schema.mjs'; export const MANIFEST_FILE = 'platform.json'; @@ -252,6 +252,31 @@ export function readWranglerConfig(file) { }; } +/** Read a cf project's canonical config, including its typed binding builders. */ +export async function readCloudflareConfig(file) { + const module = await import(pathToFileURL(file).href); + const project = + typeof module.default === 'function' + ? await module.default({ mode: undefined, isPreview: false }) + : module.default; + const worker = + typeof project?.worker === 'function' + ? await project.worker({ mode: undefined, isPreview: false }) + : project?.worker; + if (!worker?.name || !worker.env) + throw new Error(`${file} must declare a Worker with a name and env bindings.`); + const vars = {}; + const d1 = []; + const r2 = []; + for (const [binding, entry] of Object.entries(worker.env)) { + if (entry.type === 'text' || entry.type === 'json') vars[binding] = entry.value; + if (entry.type === 'd1') + d1.push({ binding, name: entry.name, id: entry.id, migrationsTable: 'd1_migrations' }); + if (entry.type === 'r2') r2.push({ binding, name: entry.name }); + } + return { name: worker.name, vars, d1, r2 }; +} + /** The .sql files in a migrations directory, in the order Wrangler applies them. */ export function migrationFiles(directory) { let files; diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs index 00f24ffd..a9dfac02 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs @@ -1,6 +1,6 @@ import path from 'node:path'; import { emailRecords } from './guides.mjs'; -import { migrationFiles, readWranglerConfig } from './manifest.mjs'; +import { migrationFiles, readCloudflareConfig, readWranglerConfig } from './manifest.mjs'; /** * Read everything the manifest names, without changing anything. Each part @@ -182,6 +182,17 @@ async function observeDns(resolve, manifest) { return answers; } +async function observeConfig(manifest, directory) { + const cfConfig = manifest.cloudflare.cloudflareConfig; + const configFile = path.join( + directory, + cfConfig ?? manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc', + ); + return attempt(() => + cfConfig ? readCloudflareConfig(configFile) : readWranglerConfig(configFile), + ); +} + /** * @param {object} input * @param {object} input.manifest a validated manifest @@ -193,8 +204,7 @@ async function observeDns(resolve, manifest) { */ export async function observePlatform({ manifest, directory, apis, run, resolve, confirmed }) { const account = `accounts/${manifest.cloudflare.accountId}`; - const configFile = path.join(directory, manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc'); - const config = await attempt(() => readWranglerConfig(configFile)); + const config = await observeConfig(manifest, directory); const migrations = {}; for (const database of manifest.d1 ?? []) { if (database.migrations) diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs index a3d7ce7d..f2ffd8a0 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs @@ -11,7 +11,7 @@ function configItem({ manifest, state, configPath }) { ...fields, status: 'mismatch', detail: `cannot read it: ${state.config.reason}`, - next: 'point cloudflare.wranglerConfig at the production wrangler config', + next: `point cloudflare.${manifest.cloudflare.cloudflareConfig ? 'cloudflareConfig' : 'wranglerConfig'} at the production config`, }); if (state.config.value.name !== name) return item({ @@ -59,14 +59,16 @@ function databaseItem({ state, configPath }, database) { ...fields, status: 'mismatch', detail: `${configPath} does not bind ${database.binding} to ${database.name}`, - next: `add it to d1_databases in ${configPath} with database_id ${real.id}`, + next: configPath.endsWith('.ts') + ? `set worker.env.${database.binding} to bindings.d1({ name: "${database.name}", id: "${real.id}" }) in ${configPath}` + : `add it to d1_databases in ${configPath} with database_id ${real.id}`, }); if (bound && bound.id !== real.id) return item({ ...fields, status: 'mismatch', - detail: `${configPath} has database_id ${bound.id}, but the database is ${real.id}`, - next: `set database_id to ${real.id} in ${configPath}`, + detail: `${configPath} has D1 id ${bound.id}, but the database is ${real.id}`, + next: `set ${configPath.endsWith('.ts') ? `worker.env.${database.binding} bindings.d1 id` : 'database_id'} to ${real.id} in ${configPath}`, }); return item({ ...fields, status: 'ok', detail: `exists and is bound as ${database.binding}` }); } @@ -113,8 +115,8 @@ function pendingItem({ state, configPath }, database, { fields, files, real, act return item({ ...fields, status: 'missing', - detail: `${pending.length} of ${files.length} not applied; they wait until ${configPath} has database_id ${real.id}`, - next: `set database_id to ${real.id} in ${configPath}, then run ${SETUP} again`, + detail: `${pending.length} of ${files.length} not applied; they wait until ${configPath} has ${configPath.endsWith('.ts') ? 'D1 id' : 'database_id'} ${real.id}`, + next: `set ${configPath.endsWith('.ts') ? `worker.env.${database.binding} bindings.d1 id` : 'database_id'} to ${real.id} in ${configPath}, then run ${SETUP} again`, }); return item({ ...fields, @@ -152,7 +154,9 @@ export function planR2({ manifest, state, configPath }) { ...fields, status: 'mismatch', detail: `${configPath} does not bind ${bucket.binding} to ${bucket.name}`, - next: `add it to r2_buckets in ${configPath}`, + next: configPath.endsWith('.ts') + ? `set worker.env.${bucket.binding} to bindings.r2({ name: "${bucket.name}" }) in ${configPath}` + : `add it to r2_buckets in ${configPath}`, }); return item({ ...fields, status: 'ok', detail: `exists and is bound as ${bucket.binding}` }); }); diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs index 420dee6e..d46a7940 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs @@ -1,4 +1,10 @@ -import { emailRecords, resendDomainGuide, varGuide } from './guides.mjs'; +import { + configVarEntry, + configVarLocation, + emailRecords, + resendDomainGuide, + varGuide, +} from './guides.mjs'; import { findApp } from './plan-access.mjs'; import { findWidget } from './plan-cloudflare.mjs'; import { GH_HINT, item, SETUP, targetLabel, unknownItem } from './plan-items.mjs'; @@ -148,7 +154,7 @@ export function planVars({ manifest, state, configPath }) { ...fields, status: 'unknown', detail: `cannot read ${configPath}`, - next: 'fix the wrangler config path', + next: 'fix the production config path', }); const widget = (manifest.turnstile ?? []).find( (candidate) => candidate.siteKeyVar === variable.name, @@ -167,8 +173,8 @@ export function planVars({ manifest, state, configPath }) { return item({ ...fields, status: 'missing', - detail: `is not in vars in ${configPath}`, - next: `add it to vars in ${configPath}${live ? ` as "${live.sitekey}"` : ''}`, + detail: `is not in ${configVarLocation(configPath)} in ${configPath}`, + next: `add ${configVarEntry(variable.name, live?.sitekey ?? '', configPath)} to ${configVarLocation(configPath)} in ${configPath}`, action, }); if (live && value !== live.sitekey) @@ -223,8 +229,8 @@ function workerForbidden({ state, configPath }, entry, fields) { }); if (state.config.ok && entry.name in state.config.value.vars) return found( - `is in vars in ${configPath}`, - `remove it from vars in ${configPath}, then deploy`, + `is in ${configVarLocation(configPath)} in ${configPath}`, + `remove it from ${configVarLocation(configPath)} in ${configPath}, then deploy`, ); if (deployed && entry.name in deployed.vars) return found( diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs index deb4df5e..fcf688c3 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs @@ -166,7 +166,7 @@ export function dnsResolver(request = fetch) { ); if (!response.ok) throw new Error(`DNS lookup answered ${response.status}`); const payload = await response.json(); - const code = { MX: 15, TXT: 16 }[type]; + const code = { CNAME: 5, MX: 15, TXT: 16 }[type]; return (payload.Answer ?? []) .filter((answer) => code === undefined || answer.type === code) .map((answer) => answer.data); diff --git a/.lvbt/web-platform/packages/eslint-config/base.js b/.lvbt/web-platform/packages/eslint-config/base.js index e9be2c87..f36fd5a5 100644 --- a/.lvbt/web-platform/packages/eslint-config/base.js +++ b/.lvbt/web-platform/packages/eslint-config/base.js @@ -14,6 +14,7 @@ import tseslint from 'typescript-eslint'; const ignores = { ignores: [ '**/.astro/**', + '**/.cloudflare/**', '**/.turbo/**', '**/.wrangler/**', '**/coverage/**', diff --git a/.lvbt/web-platform/packages/eslint-config/package.json b/.lvbt/web-platform/packages/eslint-config/package.json index 74559beb..dbc81e9a 100644 --- a/.lvbt/web-platform/packages/eslint-config/package.json +++ b/.lvbt/web-platform/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/eslint-config", - "version": "0.5.4", + "version": "0.6.0", "description": "The ESLint configurations every LVBT repository uses.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/playwright-config/package.json b/.lvbt/web-platform/packages/playwright-config/package.json index 716ab216..ad22bff3 100644 --- a/.lvbt/web-platform/packages/playwright-config/package.json +++ b/.lvbt/web-platform/packages/playwright-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/playwright-config", - "version": "0.5.4", + "version": "0.6.0", "description": "The Playwright configuration every LVBT repository spreads into its own: end-to-end tests under tests/e2e, desktop and mobile projects, traces on failure.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/prettier-config/package.json b/.lvbt/web-platform/packages/prettier-config/package.json index 74484418..284568fd 100644 --- a/.lvbt/web-platform/packages/prettier-config/package.json +++ b/.lvbt/web-platform/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/prettier-config", - "version": "0.5.4", + "version": "0.6.0", "description": "The Prettier configuration every LVBT repository uses.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/typescript-config/package.json b/.lvbt/web-platform/packages/typescript-config/package.json index 68dccd65..ff21f710 100644 --- a/.lvbt/web-platform/packages/typescript-config/package.json +++ b/.lvbt/web-platform/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/typescript-config", - "version": "0.5.4", + "version": "0.6.0", "description": "TypeScript configurations every LVBT repository extends.", "license": "MIT", "repository": { diff --git a/.lvbt/web-platform/packages/vitest-config/package.json b/.lvbt/web-platform/packages/vitest-config/package.json index f5f2e01d..5e7f8676 100644 --- a/.lvbt/web-platform/packages/vitest-config/package.json +++ b/.lvbt/web-platform/packages/vitest-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/vitest-config", - "version": "0.5.4", + "version": "0.6.0", "description": "The Vitest configuration every LVBT repository spreads into its own.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/web-platform/package.json b/.lvbt/web-platform/packages/web-platform/package.json index d08cd48a..e03030dc 100644 --- a/.lvbt/web-platform/packages/web-platform/package.json +++ b/.lvbt/web-platform/packages/web-platform/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/web-platform", - "version": "0.5.4", + "version": "0.6.0", "description": "Provider-neutral deployment, provisioning, and validation primitives for LVBT web repositories.", "license": "MIT", "type": "module", diff --git a/AGENTS.md b/AGENTS.md index 83b898d5..813ee596 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Every LVBT repository answers to the same commands, run from the repository root | `pnpm build` | Build the site and its Worker | | `pnpm test` | Run the unit tests | | `pnpm test:e2e` | Run the Playwright suites against a local build | -| `pnpm run deploy` | Build, then `wrangler deploy` the Worker (maintainers only; see below) | +| `pnpm run deploy` | Build, then deploy the Worker with `cf` (maintainers only; see below) | | `turbo gen workspace` | Scaffold a new package or app | `pnpm bootstrap` and `pnpm preflight` run the site's own setup script in diff --git a/apps/deploy/cloudflare.config.ts b/apps/deploy/cloudflare.config.ts new file mode 100644 index 00000000..d79d3ae5 --- /dev/null +++ b/apps/deploy/cloudflare.config.ts @@ -0,0 +1,75 @@ +import { bindings, defineConfig } from 'cf/config'; + +const accountId = '2557b5c2e166292ded0f8425b73075e9'; +const commonWorker = { + compatibilityDate: '2026-09-04', + compatibilityFlags: ['nodejs_compat'], + entrypoint: '../site/.wrangler/worker/index.js', + workersDev: false, + previewUrls: true, + observability: { enabled: true }, + assets: { + notFoundHandling: '404-page' as const, + runWorkerFirst: [ + '/api/*', + '/join/member', + '/join/member/*', + '/join/remove', + '/join/remove/*', + '/sign-in', + '/sign-in/*', + '/sign-out', + '/sign-out/*', + '/account', + '/account/*', + '/prototypes/join', + '/prototypes/join/*', + ], + }, +}; + +export default defineConfig((ctx) => { + switch (ctx.mode) { + case 'preview': { + return { + accountId, + worker: { + ...commonWorker, + name: 'lvbt-website-preview', + env: { + PLATFORM_DB: bindings.d1({ + name: 'lvbt-platform-preview', + id: '8bb072e7-9865-487a-8867-d97fdb2ea00b', + }), + ASSETS: bindings.assets(), + }, + }, + }; + } + default: { + return { + accountId, + worker: { + ...commonWorker, + name: 'lvbt-website', + env: { + PLATFORM_DB: bindings.d1({ + name: 'lvbt-platform', + id: 'dcde8dbd-d827-4405-85b4-97bc2accc906', + }), + ASSETS: bindings.assets(), + LVBT_RESEND_API_KEY: bindings.secret(), + LVBT_BEEHIIV_API_KEY: bindings.secret(), + LVBT_BEEHIIV_PUBLICATION_ID: bindings.secret(), + LVBT_MEMBERSHIP_INTAKE_SECRET: bindings.secret(), + LVBT_NOTION_API_KEY: bindings.secret(), + LVBT_NOTION_DATA_SOURCE_ID: bindings.secret(), + LVBT_TRANSIT_NEWS_INTAKE_SECRET: bindings.secret(), + LVBT_SIGN_IN_SECRET: bindings.secret(), + LVBT_LINK_SIGNING_SECRET: bindings.secret(), + }, + }, + }; + } + } +}); diff --git a/apps/deploy/eslint.config.js b/apps/deploy/eslint.config.js new file mode 100644 index 00000000..224e98ff --- /dev/null +++ b/apps/deploy/eslint.config.js @@ -0,0 +1,3 @@ +import { config } from '@lasvegasfortransit/eslint-config/base'; + +export default [...config, { ignores: ['.cloudflare/**'] }]; diff --git a/apps/deploy/package.json b/apps/deploy/package.json new file mode 100644 index 00000000..369c97e7 --- /dev/null +++ b/apps/deploy/package.json @@ -0,0 +1,20 @@ +{ + "name": "@lasvegasfortransit/deploy", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "lint": "eslint . --max-warnings 0", + "check-types": "tsc --project tsconfig.json", + "test": "node --test tests/*.test.ts" + }, + "devDependencies": { + "@lasvegasfortransit/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", + "@lasvegasfortransit/typescript-config": "file:../../.lvbt/web-platform/packages/typescript-config", + "@types/node": "catalog:", + "cf": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/apps/deploy/tests/config.test.ts b/apps/deploy/tests/config.test.ts new file mode 100644 index 00000000..2219609a --- /dev/null +++ b/apps/deploy/tests/config.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import ts from 'typescript'; + +import { PLATFORM_SECRETS } from '../../site/scripts/bootstrap/config/platform-secrets.ts'; +import cloudflare from '../cloudflare.config.ts'; +import wranglerBuild from '../wrangler.config.ts'; + +interface WranglerMirror { + name: string; + compatibility_date: string; + compatibility_flags: string[]; + main: string; + workers_dev: boolean; + preview_urls: boolean; + observability: { enabled: boolean }; + assets: { + directory: string; + binding: string; + not_found_handling: string; + run_worker_first: string[]; + }; + d1_databases: { binding: string; database_name: string; database_id: string }[]; + env: { preview: { d1_databases: WranglerMirror['d1_databases'] } }; +} + +const deployDir = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +const siteDir = path.resolve(deployDir, '../site'); +const source = await readFile(path.join(siteDir, 'wrangler.jsonc'), 'utf8'); +const parsed = ts.parseConfigFileTextToJson('wrangler.jsonc', source); +assert.equal(parsed.error, undefined, 'Wrangler fallback must remain valid JSONC'); +const wrangler = parsed.config as WranglerMirror; + +for (const mode of [undefined, 'preview']) { + void test(`${mode ?? 'production'} cf config matches the Wrangler fallback`, () => { + const cf = cloudflare({ mode, isPreview: false }); + const build = wranglerBuild as { assetsDirectory: string }; + const expectedDb = + mode === 'preview' ? wrangler.env.preview.d1_databases[0] : wrangler.d1_databases[0]; + assert.ok(expectedDb); + assert.equal(cf.accountId, '2557b5c2e166292ded0f8425b73075e9'); + assert.equal(cf.worker.name, mode === 'preview' ? `${wrangler.name}-preview` : wrangler.name); + assert.equal(cf.worker.compatibilityDate, wrangler.compatibility_date); + assert.deepEqual(cf.worker.compatibilityFlags, wrangler.compatibility_flags); + assert.equal( + path.resolve(deployDir, cf.worker.entrypoint), + path.resolve(siteDir, wrangler.main), + ); + assert.equal( + path.resolve(deployDir, build.assetsDirectory), + path.resolve(siteDir, wrangler.assets.directory), + ); + assert.equal(cf.worker.workersDev, wrangler.workers_dev); + assert.equal(cf.worker.previewUrls, wrangler.preview_urls); + assert.deepEqual(cf.worker.observability, wrangler.observability); + assert.deepEqual(cf.worker.assets, { + notFoundHandling: wrangler.assets.not_found_handling, + runWorkerFirst: wrangler.assets.run_worker_first, + }); + assert.deepEqual(cf.worker.env.PLATFORM_DB, { + type: 'd1', + name: expectedDb.database_name, + id: expectedDb.database_id, + }); + assert.equal(expectedDb.binding, 'PLATFORM_DB'); + assert.deepEqual(cf.worker.env.ASSETS, { type: 'assets' }); + assert.equal(wrangler.assets.binding, 'ASSETS'); + + const secretNames = Object.entries(cf.worker.env) + .filter(([, binding]) => binding.type === 'secret') + .map(([name]) => name) + .sort(); + const expectedSecrets = + mode === 'preview' + ? [] + : PLATFORM_SECRETS.filter( + (secret) => secret.use === 'live' && secret.targets.includes('worker'), + ) + .map((secret) => secret.name) + .sort(); + assert.deepEqual(secretNames, expectedSecrets); + }); +} diff --git a/apps/deploy/tsconfig.json b/apps/deploy/tsconfig.json new file mode 100644 index 00000000..e1cde30b --- /dev/null +++ b/apps/deploy/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@lasvegasfortransit/typescript-config/base.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["*.config.ts", "tests"] +} diff --git a/apps/deploy/wrangler.config.ts b/apps/deploy/wrangler.config.ts new file mode 100644 index 00000000..2cb09adb --- /dev/null +++ b/apps/deploy/wrangler.config.ts @@ -0,0 +1,8 @@ +import { defineWranglerConfig } from 'wrangler/experimental-config'; + +export default defineWranglerConfig({ + types: { + generate: false, + }, + assetsDirectory: '../site/dist', +}); diff --git a/docs/reference/deployment-pipeline.md b/docs/reference/deployment-pipeline.md index 14ff73a9..580e674e 100644 --- a/docs/reference/deployment-pipeline.md +++ b/docs/reference/deployment-pipeline.md @@ -1,7 +1,7 @@ # Deployment pipeline GitHub Actions owns builds and deployment. A change is built from a clean checkout, validated, and -sent to Cloudflare with the package and Wrangler versions recorded in the repository. +sent to Cloudflare with the package and CLI versions recorded in the repository. The `lvbt-website` Worker serves `lasvegasfortransit.org` and `www.lasvegasfortransit.org` through Cloudflare custom domains. The former Pages project stays available at `lvbt-website-5zh.pages.dev` @@ -10,7 +10,7 @@ as a rollback artifact; it owns neither public hostname. ## Build contract `pnpm build` creates the static site in `apps/site/dist/`, builds the Pagefind index, and compiles -`apps/site/functions/` into `apps/site/.wrangler/worker/index.js`. Wrangler serves the static tree +`apps/site/functions/` into `apps/site/.wrangler/worker/index.js`. The Worker serves the static tree through the `ASSETS` binding and invokes the Worker first only for `/api/*`. `pnpm check` covers the vendored LVBT standard, formatting, Markdown lint, the organization's @@ -25,9 +25,19 @@ an unused local port and verifies: - the permanent `/get-involved` redirect; - execution of the compiled subscription API. -The checked Worker configuration lives in `apps/site/wrangler.jsonc`. Production custom domains are -attached to the existing Worker in Cloudflare. Version uploads do not change those domains, and the -deployment token cannot edit DNS or routes. +The canonical `cf` configuration lives in `apps/deploy/cloudflare.config.ts`. It bundles the built +Worker and site assets from `apps/site`, and its preview mode uses a separate database. The +`apps/site/wrangler.jsonc` mirror remains for local tests, secret commands, and the versioned +candidate workflow; a parity test checks both configurations. `pnpm run deploy` uses `cf` for a +manual recovery deployment. The candidate workflow still uses Wrangler to upload and promote the +same reviewed Worker version; keep that gate until a `cf` candidate upload and receipt are verified +in production. Production custom domains are attached to the existing Worker in Cloudflare. Version +uploads do not change those domains, and the deployment token cannot edit DNS or routes. + +`cf` does not read the D1 migration directory from the Wrangler config. Apply new migrations before +deployment with +`pnpm -C apps/deploy exec cf d1 migrations apply --dir ../site/platform/storage/migrations` +from the repository root. That command targets the remote database unless `--local` is passed. ## Pull requests diff --git a/package.json b/package.json index eb6d2c37..0e97fa4e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "check": "pnpm standards:check && pnpm format:check && markdownlint-cli2 && lvbt check && turbo run lint check-types test validate", "check:fix": "pnpm format && markdownlint-cli2 --fix && turbo run lint -- --fix", "prepare": "git config --local core.hooksPath .githooks 2>/dev/null || true", - "deploy": "lvbt deploy", + "deploy": "lvbt deploy --filter deploy", "standards:update": "node .lvbt/web-platform/standards/web-platform-cli.ts update", "standards:check": "node .lvbt/web-platform/standards/web-platform-cli.ts check" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7eefbbb5..3e5db9db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ catalogs: astro-icon: specifier: 1.2.0 version: 1.2.0 + cf: + specifier: 1.0.0-beta.5 + version: 1.0.0-beta.5 cspell: specifier: 10.0.0 version: 10.0.0 @@ -115,8 +118,8 @@ catalogs: specifier: 6.0.3 version: 6.0.3 wrangler: - specifier: 4.127.1 - version: 4.127.1 + specifier: 4.141.0 + version: 4.141.0 zod: specifier: 4.5.4 version: 4.5.4 @@ -161,7 +164,31 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.127.1(@cloudflare/workers-types@5.20260901.1)(@types/node@25.9.0) + version: 4.141.0(@types/node@25.9.0) + + apps/deploy: + devDependencies: + '@lasvegasfortransit/eslint-config': + specifier: file:../../.lvbt/web-platform/packages/eslint-config + version: file:.lvbt/web-platform/packages/eslint-config(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(turbo@2.10.12)(typescript@6.0.3) + '@lasvegasfortransit/typescript-config': + specifier: file:../../.lvbt/web-platform/packages/typescript-config + version: file:.lvbt/web-platform/packages/typescript-config + '@types/node': + specifier: 'catalog:' + version: 24.13.3 + cf: + specifier: 'catalog:' + version: 1.0.0-beta.5(@types/node@24.13.3) + eslint: + specifier: 'catalog:' + version: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) + typescript: + specifier: 'catalog:' + version: 6.0.3 + wrangler: + specifier: 'catalog:' + version: 4.141.0(@cloudflare/workers-types@5.20260901.1)(@types/node@24.13.3) apps/site: dependencies: @@ -267,7 +294,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.127.1(@cloudflare/workers-types@5.20260901.1)(@types/node@24.13.3) + version: 4.141.0(@cloudflare/workers-types@5.20260901.1)(@types/node@24.13.3) packages: @@ -533,12 +560,25 @@ packages: resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} engines: {node: '>= 20.12.0'} + '@cloudflare/build-output-utils@0.8.2': + resolution: {integrity: sha512-DHKF7p5p1ZapZbh2GyF3mfBW7D45IKtcnwMdKnVZ72A/a2rfwd1kczBYFH+O4cHnNI7e9HRwTm+Z76IDZfxVLw==} + + '@cloudflare/codemods@0.2.1': + resolution: {integrity: sha512-nAoXTs5JPlQ8HeaTJQaxkvbJtGjXG19HoNx/W/Cz1d2PBETAt1Pdv4KqKqMw62DNq6Wfh4lwG2lJLvyn4X+Rhg==} + hasBin: true + + '@cloudflare/config@0.20.0': + resolution: {integrity: sha512-Dv/UDLkqsmvW95+CmM2nE55pdkLPWnJ63KDMjAFe/5KW1hc2SzJW4f8CUagqarivQugf5+oJMWYlHSmtA4KVlA==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} - '@cloudflare/unenv-preset@2.16.1': - resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + '@cloudflare/runtime-types@0.1.4': + resolution: {integrity: sha512-Jp3Za6h0KybVoi/4D6La3HicW71L8nyS7AGf0WHpbKqCs17lLkCuTbOcAuUTraoib8+SR74L+AbqxwcDq3VhTw==} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} peerDependencies: unenv: 2.0.0-rc.24 workerd: '>1.20260305.0 <2.0.0-0' @@ -546,32 +586,62 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260828.1': - resolution: {integrity: sha512-CVd+xPhqUESg8Xhq09TZx0wl4FSirfJGOzvbPz2yHhBIvmNHFFQkSN3rkd7wEwnhQQk37Xi0/aD6ykPLJbmGiQ==} + '@cloudflare/workerd-darwin-64@1.20260925.1': + resolution: {integrity: sha512-GFDjFo5jfmg98iiy73DOZjHRV+lPWaeGeJ6MbtUIVyostkh9L69KliaAWVytBHCSp36o3NNZOQ0CN8VrqO3+8A==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-64@1.20260926.1': + resolution: {integrity: sha512-VCIpwgJu5Dm1o+VHLclNOKIdpttEqss7oDHS5DYxgVoonM9Dxxxr1xCQvJbh6IXFuQ1DDrbm5krjte5NsTgX3Q==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260828.1': - resolution: {integrity: sha512-5HDPXRM152vU5JveByGFk34X57TVyIsfp4cabepAf45DC0MKvm52ucJqAjW1h8bvW4X+zRw9GU35OHF9FEC9Ww==} + '@cloudflare/workerd-darwin-arm64@1.20260925.1': + resolution: {integrity: sha512-b3BLoP9NkF6b2QOQFs8wFfOt9XSH3jVtacdGn9lcvkUCdPq7F6CfDlleHqyQEnDfZ32lH5jgMRTdaMHUPf/XbA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260828.1': - resolution: {integrity: sha512-MQ1Ll9P7F72HHUKizbb7BlDfbY8fRoNMpbIpZoU6uKsSkneFICWSKv6UlgU9EQZ+w0i7TMa12iUgJ8l29eRI9A==} + '@cloudflare/workerd-darwin-arm64@1.20260926.1': + resolution: {integrity: sha512-oVuLXfCnr1Xu9sBMNYrEPcFZQNMcviNcacz9l+oqQiLHc5UrVp++lpKbsuWstCfh+/c39Pp37TVeaNBLA8+ReA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260925.1': + resolution: {integrity: sha512-LczXd6uEMqEmBxBJPFwplRIwKmUHqj5t8hilIWOtbX5i0TFp7/iMYBXbAiIhvZnev+8yiTyA7uWBBjDvfPh5Ug==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260828.1': - resolution: {integrity: sha512-FBTaUQ1xcU9jcp4OyBPcH8x0QiFvc1iuZL2GkD8zp2q1WyTVHYOptRDQUU+cuHjt0rQ2EIKVPBjahPxfa0joBw==} + '@cloudflare/workerd-linux-64@1.20260926.1': + resolution: {integrity: sha512-BjmiDvYBVBG5248gCHSZS3yONbcm3/3qKTu9RaQA8o7jQHJfXx9JnWbhkld2KsbXnRGEGHFwpLLidIpIGtQYUA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260925.1': + resolution: {integrity: sha512-hziOugQbLuva85w8z9bGJbEtABKzSip653x0NgvxgP/Q3n1TpRqRyIJRBufbSUZRkJlfH2se2nQaTx2OU0TT3g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260926.1': + resolution: {integrity: sha512-28bdfQDIFcpaKXMPisADy8BchpIsX+tEAhD+bhfEBXukFGFr8F3ouyiG3HzN3CqAh/OYvifJizzFZtptUPCy7w==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260828.1': - resolution: {integrity: sha512-yvr77hC7dUbvK5K+SCg062kkPq3sx+drV1PcgHslzHDYcJBtT0V3X80qLE49LW1vq2svaeNmsVQS+vHsqWu8cQ==} + '@cloudflare/workerd-windows-64@1.20260925.1': + resolution: {integrity: sha512-mG0JYuF6HVCgXSsNeB5os8Vtdprk5jHPysviKNNMHJSLA3Q1djFjSmK4+pnQR/zOAgzNo0HJ+Tpcz+tZj1Q7fQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workerd-windows-64@1.20260926.1': + resolution: {integrity: sha512-ehdL3ataKdEmlnW9oom6OZUR8n3XsTL/zl8+KGREpLi5sMSW0mek0PrF4wK9SX51f3Alnlu8tF8eKBg+mc3xFw==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -1377,6 +1447,9 @@ packages: peerDependencies: prettier: '>=3' + '@lasvegasfortransit/typescript-config@file:.lvbt/web-platform/packages/typescript-config': + resolution: {directory: .lvbt/web-platform/packages/typescript-config, type: directory} + '@lasvegasfortransit/web-platform@file:.lvbt/web-platform/packages/web-platform': resolution: {directory: .lvbt/web-platform/packages/web-platform, type: directory} engines: {node: '>=24'} @@ -2312,6 +2385,11 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cf@1.0.0-beta.5: + resolution: {integrity: sha512-IKGKDAQNs8hbu7jOt3qSWKxDjmJTS+hcHCJ5B0IkF7gZ3RmEfMTUxZ0Fi7GRftjag04J127u8Nk8uppXx3OBZw==} + engines: {node: '>=22'} + hasBin: true + chalk-template@1.1.2: resolution: {integrity: sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==} engines: {node: '>=14.16'} @@ -4279,8 +4357,12 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - miniflare@5.20260828.0-alpha: - resolution: {integrity: sha512-6nbxhZEcz/UET3Y1OnYPsrAUjUmuFoib3ynUqteRdn1YnDxsLg8cwgZJZCk9QmtOmGzXwzXzgE/d/C0dJAPtVw==} + miniflare@5.20260925.0-alpha: + resolution: {integrity: sha512-ly2ygNOfuouMiPcyTNEUW6f3+zgndfC45e1nQZ2LSciOSFfrih8V7ciaZ01I/64Ccp3F6fWraxh5VuHPsP3AAw==} + engines: {node: '>=22.0.0'} + + miniflare@5.20260926.0-alpha: + resolution: {integrity: sha512-m/M3rpLnaouw6QSoKzylfw6Z1dnOwRM48idnZ7kc5ivRqBXANFQ8XqKCyQ/V+xqQ9Rg1L99j4WkZGVU9uT4L2w==} engines: {node: '>=22.0.0'} minimatch@10.2.6: @@ -4309,6 +4391,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -5843,16 +5928,22 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workerd@1.20260828.1: - resolution: {integrity: sha512-pB9yvt0kkwZDAGZHmpY59r0o3hM0DzdW6BJERqwZOhunZ3ssOyDSgQxOQer2cSZW4YCFeOTIQYN1qwhK5wv/Cw==} + workerd@1.20260925.1: + resolution: {integrity: sha512-cl7Mrvhm+xEC9brMBIWIHyfYl7y5dGH3CjQog6CglWhgRwAq+/b+2gHBXNQb4hsjLXQVCuCYExJRYCjEaZmAlA==} engines: {node: '>=16'} + hasBin: true - wrangler@4.127.1: - resolution: {integrity: sha512-OzsiNgaI8i681L/+KnAKc+uEZ5D57xK5JuNvCOpRKICF4/5Q3Cu1oTGuUiT/f3GDUqQb3gzXNT0tfOHGMEtknw==} + workerd@1.20260926.1: + resolution: {integrity: sha512-YojhsWBuZwk3Jk9OC0QfTgVbLeQOD1tK1olC9ZXAUcmeip8z4NE/xtttpxCsuTvQBRqppvy7Tkac0kxPYMjIXA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.141.0: + resolution: {integrity: sha512-8+LRZEcynBMfVYgO4N378z5XyuajaILt2IzsdWRLCHrRWz7mmGWv4igdVjqbRQMJtE0S5bSmyz0W2XpA6UM/ow==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^5.20260828.1 + '@cloudflare/workers-types': ^5.20260925.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -6009,6 +6100,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} @@ -6331,27 +6425,61 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@cloudflare/build-output-utils@0.8.2': + dependencies: + '@cloudflare/config': 0.20.0 + + '@cloudflare/codemods@0.2.1': {} + + '@cloudflare/config@0.20.0': + dependencies: + zod: 4.4.3 + '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260828.1)': + '@cloudflare/runtime-types@0.1.4(@types/node@24.13.3)': + dependencies: + miniflare: 5.20260926.0-alpha(@types/node@24.13.3) + workerd: 1.20260926.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260925.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260828.1 + workerd: 1.20260925.1 + + '@cloudflare/workerd-darwin-64@1.20260925.1': + optional: true + + '@cloudflare/workerd-darwin-64@1.20260926.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260925.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260926.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260925.1': + optional: true - '@cloudflare/workerd-darwin-64@1.20260828.1': + '@cloudflare/workerd-linux-64@1.20260926.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260828.1': + '@cloudflare/workerd-linux-arm64@1.20260925.1': optional: true - '@cloudflare/workerd-linux-64@1.20260828.1': + '@cloudflare/workerd-linux-arm64@1.20260926.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260828.1': + '@cloudflare/workerd-windows-64@1.20260925.1': optional: true - '@cloudflare/workerd-windows-64@1.20260828.1': + '@cloudflare/workerd-windows-64@1.20260926.1': optional: true '@cloudflare/workers-types@5.20260901.1': {} @@ -7017,6 +7145,8 @@ snapshots: dependencies: prettier: 3.9.6 + '@lasvegasfortransit/typescript-config@file:.lvbt/web-platform/packages/typescript-config': {} + '@lasvegasfortransit/web-platform@file:.lvbt/web-platform/packages/web-platform': dependencies: zod: 4.5.4 @@ -8040,6 +8170,20 @@ snapshots: ccount@2.0.1: {} + cf@1.0.0-beta.5(@types/node@24.13.3): + dependencies: + '@cloudflare/build-output-utils': 0.8.2 + '@cloudflare/codemods': 0.2.1 + '@cloudflare/config': 0.20.0 + '@cloudflare/runtime-types': 0.1.4(@types/node@24.13.3) + blake3-wasm: 2.1.5 + miniflare: 5.20260926.0-alpha(@types/node@24.13.3) + minisearch: 7.2.0 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + chalk-template@1.1.2: dependencies: chalk: 5.6.2 @@ -10469,12 +10613,12 @@ snapshots: mimic-response@4.0.0: {} - miniflare@5.20260828.0-alpha(@types/node@24.13.3): + miniflare@5.20260925.0-alpha(@types/node@24.13.3): dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.35.4(@types/node@24.13.3) undici: 7.29.0 - workerd: 1.20260828.1 + workerd: 1.20260925.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: @@ -10482,12 +10626,25 @@ snapshots: - bufferutil - utf-8-validate - miniflare@5.20260828.0-alpha(@types/node@25.9.0): + miniflare@5.20260925.0-alpha(@types/node@25.9.0): dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.35.4(@types/node@25.9.0) undici: 7.29.0 - workerd: 1.20260828.1 + workerd: 1.20260925.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + miniflare@5.20260926.0-alpha(@types/node@24.13.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4(@types/node@24.13.3) + undici: 7.29.0 + workerd: 1.20260926.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: @@ -10521,6 +10678,8 @@ snapshots: minipass@7.1.3: {} + minisearch@7.2.0: {} + mitt@3.0.1: {} mkdirp@0.5.6: @@ -12295,24 +12454,32 @@ snapshots: word-wrap@1.2.5: {} - workerd@1.20260828.1: + workerd@1.20260925.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260925.1 + '@cloudflare/workerd-darwin-arm64': 1.20260925.1 + '@cloudflare/workerd-linux-64': 1.20260925.1 + '@cloudflare/workerd-linux-arm64': 1.20260925.1 + '@cloudflare/workerd-windows-64': 1.20260925.1 + + workerd@1.20260926.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260828.1 - '@cloudflare/workerd-darwin-arm64': 1.20260828.1 - '@cloudflare/workerd-linux-64': 1.20260828.1 - '@cloudflare/workerd-linux-arm64': 1.20260828.1 - '@cloudflare/workerd-windows-64': 1.20260828.1 + '@cloudflare/workerd-darwin-64': 1.20260926.1 + '@cloudflare/workerd-darwin-arm64': 1.20260926.1 + '@cloudflare/workerd-linux-64': 1.20260926.1 + '@cloudflare/workerd-linux-arm64': 1.20260926.1 + '@cloudflare/workerd-windows-64': 1.20260926.1 - wrangler@4.127.1(@cloudflare/workers-types@5.20260901.1)(@types/node@24.13.3): + wrangler@4.141.0(@cloudflare/workers-types@5.20260901.1)(@types/node@24.13.3): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260828.1) + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260925.1) blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 5.20260828.0-alpha(@types/node@24.13.3) + miniflare: 5.20260925.0-alpha(@types/node@24.13.3) path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260828.1 + workerd: 1.20260925.1 optionalDependencies: '@cloudflare/workers-types': 5.20260901.1 fsevents: 2.3.3 @@ -12321,18 +12488,17 @@ snapshots: - bufferutil - utf-8-validate - wrangler@4.127.1(@cloudflare/workers-types@5.20260901.1)(@types/node@25.9.0): + wrangler@4.141.0(@types/node@25.9.0): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260828.1) + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260925.1) blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 5.20260828.0-alpha(@types/node@25.9.0) + miniflare: 5.20260925.0-alpha(@types/node@25.9.0) path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260828.1 + workerd: 1.20260925.1 optionalDependencies: - '@cloudflare/workers-types': 5.20260901.1 fsevents: 2.3.3 transitivePeerDependencies: - '@types/node' @@ -12495,6 +12661,8 @@ snapshots: zod@3.25.76: {} + zod@4.4.3: {} + zod@4.5.4: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68a6eea2..7cb1edb2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -47,6 +47,7 @@ catalog: '@types/react-dom': 19.2.5 '@vitejs/plugin-react': 6.1.1 astro: 7.2.10 + cf: 1.0.0-beta.5 eslint: 10.9.1 eslint-config-prettier: 10.1.8 eslint-plugin-react-hooks: 7.1.1 @@ -69,7 +70,7 @@ catalog: typescript-eslint: 8.69.0 vite: 8.2.2 vitest: 4.1.11 - wrangler: 4.127.1 + wrangler: 4.141.0 zod: 4.5.4 # This repository's own entries. '@astrojs/mdx': 8.0.1 From 79579292178405ee59bd2fd0f75e6b7cd28320e6 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Tue, 29 Sep 2026 20:02:57 -0700 Subject: [PATCH 2/4] build: check cf website bundle in CI Validation builds the site before the Cloudflare dry run, so missing Worker or asset output fails the required repository check. Co-authored-by: Codex --- apps/deploy/package.json | 4 +++- pnpm-lock.yaml | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/deploy/package.json b/apps/deploy/package.json index 369c97e7..06082830 100644 --- a/apps/deploy/package.json +++ b/apps/deploy/package.json @@ -6,10 +6,12 @@ "scripts": { "lint": "eslint . --max-warnings 0", "check-types": "tsc --project tsconfig.json", - "test": "node --test tests/*.test.ts" + "test": "node --test tests/*.test.ts", + "validate": "cf deploy --dry-run" }, "devDependencies": { "@lasvegasfortransit/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", + "@lasvegasfortransit/site": "workspace:*", "@lasvegasfortransit/typescript-config": "file:../../.lvbt/web-platform/packages/typescript-config", "@types/node": "catalog:", "cf": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e5db9db..3151c44d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: '@lasvegasfortransit/eslint-config': specifier: file:../../.lvbt/web-platform/packages/eslint-config version: file:.lvbt/web-platform/packages/eslint-config(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(turbo@2.10.12)(typescript@6.0.3) + '@lasvegasfortransit/site': + specifier: workspace:* + version: link:../site '@lasvegasfortransit/typescript-config': specifier: file:../../.lvbt/web-platform/packages/typescript-config version: file:.lvbt/web-platform/packages/typescript-config From 3c2c3e7723ef6d8e891f5d6aac922f743f7d5ad6 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Tue, 29 Sep 2026 22:09:19 -0700 Subject: [PATCH 3/4] build: read Cloudflare account from deploy environment Production and preview recovery deployments use CLOUDFLARE_ACCOUNT_ID. Keep the account identifier out of the typed configuration while the existing database bindings and release gate remain pinned. Co-authored-by: Codex --- apps/deploy/cloudflare.config.ts | 3 --- apps/deploy/tests/config.test.ts | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/deploy/cloudflare.config.ts b/apps/deploy/cloudflare.config.ts index d79d3ae5..456ce00d 100644 --- a/apps/deploy/cloudflare.config.ts +++ b/apps/deploy/cloudflare.config.ts @@ -1,6 +1,5 @@ import { bindings, defineConfig } from 'cf/config'; -const accountId = '2557b5c2e166292ded0f8425b73075e9'; const commonWorker = { compatibilityDate: '2026-09-04', compatibilityFlags: ['nodejs_compat'], @@ -32,7 +31,6 @@ export default defineConfig((ctx) => { switch (ctx.mode) { case 'preview': { return { - accountId, worker: { ...commonWorker, name: 'lvbt-website-preview', @@ -48,7 +46,6 @@ export default defineConfig((ctx) => { } default: { return { - accountId, worker: { ...commonWorker, name: 'lvbt-website', diff --git a/apps/deploy/tests/config.test.ts b/apps/deploy/tests/config.test.ts index 2219609a..9c7bf695 100644 --- a/apps/deploy/tests/config.test.ts +++ b/apps/deploy/tests/config.test.ts @@ -41,7 +41,7 @@ for (const mode of [undefined, 'preview']) { const expectedDb = mode === 'preview' ? wrangler.env.preview.d1_databases[0] : wrangler.d1_databases[0]; assert.ok(expectedDb); - assert.equal(cf.accountId, '2557b5c2e166292ded0f8425b73075e9'); + assert.equal('accountId' in cf, false); assert.equal(cf.worker.name, mode === 'preview' ? `${wrangler.name}-preview` : wrangler.name); assert.equal(cf.worker.compatibilityDate, wrangler.compatibility_date); assert.deepEqual(cf.worker.compatibilityFlags, wrangler.compatibility_flags); From e36361d70d53c274645a1729cac0767a1d91ac3d Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Tue, 29 Sep 2026 22:43:42 -0700 Subject: [PATCH 4/4] test(site): Avoid recurring event metadata timeout Keep the recurring-event assertion in its dedicated suite. Its title and heading are ready when each page loads, allowing preview checks to finish within the test limit. Co-authored-by: Codex --- apps/site/tests/e2e/body-links.spec.ts | 41 ---------------------- apps/site/tests/e2e/event-metadata.spec.ts | 2 -- 2 files changed, 43 deletions(-) diff --git a/apps/site/tests/e2e/body-links.spec.ts b/apps/site/tests/e2e/body-links.spec.ts index 72d5912f..cf8d2eb6 100644 --- a/apps/site/tests/e2e/body-links.spec.ts +++ b/apps/site/tests/e2e/body-links.spec.ts @@ -181,47 +181,6 @@ test.describe('body content links', () => { expect(results.qr).not.toContain('/qr/'); }); - test('uses date-specific metadata for recurring event pages', async ({ page }) => { - await page.goto('/events'); - await page.waitForLoadState('networkidle'); - - const eventPaths = [ - ...new Set( - await page - .locator('main a[href^="/events/"]') - .evaluateAll((links) => - links - .map((link) => new URL((link as HTMLAnchorElement).href).pathname) - .filter((path) => /^\/events\/[^/]+\/?$/.test(path)), - ), - ), - ]; - - const metaTitlesByHeading = new Map(); - for (const eventPath of eventPaths) { - await page.goto(eventPath); - await page.waitForLoadState('networkidle'); - - const heading = (await page.locator('h1').first().innerText()).trim(); - const metaTitle = await page.title(); - const titles = metaTitlesByHeading.get(heading) ?? []; - titles.push(metaTitle); - metaTitlesByHeading.set(heading, titles); - } - - const repeatedEvents = [...metaTitlesByHeading.entries()].filter( - ([, titles]) => titles.length > 1, - ); - - expect(repeatedEvents.length).toBeGreaterThan(0); - for (const [heading, titles] of repeatedEvents) { - expect(new Set(titles).size, heading).toBe(titles.length); - for (const title of titles) { - expect(title, heading).toMatch(/[A-Z][a-z]+ \d{1,2}, 20\d{2}/); - } - } - }); - test('breadcrumbs avoid production URLs by default', async ({ page }) => { await page.goto(await firstProjectDetailPath(page)); await page.waitForLoadState('networkidle'); diff --git a/apps/site/tests/e2e/event-metadata.spec.ts b/apps/site/tests/e2e/event-metadata.spec.ts index 569057a3..cfd0b22f 100644 --- a/apps/site/tests/e2e/event-metadata.spec.ts +++ b/apps/site/tests/e2e/event-metadata.spec.ts @@ -70,7 +70,6 @@ test.describe('event metadata', () => { test('uses date-specific metadata for recurring event pages', async ({ page }) => { await page.goto('/events'); - await page.waitForLoadState('networkidle'); const eventPaths = [ ...new Set( @@ -87,7 +86,6 @@ test.describe('event metadata', () => { const metaTitlesByHeading = new Map(); for (const eventPath of eventPaths) { await page.goto(eventPath); - await page.waitForLoadState('networkidle'); const heading = (await page.locator('h1').first().innerText()).trim(); const metaTitle = await page.title();