From a4aeeda20f4b3bae1eb271c93f3ed27c1accbfd5 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Wed, 23 Sep 2026 21:55:24 -0700 Subject: [PATCH] chore: Update analytics repository standard to v0.4.4 Adopt the current vendored standard and organization package scope for consumer configuration, hooks, workflows, and the lockfile. Co-authored-by: Codex --- .agents/plugins/marketplace.json | 2 +- .claude/settings.json | 2 +- .codex/hooks.json | 2 +- .githooks/commit-msg | 4 +- .githooks/pre-commit | 4 +- .githooks/pre-push | 4 +- .githooks/prepare-commit-msg | 4 +- .github/renovate.json | 2 +- .lvbt/web-platform.json | 6 +- .../.agents/plugins/marketplace.json | 2 +- .../examples/with-astro/.claude/settings.json | 2 +- .../examples/with-astro/.codex/hooks.json | 2 +- .../examples/with-astro/.githooks/commit-msg | 4 +- .../examples/with-astro/.githooks/pre-commit | 4 +- .../examples/with-astro/.githooks/pre-push | 4 +- .../with-astro/.githooks/prepare-commit-msg | 4 +- .../examples/with-astro/.github/renovate.json | 16 +- .../examples/with-astro/.gitignore | 6 + .../with-astro/.markdownlint-cli2.jsonc | 2 + .lvbt/web-platform/examples/with-astro/.npmrc | 1 + .../examples/with-astro/AGENTS.md | 28 +- .../examples/with-astro/README.md | 2 +- .../with-astro/apps/site/eslint.config.js | 2 +- .../with-astro/apps/site/package.json | 11 +- .../with-astro/apps/site/playwright.config.ts | 2 +- .../apps/site/tests/e2e/home.spec.ts | 4 +- .../with-astro/apps/site/tsconfig.json | 2 +- .../with-astro/apps/site/vitest.config.ts | 2 +- .../docs/development/reference/glossary.md | 10 +- .../examples/with-astro/package.json | 4 +- .../examples/with-astro/prettier.config.js | 2 +- .../examples/with-astro/turbo.json | 5 +- .../.agents/plugins/marketplace.json | 2 +- .../with-vite-react/.claude/settings.json | 2 +- .../with-vite-react/.codex/hooks.json | 2 +- .../with-vite-react/.githooks/commit-msg | 4 +- .../with-vite-react/.githooks/pre-commit | 4 +- .../with-vite-react/.githooks/pre-push | 4 +- .../.githooks/prepare-commit-msg | 4 +- .../with-vite-react/.github/renovate.json | 16 +- .../examples/with-vite-react/.gitignore | 6 + .../with-vite-react/.markdownlint-cli2.jsonc | 2 + .../examples/with-vite-react/.npmrc | 1 + .../examples/with-vite-react/AGENTS.md | 28 +- .../examples/with-vite-react/README.md | 2 +- .../with-vite-react/apps/app/eslint.config.js | 2 +- .../with-vite-react/apps/app/package.json | 10 +- .../apps/app/playwright.config.ts | 2 +- .../apps/app/tests/e2e/app.spec.ts | 4 +- .../with-vite-react/apps/app/tsconfig.json | 2 +- .../with-vite-react/apps/app/vitest.config.ts | 2 +- .../docs/development/reference/glossary.md | 10 +- .../examples/with-vite-react/package.json | 4 +- .../with-vite-react/prettier.config.js | 2 +- .lvbt/web-platform/packages/cli/package.json | 9 +- .../packages/cli/platform.schema.json | 353 +++++++++++++++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .lvbt/web-platform/packages/cli/src/cli.mjs | 22 +- .../packages/cli/src/lib/arguments.mjs | 7 +- .../packages/cli/src/lib/check/contract.mjs | 67 +++- .../packages/cli/src/lib/check/index.mjs | 2 + .../packages/cli/src/lib/check/platform.mjs | 25 ++ .../packages/cli/src/lib/operate.mjs | 61 ++- .../cli/src/lib/platform/apply-resources.mjs | 190 +++++++++ .../cli/src/lib/platform/apply-steps.mjs | 105 +++++ .../cli/src/lib/platform/apply-values.mjs | 169 ++++++++ .../packages/cli/src/lib/platform/apply.mjs | 167 ++++++++ .../packages/cli/src/lib/platform/guides.mjs | 372 ++++++++++++++++++ .../packages/cli/src/lib/platform/index.mjs | 262 ++++++++++++ .../cli/src/lib/platform/manifest.mjs | 265 +++++++++++++ .../packages/cli/src/lib/platform/observe.mjs | 228 +++++++++++ .../cli/src/lib/platform/plan-access.mjs | 251 ++++++++++++ .../cli/src/lib/platform/plan-cloudflare.mjs | 207 ++++++++++ .../cli/src/lib/platform/plan-items.mjs | 42 ++ .../cli/src/lib/platform/plan-values.mjs | 266 +++++++++++++ .../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 | 211 ++++++++++ .../cli/src/lib/platform/terminal.mjs | 89 +++++ .../packages/eslint-config/package.json | 4 +- .../packages/playwright-config/index.js | 2 +- .../packages/playwright-config/package.json | 4 +- .../packages/prettier-config/package.json | 4 +- .../packages/typescript-config/package.json | 4 +- .../packages/vitest-config/index.js | 2 +- .../packages/vitest-config/package.json | 4 +- .../packages/web-platform/README.md | 11 +- .../packages/web-platform/package.json | 8 +- .../web-platform/src/doctor-cloudflare.ts | 59 +-- .../web-platform/src/provision-analytics.ts | 4 +- .../tests/doctor-cloudflare.test.ts | 91 ++++- .../tests/provision-analytics.test.ts | 31 +- .lvbt/web-platform/standards/astro-sync.ts | 158 ++++++++ .../standards/consumer-ignores.ts | 218 ++++++++++ .../standards/template-publication.ts | 126 ++++++ .../standards/web-platform-cli.ts | 30 +- .../standards/web-platform-source.ts | 2 +- .lvbt/web-platform/standards/web-platform.ts | 82 +++- AGENTS.md | 6 +- apps/collector/eslint.config.js | 2 +- apps/collector/package.json | 4 +- apps/collector/tsconfig.json | 2 +- package.json | 4 +- packages/analytics/eslint.config.js | 2 +- packages/analytics/package.json | 4 +- packages/analytics/tsconfig.json | 2 +- pnpm-lock.yaml | 32 +- prettier.config.js | 2 +- tools/report/eslint.config.js | 2 +- tools/report/package.json | 4 +- tools/report/tsconfig.json | 2 +- 113 files changed, 4512 insertions(+), 248 deletions(-) create mode 100644 .lvbt/web-platform/examples/with-astro/.npmrc create mode 100644 .lvbt/web-platform/examples/with-vite-react/.npmrc 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-resources.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs create mode 100644 .lvbt/web-platform/packages/cli/src/lib/platform/apply-values.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 create mode 100644 .lvbt/web-platform/standards/consumer-ignores.ts create mode 100644 .lvbt/web-platform/standards/template-publication.ts diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 3109e3f..559bf3d 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -8,7 +8,7 @@ "name": "lvbt-contributions", "source": { "source": "local", - "path": "../../node_modules/@lvbt/cli/plugins/lvbt-contributions" + "path": "../../node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions" }, "policy": { "installation": "AVAILABLE", diff --git a/.claude/settings.json b/.claude/settings.json index d1c0e32..98ed33c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.2.8" + "ref": "v0.4.4" } } }, diff --git a/.codex/hooks.json b/.codex/hooks.json index e6c5136..4d785b8 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "node \"$(git rev-parse --show-toplevel)/node_modules/@lvbt/cli/plugins/lvbt-contributions/hooks/codex-pre-tool-use.mjs\"", + "command": "node \"$(git rev-parse --show-toplevel)/node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/hooks/codex-pre-tool-use.mjs\"", "timeout": 10 } ] diff --git a/.githooks/commit-msg b/.githooks/commit-msg index f16cc88..ab3a79e 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/commit-msg.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/commit-msg.sh" "$@" diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1310c52..48fc8aa 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/pre-commit.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/pre-commit.sh" "$@" diff --git a/.githooks/pre-push b/.githooks/pre-push index 76aa3a8..366e9ce 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/pre-push.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/pre-push.sh" "$@" diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg index 464132c..81b471a 100755 --- a/.githooks/prepare-commit-msg +++ b/.githooks/prepare-commit-msg @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/prepare-commit-msg.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/prepare-commit-msg.sh" "$@" diff --git a/.github/renovate.json b/.github/renovate.json index d7160ad..12d03e2 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -13,7 +13,7 @@ }, "customManagers": [ { - "description": "The @lvbt/* packages install from git tags of repository-tooling with a path: suffix that the npm manager does not parse, and the Claude Code marketplace pins the same tag. Track both as one dependency on the repository's tags.", + "description": "The @lasvegasfortransit/* packages install from git tags of repository-tooling with a path: suffix that the npm manager does not parse, and the Claude Code marketplace pins the same tag. Track both as one dependency on the repository's tags.", "customType": "regex", "managerFilePatterns": ["/(^|/)package\\.json$/", "/(^|/)\\.claude/settings\\.json$/"], "matchStrings": [ diff --git a/.lvbt/web-platform.json b/.lvbt/web-platform.json index e124081..619b896 100644 --- a/.lvbt/web-platform.json +++ b/.lvbt/web-platform.json @@ -1,9 +1,9 @@ { "formatVersion": 1, "preset": "lvbt-web", - "release": "v0.2.8", - "commit": "8c33f0f2855fb1ce919df0c076d866392b83946e", - "contentHash": "f649fa8382fb42b4c8aafb4681406f75f3baf4e24f97ccb78eb5ce2198f74996", + "release": "v0.4.4", + "commit": "ffa587b3cc0c217bfd407e202aa45ac5390a1586", + "contentHash": "64c6ddc7019e327d9f6f9ffe276a694ed0b47fafea249f51ee8fee4c99fe7298", "executables": [ "examples/with-astro/.githooks/commit-msg", "examples/with-astro/.githooks/pre-commit", diff --git a/.lvbt/web-platform/examples/with-astro/.agents/plugins/marketplace.json b/.lvbt/web-platform/examples/with-astro/.agents/plugins/marketplace.json index 3109e3f..559bf3d 100644 --- a/.lvbt/web-platform/examples/with-astro/.agents/plugins/marketplace.json +++ b/.lvbt/web-platform/examples/with-astro/.agents/plugins/marketplace.json @@ -8,7 +8,7 @@ "name": "lvbt-contributions", "source": { "source": "local", - "path": "../../node_modules/@lvbt/cli/plugins/lvbt-contributions" + "path": "../../node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions" }, "policy": { "installation": "AVAILABLE", diff --git a/.lvbt/web-platform/examples/with-astro/.claude/settings.json b/.lvbt/web-platform/examples/with-astro/.claude/settings.json index d1c0e32..98ed33c 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.2.8" + "ref": "v0.4.4" } } }, diff --git a/.lvbt/web-platform/examples/with-astro/.codex/hooks.json b/.lvbt/web-platform/examples/with-astro/.codex/hooks.json index e6c5136..4d785b8 100644 --- a/.lvbt/web-platform/examples/with-astro/.codex/hooks.json +++ b/.lvbt/web-platform/examples/with-astro/.codex/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "node \"$(git rev-parse --show-toplevel)/node_modules/@lvbt/cli/plugins/lvbt-contributions/hooks/codex-pre-tool-use.mjs\"", + "command": "node \"$(git rev-parse --show-toplevel)/node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/hooks/codex-pre-tool-use.mjs\"", "timeout": 10 } ] diff --git a/.lvbt/web-platform/examples/with-astro/.githooks/commit-msg b/.lvbt/web-platform/examples/with-astro/.githooks/commit-msg index f16cc88..ab3a79e 100755 --- a/.lvbt/web-platform/examples/with-astro/.githooks/commit-msg +++ b/.lvbt/web-platform/examples/with-astro/.githooks/commit-msg @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/commit-msg.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/commit-msg.sh" "$@" diff --git a/.lvbt/web-platform/examples/with-astro/.githooks/pre-commit b/.lvbt/web-platform/examples/with-astro/.githooks/pre-commit index 1310c52..48fc8aa 100755 --- a/.lvbt/web-platform/examples/with-astro/.githooks/pre-commit +++ b/.lvbt/web-platform/examples/with-astro/.githooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/pre-commit.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/pre-commit.sh" "$@" diff --git a/.lvbt/web-platform/examples/with-astro/.githooks/pre-push b/.lvbt/web-platform/examples/with-astro/.githooks/pre-push index 76aa3a8..366e9ce 100755 --- a/.lvbt/web-platform/examples/with-astro/.githooks/pre-push +++ b/.lvbt/web-platform/examples/with-astro/.githooks/pre-push @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/pre-push.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/pre-push.sh" "$@" diff --git a/.lvbt/web-platform/examples/with-astro/.githooks/prepare-commit-msg b/.lvbt/web-platform/examples/with-astro/.githooks/prepare-commit-msg index 464132c..81b471a 100755 --- a/.lvbt/web-platform/examples/with-astro/.githooks/prepare-commit-msg +++ b/.lvbt/web-platform/examples/with-astro/.githooks/prepare-commit-msg @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/prepare-commit-msg.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/prepare-commit-msg.sh" "$@" diff --git a/.lvbt/web-platform/examples/with-astro/.github/renovate.json b/.lvbt/web-platform/examples/with-astro/.github/renovate.json index d7160ad..293369c 100644 --- a/.lvbt/web-platform/examples/with-astro/.github/renovate.json +++ b/.lvbt/web-platform/examples/with-astro/.github/renovate.json @@ -11,24 +11,10 @@ "enabled": true, "schedule": ["before 6am on monday"] }, - "customManagers": [ - { - "description": "The @lvbt/* packages install from git tags of repository-tooling with a path: suffix that the npm manager does not parse, and the Claude Code marketplace pins the same tag. Track both as one dependency on the repository's tags.", - "customType": "regex", - "managerFilePatterns": ["/(^|/)package\\.json$/", "/(^|/)\\.claude/settings\\.json$/"], - "matchStrings": [ - "github:LasVegasForTransit/repository-tooling#(?v\\d+\\.\\d+\\.\\d+)&path:/packages/[a-z-]+", - "\"repo\": \"LasVegasForTransit/repository-tooling\",\\s*\"ref\": \"(?v\\d+\\.\\d+\\.\\d+)\"" - ], - "depNameTemplate": "LasVegasForTransit/repository-tooling", - "datasourceTemplate": "github-tags", - "versioningTemplate": "semver" - } - ], "packageRules": [ { "groupName": "LVBT repository standard", - "matchDepNames": ["LasVegasForTransit/repository-tooling"] + "matchPackageNames": ["@lasvegasfortransit/*"] }, { "groupName": "dev dependencies (non-major)", diff --git a/.lvbt/web-platform/examples/with-astro/.gitignore b/.lvbt/web-platform/examples/with-astro/.gitignore index 18995c8..4cf35a7 100644 --- a/.lvbt/web-platform/examples/with-astro/.gitignore +++ b/.lvbt/web-platform/examples/with-astro/.gitignore @@ -1,6 +1,10 @@ node_modules/ dist/ coverage/ +test-results/ +playwright-report/ +blob-report/ +**/playwright/.cache/ .turbo/ .wrangler/ .env @@ -9,4 +13,6 @@ coverage/ .dev.vars .dev.vars.* .DS_Store +# Agent worktrees are other checkouts of this repository. +.claude/worktrees/ .astro/ diff --git a/.lvbt/web-platform/examples/with-astro/.markdownlint-cli2.jsonc b/.lvbt/web-platform/examples/with-astro/.markdownlint-cli2.jsonc index 14459ad..d49cf21 100644 --- a/.lvbt/web-platform/examples/with-astro/.markdownlint-cli2.jsonc +++ b/.lvbt/web-platform/examples/with-astro/.markdownlint-cli2.jsonc @@ -13,12 +13,14 @@ // Playwright output. "**/test-results", "**/playwright-report", + // Agent worktrees are other checkouts of this repository. ".claude/worktrees", "node_modules", "**/node_modules", "dist", "**/dist", ".turbo", + ".lvbt/web-platform", "docs/superpowers", "CHANGELOG.md", ], diff --git a/.lvbt/web-platform/examples/with-astro/.npmrc b/.lvbt/web-platform/examples/with-astro/.npmrc new file mode 100644 index 0000000..e520ccd --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/.npmrc @@ -0,0 +1 @@ +@lasvegasfortransit:registry=https://npm.pkg.github.com diff --git a/.lvbt/web-platform/examples/with-astro/AGENTS.md b/.lvbt/web-platform/examples/with-astro/AGENTS.md index a599239..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 @@ -25,9 +27,9 @@ authorizes creating an issue or pull request. It carries the organization checkl templates, and the only approved creation helper: ```bash -node node_modules/@lvbt/cli/plugins/lvbt-contributions/scripts/github-create.mjs issue \ +node node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/scripts/github-create.mjs issue \ --type bug|feature --title --body-file <file> -node node_modules/@lvbt/cli/plugins/lvbt-contributions/scripts/github-create.mjs pr \ +node node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/scripts/github-create.mjs pr \ --title <title> --body-file <file> --base main ``` @@ -43,5 +45,5 @@ change crosses boundaries; never invent one for a feature, file, task, or role. ## The repository standard -Lint, format, TypeScript, and test settings extend the `@lvbt/*` packages from +Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from `LasVegasForTransit/repository-tooling`. Change a shared rule there, not here. diff --git a/.lvbt/web-platform/examples/with-astro/README.md b/.lvbt/web-platform/examples/with-astro/README.md index 4ec3fc9..98140f7 100644 --- a/.lvbt/web-platform/examples/with-astro/README.md +++ b/.lvbt/web-platform/examples/with-astro/README.md @@ -28,5 +28,5 @@ repository's boundaries. `pnpm run deploy` builds and runs `wrangler deploy` for every app with a wrangler config; `.github/workflows/deploy.yml` does the same on every push to `main`. -Lint, format, TypeScript, and test settings extend the `@lvbt/*` packages from +Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from [`LasVegasForTransit/repository-tooling`](https://github.com/LasVegasForTransit/repository-tooling). diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/eslint.config.js b/.lvbt/web-platform/examples/with-astro/apps/site/eslint.config.js index 50f29f2..fe2b986 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/eslint.config.js +++ b/.lvbt/web-platform/examples/with-astro/apps/site/eslint.config.js @@ -1,3 +1,3 @@ -import { config } from '@lvbt/eslint-config/base'; +import { config } from '@lasvegasfortransit/eslint-config/base'; export default config; 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 a062108..507a2fb 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/package.json +++ b/.lvbt/web-platform/examples/with-astro/apps/site/package.json @@ -1,5 +1,5 @@ { - "name": "@lvbt/site", + "name": "@lasvegasfortransit/site", "version": "0.0.0", "private": true, "type": "module", @@ -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:", - "@lvbt/eslint-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/eslint-config", - "@lvbt/playwright-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/playwright-config", - "@lvbt/typescript-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/typescript-config", - "@lvbt/vitest-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/vitest-config", + "@lasvegasfortransit/eslint-config": "0.4.4", + "@lasvegasfortransit/playwright-config": "0.4.4", + "@lasvegasfortransit/typescript-config": "0.4.4", + "@lasvegasfortransit/vitest-config": "0.4.4", "@playwright/test": "catalog:", "@types/node": "catalog:", "eslint": "catalog:", diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/playwright.config.ts b/.lvbt/web-platform/examples/with-astro/apps/site/playwright.config.ts index 47d462a..5caad84 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/playwright.config.ts +++ b/.lvbt/web-platform/examples/with-astro/apps/site/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from '@playwright/test'; -import { foregroundServerEnvironment, sharedConfig } from '@lvbt/playwright-config'; +import { foregroundServerEnvironment, sharedConfig } from '@lasvegasfortransit/playwright-config'; const url = 'http://127.0.0.1:4321'; diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/home.spec.ts b/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/home.spec.ts index a4b97af..550fd00 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/home.spec.ts +++ b/.lvbt/web-platform/examples/with-astro/apps/site/tests/e2e/home.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { expectNoAccessibilityViolations } from '@lvbt/playwright-config/accessibility'; -import { monitorPageHealth } from '@lvbt/playwright-config/page-health'; +import { expectNoAccessibilityViolations } from '@lasvegasfortransit/playwright-config/accessibility'; +import { monitorPageHealth } from '@lasvegasfortransit/playwright-config/page-health'; test('the home page has one main heading', async ({ page }) => { const health = monitorPageHealth(page); diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/tsconfig.json b/.lvbt/web-platform/examples/with-astro/apps/site/tsconfig.json index 898e748..fd1637b 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/tsconfig.json +++ b/.lvbt/web-platform/examples/with-astro/apps/site/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@lvbt/typescript-config/astro.json", + "extends": "@lasvegasfortransit/typescript-config/astro.json", "include": [".astro/types.d.ts", "src", "tests", "*.config.ts"], "exclude": ["dist"] } diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/vitest.config.ts b/.lvbt/web-platform/examples/with-astro/apps/site/vitest.config.ts index 2474677..4222831 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/vitest.config.ts +++ b/.lvbt/web-platform/examples/with-astro/apps/site/vitest.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'vitest/config'; -import { sharedConfig } from '@lvbt/vitest-config'; +import { sharedConfig } from '@lasvegasfortransit/vitest-config'; export default defineConfig({ ...sharedConfig }); 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 6f18f37..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 @@ -20,10 +20,10 @@ of every tool for the whole organization. A package writes `"catalog:"` as a ver pinned one. <a id="eslint"></a>**ESLint**: finds likely bugs and enforces code rules. The rules come from -`@lvbt/eslint-config`. +`@lasvegasfortransit/eslint-config`. <a id="prettier"></a>**Prettier**: formats code the same way for everyone, so formatting is never -discussed in review. The settings come from `@lvbt/prettier-config`. +discussed in review. The settings come from `@lasvegasfortransit/prettier-config`. <a id="vitest"></a>**Vitest**: runs unit tests under `tests/`. @@ -49,3 +49,9 @@ request. The required status is named `Validate`. <a id="preflight"></a>**Preflight**: `pnpm preflight`, the check that your machine can build and deploy this repository, with a fix printed for anything missing. + +<a id="platform-manifest"></a>**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 4f9ee51..a5134a7 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": { - "@lvbt/cli": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/cli", - "@lvbt/prettier-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/prettier-config", + "@lasvegasfortransit/cli": "0.4.4", + "@lasvegasfortransit/prettier-config": "0.4.4", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/.lvbt/web-platform/examples/with-astro/prettier.config.js b/.lvbt/web-platform/examples/with-astro/prettier.config.js index 06fd3db..c2d836b 100644 --- a/.lvbt/web-platform/examples/with-astro/prettier.config.js +++ b/.lvbt/web-platform/examples/with-astro/prettier.config.js @@ -1,4 +1,4 @@ -import lvbt from '@lvbt/prettier-config'; +import lvbt from '@lasvegasfortransit/prettier-config'; export default { ...lvbt, 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/.agents/plugins/marketplace.json b/.lvbt/web-platform/examples/with-vite-react/.agents/plugins/marketplace.json index 3109e3f..559bf3d 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.agents/plugins/marketplace.json +++ b/.lvbt/web-platform/examples/with-vite-react/.agents/plugins/marketplace.json @@ -8,7 +8,7 @@ "name": "lvbt-contributions", "source": { "source": "local", - "path": "../../node_modules/@lvbt/cli/plugins/lvbt-contributions" + "path": "../../node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions" }, "policy": { "installation": "AVAILABLE", 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 d1c0e32..98ed33c 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.2.8" + "ref": "v0.4.4" } } }, diff --git a/.lvbt/web-platform/examples/with-vite-react/.codex/hooks.json b/.lvbt/web-platform/examples/with-vite-react/.codex/hooks.json index e6c5136..4d785b8 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.codex/hooks.json +++ b/.lvbt/web-platform/examples/with-vite-react/.codex/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "node \"$(git rev-parse --show-toplevel)/node_modules/@lvbt/cli/plugins/lvbt-contributions/hooks/codex-pre-tool-use.mjs\"", + "command": "node \"$(git rev-parse --show-toplevel)/node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/hooks/codex-pre-tool-use.mjs\"", "timeout": 10 } ] diff --git a/.lvbt/web-platform/examples/with-vite-react/.githooks/commit-msg b/.lvbt/web-platform/examples/with-vite-react/.githooks/commit-msg index f16cc88..ab3a79e 100755 --- a/.lvbt/web-platform/examples/with-vite-react/.githooks/commit-msg +++ b/.lvbt/web-platform/examples/with-vite-react/.githooks/commit-msg @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/commit-msg.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/commit-msg.sh" "$@" diff --git a/.lvbt/web-platform/examples/with-vite-react/.githooks/pre-commit b/.lvbt/web-platform/examples/with-vite-react/.githooks/pre-commit index 1310c52..48fc8aa 100755 --- a/.lvbt/web-platform/examples/with-vite-react/.githooks/pre-commit +++ b/.lvbt/web-platform/examples/with-vite-react/.githooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/pre-commit.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/pre-commit.sh" "$@" diff --git a/.lvbt/web-platform/examples/with-vite-react/.githooks/pre-push b/.lvbt/web-platform/examples/with-vite-react/.githooks/pre-push index 76aa3a8..366e9ce 100755 --- a/.lvbt/web-platform/examples/with-vite-react/.githooks/pre-push +++ b/.lvbt/web-platform/examples/with-vite-react/.githooks/pre-push @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/pre-push.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/pre-push.sh" "$@" diff --git a/.lvbt/web-platform/examples/with-vite-react/.githooks/prepare-commit-msg b/.lvbt/web-platform/examples/with-vite-react/.githooks/prepare-commit-msg index 464132c..81b471a 100755 --- a/.lvbt/web-platform/examples/with-vite-react/.githooks/prepare-commit-msg +++ b/.lvbt/web-platform/examples/with-vite-react/.githooks/prepare-commit-msg @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# The shared hook lives in @lvbt/cli; repository-specific steps go below it. +# The shared hook lives in @lasvegasfortransit/cli; repository-specific steps go below it. set -eu ROOT=$(git rev-parse --show-toplevel) -sh "$ROOT/node_modules/@lvbt/cli/hooks/prepare-commit-msg.sh" "$@" +sh "$ROOT/node_modules/@lasvegasfortransit/cli/hooks/prepare-commit-msg.sh" "$@" diff --git a/.lvbt/web-platform/examples/with-vite-react/.github/renovate.json b/.lvbt/web-platform/examples/with-vite-react/.github/renovate.json index d7160ad..293369c 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.github/renovate.json +++ b/.lvbt/web-platform/examples/with-vite-react/.github/renovate.json @@ -11,24 +11,10 @@ "enabled": true, "schedule": ["before 6am on monday"] }, - "customManagers": [ - { - "description": "The @lvbt/* packages install from git tags of repository-tooling with a path: suffix that the npm manager does not parse, and the Claude Code marketplace pins the same tag. Track both as one dependency on the repository's tags.", - "customType": "regex", - "managerFilePatterns": ["/(^|/)package\\.json$/", "/(^|/)\\.claude/settings\\.json$/"], - "matchStrings": [ - "github:LasVegasForTransit/repository-tooling#(?<currentValue>v\\d+\\.\\d+\\.\\d+)&path:/packages/[a-z-]+", - "\"repo\": \"LasVegasForTransit/repository-tooling\",\\s*\"ref\": \"(?<currentValue>v\\d+\\.\\d+\\.\\d+)\"" - ], - "depNameTemplate": "LasVegasForTransit/repository-tooling", - "datasourceTemplate": "github-tags", - "versioningTemplate": "semver" - } - ], "packageRules": [ { "groupName": "LVBT repository standard", - "matchDepNames": ["LasVegasForTransit/repository-tooling"] + "matchPackageNames": ["@lasvegasfortransit/*"] }, { "groupName": "dev dependencies (non-major)", diff --git a/.lvbt/web-platform/examples/with-vite-react/.gitignore b/.lvbt/web-platform/examples/with-vite-react/.gitignore index cff9ebb..65717d9 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.gitignore +++ b/.lvbt/web-platform/examples/with-vite-react/.gitignore @@ -1,6 +1,10 @@ node_modules/ dist/ coverage/ +test-results/ +playwright-report/ +blob-report/ +**/playwright/.cache/ .turbo/ .wrangler/ .env @@ -9,3 +13,5 @@ coverage/ .dev.vars .dev.vars.* .DS_Store +# Agent worktrees are other checkouts of this repository. +.claude/worktrees/ diff --git a/.lvbt/web-platform/examples/with-vite-react/.markdownlint-cli2.jsonc b/.lvbt/web-platform/examples/with-vite-react/.markdownlint-cli2.jsonc index 14459ad..d49cf21 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.markdownlint-cli2.jsonc +++ b/.lvbt/web-platform/examples/with-vite-react/.markdownlint-cli2.jsonc @@ -13,12 +13,14 @@ // Playwright output. "**/test-results", "**/playwright-report", + // Agent worktrees are other checkouts of this repository. ".claude/worktrees", "node_modules", "**/node_modules", "dist", "**/dist", ".turbo", + ".lvbt/web-platform", "docs/superpowers", "CHANGELOG.md", ], diff --git a/.lvbt/web-platform/examples/with-vite-react/.npmrc b/.lvbt/web-platform/examples/with-vite-react/.npmrc new file mode 100644 index 0000000..e520ccd --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/.npmrc @@ -0,0 +1 @@ +@lasvegasfortransit:registry=https://npm.pkg.github.com diff --git a/.lvbt/web-platform/examples/with-vite-react/AGENTS.md b/.lvbt/web-platform/examples/with-vite-react/AGENTS.md index a599239..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 @@ -25,9 +27,9 @@ authorizes creating an issue or pull request. It carries the organization checkl templates, and the only approved creation helper: ```bash -node node_modules/@lvbt/cli/plugins/lvbt-contributions/scripts/github-create.mjs issue \ +node node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/scripts/github-create.mjs issue \ --type bug|feature --title <title> --body-file <file> -node node_modules/@lvbt/cli/plugins/lvbt-contributions/scripts/github-create.mjs pr \ +node node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/scripts/github-create.mjs pr \ --title <title> --body-file <file> --base main ``` @@ -43,5 +45,5 @@ change crosses boundaries; never invent one for a feature, file, task, or role. ## The repository standard -Lint, format, TypeScript, and test settings extend the `@lvbt/*` packages from +Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from `LasVegasForTransit/repository-tooling`. Change a shared rule there, not here. diff --git a/.lvbt/web-platform/examples/with-vite-react/README.md b/.lvbt/web-platform/examples/with-vite-react/README.md index 4ec3fc9..98140f7 100644 --- a/.lvbt/web-platform/examples/with-vite-react/README.md +++ b/.lvbt/web-platform/examples/with-vite-react/README.md @@ -28,5 +28,5 @@ repository's boundaries. `pnpm run deploy` builds and runs `wrangler deploy` for every app with a wrangler config; `.github/workflows/deploy.yml` does the same on every push to `main`. -Lint, format, TypeScript, and test settings extend the `@lvbt/*` packages from +Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from [`LasVegasForTransit/repository-tooling`](https://github.com/LasVegasForTransit/repository-tooling). diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/eslint.config.js b/.lvbt/web-platform/examples/with-vite-react/apps/app/eslint.config.js index 40b3111..10f3779 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/eslint.config.js +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/eslint.config.js @@ -1,3 +1,3 @@ -import { config } from '@lvbt/eslint-config/react-internal'; +import { config } from '@lasvegasfortransit/eslint-config/react-internal'; export default config; 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 16c3a41..53f6f2a 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 @@ -1,5 +1,5 @@ { - "name": "@lvbt/app", + "name": "@lasvegasfortransit/app", "version": "0.0.0", "private": true, "type": "module", @@ -17,10 +17,10 @@ "react-dom": "catalog:" }, "devDependencies": { - "@lvbt/eslint-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/eslint-config", - "@lvbt/playwright-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/playwright-config", - "@lvbt/typescript-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/typescript-config", - "@lvbt/vitest-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/vitest-config", + "@lasvegasfortransit/eslint-config": "0.4.4", + "@lasvegasfortransit/playwright-config": "0.4.4", + "@lasvegasfortransit/typescript-config": "0.4.4", + "@lasvegasfortransit/vitest-config": "0.4.4", "@playwright/test": "catalog:", "@tailwindcss/vite": "catalog:", "@types/node": "catalog:", diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/playwright.config.ts b/.lvbt/web-platform/examples/with-vite-react/apps/app/playwright.config.ts index c10f111..a4a6fc2 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/playwright.config.ts +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from '@playwright/test'; -import { sharedConfig } from '@lvbt/playwright-config'; +import { sharedConfig } from '@lasvegasfortransit/playwright-config'; const url = 'http://127.0.0.1:4173'; diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/app.spec.ts b/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/app.spec.ts index e98970f..f6f2d85 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/app.spec.ts +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/tests/e2e/app.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { expectNoAccessibilityViolations } from '@lvbt/playwright-config/accessibility'; -import { monitorPageHealth } from '@lvbt/playwright-config/page-health'; +import { expectNoAccessibilityViolations } from '@lasvegasfortransit/playwright-config/accessibility'; +import { monitorPageHealth } from '@lasvegasfortransit/playwright-config/page-health'; test('the button counts clicks', async ({ page }) => { const health = monitorPageHealth(page); diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/tsconfig.json b/.lvbt/web-platform/examples/with-vite-react/apps/app/tsconfig.json index 875c263..9a770fe 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/tsconfig.json +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@lvbt/typescript-config/react-library.json", + "extends": "@lasvegasfortransit/typescript-config/react-library.json", "compilerOptions": { "types": ["vite/client", "node"] }, diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/vitest.config.ts b/.lvbt/web-platform/examples/with-vite-react/apps/app/vitest.config.ts index 2474677..4222831 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/vitest.config.ts +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/vitest.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'vitest/config'; -import { sharedConfig } from '@lvbt/vitest-config'; +import { sharedConfig } from '@lasvegasfortransit/vitest-config'; export default defineConfig({ ...sharedConfig }); 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 6f18f37..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 @@ -20,10 +20,10 @@ of every tool for the whole organization. A package writes `"catalog:"` as a ver pinned one. <a id="eslint"></a>**ESLint**: finds likely bugs and enforces code rules. The rules come from -`@lvbt/eslint-config`. +`@lasvegasfortransit/eslint-config`. <a id="prettier"></a>**Prettier**: formats code the same way for everyone, so formatting is never -discussed in review. The settings come from `@lvbt/prettier-config`. +discussed in review. The settings come from `@lasvegasfortransit/prettier-config`. <a id="vitest"></a>**Vitest**: runs unit tests under `tests/`. @@ -49,3 +49,9 @@ request. The required status is named `Validate`. <a id="preflight"></a>**Preflight**: `pnpm preflight`, the check that your machine can build and deploy this repository, with a fix printed for anything missing. + +<a id="platform-manifest"></a>**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 41a5519..7c77f57 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": { - "@lvbt/cli": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/cli", - "@lvbt/prettier-config": "github:LasVegasForTransit/repository-tooling#v0.2.8&path:/packages/prettier-config", + "@lasvegasfortransit/cli": "0.4.4", + "@lasvegasfortransit/prettier-config": "0.4.4", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/.lvbt/web-platform/examples/with-vite-react/prettier.config.js b/.lvbt/web-platform/examples/with-vite-react/prettier.config.js index 9794a17..edf7c71 100644 --- a/.lvbt/web-platform/examples/with-vite-react/prettier.config.js +++ b/.lvbt/web-platform/examples/with-vite-react/prettier.config.js @@ -1,3 +1,3 @@ -import lvbt from '@lvbt/prettier-config'; +import lvbt from '@lasvegasfortransit/prettier-config'; export default lvbt; diff --git a/.lvbt/web-platform/packages/cli/package.json b/.lvbt/web-platform/packages/cli/package.json index f0df639..065c5be 100644 --- a/.lvbt/web-platform/packages/cli/package.json +++ b/.lvbt/web-platform/packages/cli/package.json @@ -1,7 +1,7 @@ { - "name": "@lvbt/cli", - "version": "0.2.8", - "description": "The lvbt command every LVBT repository runs for bootstrap, preflight, and deploy, plus the shared git hooks and the lvbt-contributions agent plugin.", + "name": "@lasvegasfortransit/cli", + "version": "0.4.4", + "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..5cf923b --- /dev/null +++ b/.lvbt/web-platform/packages/cli/platform.schema.json @@ -0,0 +1,353 @@ +{ + "$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. Never ask for a second copy before the first is pasted; the last step copies this value for the prompt.", + "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:<environment>. 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." + }, + "sensitive": { + "type": "boolean", + "description": "false for a value that is not a credential, such as an account ID, a team domain, or an Access audience tag. Setup then asks for it with visible input and shows its value in the report and its output. Defaults to true: the value is typed hidden and never shown." + }, + "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 475f641..3bcc134 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.2.0", + "version": "0.4.4", "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 9fa85e5..82c1f70 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.2.0", + "version": "0.4.4", "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..93fe194 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,19 @@ 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 <app>] [--rotate <SECRET>[,<SECRET>...]]] + lvbt preflight [--production [--filter <app>]] + lvbt check [filenames|contract|debt|platform ...] [--staged] lvbt deploy [--filter <app>] [--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) + --rotate For bootstrap --production: replace the named secrets' stored values + on every target. Without it, a value that is already set is kept + --dry-run For deploy: build, then run wrangler deploy --dry-run `; 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..896adf7 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 valued = new Set(['--filter']); +const flags = new Set(['--dry-run', '--staged', '--production', '--help']); +const valued = new Set(['--filter', '--rotate']); /** `<command> [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 e9c2c65..a6fda7b 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; /** @@ -25,18 +25,22 @@ const IGNORED = new Set([ ]); /** Version specifiers the standard permits besides the catalog. */ -function allowedRange(range) { +function allowedRange(name, range) { return ( range.startsWith('catalog:') || range.startsWith('workspace:') || - range.startsWith('github:LasVegasForTransit/repository-tooling#') || + (/^@lasvegasfortransit\/[a-z0-9-]+$/.test(name) && /^\d+\.\d+\.\d+$/.test(range)) || range.startsWith('link:') ); } function vendoredRange(root, directory, name, range) { - if (!/^@lvbt\/[a-z0-9-]+$/.test(name) || !range.startsWith('file:')) return false; - const expected = path.resolve(root, '.lvbt/web-platform/packages', name.slice('@lvbt/'.length)); + if (!/^@lasvegasfortransit\/[a-z0-9-]+$/.test(name) || !range.startsWith('file:')) return false; + const expected = path.resolve( + root, + '.lvbt/web-platform/packages', + name.slice('@lasvegasfortransit/'.length), + ); if (path.resolve(root, directory, range.slice('file:'.length)) !== expected) return false; try { return JSON.parse(readFileSync(path.join(expected, 'package.json'), 'utf8')).name === name; @@ -125,12 +129,57 @@ function packageFailures(root, directory) { return failures; } +// Astro loads its configuration from the first of these it finds in the project's root. +const ASTRO_CONFIGS = ['mjs', 'js', 'ts', 'mts', 'cjs', 'cts'].map((ext) => `astro.config.${ext}`); + +/** + * An Astro project depends on astro and has its own configuration. A library that only imports + * Astro's types, such as an integration or components, has nothing for `astro sync` to generate. + */ +function isAstroProject(root, directory, manifest) { + if (!manifest.dependencies?.astro && !manifest.devDependencies?.astro) return false; + return ASTRO_CONFIGS.some((name) => existsSync(path.join(root, directory, name))); +} + +/** + * 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 (!isAstroProject(root, directory, 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 = []; for (const field of ['dependencies', 'devDependencies']) { for (const [name, range] of Object.entries(manifest[field] ?? {})) { - if (!allowedRange(range) && !vendoredRange(root, directory, name, range)) { + if (!allowedRange(name, range) && !vendoredRange(root, directory, name, range)) { failures.push( `${directory}/package.json pins "${name}" to "${range}" instead of "catalog:"`, ); @@ -142,14 +191,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..3d25dfe 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,60 @@ 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 = {} }) { + if (options.rotate !== undefined) + throw new CliError( + 'preflight never changes anything; use --rotate with pnpm bootstrap --production.', + 2, + ); + 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 = {} }) { + if (options.rotate !== undefined && !options.production) + throw new CliError('--rotate replaces production secrets, so it needs --production.', 2); 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-resources.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs new file mode 100644 index 0000000..dbd9055 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs @@ -0,0 +1,190 @@ +import { SETUP } from './plan.mjs'; +import { paint } from './terminal.mjs'; +import { account, manualStep, storeFed, succeeded, targetName, wrangler } from './apply-steps.mjs'; + +/** + * The resources setup creates or fixes: D1 databases and their migrations, + * R2 buckets, Turnstile widgets, Access applications with their allow + * policies, GitHub environments, and forbidden secrets it deletes. + */ + +export async function createWidget(context, action) { + const api = await context.setupApi(); + if (!api) + return manualStep(context, { + key: `turnstile:${action.widget.name}`, + title: `Create the Turnstile widget ${action.widget.name}`, + guide: 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); +} + +const WIDGET_SETTINGS = ['bot_fight_mode', 'clearance_level', 'ephemeral_id', 'offlabel', 'region']; + +export async function updateWidget(context, action) { + const api = await context.setupApi(); + if (!api) + return manualStep(context, { + key: `turnstile:${action.widget.name}`, + title: `Update the Turnstile widget ${action.widget.name}`, + guide: action.guide, + }); + const current = await api.get(`${account(context)}/challenges/widgets/${action.sitekey}`); + // A PUT replaces the whole widget, so carry every setting this does not manage. + const kept = Object.fromEntries( + Object.entries(current ?? {}).filter(([key]) => WIDGET_SETTINGS.includes(key)), + ); + await api.put(`${account(context)}/challenges/widgets/${action.sitekey}`, { + ...kept, + 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`); +} + +/** Whether a policy attached to an application lets everyone in. */ +function allowsEveryone(policy, reusable) { + const full = policy.include ? policy : reusable.find((candidate) => candidate.id === policy.id); + return full?.decision === 'allow' && (full.include ?? []).some((rule) => 'everyone' in rule); +} + +function appBody({ app, provider, policyId, base = {}, reusable = [] }) { + 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 }, + // An allow policy that admits everyone is the mismatch being fixed, so + // it is detached; keeping it would leave the application open. + ...(base.policies ?? []) + .filter((policy) => policy.id !== policyId && !allowsEveryone(policy, reusable)) + .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) { + const same = + existing.decision === 'allow' && + JSON.stringify(existing.include ?? []) === JSON.stringify(action.rule); + if (!same) + 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; +} + +export 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, { key: `access:${action.app.name}`, title, guide: action.guide }); + const policyId = await allowPolicy(context, api, action); + const reusable = context.state.access.ok ? context.state.access.value.policies : []; + const result = action.found + ? await api.put( + `${account(context)}/access/apps/${action.found.id}`, + appBody({ + app: action.app, + provider: action.provider, + policyId, + base: action.found, + reusable, + }), + ) + : await api.post( + `${account(context)}/access/apps`, + appBody({ app: action.app, provider: 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`, + ); + // A new application has a new audience tag, so any stored one is stale. + // The team domain has not changed; its own item stores it only if missing. + if (!action.found) await storeFed(context, action.app.audienceSecret, result.aud); +} + +export 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`); +} + +/** + * Whether the config names the database this run created. Wrangler applies + * migrations to the config's database_id, so until a pull request puts the + * new id there, applying them would reach the wrong database or none. + */ +export async function namedInConfig(context, action) { + const state = context.observe ? await context.observe() : context.state; + const real = state.d1.ok ? state.d1.value[action.name] : undefined; + const bound = state.config.ok + ? state.config.value.d1.find((entry) => entry.binding === action.binding) + : undefined; + if (real && bound?.id === real.id) return true; + context.io.write( + `The migrations for ${action.name} wait until ${context.configPath} has database_id ${real?.id ?? 'of the new database'}. Run ${SETUP} again after that pull request merges.\n`, + ); + return false; +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs new file mode 100644 index 0000000..9acce1b --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-steps.mjs @@ -0,0 +1,105 @@ +import { randomBytes } from 'node:crypto'; +import { redact } from './services.mjs'; +import { paint } from './terminal.mjs'; + +/** + * The small steps every part of setup shares: running Wrangler and gh, + * storing a secret on its target, and showing a person numbered steps. + * + * 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'); +} + +export function account(context) { + return `accounts/${context.manifest.cloudflare.accountId}`; +} + +export 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, + }); +} + +export 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)); +} + +export 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`)); +} + +/** + * Show dashboard steps and wait. With `confirm`, ask whether the step is + * done instead, and remember a yes on this computer, for what setup cannot + * read for itself. + */ +export async function manualStep(context, { key, title, guide, confirm }) { + if (context.handled.has(key)) return; + context.handled.add(key); + context.shown.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); + if (!confirm) { + await io.ask('Press Enter when you have finished (or to leave it for later): '); + return; + } + if (await io.confirm(confirm.question, false)) { + context.confirmations.add(confirm.key); + io.write( + `Noted. Setup will not ask about it again on this computer; the note is in ${context.confirmations.where} and holds no secret.\n`, + ); + } else io.write('It stays open, and setup asks again next time.\n'); +} + +export function targetName(context, target) { + return target === 'worker' + ? `Worker ${context.manifest.cloudflare.worker}` + : `GitHub environment ${target.slice(7)}`; +} + +export 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}`); + const secret = context.manifest.secrets?.find((candidate) => candidate.name === name); + const shown = secret?.sensitive === false ? ` = ${value}` : ''; + context.io.write( + `${paint('green', 'Stored')} ${name}${shown} on ${targetName(context, target)}.\n`, + ); +} + +/** Store a value a new resource produced, straight away, so it cannot be lost or left stale. */ +export 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); +} diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/apply-values.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-values.mjs new file mode 100644 index 0000000..cbccece --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply-values.mjs @@ -0,0 +1,169 @@ +import { accessAppGuide, teamDomainGuide, turnstileGuide } from './guides.mjs'; +import { findApp, findWidget, isSensitive, secretSource } from './plan.mjs'; +import { paint } from './terminal.mjs'; +import { account, generateSecret, printGuide, storeSecret, targetName } from './apply-steps.mjs'; + +/** + * Where a secret's value comes from when setup stores it: a new random + * value, one a resource already has, or one a person pastes. Also the + * explicit replacement that --rotate asks for. + */ + +function skipNote(secret) { + return secret.use === 'future' + ? 'Only a feature that is not built yet needs it, so it is fine to skip it now. This command asks again next time.' + : 'Production is not ready without it. If you cannot get it now, press Enter to skip; this command asks again next time.'; +} + +/** + * Ask a person for a value. A value a resource feeds (a Turnstile widget or + * an Access application) always gets the standard's reviewed steps; a value + * only a person knows gets the steps its manifest gives. + */ +async function promptValue(context, secret, standard) { + const { io } = context; + io.write(`\n${paint('bold', secret.name)}: ${secret.purpose}\n`); + if (secret.neededFor) io.write(`Needed for ${secret.neededFor}.\n`); + io.write(`${skipNote(secret)}\n`); + const guide = standard ?? { url: secret.url, steps: secret.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 = isSensitive(secret) + ? await io.askHidden(`Paste ${secret.name} (hidden; leave empty to skip for now): `) + : await io.ask(`Paste ${secret.name} (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; +} + +/** The standard's steps for a value a resource feeds, shortened when this run already showed them. */ +function fedGuide(context, source) { + const { manifest, configPath } = context; + if (source.type === 'turnstile') { + const guide = turnstileGuide(source.widget, manifest.cloudflare, configPath); + return context.shown.has(`turnstile:${source.widget.name}`) + ? { url: guide.url, steps: guide.secretSteps } + : guide; + } + if (source.type === 'access-team') return teamDomainGuide(source.app.teamDomainSecret); + if (source.type === 'access-audience') { + const guide = accessAppGuide(source.app, manifest.cloudflare.zone.name); + return context.shown.has(`access:${source.app.name}`) + ? { url: guide.url, steps: guide.audienceSteps } + : guide; + } + return undefined; +} + +/** The value a resource or the manifest already has, without asking anyone. */ +async function knownValue(context, source) { + if (source.type === 'value') return source.value; + if (source.type === 'turnstile') return readWidgetSecret(context, source.widget); + if (source.type === 'access-team') + return context.state.access.ok ? context.state.access.value.teamDomain : undefined; + if (source.type === 'access-audience') { + const created = context.created.apps.get(source.app.name); + return ( + created?.aud ?? + (context.state.access.ok + ? findApp(context.state.access.value.apps, source.app)?.aud + : undefined) + ); + } + return undefined; +} + +export async function secretValue(context, action) { + const { secret, source } = action; + if (context.values.has(secret.name)) return context.values.get(secret.name); + let value; + if (source.type === 'generate') { + // The plan offers this only when no target holds a value yet, so every + // target this run stores gets the same new value. + value = generateSecret(); + context.io.write(`Generated a new random value for ${secret.name}.\n`); + } else { + value = await knownValue(context, source); + } + if (!value) value = await promptValue(context, secret, fedGuide(context, source)); + if (value) context.values.set(secret.name, value); + return value; +} + +/** The targets of a rotated secret that this run has not already stored. */ +function rotationTargets(context, secret) { + const targets = (secret.targets ?? ['worker']).filter( + (target) => !context.handled.has(`secret:${secret.name}:${target}`), + ); + const deployed = context.state.worker.ok && context.state.worker.value.exists; + for (const target of targets.filter((target) => target === 'worker' && !deployed)) + context.io.write( + `\n${secret.name} cannot be replaced on ${targetName(context, target)} before its first deploy.\n`, + ); + return targets.filter((target) => target !== 'worker' || deployed); +} + +/** A new value for a rotated secret, or undefined when the person keeps the old one. */ +async function rotatedValue(context, secret) { + const source = secretSource(secret, context.manifest); + if (source.type === 'generate') return generateSecret(); + return ( + (await knownValue(context, source)) ?? + (await promptValue(context, secret, fedGuide(context, source))) + ); +} + +async function rotateSecret(context, secret) { + const { io } = context; + const targets = rotationTargets(context, secret); + if (targets.length === 0) return; + const where = targets.map((target) => targetName(context, target)).join(' and '); + io.write(`\n${paint('bold', `Replace ${secret.name}`)}\n`); + const question = `Replace ${secret.name} on ${where}? Anything still using the old value stops working until it has the new one.`; + if (!(await io.confirm(question, true))) return; + const value = await rotatedValue(context, secret); + if (!value) { + io.write(`Kept the current ${secret.name}.\n`); + return; + } + for (const target of targets) { + try { + await storeSecret(context, secret.name, target, value); + } catch (error) { + io.write( + `${paint('red', 'Could not')} replace ${secret.name} on ${targetName(context, target)}: ${error.message}\n`, + ); + } + } +} + +/** + * Replace the stored value of each named secret on every target, when the + * person asked for it with --rotate. A generated secret gets a new random + * value; a typed one is asked for again; one a resource feeds is copied from + * the resource again. A target this run already stored is left alone, since + * its value is new. Nothing is replaced without that flag. + */ +export async function rotateSecrets(context, names) { + for (const name of names) + await rotateSecret( + context, + context.manifest.secrets.find((secret) => secret.name === name), + ); +} 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..d504c84 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs @@ -0,0 +1,167 @@ +import { varGuide } from './guides.mjs'; +import { SETUP } from './plan.mjs'; +import { paint } from './terminal.mjs'; +import { manualStep, storeSecret, succeeded, wrangler } from './apply-steps.mjs'; +import { + createOrUpdateApp, + createWidget, + deleteSecret, + namedInConfig, + updateWidget, +} from './apply-resources.mjs'; +import { secretValue } from './apply-values.mjs'; + +export { generateSecret, targetName } from './apply-steps.mjs'; +export { rotateSecrets } from './apply-values.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. + */ + +async function createDatabase(context, action) { + succeeded(wrangler(context, ['d1', 'create', action.name])); + context.io.write( + `${paint('green', 'Created')} the D1 database ${action.name}. Put its database_id in ${context.configPath} through a pull request; the final report shows the id.\n`, + ); +} + +async function migrateDatabase(context, action) { + if (action.afterCreate && !(await namedInConfig(context, action))) return; + context.io.write( + `Applying migrations to ${action.name}. Wrangler lists them and asks you to confirm.\n`, + ); + succeeded( + wrangler(context, ['d1', 'migrations', 'apply', action.name, '--remote'], { inherit: true }), + ); +} + +async function createBucket(context, action) { + succeeded(wrangler(context, ['r2', 'bucket', 'create', action.name])); + context.io.write(`${paint('green', 'Created')} the R2 bucket ${action.name}.\n`); +} + +async function createEnvironment(context, action) { + const repository = context.manifest.github.repository; + succeeded( + context.run( + 'gh', + ['api', '--method', 'PUT', `repos/${repository}/environments/${action.environment}`], + { cwd: context.directory }, + ), + ); + context.io.write(`${paint('green', 'Created')} the GitHub environment ${action.environment}.\n`); +} + +async function putSecret(context, action) { + 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; + } + await storeSecret(context, action.secret.name, action.target, value); +} + +async function showSteps(context, action, entry) { + // A widget created earlier in this run has a site key the plan could not know. + const created = action.widget && context.created.widgets.get(action.widget); + const guide = created + ? varGuide(action.variable, context.configPath, created.sitekey, action.widget) + : action.guide; + await manualStep(context, { + key: action.key, + title: `${entry.label} ${entry.detail}`, + guide, + confirm: action.confirm, + }); +} + +const HANDLERS = { + 'd1.create': createDatabase, + 'd1.migrate': migrateDatabase, + 'r2.create': createBucket, + 'turnstile.create': createWidget, + 'turnstile.update': updateWidget, + 'access.create': createOrUpdateApp, + 'access.update': createOrUpdateApp, + 'github.environment': createEnvironment, + 'secret.put': putSecret, + 'secret.delete': deleteSecret, + manual: showSteps, +}; + +async function perform(context, entry) { + const handler = HANDLERS[entry.action.type]; + if (!handler) throw new Error(`unknown action ${entry.action.type}`); + await handler(context, entry.action, entry); +} + +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; + if (items.every((entry) => entry.status === 'ok')) { + io.write('\nEverything platform.json declares is already in place. Nothing was changed.\n'); + return { acted: false }; + } + 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 else can be set up from here, and nothing was changed. The report above says what is left.\n', + ); + return { acted: false }; + } + 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 { acted: false }; + for (const entry of work) { + try { + await perform(context, entry); + } catch (error) { + io.write(`${paint('red', 'Could not')} ${describeAction(entry)}: ${error.message}\n`); + } + } + return { acted: true }; +} 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..26c1b16 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs @@ -0,0 +1,372 @@ +/** + * 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. + * + * Every guide assumes the person has never used the service before, so each + * step says exactly what to type or choose, and where a copied value goes. + * A guide never asks for a second copy before the first is pasted: each + * copied value goes into its destination (this command's prompt, a + * dashboard field, a file) straight away, and a value needed in two places + * is pasted in both before anything else is copied. + * The labels follow the dashboards as they were on 2026-09-23 and the + * providers' own documentation: + * - developers.cloudflare.com/cloudflare-one/setup/ + * - developers.cloudflare.com/cloudflare-one/integrations/identity-providers/google-workspace/ + * - developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app/ + * - developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/ + * - developers.cloudflare.com/turnstile/get-started/ + * - developers.cloudflare.com/fundamentals/api/get-started/create-token/ + * - resend.com/docs/knowledge-base/cloudflare and resend.com/docs/dashboard/domains/regions + */ + +export const ZERO_TRUST = 'https://one.dash.cloudflare.com/'; + +/** + * LVBT's Zero Trust organization. The team domain is what Access signs + * tokens with and what Google redirects to; the team name is only a label. + */ +export const LVBT_TEAM_SUBDOMAIN = 'lvbt'; +export const LVBT_TEAM_DOMAIN = `${LVBT_TEAM_SUBDOMAIN}.cloudflareaccess.com`; +export const LVBT_TEAM_NAME = 'Las Vegans for Better Transit'; +/** LVBT's one Google Cloud project, which holds every OAuth client and service account. */ +export const LVBT_GOOGLE_PROJECT = 'LVBT Core'; +export const LVBT_GOOGLE_PROJECT_ID = 'lvbt-core'; +/** The shared LVBT address used as a contact wherever a service asks for one. */ +export const LVBT_SHARED_EMAIL = 'tech@lasvegasfortransit.org'; +/** The LVBT Cloudflare account's name. */ +export const LVBT_CLOUDFLARE_ACCOUNT = 'Las Vegans for Better Transit'; + +const REGIONS = { + 'us-east-1': 'North Virginia (us-east-1)', + 'eu-west-1': 'Ireland (eu-west-1)', + 'sa-east-1': 'São Paulo (sa-east-1)', + 'ap-northeast-1': 'Tokyo (ap-northeast-1)', +}; + +/** Normalize a DNS-over-HTTPS answer: TXT data arrives quoted and sometimes split. */ +export function dnsText(data) { + return data.replace(/"\s+"/g, '').replace(/^"|"$/g, ''); +} + +/** A name as the Cloudflare DNS page wants it: relative to the zone, or @ for the zone itself. */ +export function relativeName(name, zone) { + if (name === zone) return '@'; + return name.endsWith(`.${zone}`) ? name.slice(0, -(zone.length + 1)) : name; +} + +/** 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) { + const region = email.region ?? 'us-east-1'; + const zone = cloudflare.zone.name; + const [mx, spf, dkim, dmarc] = emailRecords(email).map((record) => ({ + ...record, + short: relativeName(record.name, zone), + })); + return { + url: 'https://resend.com/domains', + steps: [ + 'Sign in to Resend at https://resend.com/login. If you have no account, sign up at https://resend.com/signup with your @lasvegasfortransit.org address, then ask a maintainer to invite you to the LVBT team. Everything below belongs in that team, never in a personal one.', + `On the Domains page, if ${email.domain} is listed, click it and go to the next step. Otherwise click "Add Domain", type ${email.domain}, choose the region ${REGIONS[region] ?? region}, and click "Add". Keep that region: platform.json and the DNS records both name it.`, + `The easiest way to add the DNS records is the "Sign in to Cloudflare" button on the domain's page in Resend. Approve the request in the Cloudflare window, and it adds every record for you.`, + `To add them by hand instead, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records and add these three, each with TTL "Auto" and Proxy status "DNS only": type MX, name ${mx.short}, mail server feedback-smtp.${region}.amazonses.com, priority 10; type TXT, name ${spf.short}, content v=spf1 include:amazonses.com ~all; type TXT, name ${dkim.short}, content the long p=… value Resend shows for it.`, + `Add the DMARC record too, which Resend recommends: type TXT, name ${dmarc.short}, content v=DMARC1; p=none;.`, + 'Back in Resend, click "Verify DNS Records". Wait until the domain\'s status says "Verified", usually within a few minutes (DNS can take up to 72 hours). Then run this command again.', + ], + }; +} + +export function zeroTrustGuide(manifest) { + const secrets = [...new Set((manifest?.access ?? []).map((app) => app.teamDomainSecret))]; + const holds = secrets.length > 0 ? secrets.join(' and ') : 'ACCESS_TEAM_DOMAIN'; + return { + url: ZERO_TRUST, + steps: [ + `Open Cloudflare One and choose the LVBT account (${LVBT_CLOUDFLARE_ACCOUNT}). These steps appear only the first time Zero Trust is used in an account.`, + `Cloudflare asks you to choose the team domain (its documentation calls this the team name). Type ${LVBT_TEAM_SUBDOMAIN}, so the team domain becomes ${LVBT_TEAM_DOMAIN}. That is the address of the sign-in page, the value ${holds} holds, and the start of the Google sign-in addresses.`, + `If it also asks for a team name, type ${LVBT_TEAM_NAME}. The team name is only a label people see; it changes nothing in any configuration.`, + 'Choose the Zero Trust Free plan. Cloudflare asks for payment details even for the Free plan, but does not charge for it.', + 'Finish the onboarding, then run this command again. It will find Zero Trust turned on and carry on from there.', + `Later, Cloudflare One → Overview → Account details shows the Team domain and the Team name, each with a pencil icon that edits it. Do not change the team domain: the admin sign-in and the Google sign-in stop working until ${holds} and the Google OAuth client are updated to match.`, + ], + }; +} + +/** Where to copy the team domain from, for when setup cannot read it itself. */ +export function teamDomainGuide(secretName) { + return { + url: ZERO_TRUST, + steps: [ + `Open Cloudflare One and choose the LVBT account (${LVBT_CLOUDFLARE_ACCOUNT}). If it shows its first-time setup instead, Zero Trust was never turned on for this account: press Enter to skip, and run this command with the Cloudflare token it asks for, so it can show those steps.`, + `On Overview, find Account details. It shows the Team domain (for LVBT, ${LVBT_TEAM_DOMAIN}) and the Team name ("${LVBT_TEAM_NAME}"), which is only a label. ${secretName} needs the domain.`, + "Copy the Team domain, without https://, and paste it at this command's prompt.", + ], + }; +} + +export function googleWorkspaceGuide(teamDomain, workspaceDomain, group) { + const team = teamDomain ?? LVBT_TEAM_DOMAIN; + const domain = workspaceDomain ?? 'lasvegasfortransit.org'; + const project = `?project=${LVBT_GOOGLE_PROJECT_ID}`; + return { + url: `https://console.cloud.google.com/home/dashboard${project}`, + steps: [ + `Do the Google steps signed in as a Google Workspace super admin for ${domain}: turning on "Trust internal apps" and approving group access both need one. The Cloudflare steps need a Cloudflare user who can administer the LVBT account.`, + `Use LVBT's one Google Cloud project, "${LVBT_GOOGLE_PROJECT}" (ID ${LVBT_GOOGLE_PROJECT_ID}); every link below opens it. If the project picker at the top shows no such project, click "New project", name it ${LVBT_GOOGLE_PROJECT}, make sure "Organization" is ${domain} so the project belongs to LVBT rather than to your own account, and click "Create". If Google shows a Free Trial banner, dismiss it: none of this needs billing.`, + `Open https://console.cloud.google.com/apis/library/admin.googleapis.com${project} and click "Enable" on "Admin SDK API" (it says "Manage" instead if it is already on). Access uses it to read which Google Groups a person is in.`, + 'Open https://admin.google.com/ac/owl (Security → Access and data control → API controls), click "Settings", turn on "Trust internal apps", and save. It is off by default, and Access needs it.', + 'In a new browser tab, open Cloudflare One, go to Integrations → Identity providers (not "Cloud & SaaS" just above it: that is a different feature that asks for a service account; leave it alone), click "Add new identity provider", then "Google Workspace". Keep this tab open: two of the next steps fill it in, one value at a time.', + `Back in Google Cloud, open https://console.cloud.google.com/auth/clients${project}. If it says "Google Auth Platform not configured yet", click "Get started" and complete its four steps: App Information: App name ${LVBT_TEAM_NAME}, User support email ${LVBT_SHARED_EMAIL} (a shared LVBT address, never a person's), then "Next"; Audience: "Internal", then "Next"; Contact Information: ${LVBT_SHARED_EMAIL}, then "Next"; Finish: tick the box agreeing to the Google API Services: User Data Policy, click "Continue", then "Create". Optionally, under "Branding", upload the square LVBT logo from the "Marketing & Communications" shared drive as the App logo. Then open "Clients" again.`, + `On the Clients page, if a client named "Cloudflare Access" is listed, click it, check that it has the two addresses below, and under "Client secrets" click "Add secret", because Google shows a secret only when it is made. Otherwise click "Create client", choose the application type "Web application", and name it Cloudflare Access.`, + `Under "Authorized JavaScript origins", click "Add URI" and enter exactly https://${team}`, + `Under "Authorized redirect URIs", click "Add URI" and enter exactly https://${team}/cdn-cgi/access/callback, then click "Create" (or "Save").`, + 'Google shows the Client ID and the Client secret. Copy the Client ID (it ends in .apps.googleusercontent.com) and paste it into "App ID" in the Cloudflare tab.', + 'Copy the Client secret and paste it into "Client secret" in the Cloudflare tab. Neither value is stored in GitHub or on the Worker.', + `In the Cloudflare tab, type ${domain} as the Google Workspace domain, and click "Save".`, + 'Cloudflare then shows a link. Open it signed in as the Google Workspace super admin and approve it, so Access can read group membership.', + `Back in Integrations → Identity providers, click "Test" next to Google Workspace. It should show your name and your groups${group ? `, including ${group}; add yourself to that group first (the Google Group step shows how), or the test cannot show it` : ''}. Then run this command again.`, + ], + }; +} + +/** A host-and-path destination split the way the "Add public hostname" form asks for it. */ +export function hostnameParts(destination, zone) { + const slash = destination.indexOf('/'); + const host = slash === -1 ? destination : destination.slice(0, slash); + const path = slash === -1 ? '' : destination.slice(slash + 1); + const inZone = host === zone || host.endsWith(`.${zone}`); + const subdomain = inZone && host !== zone ? host.slice(0, -(zone.length + 1)) : ''; + return { subdomain, domain: inZone ? zone : host, path }; +} + +function describeHostname({ subdomain, domain, path }) { + const parts = [ + subdomain ? `Subdomain ${subdomain}` : 'Subdomain empty', + `Domain ${domain}`, + path ? `Path ${path}` : 'Path empty', + ]; + return parts.join(', '); +} + +function sessionLabel(duration) { + const match = /^(\d+)(m|h)$/.exec(duration); + if (!match) return duration; + const unit = match[2] === 'h' ? 'hour' : 'minute'; + return `${match[1]} ${unit}${match[1] === '1' ? '' : 's'}`; +} + +function includeRule(app) { + if (app.allow.googleGroup) { + const domain = app.allow.googleGroup.split('@')[1]; + return `In the policy, add one Include rule: choose the selector "Google Workspace groups" and enter ${app.allow.googleGroup}. That selector is offered only once Google Workspace is a login method; if it is missing, the Google Workspace step was not done. Until it is, choose "Emails" instead and enter the @${domain} address of each person who needs in now; this command replaces that rule with the group once Google Workspace is connected.`; + } + if (app.allow.emailDomain) + return `In the policy, add one Include rule: choose the selector "Emails ending in" and enter @${app.allow.emailDomain}.`; + return `In the policy, add one Include rule: choose the selector "Emails" and enter ${app.allow.emails.join(', ')}.`; +} + +function identitySteps(app) { + if (app.identityProvider !== 'google-apps') + return [ + 'Under "Authentication", on the "Identity" tab, turn off "Accept all available identity providers", choose only "One-time PIN" in "Choose available identity providers", and turn on "Apply instant authentication". Leave "Authenticate with Cloudflare One Client" off.', + ]; + return [ + 'Under "Authentication", on the "Identity" tab, turn off "Accept all available identity providers" (it is on by default). In "Choose available identity providers", choose only "Google Workspace". Turn on "Apply instant authentication". Leave "Authenticate with Cloudflare One Client" off.', + 'If "Google Workspace" is not in that list, the Google Workspace sign-in step was not done. Choose "One-time PIN" for now; this command switches it to Google Workspace once it is connected.', + ]; +} + +/** + * Creating an Access application by hand, from a dashboard that has never + * had one, then copying its audience tag. The steps follow the "Create new + * self-hosted application" page from top to bottom. `audienceSteps` alone + * say where the tag is, for when the application already exists. The exact + * name matters: setup recognizes an application by its name, or else by its + * paths, and would otherwise create a second one. + */ +export function accessAppGuide(app, zone) { + const zoneName = zone ?? app.destinations[0].split('/')[0]; + const hostnames = app.destinations.map( + (destination, index) => + `row ${index + 1}: ${describeHostname(hostnameParts(destination, zoneName))}`, + ); + const policy = `${app.name} allow`; + const audienceSteps = [ + `In Cloudflare One, go to Access controls → Applications and click "Configure" on "${app.name}".`, + `Open the "Additional settings" tab, copy "Application Audience (AUD) Tag", and paste it at this command's prompt. It is 64 lowercase letters and digits, and it is the value ${app.audienceSecret} holds.`, + ]; + const createSteps = [ + `Open Cloudflare One and choose the LVBT account (${LVBT_CLOUDFLARE_ACCOUNT}). Go to Access controls → Applications. If "${app.name}" is already listed, it exists: skip the steps that create it.`, + 'Click "Create new application" at the top right (some screens say "Add an application"). An account with no applications yet shows only a list of prerequisites; the button is still at the top right.', + 'In the "Add an application" dialog, under "Self-hosted and private", choose the "Public DNS" tab. Do not choose "Private destinations", "Workers", or "Service auth". Click "Continue with Self-hosted and private". The page is now "Create new self-hosted application"; work down it from the top.', + 'Under "Destinations" there should be public hostname rows. If you see a "Private IPs" row with "Private IP address" and "Port" instead, "Private destinations" was chosen: click "+ Add public hostname", then remove the empty private row, or go back and choose "Public DNS".', + `Add one public hostname row per address with "+ Add public hostname", leaving any other box empty: ${hostnames.join('; ')}. A path does not cover the paths under it, and a wildcard does not cover its parent, so every row is needed; with one missing, that part of the site would be open to anyone.`, + 'Leave "Allow access through browser-based RDP, SSH, or VNC sessions" off.', + `Under "Access policies", which says "No policy associated", open "Add current policies". If a policy named ${policy} is listed, choose it and go on to "Authentication". Otherwise click "Create new policy", name it exactly ${policy}, and set the action to "Allow".`, + includeRule(app), + `Save the policy. If it opened in another tab, come back to this page and choose ${policy} in "Add current policies".`, + 'Skip "Policy tester".', + ...identitySteps(app), + 'Skip "Preview".', + `Under "Details", type the name exactly: ${app.name}. Set "Session Duration" to ${sessionLabel(app.sessionDuration ?? '24h')}, which is the default.`, + 'Click "Create".', + ]; + return { + url: ZERO_TRUST, + audienceSteps, + steps: [...createSteps, ...audienceSteps], + /** For a run that asks for the tag later: make the application now, copy the tag then. */ + manualSteps: [ + ...createSteps, + 'Leave the tag for now. This command asks for it later in this run and shows where to find it.', + ], + }; +} + +/** + * Creating the Google Group an Access application admits. Setup cannot read + * Google Groups, so it asks the person to confirm the group exists and + * remembers the answer on this computer. + */ +export function googleGroupGuide(group, apps) { + const [local, domain] = group.split('@'); + const names = apps.map((app) => app.name).join(' and '); + const session = sessionLabel(apps[0]?.sessionDuration ?? '24h'); + return { + url: 'https://admin.google.com/ac/groups', + steps: [ + `Do this as a Google Workspace admin with the Groups administrator privilege. The group decides who can sign in to ${names}.`, + `Open the Google Admin console at https://admin.google.com and go to Menu → Directory → Groups. If ${group} is already listed, skip to the step that adds members.`, + 'Click "Create group".', + `Group name: ${names}. Group email: type ${local} and keep the domain ${domain}. Description: People who can sign in to ${names}. Group owner(s): add yourself and anyone who will add and remove people later.`, + 'Click "Next". Tick "Security", because the group controls access, and click "Next".', + 'Set Access type to "Restricted" and "Who can join the group" to "Only invited users". Leave "Allow external members in the group" off. Click "Create Group".', + `Open the group, click "Members", then "Add members". Type each person's @${domain} address, including your own so you can test the sign-in, and click "Add To Group". Only accounts in the ${domain} Workspace can sign in through Access, so a personal Gmail address does not work, even in the group.`, + `Later, to let someone in, open Directory → Groups → ${group} → Members and click "Add members". To take someone out, point to them in the Members list and click "Remove". A removal takes effect at their next sign-in, within ${session}.`, + ], + }; +} + +export function turnstileGuide(widget, cloudflare, configPath) { + const config = configPath ?? 'the production wrangler config'; + const mode = { managed: 'Managed', 'non-interactive': 'Non-interactive', invisible: 'Invisible' }; + const createSteps = [ + `Open Turnstile in the Cloudflare dashboard with the LVBT account (${LVBT_CLOUDFLARE_ACCOUNT}). If a widget named "${widget.name}" is already listed, click it and go to the step for the Site Key.`, + `Click "Add widget". Widget name: ${widget.name}.`, + `Under "Hostname management", add ${widget.domains.join(', ')}.`, + `Widget Mode: "${mode[widget.mode ?? 'managed']}". Leave pre-clearance off, and click "Create".`, + `If "vars" in ${config} already has "${widget.siteKeyVar}" with this widget's Site Key, skip this step. Otherwise copy the Site Key (public, starts with 0x) and paste it into "vars" in ${config} as "${widget.siteKeyVar}" now; save the file and commit it through a pull request later.`, + ]; + const secretSteps = [ + `Copy the widget's Secret Key (private, also starts with 0x) and paste it at this command's prompt. It is the Worker secret ${widget.secret}.`, + ]; + return { + url: `https://dash.cloudflare.com/${cloudflare.accountId}/turnstile`, + secretSteps, + steps: [...createSteps, ...secretSteps], + /** For a run that asks for the secret later: make the widget now, copy the secret then. */ + manualSteps: [ + ...createSteps, + 'Leave the Secret Key for now. This command asks for it later in this run.', + ], + }; +} + +/** 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('Turnstile · Edit'); + if (manifest.access?.length) { + needed.push('Access: Apps and Policies · Edit'); + needed.push('Access: Organizations, Identity Providers, and Groups · Read'); + } + return { + url: setupTokenUrl(manifest), + steps: [ + 'The link opens Cloudflare\'s "Create Custom Token" page with the permissions filled in. Sign in with your LVBT Cloudflare account if it asks. This one is a personal token that expires tomorrow, because Cloudflare\'s account API tokens cannot manage Turnstile.', + `Token name: lvbt setup ${manifest.name}.`, + `Under "Permissions", check that there are exactly these rows, each set to "Account", and add any that is missing with "+ Add more": ${needed.join('; ')}.`, + `Under "Account Resources", choose "Include" and the LVBT account, "${LVBT_CLOUDFLARE_ACCOUNT}" (ID ${manifest.cloudflare.accountId}), not "All accounts".`, + 'Under "TTL", set the End Date to tomorrow, so the token stops working by itself.', + 'Click "Continue to summary", then "Create Token". Click "Copy": Cloudflare shows the token only once.', + "Paste it here. It stays in this terminal's memory and is never saved. When you finish, delete it at https://dash.cloudflare.com/profile/api-tokens.", + ], + }; +} + +export function varGuide(variable, configPath, value, widget) { + const shown = + value === undefined + ? widget + ? `"<the Site Key of the ${widget} Turnstile widget, which starts with 0x>"` + : '"<value>"' + : JSON.stringify(value); + return { + steps: [ + `Add "${variable.name}": ${shown} to "vars" in ${configPath}. It is public, so it belongs in the config rather than in a secret.`, + '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..a6ad1c5 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs @@ -0,0 +1,262 @@ +import path from 'node:path'; +import { CliError } from '../arguments.mjs'; +import { applyPlan, rotateSecrets } 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, + confirmationStore, + dnsResolver, + memoryConfirmations, + 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, + confirmations: confirmationStore(), + }; +} + +/** 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 read or manage Turnstile widgets or Access applications, so checking them needs a short-lived token, even when they are already set up. It stays in this terminal's memory and is never written to disk. To skip this question next time, put the token in ${SETUP_TOKEN_VARIABLE} for the length of your session.\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 confirmations = services.confirmations ?? memoryConfirmations(); + const observe = () => + observePlatform({ + manifest, + directory, + apis, + run: services.run, + resolve, + confirmed: confirmations.read(), + }); + 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, + confirmations, + 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, + ); +} + +/** + * The secrets `--rotate` names, checked against the manifests it applies to. + * Rotating is never implied: without the flag, a stored value is kept. + */ +export function rotationNames(option, manifests) { + if (option === undefined) return []; + const names = [ + ...new Set( + String(option) + .split(',') + .map((name) => name.trim()) + .filter(Boolean), + ), + ]; + if (names.length === 0) + throw new CliError('--rotate needs the name of a secret, such as --rotate SIGNING_SECRET.', 2); + const declared = new Set( + manifests.flatMap((manifest) => (manifest.secrets ?? []).map((secret) => secret.name)), + ); + const unknownNames = names.filter((name) => !declared.has(name)); + if (unknownNames.length > 0) + throw new CliError( + `--rotate names ${unknownNames.join(', ')}, which platform.json does not declare as a secret.`, + 2, + ); + return names; +} + +/** 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, + ); + const files = selectManifests(cwd, options.filter); + const rotate = rotationNames( + options.rotate, + files.map((file) => loadManifest(path.join(cwd, file))), + ); + let failed = 0; + for (const file of files) { + 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, + confirmations: view.confirmations, + values: new Map(), + handled: new Set(), + shown: new Set(), + created: { widgets: new Map(), apps: new Map() }, + observe: async () => (await view.refresh()).state, + }; + const declared = new Set((view.manifest.secrets ?? []).map((secret) => secret.name)); + const mine = rotate.filter((name) => declared.has(name)); + let acted; + try { + ({ acted } = await applyPlan(context, view.items)); + if (mine.length > 0) await rotateSecrets(context, mine); + } finally { + context.values.clear(); + } + // Nothing was done, so the report above is still the current one. + if (!acted && mine.length === 0) { + if (!readiness(view.items).ready) failed += 1; + continue; + } + 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..07524d7 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs @@ -0,0 +1,265 @@ +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.sensitive === false && secret.generate) + errors.push( + `${secret.name} is generated, which makes it a credential, so it cannot be "sensitive": false.`, + ); + 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..00f24ff --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs @@ -0,0 +1,228 @@ +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, + teamName: organization?.name, + 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'); + } +} + +/** + * The declared buckets that exist. Each is looked up by name: the bucket list + * is paged by cursor, so a bucket past its first page would look missing. + */ +async function observeR2(api, account, manifest) { + if (!api.client) return api.missing; + return attempt(async () => { + const found = []; + for (const bucket of manifest.r2) { + try { + await api.client.get(`${account}/r2/buckets/${encodeURIComponent(bucket.name)}`); + found.push(bucket.name); + } catch (error) { + if (error.kind !== 'not-found') throw error; + } + } + return found; + }); +} + +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 + * @param {Set<string>} [input.confirmed] what a person has confirmed on this computer + */ +export async function observePlatform({ manifest, directory, apis, run, resolve, confirmed }) { + const account = `accounts/${manifest.cloudflare.accountId}`; + const configFile = path.join(directory, manifest.cloudflare.wranglerConfig ?? 'wrangler.jsonc'); + const config = await attempt(() => readWranglerConfig(configFile)); + const 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, manifest) : 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), + confirmed, + 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..d18d9bd --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-access.mjs @@ -0,0 +1,251 @@ +import { + accessAppGuide, + googleGroupGuide, + googleWorkspaceGuide, + ZERO_TRUST, + zeroTrustGuide, +} from './guides.mjs'; +import { item, manualGuide, 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`); + else if (provider && Array.isArray(found.allowed_idps) && found.allowed_idps.length === 0) + reasons.push(`accepts every identity provider, not only ${app.identityProvider}`); + 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], group) + : { + url: ZERO_TRUST, + steps: [ + 'Open Cloudflare One and choose the LVBT account.', + 'Go to Integrations → Identity providers (not "Cloud & SaaS" just above it: that is a different feature that asks for a service account; leave it alone), click "Add new identity provider", choose "One-time PIN", and click "Save". People then sign in with a code emailed to them.', + ], + }; + 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(manifest, 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: manualGuide(accessAppGuide(app, manifest.cloudflare.zone.name)), + }; + 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}`, + }); +} + +/** + * Each Google Group an application admits. Setup cannot read Google Groups, + * so a group is ready once a person confirms it exists, which setup then + * remembers on that computer. It only warns, because a check in CI cannot + * ask anyone. + */ +function groupItems({ manifest, state }) { + const groups = new Map(); + for (const app of manifest.access ?? []) { + const group = app.allow.googleGroup; + if (group) groups.set(group, [...(groups.get(group) ?? []), app]); + } + return [...groups].map(([group, apps]) => { + const key = `google-group:${manifest.cloudflare.accountId}:${group}`; + const fields = { + id: `google-group:${group}`, + section: SECTION, + label: `Google Group ${group}`, + level: 'recommended', + }; + if (state.confirmed?.has(key)) + return item({ ...fields, status: 'ok', detail: 'was confirmed to exist on this computer' }); + return item({ + ...fields, + status: 'missing', + detail: 'is not confirmed to exist; setup cannot read Google Groups', + next: `${SETUP} shows how to create it, then asks`, + action: { + type: 'manual', + key: `google-group:${group}`, + guide: googleGroupGuide(group, apps), + confirm: { + key, + question: `Does the Google Group ${group} exist now, with the people who need in?`, + }, + }, + }); + }); +} + +export function planAccess({ manifest, state }) { + const apps = manifest.access ?? []; + if (apps.length === 0) return []; + const groups = groupItems({ manifest, state }); + if (!state.access.ok) + return [ + unknownItem( + { id: 'access', section: SECTION, label: 'Zero Trust', credentialHint: TOKEN_HINT }, + state.access, + ), + ...groups, + ]; + 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(manifest) }, + }), + ...groups, + ...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: `is on; team domain ${access.teamDomain ?? 'unknown'}${access.teamName ? ` (team name "${access.teamName}")` : ''}`, + }), + ...[...new Set(apps.map((app) => app.identityProvider))].map((type) => + providerItem(manifest, access, type), + ), + ...groups, + ...apps.map((app) => applicationItem(manifest, 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..a3d7ce7 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs @@ -0,0 +1,207 @@ +import { turnstileGuide } from './guides.mjs'; +import { item, manualGuide, 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, configPath }, 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) + // Wrangler applies migrations to the database_id in the config, so setup + // applies them after creating the database only if the config names it. + return item({ + ...fields, + status: 'missing', + detail: `${files.value.length} to apply once the database exists and ${configPath} names it`, + next: `${SETUP} applies them once ${configPath} has the new database's database_id`, + action: { ...action, binding: database.binding, afterCreate: true }, + }); + if (!real?.applied?.ok) return unknownItem(fields, real?.applied ?? state.d1); + return pendingItem({ state, configPath }, database, { fields, files: files.value, real, action }); +} + +/** Migrations against a database that exists: applied, pending, or held back by the config. */ +function pendingItem({ state, configPath }, database, { fields, files, real, action }) { + const pending = files.filter((file) => !real.applied.value.includes(file)); + if (pending.length === 0) + return item({ ...fields, status: 'ok', detail: `all ${files.length} applied` }); + const bound = state.config.ok + ? state.config.value.d1.find((entry) => entry.binding === database.binding) + : undefined; + if (state.config.ok && bound?.id !== real.id) + return item({ + ...fields, + status: 'missing', + detail: `${pending.length} of ${files.length} not applied; they wait until ${configPath} has database_id ${real.id}`, + next: `set database_id to ${real.id} in ${configPath}, then run ${SETUP} again`, + }); + return item({ + ...fields, + status: 'missing', + detail: `${pending.length} of ${files.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, configPath }, widget) { + const fields = { id: `turnstile:${widget.name}`, section: 'Turnstile', label: widget.name }; + const guide = manualGuide(turnstileGuide(widget, manifest.cloudflare, configPath)); + 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..189baa4 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-items.mjs @@ -0,0 +1,42 @@ +/** + * 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)}`; +} + +/** + * The steps to show when a person must create something by hand and the + * value it produces is asked for later, so nothing is copied before it can + * be pasted. + */ +export function manualGuide(guide) { + return { url: guide.url, steps: guide.manualSteps ?? guide.steps }; +} 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..420dee6 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs @@ -0,0 +1,266 @@ +import { emailRecords, resendDomainGuide, varGuide } from './guides.mjs'; +import { findApp } from './plan-access.mjs'; +import { findWidget } from './plan-cloudflare.mjs'; +import { GH_HINT, item, SETUP, targetLabel, unknownItem } from './plan-items.mjs'; +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' }; +} + +/** Whether a secret is a credential, which is typed hidden and never shown. */ +export function isSensitive(secret) { + return secret?.sensitive !== false; +} + +/** The value setup would store for a non-credential secret, when it can know it. */ +function knownPlainValue(secret, source, state) { + if (isSensitive(secret)) return undefined; + if (source.type === 'value') return source.value; + if (!state.access.ok) return undefined; + if (source.type === 'access-team') return state.access.value.teamDomain; + if (source.type === 'access-audience') return findApp(state.access.value.apps, source.app)?.aud; + return undefined; +} + +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); + const source = secretSource(secret, manifest); + const plain = knownPlainValue(secret, source, state); + if (present.value) + // Setup cannot read a stored value back, so for a non-credential it shows + // the value that belongs there, for a person to compare. + return item({ + ...fields, + status: 'ok', + detail: plain === undefined ? 'is set' : `is set; it should be ${plain}`, + }); + const elsewhere = (secret.targets ?? ['worker']).filter( + (other) => other !== target && stored(state, other, secret.name).value === true, + ); + if (source.type === 'generate' && elsewhere.length > 0) + // A generated value cannot be read back, so minting another here would + // leave the targets holding different values. + return item({ + ...fields, + status: 'mismatch', + detail: `is not set here but is set on ${elsewhere.map((other) => targetLabel(manifest, other)).join(', ')}, and setup cannot read that value to copy it`, + next: `${SETUP} --rotate ${secret.name} stores one new value everywhere`, + }); + return item({ + ...fields, + status: 'missing', + detail: secret.neededFor + ? `is not set; needed for ${secret.neededFor}` + : `is not set. ${secret.purpose}`, + next: plain === undefined ? sourceHint(source) : `${sourceHint(source)}: ${plain}`, + 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, widget?.name), + variable, + widget: widget?.name, + }; + 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..bb05338 --- /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 { isSensitive, 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..deb4df5 --- /dev/null +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs @@ -0,0 +1,211 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * 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. A resource missed on a later page would + * look absent and be created a second time, so a list that cannot be read + * to the end fails instead of returning what it has. + */ + list: async (endpoint) => { + const items = []; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const separator = endpoint.includes('?') ? '&' : '?'; + const payload = await call( + 'GET', + `${endpoint}${separator}page=${page}&per_page=${PER_PAGE}`, + ); + const result = Array.isArray(payload.result) ? payload.result : []; + if (payload.result_info?.page !== undefined && payload.result_info.page !== page) + throw new CloudflareError('error', 0, [ + { message: `${endpoint} did not return page ${page}` }, + ]); + items.push(...result); + if (!morePages(payload.result_info, page, items.length, result.length)) return items; + } + throw new CloudflareError('error', 0, [ + { message: `${endpoint} has more than ${MAX_PAGES * PER_PAGE} entries` }, + ]); + }, + }; +} + +const PER_PAGE = 50; +const MAX_PAGES = 100; + +/** + * Whether a list has another page. Cloudflare's list endpoints report their + * size differently: some give total_pages, some only total_count, and some + * nothing, in which case a full page means there may be more. + */ +export function morePages(info, page, seen, received) { + if (received === 0) return false; + if (info?.total_pages !== undefined) return page < info.total_pages; + if (info?.total_count !== undefined) return seen < info.total_count; + return received >= (info?.per_page ?? PER_PAGE); +} + +/** + * 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); + }; +} + +/** + * Things only a person can confirm, such as that a Google Group exists, + * remembered on this computer so setup asks once. The file holds names and + * dates, never a secret. + */ +export function confirmationFile(env = process.env) { + const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return path.join(base, 'lvbt', 'confirmations.json'); +} + +export function confirmationStore(file = confirmationFile()) { + const load = () => { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return {}; + } + }; + return { + where: file, + read: () => new Set(Object.keys(load())), + add: (key) => { + const current = load(); + if (key in current) return; + current[key] = new Date().toISOString().slice(0, 10); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(current, null, 2)}\n`); + }, + }; +} + +/** A confirmation store that lives only for this process, for callers that pass none. */ +export function memoryConfirmations() { + const keys = new Set(); + return { where: 'memory', read: () => new Set(keys), add: (key) => keys.add(key) }; +} 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 cecb449..3db03d0 100644 --- a/.lvbt/web-platform/packages/eslint-config/package.json +++ b/.lvbt/web-platform/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { - "name": "@lvbt/eslint-config", - "version": "0.2.8", + "name": "@lasvegasfortransit/eslint-config", + "version": "0.4.4", "description": "The ESLint configurations every LVBT repository uses.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/playwright-config/index.js b/.lvbt/web-platform/packages/playwright-config/index.js index bab9e01..6a16a93 100644 --- a/.lvbt/web-platform/packages/playwright-config/index.js +++ b/.lvbt/web-platform/packages/playwright-config/index.js @@ -19,7 +19,7 @@ export const foregroundServerEnvironment = { * Spread it into a package's playwright.config.ts and add the web server: * * import { defineConfig } from '@playwright/test'; - * import { sharedConfig } from '@lvbt/playwright-config'; + * import { sharedConfig } from '@lasvegasfortransit/playwright-config'; * export default defineConfig({ * ...sharedConfig, * webServer: { diff --git a/.lvbt/web-platform/packages/playwright-config/package.json b/.lvbt/web-platform/packages/playwright-config/package.json index 4a64963..0cbc409 100644 --- a/.lvbt/web-platform/packages/playwright-config/package.json +++ b/.lvbt/web-platform/packages/playwright-config/package.json @@ -1,6 +1,6 @@ { - "name": "@lvbt/playwright-config", - "version": "0.2.8", + "name": "@lasvegasfortransit/playwright-config", + "version": "0.4.4", "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 e4b7403..fb23934 100644 --- a/.lvbt/web-platform/packages/prettier-config/package.json +++ b/.lvbt/web-platform/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { - "name": "@lvbt/prettier-config", - "version": "0.2.8", + "name": "@lasvegasfortransit/prettier-config", + "version": "0.4.4", "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 9798007..cffbd69 100644 --- a/.lvbt/web-platform/packages/typescript-config/package.json +++ b/.lvbt/web-platform/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { - "name": "@lvbt/typescript-config", - "version": "0.2.8", + "name": "@lasvegasfortransit/typescript-config", + "version": "0.4.4", "description": "TypeScript configurations every LVBT repository extends.", "license": "MIT", "repository": { diff --git a/.lvbt/web-platform/packages/vitest-config/index.js b/.lvbt/web-platform/packages/vitest-config/index.js index 13cc7f0..ef6cac9 100644 --- a/.lvbt/web-platform/packages/vitest-config/index.js +++ b/.lvbt/web-platform/packages/vitest-config/index.js @@ -8,7 +8,7 @@ * Spread it into a package's vitest.config.ts: * * import { defineConfig } from 'vitest/config'; - * import { sharedConfig } from '@lvbt/vitest-config'; + * import { sharedConfig } from '@lasvegasfortransit/vitest-config'; * export default defineConfig({ ...sharedConfig }); * * @type {import("vitest/config").UserConfig} diff --git a/.lvbt/web-platform/packages/vitest-config/package.json b/.lvbt/web-platform/packages/vitest-config/package.json index fdafadd..f275eb9 100644 --- a/.lvbt/web-platform/packages/vitest-config/package.json +++ b/.lvbt/web-platform/packages/vitest-config/package.json @@ -1,6 +1,6 @@ { - "name": "@lvbt/vitest-config", - "version": "0.2.8", + "name": "@lasvegasfortransit/vitest-config", + "version": "0.4.4", "description": "The Vitest configuration every LVBT repository spreads into its own.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/web-platform/README.md b/.lvbt/web-platform/packages/web-platform/README.md index 69937b4..b08dc46 100644 --- a/.lvbt/web-platform/packages/web-platform/README.md +++ b/.lvbt/web-platform/packages/web-platform/README.md @@ -1,7 +1,8 @@ # Web platform operations -`@lvbt/web-platform` provides the provider-neutral operations used to inspect and reconcile LVBT web -infrastructure. Repository packages supply project discovery, naming, routes, and lifecycle policy. +`@lasvegasfortransit/web-platform` provides the provider-neutral operations used to inspect and +reconcile LVBT web infrastructure. Repository packages supply project discovery, naming, routes, and +lifecycle policy. The package reads external state separately from reconciliation. Every resource describes its current and desired values, then `reconcileResources` reports or applies the smallest necessary @@ -9,6 +10,6 @@ change. `reconcileResourceGroups` preserves the same fail-closed planning bounda dependency stages, such as repository creation before ruleset and environment configuration. Doctor checks return structured results without mutating provider state. -Import GitHub operations from `@lvbt/web-platform/github`, Cloudflare operations from -`@lvbt/web-platform/cloudflare`, and the reconciliation contract from -`@lvbt/web-platform/provision`. +Import GitHub operations from `@lasvegasfortransit/web-platform/github`, Cloudflare operations from +`@lasvegasfortransit/web-platform/cloudflare`, and the reconciliation contract from +`@lasvegasfortransit/web-platform/provision`. diff --git a/.lvbt/web-platform/packages/web-platform/package.json b/.lvbt/web-platform/packages/web-platform/package.json index 2d27c41..c0e01e1 100644 --- a/.lvbt/web-platform/packages/web-platform/package.json +++ b/.lvbt/web-platform/packages/web-platform/package.json @@ -1,6 +1,6 @@ { - "name": "@lvbt/web-platform", - "version": "0.2.8", + "name": "@lasvegasfortransit/web-platform", + "version": "0.4.4", "description": "Provider-neutral deployment, provisioning, and validation primitives for LVBT web repositories.", "license": "MIT", "type": "module", @@ -9,6 +9,10 @@ "url": "https://github.com/LasVegasForTransit/repository-tooling.git", "directory": "packages/web-platform" }, + "publishConfig": { + "registry": "https://npm.pkg.github.com", + "access": "restricted" + }, "exports": { ".": "./src/index.ts", "./cloudflare": "./src/cloudflare.ts", diff --git a/.lvbt/web-platform/packages/web-platform/src/doctor-cloudflare.ts b/.lvbt/web-platform/packages/web-platform/src/doctor-cloudflare.ts index 5128321..98d83fb 100644 --- a/.lvbt/web-platform/packages/web-platform/src/doctor-cloudflare.ts +++ b/.lvbt/web-platform/packages/web-platform/src/doctor-cloudflare.ts @@ -6,7 +6,7 @@ export interface CloudflareTarget { zoneId: string; zoneName: string; hostname: string; - workers: { slug: string; name: string }[]; + workers: { slug: string; name: string; previewRequired?: boolean }[]; } export interface CloudflareRead { @@ -71,22 +71,31 @@ function validRoutes(input: unknown, target: CloudflareTarget) { ); } -function validDns(input: unknown, hostname: string) { - return z - .array(z.object({ name: z.string(), type: z.string(), proxied: z.boolean().optional() })) +function validDns(input: unknown, target: CloudflareTarget) { + const domains = z + .array( + z.object({ + hostname: z.string(), + service: z.string(), + zone_id: z.string(), + cert_id: z.string().optional(), + }), + ) .parse(input) - .some( - (record) => - record.name === hostname && - ['A', 'AAAA', 'CNAME'].includes(record.type) && - record.proxied === true, - ); + .filter((domain) => domain.hostname === target.hostname); + return ( + domains.length === 1 && + domains[0]?.service === target.workers.find((worker) => worker.slug === 'home')?.name && + domains[0]?.zone_id === target.zoneId && + (domains[0].cert_id?.length ?? 0) > 0 + ); } function validAnalytics(input: unknown, hostname: string) { const sites = z .array( z.object({ + host: z.string().optional(), site_token: z.string().optional(), rules: z .array( @@ -104,9 +113,10 @@ function validAnalytics(input: unknown, hostname: string) { sites.filter( (site) => site.site_token && - site.rules?.some( - (rule) => rule.host === hostname && rule.inclusive === true && rule.is_paused !== true, - ), + (site.host === hostname || + site.rules?.some( + (rule) => rule.host === hostname && rule.inclusive === true && rule.is_paused !== true, + )), ).length === 1 ); } @@ -114,6 +124,9 @@ function validAnalytics(input: unknown, hostname: string) { export async function cloudflareDoctor(target: CloudflareTarget, read: CloudflareRead) { const account = `accounts/${target.accountId}`; const zone = `zones/${target.zoneId}`; + let domainRecords: Promise<unknown> | undefined; + const domains = () => + (domainRecords ??= read.list(`${account}/workers/domains?hostname=${target.hostname}`)); const check = (id: string, requirement: string, inspect: () => Promise<boolean>) => doctorCheck(`cloudflare.${id}`, requirement, inspect); return [ @@ -123,19 +136,17 @@ export async function cloudflareDoctor(target: CloudflareTarget, read: Cloudflar await check( 'domain', 'The Labs custom domain belongs to the home Worker in the declared zone.', - async () => - validDomain( - await read.list(`${account}/workers/domains?hostname=${target.hostname}`), - target, - ), + async () => validDomain(await domains(), target), ), await check( 'routes', 'Home owns the catchall; every published lab owns its exact and subtree routes without conflicting patterns.', async () => validRoutes(await read.list(`${zone}/workers/routes`), target), ), - await check('dns', 'The hostname has a proxied address or CNAME record.', async () => - validDns(await read.list(`${zone}/dns_records?name=${target.hostname}`), target.hostname), + await check( + 'dns', + 'The Worker custom domain has Cloudflare-managed DNS and a certificate.', + async () => validDns(await domains(), target), ), await check('workers', 'Every published lab has its declared Worker.', async () => { const workers = z @@ -147,12 +158,12 @@ export async function cloudflareDoctor(target: CloudflareTarget, read: Cloudflar }), await check( 'worker-previews', - 'Every published Worker has immutable version preview URLs enabled.', + 'Every Worker requiring immutable version preview URLs has them enabled.', async () => { const settings = await Promise.all( - target.workers.map((worker) => - read.get(`${account}/workers/scripts/${worker.name}/subdomain`), - ), + target.workers + .filter((worker) => worker.previewRequired !== false) + .map((worker) => read.get(`${account}/workers/scripts/${worker.name}/subdomain`)), ); return settings.every( (value) => z.object({ previews_enabled: z.literal(true) }).safeParse(value).success, diff --git a/.lvbt/web-platform/packages/web-platform/src/provision-analytics.ts b/.lvbt/web-platform/packages/web-platform/src/provision-analytics.ts index 487fbba..d6e49bb 100644 --- a/.lvbt/web-platform/packages/web-platform/src/provision-analytics.ts +++ b/.lvbt/web-platform/packages/web-platform/src/provision-analytics.ts @@ -9,6 +9,7 @@ const targetSchema = z.object({ environment: z.string().min(1), }); const siteSchema = z.object({ + host: z.string().optional(), site_tag: z.string().min(1), site_token: z.string().min(1), rules: z @@ -40,11 +41,12 @@ function analyticsToken(input: unknown, hostname: string) { const referenced = z .array(siteSchema) .parse(input) - .filter((site) => site.rules?.some((rule) => rule.host === hostname)); + .filter((site) => site.host === hostname || site.rules?.some((rule) => rule.host === hostname)); if (referenced.length > 1) throw new Error('Duplicate Web Analytics sites require review.'); const site = referenced[0]; if (site === undefined) return null; if ( + site.host !== hostname && !site.rules?.some( (rule) => rule.host === hostname && rule.inclusive === true && rule.is_paused !== true, ) diff --git a/.lvbt/web-platform/packages/web-platform/tests/doctor-cloudflare.test.ts b/.lvbt/web-platform/packages/web-platform/tests/doctor-cloudflare.test.ts index 00103d6..035f7ab 100644 --- a/.lvbt/web-platform/packages/web-platform/tests/doctor-cloudflare.test.ts +++ b/.lvbt/web-platform/packages/web-platform/tests/doctor-cloudflare.test.ts @@ -20,7 +20,12 @@ const fixtures: Record<string, unknown> = { paused: false, }, 'accounts/account/workers/domains?hostname=labs.example.org': [ - { hostname: 'labs.example.org', service: 'lvbt-labs-home', zone_id: 'zone' }, + { + hostname: 'labs.example.org', + service: 'lvbt-labs-home', + zone_id: 'zone', + cert_id: 'issued-certificate', + }, ], 'zones/zone/workers/routes': [ { pattern: 'labs.example.org/*', script: 'lvbt-labs-home' }, @@ -54,6 +59,59 @@ test('checks declared Worker names, exact routes, domain, DNS, and analytics', a expect(JSON.stringify(result)).not.toContain('public-id'); }); +test('recognizes DNS managed by a Worker custom domain without DNS-record access', async () => { + const reader = (endpoint: string) => { + if (endpoint.includes('/dns_records')) + return Promise.reject(new Error('DNS-record listing unavailable')); + return read(endpoint); + }; + + const result = await cloudflareDoctor(target, { get: reader, list: reader }); + expect(result.find((check) => check.id === 'cloudflare.dns')?.status).toBe('pass'); +}); + +test('rejects a Worker custom domain without an issued certificate', async () => { + const reader = (endpoint: string) => { + if (endpoint.includes('/workers/domains')) + return Promise.resolve([ + { hostname: target.hostname, service: 'lvbt-labs-home', zone_id: target.zoneId }, + ]); + return read(endpoint); + }; + + const result = await cloudflareDoctor(target, { get: reader, list: reader }); + expect(result.find((check) => check.id === 'cloudflare.domain')?.status).toBe('pass'); + expect(result.find((check) => check.id === 'cloudflare.dns')?.status).toBe('fail'); +}); + +test('accepts one hostname-created analytics site when rules are omitted', async () => { + const reader = (endpoint: string) => { + if (endpoint.includes('/rum/')) + return Promise.resolve([{ host: target.hostname, site_token: 'public-id' }]); + return read(endpoint); + }; + + const result = await cloudflareDoctor(target, { get: reader, list: reader }); + expect(result.find((check) => check.id === 'cloudflare.analytics')?.status).toBe('pass'); +}); + +test('rejects multiple active analytics sites for the hostname', async () => { + const reader = (endpoint: string) => { + if (endpoint.includes('/rum/')) + return Promise.resolve([ + { host: target.hostname, site_token: 'hostname-site' }, + { + site_token: 'rules-site', + rules: [{ host: target.hostname, inclusive: true, is_paused: false }], + }, + ]); + return read(endpoint); + }; + + const result = await cloudflareDoctor(target, { get: reader, list: reader }); + expect(result.find((check) => check.id === 'cloudflare.analytics')?.status).toBe('fail'); +}); + test('flags route collisions while preserving an unknown analytics result', async () => { const reader = (endpoint: string) => { if (endpoint.includes('/rum/')) return Promise.reject(new Error('No analytics permission')); @@ -76,3 +134,34 @@ test('fails when any published Worker has version previews disabled', async () = const result = await cloudflareDoctor(target, { get: reader, list: reader }); expect(result.find((check) => check.id === 'cloudflare.worker-previews')?.status).toBe('fail'); }); + +test('verifies an external Worker route without requiring version previews', async () => { + const external = { + ...target, + workers: [ + ...target.workers, + { slug: 'transit-mapper', name: 'transitmapper', previewRequired: false }, + ], + }; + const reader = (endpoint: string) => { + if (endpoint.endsWith('/workers/routes')) + return Promise.resolve([ + ...(fixtures['zones/zone/workers/routes'] as object[]), + { pattern: 'labs.example.org/transit-mapper', script: 'transitmapper' }, + { pattern: 'labs.example.org/transit-mapper/*', script: 'transitmapper' }, + ]); + if (endpoint.endsWith('/workers/scripts')) + return Promise.resolve([ + ...(fixtures['accounts/account/workers/scripts'] as object[]), + { id: 'transitmapper' }, + ]); + if (endpoint.endsWith('transitmapper/subdomain')) + return Promise.resolve({ enabled: false, previews_enabled: false }); + return read(endpoint); + }; + + const result = await cloudflareDoctor(external, { get: reader, list: reader }); + expect(result.find((check) => check.id === 'cloudflare.routes')?.status).toBe('pass'); + expect(result.find((check) => check.id === 'cloudflare.workers')?.status).toBe('pass'); + expect(result.find((check) => check.id === 'cloudflare.worker-previews')?.status).toBe('pass'); +}); diff --git a/.lvbt/web-platform/packages/web-platform/tests/provision-analytics.test.ts b/.lvbt/web-platform/packages/web-platform/tests/provision-analytics.test.ts index 2d67dc9..3f27f6a 100644 --- a/.lvbt/web-platform/packages/web-platform/tests/provision-analytics.test.ts +++ b/.lvbt/web-platform/packages/web-platform/tests/provision-analytics.test.ts @@ -82,6 +82,35 @@ test('updates a stale analytics variable without creating another site', async ( expect(variables.get('PUBLIC_LVBT_CWA_TOKEN')).toBe('current-token'); }); +test('recognizes a hostname-created analytics site when rules are omitted', async () => { + const sites = [ + { + host: target.hostname, + site_tag: 'site', + site_token: 'current-token', + }, + ]; + const variables = new Map<string, string>(); + let siteWrites = 0; + const resources = provisionAnalytics(target, { + readSites: () => Promise.resolve(sites), + createSite: () => { + siteWrites += 1; + return Promise.resolve(); + }, + readVariables: () => + Promise.resolve({ variables: [...variables].map(([name, value]) => ({ name, value })) }), + writeVariable: (_method, _endpoint, body) => { + variables.set(body.name, body.value); + return Promise.resolve(); + }, + }); + + expect((await reconcileResources(resources, true)).ok).toBe(true); + expect(siteWrites).toBe(0); + expect(variables.get('PUBLIC_LVBT_CWA_TOKEN')).toBe('current-token'); +}); + test('refuses duplicate or conflicting analytics sites', async () => { for (const sites of [ [ @@ -93,7 +122,7 @@ test('refuses duplicate or conflicting analytics sites', async () => { { site_tag: 'two', site_token: 'two', - rules: [{ host: target.hostname, inclusive: true, is_paused: false }], + host: target.hostname, }, ], [ diff --git a/.lvbt/web-platform/standards/astro-sync.ts b/.lvbt/web-platform/standards/astro-sync.ts new file mode 100644 index 0000000..4dbafeb --- /dev/null +++ b/.lvbt/web-platform/standards/astro-sync.ts @@ -0,0 +1,158 @@ +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<string, unknown>; +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<string[]> { + 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<string[]> { + 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<string[]> { + 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); +} + +// Astro loads its configuration from the first of these it finds in the project's root. +const ASTRO_CONFIGS = ['mjs', 'js', 'ts', 'mts', 'cjs', 'cts'].map((ext) => `astro.config.${ext}`); + +/** + * An Astro project depends on astro and has its own configuration. A library that only imports + * Astro's types, such as an integration or components, has nothing for `astro sync` to generate. + */ +function isAstroProject(root: string, directory: string, manifest: Manifest): boolean { + if (!manifest.dependencies?.astro && !manifest.devDependencies?.astro) return false; + return ASTRO_CONFIGS.some((name) => existsSync(path.join(root, directory, name))); +} + +/** 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 (!isAstroProject(root, directory, manifest)) 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<string[]> { + 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<string[]> { + const { astro, changed } = await addSyncScripts(root, dryRun); + if (!astro) return changed; + return [...changed, ...(await wireTurbo(root, dryRun))]; +} diff --git a/.lvbt/web-platform/standards/consumer-ignores.ts b/.lvbt/web-platform/standards/consumer-ignores.ts new file mode 100644 index 0000000..1ec881c --- /dev/null +++ b/.lvbt/web-platform/standards/consumer-ignores.ts @@ -0,0 +1,218 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +// Playwright writes these beside each app's configuration, such as apps/site/test-results/. +// Every example's .gitignore carries the same rules. A slash inside a pattern anchors it to the +// .gitignore's own directory, so the cache rule needs its leading **/ to reach every app. +export const PLAYWRIGHT_OUTPUT_IGNORES = [ + 'test-results/', + 'playwright-report/', + 'blob-report/', + '**/playwright/.cache/', +]; + +// Claude Code creates each agent worktree, a full checkout of the same repository on another +// branch, under .claude/worktrees/ inside the checkout. Git, Prettier, and markdownlint would +// otherwise treat another session's files as this repository's own. +export const AGENT_WORKTREES = '.claude/worktrees'; +const AGENT_WORKTREES_REASON = 'Agent worktrees are other checkouts of this repository.'; + +/** The rules each root ignore file must hold, as the examples hold them. */ +const LINE_RULES: Record<string, string[]> = { + '.gitignore': [...PLAYWRIGHT_OUTPUT_IGNORES, `${AGENT_WORKTREES}/`], + '.prettierignore': [`${AGENT_WORKTREES}/`], +}; + +const MARKDOWNLINT_CONFIG = '.markdownlint-cli2.jsonc'; +// markdownlint-cli2's other configuration formats, which the updater doesn't edit. +const OTHER_MARKDOWNLINT_CONFIGS = [ + '.markdownlint-cli2.yaml', + '.markdownlint-cli2.cjs', + '.markdownlint-cli2.mjs', +]; + +/** What the consumer must change by hand: a markdownlint configuration the updater can't edit. */ +export async function consumerIgnoreWarnings(root: string): Promise<string[]> { + const warnings: string[] = []; + for (const name of OTHER_MARKDOWNLINT_CONFIGS) { + const source = await readFile(path.join(root, name), 'utf8').catch(() => null); + if (source !== null && !source.includes(AGENT_WORKTREES)) + warnings.push( + `Add "${AGENT_WORKTREES}" to the ignores in ${name}. ${AGENT_WORKTREES_REASON}`, + ); + } + return warnings; +} + +/** + * Adds the standard's ignore rules that a consumer's root ignore files lack, leaving their own + * lines, comments, and order untouched. A file the consumer doesn't have stays absent. + */ +export async function syncConsumerIgnores(root: string, dryRun: boolean): Promise<string[]> { + const changed: string[] = []; + for (const [name, rules] of Object.entries(LINE_RULES)) { + if (await appendMissingLines(path.join(root, name), rules, dryRun)) changed.push(name); + } + const file = path.join(root, MARKDOWNLINT_CONFIG); + const source = await readFile(file, 'utf8').catch(() => null); + if (source !== null) { + const next = addMarkdownlintIgnore(source, AGENT_WORKTREES, AGENT_WORKTREES_REASON); + if (next !== source) { + if (!dryRun) await writeFile(file, next); + changed.push(MARKDOWNLINT_CONFIG); + } + } + return changed; +} + +async function appendMissingLines(file: string, rules: string[], dryRun: boolean) { + const source = await readFile(file, 'utf8').catch(() => null); + if (source === null) return false; + const present = new Set(source.split(/\r?\n/).map(patternKey)); + const missing = rules.filter((rule) => !present.has(patternKey(rule))); + if (missing.length === 0) return false; + const eol = lineEnding(source); + const separator = source === '' || source.endsWith('\n') ? '' : eol; + if (!dryRun) await writeFile(file, `${source}${separator}${missing.join(eol)}${eol}`); + return true; +} + +/** The line ending a file already uses, so added lines match it. */ +function lineEnding(source: string): string { + return source.includes('\r\n') ? '\r\n' : '\n'; +} + +/** + * An ignore pattern without the spellings that don't change what it covers here: a trailing `/` or + * `/**`, a leading `./`, and a leading `/` when another slash anchors the pattern anyway. + */ +function patternKey(pattern: string): string { + const bare = pattern + .trim() + .replace(/^\.\//, '') + .replace(/\/(?:\*\*)?$/, ''); + return bare.startsWith('/') && bare.slice(1).includes('/') ? bare.slice(1) : bare; +} + +interface Token { + text: string; + value?: string; + start: number; + end: number; +} + +/** Where the comment that starts at `index` ends, or `index` when none starts there. */ +function commentEnd(source: string, index: number): number { + if (source.startsWith('//', index)) { + const end = source.indexOf('\n', index); + return end === -1 ? source.length : end; + } + if (!source.startsWith('/*', index)) return index; + const end = source.indexOf('*/', index + 2); + if (end === -1) throw new Error(`${MARKDOWNLINT_CONFIG} has an unclosed comment.`); + return end + 2; +} + +function stringToken(source: string, start: number): Token { + let end = start + 1; + while (end < source.length && source[end] !== '"') end += source[end] === '\\' ? 2 : 1; + if (end >= source.length) throw new Error(`${MARKDOWNLINT_CONFIG} has an unclosed string.`); + const text = source.slice(start, end + 1); + return { text, value: JSON.parse(text) as string, start, end: end + 1 }; +} + +/** A punctuation mark, or a literal such as `true` or `100`. */ +function otherToken(source: string, start: number): Token { + const character = source.charAt(start); + if ('{}[]:,'.includes(character)) return { text: character, start, end: start + 1 }; + let end = start; + while (end < source.length && !/[\s{}[\]:,"/]/.test(source.charAt(end))) end += 1; + if (end === start) throw new Error(`${MARKDOWNLINT_CONFIG} is not valid JSONC.`); + return { text: source.slice(start, end), start, end }; +} + +/** The strings, punctuation, and literals of a JSONC document, without whitespace or comments. */ +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + while (index < source.length) { + const skipped = /\s/.test(source.charAt(index)) ? index + 1 : commentEnd(source, index); + if (skipped !== index) { + index = skipped; + continue; + } + const token = source[index] === '"' ? stringToken(source, index) : otherToken(source, index); + tokens.push(token); + index = token.end; + } + return tokens; +} + +const NESTING = new Map([ + ['{', 1], + ['[', 1], + ['}', -1], + [']', -1], +]); + +/** The `[` that opens the top-level `key` array and every token after it, if there is one. */ +function topLevelArray(tokens: Token[], key: string): { open: Token; rest: Token[] } | undefined { + let depth = 0; + for (const [index, { text, value }] of tokens.entries()) { + const open = tokens[index + 2]; + if (depth === 1 && value === key && tokens[index + 1]?.text === ':' && open?.text === '[') + return { open, rest: tokens.slice(index + 3) }; + depth += NESTING.get(text) ?? 0; + } + return undefined; +} + +/** Whether an array holds `value` as one of its own entries, given the tokens after its `[`. */ +function arrayHolds(rest: Token[], value: string): boolean { + let depth = 0; + for (const token of rest) { + depth += NESTING.get(token.text) ?? 0; + if (depth < 0) return false; + if (depth === 0 && token.value !== undefined && patternKey(token.value) === patternKey(value)) + return true; + } + return false; +} + +/** The whitespace that starts the first line after `offset` holding anything else. */ +function nextIndentation(source: string, offset: number): { indentation: string; closes: boolean } { + const match = /\n([ \t]*)(\S)/.exec(source.slice(offset)); + return { indentation: match?.[1] ?? '', closes: match?.[2] === ']' || match?.[2] === '}' }; +} + +/** + * Adds `glob` to the top-level `ignores` array of a markdownlint-cli2 configuration, with `reason` + * as a comment above it. The entry goes first in the array; every existing entry, comment, and + * blank line stays where it was. A configuration without `ignores` gets the array. + */ +export function addMarkdownlintIgnore(source: string, glob: string, reason: string): string { + const tokens = tokenize(source); + const [first] = tokens; + if (first?.text !== '{') throw new Error(`${MARKDOWNLINT_CONFIG} must hold one object.`); + const ignores = topLevelArray(tokens, 'ignores'); + if (!ignores) { + const entry = `"ignores": [${JSON.stringify(glob)}],`; + return insertAfter(source, first.end, `// ${reason}`, entry); + } + if (arrayHolds(ignores.rest, glob)) return source; + return insertAfter(source, ignores.open.end, `// ${reason}`, `${JSON.stringify(glob)},`); +} + +/** + * Inserts `entry` right after `offset`: on its own line below a `// comment`, indented like the + * line that follows, or ahead of the next entry when the bracket's contents share its line. + */ +function insertAfter(source: string, offset: number, comment: string, entry: string): string { + const before = source.slice(0, offset); + const after = source.slice(offset); + if (!/^[ \t]*\r?\n/.test(after)) return `${before}${entry} ${after.trimStart()}`; + const { indentation, closes } = nextIndentation(source, offset); + const indent = closes ? `${indentation} ` : indentation; + const eol = lineEnding(source); + return `${before}${eol}${indent}${comment}${eol}${indent}${entry}${after}`; +} diff --git a/.lvbt/web-platform/standards/template-publication.ts b/.lvbt/web-platform/standards/template-publication.ts new file mode 100644 index 0000000..b0f9fe5 --- /dev/null +++ b/.lvbt/web-platform/standards/template-publication.ts @@ -0,0 +1,126 @@ +import { execFileSync } from 'node:child_process'; +import { cp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { applyPreset } from './web-platform.ts'; +import { readRelease } from './web-platform-source.ts'; + +const examples = new Set(['basic', 'with-astro', 'with-vite-react']); +const dependencyFields = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +] as const; + +interface Manifest { + scripts?: Record<string, string>; + dependencies?: Record<string, string>; + devDependencies?: Record<string, string>; + optionalDependencies?: Record<string, string>; + peerDependencies?: Record<string, string>; +} + +async function manifestPaths(root: string): Promise<string[]> { + const manifests = [path.join(root, 'package.json')]; + for (const parent of ['apps', 'packages']) { + const directory = path.join(root, parent); + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (entry.isDirectory()) manifests.push(path.join(directory, entry.name, 'package.json')); + } + } + return manifests; +} + +async function rewriteManifest(root: string, file: string): Promise<void> { + const manifest = JSON.parse(await readFile(file, 'utf8')) as Manifest; + for (const field of dependencyFields) { + const dependencies = manifest[field]; + if (!dependencies) continue; + for (const name of Object.keys(dependencies)) { + if (!name.startsWith('@lasvegasfortransit/')) continue; + const packageName = name.slice('@lasvegasfortransit/'.length); + const target = path.join(root, '.lvbt/web-platform/packages', packageName); + const relative = path.relative(path.dirname(file), target).split(path.sep).join('/'); + dependencies[name] = `file:${relative}`; + } + } + if (file === path.join(root, 'package.json')) { + manifest.scripts ??= {}; + manifest.scripts['standards:update'] = + 'node .lvbt/web-platform/standards/web-platform-cli.ts update'; + manifest.scripts['standards:check'] = + 'node .lvbt/web-platform/standards/web-platform-cli.ts check'; + const check = manifest.scripts.check; + if (!check) throw new Error('The template root must define a check script.'); + if (!check.startsWith('pnpm standards:check && ')) { + manifest.scripts.check = `pnpm standards:check && ${check}`; + } + } + await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`); +} + +export async function materializeTemplate(options: { + source: string; + target: string; + example: string; + release: string; +}): Promise<void> { + const source = path.resolve(options.source); + const target = path.resolve(options.target); + if (!examples.has(options.example)) + throw new Error(`Unknown template example: ${options.example}`); + if (source === target) throw new Error('Source and target directories must differ.'); + + const bundle = readRelease(source, options.release); + const git = (args: string[]) => + execFileSync('git', ['-C', source, ...args], { encoding: 'utf8' }).trim(); + if (git(['rev-parse', 'HEAD']) !== bundle.commit || git(['status', '--porcelain'])) { + throw new Error('Template publication requires a clean checkout of the requested release.'); + } + + const entries = await readdir(target, { withFileTypes: true }).catch(() => []); + await Promise.all( + entries + .filter((entry) => entry.name !== '.git') + .map((entry) => rm(path.join(target, entry.name), { recursive: true, force: true })), + ); + await cp(path.join(source, 'examples', options.example), target, { recursive: true }); + await applyPreset(target, bundle); + for (const file of await manifestPaths(target)) await rewriteManifest(target, file); +} + +export async function main(args: string[]): Promise<void> { + const { values } = parseArgs({ + args, + options: { + source: { type: 'string' }, + target: { type: 'string' }, + example: { type: 'string' }, + release: { type: 'string' }, + }, + }); + if (!values.source || !values.target || !values.example || !values.release) { + throw new Error( + 'Usage: --source <repository> --target <directory> --example <name> --release <tag>', + ); + } + await materializeTemplate({ + source: values.source, + target: values.target, + example: values.example, + release: values.release, + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/.lvbt/web-platform/standards/web-platform-cli.ts b/.lvbt/web-platform/standards/web-platform-cli.ts index 63f41e7..bbac24e 100644 --- a/.lvbt/web-platform/standards/web-platform-cli.ts +++ b/.lvbt/web-platform/standards/web-platform-cli.ts @@ -1,11 +1,11 @@ import { execFileSync } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; -import { applyPreset, verifyPreset } from './web-platform.ts'; +import { applyPreset, verifyPreset, type WebPreset } from './web-platform.ts'; import { readCommit, readRelease } from './web-platform-source.ts'; const upstream = 'https://github.com/LasVegasForTransit/repository-tooling.git'; @@ -27,13 +27,35 @@ function readSource(repository: string, identity: SourceIdentity) { : readCommit(repository, identity.commit ?? ''); } +/** + * Applies a preset with the updater it carries, so a release's own consumer migrations run in the + * update that installs it rather than in the next one. A preset without an updater uses this one. + */ +async function applyIncoming(root: string, bundle: WebPreset, dryRun: boolean) { + const names = Object.keys(bundle.files).filter((name) => name.startsWith('standards/')); + if (!names.includes('standards/web-platform.ts')) return applyPreset(root, bundle, dryRun); + const directory = await mkdtemp(path.join(os.tmpdir(), 'lvbt-updater-')); + try { + for (const name of names) { + await mkdir(path.dirname(path.join(directory, name)), { recursive: true }); + await writeFile(path.join(directory, name), bundle.files[name] ?? ''); + } + const incoming = (await import( + pathToFileURL(path.join(directory, 'standards/web-platform.ts')).href + )) as { applyPreset: typeof applyPreset }; + return await incoming.applyPreset(root, bundle, dryRun); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + async function update( root: string, identity: SourceIdentity, source: string | undefined, dryRun: boolean, ) { - if (source) return applyPreset(root, readSource(source, identity), dryRun); + if (source) return applyIncoming(root, readSource(source, identity), dryRun); const directory = await mkdtemp(path.join(os.tmpdir(), 'lvbt-standards-')); try { if (identity.release) { @@ -63,7 +85,7 @@ async function update( { stdio: 'pipe' }, ); } - return await applyPreset(root, readSource(directory, identity), dryRun); + return await applyIncoming(root, readSource(directory, identity), dryRun); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/.lvbt/web-platform/standards/web-platform-source.ts b/.lvbt/web-platform/standards/web-platform-source.ts index c6dbe26..f993c09 100644 --- a/.lvbt/web-platform/standards/web-platform-source.ts +++ b/.lvbt/web-platform/standards/web-platform-source.ts @@ -35,7 +35,7 @@ function readPreset(repository: string, ref: string, release: string | null): We export function readRelease(repository: string, release: string): WebPreset { if (!/^v\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/.test(release)) { - throw new Error('Use an explicit version tag, such as v0.2.8.'); + throw new Error('Use an explicit version tag, such as v0.3.1.'); } return readPreset(repository, `refs/tags/${release}`, release); } diff --git a/.lvbt/web-platform/standards/web-platform.ts b/.lvbt/web-platform/standards/web-platform.ts index 586b567..a1dc4b7 100644 --- a/.lvbt/web-platform/standards/web-platform.ts +++ b/.lvbt/web-platform/standards/web-platform.ts @@ -3,6 +3,13 @@ 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'; +import { + AGENT_WORKTREES, + consumerIgnoreWarnings, + syncConsumerIgnores, +} from './consumer-ignores.ts'; + export interface WebPreset { formatVersion: number; preset: string; @@ -145,6 +152,77 @@ export async function applyPreset(root: string, bundle: WebPreset, dryRun = fals .sort() .filter((name) => !(name in bundle.files)), }; - if (!dryRun) await install(root, bundle); - return plan; + const migrate = async (dry: boolean) => + [ + ...new Set([ + ...(await migrateLegacyPackageScope(root, dry)), + ...(await syncConsumerIgnores(root, dry)), + ...(await syncAstroTypesBeforeLint(root, dry)), + ]), + ].sort(); + // Planning first means a consumer file a migration can't read stops the update before any write. + const consumerChanged = await migrate(true); + for (const warning of await consumerIgnoreWarnings(root)) + process.stderr.write(`warning: ${warning}\n`); + if (!dryRun) { + await migrate(false); + await install(root, bundle); + } + return { ...plan, consumerChanged }; +} + +const SKIPPED_DIRECTORIES = new Set([ + '.git', + 'node_modules', + 'dist', + '.turbo', + 'test-results', + 'playwright-report', + 'blob-report', +]); +const LEGACY_PLATFORM_PACKAGES = [ + 'cli', + 'eslint-config', + 'playwright-config', + 'prettier-config', + 'typescript-config', + 'vitest-config', + 'web-platform', +] as const; + +async function consumerFiles(root: string, relative = ''): Promise<string[]> { + const directory = path.join(root, relative); + const files: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === '.lvbt' && relative === '') continue; + if (entry.isDirectory()) { + const directory = path.join(relative, entry.name); + // A nested checkout, such as an agent worktree under .claude/worktrees/, is another branch's, + // and so is a worktree folder whose .git is already gone. + const nested = + directory === path.join(...AGENT_WORKTREES.split('/')) || + existsSync(path.join(root, directory, '.git')); + if (!SKIPPED_DIRECTORIES.has(entry.name) && !nested) + files.push(...(await consumerFiles(root, directory))); + continue; + } + if (entry.isFile()) files.push(path.join(relative, entry.name)); + } + return files; +} + +async function migrateLegacyPackageScope(root: string, dryRun: boolean): Promise<string[]> { + const changed: string[] = []; + for (const relative of await consumerFiles(root)) { + const file = path.join(root, relative); + const source = await readFile(file, 'utf8').catch(() => null); + if (source === null || source.includes('\0')) continue; + let next = source; + for (const name of LEGACY_PLATFORM_PACKAGES) + next = next.replaceAll(`@lvbt/${name}`, `@lasvegasfortransit/${name}`); + if (next === source) continue; + changed.push(relative.split(path.sep).join('/')); + if (!dryRun) await writeFile(file, next); + } + return changed.sort(); } diff --git a/AGENTS.md b/AGENTS.md index a599239..51ae712 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,9 +25,9 @@ authorizes creating an issue or pull request. It carries the organization checkl templates, and the only approved creation helper: ```bash -node node_modules/@lvbt/cli/plugins/lvbt-contributions/scripts/github-create.mjs issue \ +node node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/scripts/github-create.mjs issue \ --type bug|feature --title <title> --body-file <file> -node node_modules/@lvbt/cli/plugins/lvbt-contributions/scripts/github-create.mjs pr \ +node node_modules/@lasvegasfortransit/cli/plugins/lvbt-contributions/scripts/github-create.mjs pr \ --title <title> --body-file <file> --base main ``` @@ -43,5 +43,5 @@ change crosses boundaries; never invent one for a feature, file, task, or role. ## The repository standard -Lint, format, TypeScript, and test settings extend the `@lvbt/*` packages from +Lint, format, TypeScript, and test settings extend the `@lasvegasfortransit/*` packages from `LasVegasForTransit/repository-tooling`. Change a shared rule there, not here. diff --git a/apps/collector/eslint.config.js b/apps/collector/eslint.config.js index 50f29f2..fe2b986 100644 --- a/apps/collector/eslint.config.js +++ b/apps/collector/eslint.config.js @@ -1,3 +1,3 @@ -import { config } from '@lvbt/eslint-config/base'; +import { config } from '@lasvegasfortransit/eslint-config/base'; export default config; diff --git a/apps/collector/package.json b/apps/collector/package.json index 3de6437..d70a065 100644 --- a/apps/collector/package.json +++ b/apps/collector/package.json @@ -18,8 +18,8 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "catalog:", - "@lvbt/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", - "@lvbt/typescript-config": "file:../../.lvbt/web-platform/packages/typescript-config", + "@lasvegasfortransit/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", + "@lasvegasfortransit/typescript-config": "file:../../.lvbt/web-platform/packages/typescript-config", "@types/node": "catalog:", "eslint": "catalog:", "typescript": "catalog:", diff --git a/apps/collector/tsconfig.json b/apps/collector/tsconfig.json index c28fdf0..7675324 100644 --- a/apps/collector/tsconfig.json +++ b/apps/collector/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@lvbt/typescript-config/worker.json", + "extends": "@lasvegasfortransit/typescript-config/worker.json", "compilerOptions": { "types": ["node", "@cloudflare/vitest-pool-workers/types"] }, diff --git a/package.json b/package.json index 1b7f96f..f0c8234 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lvbt/cli": "file:.lvbt/web-platform/packages/cli", - "@lvbt/prettier-config": "file:.lvbt/web-platform/packages/prettier-config", + "@lasvegasfortransit/cli": "file:.lvbt/web-platform/packages/cli", + "@lasvegasfortransit/prettier-config": "file:.lvbt/web-platform/packages/prettier-config", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/packages/analytics/eslint.config.js b/packages/analytics/eslint.config.js index 50f29f2..fe2b986 100644 --- a/packages/analytics/eslint.config.js +++ b/packages/analytics/eslint.config.js @@ -1,3 +1,3 @@ -import { config } from '@lvbt/eslint-config/base'; +import { config } from '@lasvegasfortransit/eslint-config/base'; export default config; diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 5ed653a..ef07864 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -75,8 +75,8 @@ }, "devDependencies": { "@playwright/test": "catalog:", - "@lvbt/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", - "@lvbt/typescript-config": "file:../../.lvbt/web-platform/packages/typescript-config", + "@lasvegasfortransit/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", + "@lasvegasfortransit/typescript-config": "file:../../.lvbt/web-platform/packages/typescript-config", "@types/node": "catalog:", "@types/react": "catalog:", "astro": "catalog:", diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json index 2c336bd..f0fe821 100644 --- a/packages/analytics/tsconfig.json +++ b/packages/analytics/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@lvbt/typescript-config/browser.json", + "extends": "@lasvegasfortransit/typescript-config/browser.json", "compilerOptions": { "jsx": "react-jsx" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d8da53..6113c01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,10 +79,10 @@ importers: .: devDependencies: - '@lvbt/cli': + '@lasvegasfortransit/cli': specifier: file:.lvbt/web-platform/packages/cli version: file:.lvbt/web-platform/packages/cli - '@lvbt/prettier-config': + '@lasvegasfortransit/prettier-config': specifier: file:.lvbt/web-platform/packages/prettier-config version: file:.lvbt/web-platform/packages/prettier-config(prettier@3.9.6) lint-staged: @@ -116,10 +116,10 @@ importers: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' version: 0.22.0(@cloudflare/workers-types@5.20260901.1)(@types/node@24.13.3)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@24.13.3)(happy-dom@20.14.5)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) - '@lvbt/eslint-config': + '@lasvegasfortransit/eslint-config': specifier: file:../../.lvbt/web-platform/packages/eslint-config version: file:.lvbt/web-platform/packages/eslint-config(eslint@10.9.1(supports-color@10.2.2))(supports-color@10.2.2)(turbo@2.10.12)(typescript@6.0.3) - '@lvbt/typescript-config': + '@lasvegasfortransit/typescript-config': specifier: file:../../.lvbt/web-platform/packages/typescript-config version: file:.lvbt/web-platform/packages/typescript-config '@types/node': @@ -140,10 +140,10 @@ importers: packages/analytics: devDependencies: - '@lvbt/eslint-config': + '@lasvegasfortransit/eslint-config': specifier: file:../../.lvbt/web-platform/packages/eslint-config version: file:.lvbt/web-platform/packages/eslint-config(eslint@10.9.1(supports-color@10.2.2))(supports-color@10.2.2)(turbo@2.10.12)(typescript@6.0.3) - '@lvbt/typescript-config': + '@lasvegasfortransit/typescript-config': specifier: file:../../.lvbt/web-platform/packages/typescript-config version: file:.lvbt/web-platform/packages/typescript-config '@playwright/test': @@ -185,10 +185,10 @@ importers: tools/report: devDependencies: - '@lvbt/eslint-config': + '@lasvegasfortransit/eslint-config': specifier: file:../../.lvbt/web-platform/packages/eslint-config version: file:.lvbt/web-platform/packages/eslint-config(eslint@10.9.1(supports-color@10.2.2))(supports-color@10.2.2)(turbo@2.10.12)(typescript@6.0.3) - '@lvbt/typescript-config': + '@lasvegasfortransit/typescript-config': specifier: file:../../.lvbt/web-platform/packages/typescript-config version: file:.lvbt/web-platform/packages/typescript-config '@types/node': @@ -1076,23 +1076,23 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@lvbt/cli@file:.lvbt/web-platform/packages/cli': + '@lasvegasfortransit/cli@file:.lvbt/web-platform/packages/cli': resolution: {directory: .lvbt/web-platform/packages/cli, type: directory} engines: {node: '>=24'} hasBin: true - '@lvbt/eslint-config@file:.lvbt/web-platform/packages/eslint-config': + '@lasvegasfortransit/eslint-config@file:.lvbt/web-platform/packages/eslint-config': resolution: {directory: .lvbt/web-platform/packages/eslint-config, type: directory} peerDependencies: eslint: '>=10' typescript: '>=5.9' - '@lvbt/prettier-config@file:.lvbt/web-platform/packages/prettier-config': + '@lasvegasfortransit/prettier-config@file:.lvbt/web-platform/packages/prettier-config': resolution: {directory: .lvbt/web-platform/packages/prettier-config, type: directory} peerDependencies: prettier: '>=3' - '@lvbt/typescript-config@file:.lvbt/web-platform/packages/typescript-config': + '@lasvegasfortransit/typescript-config@file:.lvbt/web-platform/packages/typescript-config': resolution: {directory: .lvbt/web-platform/packages/typescript-config, type: directory} '@napi-rs/wasm-runtime@1.2.4': @@ -3930,9 +3930,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@lvbt/cli@file:.lvbt/web-platform/packages/cli': {} + '@lasvegasfortransit/cli@file:.lvbt/web-platform/packages/cli': {} - '@lvbt/eslint-config@file:.lvbt/web-platform/packages/eslint-config(eslint@10.9.1(supports-color@10.2.2))(supports-color@10.2.2)(turbo@2.10.12)(typescript@6.0.3)': + '@lasvegasfortransit/eslint-config@file:.lvbt/web-platform/packages/eslint-config(eslint@10.9.1(supports-color@10.2.2))(supports-color@10.2.2)(turbo@2.10.12)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-plugin-eslint-comments': 4.7.2(eslint@10.9.1(supports-color@10.2.2)) '@eslint/js': 10.0.1(eslint@10.9.1(supports-color@10.2.2)) @@ -3948,11 +3948,11 @@ snapshots: - supports-color - turbo - '@lvbt/prettier-config@file:.lvbt/web-platform/packages/prettier-config(prettier@3.9.6)': + '@lasvegasfortransit/prettier-config@file:.lvbt/web-platform/packages/prettier-config(prettier@3.9.6)': dependencies: prettier: 3.9.6 - '@lvbt/typescript-config@file:.lvbt/web-platform/packages/typescript-config': {} + '@lasvegasfortransit/typescript-config@file:.lvbt/web-platform/packages/typescript-config': {} '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: diff --git a/prettier.config.js b/prettier.config.js index 9b4c309..ad51682 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -1,3 +1,3 @@ -import config from '@lvbt/prettier-config'; +import config from '@lasvegasfortransit/prettier-config'; export default config; diff --git a/tools/report/eslint.config.js b/tools/report/eslint.config.js index 50f29f2..fe2b986 100644 --- a/tools/report/eslint.config.js +++ b/tools/report/eslint.config.js @@ -1,3 +1,3 @@ -import { config } from '@lvbt/eslint-config/base'; +import { config } from '@lasvegasfortransit/eslint-config/base'; export default config; diff --git a/tools/report/package.json b/tools/report/package.json index 02633e3..3a56c2d 100644 --- a/tools/report/package.json +++ b/tools/report/package.json @@ -11,8 +11,8 @@ "validate": "tsc --noEmit" }, "devDependencies": { - "@lvbt/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", - "@lvbt/typescript-config": "file:../../.lvbt/web-platform/packages/typescript-config", + "@lasvegasfortransit/eslint-config": "file:../../.lvbt/web-platform/packages/eslint-config", + "@lasvegasfortransit/typescript-config": "file:../../.lvbt/web-platform/packages/typescript-config", "@types/node": "catalog:", "eslint": "catalog:", "tsx": "catalog:", diff --git a/tools/report/tsconfig.json b/tools/report/tsconfig.json index 72e2856..9bd802f 100644 --- a/tools/report/tsconfig.json +++ b/tools/report/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "@lvbt/typescript-config/node.json", + "extends": "@lasvegasfortransit/typescript-config/node.json", "include": ["src", "tests", "tsdown.config.ts"] }