@forumone/create-throughline 0.3.0 → 0.4.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.
- package/CHANGELOG.md +47 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +7 -1
- package/dist/generator.js.map +1 -1
- package/dist/post-install.d.ts.map +1 -1
- package/dist/post-install.js +10 -3
- package/dist/post-install.js.map +1 -1
- package/dist/templates/base/.env.example +9 -13
- package/dist/templates/base/README.md +63 -11
- package/dist/templates/base/apps/web/package.json.template +21 -12
- package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
- package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
- package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
- package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +40 -4
- package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
- package/dist/templates/base/apps/web/src/payload.config.ts.template +90 -20
- package/dist/templates/base/github/scripts/check-generated.sh +44 -0
- package/dist/templates/base/github/workflows/ci.yml +131 -0
- package/dist/templates/base/package.json.template +6 -1
- package/dist/templates/base/scripts/check-audit.ts +117 -0
- package/dist/templates/base/scripts/check-single-instance.ts +113 -0
- package/dist/templates/base/scripts/check-tested-packages.ts +165 -0
- package/dist/templates/base/turbo.json +16 -1
- package/dist/templates/with-reference-ds/design-system/package.json.template +3 -3
- package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
- package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
- package/dist/utils/versions.d.ts +20 -0
- package/dist/utils/versions.d.ts.map +1 -0
- package/dist/utils/versions.js +59 -0
- package/dist/utils/versions.js.map +1 -0
- package/dist/versions.json +14 -0
- package/package.json +6 -6
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { getPayload } from 'payload'
|
|
6
|
+
import config from '@payload-config'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Writes the `.json` schema snapshot beside a hand-written migration.
|
|
10
|
+
|
|
11
|
+
pnpm migrate:snapshot # the newest migration
|
|
12
|
+
pnpm migrate:snapshot 20260812_154500_add_jobs
|
|
13
|
+
pnpm migrate:snapshot --force # replace an existing snapshot
|
|
14
|
+
|
|
15
|
+
`migrate:create` diffs the config against the snapshot beside the **most
|
|
16
|
+
recent** migration, and writes a fresh one only when it generates the SQL
|
|
17
|
+
itself. A migration written by hand therefore leaves the generator one step
|
|
18
|
+
behind, and every run after it re-proposes a change that already shipped.
|
|
19
|
+
|
|
20
|
+
This closes the gap the way `createMigration` does, minus the diff: it asks the
|
|
21
|
+
adapter for the schema the config describes and serialises it. The adapter
|
|
22
|
+
builds that schema from the config alone, so this needs **no database**.
|
|
23
|
+
|
|
24
|
+
## When it would be a lie
|
|
25
|
+
|
|
26
|
+
The snapshot says: *the database, once this migration has run, matches the
|
|
27
|
+
config.* Run it only after the hand-written migration is complete. A snapshot
|
|
28
|
+
claiming a config change the migration only half implements makes the next
|
|
29
|
+
generated migration quietly omit the missing half.
|
|
30
|
+
|
|
31
|
+
Overwriting needs `--force`, because the common mistake is running this instead
|
|
32
|
+
of writing the migration: the snapshot is rewritten to match a config that moved
|
|
33
|
+
on, and the change becomes invisible to the generator — the schema drifts with
|
|
34
|
+
no migration anywhere that would apply it.
|
|
35
|
+
|
|
36
|
+
The config is loaded, so the environment it validates must be present. Values
|
|
37
|
+
that only need to parse — secrets, keys — can be placeholders on this run.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
41
|
+
const MIGRATIONS = path.resolve(here, '../src/migrations')
|
|
42
|
+
|
|
43
|
+
// `pnpm run x -- --flag` forwards the `--` itself, which `parseArgs` would read
|
|
44
|
+
// as "positionals from here".
|
|
45
|
+
const argv = process.argv.slice(2).filter((arg) => arg !== '--')
|
|
46
|
+
|
|
47
|
+
const { positionals, values } = parseArgs({
|
|
48
|
+
args: argv,
|
|
49
|
+
allowPositionals: true,
|
|
50
|
+
options: { force: { type: 'boolean', default: false } },
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const migrations = (await readdir(MIGRATIONS).catch(() => [] as string[]))
|
|
54
|
+
.filter((name) => name.endsWith('.ts') && name !== 'index.ts')
|
|
55
|
+
.map((name) => name.slice(0, -'.ts'.length))
|
|
56
|
+
.sort()
|
|
57
|
+
|
|
58
|
+
if (migrations.length === 0) {
|
|
59
|
+
console.error(`No migrations in ${MIGRATIONS}.`)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const target = positionals[0]?.replace(/\.(json|ts)$/, '') ?? migrations.at(-1)!
|
|
64
|
+
|
|
65
|
+
if (!migrations.includes(target)) {
|
|
66
|
+
console.error(
|
|
67
|
+
`No migration named ${target}.\n\nMigrations in src/migrations:\n` +
|
|
68
|
+
migrations.map((name) => ` ${name}`).join('\n') +
|
|
69
|
+
'\n',
|
|
70
|
+
)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const destination = path.join(MIGRATIONS, `${target}.json`)
|
|
75
|
+
const existing = await readFile(destination, 'utf8').then(
|
|
76
|
+
() => true,
|
|
77
|
+
() => false,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if (existing && !values.force) {
|
|
81
|
+
console.error(
|
|
82
|
+
`${target}.json already exists, and describes the schema as of that migration.\n` +
|
|
83
|
+
'Rewriting it to match a config that has since changed hides the change from\n' +
|
|
84
|
+
'`migrate:create`. If replacing it is genuinely what you want, pass --force.\n',
|
|
85
|
+
)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (target !== migrations.at(-1)) {
|
|
90
|
+
console.warn(
|
|
91
|
+
`Warning: ${target} is not the newest migration. Only the newest snapshot is\n` +
|
|
92
|
+
'read by migrate:create, so this describes a state nothing will diff against.\n',
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// No database, and nothing that runs on startup (seeds, syncs, cron
|
|
97
|
+
// registration) from a script whose whole job is to serialise a schema.
|
|
98
|
+
const payload = await getPayload({ config, disableDBConnect: true, disableOnInit: true })
|
|
99
|
+
|
|
100
|
+
const db = payload.db as typeof payload.db & {
|
|
101
|
+
requireDrizzleKit: () => { generateDrizzleJson: (schema: unknown) => Promise<unknown> }
|
|
102
|
+
schema: unknown
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { generateDrizzleJson } = db.requireDrizzleKit()
|
|
106
|
+
const snapshot = await generateDrizzleJson(db.schema)
|
|
107
|
+
|
|
108
|
+
// Byte for byte what `createMigration` writes, with no trailing newline, so a
|
|
109
|
+
// hand-written migration's snapshot is indistinguishable from a generated one.
|
|
110
|
+
await writeFile(destination, JSON.stringify(snapshot, null, 2))
|
|
111
|
+
|
|
112
|
+
console.log(
|
|
113
|
+
`Wrote src/migrations/${target}.json\n\n` +
|
|
114
|
+
'Commit it beside the migration. Confirm the generator is unblocked with:\n\n' +
|
|
115
|
+
' pnpm migrate:create check --skip-empty\n\n' +
|
|
116
|
+
'which exits without writing anything when there is no diff.\n',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
process.exit(0)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InngestFunction } from 'inngest'
|
|
1
2
|
import { serve } from 'inngest/next'
|
|
2
3
|
import { getPayload } from 'payload'
|
|
3
4
|
import config from '@/payload.config'
|
|
@@ -8,8 +9,11 @@ import {
|
|
|
8
9
|
createExpireStaleApprovalsFunction,
|
|
9
10
|
createHealthcheckFunction,
|
|
10
11
|
createPayloadReachableCheck,
|
|
12
|
+
createPublishAtScheduledTimeFunction,
|
|
11
13
|
createRevalidateOnPublishFunction,
|
|
14
|
+
type ScheduledPublishRequest,
|
|
12
15
|
} from '@forumone/throughline-workflows'
|
|
16
|
+
import { getPublishingService } from '@forumone/throughline-publishing'
|
|
13
17
|
import { getEmailFunctions } from '@forumone/throughline-email'
|
|
14
18
|
import { getFormsFunctions } from '@forumone/throughline-forms'
|
|
15
19
|
import {
|
|
@@ -22,7 +26,30 @@ const payload = await getPayload({ config })
|
|
|
22
26
|
|
|
23
27
|
const emailFunctions = getEmailFunctions(payload) ?? []
|
|
24
28
|
const formsFunctions = getFormsFunctions(payload) ?? []
|
|
25
|
-
|
|
29
|
+
/*
|
|
30
|
+
Through the publishing service, in process — so a scheduled publish passes the
|
|
31
|
+
same composition, accessibility and approval gates as an interactive one. Not
|
|
32
|
+
`payload.update`, which skips all of them.
|
|
33
|
+
*/
|
|
34
|
+
async function publishScheduled({ collection, id, reasoning }: ScheduledPublishRequest) {
|
|
35
|
+
const outcome = await getPublishingService(payload).publish({
|
|
36
|
+
collection,
|
|
37
|
+
id,
|
|
38
|
+
actor: { apiKeyName: 'scheduled-publish', channel: 'mcp' },
|
|
39
|
+
meta: { reasoning },
|
|
40
|
+
})
|
|
41
|
+
return { published: outcome.published, reason: outcome.reason }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const scheduledCollections = [{ slug: 'pages' }]
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
Typed with this app's own `InngestFunction.Any`. The registry is generic in what
|
|
48
|
+
`createFunctions` returns and defaults to `unknown`, because the platform never
|
|
49
|
+
touches those functions — and one `unknown` element leaves `serve()` unable to
|
|
50
|
+
type the whole array below.
|
|
51
|
+
*/
|
|
52
|
+
const integrationRegistry = getIntegrationRegistry<InngestFunction.Any>(payload)
|
|
26
53
|
const integrationContext = getIntegrationContext(payload)
|
|
27
54
|
const integrationFunctions =
|
|
28
55
|
integrationRegistry && integrationContext
|
|
@@ -35,12 +62,21 @@ export const { GET, POST, PUT } = serve({
|
|
|
35
62
|
client: inngest,
|
|
36
63
|
functions: [
|
|
37
64
|
createRevalidateOnPublishFunction({ inngest, payload }),
|
|
65
|
+
// On time: sleeps until each document's scheduled time.
|
|
66
|
+
createPublishAtScheduledTimeFunction({
|
|
67
|
+
inngest,
|
|
68
|
+
payload,
|
|
69
|
+
collections: scheduledCollections,
|
|
70
|
+
publish: publishScheduled,
|
|
71
|
+
}),
|
|
72
|
+
// The backstop, for a schedule whose wake-up event never arrived.
|
|
38
73
|
createExecuteScheduledPublishesFunction({
|
|
39
74
|
inngest,
|
|
40
75
|
payload,
|
|
41
|
-
collections:
|
|
42
|
-
|
|
43
|
-
|
|
76
|
+
collections: scheduledCollections,
|
|
77
|
+
publish: publishScheduled,
|
|
78
|
+
schedule: '17 3 * * *',
|
|
79
|
+
overdueByMs: 60 * 60 * 1000,
|
|
44
80
|
}),
|
|
45
81
|
createExpireStaleApprovalsFunction({ inngest, payload }),
|
|
46
82
|
createAuditEventEchoFunction({ inngest }),
|
|
@@ -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,21 +1,27 @@
|
|
|
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
8
|
auditPlugin,
|
|
9
|
-
createApiKeysCollection,
|
|
10
9
|
createInngestClient,
|
|
10
|
+
createMcpToolCollector,
|
|
11
11
|
} from '@forumone/throughline-core'
|
|
12
|
+
import { mcpPlugin } from '@payloadcms/plugin-mcp'
|
|
12
13
|
import { componentsPlugin } from '@forumone/throughline-components'
|
|
13
14
|
import { publishingPlugin } from '@forumone/throughline-publishing'
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
approvalsPlugin,
|
|
17
|
+
buildActionUrl,
|
|
18
|
+
generateActionToken,
|
|
19
|
+
} from '@forumone/throughline-approvals'
|
|
15
20
|
import { auditQueryPlugin } from '@forumone/throughline-audit'
|
|
16
21
|
import { integrationsPlugin } from '@forumone/throughline-integrations'
|
|
17
22
|
import { emailPlugin } from '@forumone/throughline-email'
|
|
18
23
|
import { formsPlugin } from '@forumone/throughline-forms'
|
|
24
|
+
import { databaseConnectionString } from './lib/database'
|
|
19
25
|
{{#if useReferenceDs}}import type { Manifest } from '@forumone/throughline-design-contract'
|
|
20
26
|
import designSystemManifest from '{{designSystemName}}/manifest' with { type: 'json' }
|
|
21
27
|
{{/if}}
|
|
@@ -25,6 +31,23 @@ const __dirname = path.dirname(__filename)
|
|
|
25
31
|
|
|
26
32
|
const inngest = createInngestClient({ id: '{{projectName}}' })
|
|
27
33
|
|
|
34
|
+
/*
|
|
35
|
+
Where every Throughline server puts its tools for `mcpPlugin`, which serves them
|
|
36
|
+
all on one `/api/mcp`. Each plugin declares its tools as the config is built and
|
|
37
|
+
binds the handlers at `onInit`; `mcpPlugin` reads this array at both moments.
|
|
38
|
+
Hand over `mcpTools.tools` itself — a spread or `.slice()` would hand over
|
|
39
|
+
something nobody fills.
|
|
40
|
+
*/
|
|
41
|
+
const mcpTools = createMcpToolCollector()
|
|
42
|
+
|
|
43
|
+
// A person in `users` with the admin role. `req.user` can also be an MCP key
|
|
44
|
+
// document, which has no roles and is never an admin, so the collection is
|
|
45
|
+
// checked first — once payload-types.ts exists, TypeScript insists on it.
|
|
46
|
+
const isAdmin: Access = ({ req: { user } }) =>
|
|
47
|
+
user?.collection === 'users' &&
|
|
48
|
+
Array.isArray(user.roles) &&
|
|
49
|
+
(user.roles as unknown[]).includes('admin')
|
|
50
|
+
|
|
28
51
|
// Users collection — extend with your own fields and SSO/auth provider.
|
|
29
52
|
const Users: CollectionConfig = {
|
|
30
53
|
slug: 'users',
|
|
@@ -89,16 +112,30 @@ export default buildConfig({
|
|
|
89
112
|
admin: { user: 'users' },
|
|
90
113
|
editor: lexicalEditor(),
|
|
91
114
|
db: postgresAdapter({
|
|
92
|
-
|
|
115
|
+
// See lib/database.ts: which variable wins, and why a migration refuses
|
|
116
|
+
// Neon's pooled endpoint.
|
|
117
|
+
pool: { connectionString: databaseConnectionString() },
|
|
118
|
+
migrationDir: path.resolve(__dirname, 'migrations'),
|
|
119
|
+
// Never push schema: migrations are the only way this database changes.
|
|
120
|
+
// The adapter pushes whenever NODE_ENV !== 'production', which is true of
|
|
121
|
+
// every script and dev server. One pointed at a shared database silently
|
|
122
|
+
// syncs its schema and records a `dev` migration, and the next
|
|
123
|
+
// `payload migrate` in CI then stops to ask about it — with no terminal to
|
|
124
|
+
// answer — and skips the migration it was meant to apply.
|
|
125
|
+
push: false,
|
|
93
126
|
}),
|
|
94
|
-
|
|
127
|
+
// MCP keys live in `payload-mcp-api-keys`, which `mcpPlugin` below brings.
|
|
128
|
+
collections: [Users, Pages],
|
|
95
129
|
secret: process.env.PAYLOAD_SECRET!,
|
|
96
130
|
typescript: {
|
|
97
131
|
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
|
98
132
|
},
|
|
99
133
|
plugins: [
|
|
100
|
-
// Order matters
|
|
101
|
-
//
|
|
134
|
+
// Order matters twice. `auditPlugin` first: every other Throughline plugin
|
|
135
|
+
// requires the audit log at init. And every tool-bearing plugin before
|
|
136
|
+
// `mcpPlugin`, which reads the declared tools when the config is built — a
|
|
137
|
+
// plugin registered after it gets no per-key checkbox, and its tools are
|
|
138
|
+
// denied to every key with no error anywhere.
|
|
102
139
|
auditPlugin({ inngest }),
|
|
103
140
|
|
|
104
141
|
componentsPlugin({
|
|
@@ -106,6 +143,7 @@ export default buildConfig({
|
|
|
106
143
|
{{else}}// TODO: point at your design system manifest
|
|
107
144
|
manifest: { type: 'url', url: 'https://your-design-system.example.com/manifest.json' },
|
|
108
145
|
{{/if}}matching: { strategy: 'tfidf' },
|
|
146
|
+
mcpTools,
|
|
109
147
|
}),
|
|
110
148
|
|
|
111
149
|
publishingPlugin({
|
|
@@ -115,6 +153,7 @@ export default buildConfig({
|
|
|
115
153
|
approvalResolver: {
|
|
116
154
|
getActiveApproval: async () => null, // TODO: wire to approvals plugin
|
|
117
155
|
},
|
|
156
|
+
mcpTools,
|
|
118
157
|
}),
|
|
119
158
|
|
|
120
159
|
approvalsPlugin({
|
|
@@ -131,36 +170,67 @@ export default buildConfig({
|
|
|
131
170
|
return []
|
|
132
171
|
},
|
|
133
172
|
},
|
|
173
|
+
mcpTools,
|
|
134
174
|
}),
|
|
135
175
|
|
|
136
|
-
auditQueryPlugin({}),
|
|
176
|
+
auditQueryPlugin({ mcpTools }),
|
|
137
177
|
|
|
138
178
|
emailPlugin({
|
|
139
179
|
inngest,
|
|
140
180
|
// TODO: replace with your real user lookups
|
|
141
181
|
resolveApprover: async (_userId) => null,
|
|
142
182
|
resolveRequester: async (_userId) => null,
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
183
|
+
// Signed, and pointed at the route approvalsPlugin serves
|
|
184
|
+
// (`/api/approvals/action`). The token binds one approval, one action
|
|
185
|
+
// and one approver, so a link cannot be edited into a different decision.
|
|
186
|
+
buildActionUrl: async ({ approvalId, action, approverId }) => {
|
|
187
|
+
const secret = process.env.APPROVAL_TOKEN_SECRET
|
|
188
|
+
if (!secret) throw new Error('APPROVAL_TOKEN_SECRET is required to sign approval links.')
|
|
189
|
+
const token = await generateActionToken(
|
|
190
|
+
{ approvalId, action, approverId, issuedAt: Date.now() },
|
|
191
|
+
secret,
|
|
192
|
+
)
|
|
193
|
+
return buildActionUrl(process.env.NEXT_PUBLIC_SERVER_URL!, token)
|
|
194
|
+
},
|
|
146
195
|
tokens: { brandName: '{{projectName}}' },
|
|
147
196
|
}),
|
|
148
197
|
|
|
149
198
|
formsPlugin({
|
|
150
199
|
inngest,
|
|
151
200
|
ipHashSecret: process.env.FORMS_IP_HASH_SECRET!,
|
|
152
|
-
// TODO: list every place a form is allowed to send to
|
|
153
|
-
// refuses to
|
|
201
|
+
// TODO: list every place a form is allowed to send to, and delete the
|
|
202
|
+
// placeholder. The plugin refuses to start with an empty list — the
|
|
203
|
+
// allowlist is the security perimeter — and refuses to deliver anywhere
|
|
204
|
+
// not on it. example.com is reserved and accepts no mail, so the
|
|
205
|
+
// placeholder routes nothing anywhere while it stands.
|
|
154
206
|
allowedDestinations: [
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
207
|
+
{
|
|
208
|
+
type: 'email',
|
|
209
|
+
value: 'forms@example.com',
|
|
210
|
+
label: 'Placeholder',
|
|
211
|
+
description: 'Replace before launch: example.com accepts no mail.',
|
|
212
|
+
},
|
|
161
213
|
],
|
|
214
|
+
mcpTools,
|
|
162
215
|
}),
|
|
163
216
|
|
|
164
|
-
integrationsPlugin({ inngest }),
|
|
217
|
+
integrationsPlugin({ inngest, mcpTools }),
|
|
218
|
+
|
|
219
|
+
// Last of the tool-bearing chain. An MCP key is a credential: the plugin
|
|
220
|
+
// ships its collection with no access rules — "anybody signed in" — so it
|
|
221
|
+
// is narrowed to admins here.
|
|
222
|
+
mcpPlugin({
|
|
223
|
+
mcp: { tools: mcpTools.tools },
|
|
224
|
+
overrideApiKeyCollection: (collection) => ({
|
|
225
|
+
...collection,
|
|
226
|
+
access: {
|
|
227
|
+
...collection.access,
|
|
228
|
+
read: isAdmin,
|
|
229
|
+
create: isAdmin,
|
|
230
|
+
update: isAdmin,
|
|
231
|
+
delete: isAdmin,
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
}),
|
|
165
235
|
],
|
|
166
236
|
})
|
|
@@ -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"
|