diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..92b355ff5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "virtualcoffee.io", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers-extra/features/mise:1.0.0": {} + }, + "postCreateCommand": "bin/setup --yes", + "forwardPorts": [9000], + "portsAttributes": { + "9000": { + "label": "Site (mise run dev)" + } + }, + "customizations": { + "vscode": { + "extensions": ["hverlin.mise-vscode"] + } + } +} diff --git a/.env.example b/.env.example index 0d2d87b76..5796f309e 100644 --- a/.env.example +++ b/.env.example @@ -53,10 +53,10 @@ URL='http://localhost:9000' # ── Membership database and /admin ──────────────────────────────────────────── # # The database itself needs no configuration: `netlify dev` starts a local -# Postgres and @netlify/database finds it. Run `pnpm db:migrate` once, then -# `pnpm db:seed` for realistic sample applications. +# Postgres and @netlify/database finds it. Run `mise run db:migrate` once, then +# `mise run db:seed` for realistic sample applications. # -# Without Slack credentials, run `pnpm db:seed` and pick a test user from the +# Without Slack credentials, run `mise run db:seed` and pick a test user from the # "Auth DevTools" panel on /admin/sign-in (or /invites/sign-in). Local only: # deploy previews sign in with real Slack OAuth through production's callback # (docs/adr/0007). @@ -101,8 +101,11 @@ URL='http://localhost:9000' # GOOGLE_SMTP_USER=joe@virtualcoffee.io # GMAIL_SERVICE_ACCOUNT_KEY='{"type":"service_account","client_id":"...","private_key":"-----BEGIN PRIVATE KEY-----\n...","client_email":"..."}' # -# Or point local dev at a locally running SMTP sink instead — e.g. Mailpit -# (https://mailpit.axllent.org/, SMTP on :1025, web UI on :8025). No +# Or point local dev at a locally running SMTP sink instead. `mise run dev +# --mail` does this for you: it reuses or starts one shared Mailpit inbox for +# every checkout at http://localhost:8025 (installed on first use) and sets +# SMTP_HOST and SMTP_PORT=1025, which win over this file. For your own sink, e.g. Mailpit (https://mailpit.axllent.org/, +# SMTP on :1025, web UI on :8025), set them here. No # credentials needed: GOOGLE_SMTP_USER/GMAIL_SERVICE_ACCOUNT_KEY are not read # when this is set. Mail is addressed exactly as production would address it # — nothing leaves the machine. Only a checkout honours it; a deploy ignores @@ -159,31 +162,3 @@ URL='http://localhost:9000' # # GITHUB_APP_CLIENT_ID= # GITHUB_APP_PRIVATE_KEY= -# -# One-off Airtable imports and snapshots. Read keys, one per base. The runbook -# for all of these scripts is scripts/airtable/README.md — note that these scripts -# do not read this file, so the key goes on the command line. -# -# Membership base (`scripts/airtable/importMembership.ts`). -# -# MEMBERSHIP_AIRTABLE_API_KEY=token -# -# Public Data and Monthly Challenges bases -# (`scripts/airtable/snapshotChallenges.ts`). The challenge pages read committed -# JSON, so nothing needs this at runtime — it is only here so the snapshot can -# be regenerated. -# -# PUBLIC_AIRTABLE_API_KEY=token -# -# Form Submissions base (`scripts/airtable/importSubmissions.ts`). -# -# FORMS_AIRTABLE_API_KEY=token -# -# That import also stores four historical CoC attachments in Netlify Blobs. -# Locally there is nothing to set: scripts/with-local-netlify.ts supplies a -# store. These two would point the files at the *production* store instead, -# which only pairs with a production database — and the wrapper refuses that -# combination, so leave them unset. -# -# NETLIFY_SITE_ID= -# NETLIFY_AUTH_TOKEN= diff --git a/.gitattributes b/.gitattributes index d1a4b369c..9055c0be2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,3 +16,6 @@ drizzle/*/snapshot.json linguist-generated src/data/monthlyChallenges/data/*.json linguist-generated src/data/podcast/episodes.json linguist-generated pnpm-lock.yaml linguist-generated + +# bin/mise is generated by `mise generate install-script` (docs/adr/0020). +bin/mise linguist-vendored diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0954104d5..90efaa515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,13 @@ concurrency: permissions: contents: read +env: + # Empty skips mise.dev.toml (docs/adr/0020): CI installs and runs only what + # mise.toml pins. `interleave` keeps a task's own output unprefixed, which + # the problem matchers and the sed below rely on. + MISE_ENV: '' + MISE_TASK_OUTPUT: interleave + jobs: format: name: format @@ -44,14 +51,10 @@ jobs: # The fix is committed through the API, so git itself never pushes. persist-credentials: false - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - - name: Set up Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: .nvmrc - cache: pnpm + # Node (.nvmrc) and pnpm as pinned in mise.toml. The action + # installs with --locked because mise.lock is committed. + - name: Set up mise + uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -61,7 +64,7 @@ jobs: run: | # Written outside the workspace: anything left in it would be swept # up by the auto-commit below. - if pnpm exec prettier --check --ignore-unknown . > "$RUNNER_TEMP/prettier-output.txt" 2>&1; then + if mise run -q format --check > "$RUNNER_TEMP/prettier-output.txt" 2>&1; then echo "needs_fix=false" >> "$GITHUB_OUTPUT" else echo "needs_fix=true" >> "$GITHUB_OUTPUT" @@ -70,7 +73,7 @@ jobs: - name: Apply formatting if: steps.check.outputs.needs_fix == 'true' && env.CAN_AUTOFIX == 'true' - run: pnpm exec prettier --write --ignore-unknown . + run: mise run -q format # Committed through the GraphQL API rather than the git CLI, because # GitHub signs API-created commits: they show as Verified and are @@ -133,7 +136,7 @@ jobs: grep '^\[warn\] ' "$RUNNER_TEMP/prettier-output.txt" | grep -v 'Code style issues' | sed 's/^\[warn\] //' || true echo '```' echo - echo 'Run `pnpm format` locally and push the result.' + echo 'Run `mise run format` locally and push the result.' } >> "$GITHUB_STEP_SUMMARY" exit 1 @@ -146,33 +149,25 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - - name: Set up Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: .nvmrc - cache: pnpm + # Node (.nvmrc) and pnpm as pinned in mise.toml. The action + # installs with --locked because mise.lock is committed. + - name: Set up mise + uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 - name: Install dependencies run: pnpm install --frozen-lockfile - # src/data/members/{core,members}.ts and src/data/undrawAspectRatios.ts - # are gitignored codegen that the app imports through the @/* alias. Only - # `prebuild` generates them, so lint/typecheck have to do it themselves. - - name: Generate codegen files - run: pnpm codegen - - name: Register ESLint problem matcher run: echo "::add-matcher::.github/problem-matchers/eslint-stylish.json" - name: Lint + # The task depends on codegen: the gitignored barrels the app imports + # through the @/* alias have to exist first. # ESLint's stylish formatter prints absolute paths, which stop # annotations anchoring to the diff; strip the workspace prefix. run: | set -o pipefail - pnpm lint | sed "s|^${GITHUB_WORKSPACE}/||" + mise run -q lint | sed "s|^${GITHUB_WORKSPACE}/||" typecheck: name: typecheck @@ -183,26 +178,20 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - - name: Set up Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: .nvmrc - cache: pnpm + # Node (.nvmrc) and pnpm as pinned in mise.toml. The action + # installs with --locked because mise.lock is committed. + - name: Set up mise + uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Generate codegen files - run: pnpm codegen - - name: Register tsc problem matcher run: echo "::add-matcher::.github/problem-matchers/tsc.json" + # Depends on codegen, like every task below. - name: Typecheck - run: pnpm typecheck + run: mise run -q typecheck test: name: test @@ -217,27 +206,18 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - - name: Set up Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: .nvmrc - cache: pnpm + # Node (.nvmrc) and pnpm as pinned in mise.toml. The action + # installs with --locked because mise.lock is committed. + - name: Set up mise + uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 - name: Install dependencies run: pnpm install --frozen-lockfile - # Same reason as lint/typecheck: a test is free to import a module that - # reaches the gitignored codegen through @/*. - - name: Generate codegen files - run: pnpm codegen - # Vitest switches to its github-actions reporter on its own here, so # failures annotate the diff without a problem matcher. - name: Test - run: pnpm test:coverage + run: mise run -q test --coverage # Coverage totals in the job summary, plus a comment on the PR listing # the changed files' coverage. A fork PR's token can't write a comment, @@ -259,24 +239,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - - name: Set up Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: .nvmrc - cache: pnpm + # Node (.nvmrc) and pnpm as pinned in mise.toml. The action + # installs with --locked because mise.lock is committed. + - name: Set up mise + uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 - name: Install dependencies run: pnpm install --frozen-lockfile - # Same reason as lint/typecheck: the codegen barrels are gitignored, and - # without them knip reports the imports as unresolved. - - name: Generate codegen files - run: pnpm codegen - # The github-actions reporter emits workflow commands, so findings land # as inline annotations without a problem matcher. - name: Knip - run: pnpm knip --reporter github-actions + run: mise run -q knip -- --reporter github-actions diff --git a/.github/workflows/refresh-bot-list.yml b/.github/workflows/refresh-bot-list.yml index eb54e1e01..e748b8dcb 100644 --- a/.github/workflows/refresh-bot-list.yml +++ b/.github/workflows/refresh-bot-list.yml @@ -26,6 +26,12 @@ on: permissions: contents: read +env: + # Empty skips mise.dev.toml (docs/adr/0020). `interleave` keeps a task's + # output unprefixed, so the summary below holds only the generator's words. + MISE_ENV: '' + MISE_TASK_OUTPUT: interleave + jobs: refresh: name: Refresh bot list @@ -57,14 +63,8 @@ jobs: # over git — the commit goes through the API — so it is never needed. persist-credentials: false - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - - name: Set up Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: .nvmrc - cache: pnpm + - name: Set up mise + uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -73,8 +73,8 @@ jobs: id: generate run: | set -o pipefail - pnpm build-bot-list | tee "$RUNNER_TEMP/summary.txt" - pnpm format > /dev/null + mise run -q bots:build | tee "$RUNNER_TEMP/summary.txt" + mise run -q format > /dev/null if git diff --quiet -- src/data/bots.ts; then echo "changed=false" >> "$GITHUB_OUTPUT" else @@ -83,7 +83,7 @@ jobs: - name: Verify matching still holds if: steps.generate.outputs.changed == 'true' - run: pnpm check-bot-matching + run: mise run -q bots:check # Committed through the GraphQL API rather than the git CLI, for the same # reasons as the format job in ci.yml: GitHub signs API-created commits, @@ -155,7 +155,7 @@ jobs: note='Follows a bump of the pin in `.botlist-version.json`.' else title='chore(bots): regenerate the drifted bot list' - note='**This is drift, not an upstream bump.** The pin in `.botlist-version.json` has not changed, so `src/data/bots.ts` was out of step with `src/data/botOverrides.ts` — someone changed policy without running `pnpm build-bot-list`.' + note='**This is drift, not an upstream bump.** The pin in `.botlist-version.json` has not changed, so `src/data/bots.ts` was out of step with `src/data/botOverrides.ts` — someone changed policy without running `mise run bots:build`.' echo "::error::src/data/bots.ts is out of step with botOverrides.ts at the pinned version" fi @@ -163,7 +163,7 @@ jobs: # it is third-party text. Write it to a file rather than interpolating # it into a command line. { - echo 'Regenerated by `pnpm build-bot-list` from [ai-robots-txt/ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt).' + echo 'Regenerated by `mise run bots:build` from [ai-robots-txt/ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt).' echo echo "$note" echo diff --git a/.gitignore b/.gitignore index ef3726c89..f969f29eb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ node_modules # Local Netlify folder .netlify +# Repo-local mise (bin/mise): tools, cache, state +.mise/ + # testing /coverage @@ -24,18 +27,19 @@ yarn-error.log* next-env.d.ts /deno.lock -# generated member barrels (pnpm build-member-files) +# generated member barrels (mise run codegen:members) /src/data/members/* !/src/data/members/index.ts !/src/data/members/index.test.ts -# generated Undraw aspect ratios (pnpm build-undraw-ratios) +# generated Undraw aspect ratios (mise run codegen:undraw) /src/data/undrawAspectRatios.ts -# generated resource URLs for typedRoutes (pnpm build-resource-paths) +# generated resource URLs for typedRoutes (mise run codegen:resources) /src/data/resourcePaths.ts -# The reviewed Airtable -> Slack volunteer mapping. A working artefact of one -# migration that pairs real names with Slack member ids; the site never reads it. -# See scripts/airtable/README.md. +# The reviewed Airtable -> Slack volunteer mapping from the finished migration +# pairs real names with Slack member ids. The importer is gone, but local copies +# may remain; keep them out of git. scripts/airtable/volunteerSlackMapping.json + diff --git a/.greptile/config.json b/.greptile/config.json index dae838919..82af8634e 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -68,7 +68,7 @@ }, { "id": "bot-list-generated", - "rule": "src/data/bots.ts is generated from src/data/botOverrides.ts by pnpm build-bot-list and checked in; never hand-edit it.", + "rule": "src/data/bots.ts is generated from src/data/botOverrides.ts by mise run bots:build and checked in; never hand-edit it.", "scope": [ "src/data/bots.ts", "src/data/botOverrides.ts", diff --git a/.greptile/files.json b/.greptile/files.json index f2d3507cd..841655cd8 100644 --- a/.greptile/files.json +++ b/.greptile/files.json @@ -84,7 +84,7 @@ { "path": "docs/adr/0012-reviewed-airtable-volunteer-mapping.md", "description": "How Airtable volunteers were matched to Slack accounts", - "scope": ["scripts/airtable/**", "src/lib/volunteers/**"] + "scope": ["src/lib/volunteers/**"] }, { "path": "docs/adr/0013-outbound-mail-outside-production.md", @@ -145,6 +145,24 @@ "eslint.config.mjs" ] }, + { + "path": "docs/adr/0020-mise-is-the-toolchain.md", + "description": "mise as the required toolchain: the base/dev config split, Netlify's older mise, bin/setup, per-worktree dev ports, WSL or dev container for Windows", + "scope": [ + "mise.toml", + "mise.dev.toml", + ".miserc.toml", + "mise.lock", + "mise.dev.lock", + ".devcontainer/**", + "netlify.toml", + "bin/**", + "package.json", + ".nvmrc", + ".github/workflows/**", + "src/test/toolchain.test.ts" + ] + }, { "path": "docs/adr/0015-error-monitoring-with-sentry.md", "description": "Error monitoring with Sentry: what is collected, what is kept out, and which handled failures are reported", diff --git a/.miserc.toml b/.miserc.toml new file mode 100644 index 000000000..a586958a8 --- /dev/null +++ b/.miserc.toml @@ -0,0 +1,3 @@ +# Loads mise.dev.toml for local work (docs/adr/0020). CI and Netlify override it +# with `MISE_ENV=""` so they read mise.toml alone. +env = ["dev"] diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 977efe00f..000000000 --- a/.tool-versions +++ /dev/null @@ -1,2 +0,0 @@ -nodejs 24.21.0 -pnpm 12.11.2 diff --git a/CLAUDE.md b/CLAUDE.md index a23ee81af..ca61d89ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,19 +32,19 @@ virtualcoffee.io is a Next.js App Router site deployed on Netlify. Content is ch ## Commands -Scripts are in `package.json`; `pnpm` is enforced. What no script name tells you: +Every command is a `mise run ` (`./bin/mise run ` when mise is not installed); CI and Netlify call the same tasks, and `package.json` keeps only the `dev` and `test` shims — `docs/adr/0020`. `mise.toml` is what CI and Netlify read; local-only tasks (dev, db, email, setup) and the daemons are in `mise.dev.toml`, loaded by `.miserc.toml`. Before starting, restarting or requesting the dev server, read `docs/agents/dev-server.md`: a worktree's port is not :9000. What no task name tells you: -- `pnpm dev` is `netlify dev` in front of Next (site on :9000). `pnpm dev:tunnel` sets `NETLIFY_TUNNEL=1`, which gates the tunnel-only settings in `next.config.mjs`. -- `pnpm codegen` regenerates the gitignored generated files (see [Generated files](#generated-files)) and deliberately excludes the checked-in bot list. -- `pnpm typecheck` runs `next typegen` first because the gitignored `next-env.d.ts` is what declares image imports; a clean checkout fails on any `*.png` without it. -- `pnpm db:migrate` needs `netlify dev` running. Deploys apply migrations in `netlify.toml`'s build command. +- `mise run dev` is `netlify dev` in front of Next (site on `$WEB_PORT`, else :9000). +- `mise run codegen` regenerates the gitignored generated files (see [Generated files](#generated-files)) and deliberately excludes the checked-in bot list; the check tasks depend on it. +- `mise run typecheck` runs `next typegen` first because the gitignored `next-env.d.ts` is what declares image imports; a clean checkout fails on any `*.png` without it. +- `mise run db:migrate` needs `netlify dev` running in the same checkout. Netlify runs `mise run build:deploy` (build, then migrations only if the build succeeded); keep `mise.lock` at lockfile v2 and `MISE_ENV=""` in `netlify.toml`, because its image's mise is older. - There is no husky/lint-staged hook; CI (`.github/workflows/ci.yml`) auto-commits Prettier fixes on same-repo branches. -- CI does not build. Run `pnpm build` locally when a change can only fail at prerender: MDX frontmatter, `generateStaticParams`, or a component pages render at build time. -- `pnpm knip` (config in `knip.ts`) finds unused files, exports and dependencies; run `pnpm codegen` first. Content directories are entries, not ignores, so their own imports are still checked. +- CI does not build. Run `mise run build` locally when a change can only fail at prerender: MDX frontmatter, `generateStaticParams`, or a component pages render at build time. +- `mise run knip` (config in `knip.ts`) finds unused files, exports and dependencies. Content directories are entries, not ignores, so their own imports are still checked. - CodeQL is advanced-setup: `.github/workflows/codeql.yml` is the whole config, and the repository's default-setup toggle stays off. -- `typescript` is `@typescript/typescript6` (for typescript-eslint and `next build`) and `@typescript/native` is `typescript@7` (the `tsc` that `pnpm typecheck` runs), so the build and CI check with different compilers. Keep both until typescript-eslint supports TypeScript 7. +- `typescript` is `@typescript/typescript6` (for typescript-eslint and `next build`) and `@typescript/native` is `typescript@7` (the `tsc` that `mise run typecheck` runs), so the build and CI check with different compilers. Keep both until typescript-eslint supports TypeScript 7. -Before finishing a change: `pnpm codegen && pnpm typecheck && pnpm lint && pnpm test && pnpm knip`. +Before finishing a change: `mise run check` (codegen, typecheck, lint, test, knip). When writing or debugging a test, read `docs/testing.md` first. @@ -68,8 +68,8 @@ Podcast episodes are a checked-in JSON snapshot copied from the `vc-data` repo ( ### Generated files -- `src/data/members/{core,members}.ts`, `src/data/undrawAspectRatios.ts` and `src/data/resourcePaths.ts` are gitignored; `pnpm codegen` writes them. Run `pnpm build-member-files` after adding a member, `pnpm build-undraw-ratios` after adding an SVG to `public/assets/svg` (`UndrawIllustration` needs concrete dimensions for `next/image`) and `pnpm build-resource-paths` after adding or moving a file in `src/content/resources` (`ResourcePath` types `resourceHref()`). -- `src/data/bots.ts` is generated but **checked in**, so a GitHub outage cannot block a deploy and every change to who is blocked is a reviewable diff. Edit the policy in `src/data/botOverrides.ts` and regenerate with `pnpm build-bot-list`. When changing who is blocked, read `docs/bot-list.md` first. +- `src/data/members/{core,members}.ts`, `src/data/undrawAspectRatios.ts` and `src/data/resourcePaths.ts` are gitignored; `mise run codegen` writes them. Run `mise run codegen:members` after adding a member, `mise run codegen:undraw` after adding an SVG to `public/assets/svg` (`UndrawIllustration` needs concrete dimensions for `next/image`) and `mise run codegen:resources` after adding or moving a file in `src/content/resources` (`ResourcePath` types `resourceHref()`). +- `src/data/bots.ts` is generated but **checked in**, so a GitHub outage cannot block a deploy and every change to who is blocked is a reviewable diff. Edit the policy in `src/data/botOverrides.ts` and regenerate with `mise run bots:build`. When changing who is blocked, read `docs/bot-list.md` first. ### Members pipeline diff --git a/CODEOWNERS b/CODEOWNERS index db6f7a51e..b8b71242a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -34,6 +34,6 @@ src/app/monthlychallenges/ @Virtual-Coffee/content-team @Virtual-Coffee/sup src/content/monthlychallenges/ @Virtual-Coffee/content-team @Virtual-Coffee/super-contributors # Editorial assets. public/assets/svg/ is deliberately excluded: it feeds the -# `pnpm build-undraw-ratios` codegen and is engineering-owned. +# `mise run codegen:undraw` codegen and is engineering-owned. public/assets/images/ @Virtual-Coffee/content-team @Virtual-Coffee/super-contributors public/assets/pdfs/ @Virtual-Coffee/content-team @Virtual-Coffee/super-contributors diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 431cd32c9..4478ea6a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,7 @@ You can refer to our resource on [how to write an issue](https://virtualcoffee.i > ⚠️ **Heads up!** If you'd like to work on issues, please make sure you **create a new branch and work in this branch** to avoid pushing your changes directly into the `main` branch. -- Follow the steps for local development [in our README](README.md#local-development-setup). +- Follow the steps for local development [in our README](README.md#local-development-setup): clone, then run `bin/setup`. Native Windows isn't supported; use WSL, or open the repo in a dev container (Codespaces or VS Code; see the README). Commands are written `mise run `; if setup used `./bin/mise`, write `./bin/mise run `. - Create a new branch by typing this command on the terminal: ```bash @@ -130,7 +130,7 @@ When you open a pull request, three checks run automatically: **If the `format` check fails**, don't worry — it's the easiest one to fix. Because your pull request comes from a fork, our automation can't push to your branch, so you fix it yourself in two commands: ```shell -pnpm format +mise run format git commit -am "style: apply prettier formatting" ``` diff --git a/README.md b/README.md index 99ab362da..89d2904a1 100644 --- a/README.md +++ b/README.md @@ -55,66 +55,37 @@ If you have just run `git clone ...` , the next step would be to move into the c cd virtualcoffee.io ``` -### 3. Install dependencies - -This repo requires `node` and `pnpm` to get started. The [Netlify CLI](https://www.netlify.com/products/dev/) is installed as a project dependency, so you don't need to install it globally. - -#### Installing `node`: - -The best way to install `node` is to [download the installer](https://nodejs.org/en/) from their site. This repo requires node version 24.20 (see `.nvmrc`). - -If you already have a different version of `node` installed, but don't want to update globally, you can install [a package called `nvm`](https://github.com/nvm-sh/nvm), which will allow you to easily switch `node` versions. Once you have `nvm` installed (or if you already have it installed), you can run `nvm use` in the main directory and it will install the proper version of `node`. - -#### Installing `pnpm`: - -`pnpm` is a package manager that is used to install the rest of our dependencies. - -Read more about `pnpm` [on their docs site](https://pnpm.io/motivation). - -The best way to install `pnpm` for this project is by using [Corepack](https://nodejs.org/api/corepack.html), a new feature bundled with Node. - -Install pnpm via corepack with the following commands: - -```sh -corepack enable -corepack prepare -``` - -#### Setting up your .env - -Use the following command to create a local `.env` file. Then open the new file (`.env`) and adjust any settings that are needed. +### 3. Run the setup script ```shell -cp .env.example .env +bin/setup ``` -#### Installing package dependencies +This is the whole setup. It uses [mise](https://mise.jdx.dev) to install the pinned Node and pnpm; if you don't have mise, it offers to install it, or to use `./bin/mise`, a copy that lives inside this folder. Then it installs the dependencies and creates your `.env` (it never overwrites an existing one). It also offers to set up the local database; you only need that for `/join`, `/admin` and the forms, so skip it for content and page changes. Run it again any time: every step that is already done is skipped. -Once you have `node` and `pnpm` installed, you're ready to install the local dependencies! Run the following command: +**Windows:** native Windows isn't supported. Use [WSL](https://learn.microsoft.com/windows/wsl/install), clone the repo inside the Linux filesystem and run `bin/setup` there. Or skip installing anything: open the repo in a [dev container](https://containers.dev) (GitHub Codespaces, or VS Code's Dev Containers extension); `.devcontainer/devcontainer.json` runs `bin/setup --yes` for you, and `mise run dev` serves on the forwarded port 9000. -```shell -pnpm install -``` +Every command below is a `mise run `. **If you let the script use `./bin/mise`, write `./bin/mise run ` instead**, everywhere. `mise tasks` lists everything. -At this point you're ready to roll! Run the following command to get rolling! +When it finishes, start the site: ```shell -pnpm dev +mise run dev ``` -Read more about what `pnpm dev` does in the following section. +Read more about what `mise run dev` does in the following section. ## Commands -The following commands are available for your use. Most of the time you'll only ever need `pnpm dev`. +The following commands are available for your use. Most of the time you'll only ever need `mise run dev`. -### `pnpm dev` +### `mise run dev` ```shell -pnpm dev +mise run dev ``` -This is the only command you need to do normal local development. +This is the only command you need to do normal local development. (`pnpm dev` is a shortcut for it.) Starts a local server and watches your source files for changes. Use this to preview local development. @@ -124,39 +95,31 @@ You should see something like 'Server now ready on http://localhost:9000' below, Use `ctrl-c` to quit the server when you're done. -### `pnpm dev:tunnel` +### `mise run build` ```shell -pnpm dev:tunnel +mise run build ``` -The same as `pnpm dev`, but also served on a public `https://--virtual-coffee-io.netlify.live` URL (printed on start) through [`netlify dev --live`](https://docs.netlify.com/cli/local-development/#share-a-live-development-server). Use it to check the site on a phone or to receive a webhook. It sets `NETLIFY_TUNNEL=1`, which switches on the tunnel-only settings in `next.config.mjs`; plain `pnpm dev` leaves them off because they break hot reloading on localhost. +Builds a production-ready version of the site. Netlify runs `mise run build:deploy`, which is this plus the database migrations. -### `pnpm build` +### `mise run format` ```shell -pnpm build -``` - -Builds a production-ready version of the site. This is what Netlify uses to build our site. - -### `pnpm format` - -```shell -pnpm format +mise run format ``` Runs [Prettier](https://prettier.io/) on all of our files. -If your branch lives in this repository, CI formats it for you and pushes the fix back to your pull request. **If you are working from a fork** — which is the normal path for most contributors — CI cannot push to your branch, so the `format` check will fail and list the offending files. Run `pnpm format` locally and push the result. +If your branch lives in this repository, CI formats it for you and pushes the fix back to your pull request. **If you are working from a fork** — which is the normal path for most contributors — CI cannot push to your branch, so the `format` check will fail and list the offending files. Run `mise run format` locally and push the result. -### `pnpm lint` +### `mise run lint` ```shell -pnpm lint +mise run lint ``` -Runs [ESLint](https://eslint.org/) on all of our files, so you can check for errors or warnings. CI runs this on every pull request and fails on errors (warnings are allowed for now). Run `pnpm build-member-files` first if you haven't built recently. +Runs [ESLint](https://eslint.org/) on all of our files, so you can check for errors or warnings. CI runs this on every pull request and fails on errors (warnings are allowed for now). It regenerates the gitignored files first when their sources changed. Alongside the standard Next.js rules, `eslint-rules/` holds a few rules of our own for the things TypeScript can't check: @@ -166,34 +129,46 @@ Alongside the standard Next.js rules, `eslint-rules/` holds a few rules of our o - **`vc/no-raw-dangerously-set-inner-html`** — render HTML through `DisplayHtml`, and sanitize it first. - **`vc/page-requires-metadata`** — every page exports `metadata` or `generateMetadata`. -Most of these fix themselves with `pnpm lint --fix`. +Most of these fix themselves with `mise run lint --fix`. -### `pnpm typecheck` +### `mise run typecheck` ```shell -pnpm typecheck +mise run typecheck ``` -Runs `next typegen` (which regenerates the gitignored `next-env.d.ts` and the route types) and then the TypeScript compiler (`tsc --noEmit`). CI runs this on every pull request. Run `pnpm build-member-files` first if you haven't built recently. +Runs `next typegen` (which regenerates the gitignored `next-env.d.ts` and the route types) and then the TypeScript compiler (`tsc --noEmit`). CI runs this on every pull request. It regenerates the gitignored files first when their sources changed. -### `pnpm build-member-files` +### `mise run codegen` ```shell -pnpm build-member-files +mise run codegen ``` -Regenerates `src/data/members/core.ts` and `src/data/members/members.ts` from the files in `src/content/members/`. These generated files are gitignored. `pnpm dev` and `pnpm build` run this for you, and the dev watcher re-runs it whenever a member file changes. +Regenerates the gitignored files the app imports: `src/data/members/core.ts` and `members.ts` from the files in `src/content/members/`, the Undraw aspect ratios, and the typed `/resources` links. `mise run dev`, `build`, `lint`, `typecheck` and `test` run this for you, and the dev watcher re-runs it whenever a source changes. `mise run codegen:members` (or `:undraw`, `:resources`) runs one part. -### `pnpm build-bot-list` +### `mise run check` ```shell -pnpm build-bot-list +mise run check +``` + +Codegen, typecheck, lint, test and knip together, which is everything CI checks. Run it before you push. + +### `mise run bots:build` + +```shell +mise run bots:build ``` Regenerates `src/data/bots.ts` — the list of crawlers the site blocks — from the [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) release pinned in `.botlist-version.json`. It feeds both `/robots.txt` and the Netlify edge function. Unlike the other generated files this one **is** checked in, so nothing runs it for you and you only need it if you are changing the list. Edit `src/data/botOverrides.ts` — which holds every decision about who gets blocked or allowed — then run this and commit the result. Renovate keeps the pinned version current, and a scheduled workflow opens a PR when the list changes. +### Maintainers: several worktrees + +Working in several git worktrees at once? Each one gets its own port, in `$WEB_PORT` inside `mise run` and `mise x`. `mise daemons register` once, then `mise daemons start web`, runs the site in the background there; `mise daemons urls` shows where. + ## Loading data A lot of the data loaded on the site is from APIs that require private keys or tokens. Unfortunately we can't publish these or distribute them too widely. diff --git a/bin/mise b/bin/mise new file mode 100755 index 000000000..780e5c94b --- /dev/null +++ b/bin/mise @@ -0,0 +1,457 @@ +#!/usr/bin/env bash +# Generated: `mise generate install-script --write bin/mise --localize` +# Runs a repo-local mise (data in .mise/) for contributors without a global +# install — docs/adr/0020. Regenerate to bump the mise version; do not hand-edit. +set -eu + +__mise_bootstrap() { + local project_dir=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && cd .. && pwd ) + local localized_dir="$project_dir"/.mise + export MISE_DATA_DIR="$localized_dir" + export MISE_CONFIG_DIR="$localized_dir" + export MISE_CACHE_DIR="$localized_dir/cache" + export MISE_STATE_DIR="$localized_dir/state" + local mise_version="${MISE_VERSION:-2026.10.3}" + mise_version="${mise_version#v}" + export MISE_INSTALL_PATH="${MISE_INSTALL_PATH:-$localized_dir/mise-$mise_version}" + export MISE_TRUSTED_CONFIG_PATHS="$project_dir${MISE_TRUSTED_CONFIG_PATHS:+:$MISE_TRUSTED_CONFIG_PATHS}" + export MISE_IGNORED_CONFIG_PATHS="$HOME/.config/mise${MISE_IGNORED_CONFIG_PATHS:+:$MISE_IGNORED_CONFIG_PATHS}" + install() { + local initial_working_dir="$PWD" + #!/bin/sh + set -eu + + #region logging setup + if [ "${MISE_DEBUG-}" = "true" ] || [ "${MISE_DEBUG-}" = "1" ]; then + debug() { + echo "$@" >&2 + } + else + debug() { + : + } + fi + + if [ "${MISE_QUIET-}" = "1" ] || [ "${MISE_QUIET-}" = "true" ]; then + info() { + : + } + else + info() { + echo "$@" >&2 + } + fi + + warn() { + printf '%s\n' "$*" >&2 + } + + error() { + echo "$@" >&2 + exit 1 + } + + unsupported_arch() { + arch="$1" + warn "unsupported architecture: $arch" + warn "" + warn "mise does not provide prebuilt binaries for this platform." + warn "If Rust/Cargo is available, install from source with:" + warn " cargo install --locked mise" + exit 1 + } + #endregion + + #region environment setup + get_os() { + os="$(uname -s)" + if [ "$os" = Darwin ]; then + echo "macos" + elif [ "$os" = Linux ]; then + echo "linux" + else + error "unsupported OS: $os" + fi + } + + get_arch() { + musl="" + if type ldd >/dev/null 2>/dev/null; then + if [ "${MISE_INSTALL_MUSL-}" = "1" ] || [ "${MISE_INSTALL_MUSL-}" = "true" ]; then + musl="-musl" + elif [ "$(uname -o)" = "Android" ]; then + # Android (Termux) always uses musl + musl="-musl" + else + libc=$(ldd /bin/ls | grep 'musl' | head -1 | cut -d ' ' -f1) + if [ -n "$libc" ]; then + musl="-musl" + fi + fi + fi + arch="$(uname -m)" + if [ "$arch" = x86_64 ]; then + echo "x64$musl" + elif [ "$arch" = aarch64 ] || [ "$arch" = arm64 ]; then + echo "arm64$musl" + elif [ "$arch" = armv7l ]; then + echo "armv7$musl" + else + unsupported_arch "$arch" + fi + } + + get_ext() { + if [ -n "${MISE_INSTALL_EXT:-}" ]; then + echo "$MISE_INSTALL_EXT" + elif [ -n "${MISE_VERSION:-}" ] && echo "$MISE_VERSION" | grep -q '^v2024'; then + # 2024 versions don't have zstd tarballs + echo "tar.gz" + elif tar_supports_zstd; then + echo "tar.zst" + else + echo "tar.gz" + fi + } + + tar_supports_zstd() { + if ! command -v zstd >/dev/null 2>&1; then + false + # tar is bsdtar + elif tar --version | grep -q 'bsdtar'; then + true + # busybox tar reports a "1.3x" version that matches the GNU check below, but it + # cannot decompress .tar.zst itself. Detect it so we fall back to the zstd pipe + # (or a .tar.gz download) instead of running `tar -xf` on a zstd tarball. + elif tar --version 2>&1 | grep -qi 'busybox'; then + false + # tar version is >= 1.31 + elif tar --version | grep -q '1\.\(3[1-9]\|[4-9][0-9]\)'; then + true + else + false + fi + } + + shasum_bin() { + if command -v shasum >/dev/null 2>&1; then + echo "shasum" + elif command -v sha256sum >/dev/null 2>&1; then + echo "sha256sum" + else + error "mise install requires shasum or sha256sum but neither is installed. Aborting." + fi + } + + get_checksum() { + version=$1 + os=$2 + arch=$3 + ext=$4 + url="https://github.com/jdx/mise/releases/download/v${version}/SHASUMS256.txt" + current_version="v2026.10.3" + current_version="${current_version#v}" + + # For current version use static checksum otherwise + # use checksum from releases + if [ "$version" = "$current_version" ]; then + checksum_linux_x86_64="04147c68e946902f5226dfdcd54d19907aed3cf54d95b2f27d2b9c778bb26f9e ./mise-v2026.10.3-linux-x64.tar.gz" + checksum_linux_x86_64_musl="23c45c76567f8f1e4cfc40efff3f19e3352d3745e6c25e74891102765d1cdc3c ./mise-v2026.10.3-linux-x64-musl.tar.gz" + checksum_linux_arm64="e79866e32624b346f6854d93ca8a24294516cd7c0b48ce0e80af508fb7c3d8b8 ./mise-v2026.10.3-linux-arm64.tar.gz" + checksum_linux_arm64_musl="0af3379b7a8060b047416576a746078daee3c9d2bf57fa8f6ba07d3cdf5f47aa ./mise-v2026.10.3-linux-arm64-musl.tar.gz" + checksum_linux_armv7="eff3a840b2f2aeb78d35f58092cc2a385b26740c5df99f109aec9ff64689cd7d ./mise-v2026.10.3-linux-armv7.tar.gz" + checksum_linux_armv7_musl="8448b5f0e1733944eaceef9e1588d33e88ed157b1edc13e1363fd7fd6fb01f56 ./mise-v2026.10.3-linux-armv7-musl.tar.gz" + checksum_macos_x86_64="791b92b446729c53e6501acd2b84ea207f541659ca9d0480c9c70c291919a321 ./mise-v2026.10.3-macos-x64.tar.gz" + checksum_macos_arm64="28ecc8640b0a28dab52817766f37fecfd898f1dff82e03f36fcb072e971f9246 ./mise-v2026.10.3-macos-arm64.tar.gz" + checksum_linux_x86_64_zstd="447fe12a8b50f53867aacdcc16bcb6e2cb38044839ef2b8041efcaec276aae6d ./mise-v2026.10.3-linux-x64.tar.zst" + checksum_linux_x86_64_musl_zstd="3c88f2c81ad74535eb23e8df5d50ca830e86515b045333462b69525aa64285c8 ./mise-v2026.10.3-linux-x64-musl.tar.zst" + checksum_linux_arm64_zstd="538209b47a064aef24a968e88c4cee30f75fffc1c4e1d4c6313a99124b0e9433 ./mise-v2026.10.3-linux-arm64.tar.zst" + checksum_linux_arm64_musl_zstd="c7c87978de08779df98a5395f5594ad07b6a323fb80a64a38b0365c6968abaf4 ./mise-v2026.10.3-linux-arm64-musl.tar.zst" + checksum_linux_armv7_zstd="984b06ecc83dc1fe4d8ade2b80af2d571515230018847cdea91a294f4c661904 ./mise-v2026.10.3-linux-armv7.tar.zst" + checksum_linux_armv7_musl_zstd="ba46e211f443f2a74a7e1f1a93f74cfb7de7083dba273690689d7ee508ab0932 ./mise-v2026.10.3-linux-armv7-musl.tar.zst" + checksum_macos_x86_64_zstd="b97c1ba194c8f1779a6f70efb01682aa30899e98c3f52c7a3c8e22a9457e1a0d ./mise-v2026.10.3-macos-x64.tar.zst" + checksum_macos_arm64_zstd="87853e616ef2b6d601c20974a7f13fc9aa17458a6190e0c456fb85b745bb08e8 ./mise-v2026.10.3-macos-arm64.tar.zst" + + # TODO: refactor this, it's a bit messy + if [ "$ext" = "tar.zst" ]; then + if [ "$os" = "linux" ]; then + if [ "$arch" = "x64" ]; then + echo "$checksum_linux_x86_64_zstd" + elif [ "$arch" = "x64-musl" ]; then + echo "$checksum_linux_x86_64_musl_zstd" + elif [ "$arch" = "arm64" ]; then + echo "$checksum_linux_arm64_zstd" + elif [ "$arch" = "arm64-musl" ]; then + echo "$checksum_linux_arm64_musl_zstd" + elif [ "$arch" = "armv7" ]; then + echo "$checksum_linux_armv7_zstd" + elif [ "$arch" = "armv7-musl" ]; then + echo "$checksum_linux_armv7_musl_zstd" + else + warn "no checksum for $os-$arch" + fi + elif [ "$os" = "macos" ]; then + if [ "$arch" = "x64" ]; then + echo "$checksum_macos_x86_64_zstd" + elif [ "$arch" = "arm64" ]; then + echo "$checksum_macos_arm64_zstd" + else + warn "no checksum for $os-$arch" + fi + else + warn "no checksum for $os-$arch" + fi + else + if [ "$os" = "linux" ]; then + if [ "$arch" = "x64" ]; then + echo "$checksum_linux_x86_64" + elif [ "$arch" = "x64-musl" ]; then + echo "$checksum_linux_x86_64_musl" + elif [ "$arch" = "arm64" ]; then + echo "$checksum_linux_arm64" + elif [ "$arch" = "arm64-musl" ]; then + echo "$checksum_linux_arm64_musl" + elif [ "$arch" = "armv7" ]; then + echo "$checksum_linux_armv7" + elif [ "$arch" = "armv7-musl" ]; then + echo "$checksum_linux_armv7_musl" + else + warn "no checksum for $os-$arch" + fi + elif [ "$os" = "macos" ]; then + if [ "$arch" = "x64" ]; then + echo "$checksum_macos_x86_64" + elif [ "$arch" = "arm64" ]; then + echo "$checksum_macos_arm64" + else + warn "no checksum for $os-$arch" + fi + else + warn "no checksum for $os-$arch" + fi + fi + else + if command -v curl >/dev/null 2>&1; then + debug ">" curl -fsSL "$url" + checksums="$(curl --compressed -fsSL "$url")" + else + if command -v wget >/dev/null 2>&1; then + debug ">" wget -qO - "$url" + checksums="$(wget -qO - "$url")" + else + error "mise standalone install specific version requires curl or wget but neither is installed. Aborting." + fi + fi + # TODO: verify with minisign or gpg if available + + checksum="$(echo "$checksums" | grep "$os-$arch.$ext")" + if ! echo "$checksum" | grep -Eq "^([0-9a-f]{32}|[0-9a-f]{64})"; then + warn "no checksum for mise $version and $os-$arch" + else + echo "$checksum" + fi + fi + } + + #endregion + + download_file() { + url="$1" + download_dir="$2" + filename="$(basename "$url")" + file="$download_dir/$filename" + + info "mise: installing mise..." + + if command -v curl >/dev/null 2>&1; then + debug ">" curl -#fLo "$file" "$url" + curl -#fLo "$file" "$url" + else + if command -v wget >/dev/null 2>&1; then + debug ">" wget -qO "$file" "$url" + stderr=$(mktemp) + wget -O "$file" "$url" >"$stderr" 2>&1 || error "wget failed: $(cat "$stderr")" + rm "$stderr" + else + error "mise standalone install requires curl or wget but neither is installed. Aborting." + fi + fi + + echo "$file" + } + + # Prints the version of an installed mise binary (the first field of + # `mise version`, e.g. "2025.6.0"), with any leading "v" stripped. Prints + # nothing if the binary is missing or fails to report a version. + installed_mise_version() { + bin="$1" + if [ -x "$bin" ]; then + installed_version="$("$bin" version 2>/dev/null | head -n1 | cut -d' ' -f1)" + echo "${installed_version#v}" + fi + } + + # Keep bootstrap dependencies to POSIX sh/awk and curl or wget. The native CLI + # additionally supports calendar durations and absolute cutoff dates. + release_age_cutoff() { + awk -v age="$1" -v now="$(date +%s)" 'BEGIN { + if (age !~ /^[0-9]+[smhdw]$/) exit 1 + unit = substr(age, length(age)); value = substr(age, 1, length(age)-1) + seconds = (unit == "s" ? 1 : unit == "m" ? 60 : unit == "h" ? 3600 : unit == "d" ? 86400 : 604800) + printf "%.0f\n", now - value * seconds + }' + } + + select_release() { + awk -v cutoff="$1" ' + NF != 2 || $1 !~ /^v[0-9]+\.[0-9]+\.[0-9]+$/ || $2 !~ /^[0-9]+$/ { invalid = 1; next } + $2 <= cutoff { + split(substr($1, 2), v, ".") + if (!selected || v[1]+0 > year || (v[1]+0 == year && v[2]+0 > month) || (v[1]+0 == year && v[2]+0 == month && v[3]+0 > patch)) { + selected = substr($1, 2); year = v[1]+0; month = v[2]+0; patch = v[3]+0 + } + } + END { if (invalid || !selected) exit 1; print selected } + ' + } + + resolve_release() { + if [ -n "${MISE_VERSION:-}" ]; then + printf '%s\n' "${MISE_VERSION#v}" + return + fi + age="${MISE_SELF_UPDATE_MINIMUM_RELEASE_AGE-${MISE_MINIMUM_RELEASE_AGE-24h}}" + cutoff=$(release_age_cutoff "$age") || error "invalid minimum release age: $age (use an integer with s, m, h, d, or w)" + if command -v curl >/dev/null 2>&1; then + index=$(curl -fsSL https://mise.jdx.dev/releases.tsv) || error "could not fetch mise release index" + elif command -v wget >/dev/null 2>&1; then + index=$(wget -qO- https://mise.jdx.dev/releases.tsv) || error "could not fetch mise release index" + else + error "mise install requires curl or wget" + fi + selected=$(printf '%s\n' "$index" | select_release "$cutoff") || error "no eligible mise release or invalid release index; lower MISE_SELF_UPDATE_MINIMUM_RELEASE_AGE or set MISE_VERSION" + info "mise: selected $selected (minimum release age: $age)" + printf '%s\n' "$selected" + } + + install_mise() { + version="$(resolve_release)" + version="${version#v}" + current_version="v2026.10.3" + current_version="${current_version#v}" + os="${MISE_INSTALL_OS:-$(get_os)}" + arch="${MISE_INSTALL_ARCH:-$(get_arch)}" + ext="${MISE_INSTALL_EXT:-$(get_ext)}" + install_path="${MISE_INSTALL_PATH:-$HOME/.local/bin/mise}" + install_dir="$(dirname "$install_path")" + install_from_github="${MISE_INSTALL_FROM_GITHUB:-}" + + # Keep a newer manually installed release when resolving an unpinned install. + if [ -z "${MISE_VERSION:-}" ] && [ -x "$install_path" ]; then + existing_version="$(installed_mise_version "$install_path")" + [ -n "$existing_version" ] || error "cannot compare installed mise version; set MISE_VERSION explicitly" + if [ -n "$existing_version" ]; then + compare_version="${existing_version%-DEBUG}" + newest=$(printf 'v%s 0\nv%s 0\n' "$compare_version" "$version" | select_release 0) || error "cannot compare installed mise version; set MISE_VERSION explicitly" + if [ "$newest" = "$compare_version" ]; then + info "mise: $install_path is already at $existing_version, keeping installed version" + return 0 + fi + fi + fi + + # Opt-in: skip the download/install if the binary already at the install + # path matches the requested version. Only the install path is checked (not + # the wider PATH) so that skipping never leaves install_path missing. + skip_if_exists="${MISE_INSTALL_SKIP_IF_EXISTS-}" + if [ "$skip_if_exists" = "1" ] || [ "$skip_if_exists" = "true" ]; then + if [ -x "$install_path" ]; then + existing_version="$(installed_mise_version "$install_path")" + if [ -n "$existing_version" ] && [ "$existing_version" = "$version" ]; then + info "mise: $install_path is already at version $version, skipping install" + return 0 + fi + fi + fi + if [ "$version" != "$current_version" ] || [ "$install_from_github" = "1" ] || [ "$install_from_github" = "true" ]; then + tarball_url="https://github.com/jdx/mise/releases/download/v${version}/mise-v${version}-${os}-${arch}.${ext}" + elif [ -n "${MISE_TARBALL_URL-}" ]; then + tarball_url="$MISE_TARBALL_URL" + else + tarball_url="https://mise.jdx.dev/v${version}/mise-v${version}-${os}-${arch}.${ext}" + fi + + download_dir="$(mktemp -d)" + cache_file=$(download_file "$tarball_url" "$download_dir") + debug "mise-setup: tarball=$cache_file" + + debug "validating checksum" + cd "$(dirname "$cache_file")" && get_checksum "$version" "$os" "$arch" "$ext" | "$(shasum_bin)" -c >/dev/null + + # extract tarball + if [ -d "$install_path" ]; then + error "MISE_INSTALL_PATH '$install_path' is a directory. Please set it to a file path, e.g. '$install_path/mise'." + fi + mkdir -p "$install_dir" + rm -f "$install_path" + extract_dir="$(mktemp -d)" + cd "$extract_dir" + if [ "$ext" = "tar.zst" ] && ! tar_supports_zstd; then + zstd -d -c "$cache_file" | tar --no-same-owner -xf - + else + tar --no-same-owner -xf "$cache_file" + fi + if [ "$(id -u)" = "0" ]; then + chown 0:0 mise/bin/mise + chmod 755 mise/bin/mise + fi + mv mise/bin/mise "$install_path" + + # cleanup + cd / # Move out of $extract_dir before removing it + rm -rf "$download_dir" + rm -rf "$extract_dir" + + info "mise: installed successfully to $install_path" + } + + after_finish_help() { + case "${SHELL:-}" in + */zsh) + info "mise: run the following to activate mise in your shell:" + info "echo \"eval \\\"\\\$($install_path activate zsh)\\\"\" >> \"${ZDOTDIR-$HOME}/.zshrc\"" + info "" + info "mise: run \`mise doctor\` to verify this is set up correctly" + ;; + */bash) + info "mise: run the following to activate mise in your shell:" + info "echo \"eval \\\"\\\$($install_path activate bash)\\\"\" >> ~/.bashrc" + info "" + info "mise: run \`mise doctor\` to verify this is set up correctly" + ;; + */fish) + info "mise: run the following to activate mise in your shell:" + info "echo \"$install_path activate fish | source\" >> ~/.config/fish/config.fish" + info "" + info "mise: run \`mise doctor\` to verify this is set up correctly" + ;; + *) + info "mise: run \`$install_path --help\` to get started" + ;; + esac + } + + install_mise + if [ "${MISE_INSTALL_HELP-}" != 0 ]; then + after_finish_help + fi + + cd -- "$initial_working_dir" + } + local MISE_INSTALL_HELP=0 + # The installer resolves its own version and would otherwise write whatever release it picks + # under the pinned name. A prefix assignment is visible to the installer and gone after it. + test -f "$MISE_INSTALL_PATH" || MISE_VERSION="v$mise_version" install +} +__mise_bootstrap +exec -a "$0" "$MISE_INSTALL_PATH" "$@" \ No newline at end of file diff --git a/bin/setup b/bin/setup new file mode 100755 index 000000000..ba84ff803 --- /dev/null +++ b/bin/setup @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# +# First-run setup for virtualcoffee.io: mise, tools, dependencies, .env, and +# (only if you ask) the local database. Safe to re-run: every step checks first +# and skips what is already done. Why mise, and why not native Windows: +# docs/adr/0020. +# +# bin/setup interactive +# bin/setup --yes no prompts: uses an installed mise or ./bin/mise, never +# edits a shell rc, never asks for a token, skips the +# database +# +# Written for bash 3.2 (macOS's) and up. + +set -euo pipefail + +YES=0 +case "${1:-}" in + "") ;; + -y | --yes) YES=1 ;; + -h | --help) + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "usage: bin/setup [--yes]" >&2 + exit 2 + ;; +esac + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +TOTAL_STAGES=6 +STAGE=0 +SERVER_PID="" +SERVER_LOG="" +TODO=() + +if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then + BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0) + BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1) +else + BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED="" +fi + +stage() { + STAGE=$((STAGE + 1)) + printf '\n%s%s> %s/%s %s%s\n' "$BOLD" "$BLUE" "$STAGE" "$TOTAL_STAGES" "$1" "$RESET" +} +say() { printf ' %s\n' "$1"; } +ok() { printf ' %s✓%s %s\n' "$GREEN" "$RESET" "$1"; } +skip() { printf ' %s- %s (already done)%s\n' "$DIM" "$1" "$RESET"; } +note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; } +warn() { printf ' %s! %s%s\n' "$YELLOW" "$1" "$RESET"; } +die() { printf '\n %sx %s%s\n\n' "$RED" "$1" "$RESET" >&2; exit 1; } + +# confirm "question" [y|n]: the default answer is also what --yes gives. +confirm() { + local default="${2:-n}" reply="" + if ((YES)); then [[ "$default" == y ]]; return; fi + if [[ "$default" == y ]]; then printf ' %s? %s [Y/n] ' "$YELLOW" "$1"; else printf ' %s? %s [y/N] ' "$YELLOW" "$1"; fi + printf '%s' "$RESET" + read -r reply || true + if [[ "$default" == y ]]; then [[ ! "$reply" =~ ^[Nn] ]]; else [[ "$reply" =~ ^[Yy] ]]; fi +} + +# Whether KEY has a non-empty, uncommented value in .env. +env_has() { grep -Eq "^${1}=.+" .env 2>/dev/null; } + +kill_tree() { + local child + for child in $(pgrep -P "$1" 2>/dev/null || true); do kill_tree "$child"; done + kill "$1" 2>/dev/null || true +} + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + kill_tree "$SERVER_PID" + SERVER_PID="" + fi +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +printf '\n%s%s virtualcoffee.io setup%s\n' "$BOLD" "$BLUE" "$RESET" +note " Safe to re-run; each step skips what is already done." + +# ── 1. Platform ─────────────────────────────────────────────────────────────── +stage "Platform" +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + die "Native Windows is not supported. Use WSL: install it (https://learn.microsoft.com/windows/wsl/install), clone the repo inside the Linux filesystem, and run bin/setup there. A dev container (Codespaces, VS Code) works too: see .devcontainer/devcontainer.json." + ;; +esac +command -v git >/dev/null 2>&1 || die "git is required: https://git-scm.com/downloads" +command -v curl >/dev/null 2>&1 || die "curl is required (installing mise, checking the dev server)." +ok "$(uname -s), git $(git --version | awk '{print $3}')" + +# ── 2. mise ─────────────────────────────────────────────────────────────────── +stage "mise (Node, pnpm and the project tasks)" + +MISE="" +MISE_CMD="" +if command -v mise >/dev/null 2>&1; then + MISE=mise + MISE_CMD="mise" + skip "mise $(mise --version | awk '{print $1}') is on your PATH" +else + say "mise is not installed. Two ways to get it:" + say " 1) global the official installer (curl https://mise.run | sh) puts it in ~/.local/bin" + say " 2) repo-local ./bin/mise keeps it, Node and pnpm inside this clone's .mise/ folder" + choice=2 + if ! ((YES)); then + printf ' %s? Which one? [1/2, default 2] %s' "$YELLOW" "$RESET" + read -r reply || true + [[ "$reply" == 1 ]] && choice=1 + fi + if [[ "$choice" == 1 ]]; then + curl -fsSL https://mise.run | sh + MISE="$HOME/.local/bin/mise" + MISE_CMD="mise" + [[ -x "$MISE" ]] || die "The installer did not leave mise at $MISE." + ok "installed mise $("$MISE" --version | awk '{print $1}')" + + case "$(basename "${SHELL:-}")" in + zsh) rc="${ZDOTDIR:-$HOME}/.zshrc"; line='eval "$(~/.local/bin/mise activate zsh)"' ;; + # macOS terminals start bash as a login shell, which reads only the first of + # .bash_profile, .bash_login and .profile that exists, never .bashrc. + bash) + rc="$HOME/.bashrc" + if [[ "$(uname -s)" == Darwin ]]; then + rc="$HOME/.bash_profile" + for f in .bash_profile .bash_login .profile; do + [[ -f "$HOME/$f" ]] && { rc="$HOME/$f"; break; } + done + fi + line='eval "$(~/.local/bin/mise activate bash)"' ;; + fish) rc="$HOME/.config/fish/config.fish"; line='~/.local/bin/mise activate fish | source' ;; + *) rc=""; line="" ;; + esac + if [[ -z "$rc" ]]; then + warn "Unknown shell; see https://mise.jdx.dev/getting-started.html#activate-mise" + elif [[ -f "$rc" ]] && grep -q "mise activate" "$rc"; then + skip "$rc already activates mise" + else + say "Shell activation lets \`mise run\` and the pinned Node/pnpm work in every new terminal." + say "This line would be added to $rc:" + printf '\n %s\n\n' "$line" + if confirm "Append it to $rc?" n; then + mkdir -p "$(dirname "$rc")" + printf '\n# mise\n%s\n' "$line" >> "$rc" + ok "added to $rc; open a new terminal to pick it up" + else + TODO+=("Activate mise in your shell: add $line to $rc") + fi + fi + else + MISE=./bin/mise + MISE_CMD="./bin/mise" + ok "using ./bin/mise (first run downloads mise into .mise/)" + note "Run tasks as ./bin/mise run ." + fi +fi + +"$MISE" trust >/dev/null 2>&1 || true +say "Installing the pinned tools (mise.toml, mise.lock, .nvmrc)..." +"$MISE" install +ok "tools installed" + +# ── 3. Dependencies ────────────────────────────────────────────────────────── +stage "Dependencies and generated files" +"$MISE" deps install +"$MISE" run codegen +ok "dependencies installed, generated files up to date" + +# ── 4. .env ─────────────────────────────────────────────────────────────────── +stage ".env" +NEW_ENV=0 +if [[ -f .env ]]; then + skip ".env exists; it will not be overwritten" +else + cp .env.example .env + NEW_ENV=1 + ok "created .env from .env.example" +fi + +if env_has BETTER_AUTH_SECRET; then + skip "BETTER_AUTH_SECRET is set" +else + command -v openssl >/dev/null 2>&1 || die "openssl is required to generate BETTER_AUTH_SECRET." + printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -base64 32)" >> .env + ok "generated BETTER_AUTH_SECRET (not shown)" +fi + +if env_has GITHUB_TOKEN; then + skip "GITHUB_TOKEN is set" +elif ((YES)); then + note "GITHUB_TOKEN unset: /members and the sponsors section use mock data." +elif confirm "Add a GitHub token for live /members and sponsor data? (a token with no scopes is enough)" n; then + say "Create one at https://github.com/settings/tokens and paste it; input is hidden." + printf ' Token: ' + read -rs token || true + printf '\n' + if [[ -n "${token:-}" ]]; then + printf 'GITHUB_TOKEN=%s\n' "$token" >> .env + ok "wrote GITHUB_TOKEN to .env (not shown)" + else + note "Nothing entered; skipped." + fi + unset token +else + note "Skipped: everything else stays on mock data until you ask a maintainer for credentials." +fi + +# ── 5. Database ────────────────────────────────────────────────────────────── +stage "Local database (optional)" +PORT_WEB=$("$MISE" exec -- sh -c 'printf %s "${WEB_PORT:-9000}"') + +site_up() { curl -s -o /dev/null --max-time 2 "http://localhost:$PORT_WEB/" 2>/dev/null; } + +# Every failure returns instead of dying: the site and the checks work without a +# database, so a broken netlify dev must not fail the whole setup. +setup_database() { + if site_up; then + skip "a dev server is already answering on :$PORT_WEB; using it" + else + say "The local Postgres only exists while netlify dev runs; starting it for the migration." + SERVER_LOG=$(mktemp "${TMPDIR:-/tmp}/vc-setup-server.XXXXXX") + "$MISE" run dev:server >"$SERVER_LOG" 2>&1 & + SERVER_PID=$! + printf ' Waiting for http://localhost:%s ' "$PORT_WEB" + local waited=0 + until site_up; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + printf '\n' + tail -n 20 "$SERVER_LOG" >&2 || true + warn "The dev server exited early; the full log is $SERVER_LOG" + return 1 + fi + if ((waited >= 180)); then + printf '\n' + warn "Gave up waiting for the dev server; the log is $SERVER_LOG" + return 1 + fi + printf '.' + sleep 2 + waited=$((waited + 2)) + done + printf '\n' + ok "dev server is up" + fi + + "$MISE" run db:migrate || return 1 + ok "migrations applied" + + local seed_default=n + if ((NEW_ENV)); then seed_default=y; fi + if confirm "Seed sample applications, volunteers and invites?" "$seed_default"; then + "$MISE" run db:seed || return 1 + ok "seeded" + else + note "Skipped. Run $MISE_CMD run db:seed any time." + fi +} + +DB_READY=0 +if confirm "Set up the local database? Only needed for /join, /admin and the forms" n; then + if setup_database; then + DB_READY=1 + else + warn "The database was not set up; everything else is ready." + fi +else + note "Skipped: pages, content and the checks work without it." +fi + +if [[ -n "$SERVER_PID" ]]; then + cleanup + ok "stopped the dev server this script started" +fi +# A failed run keeps the log: the warning above points at it. +if [[ -n "$SERVER_LOG" ]] && ((DB_READY)); then rm -f "$SERVER_LOG"; fi + +# ── 6. Optional extras ─────────────────────────────────────────────────────── +stage "Optional extras" + +if command -v gh >/dev/null 2>&1; then + if gh auth status >/dev/null 2>&1; then + skip "gh is signed in" + else + TODO+=("Sign in to GitHub's CLI for issues and PRs: gh auth login") + fi +else + note "gh (GitHub CLI) is not installed; only needed to open PRs: https://cli.github.com" +fi + +note "Mail sink: $MISE_CMD run dev --mail (installs Mailpit on first use); one shared inbox at http://localhost:8025" + +# ── Summary ────────────────────────────────────────────────────────────────── +printf '\n%s%s Setup complete%s\n\n' "$BOLD" "$GREEN" "$RESET" +if ((${#TODO[@]})); then + warn "Still to do by hand:" + for item in "${TODO[@]}"; do note " - $item"; done + printf '\n' +fi +say "Next:" +say " $MISE_CMD run dev start the site (http://localhost:$PORT_WEB)" +say " $MISE_CMD run check codegen, typecheck, lint, test, knip" +if ! ((DB_READY)); then + printf '\n' + say "Local database (only for /join, /admin and the forms): start the site with" + say " $MISE_CMD run dev, then in another terminal:" + say " $MISE_CMD run db:migrate apply the migrations" + say " $MISE_CMD run db:seed sample applications, volunteers and invites" +fi +printf '\n' diff --git a/docs/adr/0001-netlify-database-for-membership.md b/docs/adr/0001-netlify-database-for-membership.md index 9f7c6e070..f9f91672d 100644 --- a/docs/adr/0001-netlify-database-for-membership.md +++ b/docs/adr/0001-netlify-database-for-membership.md @@ -40,11 +40,11 @@ reviewable diff. ### Migrations **drizzle-kit owns migrations end to end, and we apply them ourselves.** -`pnpm db:generate --name=` writes a migration folder under +`mise run db:generate --name ` writes a migration folder under `drizzle/` holding `migration.sql` plus the `snapshot.json` the next `generate` diffs against — both committed, neither hand-edited. The Netlify build runs -`pnpm db:migrate:deploy` (`drizzle-kit migrate`) after `next build`, on every -deploy context; locally `pnpm db:migrate` does the same through +`mise run build:deploy` (`next build`, then `drizzle-kit migrate`) on every +deploy context; locally `mise run db:migrate` does the same through `scripts/with-local-netlify.ts`, and the `db` test project applies the same folders with drizzle's migrator. diff --git a/docs/adr/0004-retiring-airtable.md b/docs/adr/0004-retiring-airtable.md index e6b8beaec..c51cd4850 100644 --- a/docs/adr/0004-retiring-airtable.md +++ b/docs/adr/0004-retiring-airtable.md @@ -41,9 +41,9 @@ therefore show as open work, which is the honest presentation. - The site no longer writes to Airtable, so the old automations can never fire again. Archiving the bases does not affect the membership pipeline. -- The import and snapshot scripts under `scripts/airtable/`, their read-only - tokens and the `airtable` dependency exist for the one-off runs only; - `scripts/airtable/README.md` says how to run them. +- The import and snapshot scripts, their tokens and the `airtable` dependency + were removed after the cut-over; see git history before the commit that + deleted `scripts/airtable/`. - Historical Submissions show as open work until a reviewer resolves them. - `netlify.toml` still redirects `/member-survey` to an Airtable-hosted form, and the finished challenge pages link out to Airtable entry forms. Those are diff --git a/docs/adr/0011-append-only-invite-ledger.md b/docs/adr/0011-append-only-invite-ledger.md index 0ffff988a..7a77cfb0d 100644 --- a/docs/adr/0011-append-only-invite-ledger.md +++ b/docs/adr/0011-append-only-invite-ledger.md @@ -39,7 +39,7 @@ change of behaviour that lets a Volunteer plan around their allowance. The job r "ensure this month's row exists" rather than "run on the first": a monthly schedule that missed its window would leave everyone short until somebody noticed, a daily idempotent one heals itself the next morning, and -`pnpm invite-maintenance` can run it by hand any number of times. +`mise run invite-maintenance` can run it by hand any number of times. There is **no cap**; `deactivated_at` is the check on it. Someone who steps back stops accruing, so the unbounded case is a person actively volunteering diff --git a/docs/adr/0012-reviewed-airtable-volunteer-mapping.md b/docs/adr/0012-reviewed-airtable-volunteer-mapping.md index 97c073845..069a3d12d 100644 --- a/docs/adr/0012-reviewed-airtable-volunteer-mapping.md +++ b/docs/adr/0012-reviewed-airtable-volunteer-mapping.md @@ -74,3 +74,5 @@ the README says how to regenerate it. - `importMembership.ts` must run first: this script attributes the Invites that one creates, and against an empty `invite` table reports `Attributed 0`. +- The scripts were removed after the cut-over; see git history before the + commit that deleted `scripts/airtable/`. diff --git a/docs/adr/0015-error-monitoring-with-sentry.md b/docs/adr/0015-error-monitoring-with-sentry.md index 7eae761b7..0dc7d1140 100644 --- a/docs/adr/0015-error-monitoring-with-sentry.md +++ b/docs/adr/0015-error-monitoring-with-sentry.md @@ -68,7 +68,7 @@ drops it: never actionable, several a day, so `ignoreErrors` drops it by host. **Source maps upload from Netlify builds.** `withSentryConfig` needs `SENTRY_AUTH_TOKEN` in the build environment; without it the upload is skipped -with a warning, so a fork or a local `pnpm build` is unaffected. Browser events +with a warning, so a fork or a local `mise run build` is unaffected. Browser events go through the `/monitoring` tunnel, for the same reason `netlify.toml` proxies Plausible: content blockers drop direct requests. diff --git a/docs/adr/0019-checked-internal-links.md b/docs/adr/0019-checked-internal-links.md index c2e7e34f9..1ae9a159b 100644 --- a/docs/adr/0019-checked-internal-links.md +++ b/docs/adr/0019-checked-internal-links.md @@ -16,7 +16,7 @@ type system cannot see Markdown links at all. `about`, `code-of-conduct` and `uses` are explicit routes sharing `src/util/simpleMdxPage.server.tsx`. - `/resources` keeps its catch-all, since the handbook is a tree of MDX - files. `pnpm codegen` writes the gitignored `ResourcePath` union from those + files. `mise run codegen` writes the gitignored `ResourcePath` union from those files. A `/resources` href is built with `resourceHref()`, and the ESLint rule `vc/resource-hrefs` bans a literal one. - The index is `resources/page.tsx` plus a required `[...slug]`, because a @@ -33,5 +33,5 @@ type system cannot see Markdown links at all. slug in `SimpleMdxSlug` (`src/util/simpleMdxPage.server.tsx`). - A new catch-all at the root, or a wider one anywhere, switches link checking off for every path it matches. It is a review finding. -- Adding or moving a resource file needs `pnpm codegen` before typecheck. +- Adding or moving a resource file needs `mise run codegen` before typecheck. - A link in content to a redirected path must be pointed at its target. diff --git a/docs/adr/0020-mise-is-the-toolchain.md b/docs/adr/0020-mise-is-the-toolchain.md new file mode 100644 index 000000000..40c26e50f --- /dev/null +++ b/docs/adr/0020-mise-is-the-toolchain.md @@ -0,0 +1,82 @@ +# mise is the toolchain + +## Context + +Onboarding was a README list: install Node (nvm or the installer), enable +Corepack, copy `.env`, `pnpm dev`, then migrate and seed by hand. The workflows +a maintainer actually runs (codegen, the checks, the dev loop, the database +tasks) lived in one person's gitignored `mise.local.toml`, so contributors and +agents got none of them. Every checkout wanted `:9000`, so a second worktree or +an agent could not run beside the first. A member reported that native Windows +does not run the site at all; WSL does. + +Most contributors change content or pages and never touch the database, and +Netlify's build image ships its own mise, older than the one we develop with, +and reads `mise.toml` on every deploy. + +## Decision + +- **mise is required**, locally and in CI. Node stays pinned in `.nvmrc` + (Netlify reads it) and mise reads that file; pnpm is pinned in `mise.toml` as + well as `packageManager`, and `src/test/toolchain.test.ts` fails if the two + differ. `mise.lock` is committed and CI installs `--locked` through + `jdx/mise-action`. +- **`mise run` is the one command vocabulary.** The docs, CI and Netlify call + mise tasks. `package.json` keeps only the `dev` and `test` shims (mise, else + `./bin/mise`), so muscle memory still works. CI flags are `usage` arguments on + the task (`mise run knip -- --reporter github-actions`), and `pnpm install +--frozen-lockfile` stays an explicit step. Anything `:prod` stays in a + personal `mise.local.toml`. +- **Netlify runs `mise run build:deploy`** on its own image's mise. The task + depends on `build` and then runs `drizzle-kit migrate`, so a failed codegen or + `next build` aborts it before any migration starts (`docs/adr/0001`). A failure + in Netlify's Next runtime steps, which run after the command, is not covered, + as before. To stay readable by that older mise, the base config keeps + `min_version` soft, `mise.lock` at lockfile v2, and `netlify.toml` sets + `MISE_ENV=""`. +- **Two config layers.** `mise.toml` holds pnpm and the tasks CI and Netlify + run (codegen, lint, typecheck, test, knip, format, bots, build, check). + `mise.dev.toml` holds everything local: `experimental`, `[deps]`, the + daemons, pitchfork, usage and watchexec, and the dev, database, email and + setup tasks. `.miserc.toml` (`env = ["dev"]`) loads it; CI and Netlify set + `MISE_ENV=""` and never see it. Its tools lock into `mise.dev.lock`, which is + v3 because pitchfork's forge ids need it; the test fails if `mise.lock` goes + above v2. Usage specs parse without the `usage` CLI. +- **Setup is content-first.** `bin/setup` is idempotent and never overwrites + `.env`. It uses a mise on `PATH`, or offers the official installer plus a + shell-activation line (shown, appended only on a yes), or falls back to + `bin/mise`, a generated repo-local mise whose data lives in the gitignored + `.mise/`. The local database is an opt-in question (default no, `--yes` skips + it), and a failure there warns instead of aborting. It does not ask about + daemons. +- **Daemons are a maintainer's tool.** `[daemons.web]` gives each git worktree + its own port (`WEB_PORT`, base 9000, stride 10; Next takes the port above it), + so worktrees and agents run side by side. `mise run dev` stays the default + and uses the same ports. The pitchfork proxy is off, so there is no + `WEB_URL`. +- **Windows: WSL or a dev container.** Native Windows is unsupported; + `bin/setup` is bash and sends MINGW/MSYS/Cygwin shells to WSL. There is no + `bin/mise.cmd`. `.devcontainer/devcontainer.json` (the mise feature, then + `bin/setup --yes`, port 9000 forwarded) covers Codespaces and VS Code. +- **No `netlify dev --live` tunnel.** It existed to forward auth, which the + local test-user sign-in replaced. + +## Consequences + +- A new contributor runs `bin/setup` and gets a working site without Postgres; + nothing is installed globally unless they say yes. +- Someone using `./bin/mise` must write `./bin/mise run …` for every command; + the README says so once, prominently. +- Two pins for pnpm, held together by a test. A Renovate bump of one without the + other fails `mise run test`. +- `bin/mise` pins a mise version; regenerate it with + `mise generate install-script --write bin/mise --localize` to move it. +- A tool whose lock entries need v3 can never join `mise.toml`; it goes in + `mise.dev.toml`. +- `experimental = true` lives in `mise.dev.toml` because daemons need it; a + daemon that changes shape upstream breaks only the opt-in path. +- knip's mise plugin reads one file at a time, so `mise.dev.toml` repeats the + `node_modules/.bin` path entry, and `prettier` is exempted by name. +- CI no longer caches the pnpm store through `setup-node`. +- Agents find the site's address from `mise daemons urls --json` or + `$WEB_PORT`, never by assuming `:9000` (`docs/agents/dev-server.md`). diff --git a/docs/agents/dev-server.md b/docs/agents/dev-server.md new file mode 100644 index 000000000..f3c7bb7e0 --- /dev/null +++ b/docs/agents/dev-server.md @@ -0,0 +1,11 @@ +# Dev server + +Read before starting, restarting or requesting the local site. Tasks and ports come from `mise.toml` and `mise.dev.toml` (`docs/adr/0020`). + +- **Never assume `:9000`.** A linked worktree gets its own port. Inside `mise run` or `mise x -- …` read `$WEB_PORT` (the site is `http://localhost:$WEB_PORT`; Next runs on the port above it). From outside, `mise daemons urls --json` lists each daemon's `port`. +- `mise run dev` starts the site and the codegen watcher on `$WEB_PORT`, else 9000. `mise daemons start web` runs the same server under pitchfork (pinned in `mise.dev.toml`; run `mise daemons register` once per checkout); `mise daemons stop web` ends it. +- A request to a worktree's site that fails to connect usually means the server is not running there; start it before debugging. +- Email is captured (logged, not sent) by default. `mise run dev --mail` points SMTP_* at one shared inbox for every worktree, UI `http://localhost:8025`, SMTP :1025, messages at `/api/v1/messages`. It reuses a Mailpit already answering on :8025 (any worktree's, or a brew one) and otherwise starts the `mail` daemon (installed on first use); `mise daemons stop mail` ends one it started. +- Local database tasks (`db:migrate`, `db:seed`, `db:studio`) need the site running in the same checkout. +- Before finishing a change, run `mise run check` (codegen, typecheck, lint, test, knip). +- Without mise on `PATH`, `./bin/mise` is the same binary: `./bin/mise run check`. diff --git a/docs/agents/membership.md b/docs/agents/membership.md index 4fddbb82b..f2004def9 100644 --- a/docs/agents/membership.md +++ b/docs/agents/membership.md @@ -20,7 +20,7 @@ Each table has one writer; go through it. - `src/lib/volunteers/invites.ts` writes `volunteer_invite_ledger` and sends every Claim Link (`issueAndSend`, `resendClaimLink`). - `src/lib/waitlist/lifecycle.ts` writes an Application's status and owns the guards, the send-first order and invite completion; the transition table is `applicationStatuses.ts`, which the action panel reads. Only the import writes `lapsed`. - The Invite Allowance is an append-only ledger; a correction is a new row through `adjustBalance` with a reason — `docs/adr/0011`. -- Volunteers imported from Airtable come from a reviewed mapping — `docs/adr/0012`. Before running an Airtable script, read `scripts/airtable/README.md`. +- Volunteers imported from Airtable come from a reviewed mapping — `docs/adr/0012`. ## Ordering @@ -41,10 +41,10 @@ Each table has one writer; go through it. ## Schema & ids -- A schema change is a new migration: `pnpm db:generate --name=`, both generated files committed — `docs/adr/0001`. +- A schema change is a new migration: `mise run db:generate --name `, both generated files committed — `docs/adr/0001`. - `id` (UUIDv7, `newId()` in `src/db/ids.ts`) is the URL and foreign-key handle, checked with `isId()` on the way in; `reference` is display-only — `docs/adr/0008`. ## Local dev - One-off scripts run through `scripts/with-local-netlify.ts`, which supplies the local connection string and refuses a non-local one. -- Locally, sign in by picking a seeded test user from the devtools panel on the sign-in pages (`pnpm db:seed`; local deploy context only — `src/lib/access/devtools.ts`). +- Locally, sign in by picking a seeded test user from the devtools panel on the sign-in pages (`mise run db:seed`; local deploy context only — `src/lib/access/devtools.ts`). diff --git a/docs/bot-list.md b/docs/bot-list.md index 24607da35..b237c50d3 100644 --- a/docs/bot-list.md +++ b/docs/bot-list.md @@ -2,7 +2,7 @@ `src/data/bots.ts` is generated by `scripts/loadBotList.ts` from [ai-robots-txt/ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) and sorted into three tiers: harvesters are blocked, AI search and agents fetching for a person right now are allowed, and opt-out directives that never appear as a User-Agent go to robots.txt only. `src/app/robots.ts` and `netlify/edge-functions/block-bots.ts` both read it. -The file is **checked in** and excluded from `pnpm codegen`: a build-time fetch would put a GitHub outage between us and a working deploy, the Deno bundle needs the file to exist, and committing it makes every change to who we block a reviewable diff. Change the policy and regenerate; the file itself is never edited by hand. +The file is **checked in** and excluded from `mise run codegen`: a build-time fetch would put a GitHub outage between us and a working deploy, the Deno bundle needs the file to exist, and committing it makes every change to who we block a reviewable diff. Change the policy and regenerate; the file itself is never edited by hand. - **`.botlist-version.json`** pins the upstream release, so two runs a week apart produce the same file. Renovate bumps it through the `jsonata` custom manager in `renovate.json` — a structural query on the `version` field rather than a regex, because Renovate reports a non-matching manager as "no dependency found" rather than as an error. - **`src/data/botOverrides.ts`** holds every policy decision. The generator exits non-zero if an override doesn't reach the tier it names, and warns if upstream has dropped one. diff --git a/docs/testing.md b/docs/testing.md index f8eab793c..3326e9f70 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,6 +1,6 @@ # Testing -Vitest, configured in `vitest.config.mts` (read its header comment: it defines the two projects and where the `@/` alias comes from). Tests are colocated as `*.test.ts` beside the module (`*.test.tsx` where the subject is JSX, as in `src/emails/`), run in a plain `node` environment with explicit `import { test, expect } from 'vitest'`; there is no jsdom and no React Testing Library, so do not add either. `pnpm test --project unit` is the fast loop; `pnpm test` runs both projects. The exception is `netlify/edge-functions/`, which Netlify bundles file-by-file; its tests live in `netlify/tests/`. +Vitest, configured in `vitest.config.mts` (read its header comment: it defines the two projects and where the `@/` alias comes from). Tests are colocated as `*.test.ts` beside the module (`*.test.tsx` where the subject is JSX, as in `src/emails/`), run in a plain `node` environment with explicit `import { test, expect } from 'vitest'`; there is no jsdom and no React Testing Library, so do not add either. `mise run test --project unit` is the fast loop; `mise run test` runs both projects (with `--coverage` for a report, and file or name filters as trailing arguments). The exception is `netlify/edge-functions/`, which Netlify bundles file-by-file; its tests live in `netlify/tests/`. ## What is tested, and how diff --git a/eslint.config.mjs b/eslint.config.mjs index 2352b2201..12b99bb14 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,7 +25,7 @@ export default defineConfig([ }, }, // Local rules for the content contributors write by hand. All `error`: - // `pnpm lint` has no `--max-warnings`, so a warning would not fail CI. + // `mise run lint` has no `--max-warnings`, so a warning would not fail CI. { name: 'vc/members', plugins: { vc }, @@ -111,6 +111,8 @@ export default defineConfig([ 'build/**', 'next-env.d.ts', '.netlify/**', + // bin/mise's data dir; its state/ holds a link back to the checkout. + '.mise/**', 'netlify/**', ]), ]); diff --git a/knip.ts b/knip.ts index 564026753..4956cfea1 100644 --- a/knip.ts +++ b/knip.ts @@ -2,8 +2,7 @@ import type { KnipConfig } from 'knip'; export default { entry: [ - // Some are not in package.json scripts (scripts/airtable/*), and the - // membership stack nests others behind `tsx scripts/with-local-netlify.ts …`. + // The membership stack nests some scripts behind `tsx scripts/with-local-netlify.ts …`. 'scripts/**/*.ts', // netlify.toml [[edge_functions]]; the netlify plugin only finds netlify/functions. 'netlify/edge-functions/*.ts', @@ -18,7 +17,9 @@ export default { ], ignoreExportsUsedInFile: true, // `mdx/types` comes from @types/mdx; there is no `mdx` package for knip to find. - ignoreDependencies: ['mdx'], + // prettier is run by the mise `format` task; knip counts a binary called from a + // mise task whose PATH is node_modules/.bin as optional, never as a use. + ignoreDependencies: ['mdx', 'prettier'], compilers: { // bootstrap is only reached through @import in src/styles. Relative imports // are Sass partials, so `./nav` has to become `./_nav.scss` for knip to diff --git a/mise.dev.lock b/mise.dev.lock new file mode 100644 index 000000000..b58377720 --- /dev/null +++ b/mise.dev.lock @@ -0,0 +1,120 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 3 + +[[tools.mailpit]] +version = "1.31.4" +backend = "aqua:axllent/mailpit" +specifiers = ["1.31.4"] + +[tools.mailpit."platforms.linux-arm64"] +checksum = "sha256:010a346a8b9d454f6fa22d4fd2dbd5bf5eb7104688412b9c130d0ba33a3579c4" +url = "https://github.com/axllent/mailpit/releases/download/v1.31.4/mailpit-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/axllent/mailpit/releases/assets/607145344" + +[tools.mailpit."platforms.linux-arm64-musl"] +checksum = "sha256:010a346a8b9d454f6fa22d4fd2dbd5bf5eb7104688412b9c130d0ba33a3579c4" +url = "https://github.com/axllent/mailpit/releases/download/v1.31.4/mailpit-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/axllent/mailpit/releases/assets/607145344" + +[tools.mailpit."platforms.linux-x64"] +checksum = "sha256:30942c4605c2ca8b9f759b1bb4e3ab6a12bdfdf66e5c94644ec2c75ac41e88e7" +url = "https://github.com/axllent/mailpit/releases/download/v1.31.4/mailpit-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/axllent/mailpit/releases/assets/607145408" + +[tools.mailpit."platforms.linux-x64-musl"] +checksum = "sha256:30942c4605c2ca8b9f759b1bb4e3ab6a12bdfdf66e5c94644ec2c75ac41e88e7" +url = "https://github.com/axllent/mailpit/releases/download/v1.31.4/mailpit-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/axllent/mailpit/releases/assets/607145408" + +[tools.mailpit."platforms.macos-arm64"] +checksum = "sha256:26afeae329874fa6154dd83bd2e0f307e5df858054145dc7e98ec1aa426a8381" +url = "https://github.com/axllent/mailpit/releases/download/v1.31.4/mailpit-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/axllent/mailpit/releases/assets/607144831" + +[tools.mailpit."platforms.macos-x64"] +checksum = "sha256:9ff1d1b249ccb884ca48a6fca01f69f9db88b07692dd98e115294778a0b20f54" +url = "https://github.com/axllent/mailpit/releases/download/v1.31.4/mailpit-darwin-amd64.tar.gz" +url_api = "https://api.github.com/repos/axllent/mailpit/releases/assets/607145500" + +[tools.mailpit."platforms.windows-x64"] +checksum = "sha256:0dd6ec909a6f5b2b66682b22d82abce97ab793e30766bf2f642b691e70a4a16b" +url = "https://github.com/axllent/mailpit/releases/download/v1.31.4/mailpit-windows-amd64.zip" +url_api = "https://api.github.com/repos/axllent/mailpit/releases/assets/607145399" + +[[tools.pitchfork]] +version = "2.29.0" +backend = "packslip:github.com/jdx/pitchfork" +specifiers = ["2.29.0"] + +[tools.pitchfork."platforms.linux-arm64"] +checksum = "sha256:dfea08ac9b4a8b2a3f4c9ce94c5faba9a1996634a22bdc6fd17510affe9dca71" +url = "https://github.com/jdx/pitchfork/releases/download/v2.29.0/pitchfork-aarch64-unknown-linux-gnu.tar.gz" +signer = "sigstore-oidc:https://github.com/jdx/pitchfork/.github/workflows/release.yml" +repository_ids = { repository = "899500793" } + +[tools.pitchfork."platforms.linux-x64"] +checksum = "sha256:80d48f7077f8612c7684777aef346b93d3ecca296d4d9b392b2fb5e50a4f891d" +url = "https://github.com/jdx/pitchfork/releases/download/v2.29.0/pitchfork-x86_64-unknown-linux-gnu.tar.gz" +signer = "sigstore-oidc:https://github.com/jdx/pitchfork/.github/workflows/release.yml" +repository_ids = { repository = "899500793" } + +[tools.pitchfork."platforms.macos-arm64"] +checksum = "sha256:06adf144e4a48dad55806706a7b0fe1a8dfc901726c5c8939e54fbc2bcabb04e" +url = "https://github.com/jdx/pitchfork/releases/download/v2.29.0/pitchfork-aarch64-apple-darwin.tar.gz" +signer = "sigstore-oidc:https://github.com/jdx/pitchfork/.github/workflows/release.yml" +repository_ids = { repository = "899500793" } + +[tools.pitchfork."platforms.windows-x64"] +checksum = "sha256:9747f3679f5c7ff4091bb3fe14c80ab92c9b15c212f37cd0193aa6e2da1631c1" +url = "https://github.com/jdx/pitchfork/releases/download/v2.29.0/pitchfork-x86_64-pc-windows-msvc.zip" +signer = "sigstore-oidc:https://github.com/jdx/pitchfork/.github/workflows/release.yml" +repository_ids = { repository = "899500793" } + +[[tools.usage]] +version = "6.12.0" +backend = "ubi:jdx/usage" +specifiers = ["6.12.0"] + +[tools.usage."platforms.macos-arm64-usage"] +checksum = "blake3:697fd703b8224112ead6b23ff1a6afff1319707751b7073186384a31b9d7036a" + +[[tools.watchexec]] +version = "2.8.0" +backend = "aqua:watchexec/watchexec" +specifiers = ["latest"] + +[tools.watchexec."platforms.linux-arm64"] +checksum = "sha256:aaa5eb7e62e9ce99cad7f14260265aaa9df8884e195c0661ab011825a6d61634" +url = "https://github.com/watchexec/watchexec/releases/download/v2.8.0/watchexec-2.8.0-aarch64-unknown-linux-gnu.tar.xz" +url_api = "https://api.github.com/repos/watchexec/watchexec/releases/assets/616138796" + +[tools.watchexec."platforms.linux-arm64-musl"] +checksum = "sha256:3961878b21e3c1729279322f4007dd5bbf0032110fbcb5e7b983946a9ceb3bf6" +url = "https://github.com/watchexec/watchexec/releases/download/v2.8.0/watchexec-2.8.0-aarch64-unknown-linux-musl.tar.xz" +url_api = "https://api.github.com/repos/watchexec/watchexec/releases/assets/616138804" + +[tools.watchexec."platforms.linux-x64"] +checksum = "sha256:21839e9840ef592b4d571885ccbd692f54ae5bd4231f3caf29fcf31fa7723afe" +url = "https://github.com/watchexec/watchexec/releases/download/v2.8.0/watchexec-2.8.0-x86_64-unknown-linux-gnu.tar.xz" +url_api = "https://api.github.com/repos/watchexec/watchexec/releases/assets/616138794" + +[tools.watchexec."platforms.linux-x64-musl"] +checksum = "sha256:35ea667cbe4bc96c23ba2a1f46c94c46bc385b67165e3bf621c0e7e4959aa99b" +url = "https://github.com/watchexec/watchexec/releases/download/v2.8.0/watchexec-2.8.0-x86_64-unknown-linux-musl.tar.xz" +url_api = "https://api.github.com/repos/watchexec/watchexec/releases/assets/616138798" + +[tools.watchexec."platforms.macos-arm64"] +checksum = "sha256:8da8bdbfb8568138616a082c05a87224e08d5cb2994feb956426291cf0c6fe10" +url = "https://github.com/watchexec/watchexec/releases/download/v2.8.0/watchexec-2.8.0-aarch64-apple-darwin.tar.xz" +url_api = "https://api.github.com/repos/watchexec/watchexec/releases/assets/616138836" + +[tools.watchexec."platforms.macos-x64"] +checksum = "sha256:b7f694ad19c1cf168388f3295a5e825425f7dac5cd27cffd2d89a51ed4ab0f22" +url = "https://github.com/watchexec/watchexec/releases/download/v2.8.0/watchexec-2.8.0-x86_64-apple-darwin.tar.xz" +url_api = "https://api.github.com/repos/watchexec/watchexec/releases/assets/616138831" + +[tools.watchexec."platforms.windows-x64"] +checksum = "sha256:0c872fc18c8e801f21614c6daf2504a5826d5c705bb67f7a583d879f5410d9ea" +url = "https://github.com/watchexec/watchexec/releases/download/v2.8.0/watchexec-2.8.0-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/watchexec/watchexec/releases/assets/616138784" diff --git a/mise.dev.toml b/mise.dev.toml new file mode 100644 index 000000000..219d16e70 --- /dev/null +++ b/mise.dev.toml @@ -0,0 +1,164 @@ +# Local development only, layered on mise.toml by `.miserc.toml` (`env = ["dev"]`) +# — docs/adr/0020. CI and Netlify set `MISE_ENV=""`, so none of this loads +# there: no pitchfork, usage or watchexec download, and no dev tasks in the log. +# Its tools lock into mise.dev.lock, which is why mise.lock can stay readable by +# the older mise Netlify's image ships. + +[settings] + # Daemons (`[daemons.web]`) and `[deps]` are experimental. + experimental = true + +[tools] + # Daemons (`[daemons.web]`) are run by pitchfork; tasks' `usage` specs need usage. + pitchfork = "2.29.0" + usage = "6.12.0" + # `mise watch` shells out to watchexec. + watchexec = "latest" + +[env] + # Same entry as mise.toml. mise merges the two; knip's mise plugin reads one + # file at a time and only resolves `netlify`, `drizzle-kit` and `email` to + # their packages when the file itself puts node_modules/.bin on the PATH. + _.path = [ + "{{config_root}}/node_modules/.bin", + ] + +# `mise run` and `mise x` run `pnpm install` first whenever pnpm-lock.yaml is +# newer than node_modules, so a fresh worktree or a pull needs no manual +# install. CI calls pnpm directly and is unaffected. +[deps.pnpm] + auto = true + + # --- dev loop --------------------------------------------------------------- + # + # Replaces concurrently, npm-watch and cross-env. `dev` has no body: its + # dependencies are the two long-lived processes, which mise runs together. + +[tasks."dev:watch"] + depends = ["codegen"] + description = "Re-run codegen when its sources change" + run = "{{mise_bin}} watch codegen" + +# WEB_PORT comes from [daemons.web] when it is configured (one port per +# worktree); the fallback is netlify.toml's 9000. netlify dev does not pass the +# port to `[dev] command`, so Next gets it through PORT, and URL (which a .env +# copy of .env.example also sets, to :9000) follows the site's port. +[tasks."dev:server"] + alias = "local-dev" + depends = ["codegen"] + description = "netlify dev in front of next dev (site on $WEB_PORT, else 9000; next on one above)" + env = { NODE_ENV = "development" } + # --mail points SMTP_* at the shared Mailpit on :1025, starting the mail + # daemon (below) only if nothing answers on :8025. Exported values win over + # .env: netlify dev ignores a .env var already in the process. + run = ''' + port=${WEB_PORT:-9000} + export PORT=$((port + 1)) URL=http://localhost:$port + if [ "${usage_mail:-false}" = true ]; then + if curl -s -o /dev/null --max-time 2 http://127.0.0.1:8025/api/v1/info; then + echo "mail: reusing the Mailpit already on http://localhost:8025" + else + {{mise_bin}} daemons start mail + fi + export SMTP_HOST=localhost SMTP_PORT=1025 + fi + exec netlify dev --port "$port" --target-port "$PORT" + ''' + usage = 'flag "--mail" help="Send email to the shared Mailpit inbox on :8025 (installed on first use)"' + +[tasks.dev] + depends = [ + "dev:watch", + "dev:server {% if usage.mail %}--mail{% endif %}", + ] + description = "codegen once, then the watcher and netlify dev together" + usage = 'flag "--mail" help="Send email to the shared Mailpit inbox on :8025 (installed on first use)"' + + # --- daemons ---------------------------------------------------------------- + # + # Opt-in: `mise daemons start web` (pitchfork comes from [tools]). `mise run + # dev` stays the default way to run the site. + # + # `port.auto` gives the main checkout 9000 and each linked worktree its own + # slot, exported as WEB_PORT, so worktrees and agents can run side by side. The + # stride of 10 leaves room for the port above it, which dev:server gives to + # Next. Read the site's address from `mise daemons urls --json` or $WEB_PORT; + # never assume 9000. + +[daemons.web] + port = { auto = true, base = 9000, stride = 10 } + proxy = false + ready_cmd = "curl -s -o /dev/null --max-time 5 http://localhost:$WEB_PORT/" + task = "dev:server" + +# One shared inbox for every checkout: Mailpit on its default ports (UI 8025, +# SMTP 1025), data in memory. Its tool is scoped to the task, so only someone +# who starts the daemon (via `mise run dev --mail`) ever downloads it. +# dev:server reuses whatever already answers on 8025, so a second worktree never +# starts a second one. (Global [daemon_providers] would need per-user config and +# only support postgres, cockroachdb and nats.) +[tasks."mail:server"] + description = "Mailpit: UI on :8025, SMTP on :1025" + run = "exec mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025" + tools = { mailpit = "1.31.4" } + +[daemons.mail] + port = 8025 + proxy = false + ready_cmd = "curl -s -o /dev/null --max-time 5 http://127.0.0.1:8025/api/v1/info" + task = "mail:server" + + # --- database --------------------------------------------------------------- + # + # scripts/with-local-netlify.ts supplies DATABASE_URL and a blob store to + # anything that is not the site process, and refuses a non-local connection + # string. It resolves its child against node_modules/.bin itself. + +[tasks."db:generate"] + description = "Generate a Drizzle migration into drizzle/" + # `mise run db:generate --name add-something`. The name is required and + # hyphenated: drizzle's own names use underscores, and Netlify's migration + # slugs are lowercase alphanumerics and hyphens only. + run = ''' + case "$usage_name" in + "" | *[!a-z0-9-]* | -*) echo "--name must be lowercase letters, digits and hyphens: $usage_name" >&2; exit 1 ;; + esac + exec drizzle-kit generate --name "$usage_name" + ''' + usage = 'flag "--name " required=#true help="Migration slug, e.g. add-invite-expiry (lowercase, digits, hyphens)"' + +[tasks."db:migrate"] + description = "Apply migrations locally (needs `mise run dev` running)" + run = "tsx scripts/with-local-netlify.ts drizzle-kit migrate" + +# `--force` skips the CLI's own prompt so the mise `confirm` is the only one. +[tasks."db:reset"] + confirm = { message = "Wipe the local database and re-apply every migration?", default = "no" } + description = "Reset the local database, then apply migrations (needs `mise run dev` running)" + run = [ + "netlify database reset --force", + { task = "db:migrate" }, + ] + +[tasks."db:seed"] + description = "Seed sample applications, volunteers, ledger rows and invites" + run = "tsx scripts/with-local-netlify.ts tsx scripts/seedDev.ts" + +[tasks."db:studio"] + description = "Drizzle Studio on the local database" + run = "tsx scripts/with-local-netlify.ts drizzle-kit studio" + +[tasks.invite-maintenance] + description = "Run the daily invite accrual and expiry by hand" + run = "tsx scripts/with-local-netlify.ts tsx scripts/runInviteMaintenance.ts" + +[tasks."email:dev"] + description = "React Email preview on :3010" + env = { EMAIL_ASSET_ORIGIN = "http://localhost:3010" } + run = "email dev --dir src/emails --port 3010" + + # --- onboarding ------------------------------------------------------------- + +[tasks.setup] + description = "First-run wizard: tools, dependencies, .env, database" + run = "bin/setup" diff --git a/mise.lock b/mise.lock new file mode 100644 index 000000000..63ee2dbe0 --- /dev/null +++ b/mise.lock @@ -0,0 +1,53 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 2 + +[[tools.node]] +version = "24.21.0" +backend = "core:node" +specifiers = ["24.21.0"] + +[tools.node."platforms.linux-arm64"] +checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz" + +[tools.node."platforms.linux-x64"] +checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz" + +[tools.node."platforms.macos-arm64"] +checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz" + +[tools.node."platforms.macos-x64"] +checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz" + +[[tools.pnpm]] +version = "12.11.2" +backend = "aqua:pnpm/pnpm" +specifiers = ["12.11.2"] + +[tools.pnpm."platforms.linux-arm64"] +checksum = "sha256:ad444fa06d7ff344d74ba1e4933f2ed71cc6357cc2d33707a5c60958b7d83085" +url = "https://github.com/pnpm/pnpm/releases/download/v12.11.2/pnpm-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/625613171" +provenance = "github-attestations" + +[tools.pnpm."platforms.linux-x64"] +checksum = "sha256:358f30541b42371360c0d48a8daddc0c0850a90c36fcbea598032a9850e2c9ab" +url = "https://github.com/pnpm/pnpm/releases/download/v12.11.2/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/625613177" +provenance = "github-attestations" + +[tools.pnpm."platforms.macos-arm64"] +checksum = "sha256:2f4701dbd4a7b81827789e94225272355fcc17c4eb9f5add08ed26f943003b67" +url = "https://github.com/pnpm/pnpm/releases/download/v12.11.2/pnpm-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/625613178" +provenance = "github-attestations" + +[tools.pnpm."platforms.macos-x64"] +checksum = "sha256:409f9754f68500719db9b17bd750d091790f1d846f61707b4716c73110f5de52" +url = "https://github.com/pnpm/pnpm/releases/download/v12.11.2/pnpm-darwin-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/625613179" +provenance = "github-attestations" diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..66d0bf9d1 --- /dev/null +++ b/mise.toml @@ -0,0 +1,182 @@ +# The toolchain and the workflows CI and Netlify run — docs/adr/0020. This file +# is what those two read, so it stays small and readable by the older mise +# Netlify's image ships. The local-only half (dev server, daemons, database, +# setup) is mise.dev.toml, loaded by `.miserc.toml`; CI and Netlify set +# `MISE_ENV=""` to skip it. +# Personal tasks (anything :prod) go in the gitignored mise.local.toml. +# +# Node is pinned in .nvmrc, which Netlify also reads; mise picks it up as an +# idiomatic version file. pnpm is pinned here and in `packageManager`, and +# src/test/toolchain.test.ts fails if the two drift apart. + +# Soft: Netlify's build image ships its own, older mise, and a hard minimum +# fails the deploy before `pnpm install`. +min_version = { soft = "2026.10.0" } + +[settings] + idiomatic_version_file_enable_tools = [ + "node", + ] + # `mise.lock` pins every tool; CI refuses to resolve anything new. + lockfile = true + +[tools] + pnpm = "12.11.2" + +[env] + # The project's own binaries, so tasks call `tsx` / `next` / `eslint` / + # `drizzle-kit` / `netlify` directly instead of through a `pnpm exec` process. + _.path = [ + "{{config_root}}/node_modules/.bin", + ] + + # --- codegen ---------------------------------------------------------------- + # + # The generated files the app imports are gitignored, so a clean checkout has + # to build them before anything typechecks. `sources` and `outputs` let mise + # skip the work when nothing changed; `mise run --force codegen` is the escape + # hatch, notably after *deleting* a member file, where a glob can still look + # fresh. + +[tasks."codegen:members"] + alias = "build-member-files" + description = "Regenerate the member barrel files" + outputs = [ + "src/data/members/core.ts", + "src/data/members/members.ts", + ] + run = "tsx scripts/loadMemberFiles.ts" + sources = [ + "src/content/members/core/*.ts", + "src/content/members/members/*.ts", + "scripts/loadMemberFiles.ts", + ] + +[tasks."codegen:undraw"] + alias = "build-undraw-ratios" + description = "Regenerate the Undraw aspect-ratio map" + outputs = [ + "src/data/undrawAspectRatios.ts", + ] + run = "tsx scripts/loadUndrawAspectRatios.ts" + sources = [ + "public/assets/svg/*.svg", + "scripts/loadUndrawAspectRatios.ts", + ] + +[tasks."codegen:resources"] + alias = "build-resource-paths" + description = "Regenerate the typed /resources hrefs" + outputs = ["src/data/resourcePaths.ts"] + run = "tsx --require ./scripts/lib/serverOnlyStub.cjs scripts/loadResourcePaths.ts" + sources = [ + "src/content/resources/**/*.mdx", + "scripts/loadResourcePaths.ts", + "src/util/loadMdx.server.ts", + ] + +[tasks.codegen] + depends = ["codegen:*"] + description = "All generated files the app imports" + + # --- checks ----------------------------------------------------------------- + +[tasks.lint] + depends = ["codegen"] + description = "ESLint over the whole project" + run = ''' + eval "set -- ${usage_files:-}" + [ "${usage_fix:-false}" = true ] && set -- --fix "$@" + exec eslint "$@" + ''' + usage = ''' + flag "--fix" help="Apply autofixes" + arg "[files]" var=#true help="Files or folders; default is the whole project" + ''' + +[tasks.typecheck] + depends = ["codegen"] + description = "next typegen, then the native tsc" + run = ["next typegen", "tsc --noEmit"] + +[tasks.test] + depends = ["codegen"] + description = "Vitest, both projects" + run = ''' + eval "set -- ${usage_files:-}" + [ "${usage_coverage:-false}" = true ] && set -- --coverage "$@" + [ -n "${usage_project:-}" ] && set -- --project "$usage_project" "$@" + exec vitest run "$@" + ''' + usage = ''' + flag "--project " help="Only one project" { + choices "unit" "db" + } + flag "--coverage" help="Collect coverage" + arg "[files]" var=#true help="Test files or name filters" + ''' + +[tasks.knip] + depends = ["codegen"] + description = "Unused files, exports and dependencies" + # CI adds `-- --reporter github-actions` so findings become annotations. + run = ''' + eval "set -- ${usage_args:-}" + exec knip "$@" + ''' + usage = 'arg "[args]" var=#true help="Passed to knip, e.g. --reporter github-actions"' + +# `--check` keeps prettier's own `[warn] file` lines, which CI reads back. +[tasks.format] + description = "Prettier over everything, in place" + run = ''' + eval "set -- $usage_paths" + [ "${usage_check:-false}" = true ] && exec prettier --check --ignore-unknown "$@" + exec prettier --write --ignore-unknown --list-different "$@" + ''' + usage = ''' + flag "--check" help="Only report unformatted files; write nothing" + arg "[paths]" var=#true default="." help="Files or folders to format" + ''' + +[tasks."bots:build"] + alias = "build-bot-list" + description = "Regenerate src/data/bots.ts from the release pinned in .botlist-version.json" + run = "tsx scripts/loadBotList.ts" + +[tasks."bots:check"] + alias = "check-bot-matching" + description = "Check the bot user-agent matcher" + run = "vitest run --project unit src/data/botMatcher.test.ts" + +# CLAUDE.md's "before finishing" list. `format` is left out: it writes files, and +# racing a formatter against eslint and tsc reading the same tree invites trouble. +[tasks.check] + depends = [ + "typecheck", + "lint", + "test", + "knip", + ] + description = "Everything CI checks, in parallel: codegen, typecheck, lint, test, knip" + + # --- build ------------------------------------------------------------------ + +[tasks.build] + depends = ["codegen"] + description = "Production build" + run = "next build" + +# Netlify's build command (netlify.toml): build, then apply migrations +# (docs/adr/0001). The ordering is the safety property: a failed dependency +# aborts the task, so migrations never run unless codegen and `next build` both +# exit 0. Do not fold the two into one `run` list or add a step that continues +# past a failure. +[tasks."build:deploy"] + depends = ["build"] + description = "Netlify's build: production build, then apply migrations" + run = "drizzle-kit migrate" + +[tasks.start] + description = "Serve an existing production build" + run = "next start" diff --git a/netlify.toml b/netlify.toml index 934f6f443..702c60f87 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,10 +1,16 @@ [build] # Migrations are applied here, by drizzle-kit, not by Netlify's own - # applier. See docs/adr/0001. - command = "pnpm build && pnpm db:migrate:deploy" + # applier, and only after codegen and `next build` succeed. See + # docs/adr/0001 and docs/adr/0020. + command = "mise run build:deploy" functions = "netlify/functions" publish = ".next" + [build.environment] + # Skips mise.dev.toml: the image's older mise would otherwise install + # pitchfork, usage and watchexec, and trip over their v3 lockfile. + MISE_ENV = "" + [[edge_functions]] function = "block-bots" path = "/*" diff --git a/netlify/env.d.ts b/netlify/env.d.ts index 82e901ac6..51e6dc036 100644 --- a/netlify/env.d.ts +++ b/netlify/env.d.ts @@ -40,7 +40,7 @@ declare namespace NodeJS { // would. Takes SMTP_PORT (default 1025). SMTP_HOST?: string; SMTP_PORT?: string; - // Set by `pnpm email:dev` only: where the templates' images load from + // Set by `mise run email:dev` only: where the templates' images load from // while the preview server, not production, is serving them. EMAIL_ASSET_ORIGIN?: string; diff --git a/next.config.mjs b/next.config.mjs index 17b2e6df6..76375663b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -28,21 +28,6 @@ const localMdxPlugin = (relPath, options = {}) => { return [absPath, { ...options, pluginVersion }]; }; -// `netlify dev --live` serves the site from a per-developer tunnel host but -// proxies to :9000, and sets `x-forwarded-host` to that local port. Next's -// Server Action CSRF check compares `origin` to the forwarded host and aborts -// on the mismatch, and its dev-resource guard blocks the tunnel the same way. -// The subdomain differs per developer (`--live=`), so allow the zone -// rather than one host. `*` matches exactly one DNS label, so `*.netlify.live` -// is the only pattern that matches `--.netlify.live`. -// -// The CLI sets NETLIFY_DEV for both plain `netlify dev` and `--live`, and -// nothing tells the framework which one it is under, so `pnpm dev:tunnel` -// sets NETLIFY_TUNNEL=1 itself. Both lists are empty everywhere else, -// including every deployed environment. -const isTunnel = process.env.NETLIFY_TUNNEL === '1'; -const devTunnelOrigins = isTunnel ? ['*.netlify.live'] : []; - /** @type {import('next').NextConfig} */ const nextConfig = { // `netlify dev` routes every request through the block-bots edge function @@ -69,25 +54,15 @@ const nextConfig = { ], }, pageExtensions: ['js', 'jsx', 'ts', 'tsx'], - // Next 16 streams React's dev-only debug info (owner stacks, component - // origins) to the browser over the `/_next/hmr` websocket, keyed by request - // id, and the client router *blocks* the Flight decode until those chunks - // arrive. Behind `netlify dev --live` the socket connects and the server - // sends every chunk, but they never reach the browser, so every client-side - // navigation suspends forever: the RSC response is a clean 200, nothing - // throws, nothing is logged, and the page simply never changes. Off under - // the tunnel only; the cost is richer dev stack traces and nothing else. experimental: { - reactDebugChannel: !isTunnel, + reactDebugChannel: true, serverActions: { - allowedOrigins: devTunnelOrigins, // Next's default is 1MB; the CoC form's 4MiB attachment // (MAX_ATTACHMENT_BYTES) plus multipart overhead needs more. Netlify // buffers function requests at 6MB, so nothing higher would arrive anyway. bodySizeLimit: '5mb', }, }, - allowedDevOrigins: devTunnelOrigins, // Sentry's `environment` tag is the Netlify deploy context (production, // deploy-preview, branch-deploy). Inlined here so the browser bundle sees // the same value as the server; CONTEXT is unset outside Netlify. diff --git a/package.json b/package.json index 875c90309..2ec66f165 100644 --- a/package.json +++ b/package.json @@ -9,41 +9,8 @@ "packageManager": "pnpm@12.11.2+sha512.25a51652a196785af5f28bda6dfe8dc582ebefa4ac1d180221ce1390231c5fb76637822ea4b6e4633ce74acdcb0a77f725af11bad38177fdeddaee818a955cb9", "scripts": { "preinstall": "npx only-allow pnpm", - "format": "prettier --write --ignore-unknown --list-different .", - "lint": "eslint", - "knip": "knip", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "build-member-files": "tsx scripts/loadMemberFiles.ts", - "build-undraw-ratios": "tsx scripts/loadUndrawAspectRatios.ts", - "build-resource-paths": "tsx --require ./scripts/lib/serverOnlyStub.cjs scripts/loadResourcePaths.ts", - "build-bot-list": "tsx scripts/loadBotList.ts", - "check-bot-matching": "vitest run --project unit src/data/botMatcher.test.ts", - "codegen": "pnpm build-member-files && pnpm build-undraw-ratios && pnpm build-resource-paths", - "local-dev": "cross-env NODE_ENV=development pnpm netlify dev", - "local-dev:tunnel": "cross-env NODE_ENV=development NETLIFY_TUNNEL=1 pnpm netlify dev --live", - "build": "next build", - "start": "next start", - "prebuild": "pnpm codegen", - "typecheck": "next typegen && tsc --noEmit", - "watch": "npm-watch", - "dev": "pnpm codegen && concurrently \"pnpm watch\" \"pnpm local-dev\"", - "dev:tunnel": "pnpm codegen && concurrently \"pnpm watch\" \"pnpm local-dev:tunnel\"", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx scripts/with-local-netlify.ts drizzle-kit migrate", - "db:migrate:deploy": "drizzle-kit migrate", - "db:seed": "tsx scripts/with-local-netlify.ts tsx scripts/seedDev.ts", - "invite-maintenance": "tsx scripts/with-local-netlify.ts tsx scripts/runInviteMaintenance.ts", - "email:dev": "cross-env EMAIL_ASSET_ORIGIN=http://localhost:3010 email dev --dir src/emails --port 3010" - }, - "watch": { - "build-member-files": "src/content/members/**/*.ts", - "build-undraw-ratios": "public/assets/svg/*.svg", - "build-resource-paths": { - "patterns": "src/content/resources", - "extensions": "mdx" - } + "dev": "sh -c 'm=mise; command -v mise >/dev/null 2>&1 || m=./bin/mise; exec \"$m\" run dev \"$@\"' dev", + "test": "sh -c 'm=mise; command -v mise >/dev/null 2>&1 || m=./bin/mise; exec \"$m\" run test \"$@\"' test" }, "dependencies": { "@googleapis/calendar": "^20.1.0", @@ -116,15 +83,11 @@ "@typescript/native": "npm:typescript@^7.0.2", "@vitest/coverage-v8": "^5.0.3", "@vitest/eslint-plugin": "^1.6.27", - "airtable": "^0.12.2", - "concurrently": "^10.0.6", - "cross-env": "^10.1.0", "drizzle-kit": "1.0.0-rc.5-ab785fc", "eslint": "^10.12.0", "eslint-config-next": "16.4.0", "knip": "^6.41.0", "netlify-cli": "^27.12.0", - "npm-watch": "^0.13.0", "prettier": "^3.9.10", "sass": "^1.105.1", "tsx": "^4.23.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19f1f7555..5b66db178 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -371,15 +371,6 @@ importers: '@vitest/eslint-plugin': specifier: ^1.6.27 version: 1.6.27(@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(@typescript/typescript6@6.0.2)(eslint@10.12.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0))(@typescript/typescript6@6.0.2)(eslint@10.12.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0))(@typescript/typescript6@6.0.2)(eslint@10.12.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(vitest@5.0.3) - airtable: - specifier: ^0.12.2 - version: 0.12.2 - concurrently: - specifier: ^10.0.6 - version: 10.0.6 - cross-env: - specifier: ^10.1.0 - version: 10.1.0 drizzle-kit: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc @@ -395,9 +386,6 @@ importers: netlify-cli: specifier: ^27.12.0 version: 27.12.0(@parcel/watcher@2.6.0)(@types/node@24.19.2)(picomatch@4.0.7)(rollup@4.63.3)(supports-color@5.5.0) - npm-watch: - specifier: ^0.13.0 - version: 0.13.0 prettier: specifier: ^3.9.10 version: 3.9.10 @@ -742,9 +730,6 @@ packages: resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} engines: {node: '>=18.0.0'} - '@epic-web/invariant@1.0.0': - resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -4045,9 +4030,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@14.18.63': - resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} - '@types/node@24.19.1': resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} @@ -4550,9 +4532,6 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - abortcontroller-polyfill@1.7.8: - resolution: {integrity: sha512-9f1iZ2uWh92VcrU9Y8x+LdM4DLj75VE0MJB8zuF1iUnroEptStw+DQ8EQPMUdfe5k+PkB1uUfDQfWbhstH8LrQ==} - abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -4591,10 +4570,6 @@ packages: resolution: {integrity: sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==} engines: {node: '>= 14'} - airtable@0.12.2: - resolution: {integrity: sha512-HS3VytUBTKj8A0vPl7DDr5p/w3IOGv6RXL0fv7eczOWAtj9Xe8ri4TAiZRXoOyo+Z/COADCj+oARFenbxhmkIg==} - engines: {node: '>=8.0.0'} - ajv-errors@3.0.0: resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==} peerDependencies: @@ -4922,10 +4897,6 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -5054,10 +5025,6 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -5109,10 +5076,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - cliui@9.0.1: - resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} - engines: {node: '>=20'} - cm6-theme-basic-light@0.2.0: resolution: {integrity: sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA==} peerDependencies: @@ -5196,11 +5159,6 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.6: - resolution: {integrity: sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==} - engines: {node: '>=22'} - hasBin: true - conf@15.1.0: resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} engines: {node: '>=20'} @@ -5292,11 +5250,6 @@ packages: resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==} engines: {node: '>=18'} - cross-env@10.1.0: - resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} - engines: {node: '>=20'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6616,9 +6569,6 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore-by-default@1.0.1: - resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -6699,10 +6649,6 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -7241,9 +7187,6 @@ packages: lodash.transform@4.6.0: resolution: {integrity: sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-process-errors@11.0.1: resolution: {integrity: sha512-HXYU83z3kH0VHfJgGyv9ZP9z7uNEayssgvpeQwSzh60mvpNqUBCPyXLSzCDSMxfGvAUUa0Kw06wJjVR46Ohd3A==} engines: {node: '>=16.17.0'} @@ -7787,11 +7730,6 @@ packages: resolution: {integrity: sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==} engines: {node: '>=20.0.0'} - nodemon@3.1.14: - resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} - engines: {node: '>=10'} - hasBin: true - nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -7829,10 +7767,6 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - npm-watch@0.13.0: - resolution: {integrity: sha512-MYcgocqCzYA44feZhFoYj69FfSaO0EeRE1gcRcmPaXIpNhUMAhNJ1pwic2C4Hn0OPOQmZKSl90CPgmwvOsVhTg==} - hasBin: true - nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -8273,9 +8207,6 @@ packages: resolution: {integrity: sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - pstree.remy@1.1.8: - resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} - pump@1.0.3: resolution: {integrity: sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==} @@ -8440,10 +8371,6 @@ packages: resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} engines: {node: '>=18'} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -8611,9 +8538,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -8744,10 +8668,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.12.0: - resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} - engines: {node: '>= 0.4'} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -8771,10 +8691,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-update-notifier@2.0.0: - resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} - engines: {node: '>=10'} - sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -9072,9 +8988,6 @@ packages: through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} - through2@4.0.2: - resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} - tinybench@6.1.4: resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} engines: {node: '>=20.0.0'} @@ -9121,17 +9034,9 @@ packages: tomlify-j0.4@3.0.0: resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==} - touch@3.1.1: - resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} - hasBin: true - tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -9264,9 +9169,6 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - undefsafe@2.0.5: - resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} - undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -9796,18 +9698,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - yargs-parser@22.0.0: - resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} - yargs@18.0.0: - resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yauzl@3.4.0: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} @@ -10396,8 +10290,6 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 - '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -13649,8 +13541,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@14.18.63': {} - '@types/node@24.19.1': dependencies: undici-types: 7.24.6 @@ -14219,8 +14109,6 @@ snapshots: dependencies: event-target-shim: 5.0.1 - abortcontroller-polyfill@1.7.8: {} - abstract-logging@2.0.1: {} accepts@1.3.8: @@ -14251,16 +14139,6 @@ snapshots: agent-base@8.0.0: {} - airtable@0.12.2: - dependencies: - '@types/node': 14.18.63 - abort-controller: 3.0.0 - abortcontroller-polyfill: 1.7.8 - lodash: 4.18.1 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - ajv-errors@3.0.0(ajv@8.20.0): dependencies: ajv: 8.20.0 @@ -14566,8 +14444,6 @@ snapshots: bignumber.js@9.3.1: {} - binary-extensions@2.3.0: {} - bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -14707,18 +14583,6 @@ snapshots: charenc@0.0.2: {} - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -14762,12 +14626,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - cliui@9.0.1: - dependencies: - string-width: 7.2.0 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 - cm6-theme-basic-light@0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(@lezer/highlight@1.2.4): dependencies: '@codemirror/language': 6.12.4 @@ -14843,15 +14701,6 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.6: - dependencies: - chalk: 5.6.2 - rxjs: 7.8.2 - shell-quote: 1.12.0 - supports-color: 10.2.2 - tree-kill: 1.2.2 - yargs: 18.0.0 - conf@15.1.0: dependencies: ajv: 8.20.0 @@ -14929,11 +14778,6 @@ snapshots: dependencies: luxon: 3.7.2 - cross-env@10.1.0: - dependencies: - '@epic-web/invariant': 1.0.0 - cross-spawn: 7.0.6 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -16275,7 +16119,8 @@ snapshots: has-bigints@1.1.0: {} - has-flag@3.0.0: {} + has-flag@3.0.0: + optional: true has-flag@4.0.0: {} @@ -16581,8 +16426,6 @@ snapshots: ieee754@1.2.1: {} - ignore-by-default@1.0.1: {} - ignore@5.3.2: {} ignore@7.0.8: {} @@ -16730,10 +16573,6 @@ snapshots: dependencies: has-bigints: 1.1.0 - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -17209,8 +17048,6 @@ snapshots: lodash.transform@4.6.0: {} - lodash@4.18.1: {} - log-process-errors@11.0.1: dependencies: is-error-instance: 2.0.0 @@ -18094,19 +17931,6 @@ snapshots: nodemailer@10.1.0: {} - nodemon@3.1.14: - dependencies: - chokidar: 3.6.0 - debug: 4.4.3(supports-color@5.5.0) - ignore-by-default: 1.0.1 - minimatch: 10.2.6 - pstree.remy: 1.1.8 - semver: 7.8.5 - simple-update-notifier: 2.0.0 - supports-color: 5.5.0 - touch: 3.1.1 - undefsafe: 2.0.5 - nopt@8.1.0: dependencies: abbrev: 3.0.1 @@ -18148,11 +17972,6 @@ snapshots: dependencies: path-key: 4.0.0 - npm-watch@0.13.0: - dependencies: - nodemon: 3.1.14 - through2: 4.0.2 - nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -18679,8 +18498,6 @@ snapshots: ps-list@8.1.1: {} - pstree.remy@1.1.8: {} - pump@1.0.3: dependencies: end-of-stream: 1.4.5 @@ -18884,10 +18701,6 @@ snapshots: dependencies: minimatch: 10.2.6 - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - readdirp@4.1.2: {} readdirp@5.1.1: {} @@ -19179,10 +18992,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - sade@1.8.1: dependencies: mri: 1.2.0 @@ -19390,8 +19199,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.12.0: {} - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -19424,10 +19231,6 @@ snapshots: signal-exit@4.1.0: {} - simple-update-notifier@2.0.0: - dependencies: - semver: 7.8.5 - sisteransi@1.0.5: {} slashes@3.0.12: {} @@ -19678,6 +19481,7 @@ snapshots: supports-color@5.5.0: dependencies: has-flag: 3.0.0 + optional: true supports-color@7.2.0: dependencies: @@ -19779,10 +19583,6 @@ snapshots: readable-stream: 2.3.8 xtend: 4.0.2 - through2@4.0.2: - dependencies: - readable-stream: 3.6.2 - tinybench@6.1.4: {} tinyclip@0.1.15: {} @@ -19814,12 +19614,8 @@ snapshots: tomlify-j0.4@3.0.0: {} - touch@3.1.1: {} - tr46@0.0.3: {} - tree-kill@1.2.2: {} - trim-lines@3.0.1: {} trim-trailing-lines@2.1.0: {} @@ -19985,8 +19781,6 @@ snapshots: uncrypto@0.1.3: {} - undefsafe@2.0.5: {} - undici-types@7.24.6: {} unicorn-magic@0.1.0: {} @@ -20444,8 +20238,6 @@ snapshots: yargs-parser@21.1.1: {} - yargs-parser@22.0.0: {} - yargs@17.7.3: dependencies: cliui: 8.0.1 @@ -20456,15 +20248,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yargs@18.0.0: - dependencies: - cliui: 9.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - string-width: 7.2.0 - y18n: 5.0.8 - yargs-parser: 22.0.0 - yauzl@3.4.0: dependencies: pend: 1.2.0 diff --git a/scripts/airtable/README.md b/scripts/airtable/README.md deleted file mode 100644 index a82884a0a..000000000 --- a/scripts/airtable/README.md +++ /dev/null @@ -1,312 +0,0 @@ -# Airtable scripts - -The one-off scripts that carried this site off Airtable. None of them run -in a build or in CI, and nothing on the site reads Airtable at runtime any more. -They are kept for provenance, for re-verification, and because the production -membership import is not yet confirmed complete. - -`docs/adr/0004-retiring-airtable.md` records why each destination was chosen — -typed tables per submission kind, committed JSON for the challenges, `lapsed` -rather than `declined`. This file is only about running them. - -| Script | Reads | Writes | Key | Wrapper | -| ----------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -------------- | -| `importMembership.ts` | base `appGHm8ztVWug6UxH`: `membership_form`, `Invites` | `membership_application`, `invite`, `application_event` | `MEMBERSHIP_AIRTABLE_API_KEY` | yes | -| `importVolunteers.ts` | base `appGHm8ztVWug6UxH`: `Volunteers`, `Roles` | `volunteer`, `volunteer_invite_ledger`, `user.role`, `pending_grant` | `MEMBERSHIP_AIRTABLE_API_KEY` | `--apply` only | -| `importSubmissions.ts` | base `appZ4d2Q9K0IepQnA`: four tables | `volunteer_signup`, `coc_report`, `lunch_and_learn_idea`, `coffee_table_group_request`, `submission_event`, `coc-attachments` | `FORMS_AIRTABLE_API_KEY` | yes | -| `snapshotChallenges.ts` | bases `appJStQemmYeoRcox`, `app10kd5ewHiLTjxn` | five JSON files in `src/data/monthlyChallenges/data/` | `PUBLIC_AIRTABLE_API_KEY` | no | - -## Before you run anything - -**The keys are read-only tokens, one per base — ask a maintainer.** Nothing here -writes back to Airtable. - -**These scripts do not read `.env`.** There is no `dotenv` anywhere in -`scripts/`, so a key sitting in `.env` is invisible to them and you get -`…_AIRTABLE_API_KEY is not set` with no hint why. Put the key on the command -line, as every example below does, or export it first: - -```bash -set -a; source .env; set +a -``` - -**Anything that writes to Postgres goes through the wrapper.** -`scripts/with-local-netlify.ts` asks the Netlify CLI for the local connection -string, refuses anything that is not `localhost`/`127.0.0.1`, and starts a -Netlify Blobs server over `netlify dev`'s own sandbox directory. So: - -- `netlify dev` must already be running in another terminal (`pnpm dev`), with - `pnpm db:migrate` applied once. -- Run from the repo root. -- Without it, an import gets no database and no blob store. - -## `importMembership.ts` - -Imports the membership base. Invites first, so `from_invite_id` on each -application can be resolved to the new `invite.id`. - -Airtable carried application state in three loose flags; the script translates -them into the status enum: - -| Airtable | Status | -| ---------------------------- | ---------------- | -| `approved` | `member` | -| `On Waiting List` | `waitlisted` | -| pulled off the queue, recent | `coffee_invited` | -| pulled off the queue, stale | `lapsed` | -| never queued, never approved | `lapsed` | - -`lapsed` is deliberately not `declined`: roughly 1,378 of these rows are people -nobody ever made a decision about, and recording that as a rejection would be -false. - -**Flags** - -- `--dry-run` — classify, print the cohort tables, write nothing. -- `--cutoff-days=N` — default `180`. Applications pulled off the waitlist longer - ago than this import as `lapsed` rather than `coffee_invited`. **The `=` is - required**; `--cutoff-days 90` is silently ignored and you get 180. A value - that is not a positive number exits 1. - -**Running it.** A dry run never constructs the database connection, so it needs -no wrapper and no `netlify dev`: - -```bash -MEMBERSHIP_AIRTABLE_API_KEY=… \ - pnpm exec tsx scripts/airtable/importMembership.ts --dry-run -``` - -A real run does: - -```bash -MEMBERSHIP_AIRTABLE_API_KEY=… pnpm exec tsx scripts/with-local-netlify.ts \ - tsx scripts/airtable/importMembership.ts -``` - -**Reading the dry run.** At the default cutoff it prints its counts next to a -snapshot taken from Airtable on 2026-09-05 — `member` 1099, `waitlisted` 7, -`coffee_invited` 63, `lapsed` 1378 — and flags the drift. Some drift is -expected: people keep joining the waitlist, and every day moves a few more rows -past the cutoff. A large difference means the classification has diverged from -what Airtable means, and is worth understanding before importing. At any other -cutoff the comparison is suppressed, since the numbers no longer apply. - -**Re-running.** Both tables key on a unique `airtable_record_id` and insert with -`onConflictDoNothing`, so a re-run inserts only what is missing and reports -`N already present`. Two things follow from that being a skip and not an update: - -- **A different `--cutoff-days` reclassifies nothing already imported.** The - cutoff only affects rows on the run that first inserts them. -- **A re-run after a partial failure keeps application→invite links.** The - Airtable-id → invite-id map is built from every invite in the database, not - just the ones this run inserted, so applications written on the second run - still resolve their `invite_id`. - -**Data quirks worth knowing.** `approved_at` is null for 553 of the 1,099 -approved rows — it postdates them — so absence of the timestamp says nothing -about approval. The old form required the code-of-conduct checkbox in the browser -but never reliably stored it, so `agreed_to_coc_at` is set only where Airtable -happened to capture it. Attendance was never recorded, so `coffee_attended_at` -is always null. - -**Production is not documented.** The wrapper refuses a non-local connection -string on purpose, so there is no supported path here for importing into the -production database. Doing it is a deliberate maintainer decision that has not -been made — see "What is deliberately left" in `docs/adr/0004-retiring-airtable.md`. - -## `importVolunteers.ts` - -Imports the 91 rows of the `Volunteers` table: who may give out Invites, what -they had left, and which historical Invites were theirs. - -**The identity join is reviewed by a person.** Airtable holds no Slack ids, so -the match is made from name, GitHub username and email against the live Slack -directory, and nothing is written that a maintainer has not confirmed -(`docs/adr/0012`). - -It therefore runs in two phases with a review in between: - -```bash -# 1. read Airtable and Slack, score every candidate, write the mapping file -MEMBERSHIP_AIRTABLE_API_KEY=… SLACK_BOT_TOKEN=xoxb-… \ - pnpm exec tsx scripts/airtable/importVolunteers.ts --propose - -# 2. open scripts/airtable/volunteerSlackMapping.json and check every row - -# 3. see what would be written -pnpm exec tsx scripts/with-local-netlify.ts \ - tsx scripts/airtable/importVolunteers.ts --apply --dry-run - -# 4. write it -pnpm exec tsx scripts/with-local-netlify.ts \ - tsx scripts/airtable/importVolunteers.ts --apply -``` - -`--propose` needs no database and no wrapper. `--apply` writes to Postgres, so -it goes through the wrapper like the other imports. - -**The mapping file is gitignored on purpose.** It pairs real names with Slack -member ids, and it is a working artefact of one migration rather than something -the site reads. Re-running `--propose` keeps every `slackUserId` already set in -the file and proposes only for rows that are still blank or new, so the review -survives a re-run; a row cleared by hand is proposed again. - -**`slackUserId` is the only field you edit.** `--propose` fills it in where one -candidate scores at least 50 and beats every other, and also where a row has -exactly one candidate at any score — the summary counts those separately as -`sole candidate` because the match can be as thin as a name prefix, so read -those rows hardest. Everything else arrives blank with up to five scored -`candidates` beside it for reference. **Leaving it blank is a valid answer** — that volunteer is skipped, and their Invites keep -`inviter_name` and stay unattributed, which is the honest result rather than a -guess. Two rows mapped to the same Slack member abort the run before anything -is written. - -**Only active volunteers get a balance.** Airtable has 91 volunteers and only 25 -`Active`. A mapped inactive volunteer is imported paused, with no credit: their -history stays attributable and reactivating them is one click in -`/admin/volunteers`, but they do not arrive holding invites. **An unmapped row -is not imported at all** — the roster is keyed on a Slack member id, so there is -nothing to key it on. The point of reviewing the file is to get that number to -zero. The 12 rows with no `Invites Available` value at -all import as zero — absent is not a number, and all twelve are recent. - -**Only active volunteers get the `volunteer` role.** The row alone would accrue -Invites its owner cannot reach, so an active volunteer is also granted the role -— directly on their user if they have signed in, otherwise as a Pending Grant -that `claimOnSignIn()` applies at first sign-in (`docs/adr/0009`, `0010`), -with `Airtable import` as the grantor. Paused volunteers arrive with no role, -exactly as pausing in `/admin/volunteers` leaves someone. - -**Allowances arrive as one net `imported` row**; Airtable's number has no -history to replay (`docs/adr/0012`). - -**Re-running is safe.** Each Volunteer is one transaction — row, role, -balance and Invite attribution commit together or not at all — so a run that -dies halfway leaves nothing for the retry to misread. Volunteers key on -`airtable_record_id` and insert with `onConflictDoNothing`, with one exception: -`role_labels` is descriptive only and is refreshed on rows already present. The -`imported` ledger row is written only in the transaction that creates the -`volunteer` row, because an append-only ledger would otherwise double every -balance on a second run; nothing else in the ledger is consulted, so an -`admin_grant` made between runs neither blocks nor doubles the import. The role -is merged into whatever a person already holds rather than duplicated, so a -second run also backfills grants for rows an earlier run imported — unless a -maintainer has paused that person since, in which case the pause stands. - -**Re-running does not correct a mapping.** A row that is already imported under -a different Slack member id stops the run before anything is written: the -`volunteer` row would keep the old id while the grant, ledger and Invites -followed the new one. Move that person by hand as a migration of their own: -re-point the `volunteer` row, the role or Pending Grant, and their Invites to -the right id, and if the balance has to move, append a debit under the old id -and a credit under the new one. Ledger rows are never updated or deleted -(`docs/adr/0011`). The script will not guess which id is right. - -Run `importMembership.ts` **first**. This script attributes Invites that script -creates; with an empty `invite` table it will report `Attributed 0`. - -## `importSubmissions.ts` - -Imports the four public form tables — 79 rows in total — one Postgres table per -kind, each inserted row also getting an `imported` row in `submission_event`. - -**Flag:** `--dry-run` only. - -```bash -# dry run: fetches everything, including the attachments, and writes nothing — -# no database or blob store, so no wrapper -FORMS_AIRTABLE_API_KEY=… pnpm exec tsx scripts/airtable/importSubmissions.ts --dry-run - -# real run -FORMS_AIRTABLE_API_KEY=… pnpm exec tsx scripts/with-local-netlify.ts \ - tsx scripts/airtable/importSubmissions.ts -``` - -It closes with a `found / expected / inserted / present` table. The expected -column is what was in Airtable on 2026-09-09 — Volunteer Form 50, CoC Violation -Reports 18, Lunch and Learn Idea 9, New Coffee Table Group 2 — and a `*` marks -any count that has drifted since. `present` is what an earlier run had already -imported; those rows are skipped before their attachments are fetched, so a -re-run neither re-hosts a file nor leaves a blob no row points at. - -**Everything imports as `new`, not `resolved`.** Airtable tracked no status at -all, so the historical rows show up as open work in `/admin`. That is the honest -presentation rather than a bug; ADR 0004 explains it. - -**Dates come from `import_created_at`, not Airtable's `createdTime`.** Most rows -share a bulk `createdTime` of 2024-09-25 from an earlier migration off Netlify -Forms, so preferring it would misdate almost every historical row. `Lunch and -Learn Idea` has no `import_created_at` and falls back to `createdTime`; it is -also the one table whose Airtable fields are TitleCase rather than lower_snake. - -**The four CoC attachments.** Four reports carry a JSON blob in `uploadedFiles` -— not an Airtable attachment array — pointing at `d33wubrfki0l68.cloudfront.net`, -Netlify's legacy asset CDN. Those URLs still resolve, so the files are fetched -and stored in the `coc-attachments` blob store rather than left as dead links, -with the content type sniffed from the leading bytes. - -- A **dry run** still fetches them, to prove the URLs resolve, but writes - nothing — so it needs no blob store. -- A **real run** checks the store before importing anything and aborts if it is - unreachable. That preflight exists because the per-file error handling would - otherwise report a missing configuration as four skipped attachments and still - exit 0 — a config mistake dressed up as a dead URL. -- Under the wrapper the files land in the **local** sandbox store, in the same - directory `netlify dev` serves, so the local site can serve them from - `/admin/submissions/coc/[id]/attachment`. -- `NETLIFY_SITE_ID` and `NETLIFY_AUTH_TOKEN` point the files at the - **production** store instead. That only makes sense beside a production - database, and there is no supported path to one (see "Production is not - documented" above), so under the wrapper the script refuses the pair: rows in - local Postgres with blob keys that resolve only in production would be a - broken local site plus orphaned production objects. Leave both unset. - -A per-file failure is a warning, not a fatal error: the report text matters far -more than the screenshot. A row whose fetch failed still records the original -filename with no blob key. - -## `snapshotChallenges.ts` - -Writes the five JSON files the monthly challenge pages read. Touches no -database, so it needs no wrapper: - -```bash -PUBLIC_AIRTABLE_API_KEY=… pnpm exec tsx scripts/airtable/snapshotChallenges.ts -``` - -Those files are committed, so the check after a re-run is `git diff` — **an empty -diff is the expected result.** Output is deterministic: a fixed field order per -snapshot, tab-indented, with empty values dropped. A diff means either Airtable -changed or the committed snapshot has drifted. - -**The view names are load-bearing.** The script reads through the same views the -old runtime fetches used, so the snapshot holds exactly the rows the site -rendered — a view can filter out rows a plain table read would include, and the -2023 and 2024 NaNoWriMo cohorts come from one `NaNoWriMo` table and are -distinguishable _only_ by view. - -| File | Table | View | -| ------------------------------- | ------------------------- | -------------------------------- | -| `member-articles.json` | `Member Articles` | _(whole table)_ | -| `hacktoberfest-2022-repos.json` | `Hacktoberfest2022 Repos` | `Default` | -| `nanowrimo-2023.json` | `NaNoWriMo` | `NaNoWriMo 2023` | -| `nanowrimo-2024.json` | `NaNoWriMo` | `NaNoWriMo 2024` | -| `pairing-challenge-2023.json` | `Pairing Challenge` | `2023 Pairing Challenge Results` | - -2023 is the last year a pairing-challenge results view exists. The old -`getTotalPairingSessions()` built that view name from the current year, so it had -been asking for a nonexistent view since 1 January 2024. - -## If the bases are ever archived - -Archiving needs base-creator rights, and nothing on the site breaks when it -happens — but every script here stops working, and two pieces of content still -point at Airtable: the `/member-survey` redirect in `netlify.toml`, and the entry -links on the November 2023 and 2024 challenge pages. See ADR 0004. - -## Shared modules - -`classify.ts` (how an Airtable membership row maps onto an application status) -and `match.ts` (scoring a volunteer against the Slack directory) sit beside the -scripts so they can be imported without running an import: each script executes -`main()` on load. Their tests are the `*.test.ts` files next to them. diff --git a/scripts/airtable/classify.test.ts b/scripts/airtable/classify.test.ts deleted file mode 100644 index 878f18183..000000000 --- a/scripts/airtable/classify.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { bool, classify, date, str } from './classify'; - -const cutoff = new Date('2026-06-01T00:00:00Z'); - -describe('classify', () => { - test('approved is a member, whatever else the row says', () => { - expect( - classify( - { - approved: true, - 'On Waiting List': true, - from_waitinglist_at: '2026-07-01', - approved_at: '2026-07-08', - }, - cutoff, - ), - ).toEqual({ - status: 'member', - source: 'waitlist_signup', - coffeeInvitedAt: new Date('2026-07-01'), - approvedAt: new Date('2026-07-08'), - }); - }); - - test('approved without approved_at is still a member — 553 rows predate the field', () => { - expect(classify({ approved: true }, cutoff)).toMatchObject({ - status: 'member', - approvedAt: null, - }); - }); - - test('on the waiting list is waitlisted', () => { - expect(classify({ 'On Waiting List': true }, cutoff)).toEqual({ - status: 'waitlisted', - source: 'waitlist_signup', - coffeeInvitedAt: null, - approvedAt: null, - }); - }); - - test('invited to coffee since the cutoff is still in progress; before it has lapsed', () => { - expect( - classify({ from_waitinglist_at: '2026-06-01T00:00:00Z' }, cutoff), - ).toMatchObject({ status: 'coffee_invited' }); - expect( - classify({ from_waitinglist_at: '2026-05-31T23:59:59Z' }, cutoff), - ).toMatchObject({ - status: 'lapsed', - coffeeInvitedAt: new Date('2026-05-31T23:59:59Z'), - }); - }); - - test('a row with nothing set has lapsed', () => { - expect(classify({}, cutoff)).toMatchObject({ - status: 'lapsed', - coffeeInvitedAt: null, - }); - }); - - test('the source reads Airtable’s select either as an object or a string', () => { - expect( - classify({ Source: { name: 'Volunteer Invite' } }, cutoff).source, - ).toBe('volunteer_invite'); - expect(classify({ Source: 'Volunteer Invite' }, cutoff).source).toBe( - 'volunteer_invite', - ); - expect(classify({ Source: 'Website' }, cutoff).source).toBe( - 'waitlist_signup', - ); - }); -}); - -describe('the field readers', () => { - test('str trims and treats blank or non-string as absent', () => { - expect(str(' x ')).toBe('x'); - expect(str(' ')).toBeNull(); - expect(str(3)).toBeNull(); - }); - - test('date rejects the unparseable rather than passing Invalid Date on', () => { - expect(date('2026-01-02')).toEqual(new Date('2026-01-02')); - expect(date('yesterday')).toBeNull(); - expect(date(undefined)).toBeNull(); - }); - - test('bool is only ever a literal true', () => { - expect(bool(true)).toBe(true); - expect(bool('true')).toBe(false); - expect(bool(1)).toBe(false); - }); -}); diff --git a/scripts/airtable/classify.ts b/scripts/airtable/classify.ts deleted file mode 100644 index 99ec01330..000000000 --- a/scripts/airtable/classify.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { ApplicationSource, ApplicationStatus } from '../../src/db'; - -/** - * How an Airtable membership row maps onto an application status. - * - * Apart from `importMembership.ts` because that script runs on import; this - * is the decision tree worth testing. - */ - -export function str(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function date(value: unknown): Date | null { - const raw = str(value); - if (!raw) return null; - const parsed = new Date(raw); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -export function bool(value: unknown): boolean { - return value === true; -} - -export type Classified = { - status: ApplicationStatus; - source: ApplicationSource; - coffeeInvitedAt: Date | null; - approvedAt: Date | null; -}; - -export function classify( - fields: Record, - cutoff: Date, -): Classified { - const sourceName = str( - typeof fields.Source === 'object' && fields.Source !== null - ? (fields.Source as { name?: string }).name - : fields.Source, - ); - const source: ApplicationSource = - sourceName === 'Volunteer Invite' ? 'volunteer_invite' : 'waitlist_signup'; - - const approved = bool(fields.approved); - const onWaitingList = bool(fields['On Waiting List']); - const fromWaitlistAt = date(fields.from_waitinglist_at); - const approvedAt = date(fields.approved_at); - - // `approved` is the second approval (membership granted). Note that 553 of - // the 1,099 approved rows predate the `approved_at` field, so the timestamp - // is frequently null even when the flag is set — don't infer from its - // absence. - if (approved) { - return { - status: 'member', - source, - coffeeInvitedAt: fromWaitlistAt, - approvedAt, - }; - } - - if (onWaitingList) { - return { - status: 'waitlisted', - source, - coffeeInvitedAt: null, - approvedAt: null, - }; - } - - if (fromWaitlistAt && fromWaitlistAt >= cutoff) { - return { - status: 'coffee_invited', - source, - coffeeInvitedAt: fromWaitlistAt, - approvedAt: null, - }; - } - - return { - status: 'lapsed', - source, - coffeeInvitedAt: fromWaitlistAt, - approvedAt: null, - }; -} diff --git a/scripts/airtable/importMembership.ts b/scripts/airtable/importMembership.ts deleted file mode 100644 index 3759123f4..000000000 --- a/scripts/airtable/importMembership.ts +++ /dev/null @@ -1,303 +0,0 @@ -import Airtable from 'airtable'; -import { inArray } from 'drizzle-orm'; - -import { - db, - invite, - membershipApplication, - type NewMembershipApplication, -} from '../../src/db'; -import { recordImport } from '../../src/lib/history/eventLog'; -import { bool, classify, date, str } from './classify'; - -/** - * One-off import of the Airtable membership base into Postgres. - * - * Airtable carries application state in three loose flags. This translates - * them into the explicit status enum: - * - * On Waiting List -> waitlisted (the live queue) - * approved -> member (membership granted) - * pulled off queue, recent -> coffee_invited (in flight) - * pulled off queue, stale -> lapsed - * never queued, never approved -> lapsed (stale volunteer invites) - * - * `lapsed` is not `declined`. Roughly 1,378 of these rows are people nobody - * ever made a decision about; recording that as a rejection would be false. - * - * Idempotent: rows are keyed on `airtable_record_id`, so a re-run inserts only - * what is missing. Run with `--dry-run` first and check the cohort counts - * against the ones in scripts/airtable/README.md. - */ - -const BASE_ID = 'appGHm8ztVWug6UxH'; -const APPLICATIONS_TABLE = 'membership_form'; -const INVITES_TABLE = 'Invites'; - -const DEFAULT_CUTOFF_DAYS = 180; - -/** - * Cohort sizes read straight from Airtable on 2026-09-05, at a 180-day cutoff. - * The dry run prints these next to what it actually computed so a mismatch is - * obvious before anything is written. - * - * These drift as the live base changes — people keep joining the waitlist, and - * every day moves a few more rows past the cutoff. A small difference is - * expected; a large one means the classification logic has diverged from what - * Airtable means, and is worth understanding before importing. - */ -const EXPECTED_AT_180_DAYS: Record = { - member: 1099, - waitlisted: 7, - coffee_invited: 63, - lapsed: 1378, -}; - -type AirtableRow = { - id: string; - fields: Record; -}; - -async function fetchAll(table: string, apiKey: string): Promise { - const base = new Airtable({ apiKey }).base(BASE_ID); - const rows: AirtableRow[] = []; - - await base(table) - .select({ pageSize: 100 }) - .eachPage((records, next) => { - for (const record of records) { - rows.push({ - id: record.id, - fields: record.fields as Record, - }); - } - next(); - }); - - return rows; -} - -async function main() { - const args = process.argv.slice(2); - const dryRun = args.includes('--dry-run'); - const cutoffArg = args.find((arg) => arg.startsWith('--cutoff-days=')); - const cutoffDays = cutoffArg - ? Number(cutoffArg.split('=')[1]) - : DEFAULT_CUTOFF_DAYS; - - if (!Number.isFinite(cutoffDays) || cutoffDays <= 0) { - throw new Error(`Invalid --cutoff-days: ${cutoffArg}`); - } - - const apiKey = process.env.MEMBERSHIP_AIRTABLE_API_KEY; - if (!apiKey) { - throw new Error( - 'MEMBERSHIP_AIRTABLE_API_KEY is not set. Ask a maintainer for a read ' + - 'key for the membership base.', - ); - } - - const cutoff = new Date(Date.now() - cutoffDays * 24 * 60 * 60 * 1000); - console.log( - `Cutoff: ${cutoffDays} days — applications pulled off the waitlist before ` + - `${cutoff.toISOString().slice(0, 10)} import as \`lapsed\`.\n`, - ); - - console.log('Fetching Airtable rows…'); - const [inviteRows, applicationRows] = await Promise.all([ - fetchAll(INVITES_TABLE, apiKey), - fetchAll(APPLICATIONS_TABLE, apiKey), - ]); - console.log( - `Fetched ${applicationRows.length} applications, ${inviteRows.length} invites.\n`, - ); - - const counts = new Map(); - const prepared = applicationRows.map((row) => { - const classified = classify(row.fields, cutoff); - const key = `${classified.status} / ${classified.source}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - return { row, classified }; - }); - - console.log('Cohorts:'); - for (const [key, count] of [...counts.entries()].sort()) { - console.log(` ${key.padEnd(34)} ${count}`); - } - const byStatus = new Map(); - for (const { classified } of prepared) { - byStatus.set(classified.status, (byStatus.get(classified.status) ?? 0) + 1); - } - const usingDefaultCutoff = cutoffDays === DEFAULT_CUTOFF_DAYS; - console.log( - usingDefaultCutoff - ? '\nBy status (expected = snapshot taken 2026-09-05):' - : '\nBy status:', - ); - for (const status of [ - ...new Set([...byStatus.keys(), ...Object.keys(EXPECTED_AT_180_DAYS)]), - ].sort()) { - const count = byStatus.get(status) ?? 0; - const expected = usingDefaultCutoff - ? EXPECTED_AT_180_DAYS[status] - : undefined; - const note = - expected === undefined - ? '' - : expected === count - ? ' (matches)' - : ` (expected ${expected}, drift ${count - expected > 0 ? '+' : ''}${count - expected})`; - console.log(` ${status.padEnd(20)} ${String(count).padStart(5)}${note}`); - } - console.log(` ${'total'.padEnd(20)} ${String(prepared.length).padStart(5)}`); - - if (dryRun) { - console.log('\nDry run — nothing written.'); - process.exit(0); - } - - const database = db(); - - console.log('\nImporting invites…'); - let insertedInvites = 0; - for (const row of inviteRows) { - const statusName = str( - typeof row.fields.Status === 'object' && row.fields.Status !== null - ? (row.fields.Status as { name?: string }).name - : row.fields.Status, - ); - const [inserted] = await database - .insert(invite) - .values({ - inviterName: str(row.fields['Inviter Name']), - inviteeName: str(row.fields.Name), - inviteeEmail: str(row.fields.Email), - status: - statusName === 'Completed' - ? 'completed' - : statusName === 'Accepted' - ? 'accepted' - : 'pending', - airtableRecordId: row.id, - createdAt: date(row.fields['Invited Date']) ?? new Date(), - }) - .onConflictDoNothing({ target: invite.airtableRecordId }) - .returning({ id: invite.id }); - - if (inserted) insertedInvites += 1; - } - - // Map every invite, not just the ones this run inserted: `onConflictDoNothing` - // returns nothing for a row that already exists, and a re-run after a partial - // failure would otherwise write the remaining applications with no invite. - const inviteIdByAirtableId = new Map(); - if (inviteRows.length > 0) { - const existing = await database - .select({ id: invite.id, airtableRecordId: invite.airtableRecordId }) - .from(invite) - .where( - inArray( - invite.airtableRecordId, - inviteRows.map((row) => row.id), - ), - ); - for (const row of existing) { - if (row.airtableRecordId) { - inviteIdByAirtableId.set(row.airtableRecordId, row.id); - } - } - } - console.log( - `Inserted ${insertedInvites} invites, ${inviteIdByAirtableId.size - insertedInvites} already present.`, - ); - - console.log('Importing applications…'); - let insertedCount = 0; - const importedAt = new Date(); - - // Oldest first, so the `reference` identity column — the number maintainers - // see — counts up with application age rather than Airtable's fetch order. - const inOrder = [...prepared].sort( - (a, b) => - (date(a.row.fields.created)?.getTime() ?? 0) - - (date(b.row.fields.created)?.getTime() ?? 0), - ); - - for (const { row, classified } of inOrder) { - const fields = row.fields; - const submittedAt = date(fields.created) ?? new Date(); - const fromInviteId = str(fields.from_invite_id); - - const values: NewMembershipApplication = { - status: classified.status, - source: classified.source, - isPriority: classified.source === 'volunteer_invite', - name: str(fields.name) ?? '(no name given)', - email: str(fields.email) ?? '', - pronouns: str(fields.pronouns), - githubUsername: str(fields.githubUsername), - twitterUsername: str(fields.twitterUsername), - howDidYouHear: str(fields.howDidYouHearAboutUs), - journey: str(fields.journey), - codeInterests: str(fields.codeInterests), - virtualCoffee: str(fields.virtualCoffee), - // The old form required this checkbox in the browser but never stored - // it, so only rows where Airtable happened to capture it get a date. - agreedToCocAt: bool(fields.agree) ? submittedAt : null, - referrer: str(fields.referrer), - inviteId: fromInviteId - ? (inviteIdByAirtableId.get(fromInviteId) ?? null) - : null, - submittedAt, - waitlistedAt: submittedAt, - coffeeInvitedAt: classified.coffeeInvitedAt, - // Attendance was never recorded under the old process. - coffeeAttendedAt: null, - approvedAt: classified.approvedAt, - // A terminal row closes. Airtable never says when a never-queued row - // lapsed, so it closes at import — the moment the site said so — rather - // than a made-up historical date. Its History event is still backdated - // to `submittedAt` by `recordImport`, so the two will not agree. - closedAt: - classified.status === 'lapsed' - ? (classified.coffeeInvitedAt ?? importedAt) - : null, - airtableRecordId: row.id, - }; - - // One transaction: a row without its `imported` event has no history, - // and the conflict target means a re-run would never come back for it. - const inserted = await database.transaction(async (tx) => { - const [created] = await tx - .insert(membershipApplication) - .values(values) - .onConflictDoNothing({ target: membershipApplication.airtableRecordId }) - .returning({ id: membershipApplication.id }); - - if (!created) return false; - - await recordImport( - { kind: 'application', id: created.id }, - row.id, - submittedAt, - classified.status, - tx, - ); - return true; - }); - - if (inserted) insertedCount += 1; - } - - console.log( - `\nInserted ${insertedCount} applications (${ - prepared.length - insertedCount - } already present).`, - ); - process.exit(0); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/scripts/airtable/importSubmissions.ts b/scripts/airtable/importSubmissions.ts deleted file mode 100644 index f086876c1..000000000 --- a/scripts/airtable/importSubmissions.ts +++ /dev/null @@ -1,521 +0,0 @@ -import Airtable from 'airtable'; -import { getStore } from '@netlify/blobs'; -import { inArray } from 'drizzle-orm'; -import { randomUUID } from 'node:crypto'; - -import { - cocReport, - coffeeTableGroupRequest, - db, - lunchAndLearnIdea, - volunteerSignup, -} from '../../src/db'; -import { ATTACHMENT_STORE } from '../../src/lib/submissions/attachments'; -import { isLocalDatabaseUrl } from '../lib/localOnly'; -import { - recordImport, - type SubmissionEventKey, - type SubmissionTable, -} from '../../src/lib/history/eventLog'; - -/** - * One-off import of the Airtable "Form Submissions" base into Postgres. - * - * Four tables, 79 rows between them. Idempotent: every row is keyed on - * `airtable_record_id`, so a re-run inserts only what is missing. Run with - * `--dry-run` first and check the counts against the numbers below. - * - * Two things about this data are easy to get wrong: - * - * - `createdTime` is not the submission date. The CoC, Volunteer and Coffee - * Table tables carry `import_created_at` from an earlier migration off - * Netlify Forms, and most rows share a bulk `createdTime` of 2024-09-25. - * Preferring `createdTime` would misdate almost every historical row. - * - Four CoC reports carry attachment metadata pointing at - * `LEGACY_CDN_HOST`, Netlify's legacy asset CDN. Those URLs still resolve, - * so the files are fetched into Netlify Blobs here rather than left as a - * dead link. - * - * A dry run needs only the Airtable key. A real run also needs a blob store, - * because storing those four attachments happens outside the Netlify runtime — - * it fails up front rather than silently importing 79 rows with no files - * attached. The wrapper supplies a local store; NETLIFY_SITE_ID and - * NETLIFY_AUTH_TOKEN target the production one instead. - * - * Full runbook: scripts/airtable/README.md. - * - * FORMS_AIRTABLE_API_KEY=… pnpm exec tsx scripts/airtable/importSubmissions.ts --dry-run - * - * FORMS_AIRTABLE_API_KEY=… pnpm exec tsx scripts/with-local-netlify.ts \ - * tsx scripts/airtable/importSubmissions.ts - */ - -const BASE_ID = 'appZ4d2Q9K0IepQnA'; - -/** The only place a legacy attachment lives; anything else is not fetched. */ -const LEGACY_CDN_HOST = 'd33wubrfki0l68.cloudfront.net'; - -/** Read from Airtable on 2026-09-09; a dry run prints these alongside what it found. */ -const EXPECTED: Record = { - 'Volunteer Form': 50, - 'CoC Violation Reports': 18, - 'Lunch and Learn Idea': 9, - 'New Coffee Table Group': 2, -}; - -type AirtableRow = { - id: string; - createdTime: string; - fields: Record; -}; - -function str(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function date(value: unknown): Date | null { - const raw = str(value); - if (!raw) return null; - const parsed = new Date(raw); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -/** - * The real submission date. - * - * `import_created_at` is when the row was created in Netlify Forms; Airtable's - * own `createdTime` is when it was bulk-imported here. Prefer the former. - */ -function submittedAt(row: AirtableRow): Date { - return ( - date(row.fields.import_created_at) ?? date(row.createdTime) ?? new Date() - ); -} - -async function fetchAll(table: string, apiKey: string): Promise { - const base = new Airtable({ apiKey }).base(BASE_ID); - const rows: AirtableRow[] = []; - - await base(table) - .select({ pageSize: 100 }) - .eachPage((records, next) => { - for (const record of records) { - rows.push({ - id: record.id, - createdTime: record._rawJson.createdTime, - fields: record.fields as Record, - }); - } - next(); - }); - - return rows; -} - -type LegacyAttachment = { - filename: string; - type: string; - size: number; - url: string; -}; - -/** `uploadedFiles` holds a JSON blob, not an Airtable attachment array. */ -function parseLegacyAttachment(value: unknown): LegacyAttachment | null { - const raw = str(value); - if (!raw) return null; - - try { - const parsed = JSON.parse(raw) as Partial; - if (!parsed.url || !parsed.filename) return null; - return { - filename: parsed.filename, - type: parsed.type ?? 'application/octet-stream', - size: Number(parsed.size) || 0, - url: parsed.url, - }; - } catch { - return null; - } -} - -/** Content types the legacy blob never records reliably; sniff the bytes instead. */ -function sniffContentType(bytes: Uint8Array, fallback: string): string { - if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png'; - if (bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg'; - if (bytes[0] === 0x47 && bytes[1] === 0x49) return 'image/gif'; - if (bytes[0] === 0x25 && bytes[1] === 0x50) return 'application/pdf'; - if (bytes[0] === 0x50 && bytes[1] === 0x4b) return 'application/zip'; - return fallback; -} - -type StoredLegacy = { - key: string; - filename: string; - contentType: string; - size: number; -}; - -/** - * The blob store. - * - * `getStore(name)` reads credentials from the environment, which - * `scripts/with-local-netlify.ts` fills in with a local sandbox store — the - * same reason it also supplies the database connection string. Passing - * NETLIFY_SITE_ID and NETLIFY_AUTH_TOKEN overrides that and writes to the real - * production store — only ever alongside a production database, which the - * wrapper refuses; `assertStoresMatch` keeps the two from coming apart. - * - * With neither, the writes fail with "The environment has not been configured - * to use Netlify Blobs", which the per-file error handling would report as a - * skipped attachment: a config mistake dressed up as a dead URL. Hence the - * preflight below. - */ -function attachmentStore() { - const siteID = process.env.NETLIFY_SITE_ID; - const token = process.env.NETLIFY_AUTH_TOKEN; - - return siteID && token - ? getStore({ name: ATTACHMENT_STORE, siteID, token }) - : getStore(ATTACHMENT_STORE); -} - -/** - * The blob store and the database have to be the same environment: local rows - * whose `attachment_blob_key` only resolves in the production store are a - * broken local site and four orphaned production objects. The wrapper only - * ever supplies a local database, so a production store override under it is - * always this mistake. - */ -function assertStoresMatch(): void { - const productionStore = Boolean( - process.env.NETLIFY_SITE_ID && process.env.NETLIFY_AUTH_TOKEN, - ); - const databaseUrl = - process.env.NETLIFY_DB_URL ?? process.env.DATABASE_URL ?? ''; - if (productionStore && isLocalDatabaseUrl(databaseUrl)) { - throw new Error( - 'NETLIFY_SITE_ID and NETLIFY_AUTH_TOKEN point attachments at the ' + - 'production blob store, but the database is local. Unset them for ' + - 'a local run; the two must be the same environment.', - ); - } -} - -/** - * Best-effort, like `discardAttachment` in `src/lib/submissions/attachments`, - * but against this script's store, which may be the production override. - */ -async function discardUpload(key: string): Promise { - try { - await attachmentStore().delete(key); - } catch (error) { - console.error(` orphaned attachment ${key} could not be deleted`, error); - } -} - -/** - * Fail before importing anything rather than after, so a misconfigured run - * cannot quietly drop every attachment and still report success. - */ -async function assertBlobsUsable(): Promise { - try { - // A read of a key that does not exist is enough to prove credentials. - await attachmentStore().get('__preflight__'); - } catch (error) { - throw new Error( - 'Netlify Blobs is not configured, so the four historical CoC ' + - 'attachments could not be stored.\n\n' + - 'Run this through the wrapper, which supplies a local store:\n\n' + - ' pnpm exec tsx scripts/with-local-netlify.ts \\\n' + - ' tsx scripts/airtable/importSubmissions.ts\n\n' + - 'To write to the production store instead, set NETLIFY_SITE_ID and ' + - 'NETLIFY_AUTH_TOKEN.\n\n' + - `Underlying error: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } -} - -async function rehost( - attachment: LegacyAttachment, - dryRun: boolean, -): Promise { - const url = URL.parse(attachment.url); - if (url?.protocol !== 'https:' || url.hostname !== LEGACY_CDN_HOST) { - console.warn( - ` ! ${attachment.filename}: ${attachment.url} is not on ${LEGACY_CDN_HOST}; skipping the file.`, - ); - return null; - } - - try { - const response = await fetch(url, { - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - console.warn( - ` ! ${attachment.filename}: CDN returned ${response.status}; skipping the file.`, - ); - return null; - } - - const buffer = await response.arrayBuffer(); - const bytes = new Uint8Array(buffer); - const contentType = sniffContentType(bytes, attachment.type); - const key = randomUUID(); - - if (!dryRun) { - await attachmentStore().set(key, buffer, { - metadata: { filename: attachment.filename, contentType }, - }); - } - - console.log( - ` + ${attachment.filename} (${buffer.byteLength} bytes, ${contentType})`, - ); - - return { - key, - filename: attachment.filename, - contentType, - size: buffer.byteLength, - }; - } catch (error) { - // A dead URL is not a reason to abandon the whole import — the report - // text matters far more than the screenshot. - console.warn( - ` ! ${attachment.filename}: ${ - error instanceof Error ? error.message : 'fetch failed' - }; skipping the file.`, - ); - return null; - } -} - -async function main() { - const dryRun = process.argv.slice(2).includes('--dry-run'); - - const apiKey = process.env.FORMS_AIRTABLE_API_KEY; - if (!apiKey) { - throw new Error( - 'FORMS_AIRTABLE_API_KEY is not set. Ask a maintainer for a read key ' + - 'for the Form Submissions base.', - ); - } - - // A dry run still fetches the attachments (to prove the URLs resolve) but - // never writes them, so it does not need working blob credentials. - if (!dryRun) { - assertStoresMatch(); - await assertBlobsUsable(); - } - - console.log(dryRun ? 'Dry run — nothing will be written.\n' : 'Importing.\n'); - - const summary: Record< - string, - { found: number; inserted: number; present: number } - > = {}; - - /** Insert one row and its `imported` event, skipping anything already there. */ - async function importRows>( - label: string, - rows: AirtableRow[], - table: SubmissionTable, - eventKey: SubmissionEventKey, - toValues: (row: AirtableRow) => Promise | T, - ) { - let inserted = 0; - - // Rows a previous run imported are skipped before `toValues` runs, not - // left to `onConflictDoNothing`: for a CoC report `toValues` re-hosts the - // attachment under a fresh random key, and a conflict after that upload - // would strand the blob with no row pointing at it. - const present = new Set( - dryRun - ? [] - : ( - await db() - .select({ airtableRecordId: table.airtableRecordId }) - .from(table) - .where( - inArray( - table.airtableRecordId, - rows.map((row) => row.id), - ), - ) - ).map((row) => row.airtableRecordId), - ); - - // Oldest first, so the `reference` identity column — the number - // maintainers see — counts up with submission age rather than Airtable's - // fetch order. - const inOrder = [...rows] - .filter((row) => !present.has(row.id)) - .sort((a, b) => submittedAt(a).getTime() - submittedAt(b).getTime()); - - for (const row of inOrder) { - const values = await toValues(row); - - if (dryRun) { - inserted++; - continue; - } - - // `onConflictDoNothing` on the unique airtable_record_id is what makes - // two runs at once safe — which is also why the row and its `imported` - // event commit together: a re-run would never come back for the event. - let created = false; - try { - created = await db().transaction(async (tx) => { - const [stored] = await tx - .insert(table) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .values(values as any) - .onConflictDoNothing({ target: table.airtableRecordId }) - .returning({ id: table.id }); - - if (!stored) return false; - - await recordImport( - { kind: 'submission', id: stored.id, table, eventKey }, - row.id, - submittedAt(row), - undefined, - tx, - ); - return true; - }); - } finally { - // The pre-read above covers an ordinary re-run; this covers a - // concurrent one, and a database failure after the upload. Either - // way the row was not written, so the blob it points at goes too. - const key = values.attachmentBlobKey; - if (!created && typeof key === 'string') { - await discardUpload(key); - } - } - - if (!created) continue; - - inserted++; - } - - summary[label] = { found: rows.length, inserted, present: present.size }; - } - - // --- Volunteer Form ---------------------------------------------------- - const volunteers = await fetchAll('Volunteer Form', apiKey); - await importRows( - 'Volunteer Form', - volunteers, - volunteerSignup, - 'volunteerSignupId', - (row) => ({ - name: str(row.fields.name) ?? 'Unknown', - email: str(row.fields.email) ?? '', - githubUsername: str(row.fields.github_username), - position: str(row.fields.position), - description: str(row.fields.description), - submittedAt: submittedAt(row), - status: 'new' as const, - airtableRecordId: row.id, - }), - ); - - // --- CoC Violation Reports --------------------------------------------- - const reports = await fetchAll('CoC Violation Reports', apiKey); - await importRows( - 'CoC Violation Reports', - reports, - cocReport, - 'cocReportId', - async (row) => { - const legacy = parseLegacyAttachment(row.fields.uploadedFiles); - const stored = legacy ? await rehost(legacy, dryRun) : null; - - return { - name: str(row.fields.name), - email: str(row.fields.email), - reporteeName: str(row.fields.reportee_name) ?? 'Unknown', - timeLocation: str(row.fields.time_location) ?? '', - description: str(row.fields.description) ?? '', - anyoneElseInvolved: str(row.fields.anyone_else_involved), - attachmentBlobKey: stored?.key ?? null, - attachmentFilename: stored?.filename ?? legacy?.filename ?? null, - attachmentContentType: stored?.contentType ?? null, - attachmentSize: stored?.size ?? null, - submittedAt: submittedAt(row), - status: 'new' as const, - airtableRecordId: row.id, - }; - }, - ); - - // --- Lunch and Learn Idea ---------------------------------------------- - const ideas = await fetchAll('Lunch and Learn Idea', apiKey); - await importRows( - 'Lunch and Learn Idea', - ideas, - lunchAndLearnIdea, - 'lunchAndLearnIdeaId', - (row) => ({ - name: str(row.fields.Name) ?? 'Unknown', - email: str(row.fields.Email) ?? '', - topic: str(row.fields.Topic) ?? 'Untitled', - description: str(row.fields.Description), - format: str(row.fields.Format), - timing: str(row.fields.Timing), - submittedAt: submittedAt(row), - status: 'new' as const, - airtableRecordId: row.id, - }), - ); - - // --- New Coffee Table Group -------------------------------------------- - const groups = await fetchAll('New Coffee Table Group', apiKey); - await importRows( - 'New Coffee Table Group', - groups, - coffeeTableGroupRequest, - 'coffeeTableGroupRequestId', - (row) => ({ - name: str(row.fields.name) ?? 'Unknown', - email: str(row.fields.email) ?? '', - groupName: str(row.fields.group_name), - description: str(row.fields.description), - submittedAt: submittedAt(row), - status: 'new' as const, - airtableRecordId: row.id, - }), - ); - - console.log('\nTable found expected inserted present'); - for (const [label, counts] of Object.entries(summary)) { - const expected = EXPECTED[label] ?? 0; - const flag = counts.found === expected ? ' ' : '*'; - console.log( - `${label.padEnd(24)} ${String(counts.found).padStart(5)} ${String( - expected, - ).padStart(8)} ${String(counts.inserted).padStart(8)}${flag} ${String( - counts.present, - ).padStart(7)}`, - ); - } - console.log( - '\n* means the row count has drifted from what was in Airtable on ' + - '2026-09-09. A small difference is expected if anyone has submitted ' + - 'since; a large one is worth understanding before importing.', - ); -} - -main() - .then(() => process.exit(0)) - .catch((error) => { - console.error(error); - process.exit(1); - }); diff --git a/scripts/airtable/importVolunteers.ts b/scripts/airtable/importVolunteers.ts deleted file mode 100644 index d1229d864..000000000 --- a/scripts/airtable/importVolunteers.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { readFileSync, writeFileSync } from 'fs'; -import { resolve } from 'path'; - -import Airtable from 'airtable'; -import { eq, inArray } from 'drizzle-orm'; - -import { db, invite, volunteer } from '../../src/db'; -import { fetchSlackMembers } from '../../src/data/slackMembers'; -import { importBalance } from '../../src/lib/volunteers/invites'; -import { addVolunteerRole } from '../../src/lib/access/roleAssignment'; -import { CONFIDENT_SCORE, score, type Candidate } from './match'; - -/** - * One-off import of the Airtable `Volunteers` table into Postgres. Airtable - * holds no Slack ids, so the join is a reviewed mapping: `--propose` scores - * every candidate into an uncommitted mapping file, a maintainer confirms or - * corrects each row, `--apply` writes only what the file says (docs/adr/0012). - */ - -const BASE_ID = 'appGHm8ztVWug6UxH'; -const VOLUNTEERS_TABLE = 'Volunteers'; -/** `Volunteers.Roles` links here; the API returns only the record ids. */ -const ROLES_TABLE = 'Roles'; - -/** Not committed — see `.gitignore` and this file's header. */ -const MAPPING_PATH = resolve(import.meta.dirname, 'volunteerSlackMapping.json'); - -/** - * Read from Airtable on 2026-09-10. Printed next to what a run actually finds, - * so drift is obvious before anything is written. - */ -const EXPECTED = { - total: 91, - active: 25, - withoutGithub: 9, - withoutBalance: 12, -}; - -type AirtableRow = { id: string; fields: Record }; - -type MappingEntry = { - airtableRecordId: string; - name: string; - profileName: string | null; - githubUsername: string | null; - email: string | null; - active: boolean; - invitesAvailable: number | null; - roleLabels: string | null; - inviteRecordIds: string[]; - /** The one field a maintainer edits. Empty means "do not import this row". */ - slackUserId: string; - /** Written by `--propose`; `apply` reads only the handle of the chosen one. */ - candidates: Candidate[]; -}; - -/** - * The Slack handle of the member the maintainer chose — the roster renders it - * as `@handle`, so it has to be Slack's, not the Airtable GitHub username. An - * id typed by hand with no matching candidate has none. - */ -function slackHandle(entry: MappingEntry): string | null { - const match = entry.candidates.find( - (candidate) => candidate.slackUserId === entry.slackUserId, - ); - return match?.handle.trim() || null; -} - -async function fetchAll(table: string, apiKey: string): Promise { - const base = new Airtable({ apiKey }).base(BASE_ID); - const rows: AirtableRow[] = []; - - await base(table) - .select({ pageSize: 100 }) - .eachPage((records, next) => { - for (const record of records) { - rows.push({ - id: record.id, - fields: record.fields as Record, - }); - } - next(); - }); - - return rows; -} - -/** Airtable lookups come back as arrays; everything here wants one value. */ -function first(value: unknown): string | null { - const raw = Array.isArray(value) ? value[0] : value; - if (raw === null || raw === undefined) return null; - if (typeof raw === 'object' && 'name' in raw) { - return String((raw as { name: unknown }).name).trim() || null; - } - const text = String(raw).trim(); - return text.length > 0 ? text : null; -} - -function linkedIds(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => - entry && typeof entry === 'object' && 'id' in entry - ? String((entry as { id: unknown }).id) - : String(entry), - ) - .filter((entry) => entry.startsWith('rec')); -} - -/** The reviewed ids from an earlier run, so re-proposing does not undo the review. */ -function reviewedIds(): Map { - let raw: string; - try { - raw = readFileSync(MAPPING_PATH, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map(); - throw error; - } - - const previous = JSON.parse(raw) as MappingEntry[]; - return new Map( - previous - .filter((entry) => (entry.slackUserId ?? '').trim().length > 0) - .map((entry) => [entry.airtableRecordId, entry.slackUserId.trim()]), - ); -} - -async function propose(apiKey: string) { - const [rows, roles, members] = await Promise.all([ - fetchAll(VOLUNTEERS_TABLE, apiKey), - fetchAll(ROLES_TABLE, apiKey), - fetchSlackMembers(), - ]); - - console.log( - `Fetched ${rows.length} volunteers, ${roles.length} roles and ${members.length} Slack members.\n`, - ); - - const roleNames = new Map( - roles.map((role) => [role.id, first(role.fields.Name)] as const), - ); - const unresolvedRoles = new Set(); - - const reviewed = reviewedIds(); - let carried = 0; - let confident = 0; - let sole = 0; - - const entries: MappingEntry[] = rows.map((row) => { - const balance = row.fields['Invites Available']; - - const labels = linkedIds(row.fields.Roles).flatMap((id) => { - const name = roleNames.get(id); - if (!name) unresolvedRoles.add(id); - return name ? [name] : []; - }); - - const entry: MappingEntry = { - airtableRecordId: row.id, - name: first(row.fields.Name) ?? '(no name)', - profileName: first(row.fields['Profile Name']), - githubUsername: first(row.fields['GitHub Username']), - email: first(row.fields.Email), - active: row.fields.Active === true, - invitesAvailable: typeof balance === 'number' ? balance : null, - roleLabels: labels.length > 0 ? labels.join(', ') : null, - inviteRecordIds: linkedIds(row.fields.Invites), - slackUserId: '', - candidates: [], - }; - - entry.candidates = members - .map((member) => score(member, entry)) - .filter((candidate): candidate is Candidate => candidate !== null) - .sort((a, b) => b.score - a.score) - .slice(0, 5); - - const previous = reviewed.get(row.id); - const [best, runnerUp] = entry.candidates; - if (previous) { - entry.slackUserId = previous; - carried += 1; - } else if ( - best && - best.score >= CONFIDENT_SCORE && - best.score > (runnerUp?.score ?? 0) - ) { - entry.slackUserId = best.slackUserId; - confident += 1; - } else if (best && entry.candidates.length === 1) { - // Possibly only a name prefix; the review is what makes this safe. - entry.slackUserId = best.slackUserId; - sole += 1; - } - - return entry; - }); - - writeFileSync(MAPPING_PATH, `${JSON.stringify(entries, null, '\t')}\n`); - - if (unresolvedRoles.size > 0) { - console.log( - `Roles not found in ${ROLES_TABLE}, dropped: ${[...unresolvedRoles].join(', ')}\n`, - ); - } - - const ambiguous = entries.filter( - (entry) => !entry.slackUserId && entry.candidates.length > 0, - ).length; - const none = entries.filter((entry) => entry.candidates.length === 0); - - const active = entries.filter((entry) => entry.active).length; - const withoutGithub = entries.filter((entry) => !entry.githubUsername).length; - const withoutBalance = entries.filter( - (entry) => entry.invitesAvailable === null, - ).length; - - console.log('Airtable, against the snapshot taken on 2026-09-10:'); - report('total', entries.length, EXPECTED.total); - report('active', active, EXPECTED.active); - report('without github', withoutGithub, EXPECTED.withoutGithub); - report('without balance', withoutBalance, EXPECTED.withoutBalance); - - console.log(`\nProposed matches:`); - console.log(` kept from review ${carried}`); - console.log(` confident ${confident}`); - console.log(` sole candidate ${sole} (any score — check these)`); - console.log(` needs a human ${ambiguous}`); - console.log(` no candidates ${none.length}`); - - if (none.length > 0) { - console.log( - '\nNo Slack candidate at all — fill these in by hand or leave blank:', - ); - for (const entry of none) { - console.log( - ` ${entry.name.padEnd(24)} ${entry.profileName ?? ''} ${entry.githubUsername ?? ''}`, - ); - } - } - - console.log(`\nWrote ${MAPPING_PATH}`); - console.log( - 'Review every row, set or clear `slackUserId`, then re-run with --apply.', - ); -} - -function report(label: string, found: number, expected: number) { - const drift = found === expected ? '' : ` * expected ${expected}`; - console.log(` ${label.padEnd(16)} ${String(found).padStart(4)}${drift}`); -} - -async function apply(dryRun: boolean) { - let entries: MappingEntry[]; - try { - entries = JSON.parse(readFileSync(MAPPING_PATH, 'utf8')) as MappingEntry[]; - } catch { - console.error( - `No mapping file at ${MAPPING_PATH}. Run with --propose first.`, - ); - process.exit(1); - } - - // Trimmed once, here: the id is written to `volunteer`, `pending_grant`, - // the ledger and `invite`, and the joins between them are exact. - const mapped = entries - .map((entry) => ({ - ...entry, - slackUserId: (entry.slackUserId ?? '').trim(), - })) - .filter((entry) => entry.slackUserId.length > 0); - const skipped = entries.length - mapped.length; - - const malformed = mapped.filter( - (entry) => !/^[UW][A-Z0-9]+$/.test(entry.slackUserId), - ); - if (malformed.length > 0) { - console.error( - 'These are not Slack member ids (a handle or an email, probably). Fix the file first:', - ); - for (const entry of malformed) { - console.error(` ${entry.name} -> ${entry.slackUserId}`); - } - process.exit(1); - } - - const seen = new Set(); - const duplicates = mapped.filter((entry) => { - if (seen.has(entry.slackUserId)) return true; - seen.add(entry.slackUserId); - return false; - }); - - if (duplicates.length > 0) { - console.error( - 'Two Airtable volunteers are mapped to the same Slack member. Fix the file first:', - ); - for (const entry of duplicates) { - console.error(` ${entry.name} -> ${entry.slackUserId}`); - } - process.exit(1); - } - - // The other direction: one Airtable record listed twice would be created by - // its first entry and then treated as already present by its second, which - // would grant the role and re-point the Invites to the second Slack member. - const seenRecords = new Set(); - const duplicateRecords = mapped.filter((entry) => { - if (seenRecords.has(entry.airtableRecordId)) return true; - seenRecords.add(entry.airtableRecordId); - return false; - }); - - if (duplicateRecords.length > 0) { - console.error( - 'One Airtable volunteer is listed more than once. Fix the file first:', - ); - for (const entry of duplicateRecords) { - console.error( - ` ${entry.airtableRecordId} (${entry.name}) -> ${entry.slackUserId}`, - ); - } - process.exit(1); - } - - // An Invite under two entries would be attributed to whichever ran last, - // silently: the update is keyed by the Invite's record id, not the entry's. - const seenInvites = new Map(); - const sharedInvites: string[] = []; - for (const entry of mapped) { - for (const inviteId of entry.inviteRecordIds) { - const other = seenInvites.get(inviteId); - if (other && other !== entry.airtableRecordId) { - sharedInvites.push( - ` ${inviteId}: ${other} and ${entry.airtableRecordId}`, - ); - } - seenInvites.set(inviteId, entry.airtableRecordId); - } - } - - if (sharedInvites.length > 0) { - console.error( - 'One Airtable invite is listed under more than one volunteer. Fix the file first:', - ); - for (const line of sharedInvites) console.error(line); - process.exit(1); - } - - console.log(`${mapped.length} mapped, ${skipped} left unmapped.\n`); - - if (dryRun) { - for (const entry of mapped) { - const credit = entry.active ? (entry.invitesAvailable ?? 0) : 0; - console.log( - ` ${entry.name.padEnd(24)} ${entry.slackUserId.padEnd(14)} ${ - entry.active ? 'active ' : 'paused ' - } ${entry.active ? 'grant ' : 'no grant'} credit ${credit} invites ${ - entry.inviteRecordIds.length - }`, - ); - } - console.log('\nDry run: nothing written.'); - return; - } - - const database = db(); - - /** - * A re-run is for backfilling, not for correcting. The `volunteer` insert - * below keeps the stored Slack id on conflict while the grant, the ledger - * and the Invites would follow the file's, splitting one person across two - * identities. A changed mapping is a migration of its own; refuse it here. - */ - const stored = await database - .select({ - airtableRecordId: volunteer.airtableRecordId, - slackUserId: volunteer.slackUserId, - deactivatedAt: volunteer.deactivatedAt, - }) - .from(volunteer) - .where( - inArray( - volunteer.airtableRecordId, - mapped.map((entry) => entry.airtableRecordId), - ), - ); - const storedBy = new Map(stored.map((row) => [row.airtableRecordId, row])); - const remapped = mapped.filter((entry) => { - const existing = storedBy.get(entry.airtableRecordId); - return existing !== undefined && existing.slackUserId !== entry.slackUserId; - }); - if (remapped.length > 0) { - console.error( - 'These volunteers are already imported under a different Slack member. A re-run cannot move them; nothing was written:', - ); - for (const entry of remapped) { - console.error( - ` ${entry.name} -> ${entry.slackUserId} (stored: ${storedBy.get(entry.airtableRecordId)?.slackUserId})`, - ); - } - process.exit(1); - } - - let created = 0; - let relabelled = 0; - let granted = 0; - let credited = 0; - let attributed = 0; - - for (const entry of mapped) { - /** - * One transaction per person: the row, the role, the balance and the - * attribution commit together or not at all. A run that dies halfway - * leaves nothing behind for the retry to misread — in particular no - * `volunteer` row without its `imported` ledger row. - */ - await database.transaction(async (tx) => { - const [row] = await tx - .insert(volunteer) - .values({ - slackUserId: entry.slackUserId, - // The Airtable name is what a maintainer will recognise. A later - // sign-in does not overwrite it; `claimOnSignIn` applies the - // Grant written below and fills in the user id. - slackDisplayName: entry.profileName ?? entry.name, - slackHandle: slackHandle(entry), - roleLabels: entry.roleLabels, - // The one place a Volunteer's address comes from in bulk. Slack's - // directory does not carry one without `users:read.email`, so without - // this the grant and accrual emails reach almost nobody. - email: entry.email?.trim().toLowerCase() || null, - /** - * Only 25 of the 91 are active. The rest come across so their history - * stays attributable and reactivating them is one click, but they - * arrive paused and with no balance. - */ - deactivatedAt: entry.active ? null : new Date(), - airtableRecordId: entry.airtableRecordId, - }) - .onConflictDoNothing({ target: volunteer.airtableRecordId }) - .returning({ id: volunteer.id }); - - if (row) { - created += 1; - } else { - // Descriptive only, so a re-run may refresh it; the identity may not. - await tx - .update(volunteer) - .set({ roleLabels: entry.roleLabels }) - .where(eq(volunteer.airtableRecordId, entry.airtableRecordId)); - relabelled += 1; - } - - /** - * The other half of a Volunteer. Only for the active ones — the paused - * arrive the way `setVolunteerActive` leaves someone, with no role — and - * not gated on `row`: the helper merges rather than duplicates, so a - * second run over people already imported backfills anyone missed. - * - * Except someone a maintainer has since paused: `setVolunteerActive` - * sets `deactivated_at` and takes the role away, and a re-run must not - * hand it back. Airtable's opinion of who is active is the older one. - */ - const paused = - storedBy.get(entry.airtableRecordId)?.deactivatedAt != null; - if (entry.active && !paused) { - await addVolunteerRole( - tx, - { - slackUserId: entry.slackUserId, - slackDisplayName: entry.profileName ?? entry.name, - slackHandle: slackHandle(entry), - }, - { name: 'Airtable import' }, - ); - granted += 1; - } - - /** - * One net row, not a reconstruction. - * - * Airtable's number is a running balance with no history behind it — the - * grants were manual and unrecorded — so there is nothing to replay. A row - * that says "this is what Airtable said, on this date" is the honest - * version of a number nobody can explain further. - * - * Written only alongside the `volunteer` row this run created: the two - * share a transaction, so a person already imported has their row, and - * the ledger is append-only, so a second one would double the balance. - * Nothing else in the ledger is consulted — an `admin_grant` added - * between runs is not evidence the import happened. - */ - const credit = entry.active ? (entry.invitesAvailable ?? 0) : 0; - if (row && credit > 0) { - await importBalance( - { - slackUserId: entry.slackUserId, - credit, - airtableRecordId: entry.airtableRecordId, - }, - tx, - ); - credited += 1; - } - - /** - * Attribute the Invites `importMembership.ts` already brought across. They - * carry only an `inviter_name`; this is what lets a Volunteer's own list - * and the roster's "sent" count include their history. - */ - if (entry.inviteRecordIds.length > 0) { - const updated = await tx - .update(invite) - .set({ inviterSlackUserId: entry.slackUserId }) - .where(inArray(invite.airtableRecordId, entry.inviteRecordIds)) - .returning({ id: invite.id }); - attributed += updated.length; - } - }); - } - - console.log( - `Created ${created} volunteers (${mapped.length - created} already present).`, - ); - console.log(`Refreshed role labels on ${relabelled} already present.`); - console.log(`Granted the volunteer role to ${granted}.`); - console.log(`Wrote ${credited} imported balances.`); - console.log(`Attributed ${attributed} invites.`); - - if (skipped > 0) { - console.log( - `\n${skipped} Airtable volunteers were left out because no Slack member was set.`, - ); - console.log( - 'Their invites keep `inviter_name` and stay unattributed, which is the honest result.', - ); - } -} - -async function main() { - const mode = process.argv.includes('--propose') - ? 'propose' - : process.argv.includes('--apply') - ? 'apply' - : null; - - if (!mode) { - console.error( - 'Usage: importVolunteers.ts --propose | --apply [--dry-run]\n\n' + - ' --propose read Airtable and Slack, write the mapping file for review\n' + - ' --apply write what the reviewed mapping file says', - ); - process.exit(1); - } - - if (mode === 'propose') { - const apiKey = process.env.MEMBERSHIP_AIRTABLE_API_KEY; - if (!apiKey) { - console.error('MEMBERSHIP_AIRTABLE_API_KEY is not set.'); - process.exit(1); - } - // Without the token `fetchSlackMembers()` falls back to faker members - // outside production, whose made-up ids would pass `--apply`'s checks. - if (!process.env.SLACK_BOT_TOKEN) { - console.error( - 'SLACK_BOT_TOKEN is not set; --propose needs the real directory.', - ); - process.exit(1); - } - await propose(apiKey); - } else { - await apply(process.argv.includes('--dry-run')); - } - - process.exit(0); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/scripts/airtable/match.test.ts b/scripts/airtable/match.test.ts deleted file mode 100644 index fb42c2b05..000000000 --- a/scripts/airtable/match.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import type { SlackMember } from '../../src/data/slackMembers'; -import { CONFIDENT_SCORE, normalise, score } from './match'; - -const member: SlackMember = { - id: 'U0AB12CD3', - name: 'Grace Hopper', - displayName: 'Grace', - handle: 'gracehopper', - email: null, -}; - -const nobody = { - name: '', - profileName: null, - githubUsername: null, - email: null, -}; - -describe('normalise', () => { - test('folds case, whitespace and punctuation — one username really ends in a space', () => { - expect(normalise(' Grace_Hopper ')).toBe('gracehopper'); - expect(normalise('grace.hopper')).toBe('gracehopper'); - expect(normalise(null)).toBe(''); - }); -}); - -describe('score', () => { - test('nothing in common is no candidate at all', () => { - expect(score(member, { ...nobody, name: 'Ada' })).toBeNull(); - }); - - test('a GitHub handle alone is confident; a name alone is not', () => { - const github = score(member, { ...nobody, githubUsername: 'Grace-Hopper' }); - expect(github).toMatchObject({ - slackUserId: 'U0AB12CD3', - score: 50, - why: ['github matches slack handle'], - }); - expect(github!.score).toBeGreaterThanOrEqual(CONFIDENT_SCORE); - - const name = score(member, { ...nobody, name: 'Grace' }); - expect(name).toMatchObject({ score: 20, why: ['name matches'] }); - expect(name!.score).toBeLessThan(CONFIDENT_SCORE); - }); - - test('the weights add up: 50 + 30 + 40 + 20', () => { - expect( - score(member, { - name: 'Grace', - profileName: 'Grace Hopper', - githubUsername: 'gracehopper', - email: 'grace.hopper@example.test', - }), - ).toMatchObject({ - score: 140, - why: [ - 'github matches slack handle', - 'email local-part matches slack handle', - 'full name matches', - 'name matches', - ], - }); - }); - - test('a short name is only a prefix hint, and two letters are ignored', () => { - expect(score(member, { ...nobody, name: 'Grac' })).toMatchObject({ - score: 8, - why: ['name is a prefix'], - }); - expect(score(member, { ...nobody, name: 'Gr' })).toBeNull(); - }); -}); diff --git a/scripts/airtable/match.ts b/scripts/airtable/match.ts deleted file mode 100644 index 6ef4ddd82..000000000 --- a/scripts/airtable/match.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { SlackMember } from '../../src/data/slackMembers'; - -/** - * Scoring an Airtable volunteer against the Slack directory. - * - * Apart from `importVolunteers.ts` because that script runs on import. The - * output only orders a list a maintainer reads — see docs/adr/0012. - */ - -export type Candidate = { - slackUserId: string; - displayName: string; - handle: string; - score: number; - why: string[]; -}; - -/** The fields of a mapping entry the scorer reads. */ -type MatchInput = { - name: string; - profileName: string | null; - githubUsername: string | null; - email: string | null; -}; - -/** Trim, lowercase, strip punctuation — one username really does end in a space. */ -export function normalise(value: string | null): string { - return (value ?? '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, ''); -} - -/** - * Score one Slack member against one Airtable volunteer. - * - * Weighted so that an identifier beats a name: two people can share "Meg", but - * a GitHub handle and an email local-part are close to unique. Nothing here - * decides anything — the score only orders the list a human reads. - * - * Email is used here although access matching never is (docs/adr/0009): every - * row is confirmed by a person before it is written (docs/adr/0012). - */ -export function score( - member: SlackMember, - entry: MatchInput, -): Candidate | null { - const why: string[] = []; - let total = 0; - - const github = normalise(entry.githubUsername); - if (github && github === normalise(member.handle)) { - total += 50; - why.push('github matches slack handle'); - } - - const emailLocal = normalise(entry.email?.split('@')[0] ?? null); - if (emailLocal && emailLocal === normalise(member.handle)) { - total += 30; - why.push('email local-part matches slack handle'); - } - - const profile = normalise(entry.profileName); - if ( - profile && - (profile === normalise(member.name) || - profile === normalise(member.displayName)) - ) { - total += 40; - why.push('full name matches'); - } - - const short = normalise(entry.name); - if (short && short.length > 2) { - if ( - short === normalise(member.displayName) || - short === normalise(member.name) - ) { - total += 20; - why.push('name matches'); - } else if ( - normalise(member.name).startsWith(short) || - normalise(member.displayName).startsWith(short) - ) { - total += 8; - why.push('name is a prefix'); - } - } - - if (total === 0) return null; - - return { - slackUserId: member.id, - displayName: member.displayName, - handle: member.handle, - score: total, - why, - }; -} - -/** A single candidate this strong is almost certainly right; anything else waits. */ -export const CONFIDENT_SCORE = 50; diff --git a/scripts/airtable/snapshotChallenges.ts b/scripts/airtable/snapshotChallenges.ts deleted file mode 100644 index 8dd5e6687..000000000 --- a/scripts/airtable/snapshotChallenges.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import Airtable from 'airtable'; - -/** - * One-off snapshot of the monthly challenge data out of Airtable. - * - * Every challenge this reads is finished — the newest entry is from November - * 2024 — so the data is frozen and there is nothing to keep in sync. Committing - * it as JSON follows the same reasoning as `src/data/podcast/episodes.json`: - * the pages become genuinely static, a build no longer depends on Airtable - * being reachable, and `PUBLIC_AIRTABLE_API_KEY` stops being needed at all. - * - * Kept in the repo for provenance rather than because it needs re-running. It - * reads through the same views the old fetches used, so the snapshot contains - * exactly the rows the site used to render — a view can filter out rows that a - * plain table read would include, and the 2023 and 2024 NaNoWriMo cohorts are - * only distinguishable by view. - * - * PUBLIC_AIRTABLE_API_KEY=… pnpm exec tsx scripts/airtable/snapshotChallenges.ts - */ - -const PUBLIC_DATA_BASE = 'appJStQemmYeoRcox'; -const MONTHLY_CHALLENGES_BASE = 'app10kd5ewHiLTjxn'; - -const OUT_DIR = join( - dirname(fileURLToPath(import.meta.url)), - '../../src/data/monthlyChallenges/data', -); - -type Snapshot = { - /** Output filename, written into src/data/monthlyChallenges/data/. */ - file: string; - baseId: string; - table: string; - /** Omitted only where the old fetch also read the whole table. */ - view?: string; - /** Written in this order, so a re-run produces an identical file. */ - fields: string[]; -}; - -const SNAPSHOTS: Snapshot[] = [ - { - file: 'member-articles.json', - baseId: PUBLIC_DATA_BASE, - table: 'Member Articles', - fields: [ - 'Member Name', - 'GitHubUsername', - 'TwitterUsername', - 'Title', - 'Url', - 'Word Count', - 'Date Published', - ], - }, - { - file: 'hacktoberfest-2022-repos.json', - baseId: PUBLIC_DATA_BASE, - table: 'Hacktoberfest2022 Repos', - view: 'Default', - fields: ['RepoName', 'RepoUrl', 'Description', 'Maintainer'], - }, - { - file: 'nanowrimo-2023.json', - baseId: MONTHLY_CHALLENGES_BASE, - table: 'NaNoWriMo', - view: 'NaNoWriMo 2023', - fields: [ - 'Name', - 'GitHubUsername', - 'EntryTitle', - 'EntryUrl', - 'EntryDate', - 'WordCount', - 'Topics', - 'ShortDescription', - ], - }, - { - file: 'nanowrimo-2024.json', - baseId: MONTHLY_CHALLENGES_BASE, - table: 'NaNoWriMo', - view: 'NaNoWriMo 2024', - fields: [ - 'Name', - 'GitHubUsername', - 'EntryTitle', - 'EntryUrl', - 'EntryDate', - 'WordCount', - 'Topics', - 'ShortDescription', - ], - }, - { - file: 'pairing-challenge-2023.json', - baseId: MONTHLY_CHALLENGES_BASE, - table: 'Pairing Challenge', - // The last year the challenge ran. `getTotalPairingSessions()` used to - // build this view name from the current year, so from 1 Jan 2024 it has - // been asking Airtable for a view that does not exist. - view: '2023 Pairing Challenge Results', - fields: ['Pairing Participants', 'Topic', 'Pairing Sessions'], - }, -]; - -/** Drop empty values rather than writing `null`s the old `fields` object never had. */ -function pick( - row: Record, - fields: string[], -): Record { - const picked: Record = {}; - for (const field of fields) { - const value = row[field]; - if (value === undefined || value === null || value === '') continue; - picked[field] = value; - } - return picked; -} - -async function main() { - const apiKey = process.env.PUBLIC_AIRTABLE_API_KEY; - - if (!apiKey) { - console.error( - 'PUBLIC_AIRTABLE_API_KEY is not set. Ask a maintainer for a read key\nfor the Public Data and Monthly Challenges bases.', - ); - process.exit(1); - } - - mkdirSync(OUT_DIR, { recursive: true }); - - for (const snapshot of SNAPSHOTS) { - const base = new Airtable({ apiKey }).base(snapshot.baseId); - const records = await base(snapshot.table) - .select(snapshot.view ? { view: snapshot.view } : {}) - .all(); - - const rows = records.map((record) => - pick(record.fields as Record, snapshot.fields), - ); - - writeFileSync( - join(OUT_DIR, snapshot.file), - `${JSON.stringify(rows, null, '\t')}\n`, - ); - - console.log( - `${snapshot.file}: ${rows.length} rows from ${snapshot.table}${ - snapshot.view ? ` (${snapshot.view})` : '' - }`, - ); - } -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/scripts/loadBotList.ts b/scripts/loadBotList.ts index a25820d24..22f903bd3 100644 --- a/scripts/loadBotList.ts +++ b/scripts/loadBotList.ts @@ -21,7 +21,7 @@ import { * edge function needs the file to exist for Deno to bundle it. Committing it * also means every change to who we block shows up in a diff someone reviews. * - * Policy lives in `src/data/botOverrides.ts`. Run with `pnpm build-bot-list`. + * Policy lives in `src/data/botOverrides.ts`. Run with `mise run bots:build`. */ /** @@ -188,7 +188,7 @@ async function main() { } } - const banner = `// Generated by \`pnpm build-bot-list\`. Do not edit by hand — change + const banner = `// Generated by \`mise run bots:build\`. Do not edit by hand — change // src/data/botOverrides.ts and regenerate. // // Source: ai-robots-txt/ai.robots.txt (robots.json). diff --git a/scripts/loadResourcePaths.ts b/scripts/loadResourcePaths.ts index c054838f1..c0b54c0ce 100644 --- a/scripts/loadResourcePaths.ts +++ b/scripts/loadResourcePaths.ts @@ -28,7 +28,7 @@ async function main() { fs.writeFileSync( outFile, `// Generated by scripts/loadResourcePaths.ts - do not edit. -// Run \`pnpm build-resource-paths\` after adding or moving a file in src/content/resources. +// Run \`mise run codegen:resources\` after adding or moving a file in src/content/resources. export const resourcePaths = [ ${paths.map((p) => `\t'${p}',`).join('\n')} diff --git a/scripts/loadUndrawAspectRatios.ts b/scripts/loadUndrawAspectRatios.ts index f2aa1ae12..48581e143 100644 --- a/scripts/loadUndrawAspectRatios.ts +++ b/scripts/loadUndrawAspectRatios.ts @@ -68,7 +68,7 @@ if (skipped.length) { fs.writeFileSync( outFile, `// Generated by scripts/loadUndrawAspectRatios.ts - do not edit. -// Run \`pnpm build-undraw-ratios\` after adding an SVG to public/assets/svg. +// Run \`mise run codegen:undraw\` after adding an SVG to public/assets/svg. /** \`width / height\` from each illustration's \`viewBox\`, for \`next/image\`. */ export const svgAspectRatios = { diff --git a/scripts/seedDev.ts b/scripts/seedDev.ts index a5f6f2369..6a67af2d2 100644 --- a/scripts/seedDev.ts +++ b/scripts/seedDev.ts @@ -10,7 +10,7 @@ import { seedDev } from './seed'; import { fetchPlaceholder } from './seed/attachment'; /** - * Seed the local development database — `pnpm db:seed`, which runs this + * Seed the local development database — `mise run db:seed`, which runs this * under `scripts/with-local-netlify.ts` so the connection string and the * blob server are local ones. The work is in `scripts/seed/`; this is the * environment check, the blob store and the printout. @@ -27,7 +27,7 @@ async function main() { const databaseUrl = process.env.NETLIFY_DB_URL ?? process.env.DATABASE_URL; if (!databaseUrl || !isLocalDatabaseUrl(databaseUrl)) { throw new Error( - 'Refusing to seed: the connection string is not local. Run `pnpm db:seed`.', + 'Refusing to seed: the connection string is not local. Run `mise run db:seed`.', ); } diff --git a/scripts/with-local-netlify.ts b/scripts/with-local-netlify.ts index c8f85b905..cdb6298c1 100644 --- a/scripts/with-local-netlify.ts +++ b/scripts/with-local-netlify.ts @@ -24,7 +24,7 @@ import { isLocalDatabaseUrl } from './lib/localOnly'; * * Requires `netlify dev` to already be running in another terminal. * - * pnpm exec tsx scripts/with-local-netlify.ts tsx scripts/seedDev.ts + * mise exec -- tsx scripts/with-local-netlify.ts tsx scripts/seedDev.ts */ const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -213,7 +213,7 @@ async function main() { if (!command) { fail( - 'Usage: pnpm exec tsx scripts/with-local-netlify.ts [args…]', + 'Usage: mise exec -- tsx scripts/with-local-netlify.ts [args…]', ); } diff --git a/src/app/admin/testUserPanel.tsx b/src/app/admin/testUserPanel.tsx index 6eb9194a3..aad081413 100644 --- a/src/app/admin/testUserPanel.tsx +++ b/src/app/admin/testUserPanel.tsx @@ -24,8 +24,9 @@ export function SlackNotConfigured() {

Sign in as a test user

- Slack sign-in isn’t configured. Run pnpm db:seed, - then pick a test user from the Auth DevTools panel. + Slack sign-in isn’t configured. Run{' '} + mise run db:seed, then pick a test user from the Auth + DevTools panel.

); diff --git a/src/data/botMatcher.test.ts b/src/data/botMatcher.test.ts index 25bbb91f0..7e1bcbd47 100644 --- a/src/data/botMatcher.test.ts +++ b/src/data/botMatcher.test.ts @@ -11,7 +11,7 @@ import { createBotMatcher, createBotPolicy } from './botMatcher'; * Copilot, and a naive match on it also catches `vscode`. * * `refresh-bot-list.yml` runs this file on its own after regenerating the - * list, via `pnpm check-bot-matching`. + * list, via `mise run bots:check`. */ describe('createBotMatcher', () => { diff --git a/src/data/bots.ts b/src/data/bots.ts index 0e704d416..66438c626 100644 --- a/src/data/bots.ts +++ b/src/data/bots.ts @@ -1,4 +1,4 @@ -// Generated by `pnpm build-bot-list`. Do not edit by hand — change +// Generated by `mise run bots:build`. Do not edit by hand — change // src/data/botOverrides.ts and regenerate. // // Source: ai-robots-txt/ai.robots.txt (robots.json). diff --git a/src/data/monthlyChallenges/NaNoWriMo.ts b/src/data/monthlyChallenges/NaNoWriMo.ts index 4cc6227f5..72bb76070 100644 --- a/src/data/monthlyChallenges/NaNoWriMo.ts +++ b/src/data/monthlyChallenges/NaNoWriMo.ts @@ -8,7 +8,7 @@ import entries2024 from './data/nanowrimo-2024.json'; * why the cohorts arrive here as separate files: the year is not recorded on * the rows themselves, only in which view returned them. * - * Snapshot: `scripts/airtable/snapshotChallenges.ts`. + * Frozen snapshot, docs/adr/0004. */ export type WritingChallengeEntry = { diff --git a/src/data/monthlyChallenges/nov-2021.ts b/src/data/monthlyChallenges/nov-2021.ts index c0090d9fd..ddba12b89 100644 --- a/src/data/monthlyChallenges/nov-2021.ts +++ b/src/data/monthlyChallenges/nov-2021.ts @@ -6,7 +6,7 @@ import rows from './data/member-articles.json'; * The November 2021 writing challenge, frozen as a snapshot of the Airtable * "Member Articles" table. * - * Snapshot: `scripts/airtable/snapshotChallenges.ts`. + * Frozen snapshot, docs/adr/0004. */ export type MemberArticle = { diff --git a/src/data/monthlyChallenges/oct-2022.ts b/src/data/monthlyChallenges/oct-2022.ts index ed1d36ec0..172f19230 100644 --- a/src/data/monthlyChallenges/oct-2022.ts +++ b/src/data/monthlyChallenges/oct-2022.ts @@ -2,9 +2,7 @@ import rows from './data/hacktoberfest-2022-repos.json'; /** * Repos that took part in the October 2022 Hacktoberfest challenge, frozen as a - * snapshot. Read the "Default" view of the Airtable table until then. - * - * Snapshot: `scripts/airtable/snapshotChallenges.ts`. + * snapshot, docs/adr/0004. */ export type HacktoberfestRepo = { diff --git a/src/data/monthlyChallenges/pairing-challenge.ts b/src/data/monthlyChallenges/pairing-challenge.ts index 2888d0644..04f0005b9 100644 --- a/src/data/monthlyChallenges/pairing-challenge.ts +++ b/src/data/monthlyChallenges/pairing-challenge.ts @@ -10,7 +10,7 @@ import rows from './data/pairing-challenge-2023.json'; * since, so the honest fix is to record what it finished on rather than to keep * presenting a live counter. * - * Snapshot: `scripts/airtable/snapshotChallenges.ts`. + * Frozen snapshot, docs/adr/0004. */ export type PairingChallengeRow = { diff --git a/src/emails/emails.test.tsx b/src/emails/emails.test.tsx index a10cc291b..b1840141c 100644 --- a/src/emails/emails.test.tsx +++ b/src/emails/emails.test.tsx @@ -113,7 +113,7 @@ describe.each(templates)('%s', (_name, template, props, greeting, link) => { }); /** - * `pnpm email:dev` renders each file's default export and reads `PreviewProps` + * `mise run email:dev` renders each file's default export and reads `PreviewProps` * off it, so a file that forgets to export its `Email` disappears from the * preview server with nothing else to notice. */ diff --git a/src/lib/access/devtools.ts b/src/lib/access/devtools.ts index 86bf7b7e1..82c94cc1e 100644 --- a/src/lib/access/devtools.ts +++ b/src/lib/access/devtools.ts @@ -12,7 +12,7 @@ import { GRANTABLE_ROLES, type RoleName } from '@/lib/access/permissions'; * something. Active only on a local checkout — `devtoolsEnabled()` — and the * library itself also refuses where `NODE_ENV` is production. * - * `pnpm db:seed` registers its own users with the panel (`scripts/seed/users.ts`), + * `mise run db:seed` registers its own users with the panel (`scripts/seed/users.ts`), * among them a Volunteer with an allowance; a `volunteer` created from here * is a fresh one with no roster row and nothing to spend. */ @@ -24,7 +24,7 @@ const TEMPLATE_ROLES: ReadonlyArray<{ name: RoleName; description: string }> = [ /** * Whether the panel is on: a local checkout (docs/adr/0018) outside a * production build. The library refuses a production build on its own; the - * check is repeated so a local `pnpm start` doesn't offer the sign-in hint. + * check is repeated so a local `mise run start` doesn't offer the sign-in hint. */ export function devtoolsEnabled(): boolean { return process.env.NODE_ENV !== 'production' && deployContext() === 'local'; diff --git a/src/lib/email/layout.tsx b/src/lib/email/layout.tsx index 69748f372..6ad8ff812 100644 --- a/src/lib/email/layout.tsx +++ b/src/lib/email/layout.tsx @@ -21,7 +21,7 @@ import { styles } from './styles'; * SVG images. The origin follows `siteUrl()`, same as every link in these * emails, so a local or preview send loads the logo from that env instead of * 404ing against production. `EMAIL_ASSET_ORIGIN` overrides it for - * `pnpm email:dev`, which runs outside the Next app and serves the copy in + * `mise run email:dev`, which runs outside the Next app and serves the copy in * `src/emails/static/` itself. Read per-render, not cached at module load, * since `siteUrl()` reads the environment on every call. */ diff --git a/src/lib/email/template.tsx b/src/lib/email/template.tsx index c9bab35b1..ba837d57d 100644 --- a/src/lib/email/template.tsx +++ b/src/lib/email/template.tsx @@ -10,7 +10,7 @@ import { Layout } from './layout'; /** * One template, three views of it. `Email` is the whole message — shell and - * all — for sending and for the `pnpm email:dev` preview server. `Content` + * all — for sending and for the `mise run email:dev` preview server. `Content` * is the part a maintainer signs off on in a confirmation dialog, rendered * there as the same React component that produces the sent HTML. */ @@ -24,7 +24,7 @@ export type EmailTemplate

= { * Declares a template from its parts: `Email` is built here, once, as the * `Content` inside the shared `Layout`, so no template can ship a shell that * disagrees with the one every other message uses. `PreviewProps` rides on - * `Email` because the `pnpm email:dev` server reads it off the file's default + * `Email` because the `mise run email:dev` server reads it off the file's default * export. */ export function defineEmail

({ diff --git a/src/lib/submissions/attachments.ts b/src/lib/submissions/attachments.ts index df50aade2..20387ce41 100644 --- a/src/lib/submissions/attachments.ts +++ b/src/lib/submissions/attachments.ts @@ -8,9 +8,7 @@ import { reportHandled } from '@/lib/monitoring/reportHandled'; * Storage for CoC report attachments. * * The form's file input had been commented out since the Netlify Forms era with - * a `TODO: hook up file upload - probably using Netlify Blob`. The four - * historical attachments in Airtable are rehosted into the same store by - * `scripts/airtable/importSubmissions.ts`. + * a `TODO: hook up file upload - probably using Netlify Blob`. * * Files are never served from a public URL — `/admin/submissions/coc/[id]/ * attachment` reads them back after checking `coc:read`. diff --git a/src/test/toolchain.test.ts b/src/test/toolchain.test.ts new file mode 100644 index 000000000..7692053d6 --- /dev/null +++ b/src/test/toolchain.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs'; +import { expect, test } from 'vitest'; +import { z } from 'zod'; + +/** + * pnpm is pinned twice: `packageManager` (what pnpm and Netlify read) and + * `mise.toml` (what mise installs). docs/adr/0020. + */ + +const read = (file: string) => readFileSync(file, 'utf8'); + +const packageManager = z + .object({ packageManager: z.string() }) + .parse(JSON.parse(read('package.json'))) + .packageManager.split('+')[0]; + +test('mise.toml pins the pnpm in packageManager', () => { + const pinned = /^\s*pnpm\s*=\s*"([^"]+)"/m.exec(read('mise.toml'))?.[1]; + + expect(`pnpm@${pinned}`).toBe(packageManager); +}); + +test('mise.lock locks that pnpm', () => { + const version = packageManager.replace('pnpm@', ''); + + const locked = /^\[\[tools\.pnpm\]\]\nversion = "([^"]+)"/m.exec( + read('mise.lock'), + )?.[1]; + + expect(locked).toBe(version); +}); + +// Netlify builds with the mise its image ships (2026.9.14 when this was +// written), which rejects a lockfile newer than v2: a v3 mise.lock turns every +// deploy log into an error. Tools that force v3 (pitchfork's forge ids) belong +// in mise.dev.toml, which Netlify never loads. docs/adr/0020. +test('mise.lock stays readable by the mise on Netlify', () => { + const version = Number( + /^lockfile_version = (\d+)$/m.exec(read('mise.lock'))?.[1], + ); + + expect(version).toBeLessThanOrEqual(2); +}); diff --git a/src/util/resourceHref.ts b/src/util/resourceHref.ts index 9d8dd271b..ab241911c 100644 --- a/src/util/resourceHref.ts +++ b/src/util/resourceHref.ts @@ -6,7 +6,7 @@ type ResourceHref = Route; /** * An href into the handbook and resources. `resources/[...slug]` types as * `/resources/${string}`, so a literal href would pass typedRoutes whatever - * it said; `ResourcePath` is every page that exists, and `pnpm codegen` + * it said; `ResourcePath` is every page that exists, and `mise run codegen` * rewrites it when a file is added or moved. ESLint bans a raw `/resources` * href elsewhere. */ diff --git a/tsconfig.json b/tsconfig.json index e7a8f42a2..c5487d404 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,5 +33,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".mise"] } diff --git a/vitest.config.mts b/vitest.config.mts index 6ac128ae5..5001dbf34 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -4,6 +4,9 @@ import { configDefaults, defineConfig } from 'vitest/config'; // CI runs in UTC; pin it so a zone-sensitive test fails the same way here. process.env.TZ = 'UTC'; +/** bin/mise's data dir: its state/ links back to the checkout, so every test would run twice. */ +const mise = ['**/.mise/**']; + /** * Two projects, told apart by filename: * @@ -43,7 +46,7 @@ export default defineConfig({ 'scripts/**/*.test.ts', 'netlify/**/*.test.ts', ], - exclude: [...configDefaults.exclude, '**/*.db.test.ts'], + exclude: [...configDefaults.exclude, ...mise, '**/*.db.test.ts'], setupFiles: ['./src/test/setup.ts'], }, }, @@ -51,6 +54,7 @@ export default defineConfig({ test: { name: 'db', include: ['**/*.db.test.ts'], + exclude: [...configDefaults.exclude, ...mise], globalSetup: ['./src/test/db/globalSetup.ts'], setupFiles: ['./src/test/setup.ts', './src/test/db/setup.ts'], // One in-memory database per run, so files must not race — and