From 354b8bac232ea696917c972edbcb5cbb5bd40ffb Mon Sep 17 00:00:00 2001 From: lvbt-bot Date: Wed, 23 Sep 2026 21:32:07 +0000 Subject: [PATCH] chore: publish LVBT repository standard v0.4.1 Refresh this template from the reviewed v0.4.1 source. --- .claude/settings.json | 2 +- .lvbt/web-platform.json | 6 +- .../examples/with-astro/.claude/settings.json | 2 +- .../examples/with-astro/AGENTS.md | 22 +- .../with-astro/apps/site/package.json | 9 +- .../docs/development/reference/glossary.md | 6 + .../examples/with-astro/package.json | 4 +- .../examples/with-astro/turbo.json | 5 +- .../with-vite-react/.claude/settings.json | 2 +- .../examples/with-vite-react/AGENTS.md | 22 +- .../with-vite-react/apps/app/package.json | 8 +- .../docs/development/reference/glossary.md | 6 + .../examples/with-vite-react/package.json | 4 +- .lvbt/web-platform/packages/cli/package.json | 7 +- .../packages/cli/platform.schema.json | 349 +++++++++++++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .lvbt/web-platform/packages/cli/src/cli.mjs | 20 +- .../packages/cli/src/lib/arguments.mjs | 5 +- .../packages/cli/src/lib/check/contract.mjs | 43 +- .../packages/cli/src/lib/check/index.mjs | 2 + .../packages/cli/src/lib/check/platform.mjs | 25 ++ .../packages/cli/src/lib/operate.mjs | 54 ++- .../packages/cli/src/lib/platform/apply.mjs | 415 ++++++++++++++++++ .../packages/cli/src/lib/platform/guides.mjs | 185 ++++++++ .../packages/cli/src/lib/platform/index.mjs | 195 ++++++++ .../cli/src/lib/platform/manifest.mjs | 261 +++++++++++ .../packages/cli/src/lib/platform/observe.mjs | 213 +++++++++ .../cli/src/lib/platform/plan-access.mjs | 194 ++++++++ .../cli/src/lib/platform/plan-cloudflare.mjs | 190 ++++++++ .../cli/src/lib/platform/plan-items.mjs | 33 ++ .../cli/src/lib/platform/plan-values.mjs | 228 ++++++++++ .../packages/cli/src/lib/platform/plan.mjs | 53 +++ .../packages/cli/src/lib/platform/report.mjs | 39 ++ .../packages/cli/src/lib/platform/schema.mjs | 116 +++++ .../cli/src/lib/platform/services.mjs | 144 ++++++ .../cli/src/lib/platform/terminal.mjs | 89 ++++ .../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 +- .lvbt/web-platform/standards/astro-sync.ts | 146 ++++++ .lvbt/web-platform/standards/web-platform.ts | 3 + AGENTS.md | 22 +- docs/development/reference/glossary.md | 6 + 47 files changed, 3071 insertions(+), 80 deletions(-) create mode 100644 .lvbt/web-platform/packages/cli/platform.schema.json create mode 100644 .lvbt/web-platform/packages/cli/src/lib/check/platform.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/index.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/plan-access.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/plan-items.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/report.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/schema.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/services.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/terminal.mjs create mode 100644 .lvbt/web-platform/standards/astro-sync.ts diff --git a/.claude/settings.json b/.claude/settings.json index 6704e7d..a52e93d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.3.5" + "ref": "v0.4.1" } } }, diff --git a/.lvbt/web-platform.json b/.lvbt/web-platform.json index 029cfba..8306138 100644 --- a/.lvbt/web-platform.json +++ b/.lvbt/web-platform.json @@ -1,9 +1,9 @@ { "formatVersion": 1, "preset": "lvbt-web", - "release": "v0.3.5", - "commit": "657c8ecf60b2cb6791f4b731f0b03c9756384712", - "contentHash": "849142b984ac92bf36a930904a064f31e2a5ecfcbb2e2dc509caa41a77f24a27", + "release": "v0.4.1", + "commit": "0577d597f03f1036d49c497a5424ec643b75c5d1", + "contentHash": "15acec6a3332eb5a2817da55147a740549eea1b921c5dbc640658cc6226ea224", "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 6704e7d..a52e93d 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.3.5" + "ref": "v0.4.1" } } }, diff --git a/.lvbt/web-platform/examples/with-astro/AGENTS.md b/.lvbt/web-platform/examples/with-astro/AGENTS.md index 51ae712..b127dba 100644 --- a/.lvbt/web-platform/examples/with-astro/AGENTS.md +++ b/.lvbt/web-platform/examples/with-astro/AGENTS.md @@ -7,16 +7,18 @@ command that fixes it (`pnpm check:fix` repairs everything a machine can). Every LVBT repository answers to the same commands: -| Command | What it does | -| --------------------- | ---------------------------------------------------------- | -| `pnpm bootstrap` | Install dependencies, wire git hooks, and run preflight | -| `pnpm preflight` | Confirm the machine can build and deploy this repository | -| `pnpm check` | Format, docs, shape rules, lint, types, tests, repo checks | -| `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) | -| `turbo gen workspace` | Scaffold a new package or app | +| Command | What it does | +| ----------------------------- | -------------------------------------------------------------- | +| `pnpm bootstrap` | Install dependencies, wire git hooks, and run preflight | +| `pnpm preflight` | Confirm the machine can build and deploy this repository | +| `pnpm preflight --production` | Report whether production has everything `platform.json` lists | +| `pnpm bootstrap --production` | Set up whatever production is missing, asking for values | +| `pnpm check` | Format, docs, shape rules, lint, types, tests, repo checks | +| `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) | +| `turbo gen workspace` | Scaffold a new package or app | ## Create GitHub issues and pull requests 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 7cb718f..fa04257 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/package.json +++ b/.lvbt/web-platform/examples/with-astro/apps/site/package.json @@ -8,6 +8,7 @@ "dev": "astro dev --host 127.0.0.1 --port 4321", "preview": "astro preview --host 127.0.0.1 --port 4321", "lint": "eslint . --max-warnings 0", + "sync": "astro sync", "check-types": "astro check", "test": "vitest run", "test:e2e": "playwright test" @@ -20,10 +21,10 @@ }, "devDependencies": { "@astrojs/check": "catalog:", - "@lasvegasfortransit/eslint-config": "0.3.5", - "@lasvegasfortransit/playwright-config": "0.3.5", - "@lasvegasfortransit/typescript-config": "0.3.5", - "@lasvegasfortransit/vitest-config": "0.3.5", + "@lasvegasfortransit/eslint-config": "0.4.1", + "@lasvegasfortransit/playwright-config": "0.4.1", + "@lasvegasfortransit/typescript-config": "0.4.1", + "@lasvegasfortransit/vitest-config": "0.4.1", "@playwright/test": "catalog:", "@types/node": "catalog:", "eslint": "catalog:", 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 39abe60..55588ff 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 @@ -49,3 +49,9 @@ request. The required status is named `Validate`. **Preflight**: `pnpm preflight`, the check that your machine can build and 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. +`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/package.json b/.lvbt/web-platform/examples/with-astro/package.json index fdb9b97..4faf64f 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.3.5", - "@lasvegasfortransit/prettier-config": "0.3.5", + "@lasvegasfortransit/cli": "0.4.1", + "@lasvegasfortransit/prettier-config": "0.4.1", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/.lvbt/web-platform/examples/with-astro/turbo.json b/.lvbt/web-platform/examples/with-astro/turbo.json index fdc643b..8b2b298 100644 --- a/.lvbt/web-platform/examples/with-astro/turbo.json +++ b/.lvbt/web-platform/examples/with-astro/turbo.json @@ -8,8 +8,11 @@ "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, + "sync": { + "outputs": [".astro/**"] + }, "lint": { - "dependsOn": ["^lint"] + "dependsOn": ["^lint", "sync"] }, "check-types": { "dependsOn": ["^check-types"] 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 6704e7d..a52e93d 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.3.5" + "ref": "v0.4.1" } } }, diff --git a/.lvbt/web-platform/examples/with-vite-react/AGENTS.md b/.lvbt/web-platform/examples/with-vite-react/AGENTS.md index 51ae712..b127dba 100644 --- a/.lvbt/web-platform/examples/with-vite-react/AGENTS.md +++ b/.lvbt/web-platform/examples/with-vite-react/AGENTS.md @@ -7,16 +7,18 @@ command that fixes it (`pnpm check:fix` repairs everything a machine can). Every LVBT repository answers to the same commands: -| Command | What it does | -| --------------------- | ---------------------------------------------------------- | -| `pnpm bootstrap` | Install dependencies, wire git hooks, and run preflight | -| `pnpm preflight` | Confirm the machine can build and deploy this repository | -| `pnpm check` | Format, docs, shape rules, lint, types, tests, repo checks | -| `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) | -| `turbo gen workspace` | Scaffold a new package or app | +| Command | What it does | +| ----------------------------- | -------------------------------------------------------------- | +| `pnpm bootstrap` | Install dependencies, wire git hooks, and run preflight | +| `pnpm preflight` | Confirm the machine can build and deploy this repository | +| `pnpm preflight --production` | Report whether production has everything `platform.json` lists | +| `pnpm bootstrap --production` | Set up whatever production is missing, asking for values | +| `pnpm check` | Format, docs, shape rules, lint, types, tests, repo checks | +| `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) | +| `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/apps/app/package.json b/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json index cf8b1b5..3f7af88 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.3.5", - "@lasvegasfortransit/playwright-config": "0.3.5", - "@lasvegasfortransit/typescript-config": "0.3.5", - "@lasvegasfortransit/vitest-config": "0.3.5", + "@lasvegasfortransit/eslint-config": "0.4.1", + "@lasvegasfortransit/playwright-config": "0.4.1", + "@lasvegasfortransit/typescript-config": "0.4.1", + "@lasvegasfortransit/vitest-config": "0.4.1", "@playwright/test": "catalog:", "@tailwindcss/vite": "catalog:", "@types/node": "catalog:", 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 39abe60..55588ff 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 @@ -49,3 +49,9 @@ request. The required status is named `Validate`. **Preflight**: `pnpm preflight`, the check that your machine can build and 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. +`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/package.json b/.lvbt/web-platform/examples/with-vite-react/package.json index f33cbcd..fef1d39 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.3.5", - "@lasvegasfortransit/prettier-config": "0.3.5", + "@lasvegasfortransit/cli": "0.4.1", + "@lasvegasfortransit/prettier-config": "0.4.1", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/.lvbt/web-platform/packages/cli/package.json b/.lvbt/web-platform/packages/cli/package.json index e14c3c8..4ac27de 100644 --- a/.lvbt/web-platform/packages/cli/package.json +++ b/.lvbt/web-platform/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@lasvegasfortransit/cli", - "version": "0.3.5", - "description": "The lvbt command every LVBT repository runs for bootstrap, preflight, and deploy, plus the shared git hooks and the lvbt-contributions agent plugin.", + "version": "0.4.1", + "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", "repository": { @@ -20,7 +20,8 @@ "src", "hooks", "plugins", - "catalog.json" + "catalog.json", + "platform.schema.json" ], "engines": { "node": ">=24" diff --git a/.lvbt/web-platform/packages/cli/platform.schema.json b/.lvbt/web-platform/packages/cli/platform.schema.json new file mode 100644 index 0000000..f7214bd --- /dev/null +++ b/.lvbt/web-platform/packages/cli/platform.schema.json @@ -0,0 +1,349 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/LasVegasForTransit/repository-tooling/blob/main/packages/cli/platform.schema.json", + "title": "LVBT production platform manifest", + "description": "Everything one app needs in production on Cloudflare and GitHub. `pnpm preflight --production` checks it; `pnpm bootstrap --production` sets up what is missing.", + "type": "object", + "additionalProperties": false, + "required": ["version", "name", "cloudflare"], + "properties": { + "$schema": { + "type": "string", + "description": "Path to this schema, for editor completion." + }, + "version": { + "const": 1, + "description": "The manifest format. Always 1." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "What people call the production site, such as lvwwd.org. Reports and token names use it." + }, + "cloudflare": { "$ref": "#/$defs/cloudflare" }, + "d1": { + "type": "array", + "description": "D1 databases the production Worker binds.", + "items": { "$ref": "#/$defs/d1" } + }, + "r2": { + "type": "array", + "description": "R2 buckets the production Worker binds.", + "items": { "$ref": "#/$defs/r2" } + }, + "turnstile": { + "type": "array", + "description": "Turnstile widgets, each feeding a site-key var and a secret on the Worker.", + "items": { "$ref": "#/$defs/turnstile" } + }, + "access": { + "type": "array", + "description": "Cloudflare Access applications that put paths behind a sign-in, each feeding the team-domain and audience secrets the Worker checks.", + "items": { "$ref": "#/$defs/access" } + }, + "email": { + "type": "array", + "description": "Domains the Worker sends email from, with the DNS records their provider needs.", + "items": { "$ref": "#/$defs/email" } + }, + "secrets": { + "type": "array", + "description": "Secret values, on the Worker or in a GitHub environment. Values never appear in this file.", + "items": { "$ref": "#/$defs/secret" } + }, + "vars": { + "type": "array", + "description": "Plain-text Worker vars that must be in the wrangler config's vars.", + "items": { "$ref": "#/$defs/var" } + }, + "github": { "$ref": "#/$defs/github" }, + "forbidden": { + "type": "array", + "description": "Names that must never be set in production, as a secret or a var.", + "items": { "$ref": "#/$defs/forbidden" } + } + }, + "$defs": { + "name": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "An environment name in capitals, such as RESEND_API_KEY." + }, + "use": { + "enum": ["live", "future"], + "description": "live: a feature people use today needs it, so production is not ready without it. future: only a feature that is not built yet needs it, so it can wait." + }, + "steps": { + "type": "array", + "minItems": 1, + "description": "Numbered, click-by-click steps to find or create the value. Write each as one complete sentence.", + "items": { "type": "string", "minLength": 1 } + }, + "cloudflare": { + "type": "object", + "additionalProperties": false, + "required": ["accountId", "zone", "worker"], + "properties": { + "accountId": { + "type": "string", + "pattern": "^[0-9a-f]{32}$", + "description": "The Cloudflare account that owns the Worker and the zone." + }, + "zone": { + "type": "object", + "additionalProperties": false, + "required": ["name", "id"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z0-9.-]+\\.[a-z]+$" }, + "id": { "type": "string", "pattern": "^[0-9a-f]{32}$" } + } + }, + "worker": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "The production Worker's name. It must match the wrangler config's name." + }, + "wranglerConfig": { + "type": "string", + "minLength": 1, + "description": "The production wrangler config, relative to this file. Defaults to wrangler.jsonc." + } + } + }, + "d1": { + "type": "object", + "additionalProperties": false, + "required": ["binding", "name"], + "properties": { + "binding": { "$ref": "#/$defs/name" }, + "name": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "migrations": { + "type": "string", + "minLength": 1, + "description": "The migrations directory, relative to this file. Every .sql file in it must be applied." + } + } + }, + "r2": { + "type": "object", + "additionalProperties": false, + "required": ["binding", "name"], + "properties": { + "binding": { "$ref": "#/$defs/name" }, + "name": { "type": "string", "pattern": "^[a-z0-9-]+$" } + } + }, + "turnstile": { + "type": "object", + "additionalProperties": false, + "required": ["name", "domains", "siteKeyVar", "secret"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The widget's name in the Cloudflare dashboard." + }, + "domains": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^[a-z0-9.-]+\\.[a-z]+$" } + }, + "mode": { + "enum": ["managed", "non-interactive", "invisible"], + "description": "Defaults to managed." + }, + "siteKeyVar": { + "$ref": "#/$defs/name", + "description": "The var that carries the public site key. It must be listed in vars." + }, + "secret": { + "$ref": "#/$defs/name", + "description": "The Worker secret that carries the widget's secret key. It must be listed in secrets." + } + } + }, + "access": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "destinations", + "identityProvider", + "allow", + "teamDomainSecret", + "audienceSecret" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The application's name in Zero Trust." + }, + "destinations": { + "type": "array", + "minItems": 1, + "description": "Host and path pairs such as example.org/admin and example.org/admin/*. A path does not cover the paths under it, and a wildcard does not cover its parent, so list both.", + "items": { "type": "string", "pattern": "^[a-z0-9.-]+\\.[a-z]+(/\\S*)?$" } + }, + "sessionDuration": { + "type": "string", + "pattern": "^[0-9]+(m|h)$", + "description": "How long a sign-in lasts, such as 24h. Defaults to 24h." + }, + "identityProvider": { + "enum": ["google-apps", "onetimepin"], + "description": "google-apps is Google Workspace; onetimepin emails a code." + }, + "allow": { + "type": "object", + "additionalProperties": false, + "description": "Who may sign in. Give exactly one.", + "properties": { + "googleGroup": { + "type": "string", + "pattern": "^[^@\\s]+@[a-z0-9.-]+\\.[a-z]+$", + "description": "A Google Workspace group address. Needs identityProvider google-apps." + }, + "emailDomain": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\.[a-z]+$", + "description": "Everyone with an address at this domain." + }, + "emails": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^[^@\\s]+@[a-z0-9.-]+\\.[a-z]+$" } + } + } + }, + "teamDomainSecret": { + "$ref": "#/$defs/name", + "description": "The Worker secret that carries the Zero Trust team domain. It must be listed in secrets." + }, + "audienceSecret": { + "$ref": "#/$defs/name", + "description": "The Worker secret that carries the application's audience (AUD) tag. It must be listed in secrets." + } + } + }, + "email": { + "type": "object", + "additionalProperties": false, + "required": ["domain", "provider"], + "properties": { + "domain": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\.[a-z]+$", + "description": "The domain in the From address." + }, + "provider": { + "enum": ["resend"], + "description": "The sending service. It decides which DNS records are checked." + }, + "region": { + "type": "string", + "pattern": "^[a-z]{2}-[a-z]+-[0-9]$", + "description": "The provider's sending region. Defaults to us-east-1." + }, + "apiKeySecret": { + "$ref": "#/$defs/name", + "description": "The Worker secret that carries the provider's API key. It must be listed in secrets." + } + } + }, + "secret": { + "type": "object", + "additionalProperties": false, + "required": ["name", "purpose"], + "properties": { + "name": { "$ref": "#/$defs/name" }, + "purpose": { + "type": "string", + "minLength": 1, + "description": "What production uses it for, in one sentence." + }, + "use": { "$ref": "#/$defs/use" }, + "neededFor": { + "type": "string", + "minLength": 1, + "description": "The feature that stays broken without it, so the report can say so." + }, + "targets": { + "type": "array", + "minItems": 1, + "description": "Where the value is stored: worker, or github:. Defaults to worker.", + "items": { "type": "string", "pattern": "^(worker|github:[A-Za-z0-9_-]+)$" } + }, + "url": { + "type": "string", + "pattern": "^https://", + "description": "The page to open first. Setup offers to open it." + }, + "steps": { "$ref": "#/$defs/steps" }, + "generate": { + "type": "boolean", + "description": "Setup mints a random value instead of asking for one." + }, + "from": { + "enum": ["cloudflare.accountId"], + "description": "Setup copies a value the manifest already knows." + }, + "pattern": { + "type": "string", + "minLength": 1, + "description": "A regular expression a pasted value must match." + }, + "patternHint": { + "type": "string", + "minLength": 1, + "description": "What a valid value looks like, shown when a pasted value does not match." + } + } + }, + "var": { + "type": "object", + "additionalProperties": false, + "required": ["name", "purpose"], + "properties": { + "name": { "$ref": "#/$defs/name" }, + "purpose": { "type": "string", "minLength": 1 }, + "use": { "$ref": "#/$defs/use" }, + "neededFor": { "type": "string", "minLength": 1 } + } + }, + "github": { + "type": "object", + "additionalProperties": false, + "required": ["repository"], + "properties": { + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "description": "owner/name of the repository whose environments hold github: secrets." + } + } + }, + "forbidden": { + "type": "object", + "additionalProperties": false, + "required": ["name", "reason"], + "properties": { + "name": { "$ref": "#/$defs/name" }, + "reason": { + "type": "string", + "minLength": 1, + "description": "Why it must not be set, in one sentence the report prints." + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^(worker|github:[A-Za-z0-9_-]+)$" } + }, + "severity": { + "enum": ["error", "warning"], + "description": "error fails the check; warning only reports it. Defaults to error." + } + } + } + } +} 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 7b2144b..b22e557 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.3.1", + "version": "0.4.1", "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 ee124a1..61b8939 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.3.1", + "version": "0.4.1", "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 bed7c0d..ed0e03f 100755 --- a/.lvbt/web-platform/packages/cli/src/cli.mjs +++ b/.lvbt/web-platform/packages/cli/src/cli.mjs @@ -2,8 +2,10 @@ /** * The commands every LVBT repository runs the same way. * - * lvbt bootstrap install, wire git hooks, run preflight - * lvbt preflight confirm this machine can build and deploy the repository + * lvbt bootstrap install, wire git hooks, run preflight; with --production, + * also set up everything the platform manifest declares + * 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 * @@ -17,15 +19,17 @@ import { check } from './lib/check/index.mjs'; import { bootstrap, deploy, preflight } from './lib/operate.mjs'; const usage = `Usage: - lvbt bootstrap - lvbt preflight - lvbt check [filenames|contract|debt ...] [--staged] + lvbt bootstrap [--production [--filter ]] + lvbt preflight [--production [--filter ]] + lvbt check [filenames|contract|debt|platform ...] [--staged] lvbt deploy [--filter ] [--dry-run] Options: - --staged For check filenames: check the staged tree instead of the working tree - --filter For deploy: only the app directory named (for example apps/worker) - --dry-run For deploy: build, then run wrangler deploy --dry-run + --production For bootstrap: set up what platform.json declares. For preflight: + report whether production has it, without changing anything + --staged For check filenames: check the staged tree instead of the working tree + --filter For deploy and --production: only the app directory named (apps/site) + --dry-run For deploy: build, then run wrangler deploy --dry-run `; const commands = { bootstrap, preflight, check, deploy }; diff --git a/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs b/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs index 026ca0b..aa16592 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs @@ -5,19 +5,20 @@ export class CliError extends Error { } } -const flags = new Set(['--dry-run', '--staged', '--help']); +const flags = new Set(['--dry-run', '--staged', '--production', '--help']); const valued = new Set(['--filter']); /** ` [positional...] [--flag] [--option value]`. Unknown options are an error. */ export function parseArguments(argv) { const [first, ...rest] = argv; const command = first === '--help' ? 'help' : first; - const options = { dryRun: false, staged: false, positional: [] }; + const options = { dryRun: false, staged: false, production: false, positional: [] }; for (let index = 0; index < rest.length; index += 1) { const argument = rest[index]; if (argument === '--dry-run') options.dryRun = true; else if (argument === '--staged') options.staged = true; + else if (argument === '--production') options.production = true; else if (argument === '--help') return { command: 'help', options }; else if (valued.has(argument)) { const value = rest[index + 1]; 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 a59a008..818ab46 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs @@ -129,6 +129,43 @@ function packageFailures(root, directory) { return failures; } +function dependsOnAstro(manifest) { + return Boolean(manifest.dependencies?.astro ?? manifest.devDependencies?.astro); +} + +/** + * An Astro package generates its `astro:content` and environment types with `astro sync`. On a + * clean checkout, as in CI, those types don't exist until it runs, and type-aware lint rules fail + * on every module that imports them. So each Astro package declares a `sync` script, and the root + * turbo.json runs it before `lint`. + */ +function astroFailures(root, directories) { + const failures = []; + let astro = false; + for (const directory of directories) { + const manifest = JSON.parse(readFileSync(path.join(root, directory, 'package.json'), 'utf8')); + if (!dependsOnAstro(manifest)) continue; + astro = true; + if (!manifest.scripts?.sync) { + failures.push( + `${directory}/package.json has no "sync" script to generate Astro's types before lint`, + ); + } + } + if (!astro) return []; + let turbo; + try { + turbo = JSON.parse(readFileSync(path.join(root, 'turbo.json'), 'utf8')); + } catch { + return [...failures, 'turbo.json is missing or not plain JSON, so lint cannot run after sync']; + } + if (!turbo.tasks?.sync) failures.push('turbo.json has no "sync" task'); + if (!turbo.tasks?.lint?.dependsOn?.includes('sync')) { + failures.push('turbo.json does not run "sync" before "lint"'); + } + return failures; +} + function dependencyFailures(root, directory) { const manifest = JSON.parse(readFileSync(path.join(root, directory, 'package.json'), 'utf8')); const failures = []; @@ -146,14 +183,16 @@ function dependencyFailures(root, directory) { export function checkContract({ cwd }) { const lines = []; - for (const directory of ['.', ...packageDirectories(cwd)]) { + const directories = packageDirectories(cwd); + for (const directory of ['.', ...directories]) { if (directory !== '.') lines.push(...packageFailures(cwd, directory)); lines.push(...dependencyFailures(cwd, directory)); } + lines.push(...astroFailures(cwd, directories)); return { name: 'contract', ok: lines.length === 0, lines, - fix: 'add the missing script, move test material under tests/, or set the range to "catalog:" and add the version to pnpm-workspace.yaml', + fix: 'add the missing script, move test material under tests/, set the range to "catalog:" and add the version to pnpm-workspace.yaml, or run `pnpm standards:update` to wire an Astro package\'s "sync" task before lint', }; } diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/index.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/index.mjs index 00254c6..4eb2215 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/check/index.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/check/index.mjs @@ -2,11 +2,13 @@ import { CliError } from '../arguments.mjs'; import { checkContract } from './contract.mjs'; import { checkDebt } from './debt.mjs'; import { checkFilenames } from './filenames.mjs'; +import { checkPlatform } from './platform.mjs'; const checks = { filenames: checkFilenames, contract: checkContract, debt: checkDebt, + platform: checkPlatform, }; /** diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/platform.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/platform.mjs new file mode 100644 index 0000000..a7b8b0f --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/check/platform.mjs @@ -0,0 +1,25 @@ +import path from 'node:path'; +import { findManifests, loadManifest } from '../platform/manifest.mjs'; + +/** + * Every platform.json at the root or under apps/ matches the schema the CLI + * ships and names nothing it does not declare. This is the structural half of + * `pnpm preflight --production`, cheap enough to run on every commit, so a + * typo in the manifest fails the check before anyone relies on it. + */ +export function checkPlatform({ cwd }) { + const lines = []; + for (const file of findManifests(cwd)) { + try { + loadManifest(path.join(cwd, file)); + } catch (error) { + lines.push(...error.message.split('\n').map((line) => line.trim())); + } + } + return { + name: 'platform', + ok: lines.length === 0, + lines, + fix: 'correct the fields named above; the platform manifest reference in repository-tooling describes each one', + }; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/operate.mjs b/.lvbt/web-platform/packages/cli/src/lib/operate.mjs index 8d4121b..b8f25f3 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/operate.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/operate.mjs @@ -3,6 +3,8 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { CliError } from './arguments.mjs'; import { exists, readJson } from './files.mjs'; +import { findManifests } from './platform/manifest.mjs'; +import { platformBootstrap, platformPreflight } from './platform/index.mjs'; function output(command, args, cwd) { const result = spawnSync(command, args, { cwd, encoding: 'utf8' }); @@ -160,9 +162,10 @@ async function cloudflareFindings(cwd, report) { /** * Confirm the machine can work on this repository. Every finding names the - * command that fixes it; the exit code is 1 when anything failed. + * command that fixes it. Returns the failures instead of throwing, so + * `--production` can still report on production. */ -export async function preflight({ cwd }) { +async function machineFindings(cwd) { const packageJson = await readJson(path.join(cwd, 'package.json')); const findings = []; const report = { @@ -181,18 +184,53 @@ export async function preflight({ cwd }) { if (!finding.ok) process.stdout.write(` fix: ${finding.fix}\n`); } const failed = findings.filter((finding) => !finding.ok); - if (failed.length > 0) - throw new CliError(`preflight: ${failed.length} of ${findings.length} checks failed`, 1); - process.stdout.write(`preflight: all ${findings.length} checks passed\n`); + if (failed.length === 0) + process.stdout.write(`preflight: all ${findings.length} checks passed\n`); + return failed.length > 0 + ? `preflight: ${failed.length} of ${findings.length} checks failed` + : undefined; } -/** Install, wire hooks, and confirm the machine is ready. */ -export async function bootstrap({ cwd }) { +/** + * `lvbt preflight`: the machine checks. With `--production`, also the + * read-only readiness report for every platform manifest. + */ +export async function preflight({ cwd, options = {} }) { + const machine = await machineFindings(cwd); + if (options.production) { + try { + await platformPreflight({ cwd, options }); + } catch (error) { + if (machine && error instanceof CliError) + throw new CliError(`${machine}\n${error.message}`, 1); + throw error; + } + } + if (machine) throw new CliError(machine, 1); +} + +/** + * Install, wire hooks, and confirm the machine is ready. With `--production`, + * then set up everything the platform manifests declare. + */ +export async function bootstrap({ cwd, options = {} }) { process.stdout.write('pnpm install\n'); const install = spawnSync('pnpm', ['install'], { cwd, stdio: 'inherit' }); if (install.status !== 0) throw new CliError('bootstrap: pnpm install failed', install.status ?? 1); - await preflight({ cwd }); + const machine = await machineFindings(cwd); + if (machine) throw new CliError(machine, 1); + if (options.production) { + await platformBootstrap({ cwd, options }); + return; + } + const manifests = findManifests(cwd); + if (manifests.length > 0) + process.stdout.write( + `\nThis repository declares its production platform in ${manifests.join(', ')}.\n` + + 'Maintainers: `pnpm preflight --production` checks it without changing anything, and\n' + + '`pnpm bootstrap --production` sets up whatever is missing.\n', + ); } /** diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs new file mode 100644 index 0000000..c1453f2 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs @@ -0,0 +1,415 @@ +import { randomBytes } from 'node:crypto'; +import { accessAppGuide, turnstileGuide } from './guides.mjs'; +import { findApp, findWidget, SETUP } from './plan.mjs'; +import { redact } from './services.mjs'; +import { paint } from './terminal.mjs'; + +/** + * Carry out a plan: create what is missing, store secrets, and walk a person + * through the steps only a dashboard can do. Each step acts only on an item + * the plan found open, so running setup again after a failure or a skipped + * step picks up exactly the items that are still open. + * + * Secret values live only in `context.values` for the length of the run. + * They reach Wrangler and gh on stdin and are redacted from any output shown. + */ + +export function generateSecret() { + return randomBytes(32).toString('base64url'); +} + +function account(context) { + return `accounts/${context.manifest.cloudflare.accountId}`; +} + +function wrangler(context, args, options = {}) { + return context.run('pnpm', ['exec', 'wrangler', ...args], { + cwd: context.directory, + env: { + CLOUDFLARE_ACCOUNT_ID: context.manifest.cloudflare.accountId, + WRANGLER_SEND_METRICS: 'false', + }, + ...options, + }); +} + +function succeeded(result, secret) { + if (result.status === 0) return; + const output = `${result.stderr}\n${result.stdout}`.trim().split('\n').slice(-3).join(' '); + throw new Error(redact(output || `exited with ${result.status}`, secret)); +} + +function printGuide(io, guide) { + if (guide.url) io.write(`Open: ${paint('cyan', guide.url)}\n`); + guide.steps.forEach((step, index) => io.write(` ${index + 1}. ${step}\n`)); +} + +async function manualStep(context, key, title, guide) { + if (context.handled.has(key)) return; + context.handled.add(key); + const { io } = context; + io.write(`\n${paint('bold', title)}\n`); + printGuide(io, guide); + if (guide.url && (await io.confirm('Open that page in your browser?', true))) io.open(guide.url); + await io.ask('Press Enter when you have finished (or to leave it for later): '); +} + +export function targetName(context, target) { + return target === 'worker' + ? `Worker ${context.manifest.cloudflare.worker}` + : `GitHub environment ${target.slice(7)}`; +} + +async function storeSecret(context, name, target, value) { + if (target === 'worker') { + succeeded( + wrangler(context, ['secret', 'put', name, '--name', context.manifest.cloudflare.worker], { + input: value, + }), + value, + ); + } else { + const repository = context.manifest.github.repository; + succeeded( + context.run('gh', ['secret', 'set', name, '--env', target.slice(7), '--repo', repository], { + cwd: context.directory, + input: value, + }), + value, + ); + } + context.handled.add(`secret:${name}:${target}`); + context.io.write(`${paint('green', 'Stored')} ${name} on ${targetName(context, target)}.\n`); +} + +/** Store a value a new resource produced, straight away, so it cannot be lost or left stale. */ +async function storeFed(context, name, value) { + if (!value || !context.state.worker.ok || !context.state.worker.value.exists) return; + context.values.set(name, value); + await storeSecret(context, name, 'worker', value); +} + +async function promptValue(context, secret, fallback) { + const { io } = context; + io.write(`\n${paint('bold', secret.name)}: ${secret.purpose}\n`); + if (secret.neededFor) io.write(`Needed for ${secret.neededFor}.\n`); + const guide = { url: secret.url ?? fallback?.url, steps: secret.steps ?? fallback?.steps ?? [] }; + printGuide(io, guide); + if (guide.url && (await io.confirm('Open that page in your browser?', true))) io.open(guide.url); + for (let attempt = 0; attempt < 3; attempt += 1) { + const value = await io.askHidden( + `Paste ${secret.name} (hidden; leave empty to skip for now): `, + ); + if (!value) return undefined; + if (!secret.pattern || new RegExp(secret.pattern, 'u').test(value)) return value; + io.write( + `That does not look right. ${secret.patternHint ?? `It should match ${secret.pattern}.`}\n`, + ); + } + return undefined; +} + +async function readWidgetSecret(context, widget) { + const created = context.created.widgets.get(widget.name); + if (created?.secret) return created.secret; + const api = await context.setupApi(); + if (!api) return undefined; + const found = findWidget(await api.list(`${account(context)}/challenges/widgets`), widget); + return found + ? (await api.get(`${account(context)}/challenges/widgets/${found.sitekey}`))?.secret + : undefined; +} + +async function secretValue(context, action) { + const { secret, source } = action; + if (context.values.has(secret.name)) return context.values.get(secret.name); + let value; + let fallback; + if (source.type === 'generate') { + value = generateSecret(); + context.io.write(`Generated a new random value for ${secret.name}.\n`); + } else if (source.type === 'value') { + value = source.value; + } else if (source.type === 'turnstile') { + value = await readWidgetSecret(context, source.widget); + fallback = turnstileGuide(source.widget, context.manifest.cloudflare); + } else if (source.type === 'access-team') { + value = context.state.access.ok ? context.state.access.value.teamDomain : undefined; + } else if (source.type === 'access-audience') { + fallback = accessAppGuide(source.app); + const created = context.created.apps.get(source.app.name); + value = + created?.aud ?? + (context.state.access.ok + ? findApp(context.state.access.value.apps, source.app)?.aud + : undefined); + } + if (!value) value = await promptValue(context, secret, fallback); + if (value) context.values.set(secret.name, value); + return value; +} + +async function createWidget(context, action) { + const api = await context.setupApi(); + if (!api) + return manualStep( + context, + `turnstile:${action.widget.name}`, + `Create the Turnstile widget ${action.widget.name}`, + action.guide, + ); + const { widget } = action; + const created = await api.post(`${account(context)}/challenges/widgets`, { + name: widget.name, + domains: widget.domains, + mode: widget.mode ?? 'managed', + }); + 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`, + ); + await storeFed(context, widget.secret, created.secret); +} + +async function updateWidget(context, action) { + const api = await context.setupApi(); + if (!api) + return manualStep( + context, + `turnstile:${action.widget.name}`, + `Update the Turnstile widget ${action.widget.name}`, + action.guide, + ); + const current = await api.get(`${account(context)}/challenges/widgets/${action.sitekey}`); + await api.put(`${account(context)}/challenges/widgets/${action.sitekey}`, { + name: current.name, + domains: [...new Set([...(current.domains ?? []), ...action.widget.domains])], + mode: action.widget.mode ?? 'managed', + }); + context.io.write(`${paint('green', 'Updated')} the Turnstile widget ${action.widget.name}.\n`); +} + +function appBody(app, provider, policyId, base = {}) { + const existing = base.destinations ?? []; + const covered = new Set(existing.map((destination) => destination.uri)); + return { + name: base.name ?? app.name, + type: 'self_hosted', + domain: base.domain ?? app.destinations[0], + destinations: [ + ...existing, + ...app.destinations + .filter((uri) => !covered.has(uri)) + .map((uri) => ({ type: 'public', uri })), + ], + session_duration: app.sessionDuration ?? '24h', + allowed_idps: [provider.id], + auto_redirect_to_identity: true, + app_launcher_visible: base.app_launcher_visible ?? false, + policies: [ + { id: policyId, precedence: 1 }, + ...(base.policies ?? []) + .filter((policy) => policy.id !== policyId) + .map((policy, index) => ({ id: policy.id, precedence: index + 2 })), + ], + }; +} + +async function allowPolicy(context, api, action) { + const name = `${action.app.name} allow`; + const existing = context.state.access.ok + ? context.state.access.value.policies.find((policy) => policy.name === name) + : undefined; + if (existing) { + await api.put(`${account(context)}/access/policies/${existing.id}`, { + name, + decision: 'allow', + include: action.rule, + }); + return existing.id; + } + const created = await api.post(`${account(context)}/access/policies`, { + name, + decision: 'allow', + include: action.rule, + }); + return created.id; +} + +async function createOrUpdateApp(context, action) { + const api = await context.setupApi(); + const title = `${action.found ? 'Fix' : 'Create'} the Access application ${action.app.name}`; + if (!api) return manualStep(context, `access:${action.app.name}`, title, action.guide); + const policyId = await allowPolicy(context, api, action); + const result = action.found + ? await api.put( + `${account(context)}/access/apps/${action.found.id}`, + appBody(action.app, action.provider, policyId, action.found), + ) + : await api.post( + `${account(context)}/access/apps`, + appBody(action.app, action.provider, policyId), + ); + context.created.apps.set(action.app.name, result); + context.io.write( + `${paint('green', action.found ? 'Updated' : 'Created')} the Access application ${action.app.name}.\n`, + ); + if (!action.found) { + await storeFed(context, action.app.audienceSecret, result.aud); + if (!context.values.has(action.app.teamDomainSecret) && context.state.access.ok) + await storeFed(context, action.app.teamDomainSecret, context.state.access.value.teamDomain); + } +} + +async function deleteSecret(context, action) { + const where = targetName(context, action.target); + if (!(await context.io.confirm(`Delete ${action.name} from ${where}?`, true))) return; + const result = + action.target === 'worker' + ? wrangler( + context, + ['secret', 'delete', action.name, '--name', context.manifest.cloudflare.worker], + { inherit: true }, + ) + : context.run( + 'gh', + [ + 'secret', + 'delete', + action.name, + '--env', + action.target.slice(7), + '--repo', + context.manifest.github.repository, + ], + { cwd: context.directory }, + ); + succeeded(result); + context.io.write(`${paint('green', 'Deleted')} ${action.name} from ${where}.\n`); +} + +async function perform(context, entry) { + const { action } = entry; + switch (action.type) { + case 'd1.create': + succeeded(wrangler(context, ['d1', 'create', action.name])); + context.io.write( + `${paint('green', 'Created')} the D1 database ${action.name}. Check that database_id in ${context.configPath} matches it; the final report says if not.\n`, + ); + return; + case 'd1.migrate': + 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, + }), + ); + return; + case 'r2.create': + succeeded(wrangler(context, ['r2', 'bucket', 'create', action.name])); + context.io.write(`${paint('green', 'Created')} the R2 bucket ${action.name}.\n`); + return; + case 'turnstile.create': + return createWidget(context, action); + case 'turnstile.update': + return updateWidget(context, action); + case 'access.create': + case 'access.update': + return createOrUpdateApp(context, action); + case 'github.environment': + succeeded( + context.run( + 'gh', + [ + 'api', + '--method', + 'PUT', + `repos/${context.manifest.github.repository}/environments/${action.environment}`, + ], + { cwd: context.directory }, + ), + ); + context.io.write( + `${paint('green', 'Created')} the GitHub environment ${action.environment}.\n`, + ); + return; + case 'secret.put': { + if (context.handled.has(`secret:${action.secret.name}:${action.target}`)) return; + const value = await secretValue(context, action); + if (!value) { + context.io.write(`Skipped ${action.secret.name}. Run ${SETUP} again to set it.\n`); + return; + } + return storeSecret(context, action.secret.name, action.target, value); + } + case 'secret.delete': + return deleteSecret(context, action); + case 'manual': + return manualStep(context, action.key, `${entry.label} ${entry.detail}`, action.guide); + default: + throw new Error(`unknown action ${action.type}`); + } +} + +export function describeAction(entry) { + const { action } = entry; + switch (action.type) { + case 'd1.create': + return `create the D1 database ${action.name}`; + case 'd1.migrate': + return `apply the migrations to ${action.name}`; + case 'r2.create': + return `create the R2 bucket ${action.name}`; + case 'turnstile.create': + return `create the Turnstile widget ${action.widget.name} and store its secret`; + case 'turnstile.update': + return `update the Turnstile widget ${action.widget.name}`; + case 'access.create': + return `create the Access application ${action.app.name} and its allow policy`; + case 'access.update': + return `fix the Access application ${action.app.name}`; + case 'github.environment': + return `create the GitHub environment ${action.environment}`; + case 'secret.put': + return `${action.source.type === 'prompt' ? 'ask for' : 'store'} ${action.secret.name} on ${action.target === 'worker' ? 'the Worker' : `GitHub ${action.target.slice(7)}`}`; + case 'secret.delete': + return `offer to delete ${action.name}`; + default: + return `show the steps for ${entry.label}`; + } +} + +/** + * Work through every open item that has an action. Items for features that + * are not built yet are offered separately, after asking. + */ +export async function applyPlan(context, items) { + const { io } = context; + const open = items.filter((entry) => entry.status !== 'ok' && entry.action); + const later = open.filter((entry) => entry.level === 'later'); + let work = open.filter((entry) => entry.level !== 'later'); + if (later.length > 0) { + io.write( + `\n${later.length} item(s) are only needed by features that are not built yet: ${later.map((entry) => entry.label).join(', ')}.\n`, + ); + if (await io.confirm('Set those up now too?', false)) work = open; + } + if (work.length === 0) { + io.write('\nNothing here can be set up automatically. The report above says what is left.\n'); + return; + } + io.write('\nSetup will now, in this order:\n'); + const described = [...new Set(work.map(describeAction))]; + for (const line of described) io.write(` - ${line}\n`); + if (!(await io.confirm('Start?', true))) return; + for (const entry of work) { + try { + await perform(context, entry); + } catch (error) { + io.write(`${paint('red', 'Could not')} ${describeAction(entry)}: ${error.message}\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 new file mode 100644 index 0000000..8d1b964 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs @@ -0,0 +1,185 @@ +/** + * What the standard knows about the services behind a manifest: the DNS + * records an email provider needs, the dashboard pages to open, and the + * click-by-click steps for the parts no API can do. Keeping them here means + * every repository gets the same, reviewed instructions. + */ + +const ZERO_TRUST = 'https://one.dash.cloudflare.com/'; + +/** Normalize a DNS-over-HTTPS answer: TXT data arrives quoted and sometimes split. */ +export function dnsText(data) { + return data.replace(/"\s+"/g, '').replace(/^"|"$/g, ''); +} + +/** The DNS records an email provider needs for a sending domain. */ +export function emailRecords(email) { + const region = email.region ?? 'us-east-1'; + const domain = email.domain; + const mailFrom = `feedback-smtp.${region}.amazonses.com`; + return [ + { + key: 'mx', + type: 'MX', + name: `send.${domain}`, + purpose: 'returns bounces to Resend', + expected: `MX send → ${mailFrom}, priority 10`, + matches: (data) => data.replace(/\.$/, '').endsWith(mailFrom), + level: 'required', + }, + { + key: 'spf', + type: 'TXT', + name: `send.${domain}`, + purpose: 'allows Resend to send for the domain (SPF)', + expected: 'TXT send → "v=spf1 include:amazonses.com ~all"', + matches: (data) => + 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', + }, + ]; +} + +export function resendDomainGuide(email, cloudflare) { + return { + url: 'https://resend.com/domains', + steps: [ + 'Sign in to Resend with the LVBT account (ask a maintainer for access if you have none).', + `If ${email.domain} is not listed on the Domains page, click "Add Domain", type ${email.domain}, choose the region ${email.region ?? 'us-east-1'}, and click "Add".`, + `Open ${email.domain} and click "Sign in to Cloudflare". Approve the request in the Cloudflare window; it adds every record for you.`, + `If you cannot use that button, add each record Resend lists by hand at https://dash.cloudflare.com/${cloudflare.accountId}/${cloudflare.zone.name}/dns/records. Type only the part before .${email.domain} as the name, and set the DKIM record to "DNS only".`, + `Add the DMARC record too: type TXT, name _dmarc, content v=DMARC1; p=none;.`, + 'Wait until Resend says "Verified". It usually takes a few minutes. Then run this command again.', + ], + }; +} + +export function zeroTrustGuide() { + return { + url: ZERO_TRUST, + steps: [ + 'Open Cloudflare Zero Trust and choose the LVBT account.', + 'If Cloudflare asks for a team name, type lasvegasfortransit. Your team domain becomes lasvegasfortransit.cloudflareaccess.com.', + 'Choose the Free plan. It costs nothing for up to 50 people. Cloudflare may still ask for a payment method.', + 'Finish the onboarding, then run this command again.', + ], + }; +} + +export function googleWorkspaceGuide(teamDomain, workspaceDomain) { + const team = teamDomain ?? '.cloudflareaccess.com'; + return { + url: 'https://console.cloud.google.com/apis/library/admin.googleapis.com', + steps: [ + 'Sign in to Google Cloud with an LVBT Google Workspace admin account and choose the LVBT project at the top of the page. Create one named "LVBT Access" if there is none.', + 'On the Admin SDK API page, click "Enable".', + 'Open https://console.cloud.google.com/auth/overview. If Google asks you to configure the consent screen: App name "LVBT volunteer sign-in", support email your LVBT address, Audience "Internal", then "Create".', + 'Open https://console.cloud.google.com/auth/clients and click "Create client". Application type: "Web application". Name: "Cloudflare Access".', + `Under "Authorized JavaScript origins", add https://${team}.`, + `Under "Authorized redirect URIs", add https://${team}/cdn-cgi/access/callback, then click "Create".`, + 'Copy the Client ID and the Client secret from the dialog.', + 'Open https://admin.google.com/ac/owl (Security → Access and data control → API controls), click "Settings", choose "Internal apps", turn on "Trust internal apps", and save.', + 'In Cloudflare Zero Trust, go to Integrations → Identity providers → "Add new identity provider" → "Google Workspace".', + `Paste the Client ID into "App ID" and the Client secret into "Client secret", type ${workspaceDomain} as the Google Workspace domain, and click "Save".`, + 'Open the link Cloudflare shows after saving and approve it with the Google Workspace admin account, so Access can read group membership.', + 'Click "Test" next to Google Workspace. It should list your groups. Then run this command again.', + ], + }; +} + +export function accessAppGuide(app) { + const allow = app.allow.googleGroup + ? `Include → "Google Workspace groups" → ${app.allow.googleGroup}` + : app.allow.emailDomain + ? `Include → "Emails ending in" → @${app.allow.emailDomain}` + : `Include → "Emails" → ${app.allow.emails.join(', ')}`; + return { + url: ZERO_TRUST, + steps: [ + 'In Cloudflare Zero Trust, go to Access controls → Applications → "Create new application" → "Self-hosted and private".', + `Name it "${app.name}".`, + `Add one public hostname for each of: ${app.destinations.join(', ')}. A path does not cover the paths under it, so each one is needed.`, + `Under Access policies, create a policy named "${app.name} allow" with Action "Allow" and ${allow}.`, + `Under authentication, select only ${app.identityProvider === 'google-apps' ? 'Google Workspace' : 'One-time PIN'} and turn on "Apply instant authentication".`, + `Set Session Duration to ${app.sessionDuration ?? '24h'} and click "Create".`, + `Open the application's Overview tab and copy "Application Audience (AUD) Tag" into ${app.audienceSecret} when this command asks for it.`, + ], + }; +} + +export function turnstileGuide(widget, cloudflare) { + return { + url: `https://dash.cloudflare.com/${cloudflare.accountId}/turnstile`, + steps: [ + 'Click "Add widget".', + `Name it "${widget.name}" and add the hostnames ${widget.domains.join(', ')}.`, + `Choose the widget mode "${widget.mode ?? 'managed'}" and click "Create".`, + `Copy the Site Key into "${widget.siteKeyVar}" in the wrangler config's vars, and the Secret Key into ${widget.secret} when this command asks for it.`, + ], + }; +} + +/** The pre-filled dashboard link for the token that manages Turnstile and Access. */ +export function setupTokenUrl(manifest) { + const permissions = []; + if (manifest.turnstile?.length) permissions.push({ key: 'challenge_widgets', type: 'edit' }); + if (manifest.access?.length) { + permissions.push({ key: 'access', type: 'edit' }); + permissions.push({ key: 'access_acct', type: 'read' }); + } + const query = new URLSearchParams({ + permissionGroupKeys: JSON.stringify(permissions), + accountId: manifest.cloudflare.accountId, + zoneId: manifest.cloudflare.zone.id, + name: `lvbt setup ${manifest.name}`, + }); + return `https://dash.cloudflare.com/profile/api-tokens?${query}`; +} + +export function setupTokenGuide(manifest) { + const needed = []; + if (manifest.turnstile?.length) needed.push('Account · Turnstile · Edit'); + if (manifest.access?.length) { + needed.push('Account · Access: Apps and Policies · Edit'); + needed.push('Account · Access: Organizations, Identity Providers, and Groups · Read'); + } + return { + url: setupTokenUrl(manifest), + steps: [ + 'Sign in to Cloudflare with your LVBT account if it asks.', + `Check that the token lists these permissions, and add any that is missing with "+ Add more": ${needed.join('; ')}.`, + 'Under "Account Resources", choose "Include" and the LVBT account.', + 'Under "TTL", set an end date of tomorrow so the token stops working by itself.', + 'Click "Continue to summary", then "Create Token".', + 'Click "Copy" and paste the token here. It stays in this terminal\'s memory and is never saved.', + ], + }; +} + +export function varGuide(variable, configPath, value) { + const shown = value === undefined ? '' : JSON.stringify(value); + return { + steps: [ + `Add "${variable.name}": ${shown} to "vars" in ${configPath}.`, + '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 new file mode 100644 index 0000000..7a5688c --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs @@ -0,0 +1,195 @@ +import path from 'node:path'; +import { CliError } from '../arguments.mjs'; +import { applyPlan } from './apply.mjs'; +import { setupTokenGuide } from './guides.mjs'; +import { findManifests, loadManifest, MANIFEST_FILE } from './manifest.mjs'; +import { observePlatform } from './observe.mjs'; +import { planPlatform, readiness, SETUP } from './plan.mjs'; +import { formatReport } from './report.mjs'; +import { cloudflareApi, dnsResolver, runCommand, wranglerToken } from './services.mjs'; +import { paint, terminalIo } from './terminal.mjs'; + +/** + * `lvbt preflight --production` and `lvbt bootstrap --production`: read a + * repository's platform manifests, report whether production has everything + * they declare, and (for bootstrap) set up what is missing. + */ + +export const SETUP_TOKEN_VARIABLE = 'LVBT_CLOUDFLARE_SETUP_TOKEN'; + +export function defaultServices() { + return { run: runCommand, request: fetch, env: process.env }; +} + +/** The manifests `--filter` selects: `apps/site`, `site`, or `.` for the root. */ +export function selectManifests(cwd, filter) { + const all = findManifests(cwd); + if (all.length === 0) + throw new CliError( + `No ${MANIFEST_FILE} at the root or under apps/. Add one to declare what production needs; see docs/reference/platform-manifest.md in repository-tooling.`, + 2, + ); + if (!filter) return all; + const target = filter.replace(/\/+$/, ''); + const wanted = all.filter((file) => [target, `apps/${target}`].includes(path.dirname(file))); + if (wanted.length === 0) + throw new CliError( + `No ${MANIFEST_FILE} matches --filter ${filter}. Found: ${all.join(', ')}.`, + 2, + ); + return wanted; +} + +function setupApiProvider({ manifest, services, io, interactive }) { + let api; + let declined = false; + return async () => { + if (api) return api; + const fromEnvironment = services.env[SETUP_TOKEN_VARIABLE]; + if (fromEnvironment) { + api = cloudflareApi(fromEnvironment, services.request); + return api; + } + if (declined || !interactive) return undefined; + const guide = setupTokenGuide(manifest); + io.write(`\n${paint('bold', 'A Cloudflare API token for Turnstile and Access')}\n`); + io.write( + "Wrangler's sign-in cannot manage Turnstile widgets or Access applications, so this step needs a short-lived token. It stays in this terminal's memory and is never written to disk.\n", + ); + io.write(`Open: ${paint('cyan', guide.url)}\n`); + guide.steps.forEach((step, index) => io.write(` ${index + 1}. ${step}\n`)); + if (await io.confirm('Open that page in your browser?', true)) io.open(guide.url); + for (let attempt = 0; attempt < 3; attempt += 1) { + const token = await io.askHidden( + 'Paste the token (hidden; leave empty to skip Turnstile and Access for now): ', + ); + if (!token) break; + const candidate = cloudflareApi(token, services.request); + try { + // A user token verifies here; an account-owned token verifies under its account. + await candidate + .get('user/tokens/verify') + .catch(() => candidate.get(`accounts/${manifest.cloudflare.accountId}/tokens/verify`)); + api = candidate; + return api; + } catch (error) { + io.write(`That token did not work (${error.message}). Check that you copied all of it.\n`); + } + } + declined = true; + return undefined; + }; +} + +async function inspect({ cwd, file, services, io, interactive, askForToken }) { + const manifest = loadManifest(path.join(cwd, file)); + const directory = path.join(cwd, path.dirname(file)); + const configPath = path.join( + path.dirname(file), + manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc', + ); + const signedIn = wranglerToken(services.run, directory); + const setupApi = setupApiProvider({ manifest, services, io, interactive }); + const apis = { + wrangler: signedIn.token ? cloudflareApi(signedIn.token, services.request) : undefined, + wranglerMissing: signedIn.reason, + setup: services.env[SETUP_TOKEN_VARIABLE] ? await setupApi() : undefined, + }; + const resolve = dnsResolver(services.request); + const observe = () => observePlatform({ manifest, directory, apis, run: services.run, resolve }); + let state = await observe(); + const needsToken = [state.turnstile, state.access].some( + (part) => !part.ok && part.kind === 'unauthorized', + ); + if (needsToken && askForToken && !apis.setup) { + apis.setup = await setupApi(); + if (apis.setup) state = await observe(); + } + const plan = () => planPlatform({ manifest, state, configPath }); + return { + manifest, + directory, + configPath, + title: `${manifest.name} production (${file})`, + items: plan(), + setupApi, + refresh: async () => { + state = await observe(); + return { state, items: plan() }; + }, + get state() { + return state; + }, + }; +} + +/** Read-only: print each manifest's readiness report. Fails when a required item is not ready. */ +export async function platformPreflight({ + cwd, + options, + services = defaultServices(), + io = terminalIo(), +}) { + let failed = 0; + for (const file of selectManifests(cwd, options.filter)) { + const view = await inspect({ + cwd, + file, + services, + io, + interactive: io.interactive, + askForToken: io.interactive, + }); + io.write(`\n${formatReport({ title: view.title, items: view.items })}`); + if (!readiness(view.items).ready) failed += 1; + } + if (failed > 0) + throw new CliError( + `preflight --production: production is not ready. Run ${SETUP} to fix what it can.`, + 1, + ); +} + +/** Set up everything each manifest declares, then report what is still open. */ +export async function platformBootstrap({ + cwd, + options, + services = defaultServices(), + io = terminalIo(), +}) { + if (!io.interactive) + throw new CliError( + `${SETUP} asks for values, so it needs a terminal. To check production without changing it, run pnpm preflight --production.`, + 2, + ); + let failed = 0; + for (const file of selectManifests(cwd, options.filter)) { + const view = await inspect({ cwd, file, services, io, interactive: true, askForToken: true }); + io.write(`\n${formatReport({ title: view.title, items: view.items })}`); + const context = { + manifest: view.manifest, + directory: view.directory, + configPath: view.configPath, + state: view.state, + run: services.run, + io, + setupApi: view.setupApi, + values: new Map(), + handled: new Set(), + created: { widgets: new Map(), apps: new Map() }, + }; + try { + await applyPlan(context, view.items); + } finally { + context.values.clear(); + } + const after = await view.refresh(); + io.write(`\n${formatReport({ title: `${view.title}, after setup`, items: after.items })}`); + if (!readiness(after.items).ready) failed += 1; + } + if (failed > 0) + throw new CliError( + `bootstrap --production: some items are still open. Finish the steps above, then run ${SETUP} again; it picks up where it stopped.`, + 1, + ); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs new file mode 100644 index 0000000..8cd4019 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs @@ -0,0 +1,261 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { validateAgainstSchema } from './schema.mjs'; + +export const MANIFEST_FILE = 'platform.json'; +export const SCHEMA_PATH = fileURLToPath(new URL('../../../platform.schema.json', import.meta.url)); + +let schemaCache; +export function platformSchema() { + schemaCache ??= JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')); + return schemaCache; +} + +/** platform.json at the root and in each apps/*, as paths relative to `cwd`. */ +export function findManifests(cwd) { + const found = []; + const isFile = (file) => statSync(file, { throwIfNoEntry: false })?.isFile() === true; + if (isFile(path.join(cwd, MANIFEST_FILE))) found.push(MANIFEST_FILE); + let apps = []; + try { + apps = readdirSync(path.join(cwd, 'apps'), { withFileTypes: true }); + } catch { + // A repository without apps/ has only the root to look at. + } + for (const entry of apps.sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join('apps', entry.name, MANIFEST_FILE); + if (entry.isDirectory() && isFile(path.join(cwd, file))) found.push(file); + } + return found; +} + +/** Where the string that opens at `start` closes, counting escapes. */ +function stringEnd(text, start) { + let end = start + 1; + while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1; + return end + 1; +} + +/** Where the comment that opens at `start` ends, or `start` when none opens there. */ +function commentEnd(text, start) { + if (text[start] !== '/') return start; + if (text[start + 1] === '/') { + const end = text.indexOf('\n', start); + return end === -1 ? text.length : end; + } + if (text[start + 1] === '*') { + const end = text.indexOf('*/', start + 2); + return end === -1 ? text.length : end + 2; + } + return start; +} + +/** + * JSON with comments and trailing commas, as wrangler.jsonc allows. Strings + * are copied untouched, so a URL's `//` is not mistaken for a comment. + */ +export function parseJsonc(text) { + const tokens = []; + let index = 0; + while (index < text.length) { + const skipped = commentEnd(text, index); + if (skipped !== index) index = skipped; + else if (text[index] === '"') { + const end = stringEnd(text, index); + tokens.push(text.slice(index, end)); + index = end; + } else { + if (!/\s/.test(text[index])) tokens.push(text[index]); + index += 1; + } + } + // A comma directly before a closing bracket is the trailing comma JSONC allows. + const kept = tokens.filter( + (token, position) => !(token === ',' && ['}', ']'].includes(tokens[position + 1])), + ); + return JSON.parse(kept.join('')); +} + +function names(list) { + return (list ?? []).map((item) => item.name); +} + +function duplicates(values) { + return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]; +} + +function duplicateErrors(manifest) { + const errors = duplicates([...names(manifest.secrets), ...names(manifest.vars)]).map( + (name) => `${name} is declared more than once across secrets and vars.`, + ); + for (const label of ['d1', 'r2', 'turnstile', 'access']) { + for (const name of duplicates(names(manifest[label]))) + errors.push(`${label} "${name}" is declared twice.`); + } + return errors; +} + +/** Secrets a resource fills in, each with the resource that feeds it. */ +export function fedSecrets(manifest) { + const fed = []; + for (const widget of manifest.turnstile ?? []) + fed.push({ name: widget.secret, owner: `turnstile "${widget.name}"` }); + for (const app of manifest.access ?? []) { + fed.push({ name: app.teamDomainSecret, owner: `access "${app.name}"` }); + fed.push({ name: app.audienceSecret, owner: `access "${app.name}"` }); + } + return fed; +} + +function referenceErrors(manifest) { + const errors = []; + const secrets = new Map((manifest.secrets ?? []).map((secret) => [secret.name, secret])); + const vars = new Set(names(manifest.vars)); + const references = [ + ...fedSecrets(manifest), + ...(manifest.email ?? []) + .filter((email) => email.apiKeySecret) + .map((email) => ({ name: email.apiKeySecret, owner: `email "${email.domain}"` })), + ]; + for (const { name, owner } of references) { + const secret = secrets.get(name); + if (!secret) errors.push(`${owner} feeds ${name}, which is not listed in secrets.`); + else if (!(secret.targets ?? ['worker']).includes('worker')) + errors.push(`${owner} feeds ${name}, so ${name} must target the worker.`); + } + for (const widget of manifest.turnstile ?? []) { + if (!vars.has(widget.siteKeyVar)) + errors.push( + `turnstile "${widget.name}" feeds ${widget.siteKeyVar}, which is not listed in vars.`, + ); + } + return errors; +} + +function accessErrors(manifest) { + const errors = []; + for (const app of manifest.access ?? []) { + if (Object.keys(app.allow ?? {}).length !== 1) + errors.push( + `access "${app.name}" must allow exactly one of googleGroup, emailDomain, emails.`, + ); + if (app.allow?.googleGroup && app.identityProvider !== 'google-apps') + errors.push( + `access "${app.name}" allows a Google group, so identityProvider must be google-apps.`, + ); + } + return errors; +} + +function validPattern(pattern) { + try { + new RegExp(pattern, 'u'); + return true; + } catch { + return false; + } +} + +const targetsGithub = (entry) => + (entry.targets ?? []).some((target) => target.startsWith('github:')); + +function secretErrors(manifest) { + const errors = []; + const fed = new Set(fedSecrets(manifest).map((entry) => entry.name)); + for (const secret of manifest.secrets ?? []) { + const sources = [secret.generate === true, secret.from !== undefined, fed.has(secret.name)]; + const count = sources.filter(Boolean).length; + if (count > 1) + errors.push( + `${secret.name} has more than one source; use only one of generate, from, or a feeding resource.`, + ); + if (count === 0 && !secret.steps) + errors.push( + `${secret.name} is typed in by a person, so it needs steps that say where to find it.`, + ); + if (secret.pattern && !validPattern(secret.pattern)) + errors.push(`${secret.name} has a pattern that is not a valid regular expression.`); + if (targetsGithub(secret) && !manifest.github) + errors.push(`${secret.name} targets a GitHub environment, so github.repository is required.`); + } + return errors; +} + +function forbiddenErrors(manifest) { + const errors = []; + const declared = new Set([...names(manifest.secrets), ...names(manifest.vars)]); + for (const entry of manifest.forbidden ?? []) { + if (declared.has(entry.name)) errors.push(`${entry.name} is both required and forbidden.`); + if (targetsGithub(entry) && !manifest.github) + errors.push( + `forbidden ${entry.name} targets a GitHub environment, so github.repository is required.`, + ); + } + return errors; +} + +/** The rules a JSON schema cannot express: names that refer to each other, and contradictions. */ +function semanticErrors(manifest) { + return [ + ...duplicateErrors(manifest), + ...referenceErrors(manifest), + ...accessErrors(manifest), + ...secretErrors(manifest), + ...forbiddenErrors(manifest), + ]; +} + +/** Every problem with a parsed manifest, as readable lines. Empty when it is valid. */ +export function validateManifest(manifest) { + const errors = validateAgainstSchema(platformSchema(), manifest); + return errors.length > 0 ? errors : semanticErrors(manifest); +} + +/** Read and validate one manifest. Throws with every problem listed. */ +export function loadManifest(file) { + let manifest; + try { + manifest = JSON.parse(readFileSync(file, 'utf8')); + } catch (error) { + throw new Error(`${file} is not valid JSON: ${error.message}`, { cause: error }); + } + const errors = validateManifest(manifest); + if (errors.length > 0) + throw new Error(`${file} is not a valid platform manifest:\n ${errors.join('\n ')}`); + return manifest; +} + +/** + * The parts of the production wrangler config the platform check compares + * against: the Worker's name, its vars, and its D1 and R2 bindings. + */ +export function readWranglerConfig(file) { + const config = parseJsonc(readFileSync(file, 'utf8')); + return { + name: config.name, + vars: config.vars ?? {}, + d1: (config.d1_databases ?? []).map((database) => ({ + binding: database.binding, + name: database.database_name, + id: database.database_id, + migrationsTable: database.migrations_table ?? 'd1_migrations', + })), + r2: (config.r2_buckets ?? []).map((bucket) => ({ + binding: bucket.binding, + name: bucket.bucket_name, + })), + }; +} + +/** The .sql files in a migrations directory, in the order Wrangler applies them. */ +export function migrationFiles(directory) { + let files; + try { + files = readdirSync(directory); + } catch (error) { + if (error.code === 'ENOENT') throw new Error('the directory does not exist', { cause: error }); + throw error; + } + return files.filter((file) => file.endsWith('.sql')).sort(); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs new file mode 100644 index 0000000..eff677b --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs @@ -0,0 +1,213 @@ +import path from 'node:path'; +import { emailRecords } from './guides.mjs'; +import { migrationFiles, readWranglerConfig } from './manifest.mjs'; + +/** + * Read everything the manifest names, without changing anything. Each part + * is either `{ ok: true, value }` or `{ ok: false, reason, kind }`, so one + * unreadable service leaves the rest of the report intact. + */ + +export const known = (value) => ({ ok: true, value }); +export const unknown = (reason, kind = 'error') => ({ ok: false, reason, kind }); + +async function attempt(read) { + try { + return known(await read()); + } catch (error) { + return unknown(error.message, error.kind ?? 'error'); + } +} + +const SQL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function githubEnvironments(manifest) { + const targets = [ + ...(manifest.secrets ?? []).flatMap((secret) => secret.targets ?? []), + ...(manifest.forbidden ?? []).flatMap((entry) => entry.targets ?? []), + ]; + return [ + ...new Set( + targets.filter((target) => target.startsWith('github:')).map((target) => target.slice(7)), + ), + ]; +} + +async function observeWorker(api, account, name) { + if (!api.client) return api.missing; + return attempt(async () => { + let settings; + try { + settings = await api.client.get(`${account}/workers/scripts/${name}/settings`); + } catch (error) { + if (error.kind === 'not-found') return { exists: false, secrets: [], vars: {} }; + throw error; + } + const secrets = await api.client.get(`${account}/workers/scripts/${name}/secrets`); + const vars = {}; + for (const binding of settings?.bindings ?? []) { + if (binding.type === 'plain_text' || binding.type === 'json') + vars[binding.name] = binding.text ?? binding.json; + } + return { exists: true, secrets: (secrets ?? []).map((secret) => secret.name), vars }; + }); +} + +async function observeD1(api, account, manifest, config) { + if (!manifest.d1?.length) return known({}); + if (!api.client) return api.missing; + return attempt(async () => { + 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); + if (!match) continue; + const entry = { id: match.uuid }; + if (database.migrations) { + const table = + config?.d1.find((item) => item.name === database.name)?.migrationsTable ?? + 'd1_migrations'; + entry.applied = SQL_NAME.test(table) + ? await attempt(async () => { + try { + const [result] = await api.client.post( + `${account}/d1/database/${match.uuid}/query`, + { + sql: `SELECT name FROM ${table} ORDER BY id`, + }, + ); + return (result?.results ?? []).map((row) => row.name); + } catch (error) { + if (/no such table/i.test(error.message)) return []; + throw error; + } + }) + : unknown(`The migrations table name "${table}" is not a plain SQL name.`); + } + found[database.name] = entry; + } + return found; + }); +} + +async function observeAccess(api, account) { + if (!api.client) return api.missing; + try { + const apps = await api.client.list(`${account}/access/apps`); + const [organization, providers, policies] = await Promise.all([ + api.client.get(`${account}/access/organizations`), + api.client.list(`${account}/access/identity_providers`), + api.client.list(`${account}/access/policies`), + ]); + return known({ + enabled: true, + teamDomain: organization?.auth_domain, + providers: providers.map((provider) => ({ + id: provider.id, + type: provider.type, + name: provider.name, + })), + apps, + policies, + }); + } catch (error) { + if (error.kind === 'not-enabled') + return known({ enabled: false, providers: [], apps: [], policies: [] }); + return unknown(error.message, error.kind ?? 'error'); + } +} + +async function observeR2(api, account) { + if (!api.client) return api.missing; + return attempt(async () => { + const result = await api.client.get(`${account}/r2/buckets`); + return (result?.buckets ?? []).map((bucket) => bucket.name); + }); +} + +function githubRead(run, cwd, args) { + const result = run('gh', args, { cwd }); + if (result.status === 0) return { ok: true, stdout: result.stdout }; + const missing = /HTTP 404|Not Found/i.test(result.stderr); + return { ok: false, missing, reason: result.stderr.trim().split('\n')[0] || 'gh failed' }; +} + +async function observeGithub(run, cwd, manifest) { + const environments = githubEnvironments(manifest); + if (environments.length === 0) return known({ environments: [], secrets: {} }); + const repository = manifest.github.repository; + const value = { environments: [], secrets: {} }; + for (const environment of environments) { + const exists = githubRead(run, cwd, ['api', `repos/${repository}/environments/${environment}`]); + if (!exists.ok && !exists.missing) return unknown(`gh: ${exists.reason}`, 'unauthorized'); + if (!exists.ok) continue; + value.environments.push(environment); + const listed = githubRead(run, cwd, [ + 'secret', + 'list', + '--env', + environment, + '--repo', + repository, + '--json', + 'name', + ]); + if (!listed.ok) return unknown(`gh: ${listed.reason}`, 'unauthorized'); + value.secrets[environment] = JSON.parse(listed.stdout).map((secret) => secret.name); + } + return known(value); +} + +async function observeDns(resolve, manifest) { + const answers = {}; + for (const email of manifest.email ?? []) { + for (const record of emailRecords(email)) { + const key = `${record.name} ${record.type}`; + answers[key] ??= await attempt(() => resolve(record.name, record.type)); + } + } + return answers; +} + +/** + * @param {object} input + * @param {object} input.manifest a validated manifest + * @param {string} input.directory the manifest's directory, absolute + * @param {{ wrangler?: object, setup?: object }} input.apis Cloudflare clients + * @param {Function} input.run the command runner + * @param {Function} input.resolve the DNS resolver + */ +export async function observePlatform({ manifest, directory, apis, run, resolve }) { + const account = `accounts/${manifest.cloudflare.accountId}`; + const configFile = path.join(directory, manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc'); + const config = await attempt(() => readWranglerConfig(configFile)); + const migrations = {}; + for (const database of manifest.d1 ?? []) { + if (database.migrations) + migrations[database.name] = await attempt(() => + migrationFiles(path.join(directory, database.migrations)), + ); + } + // Workers, D1 and R2 read with Wrangler's own sign-in. Turnstile and Access + // prefer the setup token, because Wrangler's sign-in usually cannot see them. + const missing = unknown(apis.wranglerMissing ?? 'Wrangler is not signed in.', 'unauthorized'); + const wrangler = { client: apis.wrangler, missing }; + const privileged = { client: apis.setup ?? apis.wrangler, missing }; + return { + config, + migrations, + worker: await observeWorker(wrangler, account, manifest.cloudflare.worker), + d1: await observeD1(wrangler, account, manifest, config.ok ? config.value : undefined), + r2: manifest.r2?.length ? await observeR2(wrangler, account) : known([]), + turnstile: manifest.turnstile?.length + ? privileged.client + ? await attempt(() => privileged.client.list(`${account}/challenges/widgets`)) + : missing + : known([]), + access: manifest.access?.length + ? await observeAccess(privileged, account) + : known({ enabled: true, providers: [], apps: [], policies: [] }), + dns: await observeDns(resolve, manifest), + github: await observeGithub(run, directory, manifest), + }; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-access.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-access.mjs new file mode 100644 index 0000000..19e5eb2 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-access.mjs @@ -0,0 +1,194 @@ +import { accessAppGuide, googleWorkspaceGuide, zeroTrustGuide } from './guides.mjs'; +import { item, SETUP, TOKEN_HINT, unknownItem } from './plan-items.mjs'; + +/** + * Cloudflare Access: Zero Trust itself, the identity provider people sign in + * with, and each application with its allow policy. + */ + +const SECTION = 'Access'; + +/** The Access rule that admits the people a manifest entry allows. */ +export function allowRule(allow, provider) { + if (allow.googleGroup) + return [{ gsuite: { email: allow.googleGroup, identity_provider_id: provider?.id } }]; + if (allow.emailDomain) return [{ email_domain: { domain: allow.emailDomain } }]; + return allow.emails.map((email) => ({ email: { email } })); +} + +const same = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase(); + +function includesAllow(include, allow) { + const rules = include ?? []; + if (allow.googleGroup) return rules.some((rule) => same(rule.gsuite?.email, allow.googleGroup)); + if (allow.emailDomain) + return rules.some((rule) => same(rule.email_domain?.domain, allow.emailDomain)); + return allow.emails.every((email) => rules.some((rule) => same(rule.email?.email, email))); +} + +export function appDestinations(found) { + return new Set([ + ...(found.destinations ?? []) + .filter((destination) => !destination.type || destination.type === 'public') + .map((destination) => destination.uri), + ...(found.self_hosted_domains ?? []), + ...(found.domain ? [found.domain] : []), + ]); +} + +/** The live application a manifest entry describes: by name, else by a shared path. */ +export function findApp(apps, app) { + return ( + apps.find((candidate) => candidate.name === app.name) ?? + apps.find((candidate) => + app.destinations.some((destination) => appDestinations(candidate).has(destination)), + ) + ); +} + +function policyReasons(found, app, reusable) { + const policies = (found.policies ?? []) + .map((policy) => + policy.include ? policy : reusable.find((candidate) => candidate.id === policy.id), + ) + .filter(Boolean); + const allows = policies.filter((policy) => policy.decision === 'allow'); + const reasons = []; + if (allows.some((policy) => (policy.include ?? []).some((rule) => 'everyone' in rule))) + reasons.push('has an allow policy that lets everyone in'); + if (!allows.some((policy) => includesAllow(policy.include, app.allow))) + reasons.push('has no allow policy for the declared people'); + return reasons; +} + +/** Why a live Access application differs from the manifest, as readable reasons. */ +export function accessDifferences(found, app, provider, reusable) { + const reasons = []; + const destinations = appDestinations(found); + const lacking = app.destinations.filter((destination) => !destinations.has(destination)); + if (lacking.length > 0) reasons.push(`does not protect ${lacking.join(', ')}`); + const session = app.sessionDuration ?? '24h'; + if (found.session_duration && found.session_duration !== session) + reasons.push(`signs people in for ${found.session_duration}, not ${session}`); + if (provider && found.allowed_idps?.length > 0 && !found.allowed_idps.includes(provider.id)) + reasons.push(`does not offer the ${app.identityProvider} identity provider`); + return [...reasons, ...policyReasons(found, app, reusable)]; +} + +function providerItem(manifest, access, type) { + const provider = access.providers.find((candidate) => candidate.type === type); + const fields = { + id: `access:idp:${type}`, + section: SECTION, + label: type === 'google-apps' ? 'Google Workspace sign-in' : 'One-time PIN sign-in', + }; + if (provider) + return item({ ...fields, status: 'ok', detail: `identity provider "${provider.name}"` }); + const group = manifest.access.find((app) => app.allow.googleGroup)?.allow.googleGroup; + const guide = + type === 'google-apps' + ? googleWorkspaceGuide(access.teamDomain, group?.split('@')[1]) + : { + url: 'https://one.dash.cloudflare.com/', + steps: [ + 'In Zero Trust, go to Integrations → Identity providers → "Add new identity provider" → "One-time PIN", and save.', + ], + }; + return item({ + ...fields, + status: 'missing', + detail: 'is not an identity provider yet', + next: `add it in the dashboard; ${SETUP} shows the steps`, + action: { type: 'manual', key: `idp:${type}`, guide }, + }); +} + +function applicationItem(access, app) { + const fields = { id: `access:${app.name}`, section: SECTION, label: app.name }; + const provider = access.providers.find((candidate) => candidate.type === app.identityProvider); + const found = findApp(access.apps, app); + const shared = { + app, + provider, + rule: allowRule(app.allow, provider), + guide: accessAppGuide(app), + }; + if (!found && !provider) + return item({ + ...fields, + status: 'missing', + detail: 'waits for the identity provider', + next: `run ${SETUP} again after the identity provider is added`, + }); + if (!found) + return item({ + ...fields, + status: 'missing', + detail: `nothing protects ${app.destinations.join(', ')}`, + next: `${SETUP} creates the application and its allow policy`, + action: { type: 'access.create', ...shared }, + }); + const reasons = accessDifferences(found, app, provider, access.policies); + if (reasons.length > 0 && !provider) + return item({ + ...fields, + status: 'mismatch', + detail: `${reasons.join('; ')}; it also waits for the identity provider`, + next: `run ${SETUP} again after the identity provider is added`, + }); + if (reasons.length > 0) + return item({ + ...fields, + status: 'mismatch', + detail: reasons.join('; '), + next: `${SETUP} updates it`, + action: { type: 'access.update', found, ...shared }, + }); + const people = app.allow.googleGroup ?? app.allow.emailDomain ?? app.allow.emails.join(', '); + return item({ + ...fields, + status: 'ok', + detail: `protects ${app.destinations.length} paths for ${people}`, + }); +} + +export function planAccess({ manifest, state }) { + const apps = manifest.access ?? []; + if (apps.length === 0) return []; + if (!state.access.ok) + return [ + unknownItem( + { id: 'access', section: SECTION, label: 'Zero Trust', credentialHint: TOKEN_HINT }, + state.access, + ), + ]; + const access = state.access.value; + const zeroTrust = { id: 'access:zero-trust', section: SECTION, label: 'Zero Trust' }; + if (!access.enabled) + return [ + item({ + ...zeroTrust, + status: 'missing', + detail: 'is not turned on for this account', + next: `turn it on in the dashboard; ${SETUP} shows the steps`, + action: { type: 'manual', key: 'zero-trust', guide: zeroTrustGuide() }, + }), + ...apps.map((app) => + item({ + id: `access:${app.name}`, + section: SECTION, + label: app.name, + status: 'missing', + detail: 'waits for Zero Trust', + next: `run ${SETUP} again after Zero Trust is on`, + }), + ), + ]; + return [ + item({ ...zeroTrust, status: 'ok', detail: `team domain ${access.teamDomain ?? 'unknown'}` }), + ...[...new Set(apps.map((app) => app.identityProvider))].map((type) => + providerItem(manifest, access, type), + ), + ...apps.map((app) => applicationItem(access, app)), + ]; +} 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 new file mode 100644 index 0000000..7a314c2 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs @@ -0,0 +1,190 @@ +import { turnstileGuide } from './guides.mjs'; +import { item, SETUP, TOKEN_HINT, unknownItem } from './plan-items.mjs'; + +/** The Worker, its D1 databases and R2 buckets, and its Turnstile widgets. */ + +function configItem({ manifest, state, configPath }) { + const fields = { id: 'config', section: 'Worker', label: configPath }; + const name = manifest.cloudflare.worker; + if (!state.config.ok) + return item({ + ...fields, + status: 'mismatch', + detail: `cannot read it: ${state.config.reason}`, + next: 'point cloudflare.wranglerConfig at the production wrangler config', + }); + if (state.config.value.name !== name) + return item({ + ...fields, + status: 'mismatch', + detail: `names the Worker "${state.config.value.name}", but platform.json says "${name}"`, + next: 'make the two names agree', + }); + return item({ ...fields, status: 'ok', detail: `names the Worker ${name}` }); +} + +export function planWorker(context) { + const { manifest, state } = context; + const fields = { id: 'worker', section: 'Worker', label: manifest.cloudflare.worker }; + let deployed; + if (!state.worker.ok) deployed = unknownItem(fields, state.worker); + else if (!state.worker.value.exists) + deployed = item({ + ...fields, + status: 'missing', + detail: 'has never been deployed', + next: 'deploy it once: merge to main, or run pnpm run deploy from a clean checkout of main', + }); + else deployed = item({ ...fields, status: 'ok', detail: 'is deployed' }); + return [configItem(context), deployed]; +} + +function databaseItem({ state, configPath }, database) { + const fields = { id: `d1:${database.name}`, section: 'D1 databases', label: database.name }; + if (!state.d1.ok) return unknownItem(fields, state.d1); + const real = state.d1.value[database.name]; + if (!real) + return item({ + ...fields, + status: 'missing', + detail: 'does not exist', + next: `${SETUP} creates it`, + action: { type: 'd1.create', name: database.name }, + }); + const bound = state.config.ok + ? state.config.value.d1.find((entry) => entry.binding === database.binding) + : undefined; + if (state.config.ok && bound?.name !== database.name) + return item({ + ...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}`, + }); + 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}`, + }); + return item({ ...fields, status: 'ok', detail: `exists and is bound as ${database.binding}` }); +} + +function migrationsItem({ state }, database) { + const fields = { + id: `d1:${database.name}:migrations`, + section: 'D1 databases', + label: `${database.name} migrations`, + }; + const files = state.migrations[database.name]; + if (!files?.ok) + return item({ + ...fields, + status: 'mismatch', + detail: `cannot read ${database.migrations}: ${files?.reason ?? 'missing'}`, + next: 'fix the migrations path in platform.json', + }); + const action = { type: 'd1.migrate', name: database.name }; + const real = state.d1.ok ? state.d1.value[database.name] : undefined; + if (state.d1.ok && !real) + return item({ + ...fields, + status: 'missing', + detail: `${files.value.length} to apply once the database exists`, + next: `${SETUP} applies them`, + action, + }); + if (!real?.applied?.ok) return unknownItem(fields, real?.applied ?? state.d1); + const pending = files.value.filter((file) => !real.applied.value.includes(file)); + if (pending.length === 0) + return item({ ...fields, status: 'ok', detail: `all ${files.value.length} applied` }); + return item({ + ...fields, + status: 'missing', + detail: `${pending.length} of ${files.value.length} not applied: ${pending.join(', ')}`, + next: `${SETUP} applies them`, + action, + }); +} + +export function planD1(context) { + return (context.manifest.d1 ?? []).flatMap((database) => [ + databaseItem(context, database), + ...(database.migrations ? [migrationsItem(context, database)] : []), + ]); +} + +export function planR2({ manifest, state, configPath }) { + return (manifest.r2 ?? []).map((bucket) => { + const fields = { id: `r2:${bucket.name}`, section: 'R2 buckets', label: bucket.name }; + if (!state.r2.ok) return unknownItem(fields, state.r2); + if (!state.r2.value.includes(bucket.name)) + return item({ + ...fields, + status: 'missing', + detail: 'does not exist', + next: `${SETUP} creates it`, + action: { type: 'r2.create', name: bucket.name }, + }); + const bound = state.config.ok + ? state.config.value.r2.find((entry) => entry.binding === bucket.binding) + : undefined; + if (state.config.ok && bound?.name !== bucket.name) + return item({ + ...fields, + status: 'mismatch', + detail: `${configPath} does not bind ${bucket.binding} to ${bucket.name}`, + next: `add it to r2_buckets in ${configPath}`, + }); + return item({ ...fields, status: 'ok', detail: `exists and is bound as ${bucket.binding}` }); + }); +} + +/** The live widget a manifest entry describes: by name, else by covering its domains. */ +export function findWidget(widgets, widget) { + return ( + widgets.find((candidate) => candidate.name === widget.name) ?? + widgets.find((candidate) => + widget.domains.every((domain) => candidate.domains?.includes(domain)), + ) + ); +} + +function widgetItem({ manifest, state }, widget) { + const fields = { id: `turnstile:${widget.name}`, section: 'Turnstile', label: widget.name }; + const guide = turnstileGuide(widget, manifest.cloudflare); + if (!state.turnstile.ok) + return unknownItem({ ...fields, credentialHint: TOKEN_HINT }, state.turnstile); + const found = findWidget(state.turnstile.value, widget); + if (!found) + return item({ + ...fields, + status: 'missing', + detail: `no widget covers ${widget.domains.join(', ')}`, + next: `${SETUP} creates it`, + action: { type: 'turnstile.create', widget, guide }, + }); + const lacking = widget.domains.filter((domain) => !found.domains?.includes(domain)); + const mode = widget.mode ?? 'managed'; + if (lacking.length > 0 || found.mode !== mode) + return item({ + ...fields, + status: 'mismatch', + detail: + lacking.length > 0 + ? `does not cover ${lacking.join(', ')}` + : `is ${found.mode}, not ${mode}`, + next: `${SETUP} updates it`, + action: { type: 'turnstile.update', widget, sitekey: found.sitekey, guide }, + }); + return item({ + ...fields, + status: 'ok', + detail: `covers ${widget.domains.join(', ')}; site key ${found.sitekey}`, + }); +} + +export function planTurnstile(context) { + return (context.manifest.turnstile ?? []).map((widget) => widgetItem(context, widget)); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-items.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-items.mjs new file mode 100644 index 0000000..eaefe3c --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-items.mjs @@ -0,0 +1,33 @@ +/** + * The shape every plan item shares. An item's `level` decides whether it + * blocks production: `required` items fail the check, while `later` (a + * feature not built yet) and `recommended` items only warn. `action` is what + * `lvbt bootstrap --production` does about it; a `manual` action carries + * dashboard steps instead. + */ + +export const SETUP = 'pnpm bootstrap --production'; +export const TOKEN_HINT = `needs a Cloudflare API token: run ${SETUP}, or set LVBT_CLOUDFLARE_SETUP_TOKEN`; +export const GH_HINT = 'sign in: gh auth login'; + +export function item(fields) { + return { level: 'required', ...fields }; +} + +/** An item whose current state could not be read, with what would let it be read. */ +export function unknownItem(fields, observation) { + const { credentialHint, ...rest } = fields; + return item({ + ...rest, + status: 'unknown', + detail: `could not check: ${observation.reason}`, + next: + observation.kind === 'unauthorized' + ? (credentialHint ?? 'sign in: pnpm exec wrangler login') + : 'run the check again; if it keeps failing, read the reason above', + }); +} + +export function targetLabel(manifest, target) { + return target === 'worker' ? `Worker ${manifest.cloudflare.worker}` : `GitHub ${target.slice(7)}`; +} 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 new file mode 100644 index 0000000..111fcc2 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs @@ -0,0 +1,228 @@ +import { emailRecords, resendDomainGuide, varGuide } from './guides.mjs'; +import { findWidget } from './plan-cloudflare.mjs'; +import { GH_HINT, item, SETUP, targetLabel, unknownItem } from './plan-items.mjs'; +import { githubEnvironments } from './observe.mjs'; + +/** GitHub environments, secrets, vars, email DNS, and the values that must never be set. */ + +export function planGithubEnvironments({ manifest, state }) { + return githubEnvironments(manifest).map((environment) => { + const fields = { + id: `github:${environment}`, + section: 'GitHub', + label: `environment ${environment}`, + }; + if (!state.github.ok) return unknownItem({ ...fields, credentialHint: GH_HINT }, state.github); + if (state.github.value.environments.includes(environment)) + return item({ ...fields, status: 'ok', detail: `exists in ${manifest.github.repository}` }); + return item({ + ...fields, + status: 'missing', + detail: `does not exist in ${manifest.github.repository}`, + next: `${SETUP} creates it`, + action: { type: 'github.environment', environment }, + }); + }); +} + +/** Where a secret's value comes from when setup stores it. */ +export function secretSource(secret, manifest) { + if (secret.generate) return { type: 'generate' }; + if (secret.from === 'cloudflare.accountId') + return { + type: 'value', + value: manifest.cloudflare.accountId, + from: 'the Cloudflare account ID', + }; + const widget = (manifest.turnstile ?? []).find((candidate) => candidate.secret === secret.name); + if (widget) return { type: 'turnstile', widget, from: `the ${widget.name} Turnstile widget` }; + for (const app of manifest.access ?? []) { + if (app.teamDomainSecret === secret.name) + return { type: 'access-team', app, from: 'the Zero Trust team domain' }; + if (app.audienceSecret === secret.name) + return { type: 'access-audience', app, from: `the ${app.name} Access application` }; + } + return { type: 'prompt' }; +} + +function sourceHint(source) { + if (source.type === 'generate') return `${SETUP} generates and stores it`; + if (source.type === 'prompt') return `${SETUP} asks for it, with steps`; + return `${SETUP} copies it from ${source.from}`; +} + +/** Whether `name` is stored at `target`: an observation of true or false. */ +function stored(state, target, name) { + if (target === 'worker') { + if (!state.worker.ok) return state.worker; + if (!state.worker.value.exists) + return { ok: false, blocked: true, reason: "waits for the Worker's first deploy" }; + return { ok: true, value: state.worker.value.secrets.includes(name) }; + } + if (!state.github.ok) return { ...state.github, credentialHint: GH_HINT }; + return { ok: true, value: state.github.value.secrets[target.slice(7)]?.includes(name) ?? false }; +} + +function secretItem({ manifest, state }, secret, target) { + const fields = { + id: `secret:${secret.name}:${target}`, + section: 'Secrets', + label: `${secret.name} → ${targetLabel(manifest, target)}`, + level: secret.use === 'future' ? 'later' : 'required', + }; + const present = stored(state, target, secret.name); + if (present.blocked) + return item({ + ...fields, + status: 'missing', + detail: present.reason, + next: 'deploy the Worker first', + }); + if (!present.ok) + return unknownItem({ ...fields, credentialHint: present.credentialHint }, present); + if (present.value) return item({ ...fields, status: 'ok', detail: 'is set' }); + const source = secretSource(secret, manifest); + return item({ + ...fields, + status: 'missing', + detail: secret.neededFor + ? `is not set; needed for ${secret.neededFor}` + : `is not set. ${secret.purpose}`, + next: sourceHint(source), + action: { type: 'secret.put', secret, target, source }, + }); +} + +export function planSecrets(context) { + return (context.manifest.secrets ?? []).flatMap((secret) => + (secret.targets ?? ['worker']).map((target) => secretItem(context, secret, target)), + ); +} + +export function planVars({ manifest, state, configPath }) { + return (manifest.vars ?? []).map((variable) => { + const fields = { + id: `var:${variable.name}`, + section: 'Vars', + label: variable.name, + level: variable.use === 'future' ? 'later' : 'required', + }; + if (!state.config.ok) + return item({ + ...fields, + status: 'unknown', + detail: `cannot read ${configPath}`, + next: 'fix the wrangler config path', + }); + const widget = (manifest.turnstile ?? []).find( + (candidate) => candidate.siteKeyVar === variable.name, + ); + const live = + widget && state.turnstile.ok ? findWidget(state.turnstile.value, widget) : undefined; + const value = state.config.value.vars[variable.name]; + const action = { + type: 'manual', + key: `var:${variable.name}`, + guide: varGuide(variable, configPath, live?.sitekey), + }; + if (value === undefined || value === '') + return item({ + ...fields, + status: 'missing', + detail: `is not in vars in ${configPath}`, + next: `add it to vars in ${configPath}${live ? ` as "${live.sitekey}"` : ''}`, + action, + }); + if (live && value !== live.sitekey) + return item({ + ...fields, + status: 'mismatch', + detail: `is ${value}, but the ${widget.name} widget's site key is ${live.sitekey}`, + next: `set it to "${live.sitekey}" in ${configPath}`, + action, + }); + return item({ ...fields, status: 'ok', detail: `is set in ${configPath}` }); + }); +} + +export function planEmail({ manifest, state }) { + return (manifest.email ?? []).flatMap((email) => { + const guide = resendDomainGuide(email, manifest.cloudflare); + return emailRecords(email).map((record) => { + const fields = { + id: `email:${email.domain}:${record.key}`, + section: 'Email', + label: `${record.type} ${record.name}`, + level: record.level, + }; + const answers = state.dns[`${record.name} ${record.type}`] ?? { + ok: false, + reason: 'not looked up', + }; + if (!answers.ok) return unknownItem(fields, answers); + if (answers.value.some(record.matches)) + return item({ ...fields, status: 'ok', detail: record.purpose }); + return item({ + ...fields, + status: 'missing', + detail: `is missing; it ${record.purpose}. Expected ${record.expected}`, + next: `verify ${email.domain} in Resend; ${SETUP} shows the steps`, + action: { type: 'manual', key: `email:${email.domain}`, guide }, + }); + }); + }); +} + +function workerForbidden({ state, configPath }, entry, fields) { + const deployed = state.worker.ok && state.worker.value.exists ? state.worker.value : undefined; + const found = (detail, next, action) => + item({ ...fields, status: 'forbidden', detail: `${detail}. ${entry.reason}`, next, action }); + if (deployed?.secrets.includes(entry.name)) + return found('is set as a secret', `${SETUP} offers to delete it`, { + type: 'secret.delete', + name: entry.name, + target: 'worker', + }); + 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`, + ); + if (deployed && entry.name in deployed.vars) + return found( + 'is still a var on the deployed Worker', + 'deploy from main again; a deploy replaces every var', + ); + if (!state.worker.ok) return unknownItem(fields, state.worker); + return item({ ...fields, status: 'ok', detail: 'is not set' }); +} + +function githubForbidden({ state }, entry, fields, environment) { + if (!state.github.ok) return unknownItem({ ...fields, credentialHint: GH_HINT }, state.github); + if (!state.github.value.secrets[environment]?.includes(entry.name)) + return item({ ...fields, status: 'ok', detail: 'is not set' }); + return item({ + ...fields, + status: 'forbidden', + detail: `is set in the ${environment} environment. ${entry.reason}`, + next: `${SETUP} offers to delete it`, + action: { type: 'secret.delete', name: entry.name, target: `github:${environment}` }, + }); +} + +export function planForbidden(context) { + const { manifest } = context; + return (manifest.forbidden ?? []).flatMap((entry) => + (entry.targets ?? ['worker']).map((target) => { + const fields = { + id: `forbidden:${entry.name}:${target}`, + section: 'Must not be set', + label: `${entry.name} on ${targetLabel(manifest, target)}`, + level: entry.severity === 'warning' ? 'recommended' : 'required', + }; + return target === 'worker' + ? workerForbidden(context, entry, fields) + : githubForbidden(context, entry, fields, target.slice(7)); + }), + ); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs new file mode 100644 index 0000000..afec2bc --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs @@ -0,0 +1,53 @@ +import { planAccess } from './plan-access.mjs'; +import { planD1, planR2, planTurnstile, planWorker } from './plan-cloudflare.mjs'; +import { + planEmail, + planForbidden, + planGithubEnvironments, + planSecrets, + planVars, +} from './plan-values.mjs'; + +/** + * Compare what a manifest declares with what exists, and say for every item + * whether it is ready, why not, and what fixes it. Pure: the same manifest + * and state always give the same plan, so it is tested without a network. + */ + +export { SETUP } from './plan-items.mjs'; +export { findWidget } from './plan-cloudflare.mjs'; +export { accessDifferences, allowRule, findApp } from './plan-access.mjs'; +export { secretSource } from './plan-values.mjs'; + +/** + * Every item the manifest declares, in the order setup works through them: + * each one comes after the things it depends on. + */ +export function planPlatform({ manifest, state, configPath }) { + const context = { manifest, state, configPath }; + return [ + ...planWorker(context), + ...planD1(context), + ...planR2(context), + ...planTurnstile(context), + ...planAccess(context), + ...planGithubEnvironments(context), + ...planSecrets(context), + ...planVars(context), + ...planEmail(context), + ...planForbidden(context), + ]; +} + +/** Ready when every required item is ok. Anything else is listed by urgency. */ +export function readiness(items) { + const open = items.filter((entry) => entry.status !== 'ok'); + const now = open.filter((entry) => entry.level === 'required'); + return { + ready: now.length === 0, + ok: items.length - open.length, + now, + later: open.filter((entry) => entry.level === 'later'), + recommended: open.filter((entry) => entry.level === 'recommended'), + }; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/report.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/report.mjs new file mode 100644 index 0000000..ffc4075 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/report.mjs @@ -0,0 +1,39 @@ +import { readiness } from './plan.mjs'; +import { paint } from './terminal.mjs'; + +function mark(entry) { + if (entry.status === 'ok') return paint('green', 'ok '); + if (entry.level === 'required') return paint('red', 'FAIL'); + return paint('yellow', 'WARN'); +} + +/** + * The readiness report: one line per item under its section, the next step + * under anything that is not ok, then a summary grouped by urgency. + */ +export function formatReport({ title, items }) { + const width = Math.min(Math.max(...items.map((entry) => entry.label.length), 10), 44); + const lines = [paint('bold', title)]; + let section; + for (const entry of items) { + if (entry.section !== section) { + section = entry.section; + lines.push('', paint('bold', section)); + } + lines.push(` ${mark(entry)} ${entry.label.padEnd(width)} ${entry.detail}`); + if (entry.status !== 'ok' && entry.next) + lines.push(` ${' '.repeat(width)} next: ${entry.next}`); + } + const summary = readiness(items); + lines.push(''); + const parts = [`${summary.ok} of ${items.length} ready`]; + if (summary.now.length > 0) parts.push(paint('red', `${summary.now.length} needed now`)); + if (summary.later.length > 0) + parts.push(paint('yellow', `${summary.later.length} for features not built yet`)); + if (summary.recommended.length > 0) + parts.push(paint('yellow', `${summary.recommended.length} recommended`)); + lines.push( + `${summary.ready ? paint('green', 'Ready for production.') : paint('red', 'Not ready for production.')} ${parts.join(', ')}.`, + ); + return `${lines.join('\n')}\n`; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/schema.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/schema.mjs new file mode 100644 index 0000000..db65382 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/schema.mjs @@ -0,0 +1,116 @@ +/** + * The subset of JSON Schema that platform.schema.json uses, interpreted + * directly so the schema file stays the one definition of a valid manifest. + * A test fails when the schema uses a keyword outside this list, so the two + * cannot drift apart silently. + */ +export const SUPPORTED_KEYWORDS = new Set([ + '$schema', + '$id', + '$defs', + '$ref', + 'title', + 'description', + 'type', + 'const', + 'enum', + 'pattern', + 'minLength', + 'minItems', + 'items', + 'properties', + 'required', + 'additionalProperties', +]); + +function typeOf(value) { + if (Array.isArray(value)) return 'array'; + if (value === null) return 'null'; + if (Number.isInteger(value)) return 'integer'; + return typeof value; +} + +function resolve(root, reference) { + const match = /^#\/\$defs\/([A-Za-z0-9_-]+)$/.exec(reference); + const target = match ? root.$defs?.[match[1]] : undefined; + if (!target) throw new Error(`Unsupported or unknown schema reference ${reference}.`); + return target; +} + +function checkString(schema, value, at, errors) { + if (schema.minLength !== undefined && value.length < schema.minLength) + errors.push(`${at}: must not be empty.`); + if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) + errors.push(`${at}: "${value}" does not match ${schema.pattern}.`); +} + +function checkArray(walker, schema, value, at) { + if (schema.minItems !== undefined && value.length < schema.minItems) + walker.errors.push(`${at}: needs at least ${schema.minItems} item(s).`); + if (schema.items) + value.forEach((item, index) => visit(walker, schema.items, item, `${at}[${index}]`)); +} + +function checkObject(walker, schema, value, at) { + for (const key of schema.required ?? []) { + if (!(key in value)) walker.errors.push(`${at}: "${key}" is required.`); + } + for (const [key, child] of Object.entries(value)) { + const property = schema.properties?.[key]; + if (property) visit(walker, property, child, `${at}.${key}`); + else if (schema.additionalProperties === false) + walker.errors.push(`${at}: "${key}" is not a known field.`); + } +} + +/** The first way `value` breaks the schema's own constraints, or undefined. */ +function mismatch(schema, value) { + if ('const' in schema && value !== schema.const) return `must be ${JSON.stringify(schema.const)}`; + if (schema.enum && !schema.enum.includes(value)) + return `must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(', ')}`; + const actual = typeOf(value); + if (schema.type && actual !== schema.type) + return `must be ${schema.type === 'array' ? 'an array' : `a ${schema.type}`}`; + return undefined; +} + +function visit(walker, schema, value, at) { + if (schema.$ref) { + visit(walker, resolve(walker.root, schema.$ref), value, at); + return; + } + const problem = mismatch(schema, value); + if (problem) { + walker.errors.push(`${at}: ${problem}.`); + return; + } + const actual = typeOf(value); + if (actual === 'string') checkString(schema, value, at, walker.errors); + else if (actual === 'array') checkArray(walker, schema, value, at); + else if (actual === 'object') checkObject(walker, schema, value, at); +} + +/** Every way `value` breaks `schema`, as readable lines. Empty when it is valid. */ +export function validateAgainstSchema(schema, value) { + const walker = { root: schema, errors: [] }; + visit(walker, schema, value, '$'); + return walker.errors; +} + +/** Every keyword the schema uses that this interpreter would ignore. */ +export function unsupportedKeywords(schema) { + const found = new Set(); + const walk = (node, isMap) => { + if (node === null || typeof node !== 'object' || Array.isArray(node)) return; + for (const [key, child] of Object.entries(node)) { + if (isMap) walk(child, false); + else { + if (!SUPPORTED_KEYWORDS.has(key)) found.add(key); + if (key === 'properties' || key === '$defs') walk(child, true); + else if (key === 'items' || key === 'additionalProperties') walk(child, false); + } + } + }; + walk(schema, false); + 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 new file mode 100644 index 0000000..f28fb84 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs @@ -0,0 +1,144 @@ +import { spawnSync } from 'node:child_process'; + +/** + * The outside world the platform command talks to, behind small interfaces + * so tests replace them: a command runner, the Cloudflare API, and DNS. Secret + * values only ever travel on a child process's stdin or in a request body. + */ + +/** Run a command. `input` goes to stdin; `inherit` hands the terminal to it. */ +export function runCommand(command, args, { cwd, env, input, inherit = false } = {}) { + const result = spawnSync(command, args, { + cwd, + env: env ? { ...process.env, ...env } : process.env, + input, + encoding: 'utf8', + stdio: inherit ? 'inherit' : ['pipe', 'pipe', 'pipe'], + timeout: inherit ? undefined : 120_000, + }); + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +/** Replace a secret value anywhere in text that is about to be printed. */ +export function redact(text, value) { + return value ? text.split(value).join('[redacted]') : text; +} + +/** Why a Cloudflare call failed, in terms the plan can act on. */ +export function failureKind(status, errors = []) { + const messages = errors.map((error) => `${error.code ?? ''} ${error.message ?? ''}`).join(' '); + if (/not_enabled/.test(messages)) return 'not-enabled'; + if ( + status === 401 || + status === 403 || + errors.some((error) => [9109, 10000, 10001].includes(error.code)) + ) + return 'unauthorized'; + if (status === 404) return 'not-found'; + return 'error'; +} + +export class CloudflareError extends Error { + constructor(kind, status, errors) { + const detail = errors + .map((error) => error.message) + .filter(Boolean) + .join('; '); + super(`Cloudflare answered ${status}${detail ? `: ${detail}` : ''}`); + this.kind = kind; + this.status = status; + } +} + +/** A Cloudflare API client for one bearer token. */ +export function cloudflareApi(token, request = fetch) { + async function call(method, endpoint, body) { + let response; + try { + response = await request(`https://api.cloudflare.com/client/v4/${endpoint}`, { + method, + redirect: 'error', + headers: { + Authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(20_000), + }); + } catch { + throw new CloudflareError('error', 0, [{ message: 'the request failed or timed out' }]); + } + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success === false) { + const errors = payload.errors ?? []; + throw new CloudflareError(failureKind(response.status, errors), response.status, errors); + } + return payload; + } + return { + get: async (endpoint) => (await call('GET', endpoint)).result, + post: async (endpoint, body) => (await call('POST', endpoint, body)).result, + put: async (endpoint, body) => (await call('PUT', endpoint, body)).result, + /** Every page of a list endpoint. */ + list: async (endpoint) => { + const items = []; + for (let page = 1; page <= 50; page += 1) { + const separator = endpoint.includes('?') ? '&' : '?'; + const payload = await call('GET', `${endpoint}${separator}page=${page}&per_page=50`); + items.push(...(Array.isArray(payload.result) ? payload.result : [])); + const pages = payload.result_info?.total_pages ?? 1; + if (page >= pages || !Array.isArray(payload.result) || payload.result.length === 0) + return items; + } + return items; + }, + }; +} + +/** + * The token Wrangler signed in with, so reads need no extra credential. + * Returns `{ token }`, or `{ reason }` saying why there is none. + */ +export function wranglerToken(run, cwd) { + const result = run('pnpm', ['exec', 'wrangler', 'auth', 'token', '--json'], { + cwd, + env: { WRANGLER_SEND_METRICS: 'false' }, + }); + if (result.status === 0) { + try { + const parsed = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))); + if (typeof parsed.token === 'string' && parsed.token.length > 0) + return { token: parsed.token }; + } catch { + // Fall through to the explanation below. + } + } + const said = `${result.stderr}\n${result.stdout}` + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !/^\[?WARN/.test(line)); + const hint = said.find((line) => /log ?in|authenticat|ERR_|error/i.test(line)) ?? said.at(-1); + return { + reason: `Wrangler has no credential here (${hint ?? 'pnpm exec wrangler auth token failed'}). Sign in with pnpm exec wrangler login.`, + }; +} + +/** Public DNS answers through DNS over HTTPS, so checking records needs no credential. */ +export function dnsResolver(request = fetch) { + return async (name, type) => { + const response = await request( + `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`, + { headers: { accept: 'application/dns-json' }, signal: AbortSignal.timeout(10_000) }, + ); + if (!response.ok) throw new Error(`DNS lookup answered ${response.status}`); + const payload = await response.json(); + const code = { 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/cli/src/lib/platform/terminal.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/terminal.mjs new file mode 100644 index 0000000..aa0b080 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/terminal.mjs @@ -0,0 +1,89 @@ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline/promises'; +import { styleText } from 'node:util'; +import { CliError } from '../arguments.mjs'; + +/** Color only when the output is a terminal that wants it (styleText honors NO_COLOR). */ +export function paint(color, text, stream = process.stdout) { + return styleText(color, text, { stream }); +} + +/** Printable bytes typed or pasted, without escape sequences such as bracketed paste markers. */ +export function cleanTyped(text) { + // eslint-disable-next-line no-control-regex -- terminal escape sequences are control characters + return text.replace(/\u001b\[[0-9;]*[~A-Za-z]/g, '').trim(); +} + +/** + * Read a line without echoing it, for secrets. Backspace edits, Enter + * finishes, and Ctrl-C stops the command. + */ +function readHidden(input, output, question) { + return new Promise((resolve, reject) => { + output.write(question); + const wasRaw = input.isRaw; + input.setRawMode(true); + input.resume(); + input.setEncoding('utf8'); + let value = ''; + const done = (error) => { + input.off('data', onData); + input.setRawMode(wasRaw); + input.pause(); + output.write('\n'); + if (error) reject(error); + else resolve(cleanTyped(value)); + }; + function onData(chunk) { + for (const char of chunk) { + if (char === '\r' || char === '\n' || char === '\u0004') return done(); + if (char === '\u0003') + return done( + new CliError('Stopped. Nothing was saved for the value you were typing.', 130), + ); + if (char === '\u007f' || char === '\b') value = value.slice(0, -1); + else value += char; + } + return undefined; + } + input.on('data', onData); + }); +} + +function openCommand(url) { + if (process.platform === 'darwin') return ['open', [url]]; + if (process.platform === 'win32') return ['cmd', ['/c', 'start', '""', url]]; + return ['xdg-open', [url]]; +} + +/** The real terminal. Tests pass an object with the same methods. */ +export function terminalIo({ input = process.stdin, output = process.stdout } = {}) { + const ask = async (question) => { + const readline = createInterface({ input, output }); + try { + return (await readline.question(question)).trim(); + } finally { + readline.close(); + } + }; + return { + interactive: Boolean(input.isTTY && output.isTTY) && !process.env.CI, + write: (text) => output.write(text), + ask, + askHidden: (question) => readHidden(input, output, question), + confirm: async (question, byDefault) => { + const answer = await ask(`${question} ${byDefault ? '[Y/n]' : '[y/N]'} `); + return answer === '' ? byDefault : /^y(es)?$/i.test(answer); + }, + open: (url) => { + const [command, args] = openCommand(url); + try { + spawn(command, args, { stdio: 'ignore', detached: true }) + .on('error', () => undefined) + .unref(); + } catch { + // The link is printed too, so a missing opener costs nothing. + } + }, + }; +} diff --git a/.lvbt/web-platform/packages/eslint-config/package.json b/.lvbt/web-platform/packages/eslint-config/package.json index 0c18d77..85cc902 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.3.5", + "version": "0.4.1", "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 bf0cefd..7293bfd 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.3.5", + "version": "0.4.1", "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 5ce39b4..d4e8107 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.3.5", + "version": "0.4.1", "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 7e5592a..e412c38 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.3.5", + "version": "0.4.1", "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 6b85e4a..5a1f31b 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.3.5", + "version": "0.4.1", "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 6fc9c91..d78fd49 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.3.5", + "version": "0.4.1", "description": "Provider-neutral deployment, provisioning, and validation primitives for LVBT web repositories.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/standards/astro-sync.ts b/.lvbt/web-platform/standards/astro-sync.ts new file mode 100644 index 0000000..907bcf4 --- /dev/null +++ b/.lvbt/web-platform/standards/astro-sync.ts @@ -0,0 +1,146 @@ +import { existsSync } from 'node:fs'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +// An Astro package generates its astro:content and environment types with `astro sync`. On a +// clean checkout, as in CI, type-aware lint rules fail on every module that imports them until it +// has run. Every Astro package gets a `sync` script, and turbo.json runs it before lint and caches +// what it writes. `lvbt check contract` requires the same wiring. +const SYNC_SCRIPT = 'astro sync'; +const SYNC_TASK = { outputs: ['.astro/**'] }; +const PRINT_WIDTH = 100; + +type JsonObject = Record; +interface Manifest extends JsonObject { + dependencies?: JsonObject; + devDependencies?: JsonObject; + scripts?: JsonObject; +} +interface Turbo extends JsonObject { + tasks?: JsonObject; +} +interface Placement { + before?: string; + after?: string; +} + +/** The `packages:` globs in pnpm-workspace.yaml, without a YAML dependency. */ +async function workspaceGlobs(root: string): Promise { + const text = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8').catch(() => ''); + const lines = text.split('\n'); + const start = lines.findIndex((line) => /^packages:\s*$/.test(line)); + if (start === -1) return []; + const globs: string[] = []; + for (const line of lines.slice(start + 1)) { + if (/^\S/.test(line)) break; + if (/^\s+-\s+/.test(line)) globs.push(line.replace(/^\s+-\s+/, '').replace(/^['"]|['"]$/g, '')); + } + return globs; +} + +/** The directories one glob names: each child of `parent/*`, or the path itself. */ +async function expand(root: string, glob: string): Promise { + if (!glob.endsWith('/*')) return [glob]; + const parent = glob.slice(0, -2); + const entries = await readdir(path.join(root, parent), { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => `${parent}/${entry.name}`); +} + +async function workspacePackages(root: string): Promise { + const directories: string[] = []; + for (const glob of await workspaceGlobs(root)) directories.push(...(await expand(root, glob))); + return directories.filter((directory) => existsSync(path.join(root, directory, 'package.json'))); +} + +/** A copy of `object` with `key` placed before or after another key, or last when it is absent. */ +function withEntry(object: JsonObject, key: string, value: unknown, at: Placement): JsonObject { + const entries = Object.entries(object); + const anchor = entries.findIndex(([name]) => name === (at.before ?? at.after)); + if (anchor === -1) return { ...object, [key]: value }; + entries.splice(at.before ? anchor : anchor + 1, 0, [key, value]); + return Object.fromEntries(entries); +} + +function formatArray(value: unknown[], indent: string, lead: number): string { + if (value.length === 0) return '[]'; + const plain = value.every((item) => item === null || typeof item !== 'object'); + const inline = `[${value.map((item) => JSON.stringify(item)).join(', ')}]`; + if (plain && lead + inline.length + 1 <= PRINT_WIDTH) return inline; + const inner = `${indent} `; + const items = value.map((item) => `${inner}${formatJson(item, inner, inner.length)}`); + return `[\n${items.join(',\n')}\n${indent}]`; +} + +function formatObject(value: JsonObject, indent: string): string { + const entries = Object.entries(value); + if (entries.length === 0) return '{}'; + const inner = `${indent} `; + const lines = entries.map(([key, item]) => { + const label = `${JSON.stringify(key)}: `; + return `${inner}${label}${formatJson(item, inner, inner.length + label.length)}`; + }); + return `{\n${lines.join(',\n')}\n${indent}}`; +} + +/** + * JSON as Prettier prints a .json file: objects expanded one key per line, and arrays of plain + * values on one line when the whole line fits the organization's print width. + */ +export function formatJson(value: unknown, indent = '', lead = 0): string { + if (Array.isArray(value)) return formatArray(value, indent, lead); + if (value !== null && typeof value === 'object') return formatObject(value as JsonObject, indent); + return JSON.stringify(value); +} + +/** Gives each Astro package a `sync` script; reports whether any exist and which files change. */ +async function addSyncScripts(root: string, dryRun: boolean) { + const changed: string[] = []; + let astro = false; + for (const directory of await workspacePackages(root)) { + const file = path.join(root, directory, 'package.json'); + const manifest = JSON.parse(await readFile(file, 'utf8')) as Manifest; + if (!manifest.dependencies?.astro && !manifest.devDependencies?.astro) continue; + astro = true; + if (manifest.scripts?.sync) continue; + manifest.scripts = withEntry(manifest.scripts ?? {}, 'sync', SYNC_SCRIPT, { after: 'lint' }); + changed.push(`${directory}/package.json`); + // Prettier prints package.json exactly as JSON.stringify does. + if (!dryRun) await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`); + } + return { astro, changed }; +} + +/** The turbo.json tasks with a `sync` task that `lint` depends on. */ +function wiredTasks(tasks: JsonObject): JsonObject { + const withSync = tasks.sync ? tasks : withEntry(tasks, 'sync', SYNC_TASK, { before: 'lint' }); + const lint = (withSync.lint ?? { dependsOn: ['^lint'] }) as { dependsOn?: string[] }; + const dependsOn = lint.dependsOn ?? []; + if (dependsOn.includes('sync')) return withSync; + return { ...withSync, lint: { ...lint, dependsOn: [...dependsOn, 'sync'] } }; +} + +/** Runs `sync` before `lint` in the root turbo.json; returns the file when it changes. */ +async function wireTurbo(root: string, dryRun: boolean): Promise { + const file = path.join(root, 'turbo.json'); + let turbo: Turbo; + try { + turbo = JSON.parse(await readFile(file, 'utf8')) as Turbo; + } catch { + // A missing or commented turbo.json is left alone; `lvbt check contract` names what to add. + return []; + } + if (!turbo.tasks) return []; + const tasks = wiredTasks(turbo.tasks); + if (tasks === turbo.tasks) return []; + if (!dryRun) await writeFile(file, `${formatJson({ ...turbo, tasks })}\n`); + return ['turbo.json']; +} + +/** Adds the `sync` script to each Astro package and runs it before lint in turbo.json. */ +export async function syncAstroTypesBeforeLint(root: string, dryRun: boolean): Promise { + const { astro, changed } = await addSyncScripts(root, dryRun); + if (!astro) return changed; + return [...changed, ...(await wireTurbo(root, dryRun))]; +} diff --git a/.lvbt/web-platform/standards/web-platform.ts b/.lvbt/web-platform/standards/web-platform.ts index 626e6b5..742c8b5 100644 --- a/.lvbt/web-platform/standards/web-platform.ts +++ b/.lvbt/web-platform/standards/web-platform.ts @@ -3,6 +3,8 @@ import { existsSync } from 'node:fs'; import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { syncAstroTypesBeforeLint } from './astro-sync.ts'; + export interface WebPreset { formatVersion: number; preset: string; @@ -149,6 +151,7 @@ export async function applyPreset(root: string, bundle: WebPreset, dryRun = fals ...new Set([ ...(await migrateLegacyPackageScope(root, dryRun)), ...(await ignorePlaywrightOutput(root, dryRun)), + ...(await syncAstroTypesBeforeLint(root, dryRun)), ]), ].sort(); if (!dryRun) await install(root, bundle); diff --git a/AGENTS.md b/AGENTS.md index 51ae712..b127dba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,16 +7,18 @@ command that fixes it (`pnpm check:fix` repairs everything a machine can). Every LVBT repository answers to the same commands: -| Command | What it does | -| --------------------- | ---------------------------------------------------------- | -| `pnpm bootstrap` | Install dependencies, wire git hooks, and run preflight | -| `pnpm preflight` | Confirm the machine can build and deploy this repository | -| `pnpm check` | Format, docs, shape rules, lint, types, tests, repo checks | -| `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) | -| `turbo gen workspace` | Scaffold a new package or app | +| Command | What it does | +| ----------------------------- | -------------------------------------------------------------- | +| `pnpm bootstrap` | Install dependencies, wire git hooks, and run preflight | +| `pnpm preflight` | Confirm the machine can build and deploy this repository | +| `pnpm preflight --production` | Report whether production has everything `platform.json` lists | +| `pnpm bootstrap --production` | Set up whatever production is missing, asking for values | +| `pnpm check` | Format, docs, shape rules, lint, types, tests, repo checks | +| `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) | +| `turbo gen workspace` | Scaffold a new package or app | ## Create GitHub issues and pull requests diff --git a/docs/development/reference/glossary.md b/docs/development/reference/glossary.md index 39abe60..55588ff 100644 --- a/docs/development/reference/glossary.md +++ b/docs/development/reference/glossary.md @@ -49,3 +49,9 @@ request. The required status is named `Validate`. **Preflight**: `pnpm preflight`, the check that your machine can build and 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. +`pnpm preflight --production` checks production against it, and `pnpm bootstrap --production` sets +up what is missing.