@forumone/create-throughline 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/generator.d.ts.map +1 -1
  3. package/dist/generator.js +5 -0
  4. package/dist/generator.js.map +1 -1
  5. package/dist/post-install.d.ts.map +1 -1
  6. package/dist/post-install.js +10 -3
  7. package/dist/post-install.js.map +1 -1
  8. package/dist/templates/base/.env.example +32 -18
  9. package/dist/templates/base/CLAUDE.md +112 -0
  10. package/dist/templates/base/README.md +92 -7
  11. package/dist/templates/base/apps/web/e2e/site.ts +36 -0
  12. package/dist/templates/base/apps/web/e2e/smoke.spec.ts +52 -0
  13. package/dist/templates/base/apps/web/package.json.template +23 -7
  14. package/dist/templates/base/apps/web/playwright.config.ts +63 -0
  15. package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
  16. package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
  17. package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
  18. package/dist/templates/base/apps/web/src/access/anonymousAccess.test.ts +36 -0
  19. package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +41 -4
  20. package/dist/templates/base/apps/web/src/instrumentation.ts +35 -0
  21. package/dist/templates/base/apps/web/src/lib/cache-tags.ts +23 -0
  22. package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
  23. package/dist/templates/base/apps/web/src/payload.config.ts.template +136 -19
  24. package/dist/templates/base/apps/web/vitest.config.ts +57 -0
  25. package/dist/templates/base/claude/hooks/gate.sh +45 -0
  26. package/dist/templates/base/claude/settings.json +16 -0
  27. package/dist/templates/base/github/scripts/check-generated.sh +44 -0
  28. package/dist/templates/base/github/workflows/ci.yml +143 -0
  29. package/dist/templates/base/gitignore +9 -0
  30. package/dist/templates/base/package.json.template +7 -1
  31. package/dist/templates/base/scripts/check-audit.ts +117 -0
  32. package/dist/templates/base/scripts/check-single-instance.ts +113 -0
  33. package/dist/templates/base/scripts/check-tested-packages.ts +163 -0
  34. package/dist/templates/base/turbo.json +16 -1
  35. package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
  36. package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
  37. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +1 -0
  38. package/dist/versions.json +11 -11
  39. package/package.json +5 -4
@@ -0,0 +1,158 @@
1
+ /*
2
+ Which connection string to connect with, and the name of the variable it came
3
+ from.
4
+
5
+ Three things make this more than `process.env.DATABASE_URI!`.
6
+
7
+ **The Vercel–Neon integration does not set `DATABASE_URI`.** It injects
8
+ `DATABASE_URL`, `DATABASE_URL_UNPOOLED`, `POSTGRES_URL`,
9
+ `POSTGRES_URL_NON_POOLING` and friends. Left alone, the first deploy fails at
10
+ page-data collection with a connection error that says nothing about a missing
11
+ variable.
12
+
13
+ **The app wants the pooler and a migration cannot use it.** Neon's pooled
14
+ endpoint is PgBouncer in transaction mode: right for a serving function, whose
15
+ connections are many and short, and unusable for DDL, where Drizzle relies on
16
+ prepared statements and session state. `migrate` hangs or fails partway. The two
17
+ hostnames differ only by a `-pooler` suffix on the endpoint id.
18
+
19
+ **`DATABASE_URI` leads both lists, deliberately.** It is the variable you scope
20
+ per environment — a Preview-scoped `DATABASE_URI` pointing at a preview branch —
21
+ while the integration's variables are project-wide and resolve to production. A
22
+ preview build that fell through to one of those while migrating would migrate
23
+ production.
24
+
25
+ If you deploy somewhere other than Vercel + Neon, set `DATABASE_URI` and the
26
+ rest of the list is never consulted.
27
+ */
28
+ const RUNTIME_CANDIDATES = [
29
+ 'DATABASE_URI',
30
+ // Neon via the Vercel integration, pooled. Correct for a serving instance.
31
+ 'DATABASE_URL',
32
+ 'POSTGRES_URL',
33
+ // Direct, as a fallback: a project with no pooler still boots.
34
+ 'DATABASE_URL_UNPOOLED',
35
+ 'POSTGRES_URL_NON_POOLING',
36
+ ] as const
37
+
38
+ /*
39
+ The DDL path: an optional migration-only credential first, then direct endpoints
40
+ ahead of pooled ones. The pooled pair is kept at the end rather than dropped, so
41
+ a migration whose only reachable string is pooled meets the explanation in
42
+ `describeProblem` instead of "no connection string".
43
+
44
+ `MIGRATION_DATABASE_URL` lets the app run as a role that can read and write rows
45
+ and no more, while migrations run as the schema owner. It is consulted only by
46
+ the process that migrates — see `isMigrationProcess` — because a platform with
47
+ no build-only variables would otherwise hand the DDL role straight back to the
48
+ running app.
49
+ */
50
+ const MIGRATION_CANDIDATES = [
51
+ 'MIGRATION_DATABASE_URL',
52
+ 'DATABASE_URI',
53
+ 'DATABASE_URL_UNPOOLED',
54
+ 'POSTGRES_URL_NON_POOLING',
55
+ 'DATABASE_URL',
56
+ 'POSTGRES_URL',
57
+ ] as const
58
+
59
+ /**
60
+ * Whether this process is `payload migrate` rather than a server or a build.
61
+ *
62
+ * `PAYLOAD_MIGRATING=1` is set by the `migrate` script in package.json and by
63
+ * nothing else. `NEXT_RUNTIME` is defined in every server bundle Next emits, so
64
+ * no route or page can satisfy both conditions even if the first were set.
65
+ */
66
+ export function isMigrationProcess(): boolean {
67
+ return process.env.PAYLOAD_MIGRATING === '1' && !process.env.NEXT_RUNTIME
68
+ }
69
+
70
+ let announced = false
71
+
72
+ /*
73
+ One line in the deploy log saying which variable a migration connected through.
74
+ Without it, a migration that quietly fell through to the app's credential looks
75
+ exactly like one that worked. The name, never the value: a build log is not a
76
+ secret store.
77
+ */
78
+ function announceMigrationCredential(name: string): void {
79
+ if (announced) return
80
+ announced = true
81
+ process.stderr.write(`payload migrate: connecting via ${name}\n`)
82
+ }
83
+
84
+ /*
85
+ What is wrong with a connection string, in words, before Postgres says
86
+ "password authentication failed" and leaves you guessing. A string wrapped in
87
+ quotes or prefixed with `psql` fails as `getaddrinfo ENOTFOUND`; a masked or
88
+ truncated password fails as an auth error. The checks below catch the shapes
89
+ that can be seen without connecting. Nothing here ever prints the value.
90
+ */
91
+ function describeProblem(name: string, value: string, migrating: boolean): string | undefined {
92
+ const raw = value.trim()
93
+
94
+ if (/^psql\s/i.test(raw)) {
95
+ return `${name} starts with "psql". Copy the connection string itself, not the command that uses it.`
96
+ }
97
+ if (/^["']|["']$/.test(raw)) {
98
+ return `${name} is wrapped in quotes. Most hosts store the value literally — paste it without them.`
99
+ }
100
+ if (!/^postgres(ql)?:\/\//i.test(raw)) {
101
+ return `${name} does not start with postgres:// or postgresql://.`
102
+ }
103
+
104
+ let url: URL
105
+ try {
106
+ url = new URL(raw)
107
+ } catch {
108
+ return `${name} is not a valid URL.`
109
+ }
110
+
111
+ if (!url.password) {
112
+ return `${name} has no password. Neon's console masks it until you reveal or copy it.`
113
+ }
114
+ if (/^[•*·]+$/.test(decodeURIComponent(url.password))) {
115
+ return `${name}'s password is a row of bullets — it was copied while the console still had it masked.`
116
+ }
117
+ if (migrating && url.hostname.includes('-pooler')) {
118
+ return (
119
+ `${name} points at Neon's pooled endpoint. Migrations need the direct one: PgBouncer in ` +
120
+ `transaction mode does not support the prepared statements Drizzle's DDL relies on. Use ` +
121
+ `the host without "-pooler".`
122
+ )
123
+ }
124
+
125
+ return undefined
126
+ }
127
+
128
+ /**
129
+ * The connection string, and the name of the variable it came from.
130
+ *
131
+ * The name matters: `DATABASE_URI` leads both lists, so one left in a shell or a
132
+ * pulled `.env` file wins without saying so, and every Neon branch's database is
133
+ * called `neondb` — the host and database name alone may not tell you which
134
+ * environment you are about to write to. See `scripts/lib/target.ts`.
135
+ */
136
+ export function resolveDatabaseConnection(): { name: string; value: string } {
137
+ const migrating = isMigrationProcess()
138
+ const candidates: readonly string[] = migrating ? MIGRATION_CANDIDATES : RUNTIME_CANDIDATES
139
+
140
+ for (const name of candidates) {
141
+ const value = process.env[name]
142
+ if (value && value.trim() !== '') {
143
+ const problem = describeProblem(name, value, migrating)
144
+ if (problem) throw new Error(problem)
145
+ if (migrating) announceMigrationCredential(name)
146
+ return { name, value: value.trim() }
147
+ }
148
+ }
149
+
150
+ throw new Error(
151
+ `No database connection string. Set DATABASE_URI, or connect a Neon database in Vercel — ` +
152
+ `the integration provides ${candidates.slice(1).join(', ')}, and every one was empty.`,
153
+ )
154
+ }
155
+
156
+ export function databaseConnectionString(): string {
157
+ return resolveDatabaseConnection().value
158
+ }
@@ -1,34 +1,102 @@
1
1
  import path from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { buildConfig } from 'payload'
4
- import type { CollectionConfig } from 'payload'
4
+ import type { Access, CollectionConfig } from 'payload'
5
5
  import { postgresAdapter } from '@payloadcms/db-postgres'
6
6
  import { lexicalEditor } from '@payloadcms/richtext-lexical'
7
7
  import {
8
+ assertEnvironment,
8
9
  auditPlugin,
9
- createApiKeysCollection,
10
10
  createInngestClient,
11
+ createMcpToolCollector,
12
+ mcpApiKeyAccess,
11
13
  } from '@forumone/throughline-core'
14
+ import { mcpPlugin } from '@payloadcms/plugin-mcp'
12
15
  import { componentsPlugin } from '@forumone/throughline-components'
13
16
  import { publishingPlugin } from '@forumone/throughline-publishing'
14
17
  import {
18
+ approvalsEnv,
15
19
  approvalsPlugin,
16
20
  buildActionUrl,
17
21
  generateActionToken,
18
22
  } from '@forumone/throughline-approvals'
19
23
  import { auditQueryPlugin } from '@forumone/throughline-audit'
20
24
  import { integrationsPlugin } from '@forumone/throughline-integrations'
21
- import { emailPlugin } from '@forumone/throughline-email'
22
- import { formsPlugin } from '@forumone/throughline-forms'
25
+ import { emailEnv, emailPlugin } from '@forumone/throughline-email'
26
+ import { formsEnv, formsPlugin } from '@forumone/throughline-forms'
27
+ import { createTagRevalidationHooks } from '@forumone/throughline-workflows'
28
+ import { cacheTags } from './lib/cache-tags'
29
+ import { jobFailuresPlugin } from '@forumone/throughline-core/observability'
30
+ import { databaseConnectionString } from './lib/database'
23
31
  {{#if useReferenceDs}}import type { Manifest } from '@forumone/throughline-design-contract'
24
32
  import designSystemManifest from '{{designSystemName}}/manifest' with { type: 'json' }
25
33
  {{/if}}
26
34
 
35
+ /*
36
+ Every variable this site needs, checked once and reported together, before any
37
+ plugin runs. Without it each plugin throws on the first thing it finds, in
38
+ registration order, inside `next build`'s "Failed to collect page data" — one
39
+ missing variable per deploy, reading like a build failure.
40
+
41
+ The plugins' lists are their own declarations of what they refuse to start
42
+ without, so a requirement a plugin adds arrives here with the upgrade. Only the
43
+ site's own variables are written out. A value passed to a plugin as an option
44
+ rather than read from the environment takes that plugin's list out of this call.
45
+
46
+ Anything that loads this file — `next build`, `payload generate:types`,
47
+ `payload migrate` — needs these set. Where only parsing matters (CI's typecheck
48
+ and type generation), placeholders of the right length do; see
49
+ .github/workflows/ci.yml.
50
+ */
51
+ assertEnvironment(
52
+ approvalsEnv,
53
+ emailEnv,
54
+ formsEnv,
55
+ {
56
+ name: 'PAYLOAD_SECRET',
57
+ minLength: 32,
58
+ why: 'Signs Payload sessions and auth tokens. Generate with `openssl rand -base64 48`.',
59
+ },
60
+ {
61
+ name: 'NEXT_PUBLIC_SERVER_URL',
62
+ why:
63
+ 'The origin approval links are built against, e.g. http://localhost:3000. On a ' +
64
+ 'Vercel preview, set it to https://$VERCEL_URL.',
65
+ },
66
+ // Accepts several names (see lib/database.ts), so it is a check, not a name.
67
+ () => databaseConnectionString(),
68
+ )
69
+
27
70
  const __filename = fileURLToPath(import.meta.url)
28
71
  const __dirname = path.dirname(__filename)
29
72
 
30
73
  const inngest = createInngestClient({ id: '{{projectName}}' })
31
74
 
75
+ /*
76
+ Where every Throughline server puts its tools for `mcpPlugin`, which serves them
77
+ all on one `/api/mcp`. Each plugin declares its tools as the config is built and
78
+ binds the handlers at `onInit`; `mcpPlugin` reads this array at both moments.
79
+ Hand over `mcpTools.tools` itself — a spread or `.slice()` would hand over
80
+ something nobody fills.
81
+ */
82
+ const mcpTools = createMcpToolCollector()
83
+
84
+ /*
85
+ Drops a Next cache tag when content changes, named by the same scheme your
86
+ cached reads use (see lib/cache-tags.ts). Give every collection and global that
87
+ a cached read renders a pair of these: afterChange and afterDelete on a
88
+ collection, afterChange on a global. Draft saves and autosave drop nothing.
89
+ */
90
+ const revalidation = createTagRevalidationHooks({ cacheTags })
91
+
92
+ // A person in `users` with the admin role. `req.user` can also be an MCP key
93
+ // document, which has no roles and is never an admin, so the collection is
94
+ // checked first — once payload-types.ts exists, TypeScript insists on it.
95
+ const isAdmin: Access = ({ req: { user } }) =>
96
+ user?.collection === 'users' &&
97
+ Array.isArray(user.roles) &&
98
+ (user.roles as unknown[]).includes('admin')
99
+
32
100
  // Users collection — extend with your own fields and SSO/auth provider.
33
101
  const Users: CollectionConfig = {
34
102
  slug: 'users',
@@ -52,13 +120,27 @@ const Users: CollectionConfig = {
52
120
  ],
53
121
  }
54
122
 
123
+ // The public site reads with nobody signed in, so a collection it renders needs
124
+ // a read rule that says yes to that. Without one, Payload's default refuses —
125
+ // and the page renders empty rather than erroring. Anonymous readers get a
126
+ // query, so they see published documents only; anybody signed in sees drafts
127
+ // too, which the admin and live preview need. `src/access/anonymousAccess.test.ts`
128
+ // holds every collection to the bucket it is listed in.
129
+ const publishedOrSignedIn: Access = ({ req: { user } }) =>
130
+ user ? true : { _status: { equals: 'published' } }
131
+
55
132
  // Example Pages collection. Replace this with your real content model;
56
133
  // keep the `policy` group on collections that should require approval —
57
134
  // the approvals plugin reads that field shape.
58
135
  const Pages: CollectionConfig = {
59
136
  slug: 'pages',
60
137
  admin: { useAsTitle: 'title' },
138
+ access: { read: publishedOrSignedIn },
61
139
  versions: { drafts: true },
140
+ hooks: {
141
+ afterChange: [revalidation.afterCollectionChange()],
142
+ afterDelete: [revalidation.afterCollectionDelete()],
143
+ },
62
144
  fields: [
63
145
  { name: 'title', type: 'text', required: true },
64
146
  { name: 'slug', type: 'text', required: true, unique: true },
@@ -93,23 +175,43 @@ export default buildConfig({
93
175
  admin: { user: 'users' },
94
176
  editor: lexicalEditor(),
95
177
  db: postgresAdapter({
96
- pool: { connectionString: process.env.DATABASE_URI! },
178
+ // See lib/database.ts: which variable wins, and why a migration refuses
179
+ // Neon's pooled endpoint.
180
+ pool: { connectionString: databaseConnectionString() },
181
+ migrationDir: path.resolve(__dirname, 'migrations'),
182
+ // Never push schema: migrations are the only way this database changes.
183
+ // The adapter pushes whenever NODE_ENV !== 'production', which is true of
184
+ // every script and dev server. One pointed at a shared database silently
185
+ // syncs its schema and records a `dev` migration, and the next
186
+ // `payload migrate` in CI then stops to ask about it — with no terminal to
187
+ // answer — and skips the migration it was meant to apply.
188
+ push: false,
97
189
  }),
98
- collections: [Users, Pages, createApiKeysCollection()],
190
+ // MCP keys live in `payload-mcp-api-keys`, which `mcpPlugin` below brings.
191
+ collections: [Users, Pages],
99
192
  secret: process.env.PAYLOAD_SECRET!,
100
193
  typescript: {
101
194
  outputFile: path.resolve(__dirname, 'payload-types.ts'),
102
195
  },
103
196
  plugins: [
104
- // Order matters: audit first (so other plugins can write to it),
105
- // then content plugins, then notification/forms/integrations.
197
+ // Order matters twice. `auditPlugin` first: every other Throughline plugin
198
+ // requires the audit log at init. And every tool-bearing plugin before
199
+ // `mcpPlugin`, which reads the declared tools when the config is built — a
200
+ // plugin registered after it gets no per-key checkbox, and its tools are
201
+ // denied to every key with no error anywhere.
106
202
  auditPlugin({ inngest }),
107
203
 
204
+ // Where a workflow that ran out of retries, or a failing healthcheck, leaves
205
+ // a row — see the failure handlers in app/api/inngest/route.ts. Not the
206
+ // audit log: that records who did what through an MCP tool.
207
+ jobFailuresPlugin({}),
208
+
108
209
  componentsPlugin({
109
210
  {{#if useReferenceDs}}manifest: { type: 'object', manifest: designSystemManifest as unknown as Manifest },
110
211
  {{else}}// TODO: point at your design system manifest
111
212
  manifest: { type: 'url', url: 'https://your-design-system.example.com/manifest.json' },
112
213
  {{/if}}matching: { strategy: 'tfidf' },
214
+ mcpTools,
113
215
  }),
114
216
 
115
217
  publishingPlugin({
@@ -119,6 +221,7 @@ export default buildConfig({
119
221
  approvalResolver: {
120
222
  getActiveApproval: async () => null, // TODO: wire to approvals plugin
121
223
  },
224
+ mcpTools,
122
225
  }),
123
226
 
124
227
  approvalsPlugin({
@@ -135,9 +238,10 @@ export default buildConfig({
135
238
  return []
136
239
  },
137
240
  },
241
+ mcpTools,
138
242
  }),
139
243
 
140
- auditQueryPlugin({}),
244
+ auditQueryPlugin({ mcpTools }),
141
245
 
142
246
  emailPlugin({
143
247
  inngest,
@@ -161,19 +265,32 @@ export default buildConfig({
161
265
 
162
266
  formsPlugin({
163
267
  inngest,
164
- ipHashSecret: process.env.FORMS_IP_HASH_SECRET!,
165
- // TODO: list every place a form is allowed to send to. The plugin
166
- // refuses to deliver to destinations that aren't in this list.
268
+ // ipHashSecret: read from FORMS_IP_HASH_SECRET, checked above.
269
+ // TODO: list every place a form is allowed to send to, and delete the
270
+ // placeholder. The plugin refuses to start with an empty list — the
271
+ // allowlist is the security perimeter — and refuses to deliver anywhere
272
+ // not on it. example.com is reserved and accepts no mail, so the
273
+ // placeholder routes nothing anywhere while it stands.
167
274
  allowedDestinations: [
168
- // {
169
- // type: 'email',
170
- // value: 'team@example.com',
171
- // label: 'Main inbox',
172
- // description: 'General contact form destination',
173
- // },
275
+ {
276
+ type: 'email',
277
+ value: 'forms@example.com',
278
+ label: 'Placeholder',
279
+ description: 'Replace before launch: example.com accepts no mail.',
280
+ },
174
281
  ],
282
+ mcpTools,
175
283
  }),
176
284
 
177
- integrationsPlugin({ inngest }),
285
+ integrationsPlugin({ inngest, mcpTools }),
286
+
287
+ // Last of the tool-bearing chain. An MCP key is a credential that runs
288
+ // every tool as the person it is bound to, so who may mint, read and
289
+ // revoke one is an admin's call: `mcpApiKeyAccess` narrows the key
290
+ // collection to `isAdmin`, and refuses a key principal outright.
291
+ mcpPlugin({
292
+ mcp: { tools: mcpTools.tools },
293
+ overrideApiKeyCollection: mcpApiKeyAccess(isAdmin),
294
+ }),
178
295
  ],
179
296
  })
@@ -0,0 +1,57 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { defineConfig } from 'vitest/config'
4
+
5
+ const here = path.dirname(fileURLToPath(import.meta.url))
6
+
7
+ /*
8
+ The cheap tier: vitest in node, with no database and no browser.
9
+
10
+ Everything here is an invariant over values — the access rules, field
11
+ validations, hooks as functions — and `payload.config.ts` is imported, never
12
+ connected. That is what lets these run on every pull request, in CI's `fast`
13
+ job, which has no database. Anything that needs one belongs in `verify`, and
14
+ anything true only of a real response belongs in the smoke pack (`e2e/`).
15
+
16
+ `environment: 'node'`, deliberately: nothing in `apps/web` renders here. The
17
+ components live in the design system, whose own suite runs them.
18
+ */
19
+ export default defineConfig({
20
+ /*
21
+ The two aliases `tsconfig.json` declares. Next resolves them from that file
22
+ and vitest does not, so a module importing `@/lib/…` or `@payload-config`
23
+ cannot be loaded here unless they are repeated.
24
+ */
25
+ resolve: {
26
+ alias: {
27
+ '@payload-config': path.join(here, 'src/payload.config.ts'),
28
+ '@': path.join(here, 'src'),
29
+ },
30
+ },
31
+ test: {
32
+ environment: 'node',
33
+ include: ['src/**/*.test.ts'],
34
+ /*
35
+ Enough for `payload.config.ts` to load, and nothing more.
36
+
37
+ The plugins check their secrets when the config is built, so importing it
38
+ needs every value present. None is a credential: the secrets are padded
39
+ placeholders, the address is under `.invalid`, which RFC 2606 reserves so
40
+ it can never resolve, and the connection string names a database nobody
41
+ has. That is safe because the config *parses* the string and never
42
+ connects — the same fact CI's `fast` job relies on.
43
+
44
+ Set here rather than left to the shell, so `pnpm test` needs no setup and a
45
+ `DATABASE_URI` somebody exported for a migration cannot reach a test.
46
+ */
47
+ env: {
48
+ DATABASE_URI: 'postgres://test:test@localhost:5432/does-not-exist',
49
+ PAYLOAD_SECRET: 'test-only-not-a-real-secret-000000000000',
50
+ APPROVAL_TOKEN_SECRET: 'test-only-not-a-real-secret-000000000000',
51
+ FORMS_IP_HASH_SECRET: 'test-only-not-a-real-secret-000000000000',
52
+ RESEND_API_KEY: 're_test_only_not_a_real_key',
53
+ EMAIL_FROM_ADDRESS: 'test-only@example.invalid',
54
+ NEXT_PUBLIC_SERVER_URL: 'http://localhost:3000',
55
+ },
56
+ },
57
+ })
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Stop hook: run the cheap half of CI's gate — typecheck and lint — when a
4
+ # session ends with pending changes under apps/ or design-system/.
5
+ #
6
+ # Why a hook and not only CI: these failures are cheap to fix in the session
7
+ # that caused them and expensive later. A session that touched nothing exits in
8
+ # milliseconds, which is what keeps this cheap enough to leave switched on.
9
+ #
10
+ # Deliberately not here: tests, manifest validation, the generated-files check
11
+ # and the build. They take minutes or need a database; they are CI's job. See
12
+ # CLAUDE.md for the whole gate.
13
+ #
14
+ # This reports. It never blocks and never edits, so it cannot wedge a session on
15
+ # a failure it cannot resolve. Exit is always 0. Set THROUGHLINE_SKIP_GATE=1 to
16
+ # silence it, for instance in headless sessions that run their own gate.
17
+
18
+ set -uo pipefail
19
+
20
+ [[ -z "${THROUGHLINE_SKIP_GATE:-}" ]] || exit 0
21
+
22
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
23
+ [[ -n "$ROOT" ]] || exit 0
24
+
25
+ if [[ -z "$(git -C "$ROOT" status --porcelain -- apps design-system 2>/dev/null)" ]]; then
26
+ exit 0
27
+ fi
28
+
29
+ cd "$ROOT" || exit 0
30
+
31
+ OUTPUT="$(pnpm typecheck 2>&1 && pnpm lint 2>&1)"
32
+ STATUS=$?
33
+
34
+ if [[ $STATUS -eq 0 ]]; then
35
+ MESSAGE="Gate clean: typecheck + lint. Tests, validate, check-generated and the build are CI's job — see CLAUDE.md."
36
+ else
37
+ # The failing rule or type error is at the end, and a systemMessage is read
38
+ # in a terminal, not scrolled.
39
+ MESSAGE="Gate FAILED (typecheck/lint) — CI will fail on this:
40
+
41
+ $(printf '%s' "$OUTPUT" | tail -25)"
42
+ fi
43
+
44
+ node -e 'process.stdout.write(JSON.stringify({ systemMessage: process.argv[1], suppressOutput: true }))' "$MESSAGE"
45
+ exit 0
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "Stop": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "GATE=\"$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/gate.sh\"; [ -f \"$GATE\" ] && exec bash \"$GATE\" || exit 0",
9
+ "timeout": 180,
10
+ "statusMessage": "Running the gate (typecheck + lint)…"
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # payload-types.ts and importMap.js are committed but generated from
4
+ # payload.config.ts. Regenerating and diffing is what stops them drifting from
5
+ # the config:
6
+ #
7
+ # - a stale importMap.js is a 500 on any admin screen that renders a component
8
+ # the map has never heard of — a plugin upgrade that swaps in a custom
9
+ # Publish button is enough
10
+ # - stale types make everything downstream compile against a schema that no
11
+ # longer exists
12
+ #
13
+ # Run from the repository root, with the environment payload.config.ts demands.
14
+ # **No database is needed** — the config parses a connection string and never
15
+ # reaches it — which is what lets this run on every pull request.
16
+
17
+ set -euo pipefail
18
+
19
+ TYPES='apps/web/src/payload-types.ts'
20
+ MAP='apps/web/src/app/(payload)/admin/importMap.js'
21
+
22
+ # `git status --porcelain` rather than `git diff`: a file that was never
23
+ # committed is untracked, and `git diff` says nothing about it.
24
+ pnpm --dir apps/web run generate:types
25
+ if [ -n "$(git status --porcelain -- "$TYPES")" ]; then
26
+ git --no-pager diff -- "$TYPES" || true
27
+ echo "::error::payload-types.ts is stale or uncommitted — run 'pnpm --dir apps/web generate:types' and commit it."
28
+ exit 1
29
+ fi
30
+
31
+ # importMap.js is compared as a *set* of lines. Payload emits plugin entries in
32
+ # an order that differs between macOS and Linux, so a byte comparison fails on a
33
+ # file whose content is identical. Order does not matter to correctness; a
34
+ # missing entry does, and that is what this catches.
35
+ sort "$MAP" > "${RUNNER_TEMP:-/tmp}/importmap-committed"
36
+ pnpm --dir apps/web run generate:importmap
37
+ sort "$MAP" > "${RUNNER_TEMP:-/tmp}/importmap-regenerated"
38
+
39
+ if ! diff -u "${RUNNER_TEMP:-/tmp}/importmap-committed" "${RUNNER_TEMP:-/tmp}/importmap-regenerated"; then
40
+ echo "::error::importMap.js is stale — run 'pnpm --dir apps/web generate:importmap' and commit it."
41
+ exit 1
42
+ fi
43
+
44
+ git checkout -- "$MAP"