From 2a7cac6ad0e2cfb854bbe6ca75ad3ba0d9699d7a Mon Sep 17 00:00:00 2001 From: lvbt-bot Date: Thu, 24 Sep 2026 02:27:00 +0000 Subject: [PATCH] chore: publish LVBT repository standard v0.4.4 Refresh this template from the reviewed v0.4.4 source. --- .claude/settings.json | 2 +- .gitignore | 2 + .lvbt/web-platform.json | 6 +- .../examples/with-astro/.claude/settings.json | 2 +- .../examples/with-astro/.gitignore | 2 + .../with-astro/.markdownlint-cli2.jsonc | 1 + .../with-astro/apps/site/package.json | 8 +- .../examples/with-astro/package.json | 4 +- .../with-vite-react/.claude/settings.json | 2 +- .../examples/with-vite-react/.gitignore | 2 + .../with-vite-react/.markdownlint-cli2.jsonc | 1 + .../with-vite-react/apps/app/package.json | 8 +- .../examples/with-vite-react/package.json | 4 +- .lvbt/web-platform/packages/cli/package.json | 2 +- .../packages/cli/platform.schema.json | 6 +- .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .lvbt/web-platform/packages/cli/src/cli.mjs | 4 +- .../packages/cli/src/lib/arguments.mjs | 2 +- .../packages/cli/src/lib/check/contract.mjs | 16 +- .../packages/cli/src/lib/operate.mjs | 7 + .../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 | 416 ++++-------------- .../packages/cli/src/lib/platform/guides.mjs | 303 ++++++++++--- .../packages/cli/src/lib/platform/index.mjs | 81 +++- .../cli/src/lib/platform/manifest.mjs | 4 + .../packages/cli/src/lib/platform/observe.mjs | 25 +- .../cli/src/lib/platform/plan-access.mjs | 77 +++- .../cli/src/lib/platform/plan-cloudflare.mjs | 37 +- .../cli/src/lib/platform/plan-items.mjs | 9 + .../cli/src/lib/platform/plan-values.mjs | 44 +- .../packages/cli/src/lib/platform/plan.mjs | 2 +- .../cli/src/lib/platform/services.mjs | 83 +++- .../packages/eslint-config/package.json | 2 +- .../packages/playwright-config/package.json | 2 +- .../packages/prettier-config/package.json | 2 +- .../packages/typescript-config/package.json | 2 +- .../packages/vitest-config/package.json | 2 +- .../packages/web-platform/package.json | 2 +- .lvbt/web-platform/standards/astro-sync.ts | 14 +- .../standards/consumer-ignores.ts | 218 +++++++++ .lvbt/web-platform/standards/web-platform.ts | 62 ++- .markdownlint-cli2.jsonc | 1 + pnpm-lock.yaml | 8 +- 46 files changed, 1439 insertions(+), 506 deletions(-) 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/standards/consumer-ignores.ts diff --git a/.claude/settings.json b/.claude/settings.json index a52e93d..98ed33c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.4.1" + "ref": "v0.4.4" } } }, diff --git a/.gitignore b/.gitignore index 39d43f7..65717d9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ blob-report/ .dev.vars .dev.vars.* .DS_Store +# Agent worktrees are other checkouts of this repository. +.claude/worktrees/ diff --git a/.lvbt/web-platform.json b/.lvbt/web-platform.json index 8306138..619b896 100644 --- a/.lvbt/web-platform.json +++ b/.lvbt/web-platform.json @@ -1,9 +1,9 @@ { "formatVersion": 1, "preset": "lvbt-web", - "release": "v0.4.1", - "commit": "0577d597f03f1036d49c497a5424ec643b75c5d1", - "contentHash": "15acec6a3332eb5a2817da55147a740549eea1b921c5dbc640658cc6226ea224", + "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/.claude/settings.json b/.lvbt/web-platform/examples/with-astro/.claude/settings.json index a52e93d..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.4.1" + "ref": "v0.4.4" } } }, diff --git a/.lvbt/web-platform/examples/with-astro/.gitignore b/.lvbt/web-platform/examples/with-astro/.gitignore index c65b6f7..4cf35a7 100644 --- a/.lvbt/web-platform/examples/with-astro/.gitignore +++ b/.lvbt/web-platform/examples/with-astro/.gitignore @@ -13,4 +13,6 @@ blob-report/ .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 350e5df..d49cf21 100644 --- a/.lvbt/web-platform/examples/with-astro/.markdownlint-cli2.jsonc +++ b/.lvbt/web-platform/examples/with-astro/.markdownlint-cli2.jsonc @@ -13,6 +13,7 @@ // Playwright output. "**/test-results", "**/playwright-report", + // Agent worktrees are other checkouts of this repository. ".claude/worktrees", "node_modules", "**/node_modules", 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 fa04257..507a2fb 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/package.json +++ b/.lvbt/web-platform/examples/with-astro/apps/site/package.json @@ -21,10 +21,10 @@ }, "devDependencies": { "@astrojs/check": "catalog:", - "@lasvegasfortransit/eslint-config": "0.4.1", - "@lasvegasfortransit/playwright-config": "0.4.1", - "@lasvegasfortransit/typescript-config": "0.4.1", - "@lasvegasfortransit/vitest-config": "0.4.1", + "@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/package.json b/.lvbt/web-platform/examples/with-astro/package.json index 4faf64f..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": { - "@lasvegasfortransit/cli": "0.4.1", - "@lasvegasfortransit/prettier-config": "0.4.1", + "@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/.claude/settings.json b/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json index a52e93d..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.4.1" + "ref": "v0.4.4" } } }, diff --git a/.lvbt/web-platform/examples/with-vite-react/.gitignore b/.lvbt/web-platform/examples/with-vite-react/.gitignore index 39d43f7..65717d9 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.gitignore +++ b/.lvbt/web-platform/examples/with-vite-react/.gitignore @@ -13,3 +13,5 @@ blob-report/ .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 350e5df..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,6 +13,7 @@ // Playwright output. "**/test-results", "**/playwright-report", + // Agent worktrees are other checkouts of this repository. ".claude/worktrees", "node_modules", "**/node_modules", 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 3f7af88..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 @@ -17,10 +17,10 @@ "react-dom": "catalog:" }, "devDependencies": { - "@lasvegasfortransit/eslint-config": "0.4.1", - "@lasvegasfortransit/playwright-config": "0.4.1", - "@lasvegasfortransit/typescript-config": "0.4.1", - "@lasvegasfortransit/vitest-config": "0.4.1", + "@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/package.json b/.lvbt/web-platform/examples/with-vite-react/package.json index fef1d39..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": { - "@lasvegasfortransit/cli": "0.4.1", - "@lasvegasfortransit/prettier-config": "0.4.1", + "@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/packages/cli/package.json b/.lvbt/web-platform/packages/cli/package.json index 4ac27de..065c5be 100644 --- a/.lvbt/web-platform/packages/cli/package.json +++ b/.lvbt/web-platform/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/cli", - "version": "0.4.1", + "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", diff --git a/.lvbt/web-platform/packages/cli/platform.schema.json b/.lvbt/web-platform/packages/cli/platform.schema.json index f7214bd..5cf923b 100644 --- a/.lvbt/web-platform/packages/cli/platform.schema.json +++ b/.lvbt/web-platform/packages/cli/platform.schema.json @@ -76,7 +76,7 @@ "steps": { "type": "array", "minItems": 1, - "description": "Numbered, click-by-click steps to find or create the value. Write each as one complete sentence.", + "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": { @@ -284,6 +284,10 @@ "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." 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 b22e557..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.4.1", + "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 61b8939..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.4.1", + "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 ed0e03f..93fe194 100755 --- a/.lvbt/web-platform/packages/cli/src/cli.mjs +++ b/.lvbt/web-platform/packages/cli/src/cli.mjs @@ -19,7 +19,7 @@ import { check } from './lib/check/index.mjs'; import { bootstrap, deploy, preflight } from './lib/operate.mjs'; const usage = `Usage: - lvbt bootstrap [--production [--filter ]] + lvbt bootstrap [--production [--filter ] [--rotate [,...]]] lvbt preflight [--production [--filter ]] lvbt check [filenames|contract|debt|platform ...] [--staged] lvbt deploy [--filter ] [--dry-run] @@ -29,6 +29,8 @@ Options: 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 `; diff --git a/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs b/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs index aa16592..896adf7 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/arguments.mjs @@ -6,7 +6,7 @@ export class CliError extends Error { } const flags = new Set(['--dry-run', '--staged', '--production', '--help']); -const valued = new Set(['--filter']); +const valued = new Set(['--filter', '--rotate']); /** ` [positional...] [--flag] [--option value]`. Unknown options are an error. */ export function parseArguments(argv) { 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 818ab46..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'; /** @@ -129,8 +129,16 @@ function packageFailures(root, directory) { return failures; } -function dependsOnAstro(manifest) { - return Boolean(manifest.dependencies?.astro ?? manifest.devDependencies?.astro); +// 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))); } /** @@ -144,7 +152,7 @@ function astroFailures(root, directories) { let astro = false; for (const directory of directories) { const manifest = JSON.parse(readFileSync(path.join(root, directory, 'package.json'), 'utf8')); - if (!dependsOnAstro(manifest)) continue; + if (!isAstroProject(root, directory, manifest)) continue; astro = true; if (!manifest.scripts?.sync) { failures.push( diff --git a/.lvbt/web-platform/packages/cli/src/lib/operate.mjs b/.lvbt/web-platform/packages/cli/src/lib/operate.mjs index b8f25f3..3d25dfe 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/operate.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/operate.mjs @@ -196,6 +196,11 @@ async function machineFindings(cwd) { * 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 { @@ -214,6 +219,8 @@ export async function preflight({ cwd, options = {} }) { * 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) 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 index c1453f2..d504c84 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/apply.mjs @@ -1,357 +1,102 @@ -import { randomBytes } from 'node:crypto'; -import { accessAppGuide, turnstileGuide } from './guides.mjs'; -import { findApp, findWidget, SETUP } from './plan.mjs'; -import { redact } from './services.mjs'; +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. - * - * Secret values live only in `context.values` for the length of the run. - * They reach Wrangler and gh on stdin and are redacted from any output shown. */ -export function generateSecret() { - return randomBytes(32).toString('base64url'); -} - -function account(context) { - return `accounts/${context.manifest.cloudflare.accountId}`; -} - -function wrangler(context, args, options = {}) { - return context.run('pnpm', ['exec', 'wrangler', ...args], { - cwd: context.directory, - env: { - CLOUDFLARE_ACCOUNT_ID: context.manifest.cloudflare.accountId, - WRANGLER_SEND_METRICS: 'false', - }, - ...options, - }); -} - -function succeeded(result, secret) { - if (result.status === 0) return; - const output = `${result.stderr}\n${result.stdout}`.trim().split('\n').slice(-3).join(' '); - throw new Error(redact(output || `exited with ${result.status}`, secret)); -} - -function printGuide(io, guide) { - if (guide.url) io.write(`Open: ${paint('cyan', guide.url)}\n`); - guide.steps.forEach((step, index) => io.write(` ${index + 1}. ${step}\n`)); -} - -async function manualStep(context, key, title, guide) { - if (context.handled.has(key)) return; - context.handled.add(key); - const { io } = context; - io.write(`\n${paint('bold', title)}\n`); - printGuide(io, guide); - if (guide.url && (await io.confirm('Open that page in your browser?', true))) io.open(guide.url); - await io.ask('Press Enter when you have finished (or to leave it for later): '); -} - -export function targetName(context, target) { - return target === 'worker' - ? `Worker ${context.manifest.cloudflare.worker}` - : `GitHub environment ${target.slice(7)}`; -} - -async function storeSecret(context, name, target, value) { - if (target === 'worker') { - succeeded( - wrangler(context, ['secret', 'put', name, '--name', context.manifest.cloudflare.worker], { - input: value, - }), - value, - ); - } else { - const repository = context.manifest.github.repository; - succeeded( - context.run('gh', ['secret', 'set', name, '--env', target.slice(7), '--repo', repository], { - cwd: context.directory, - input: value, - }), - value, - ); - } - context.handled.add(`secret:${name}:${target}`); - context.io.write(`${paint('green', 'Stored')} ${name} on ${targetName(context, target)}.\n`); -} - -/** Store a value a new resource produced, straight away, so it cannot be lost or left stale. */ -async function storeFed(context, name, value) { - if (!value || !context.state.worker.ok || !context.state.worker.value.exists) return; - context.values.set(name, value); - await storeSecret(context, name, 'worker', value); -} - -async function promptValue(context, secret, fallback) { - const { io } = context; - io.write(`\n${paint('bold', secret.name)}: ${secret.purpose}\n`); - if (secret.neededFor) io.write(`Needed for ${secret.neededFor}.\n`); - const guide = { url: secret.url ?? fallback?.url, steps: secret.steps ?? fallback?.steps ?? [] }; - printGuide(io, guide); - if (guide.url && (await io.confirm('Open that page in your browser?', true))) io.open(guide.url); - for (let attempt = 0; attempt < 3; attempt += 1) { - const value = await io.askHidden( - `Paste ${secret.name} (hidden; leave empty to skip for now): `, - ); - if (!value) return undefined; - if (!secret.pattern || new RegExp(secret.pattern, 'u').test(value)) return value; - io.write( - `That does not look right. ${secret.patternHint ?? `It should match ${secret.pattern}.`}\n`, - ); - } - return undefined; -} - -async function readWidgetSecret(context, widget) { - const created = context.created.widgets.get(widget.name); - if (created?.secret) return created.secret; - const api = await context.setupApi(); - if (!api) return undefined; - const found = findWidget(await api.list(`${account(context)}/challenges/widgets`), widget); - return found - ? (await api.get(`${account(context)}/challenges/widgets/${found.sitekey}`))?.secret - : undefined; -} - -async function secretValue(context, action) { - const { secret, source } = action; - if (context.values.has(secret.name)) return context.values.get(secret.name); - let value; - let fallback; - if (source.type === 'generate') { - value = generateSecret(); - context.io.write(`Generated a new random value for ${secret.name}.\n`); - } else if (source.type === 'value') { - value = source.value; - } else if (source.type === 'turnstile') { - value = await readWidgetSecret(context, source.widget); - fallback = turnstileGuide(source.widget, context.manifest.cloudflare); - } else if (source.type === 'access-team') { - value = context.state.access.ok ? context.state.access.value.teamDomain : undefined; - } else if (source.type === 'access-audience') { - fallback = accessAppGuide(source.app); - const created = context.created.apps.get(source.app.name); - value = - created?.aud ?? - (context.state.access.ok - ? findApp(context.state.access.value.apps, source.app)?.aud - : undefined); - } - if (!value) value = await promptValue(context, secret, fallback); - if (value) context.values.set(secret.name, value); - return value; +async function 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 createWidget(context, action) { - const api = await context.setupApi(); - if (!api) - return manualStep( - context, - `turnstile:${action.widget.name}`, - `Create the Turnstile widget ${action.widget.name}`, - action.guide, - ); - const { widget } = action; - const created = await api.post(`${account(context)}/challenges/widgets`, { - name: widget.name, - domains: widget.domains, - mode: widget.mode ?? 'managed', - }); - context.created.widgets.set(widget.name, created); - context.io.write(`${paint('green', 'Created')} the Turnstile widget ${widget.name}.\n`); +async function migrateDatabase(context, action) { + if (action.afterCreate && !(await namedInConfig(context, action))) return; context.io.write( - `Put "${widget.siteKeyVar}": "${created.sitekey}" in vars in ${context.configPath}, commit it, and deploy. The site key is public.\n`, + `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 }), ); - await storeFed(context, widget.secret, created.secret); } -async function updateWidget(context, action) { - const api = await context.setupApi(); - if (!api) - return manualStep( - context, - `turnstile:${action.widget.name}`, - `Update the Turnstile widget ${action.widget.name}`, - action.guide, - ); - const current = await api.get(`${account(context)}/challenges/widgets/${action.sitekey}`); - await api.put(`${account(context)}/challenges/widgets/${action.sitekey}`, { - name: current.name, - domains: [...new Set([...(current.domains ?? []), ...action.widget.domains])], - mode: action.widget.mode ?? 'managed', - }); - context.io.write(`${paint('green', 'Updated')} the Turnstile widget ${action.widget.name}.\n`); +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`); } -function appBody(app, provider, policyId, base = {}) { - const existing = base.destinations ?? []; - const covered = new Set(existing.map((destination) => destination.uri)); - return { - name: base.name ?? app.name, - type: 'self_hosted', - domain: base.domain ?? app.destinations[0], - destinations: [ - ...existing, - ...app.destinations - .filter((uri) => !covered.has(uri)) - .map((uri) => ({ type: 'public', uri })), - ], - session_duration: app.sessionDuration ?? '24h', - allowed_idps: [provider.id], - auto_redirect_to_identity: true, - app_launcher_visible: base.app_launcher_visible ?? false, - policies: [ - { id: policyId, precedence: 1 }, - ...(base.policies ?? []) - .filter((policy) => policy.id !== policyId) - .map((policy, index) => ({ id: policy.id, precedence: index + 2 })), - ], - }; +async function 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 allowPolicy(context, api, action) { - const name = `${action.app.name} allow`; - const existing = context.state.access.ok - ? context.state.access.value.policies.find((policy) => policy.name === name) - : undefined; - if (existing) { - await api.put(`${account(context)}/access/policies/${existing.id}`, { - name, - decision: 'allow', - include: action.rule, - }); - return existing.id; +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; } - const created = await api.post(`${account(context)}/access/policies`, { - name, - decision: 'allow', - include: action.rule, + 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, }); - return created.id; } -async function createOrUpdateApp(context, action) { - const api = await context.setupApi(); - const title = `${action.found ? 'Fix' : 'Create'} the Access application ${action.app.name}`; - if (!api) return manualStep(context, `access:${action.app.name}`, title, action.guide); - const policyId = await allowPolicy(context, api, action); - const result = action.found - ? await api.put( - `${account(context)}/access/apps/${action.found.id}`, - appBody(action.app, action.provider, policyId, action.found), - ) - : await api.post( - `${account(context)}/access/apps`, - appBody(action.app, action.provider, policyId), - ); - context.created.apps.set(action.app.name, result); - context.io.write( - `${paint('green', action.found ? 'Updated' : 'Created')} the Access application ${action.app.name}.\n`, - ); - if (!action.found) { - await storeFed(context, action.app.audienceSecret, result.aud); - if (!context.values.has(action.app.teamDomainSecret) && context.state.access.ok) - await storeFed(context, action.app.teamDomainSecret, context.state.access.value.teamDomain); - } -} - -async function deleteSecret(context, action) { - const where = targetName(context, action.target); - if (!(await context.io.confirm(`Delete ${action.name} from ${where}?`, true))) return; - const result = - action.target === 'worker' - ? wrangler( - context, - ['secret', 'delete', action.name, '--name', context.manifest.cloudflare.worker], - { inherit: true }, - ) - : context.run( - 'gh', - [ - 'secret', - 'delete', - action.name, - '--env', - action.target.slice(7), - '--repo', - context.manifest.github.repository, - ], - { cwd: context.directory }, - ); - succeeded(result); - context.io.write(`${paint('green', 'Deleted')} ${action.name} from ${where}.\n`); -} +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 { action } = entry; - switch (action.type) { - case 'd1.create': - succeeded(wrangler(context, ['d1', 'create', action.name])); - context.io.write( - `${paint('green', 'Created')} the D1 database ${action.name}. Check that database_id in ${context.configPath} matches it; the final report says if not.\n`, - ); - return; - case 'd1.migrate': - context.io.write( - `Applying migrations to ${action.name}. Wrangler lists them and asks you to confirm.\n`, - ); - succeeded( - wrangler(context, ['d1', 'migrations', 'apply', action.name, '--remote'], { - inherit: true, - }), - ); - return; - case 'r2.create': - succeeded(wrangler(context, ['r2', 'bucket', 'create', action.name])); - context.io.write(`${paint('green', 'Created')} the R2 bucket ${action.name}.\n`); - return; - case 'turnstile.create': - return createWidget(context, action); - case 'turnstile.update': - return updateWidget(context, action); - case 'access.create': - case 'access.update': - return createOrUpdateApp(context, action); - case 'github.environment': - succeeded( - context.run( - 'gh', - [ - 'api', - '--method', - 'PUT', - `repos/${context.manifest.github.repository}/environments/${action.environment}`, - ], - { cwd: context.directory }, - ), - ); - context.io.write( - `${paint('green', 'Created')} the GitHub environment ${action.environment}.\n`, - ); - return; - case 'secret.put': { - if (context.handled.has(`secret:${action.secret.name}:${action.target}`)) return; - const value = await secretValue(context, action); - if (!value) { - context.io.write(`Skipped ${action.secret.name}. Run ${SETUP} again to set it.\n`); - return; - } - return storeSecret(context, action.secret.name, action.target, value); - } - case 'secret.delete': - return deleteSecret(context, action); - case 'manual': - return manualStep(context, action.key, `${entry.label} ${entry.detail}`, action.guide); - default: - throw new Error(`unknown action ${action.type}`); - } + 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) { @@ -388,6 +133,10 @@ export function describeAction(entry) { */ 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'); @@ -398,13 +147,15 @@ export async function applyPlan(context, items) { if (await io.confirm('Set those up now too?', false)) work = open; } if (work.length === 0) { - io.write('\nNothing here can be set up automatically. The report above says what is left.\n'); - return; + io.write( + '\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; + if (!(await io.confirm('Start?', true))) return { acted: false }; for (const entry of work) { try { await perform(context, entry); @@ -412,4 +163,5 @@ export async function applyPlan(context, items) { 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 index 8d1b964..26c1b16 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/guides.mjs @@ -3,15 +3,59 @@ * 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 */ -const ZERO_TRUST = 'https://one.dash.cloudflare.com/'; +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'; @@ -59,80 +103,217 @@ export function emailRecords(email) { } 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 with the LVBT account (ask a maintainer for access if you have none).', - `If ${email.domain} is not listed on the Domains page, click "Add Domain", type ${email.domain}, choose the region ${email.region ?? 'us-east-1'}, and click "Add".`, - `Open ${email.domain} and click "Sign in to Cloudflare". Approve the request in the Cloudflare window; it adds every record for you.`, - `If you cannot use that button, add each record Resend lists by hand at https://dash.cloudflare.com/${cloudflare.accountId}/${cloudflare.zone.name}/dns/records. Type only the part before .${email.domain} as the name, and set the DKIM record to "DNS only".`, - `Add the DMARC record too: type TXT, name _dmarc, content v=DMARC1; p=none;.`, - 'Wait until Resend says "Verified". It usually takes a few minutes. Then run this command again.', + '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() { +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 Zero Trust and choose the LVBT account.', - 'If Cloudflare asks for a team name, type lasvegasfortransit. Your team domain becomes lasvegasfortransit.cloudflareaccess.com.', - 'Choose the Free plan. It costs nothing for up to 50 people. Cloudflare may still ask for a payment method.', - 'Finish the onboarding, then run this command again.', + `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.`, ], }; } -export function googleWorkspaceGuide(teamDomain, workspaceDomain) { - const team = teamDomain ?? '.cloudflareaccess.com'; +/** Where to copy the team domain from, for when setup cannot read it itself. */ +export function teamDomainGuide(secretName) { return { - url: 'https://console.cloud.google.com/apis/library/admin.googleapis.com', + url: ZERO_TRUST, steps: [ - 'Sign in to Google Cloud with an LVBT Google Workspace admin account and choose the LVBT project at the top of the page. Create one named "LVBT Access" if there is none.', - 'On the Admin SDK API page, click "Enable".', - 'Open https://console.cloud.google.com/auth/overview. If Google asks you to configure the consent screen: App name "LVBT volunteer sign-in", support email your LVBT address, Audience "Internal", then "Create".', - 'Open https://console.cloud.google.com/auth/clients and click "Create client". Application type: "Web application". Name: "Cloudflare Access".', - `Under "Authorized JavaScript origins", add https://${team}.`, - `Under "Authorized redirect URIs", add https://${team}/cdn-cgi/access/callback, then click "Create".`, - 'Copy the Client ID and the Client secret from the dialog.', - 'Open https://admin.google.com/ac/owl (Security → Access and data control → API controls), click "Settings", choose "Internal apps", turn on "Trust internal apps", and save.', - 'In Cloudflare Zero Trust, go to Integrations → Identity providers → "Add new identity provider" → "Google Workspace".', - `Paste the Client ID into "App ID" and the Client secret into "Client secret", type ${workspaceDomain} as the Google Workspace domain, and click "Save".`, - 'Open the link Cloudflare shows after saving and approve it with the Google Workspace admin account, so Access can read group membership.', - 'Click "Test" next to Google Workspace. It should list your groups. Then run this command again.', + `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 accessAppGuide(app) { - const allow = app.allow.googleGroup - ? `Include → "Google Workspace groups" → ${app.allow.googleGroup}` - : app.allow.emailDomain - ? `Include → "Emails ending in" → @${app.allow.emailDomain}` - : `Include → "Emails" → ${app.allow.emails.join(', ')}`; +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: [ - 'In Cloudflare Zero Trust, go to Access controls → Applications → "Create new application" → "Self-hosted and private".', - `Name it "${app.name}".`, - `Add one public hostname for each of: ${app.destinations.join(', ')}. A path does not cover the paths under it, so each one is needed.`, - `Under Access policies, create a policy named "${app.name} allow" with Action "Allow" and ${allow}.`, - `Under authentication, select only ${app.identityProvider === 'google-apps' ? 'Google Workspace' : 'One-time PIN'} and turn on "Apply instant authentication".`, - `Set Session Duration to ${app.sessionDuration ?? '24h'} and click "Create".`, - `Open the application's Overview tab and copy "Application Audience (AUD) Tag" into ${app.audienceSecret} when this command asks for it.`, + `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) { +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`, - steps: [ - 'Click "Add widget".', - `Name it "${widget.name}" and add the hostnames ${widget.domains.join(', ')}.`, - `Choose the widget mode "${widget.mode ?? 'managed'}" and click "Create".`, - `Copy the Site Key into "${widget.siteKeyVar}" in the wrangler config's vars, and the Secret Key into ${widget.secret} when this command asks for it.`, + 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.', ], }; } @@ -156,29 +337,35 @@ export function setupTokenUrl(manifest) { export function setupTokenGuide(manifest) { const needed = []; - if (manifest.turnstile?.length) needed.push('Account · Turnstile · Edit'); + if (manifest.turnstile?.length) needed.push('Turnstile · Edit'); if (manifest.access?.length) { - needed.push('Account · Access: Apps and Policies · Edit'); - needed.push('Account · Access: Organizations, Identity Providers, and Groups · Read'); + needed.push('Access: Apps and Policies · Edit'); + needed.push('Access: Organizations, Identity Providers, and Groups · Read'); } return { url: setupTokenUrl(manifest), steps: [ - 'Sign in to Cloudflare with your LVBT account if it asks.', - `Check that the token lists these permissions, and add any that is missing with "+ Add more": ${needed.join('; ')}.`, - 'Under "Account Resources", choose "Include" and the LVBT account.', - 'Under "TTL", set an end date of tomorrow so the token stops working by itself.', - 'Click "Continue to summary", then "Create Token".', - 'Click "Copy" and paste the token here. It stays in this terminal\'s memory and is never saved.', + '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) { - const shown = value === undefined ? '' : JSON.stringify(value); +export function varGuide(variable, configPath, value, widget) { + const shown = + value === undefined + ? widget + ? `""` + : '""' + : JSON.stringify(value); return { steps: [ - `Add "${variable.name}": ${shown} to "vars" in ${configPath}.`, + `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 index 7a5688c..a6ad1c5 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/index.mjs @@ -1,12 +1,19 @@ import path from 'node:path'; import { CliError } from '../arguments.mjs'; -import { applyPlan } from './apply.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, dnsResolver, runCommand, wranglerToken } from './services.mjs'; +import { + cloudflareApi, + confirmationStore, + dnsResolver, + memoryConfirmations, + runCommand, + wranglerToken, +} from './services.mjs'; import { paint, terminalIo } from './terminal.mjs'; /** @@ -18,7 +25,12 @@ import { paint, terminalIo } from './terminal.mjs'; export const SETUP_TOKEN_VARIABLE = 'LVBT_CLOUDFLARE_SETUP_TOKEN'; export function defaultServices() { - return { run: runCommand, request: fetch, env: process.env }; + return { + run: runCommand, + request: fetch, + env: process.env, + confirmations: confirmationStore(), + }; } /** The manifests `--filter` selects: `apps/site`, `site`, or `.` for the root. */ @@ -54,7 +66,7 @@ function setupApiProvider({ manifest, services, io, interactive }) { const guide = setupTokenGuide(manifest); io.write(`\n${paint('bold', 'A Cloudflare API token for Turnstile and Access')}\n`); io.write( - "Wrangler's sign-in cannot manage Turnstile widgets or Access applications, so this step needs a short-lived token. It stays in this terminal's memory and is never written to disk.\n", + `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`)); @@ -96,7 +108,16 @@ async function inspect({ cwd, file, services, io, interactive, askForToken }) { setup: services.env[SETUP_TOKEN_VARIABLE] ? await setupApi() : undefined, }; const resolve = dnsResolver(services.request); - const observe = () => observePlatform({ manifest, directory, apis, run: services.run, resolve }); + 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', @@ -113,6 +134,7 @@ async function inspect({ cwd, file, services, io, interactive, askForToken }) { title: `${manifest.name} production (${file})`, items: plan(), setupApi, + confirmations, refresh: async () => { state = await observe(); return { state, items: plan() }; @@ -150,6 +172,34 @@ export async function platformPreflight({ ); } +/** + * 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, @@ -162,8 +212,13 @@ export async function platformBootstrap({ `${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 selectManifests(cwd, options.filter)) { + 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 = { @@ -174,15 +229,27 @@ export async function platformBootstrap({ 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 { - await applyPlan(context, view.items); + ({ 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; diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs index 8cd4019..07524d7 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/manifest.mjs @@ -174,6 +174,10 @@ function secretErrors(manifest) { 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) diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs index eff677b..00f24ff 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/observe.mjs @@ -102,6 +102,7 @@ async function observeAccess(api, account) { return known({ enabled: true, teamDomain: organization?.auth_domain, + teamName: organization?.name, providers: providers.map((provider) => ({ id: provider.id, type: provider.type, @@ -117,11 +118,23 @@ async function observeAccess(api, account) { } } -async function observeR2(api, account) { +/** + * 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 result = await api.client.get(`${account}/r2/buckets`); - return (result?.buckets ?? []).map((bucket) => bucket.name); + 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; }); } @@ -176,8 +189,9 @@ async function observeDns(resolve, manifest) { * @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} [input.confirmed] what a person has confirmed on this computer */ -export async function observePlatform({ manifest, directory, apis, run, resolve }) { +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)); @@ -198,7 +212,7 @@ export async function observePlatform({ manifest, directory, apis, run, resolve migrations, worker: await observeWorker(wrangler, account, manifest.cloudflare.worker), d1: await observeD1(wrangler, account, manifest, config.ok ? config.value : undefined), - r2: manifest.r2?.length ? await observeR2(wrangler, account) : known([]), + r2: manifest.r2?.length ? await observeR2(wrangler, account, manifest) : known([]), turnstile: manifest.turnstile?.length ? privileged.client ? await attempt(() => privileged.client.list(`${account}/challenges/widgets`)) @@ -208,6 +222,7 @@ export async function observePlatform({ manifest, directory, apis, run, resolve ? 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 index 19e5eb2..d18d9bd 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-access.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-access.mjs @@ -1,5 +1,11 @@ -import { accessAppGuide, googleWorkspaceGuide, zeroTrustGuide } from './guides.mjs'; -import { item, SETUP, TOKEN_HINT, unknownItem } from './plan-items.mjs'; +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 @@ -72,6 +78,8 @@ export function accessDifferences(found, app, provider, reusable) { 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)]; } @@ -87,11 +95,12 @@ function providerItem(manifest, access, type) { const group = manifest.access.find((app) => app.allow.googleGroup)?.allow.googleGroup; const guide = type === 'google-apps' - ? googleWorkspaceGuide(access.teamDomain, group?.split('@')[1]) + ? googleWorkspaceGuide(access.teamDomain, group?.split('@')[1], group) : { - url: 'https://one.dash.cloudflare.com/', + url: ZERO_TRUST, steps: [ - 'In Zero Trust, go to Integrations → Identity providers → "Add new identity provider" → "One-time PIN", and save.', + '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({ @@ -103,7 +112,7 @@ function providerItem(manifest, access, type) { }); } -function applicationItem(access, app) { +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); @@ -111,7 +120,7 @@ function applicationItem(access, app) { app, provider, rule: allowRule(app.allow, provider), - guide: accessAppGuide(app), + guide: manualGuide(accessAppGuide(app, manifest.cloudflare.zone.name)), }; if (!found && !provider) return item({ @@ -152,15 +161,57 @@ function applicationItem(access, app) { }); } +/** + * 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' }; @@ -171,8 +222,9 @@ export function planAccess({ manifest, state }) { 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() }, + action: { type: 'manual', key: 'zero-trust', guide: zeroTrustGuide(manifest) }, }), + ...groups, ...apps.map((app) => item({ id: `access:${app.name}`, @@ -185,10 +237,15 @@ export function planAccess({ manifest, state }) { ), ]; return [ - item({ ...zeroTrust, status: 'ok', detail: `team domain ${access.teamDomain ?? 'unknown'}` }), + 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), ), - ...apps.map((app) => applicationItem(access, app)), + ...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 index 7a314c2..a3d7ce7 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-cloudflare.mjs @@ -1,5 +1,5 @@ import { turnstileGuide } from './guides.mjs'; -import { item, SETUP, TOKEN_HINT, unknownItem } from './plan-items.mjs'; +import { item, manualGuide, SETUP, TOKEN_HINT, unknownItem } from './plan-items.mjs'; /** The Worker, its D1 databases and R2 buckets, and its Turnstile widgets. */ @@ -71,7 +71,7 @@ function databaseItem({ state, configPath }, database) { return item({ ...fields, status: 'ok', detail: `exists and is bound as ${database.binding}` }); } -function migrationsItem({ state }, database) { +function migrationsItem({ state, configPath }, database) { const fields = { id: `d1:${database.name}:migrations`, section: 'D1 databases', @@ -88,21 +88,38 @@ function migrationsItem({ state }, database) { const action = { type: 'd1.migrate', name: database.name }; const real = state.d1.ok ? state.d1.value[database.name] : undefined; if (state.d1.ok && !real) + // Wrangler applies migrations to the database_id in the config, so setup + // applies them after creating the database only if the config names it. return item({ ...fields, status: 'missing', - detail: `${files.value.length} to apply once the database exists`, - next: `${SETUP} applies them`, - action, + 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); - const pending = files.value.filter((file) => !real.applied.value.includes(file)); + 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.value.length} applied` }); + 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.value.length} not applied: ${pending.join(', ')}`, + detail: `${pending.length} of ${files.length} not applied: ${pending.join(', ')}`, next: `${SETUP} applies them`, action, }); @@ -151,9 +168,9 @@ export function findWidget(widgets, widget) { ); } -function widgetItem({ manifest, state }, widget) { +function widgetItem({ manifest, state, configPath }, widget) { const fields = { id: `turnstile:${widget.name}`, section: 'Turnstile', label: widget.name }; - const guide = turnstileGuide(widget, manifest.cloudflare); + 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); 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 index eaefe3c..189baa4 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-items.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-items.mjs @@ -31,3 +31,12 @@ export function unknownItem(fields, observation) { 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 index 111fcc2..420dee6 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan-values.mjs @@ -1,4 +1,5 @@ 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'; @@ -45,6 +46,21 @@ export function secretSource(secret, manifest) { 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`; @@ -80,15 +96,35 @@ function secretItem({ manifest, state }, secret, target) { }); if (!present.ok) return unknownItem({ ...fields, credentialHint: present.credentialHint }, present); - if (present.value) return item({ ...fields, status: 'ok', detail: 'is set' }); const source = secretSource(secret, manifest); + 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: sourceHint(source), + next: plain === undefined ? sourceHint(source) : `${sourceHint(source)}: ${plain}`, action: { type: 'secret.put', secret, target, source }, }); } @@ -123,7 +159,9 @@ export function planVars({ manifest, state, configPath }) { const action = { type: 'manual', key: `var:${variable.name}`, - guide: varGuide(variable, configPath, live?.sitekey), + guide: varGuide(variable, configPath, live?.sitekey, widget?.name), + variable, + widget: widget?.name, }; if (value === undefined || value === '') return item({ diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs index afec2bc..bb05338 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/plan.mjs @@ -17,7 +17,7 @@ import { export { SETUP } from './plan-items.mjs'; export { findWidget } from './plan-cloudflare.mjs'; export { accessDifferences, allowRule, findApp } from './plan-access.mjs'; -export { secretSource } from './plan-values.mjs'; +export { isSensitive, secretSource } from './plan-values.mjs'; /** * Every item the manifest declares, in the order setup works through them: diff --git a/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs b/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs index f28fb84..deb4df5 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/platform/services.mjs @@ -1,4 +1,7 @@ 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 @@ -83,22 +86,49 @@ export function cloudflareApi(token, request = fetch) { 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. */ + /** + * 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 <= 50; page += 1) { + for (let page = 1; page <= MAX_PAGES; page += 1) { const separator = endpoint.includes('?') ? '&' : '?'; - const payload = await call('GET', `${endpoint}${separator}page=${page}&per_page=50`); - items.push(...(Array.isArray(payload.result) ? payload.result : [])); - const pages = payload.result_info?.total_pages ?? 1; - if (page >= pages || !Array.isArray(payload.result) || payload.result.length === 0) - return items; + 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; } - 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. @@ -142,3 +172,40 @@ export function dnsResolver(request = fetch) { .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/eslint-config/package.json b/.lvbt/web-platform/packages/eslint-config/package.json index 85cc902..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": "@lasvegasfortransit/eslint-config", - "version": "0.4.1", + "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/package.json b/.lvbt/web-platform/packages/playwright-config/package.json index 7293bfd..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": "@lasvegasfortransit/playwright-config", - "version": "0.4.1", + "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 d4e8107..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": "@lasvegasfortransit/prettier-config", - "version": "0.4.1", + "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 e412c38..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": "@lasvegasfortransit/typescript-config", - "version": "0.4.1", + "version": "0.4.4", "description": "TypeScript configurations every LVBT repository extends.", "license": "MIT", "repository": { diff --git a/.lvbt/web-platform/packages/vitest-config/package.json b/.lvbt/web-platform/packages/vitest-config/package.json index 5a1f31b..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": "@lasvegasfortransit/vitest-config", - "version": "0.4.1", + "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/package.json b/.lvbt/web-platform/packages/web-platform/package.json index d78fd49..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": "@lasvegasfortransit/web-platform", - "version": "0.4.1", + "version": "0.4.4", "description": "Provider-neutral deployment, provisioning, and validation primitives for LVBT web repositories.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/standards/astro-sync.ts b/.lvbt/web-platform/standards/astro-sync.ts index 907bcf4..4dbafeb 100644 --- a/.lvbt/web-platform/standards/astro-sync.ts +++ b/.lvbt/web-platform/standards/astro-sync.ts @@ -94,6 +94,18 @@ export function formatJson(value: unknown, indent = '', lead = 0): string { 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[] = []; @@ -101,7 +113,7 @@ async function addSyncScripts(root: string, dryRun: boolean) { for (const directory of await workspacePackages(root)) { const file = path.join(root, directory, 'package.json'); const manifest = JSON.parse(await readFile(file, 'utf8')) as Manifest; - if (!manifest.dependencies?.astro && !manifest.devDependencies?.astro) continue; + if (!isAstroProject(root, directory, manifest)) continue; astro = true; if (manifest.scripts?.sync) continue; manifest.scripts = withEntry(manifest.scripts ?? {}, 'sync', SYNC_SCRIPT, { after: 'lint' }); 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 = { + '.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 { + 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 { + 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/web-platform.ts b/.lvbt/web-platform/standards/web-platform.ts index 742c8b5..a1dc4b7 100644 --- a/.lvbt/web-platform/standards/web-platform.ts +++ b/.lvbt/web-platform/standards/web-platform.ts @@ -4,6 +4,11 @@ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/p 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; @@ -147,40 +152,25 @@ export async function applyPreset(root: string, bundle: WebPreset, dryRun = fals .sort() .filter((name) => !(name in bundle.files)), }; - const consumerChanged = [ - ...new Set([ - ...(await migrateLegacyPackageScope(root, dryRun)), - ...(await ignorePlaywrightOutput(root, dryRun)), - ...(await syncAstroTypesBeforeLint(root, dryRun)), - ]), - ].sort(); - if (!dryRun) await install(root, bundle); + 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 }; } -// 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/', -]; - -/** Appends the rules a consumer's root .gitignore lacks, leaving its own lines untouched. */ -async function ignorePlaywrightOutput(root: string, dryRun: boolean): Promise { - const file = path.join(root, '.gitignore'); - const source = await readFile(file, 'utf8').catch(() => null); - if (source === null) return []; - const present = new Set(source.split(/\r?\n/).map((line) => line.trim())); - const missing = PLAYWRIGHT_OUTPUT_IGNORES.filter((rule) => !present.has(rule)); - if (missing.length === 0) return []; - const separator = source === '' || source.endsWith('\n') ? '' : '\n'; - if (!dryRun) await writeFile(file, `${source}${separator}${missing.join('\n')}\n`); - return ['.gitignore']; -} - const SKIPPED_DIRECTORIES = new Set([ '.git', 'node_modules', @@ -206,8 +196,14 @@ async function consumerFiles(root: string, relative = ''): Promise { for (const entry of await readdir(directory, { withFileTypes: true })) { if (entry.name === '.lvbt' && relative === '') continue; if (entry.isDirectory()) { - if (!SKIPPED_DIRECTORIES.has(entry.name)) - files.push(...(await consumerFiles(root, path.join(relative, entry.name)))); + 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)); diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 350e5df..d49cf21 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -13,6 +13,7 @@ // Playwright output. "**/test-results", "**/playwright-report", + // Agent worktrees are other checkouts of this repository. ".claude/worktrees", "node_modules", "**/node_modules", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ece4be5..059e2a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,8 +646,8 @@ packages: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} - electron-to-chromium@1.5.434: - resolution: {integrity: sha512-7EeFW9OLf1NN9NKQP9xdOE/XKqjxv7JDPP6AEAkHli11+Fae1OaLoNuAf13hQv1PPNtuES0pLQSPk2+fS3s8ww==} + electron-to-chromium@1.5.436: + resolution: {integrity: sha512-iG79/xCF3iThADicbgbES2+liFYmTvelo7jWcEfg6lQ+Adrh7zShrXSg2ZdEDLbKjJ298kGAKMnnzVWobp0L+g==} entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} @@ -1940,7 +1940,7 @@ snapshots: dependencies: baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.434 + electron-to-chromium: 1.5.436 node-releases: 2.0.56 update-browserslist-db: 1.3.3(browserslist@4.29.0) @@ -1988,7 +1988,7 @@ snapshots: dotenv@16.0.3: {} - electron-to-chromium@1.5.434: {} + electron-to-chromium@1.5.436: {} entities@4.5.0: {}