Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ test-results/
playwright-report/
blob-report/
**/playwright/.cache/
.claude/worktrees/
6 changes: 3 additions & 3 deletions .lvbt/web-platform.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"formatVersion": 1,
"preset": "lvbt-web",
"release": "v0.4.1",
"commit": "0577d597f03f1036d49c497a5424ec643b75c5d1",
"contentHash": "15acec6a3332eb5a2817da55147a740549eea1b921c5dbc640658cc6226ea224",
"release": "v0.4.3",
"commit": "22125bc4640c8cfa638b94d0e8e78f6ed4128b07",
"contentHash": "4e45b510b957bfab1de24b36b80d5f84d4cd4621dd4f4af77c01f31b9a64b93a",
"executables": [
"examples/with-astro/.githooks/commit-msg",
"examples/with-astro/.githooks/pre-commit",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"source": {
"source": "github",
"repo": "LasVegasForTransit/repository-tooling",
"ref": "v0.4.1"
"ref": "v0.4.3"
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions .lvbt/web-platform/examples/with-astro/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ blob-report/
.dev.vars
.dev.vars.*
.DS_Store
# Agent worktrees are other checkouts of this repository.
.claude/worktrees/
.astro/
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// Playwright output.
"**/test-results",
"**/playwright-report",
// Agent worktrees are other checkouts of this repository.
".claude/worktrees",
"node_modules",
"**/node_modules",
Expand Down
8 changes: 4 additions & 4 deletions .lvbt/web-platform/examples/with-astro/apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.3",
"@lasvegasfortransit/playwright-config": "0.4.3",
"@lasvegasfortransit/typescript-config": "0.4.3",
"@lasvegasfortransit/vitest-config": "0.4.3",
"@playwright/test": "catalog:",
"@types/node": "catalog:",
"eslint": "catalog:",
Expand Down
4 changes: 2 additions & 2 deletions .lvbt/web-platform/examples/with-astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
"*": "prettier --write --ignore-unknown"
},
"devDependencies": {
"@lasvegasfortransit/cli": "0.4.1",
"@lasvegasfortransit/prettier-config": "0.4.1",
"@lasvegasfortransit/cli": "0.4.3",
"@lasvegasfortransit/prettier-config": "0.4.3",
"lint-staged": "catalog:",
"markdownlint-cli2": "catalog:",
"markdownlint-rule-relative-links": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"source": {
"source": "github",
"repo": "LasVegasForTransit/repository-tooling",
"ref": "v0.4.1"
"ref": "v0.4.3"
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions .lvbt/web-platform/examples/with-vite-react/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ blob-report/
.dev.vars
.dev.vars.*
.DS_Store
# Agent worktrees are other checkouts of this repository.
.claude/worktrees/
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// Playwright output.
"**/test-results",
"**/playwright-report",
// Agent worktrees are other checkouts of this repository.
".claude/worktrees",
"node_modules",
"**/node_modules",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.3",
"@lasvegasfortransit/playwright-config": "0.4.3",
"@lasvegasfortransit/typescript-config": "0.4.3",
"@lasvegasfortransit/vitest-config": "0.4.3",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
Expand Down
4 changes: 2 additions & 2 deletions .lvbt/web-platform/examples/with-vite-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
"*": "prettier --write --ignore-unknown"
},
"devDependencies": {
"@lasvegasfortransit/cli": "0.4.1",
"@lasvegasfortransit/prettier-config": "0.4.1",
"@lasvegasfortransit/cli": "0.4.3",
"@lasvegasfortransit/prettier-config": "0.4.3",
"lint-staged": "catalog:",
"markdownlint-cli2": "catalog:",
"markdownlint-rule-relative-links": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion .lvbt/web-platform/packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lasvegasfortransit/cli",
"version": "0.4.1",
"version": "0.4.3",
"description": "The lvbt command every LVBT repository runs for bootstrap, preflight, and deploy, plus the production platform setup, the shared git hooks, and the lvbt-contributions agent plugin.",
"license": "MIT",
"type": "module",
Expand Down
6 changes: 5 additions & 1 deletion .lvbt/web-platform/packages/cli/platform.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lvbt-contributions",
"version": "0.4.1",
"version": "0.4.3",
"description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.",
"author": {
"name": "Las Vegans for Better Transit",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lvbt-contributions",
"version": "0.4.1",
"version": "0.4.3",
"description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.",
"author": {
"name": "Las Vegans for Better Transit",
Expand Down
4 changes: 3 additions & 1 deletion .lvbt/web-platform/packages/cli/src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app>]]
lvbt bootstrap [--production [--filter <app>] [--rotate <SECRET>[,<SECRET>...]]]
lvbt preflight [--production [--filter <app>]]
lvbt check [filenames|contract|debt|platform ...] [--staged]
lvbt deploy [--filter <app>] [--dry-run]
Expand All @@ -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
`;

Expand Down
2 changes: 1 addition & 1 deletion .lvbt/web-platform/packages/cli/src/lib/arguments.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

/** `<command> [positional...] [--flag] [--option value]`. Unknown options are an error. */
export function parseArguments(argv) {
Expand Down
7 changes: 7 additions & 0 deletions .lvbt/web-platform/packages/cli/src/lib/operate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
190 changes: 190 additions & 0 deletions .lvbt/web-platform/packages/cli/src/lib/platform/apply-resources.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading