diff --git a/.claude/settings.json b/.claude/settings.json
index 6b98fe8..a514b00 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -4,7 +4,7 @@
"source": {
"source": "github",
"repo": "LasVegasForTransit/repository-tooling",
- "ref": "v0.5.4"
+ "ref": "v0.6.3"
}
}
},
diff --git a/.lvbt/web-platform.json b/.lvbt/web-platform.json
index 6a635d4..36df38d 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": "v0.6.3",
+ "commit": "2bd5cf25df37c26ee93ced7ae1c2cb19fdaf0010",
+ "contentHash": "0b8d4f2caa5c3e71afcaf720cf3044d8762ff937100028e5bc4d39619fbed6cf",
"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 6b98fe8..a514b00 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.3"
}
}
},
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 97d169c..90ba049 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/.gitignore b/.lvbt/web-platform/examples/with-astro/.gitignore
index 4cf35a7..94646a7 100644
--- a/.lvbt/web-platform/examples/with-astro/.gitignore
+++ b/.lvbt/web-platform/examples/with-astro/.gitignore
@@ -7,6 +7,7 @@ blob-report/
**/playwright/.cache/
.turbo/
.wrangler/
+.cloudflare/
.env
.env.*
!.env.example
diff --git a/.lvbt/web-platform/examples/with-astro/AGENTS.md b/.lvbt/web-platform/examples/with-astro/AGENTS.md
index b127dba..0a8344b 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 98140f7..4bc99a9 100644
--- a/.lvbt/web-platform/examples/with-astro/README.md
+++ b/.lvbt/web-platform/examples/with-astro/README.md
@@ -15,7 +15,7 @@ pnpm check # the same check CI runs
pnpm dev # the site at http://127.0.0.1:4321
```
-Then rename the root package and the Worker in `apps/site/wrangler.jsonc`, set `site` in
+Then rename the root package and the Worker in `apps/deploy/cloudflare.config.ts`, set `site` in
`apps/site/astro.config.ts`, and replace the scopes in `.lvbt/commit-scopes.txt` with this
repository's boundaries.
@@ -23,9 +23,10 @@ repository's boundaries.
- `apps/site` is the Astro site: pages under `src/pages`, layouts under `src/layouts`, Tailwind in
`src/styles/global.css`, unit tests under `tests/`, end-to-end tests under `tests/e2e/`
+- `apps/deploy` holds the `cf` Worker configuration and reads `apps/site/dist` after the site build
- `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 the site and deploys the canonical `cf` project in `apps/deploy`;
`.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/deploy/cloudflare.config.ts b/.lvbt/web-platform/examples/with-astro/apps/deploy/cloudflare.config.ts
new file mode 100644
index 0000000..9be2e6e
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/cloudflare.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'cf/config';
+
+// Set CLOUDFLARE_ACCOUNT_ID in the deploy environment before publishing.
+export default defineConfig({
+ worker: {
+ name: 'lvbt-site',
+ compatibilityDate: '2026-08-31',
+ previewUrls: true,
+ assets: {
+ notFoundHandling: '404-page',
+ },
+ observability: {
+ enabled: true,
+ },
+ },
+});
diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/eslint.config.js b/.lvbt/web-platform/examples/with-astro/apps/deploy/eslint.config.js
new file mode 100644
index 0000000..fe2b986
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/eslint.config.js
@@ -0,0 +1,3 @@
+import { config } from '@lasvegasfortransit/eslint-config/base';
+
+export default config;
diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/package.json b/.lvbt/web-platform/examples/with-astro/apps/deploy/package.json
new file mode 100644
index 0000000..945f8d4
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@lasvegasfortransit/deploy",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "check-types": "tsc --noEmit",
+ "test": "node --test",
+ "validate": "cf deploy --dry-run"
+ },
+ "devDependencies": {
+ "@lasvegasfortransit/eslint-config": "0.6.3",
+ "@lasvegasfortransit/site": "workspace:*",
+ "@lasvegasfortransit/typescript-config": "0.6.3",
+ "cf": "catalog:",
+ "eslint": "catalog:",
+ "typescript": "catalog:",
+ "wrangler": "catalog:"
+ }
+}
diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/tsconfig.json b/.lvbt/web-platform/examples/with-astro/apps/deploy/tsconfig.json
new file mode 100644
index 0000000..424aa9e
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-astro/apps/deploy/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "@lasvegasfortransit/typescript-config/base.json",
+ "include": ["*.config.ts"]
+}
diff --git a/.lvbt/web-platform/examples/with-astro/apps/deploy/wrangler.config.ts b/.lvbt/web-platform/examples/with-astro/apps/deploy/wrangler.config.ts
new file mode 100644
index 0000000..2cb09ad
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-astro/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/.lvbt/web-platform/examples/with-astro/apps/site/package.json b/.lvbt/web-platform/examples/with-astro/apps/site/package.json
index 8398cd7..fa9e297 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.3",
+ "@lasvegasfortransit/playwright-config": "0.6.3",
+ "@lasvegasfortransit/typescript-config": "0.6.3",
+ "@lasvegasfortransit/vitest-config": "0.6.3",
"@playwright/test": "catalog:",
"@types/node": "catalog:",
"eslint": "catalog:",
diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/wrangler.jsonc b/.lvbt/web-platform/examples/with-astro/apps/site/wrangler.jsonc
deleted file mode 100644
index a4aab15..0000000
--- a/.lvbt/web-platform/examples/with-astro/apps/site/wrangler.jsonc
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "$schema": "./node_modules/wrangler/config-schema.json",
- // The Worker name. Rename it before the first deploy.
- "name": "lvbt-site",
- "compatibility_date": "2026-08-31",
- "preview_urls": true,
- "assets": {
- "directory": "./dist",
- "not_found_handling": "404-page",
- },
- "observability": {
- "enabled": true,
- },
-}
diff --git a/.lvbt/web-platform/examples/with-astro/docs/development/reference/glossary.md b/.lvbt/web-platform/examples/with-astro/docs/development/reference/glossary.md
index 55588ff..c3eaa61 100644
--- a/.lvbt/web-platform/examples/with-astro/docs/development/reference/glossary.md
+++ b/.lvbt/web-platform/examples/with-astro/docs/development/reference/glossary.md
@@ -51,7 +51,7 @@ request. The required status is named `Validate`.
deploy this repository, with a fix printed for anything missing.
**Platform manifest**: `platform.json`, next to an app's production
-`wrangler.jsonc`, which lists everything the app needs in production: its database, bucket, bot
-check, admin sign-in, email domain, secrets, and the values that must never be set there.
+configuration, which lists everything the app needs in production: its database, bucket, bot check,
+admin sign-in, email domain, secrets, and the values that must never be set there.
`pnpm preflight --production` checks production against it, and `pnpm bootstrap --production` sets
up what is missing.
diff --git a/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md b/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md
index 3646d2c..f22ffcc 100644
--- a/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md
+++ b/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md
@@ -34,7 +34,7 @@ prints one line per check:
ok git hooks core.hooksPath is .githooks
ok commit scopes .lvbt/commit-scopes.txt is present
ok GitHub CLI gh is installed and signed in
- ok Cloudflare wrangler is signed in; deployables: apps/site
+ ok Cloudflare cf ready for: apps/deploy
preflight: all 7 checks passed
```
@@ -47,9 +47,9 @@ pnpm check
```
This is the same command CI runs: formatting, documentation links, the repository-shape rules, then
-lint, typecheck, and tests for every package. On a fresh clone it passes. When it fails, the output
-names the package and file; `pnpm check:fix` repairs everything a machine can (formatting and
-auto-fixable lint findings).
+lint, typecheck, tests, a site build, and a Cloudflare deploy dry run. On a fresh clone it passes.
+When it fails, the output names the package and file; `pnpm check:fix` repairs formatting and
+auto-fixable lint findings.
## 3. Make a change and commit it
diff --git a/.lvbt/web-platform/examples/with-astro/package.json b/.lvbt/web-platform/examples/with-astro/package.json
index 0dbfd39..ea1e64a 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.3",
+ "@lasvegasfortransit/prettier-config": "0.6.3",
"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 c4b29ca..fec240d 100644
--- a/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml
+++ b/.lvbt/web-platform/examples/with-astro/pnpm-workspace.yaml
@@ -16,7 +16,11 @@ minimumReleaseAge: 1440
# Temporary transitive pins for published security fixes not yet selected by
# their direct dependents.
overrides:
+ brace-expansion: 5.0.12
+ fast-uri: 3.1.8
sharp: 0.35.4
+ 'undici@^7.0.0': 7.29.1
+ 'undici@^8.0.0': 8.10.2
smol-toml: 1.8.0
svgo: 4.1.0
@@ -61,5 +65,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 6b98fe8..a514b00 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.3"
}
}
},
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 97d169c..90ba049 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/.gitignore b/.lvbt/web-platform/examples/with-vite-react/.gitignore
index 65717d9..393f057 100644
--- a/.lvbt/web-platform/examples/with-vite-react/.gitignore
+++ b/.lvbt/web-platform/examples/with-vite-react/.gitignore
@@ -7,6 +7,7 @@ blob-report/
**/playwright/.cache/
.turbo/
.wrangler/
+.cloudflare/
.env
.env.*
!.env.example
diff --git a/.lvbt/web-platform/examples/with-vite-react/AGENTS.md b/.lvbt/web-platform/examples/with-vite-react/AGENTS.md
index b127dba..0a8344b 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 98140f7..42ad025 100644
--- a/.lvbt/web-platform/examples/with-vite-react/README.md
+++ b/.lvbt/web-platform/examples/with-vite-react/README.md
@@ -15,17 +15,17 @@ pnpm check # the same check CI runs
pnpm dev # the site at http://127.0.0.1:4321
```
-Then rename the root package and the Worker in `apps/site/wrangler.jsonc`, set `site` in
-`apps/site/astro.config.ts`, and replace the scopes in `.lvbt/commit-scopes.txt` with this
-repository's boundaries.
+Then rename the root package and the Worker in `apps/deploy/cloudflare.config.ts`, and replace the
+scopes in `.lvbt/commit-scopes.txt` with this repository's boundaries.
## Layout
-- `apps/site` is the Astro site: pages under `src/pages`, layouts under `src/layouts`, Tailwind in
- `src/styles/global.css`, unit tests under `tests/`, end-to-end tests under `tests/e2e/`
+- `apps/app` is the Vite app: source under `src/`, unit tests under `tests/`, and end-to-end tests
+ under `tests/e2e/`
+- `apps/deploy` holds the `cf` Worker configuration and reads `apps/app/dist` after the app build
- `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 the app and deploys the canonical `cf` project in `apps/deploy`;
`.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 1acf948..afa44e5 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.3",
+ "@lasvegasfortransit/playwright-config": "0.6.3",
+ "@lasvegasfortransit/typescript-config": "0.6.3",
+ "@lasvegasfortransit/vitest-config": "0.6.3",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/wrangler.jsonc b/.lvbt/web-platform/examples/with-vite-react/apps/app/wrangler.jsonc
deleted file mode 100644
index 9c876a5..0000000
--- a/.lvbt/web-platform/examples/with-vite-react/apps/app/wrangler.jsonc
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "$schema": "./node_modules/wrangler/config-schema.json",
- // The Worker name. Rename it before the first deploy.
- "name": "lvbt-app",
- "compatibility_date": "2026-08-31",
- "preview_urls": true,
- "assets": {
- "directory": "./dist",
- "not_found_handling": "single-page-application",
- },
- "observability": {
- "enabled": true,
- },
-}
diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/cloudflare.config.ts b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/cloudflare.config.ts
new file mode 100644
index 0000000..f5541d9
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/cloudflare.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'cf/config';
+
+// Set CLOUDFLARE_ACCOUNT_ID in the deploy environment before publishing.
+export default defineConfig({
+ worker: {
+ name: 'lvbt-app',
+ compatibilityDate: '2026-08-31',
+ previewUrls: true,
+ assets: {
+ notFoundHandling: 'single-page-application',
+ },
+ observability: {
+ enabled: true,
+ },
+ },
+});
diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/eslint.config.js b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/eslint.config.js
new file mode 100644
index 0000000..fe2b986
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/eslint.config.js
@@ -0,0 +1,3 @@
+import { config } from '@lasvegasfortransit/eslint-config/base';
+
+export default config;
diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/package.json b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/package.json
new file mode 100644
index 0000000..0c3b085
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@lasvegasfortransit/deploy",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "check-types": "tsc --noEmit",
+ "test": "node --test",
+ "validate": "cf deploy --dry-run"
+ },
+ "devDependencies": {
+ "@lasvegasfortransit/eslint-config": "0.6.3",
+ "@lasvegasfortransit/app": "workspace:*",
+ "@lasvegasfortransit/typescript-config": "0.6.3",
+ "cf": "catalog:",
+ "eslint": "catalog:",
+ "typescript": "catalog:",
+ "wrangler": "catalog:"
+ }
+}
diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/tsconfig.json b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/tsconfig.json
new file mode 100644
index 0000000..424aa9e
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "@lasvegasfortransit/typescript-config/base.json",
+ "include": ["*.config.ts"]
+}
diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/deploy/wrangler.config.ts b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/wrangler.config.ts
new file mode 100644
index 0000000..fa36cac
--- /dev/null
+++ b/.lvbt/web-platform/examples/with-vite-react/apps/deploy/wrangler.config.ts
@@ -0,0 +1,8 @@
+import { defineWranglerConfig } from 'wrangler/experimental-config';
+
+export default defineWranglerConfig({
+ types: {
+ generate: false,
+ },
+ assetsDirectory: '../app/dist',
+});
diff --git a/.lvbt/web-platform/examples/with-vite-react/docs/development/reference/glossary.md b/.lvbt/web-platform/examples/with-vite-react/docs/development/reference/glossary.md
index 55588ff..c3eaa61 100644
--- a/.lvbt/web-platform/examples/with-vite-react/docs/development/reference/glossary.md
+++ b/.lvbt/web-platform/examples/with-vite-react/docs/development/reference/glossary.md
@@ -51,7 +51,7 @@ request. The required status is named `Validate`.
deploy this repository, with a fix printed for anything missing.
**Platform manifest**: `platform.json`, next to an app's production
-`wrangler.jsonc`, which lists everything the app needs in production: its database, bucket, bot
-check, admin sign-in, email domain, secrets, and the values that must never be set there.
+configuration, which lists everything the app needs in production: its database, bucket, bot check,
+admin sign-in, email domain, secrets, and the values that must never be set there.
`pnpm preflight --production` checks production against it, and `pnpm bootstrap --production` sets
up what is missing.
diff --git a/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md b/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md
index dc39c88..acf377f 100644
--- a/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md
+++ b/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md
@@ -34,7 +34,7 @@ prints one line per check:
ok git hooks core.hooksPath is .githooks
ok commit scopes .lvbt/commit-scopes.txt is present
ok GitHub CLI gh is installed and signed in
- ok Cloudflare wrangler is signed in; deployables: apps/app
+ ok Cloudflare cf ready for: apps/deploy
preflight: all 7 checks passed
```
@@ -47,9 +47,9 @@ pnpm check
```
This is the same command CI runs: formatting, documentation links, the repository-shape rules, then
-lint, typecheck, and tests for every package. On a fresh clone it passes. When it fails, the output
-names the package and file; `pnpm check:fix` repairs everything a machine can (formatting and
-auto-fixable lint findings).
+lint, typecheck, tests, an app build, and a Cloudflare deploy dry run. On a fresh clone it passes.
+When it fails, the output names the package and file; `pnpm check:fix` repairs formatting and
+auto-fixable lint findings.
## 3. Make a change and commit it
diff --git a/.lvbt/web-platform/examples/with-vite-react/package.json b/.lvbt/web-platform/examples/with-vite-react/package.json
index a9a48da..577a04b 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.3",
+ "@lasvegasfortransit/prettier-config": "0.6.3",
"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 c4b29ca..fec240d 100644
--- a/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml
+++ b/.lvbt/web-platform/examples/with-vite-react/pnpm-workspace.yaml
@@ -16,7 +16,11 @@ minimumReleaseAge: 1440
# Temporary transitive pins for published security fixes not yet selected by
# their direct dependents.
overrides:
+ brace-expansion: 5.0.12
+ fast-uri: 3.1.8
sharp: 0.35.4
+ 'undici@^7.0.0': 7.29.1
+ 'undici@^8.0.0': 8.10.2
smol-toml: 1.8.0
svgo: 4.1.0
@@ -61,5 +65,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 4940880..be56126 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 447dc57..7b4c1d4 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.3",
"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 5cf923b..bee8553 100644
--- a/.lvbt/web-platform/packages/cli/platform.schema.json
+++ b/.lvbt/web-platform/packages/cli/platform.schema.json
@@ -82,13 +82,17 @@
"cloudflare": {
"type": "object",
"additionalProperties": false,
- "required": ["accountId", "zone", "worker"],
+ "required": ["zone", "worker"],
"properties": {
"accountId": {
"type": "string",
"pattern": "^[0-9a-f]{32}$",
"description": "The Cloudflare account that owns the Worker and the zone."
},
+ "accountIdEnv": {
+ "const": "CLOUDFLARE_ACCOUNT_ID",
+ "description": "Read the account ID from CLOUDFLARE_ACCOUNT_ID at production check or setup time."
+ },
"zone": {
"type": "object",
"additionalProperties": false,
@@ -107,6 +111,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 +250,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 981b151..b11ea44 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.3",
"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 34a7e6f..58e897f 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.3",
"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 93fe194..9886a61 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 c0b8cf4..e0b0bfa 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 3d25dfe..f5d233d 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) {
@@ -148,16 +187,55 @@ async function repositoryFindings(cwd, report) {
);
}
-async function cloudflareFindings(cwd, report) {
+async function cloudflareFindings(cwd, report, { productionBootstrap = false } = {}) {
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 {
+ const identity = JSON.parse(whoami);
+ authenticated = identity.authenticated === true && identity.tokenValid !== false;
+ } 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 if (productionBootstrap)
+ report.warn(
+ 'Cloudflare cf',
+ 'cf is not signed in; setup can continue, but cf resource commands need authentication',
+ 'provide CLOUDFLARE_API_TOKEN for this session or run pnpm exec cf auth login',
+ );
+ else
+ report.fail(
+ 'Cloudflare cf',
+ 'cf is not signed in',
+ 'provide CLOUDFLARE_API_TOKEN for this session or run 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');
+ }
}
/**
@@ -165,23 +243,24 @@ async function cloudflareFindings(cwd, report) {
* command that fixes it. Returns the failures instead of throwing, so
* `--production` can still report on production.
*/
-async function machineFindings(cwd) {
+async function machineFindings(cwd, { productionBootstrap = false } = {}) {
const packageJson = await readJson(path.join(cwd, 'package.json'));
const findings = [];
const report = {
pass: (label, detail) => findings.push({ ok: true, label, detail }),
+ warn: (label, detail, fix) => findings.push({ ok: true, warning: true, label, detail, fix }),
fail: (label, detail, fix) => findings.push({ ok: false, label, detail, fix }),
};
await toolchainFindings(cwd, packageJson, report);
await repositoryFindings(cwd, report);
- await cloudflareFindings(cwd, report);
+ await cloudflareFindings(cwd, report, { productionBootstrap });
for (const finding of findings) {
process.stdout.write(
- ` ${finding.ok ? 'ok ' : 'FAIL'} ${finding.label.padEnd(14)} ${finding.detail}\n`,
+ ` ${finding.warning ? 'WARN' : finding.ok ? 'ok ' : 'FAIL'} ${finding.label.padEnd(14)} ${finding.detail}\n`,
);
- if (!finding.ok) process.stdout.write(` fix: ${finding.fix}\n`);
+ if (finding.fix) process.stdout.write(` fix: ${finding.fix}\n`);
}
const failed = findings.filter((finding) => !finding.ok);
if (failed.length === 0)
@@ -225,7 +304,7 @@ export async function bootstrap({ cwd, options = {} }) {
const install = spawnSync('pnpm', ['install'], { cwd, stdio: 'inherit' });
if (install.status !== 0)
throw new CliError('bootstrap: pnpm install failed', install.status ?? 1);
- const machine = await machineFindings(cwd);
+ const machine = await machineFindings(cwd, { productionBootstrap: options.production });
if (machine) throw new CliError(machine, 1);
if (options.production) {
await platformBootstrap({ cwd, options });
@@ -241,21 +320,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 +350,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 dbd9055..c69dd01 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);
}
@@ -172,9 +173,8 @@ export async function deleteSecret(context, action) {
}
/**
- * Whether the config names the database this run created. Wrangler applies
- * migrations to the config's database_id, so until a pull request puts the
- * new id there, applying them would reach the wrong database or none.
+ * Whether the config names the database this run created. The account
+ * inventory supplies its ID; any explicit ID in the config must agree.
*/
export async function namedInConfig(context, action) {
const state = context.observe ? await context.observe() : context.state;
@@ -182,9 +182,10 @@ export async function namedInConfig(context, action) {
const bound = state.config.ok
? state.config.value.d1.find((entry) => entry.binding === action.binding)
: undefined;
- if (real && bound?.id === real.id) return true;
+ if (real && bound?.name === action.name && (bound.id === undefined || 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} binds ${action.binding} to ${action.name}${bound?.id && real ? ` with ID ${real.id}` : ''}. 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 9acce1b..73df97f 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 d504c84..1d3464f 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}. Bind its name in ${context.configPath} through a pull request; the final report shows its ID if you choose to pin it.\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 130f700..3b3e00b 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 a6ad1c5..0236d1a 100644
--- a/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs
+++ b/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs
@@ -33,6 +33,16 @@ export function defaultServices() {
};
}
+/** Resolve a manifest's account selector before any production API call. */
+export function resolveManifestAccount(manifest, env) {
+ if (!manifest.cloudflare.accountIdEnv) return manifest;
+ const name = manifest.cloudflare.accountIdEnv;
+ const accountId = env[name]?.trim();
+ if (!/^[0-9a-f]{32}$/.test(accountId ?? ''))
+ throw new CliError(`${name} must be set to the Cloudflare account's 32-character ID.`, 2);
+ return { ...manifest, cloudflare: { ...manifest.cloudflare, accountId } };
+}
+
/** The manifests `--filter` selects: `apps/site`, `site`, or `.` for the root. */
export function selectManifests(cwd, filter) {
const all = findManifests(cwd);
@@ -94,13 +104,19 @@ function setupApiProvider({ manifest, services, io, interactive }) {
}
async function inspect({ cwd, file, services, io, interactive, askForToken }) {
- const manifest = loadManifest(path.join(cwd, file));
+ const manifest = resolveManifestAccount(loadManifest(path.join(cwd, file)), services.env);
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 07524d7..afcd4d8 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';
@@ -199,9 +199,17 @@ function forbiddenErrors(manifest) {
return errors;
}
+function accountErrors(manifest) {
+ const { accountId, accountIdEnv } = manifest.cloudflare;
+ return (accountId === undefined) === (accountIdEnv === undefined)
+ ? ['cloudflare needs exactly one of accountId or accountIdEnv.']
+ : [];
+}
+
/** The rules a JSON schema cannot express: names that refer to each other, and contradictions. */
function semanticErrors(manifest) {
return [
+ ...accountErrors(manifest),
...duplicateErrors(manifest),
...referenceErrors(manifest),
...accessErrors(manifest),
@@ -252,6 +260,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 00f24ff..3e74054 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
@@ -60,7 +60,12 @@ async function observeD1(api, account, manifest, config) {
const databases = await api.client.list(`${account}/d1/database`);
const found = {};
for (const database of manifest.d1) {
- const match = databases.find((candidate) => candidate.name === database.name);
+ const matches = databases.filter((candidate) => candidate.name === database.name);
+ if (matches.length > 1)
+ throw new Error(
+ `multiple D1 databases named ${database.name} exist in this Cloudflare account`,
+ );
+ const [match] = matches;
if (!match) continue;
const entry = { id: match.uuid };
if (database.migrations) {
@@ -182,6 +187,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 +209,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 a3d7ce7..63946f2 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({
@@ -51,6 +51,7 @@ function databaseItem({ state, configPath }, database) {
next: `${SETUP} creates it`,
action: { type: 'd1.create', name: database.name },
});
+ if (!state.config.ok) return unknownItem(fields, state.config);
const bound = state.config.ok
? state.config.value.d1.find((entry) => entry.binding === database.binding)
: undefined;
@@ -59,14 +60,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}" }) in ${configPath}`
+ : `bind ${database.binding} to database_name ${database.name} in d1_databases in ${configPath}`,
});
- if (bound && bound.id !== real.id)
+ if (bound?.id !== undefined && 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}` });
}
@@ -88,13 +91,13 @@ function migrationsItem({ state, configPath }, database) {
const action = { type: 'd1.migrate', name: database.name };
const real = state.d1.ok ? state.d1.value[database.name] : undefined;
if (state.d1.ok && !real)
- // Wrangler applies migrations to the database_id in the config, so setup
- // applies them after creating the database only if the config names it.
+ // Setup applies migrations after creating the database only if the config
+ // names it and any explicit ID agrees with the account inventory.
return item({
...fields,
status: 'missing',
detail: `${files.value.length} to apply once the database exists and ${configPath} names it`,
- next: `${SETUP} applies them once ${configPath} has the new database's database_id`,
+ next: `${SETUP} applies them once ${configPath} binds ${database.binding} to ${database.name}`,
action: { ...action, binding: database.binding, afterCreate: true },
});
if (!real?.applied?.ok) return unknownItem(fields, real?.applied ?? state.d1);
@@ -106,15 +109,23 @@ function pendingItem({ state, configPath }, database, { fields, files, real, act
const pending = files.filter((file) => !real.applied.value.includes(file));
if (pending.length === 0)
return item({ ...fields, status: 'ok', detail: `all ${files.length} applied` });
+ if (!state.config.ok) return unknownItem(fields, state.config);
const bound = state.config.ok
? state.config.value.d1.find((entry) => entry.binding === database.binding)
: undefined;
- if (state.config.ok && bound?.id !== real.id)
+ if (state.config.ok && bound?.name !== database.name)
+ return item({
+ ...fields,
+ status: 'missing',
+ detail: `${pending.length} of ${files.length} not applied; they wait until ${configPath} binds ${database.binding} to ${database.name}`,
+ next: `bind ${database.binding} to ${database.name} in ${configPath}, then run ${SETUP} again`,
+ });
+ if (bound?.id !== undefined && bound.id !== real.id)
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 +163,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 420dee6..d46a794 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 deb4df5..fcf688c 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 e9be2c8..f36fd5a 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 74559be..dd1ca61 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.3",
"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 716ab21..62577e1 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.3",
"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 7448441..2d5bac5 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.3",
"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 68dccd6..6aac9fc 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.3",
"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 f5f2e01..943b3d6 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.3",
"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 d08cd48..38cc7a4 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.3",
"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 b127dba..0a8344b 100644
--- a/AGENTS.md
+++ b/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/pnpm-lock.yaml b/pnpm-lock.yaml
index e6f54ff..c567606 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,7 +35,11 @@ catalogs:
version: 4.1.11
overrides:
+ brace-expansion: 5.0.12
+ fast-uri: 3.1.8
sharp: 0.35.4
+ undici@^7.0.0: 7.29.1
+ undici@^8.0.0: 8.10.2
smol-toml: 1.8.0
svgo: 4.1.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c4b29ca..fec240d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -16,7 +16,11 @@ minimumReleaseAge: 1440
# Temporary transitive pins for published security fixes not yet selected by
# their direct dependents.
overrides:
+ brace-expansion: 5.0.12
+ fast-uri: 3.1.8
sharp: 0.35.4
+ 'undici@^7.0.0': 7.29.1
+ 'undici@^8.0.0': 8.10.2
smol-toml: 1.8.0
svgo: 4.1.0
@@ -61,5 +65,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