create-meith 0.17.2 → 0.19.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/src/scaffold.ts CHANGED
@@ -1,11 +1,78 @@
1
+ export type ScaffoldTarget = 'self-host' | 'vercel'
2
+
1
3
  export interface ScaffoldOptions {
2
4
  readonly name: string
3
5
  readonly version: string
4
6
  readonly repositoryUrl: string
7
+ readonly target?: ScaffoldTarget
8
+ readonly templateRepositoryUrl?: string
5
9
  }
6
10
 
7
11
  export const DEFAULT_REPOSITORY_URL = 'https://github.com/meith-dev/meith'
8
12
 
13
+ export const DEFAULT_TEMPLATE_REPOSITORY_URL = 'https://github.com/meith-dev/vercel-template'
14
+
15
+ export const NEXT_VERSION = '16.3.1'
16
+
17
+ export const AT_ROOT_FLAG = '--at-root'
18
+
19
+ export const MATERIALIZED_AT_ROOT = [
20
+ 'app',
21
+ 'src',
22
+ 'public',
23
+ 'next.config.mjs',
24
+ 'postcss.config.mjs',
25
+ 'components.json',
26
+ 'instrumentation.ts',
27
+ 'proxy.ts',
28
+ 'tsconfig.json',
29
+ 'next-env.d.ts',
30
+ ]
31
+
32
+ export const VERCEL_BUILD_COMMAND = `community migrate && forum-web build ${AT_ROOT_FLAG}`
33
+
34
+ export const TICK_PATH = '/api/system/tick'
35
+
36
+ export const TICK_SCHEDULE = '* * * * *'
37
+
38
+ export const MATERIALIZED_PUBLIC = [
39
+ 'placeholder-logo.png',
40
+ 'placeholder-logo.svg',
41
+ 'placeholder-user.jpg',
42
+ 'placeholder.jpg',
43
+ 'placeholder.svg',
44
+ 'sw.js',
45
+ ]
46
+
47
+ const AT_ROOT_IGNORE_PATHS = MATERIALIZED_AT_ROOT.flatMap((entry) =>
48
+ entry === 'public' ? MATERIALIZED_PUBLIC.map((file) => `/public/${file}`) : [`/${entry}`],
49
+ ).join('\n')
50
+
51
+ const AT_ROOT_IGNORES = `# What \`forum-web ${AT_ROOT_FLAG}\` writes into this directory: @meith/web's own
52
+ # Next app, materialized here rather than into .meith/app so that the build
53
+ # artefact lands at ./.next, where Vercel's Next.js builder reads it. Every
54
+ # path here belongs to the framework and is rewritten on every build.
55
+ #
56
+ # public/ is listed file by file rather than as a directory, because that one
57
+ # is shared: forum-web decides what it owns per file, so this board's own
58
+ # public/ads.txt, public/.well-known/... or domain-verification file sits
59
+ # beside the framework's and is tracked normally.
60
+ #
61
+ # app/ and src/ are ignored WHOLESALE, and that has a consequence worth
62
+ # knowing before you go looking for it: a file you add under either is left
63
+ # alone by the build and still never committed, so it works locally and is
64
+ # simply absent from the deploy, which builds from what git has. Extend the
65
+ # board with a plugin or a theme instead — the forum loads those from
66
+ # community.config.ts, and they are yours to commit. forum-web prints a
67
+ # warning naming any file of yours it finds there.
68
+ #
69
+ # For the rest, a build refuses rather than overwriting a file it did not
70
+ # write, and names it. The two exceptions are tsconfig.json and
71
+ # next-env.d.ts: forum-web generates those from scratch every run rather than
72
+ # copying them, so it cannot tell one of yours from a stale one of its own
73
+ # and replaces them without asking.
74
+ ${AT_ROOT_IGNORE_PATHS}`
75
+
9
76
  const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,213}$/
10
77
 
11
78
  export function validateName(name: string): string | null {
@@ -20,8 +87,323 @@ export function validateName(name: string): string | null {
20
87
  return null
21
88
  }
22
89
 
90
+ const ENV_REQUIRED_HEADING = `# ─── Required ────────────────────────────────────────────────────────────────`
91
+
92
+ const ENV_OPTIONAL_HEADING = `# ─── Optional ────────────────────────────────────────────────────────────────`
93
+
94
+ const ENV_DATABASE_URL_PROSE = `# Your Postgres connection string.
95
+ #
96
+ # If it is a managed database that offers a TRANSACTION-MODE POOLER string, use
97
+ # that rather than the direct one — Neon, Supabase and their kind hand out both,
98
+ # and on the direct string a board works in testing and starts refusing
99
+ # connections under the first real traffic, with an error that names the
100
+ # database rather than the cause. Your own Postgres, with a fixed number of
101
+ # processes in front of it, does not need one.`
102
+
103
+ const ENV_DIRECT_DATABASE_URL_PROSE = `# The other half of that pair: the DIRECT (non-pooler) string, used only by
104
+ # \`community migrate\` and \`community backup\`. Migrations hold a session-level
105
+ # advisory lock so that two deploys landing together queue instead of both
106
+ # applying the same migration, and a transaction-mode pooler cannot hold that
107
+ # lock: it takes the connection back the moment the lock statement ends, which
108
+ # leaves the lock on a backend another client gets. Set both and each gets the
109
+ # connection it needs; set only DATABASE_URL and migrations use it too, which is
110
+ # right for a Postgres you run yourself.`
111
+
112
+ const ENV_AUTH_SECRET_PROSE = `# Session and token signing. No default, deliberately: a shipped default is a
113
+ # board every reader of the source can sign a session for.
114
+ #
115
+ # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`
116
+
117
+ const ENV_TICK_SECRET_PROSE = `# The shared secret the tick caller presents to GET /api/system/tick. Generate
118
+ # it the same way. Without it the tick is unauthenticated, and the tick is how
119
+ # bans expire and digests send.`
120
+
121
+ const ENV_DATA_SOURCE_PROSE = `# fixture = deterministic in-memory sample data, no database needed. This is
122
+ # what \`npm run build\` uses, and what a checkout with no database falls back to.`
123
+
124
+ const ENV_APP_URL_PROSE = `# Absolute, no trailing slash. Used in mail, feeds and canonical URLs — every
125
+ # place a relative URL cannot work because there is no request to be relative to.
126
+ #
127
+ # Optional: leave it blank and the installer asks, prefilled from the address you
128
+ # load /install at, and stores the answer on the board where the settings screen
129
+ # can change it without a redeploy. Set it here and it wins outright.`
130
+
131
+ const ENV_SMTP_MAIL_BLOCK = `# Mail. Leave these alone and the installer asks for mail on first run, storing
132
+ # it on the board — a settings screen with a test button, no redeploy. Set
133
+ # MAIL_DRIVER here instead and the environment wins outright, which is what you
134
+ # want if the credential must not live in the database.
135
+ #
136
+ # The default sends NOTHING: each message goes to the server log, so password
137
+ # reset fails silently until mail is configured one way or the other.
138
+ # MAIL_DRIVER=smtp
139
+ # MAIL_SMTP_HOST=smtp.example.com
140
+ # MAIL_SMTP_PORT=465
141
+ # MAIL_SMTP_SECURITY=tls # tls (465) | starttls (587) | none
142
+ # MAIL_SMTP_USERNAME=
143
+ # MAIL_SMTP_PASSWORD=
144
+ # MAIL_FROM=noreply@yourdomain.com`
145
+
146
+ function selfHostEnvExample(name: string): string {
147
+ return `# ${name} — environment.
148
+ #
149
+ # Copy to .env.local for development. On the server this is \`.env\` beside the
150
+ # compose file; nothing here belongs in git.
151
+
152
+ ${ENV_REQUIRED_HEADING}
153
+
154
+ ${ENV_DATABASE_URL_PROSE}
155
+ DATABASE_URL=
156
+
157
+ ${ENV_DIRECT_DATABASE_URL_PROSE}
158
+ # DIRECT_DATABASE_URL=
159
+
160
+ ${ENV_AUTH_SECRET_PROSE}
161
+ AUTH_SECRET=
162
+
163
+ ${ENV_TICK_SECRET_PROSE}
164
+ TICK_SECRET=
165
+
166
+ ${ENV_OPTIONAL_HEADING}
167
+
168
+ ${ENV_DATA_SOURCE_PROSE}
169
+ DATA_SOURCE=postgres
170
+
171
+ ${ENV_APP_URL_PROSE}
172
+ APP_URL=
173
+
174
+ ${ENV_SMTP_MAIL_BLOCK}
175
+
176
+ `
177
+ }
178
+
179
+ function vercelEnvExample(name: string): string {
180
+ return `# ${name} — environment, on Vercel.
181
+ #
182
+ # Nothing on Vercel reads this file. The platform holds each of these as a
183
+ # project environment variable, and the Deploy Button in README.md asks for the
184
+ # ones it cannot provision itself. This is the reference for what they mean —
185
+ # and the file to copy to .env.local to run the same board on your own machine.
186
+
187
+ # ─── Drivers ─────────────────────────────────────────────────────────────────
188
+
189
+ # An instance is created for a request, may be frozen between requests, and is
190
+ # destroyed without warning; it has a writable /tmp nothing else can read and no
191
+ # background process of its own. Every driver below therefore keeps its state
192
+ # somewhere outside the instance, and these five values are not a default to
193
+ # tune — they are the one combination the board supports on functions.
194
+ #
195
+ # DATA_SOURCE=fixture is a read-only sample board with no write side.
196
+ # QUEUE_DRIVER=memory loses every queued job when the instance goes away, which
197
+ # is after almost every request, and the board already refuses it in production.
198
+ # CACHE_DRIVER=next and memory cache inside the process, so each instance serves
199
+ # its own stale copy for up to a minute. FILESTORE_DRIVER=local writes to a disk
200
+ # no other instance can read and that is discarded with the instance — on Vercel
201
+ # the board refuses it outright rather than losing uploads quietly.
202
+ #
203
+ # The deploy form asks for none of them. On Vercel the board works each one out
204
+ # from what the linked stores publish: a DATABASE_URL means postgres for the
205
+ # data source and the queue, a Redis connection string means CACHE_DRIVER=redis,
206
+ # a Blob store's read-write token means FILESTORE_DRIVER=blob, and a
207
+ # RESEND_API_KEY with a MAIL_FROM beside it means mail over the provider's HTTPS
208
+ # API. Setting one here overrides the derivation, which is what this file is for
209
+ # when you copy it to .env.local.
210
+ #
211
+ # Every one of those derivations is scoped to Vercel, and each fires only from a
212
+ # value that is unambiguously the thing itself — a redis:// or rediss:// URL, a
213
+ # read-write token. A board you run anywhere else is untouched by all of it and
214
+ # still takes these values from this file, exactly as it did before.
215
+ #
216
+ # A derivation that cannot resolve is a configuration error, not an invitation
217
+ # to pick something safe-looking. On Vercel, with the cache or the object store
218
+ # missing, the board refuses to boot and names every variable it looked at. It
219
+ # will not quietly cache inside the instance, and it will not quietly write
220
+ # uploads to a disk that is about to disappear.
221
+ DATA_SOURCE=postgres
222
+ QUEUE_DRIVER=postgres
223
+ CACHE_DRIVER=redis
224
+ FILESTORE_DRIVER=blob
225
+ MAIL_DRIVER=http
226
+
227
+ ${ENV_REQUIRED_HEADING}
228
+
229
+ ${ENV_DATABASE_URL_PROSE}
230
+ DATABASE_URL=
231
+
232
+ ${ENV_DIRECT_DATABASE_URL_PROSE}
233
+ #
234
+ # On Vercel this is not optional, and it is no longer yours to copy. DATABASE_URL
235
+ # here is the pooler string, the build runs \`community migrate\` against it, and
236
+ # /install takes the second of those two session locks on first run. Left blank,
237
+ # the board reads Neon's own direct string — \`DATABASE_URL_UNPOOLED\` first, then
238
+ # \`POSTGRES_URL_NON_POOLING\` — and refuses to boot if neither is there, naming
239
+ # both. Never \`POSTGRES_URL\`: that one is pooled.
240
+ DIRECT_DATABASE_URL=
241
+
242
+ # The shared cache — a Redis or Valkey endpoint, \`rediss://\` for TLS. Redis
243
+ # holds cache entries and nothing else: losing it costs the board a warm cache,
244
+ # not data, and signs nobody out. Left blank on Vercel, the board reads the
245
+ # Upstash store's own \`KV_URL\`, which is the one variable it publishes that
246
+ # speaks the Redis protocol — \`KV_REST_API_URL\` is an HTTPS endpoint and is
247
+ # never used for this. A name we do not know goes here by hand.
248
+ REDIS_URL=
249
+
250
+ ${ENV_AUTH_SECRET_PROSE}
251
+ AUTH_SECRET=
252
+
253
+ ${ENV_TICK_SECRET_PROSE}
254
+ #
255
+ # Vercel Cron sends \`Authorization: Bearer <CRON_SECRET>\` and cannot be told to
256
+ # send any other name, so CRON_SECRET is the one to set here. The board accepts
257
+ # either name, and both when both are set. Whichever you use, 32 characters is
258
+ # the floor — stricter than the 16 Vercel's own cron documentation suggests, so
259
+ # a secret generated by following those instructions is rejected here.
260
+ CRON_SECRET=
261
+ # TICK_SECRET=
262
+
263
+ # Uploads, in the Vercel Blob store the Deploy Button provisions. A store
264
+ # attached to the project publishes BLOB_STORE_ID and nothing else — no token —
265
+ # because the SDK authenticates with the deployment's own OIDC identity: the
266
+ # board hands it the store id and lets it fetch the credential. There is nothing
267
+ # to type and nothing to mistype, and FILESTORE_DRIVER=blob derives from this
268
+ # variable being present.
269
+ #
270
+ # BLOB_READ_WRITE_TOKEN is the other way in, and you make it yourself on the
271
+ # store. Set it when something has to reach the store from OUTSIDE a Vercel
272
+ # deployment — \`community backup\` run on your own machine is the case that
273
+ # matters — because there is no OIDC identity there to borrow. Set both and the
274
+ # board prefers the store id, unless the token names a different store, in which
275
+ # case the token wins: naming another store is a deliberate act.
276
+ #
277
+ # Every object is written with private access: an object URL is not a public
278
+ # link, and member content is served by the board, which is where permissions
279
+ # are checked. An upload is held whole in the instance's memory on the way in
280
+ # and on the way out, so the function's memory limit, not the store, is what
281
+ # caps a file.
282
+ BLOB_STORE_ID=
283
+ BLOB_READ_WRITE_TOKEN=
284
+
285
+ # Uploads in an S3-compatible bucket instead — AWS, R2, MinIO, Spaces. This is
286
+ # the portable option, and the one every other deployment of this board uses:
287
+ # a bucket is a thing you hold, and it is not the only way to get the objects
288
+ # out of it. Set FILESTORE_DRIVER=s3 above and the first four below; boot fails
289
+ # naming any that are missing. S3_ENDPOINT is for anything that is not AWS and
290
+ # switches the client to path-style addressing; set S3_REGION=auto for R2.
291
+ # S3_PUBLIC_BASE_URL is the host objects are *served* from when that is not the
292
+ # API endpoint.
293
+ # S3_BUCKET=
294
+ # S3_REGION=
295
+ # S3_ACCESS_KEY_ID=
296
+ # S3_SECRET_ACCESS_KEY=
297
+ # S3_ENDPOINT=
298
+ # S3_PUBLIC_BASE_URL=
299
+
300
+ # Mail over the provider's own HTTPS API, on 443 — the one outbound path a
301
+ # function can rely on. SMTP on port 25 is blocked by serverless egress and the
302
+ # board refuses it on Vercel; 587 with STARTTLS may work, but an API does not
303
+ # depend on the platform's egress rules staying as they are.
304
+ #
305
+ # MAIL_FROM is yours to decide, and it belongs to the step that adds Resend
306
+ # rather than to the deploy: it must be an address at a domain the provider has
307
+ # verified for you, no default is right, and until a provider is added the board
308
+ # cannot send from any address at all. Set it alongside the integration.
309
+ MAIL_FROM=
310
+
311
+ # Add the Resend integration to the project from Vercel's marketplace and it
312
+ # publishes its key under this name, which the board reads: with RESEND_API_KEY
313
+ # set, and MAIL_FROM beside it, the board sends over Resend's HTTPS API and
314
+ # needs neither of the two variables below. The mail driver itself is a plain
315
+ # JSON-over-HTTPS sender and is not Resend-specific — this is one injected name
316
+ # bridged to the generic pair, not a provider baked into the board.
317
+ RESEND_API_KEY=
318
+
319
+ # Any other provider with the same shape — a bearer token and an endpoint that
320
+ # accepts {from, to, subject, text, html, reply_to}. Set BOTH, plus
321
+ # MAIL_DRIVER=http above; they do not turn the driver on by themselves, and only
322
+ # RESEND_API_KEY implies it.
323
+ #
324
+ # Setting just one of them stands the Resend bridge down completely, on purpose:
325
+ # the board will not hand a key issued for Resend to an endpoint you chose, nor
326
+ # aim your token at Resend. Boot fails naming the half you left out. Delete
327
+ # RESEND_API_KEY once you have moved off Resend.
328
+ # MAIL_HTTP_ENDPOINT=
329
+ # MAIL_HTTP_TOKEN=
330
+
331
+ ${ENV_OPTIONAL_HEADING}
332
+
333
+ ${ENV_APP_URL_PROSE}
334
+ APP_URL=
335
+
336
+ `
337
+ }
338
+
339
+ function envExample(name: string, target: ScaffoldTarget): string {
340
+ return target === 'vercel' ? vercelEnvExample(name) : selfHostEnvExample(name)
341
+ }
342
+
343
+ const SELF_HOST_DEPLOY_KIT = [
344
+ '.dockerignore',
345
+ '.github/workflows/build.yml',
346
+ 'Dockerfile',
347
+ 'docker-compose.yml',
348
+ 'docker-entrypoint.sh',
349
+ 'docker-healthcheck.sh',
350
+ ] as const
351
+
352
+ const VERCEL_DERIVED_DRIVERS = [
353
+ 'DATA_SOURCE=postgres',
354
+ 'QUEUE_DRIVER=postgres',
355
+ 'CACHE_DRIVER=redis',
356
+ 'FILESTORE_DRIVER=blob',
357
+ 'MAIL_DRIVER=http',
358
+ ] as const
359
+
360
+ export const VERCEL_PROMPTED_ENV = ['AUTH_SECRET', 'CRON_SECRET'] as const
361
+
362
+ export const VERCEL_MARKETPLACE_STORES = [
363
+ { type: 'integration', integrationSlug: 'neon', productSlug: 'neon', protocol: 'storage' },
364
+ {
365
+ type: 'integration',
366
+ integrationSlug: 'upstash',
367
+ productSlug: 'upstash-kv',
368
+ protocol: 'storage',
369
+ },
370
+ { type: 'blob' },
371
+ ] as const
372
+
373
+ export function deployButtonUrl(templateRepositoryUrl: string): string {
374
+ const params = new URLSearchParams([
375
+ ['repository-url', templateRepositoryUrl],
376
+ ['project-name', 'meith-board'],
377
+ ['repository-name', 'meith-board'],
378
+ ['env', VERCEL_PROMPTED_ENV.join(',')],
379
+ [
380
+ 'envDescription',
381
+ 'Two secrets, generated rather than chosen — 32 characters or more each. Everything else the board reads from the database, cache and blob store this form links.',
382
+ ],
383
+ ['envLink', `${templateRepositoryUrl}/blob/main/.env.example`],
384
+ ['stores', JSON.stringify(VERCEL_MARKETPLACE_STORES)],
385
+ ['skippable-integrations', '1'],
386
+ ])
387
+
388
+ return `https://vercel.com/new/clone?${params.toString()}`
389
+ }
390
+
391
+ function vercelJson(): string {
392
+ return `${JSON.stringify(
393
+ {
394
+ framework: 'nextjs',
395
+ buildCommand: VERCEL_BUILD_COMMAND,
396
+ crons: [{ path: TICK_PATH, schedule: TICK_SCHEDULE }],
397
+ },
398
+ null,
399
+ 2,
400
+ )}\n`
401
+ }
402
+
23
403
  export function scaffold(options: ScaffoldOptions): ReadonlyMap<string, string> {
24
404
  const { name, version, repositoryUrl } = options
405
+ const target = options.target ?? 'self-host'
406
+ const atRootFlag = target === 'vercel' ? ` ${AT_ROOT_FLAG}` : ''
25
407
  const files = new Map<string, string>()
26
408
 
27
409
  files.set(
@@ -33,15 +415,16 @@ export function scaffold(options: ScaffoldOptions): ReadonlyMap<string, string>
33
415
  private: true,
34
416
  type: 'module',
35
417
  scripts: {
36
- dev: 'forum-web dev',
37
- build: 'forum-web build',
38
- start: 'forum-web start',
418
+ dev: `forum-web dev${atRootFlag}`,
419
+ build: `forum-web build${atRootFlag}`,
420
+ start: `forum-web start${atRootFlag}`,
39
421
  community: 'community',
40
422
  },
41
423
  dependencies: {
42
424
  '@meith/web': version,
43
425
  '@meith/cli': version,
44
426
  '@meith/theme-default': version,
427
+ next: NEXT_VERSION,
45
428
  },
46
429
  engines: { node: '>=22' },
47
430
  },
@@ -55,7 +438,7 @@ export function scaffold(options: ScaffoldOptions): ReadonlyMap<string, string>
55
438
  `# Every @meith/* dependency here is an exact version, not a range — see
56
439
  # README.md, "Upgrading", for why a range breaks the build. This makes that
57
440
  # the default for any \`npm install\` run in this project from here on,
58
- # including a plugin installed by hand later, not only the three packages
441
+ # including a plugin installed by hand later, not only the four packages
59
442
  # the scaffold pinned itself.
60
443
  save-exact=true
61
444
  `,
@@ -138,67 +521,7 @@ export function installedPluginDefinitions() {
138
521
  `,
139
522
  )
140
523
 
141
- files.set(
142
- '.env.example',
143
- `# ${name} — environment.
144
- #
145
- # Copy to .env.local for development. On the server this is \`.env\` beside the
146
- # compose file; nothing here belongs in git.
147
-
148
- # ─── Required ────────────────────────────────────────────────────────────────
149
-
150
- # Your Postgres connection string.
151
- #
152
- # If it is a managed database that offers a TRANSACTION-MODE POOLER string, use
153
- # that rather than the direct one — Neon, Supabase and their kind hand out both,
154
- # and on the direct string a board works in testing and starts refusing
155
- # connections under the first real traffic, with an error that names the
156
- # database rather than the cause. Your own Postgres, with a fixed number of
157
- # processes in front of it, does not need one.
158
- DATABASE_URL=
159
-
160
- # Session and token signing. No default, deliberately: a shipped default is a
161
- # board every reader of the source can sign a session for.
162
- #
163
- # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
164
- AUTH_SECRET=
165
-
166
- # The shared secret the tick caller presents to GET /api/system/tick. Generate
167
- # it the same way. Without it the tick is unauthenticated, and the tick is how
168
- # bans expire and digests send.
169
- TICK_SECRET=
170
-
171
- # ─── Optional ────────────────────────────────────────────────────────────────
172
-
173
- # fixture = deterministic in-memory sample data, no database needed. This is
174
- # what \`npm run build\` uses, and what a checkout with no database falls back to.
175
- DATA_SOURCE=postgres
176
-
177
- # Absolute, no trailing slash. Used in mail, feeds and canonical URLs — every
178
- # place a relative URL cannot work because there is no request to be relative to.
179
- #
180
- # Optional: leave it blank and the installer asks, prefilled from the address you
181
- # load /install at, and stores the answer on the board where the settings screen
182
- # can change it without a redeploy. Set it here and it wins outright.
183
- APP_URL=
184
-
185
- # Mail. Leave these alone and the installer asks for mail on first run, storing
186
- # it on the board — a settings screen with a test button, no redeploy. Set
187
- # MAIL_DRIVER here instead and the environment wins outright, which is what you
188
- # want if the credential must not live in the database.
189
- #
190
- # The default sends NOTHING: each message goes to the server log, so password
191
- # reset fails silently until mail is configured one way or the other.
192
- # MAIL_DRIVER=smtp
193
- # MAIL_SMTP_HOST=smtp.example.com
194
- # MAIL_SMTP_PORT=465
195
- # MAIL_SMTP_SECURITY=tls # tls (465) | starttls (587) | none
196
- # MAIL_SMTP_USERNAME=
197
- # MAIL_SMTP_PASSWORD=
198
- # MAIL_FROM=noreply@yourdomain.com
199
-
200
- `,
201
- )
524
+ files.set('.env.example', envExample(name, target))
202
525
 
203
526
  files.set(
204
527
  '.gitignore',
@@ -641,11 +964,20 @@ echo "<password>" | npm run community -- user:create --username <name> --email <
641
964
 
642
965
  \`\`\`sh
643
966
  npm install --save-exact @meith/web@latest @meith/cli@latest @meith/theme-default@latest
644
- git commit -am "Upgrade @meith/web, @meith/cli and @meith/theme-default"
967
+ npm install --save-exact next@$(node -p "require('./node_modules/@meith/web/package.json').dependencies.next")
968
+ git commit -am "Upgrade Meith and the Next.js version it builds with"
645
969
  git push
646
970
  \`\`\`
647
971
 
648
- That one \`package.json\` change is the whole pin: \`Dockerfile\`'s own
972
+ The second command is not optional. This board pins \`next\` itself, and
973
+ nothing bumps it for you: upgrading only the \`@meith/*\` packages leaves the
974
+ board's own pin on the old Next while \`@meith/web\` depends on the new one,
975
+ which npm resolves by installing both — the build then runs on one version
976
+ while everything reading \`package.json\` sees the other. Reading the version
977
+ out of the freshly installed \`@meith/web\` is what keeps the two the same
978
+ without anybody having to know the number.
979
+
980
+ That \`package.json\` change is the whole pin: \`Dockerfile\`'s own
649
981
  \`FROM\` line takes the version as a build argument, and
650
982
  \`.github/workflows/build.yml\` reads it straight out of \`package.json\`'s
651
983
  own \`@meith/web\` dependency when it rebuilds — nothing in \`Dockerfile\`
@@ -667,9 +999,296 @@ otherwise would be worse than its absence.
667
999
  `,
668
1000
  )
669
1001
 
1002
+ if (target === 'vercel') {
1003
+ return vercelTree(files, {
1004
+ name,
1005
+ repositoryUrl,
1006
+ templateRepositoryUrl: options.templateRepositoryUrl ?? DEFAULT_TEMPLATE_REPOSITORY_URL,
1007
+ })
1008
+ }
1009
+
1010
+ return files
1011
+ }
1012
+
1013
+ interface VercelTreeOptions {
1014
+ readonly name: string
1015
+ readonly repositoryUrl: string
1016
+ readonly templateRepositoryUrl: string
1017
+ }
1018
+
1019
+ function vercelTree(
1020
+ base: ReadonlyMap<string, string>,
1021
+ options: VercelTreeOptions,
1022
+ ): ReadonlyMap<string, string> {
1023
+ const files = new Map(base)
1024
+
1025
+ for (const path of SELF_HOST_DEPLOY_KIT) files.delete(path)
1026
+
1027
+ files.set(
1028
+ '.gitignore',
1029
+ `node_modules
1030
+ .next
1031
+ .meith
1032
+ .vercel
1033
+ .env
1034
+ .env.local
1035
+ .env*.local
1036
+ *.log
1037
+ .DS_Store
1038
+
1039
+ ${AT_ROOT_IGNORES}
1040
+ `,
1041
+ )
1042
+
1043
+ files.set('vercel.json', vercelJson())
1044
+ files.set('README.md', vercelReadme(options))
1045
+
670
1046
  return files
671
1047
  }
672
1048
 
1049
+ function vercelReadme({ name, repositoryUrl, templateRepositoryUrl }: VercelTreeOptions): string {
1050
+ return `# ${name}
1051
+
1052
+ A forum, built on [Meith](${repositoryUrl}), running as Vercel functions.
1053
+
1054
+ [![Deploy with Vercel](https://vercel.com/button)](${deployButtonUrl(templateRepositoryUrl)})
1055
+
1056
+ ## What the button provisions
1057
+
1058
+ - **A copy of this repository** under your own GitHub account. Vercel builds
1059
+ from it, and every later push to \`main\` redeploys.
1060
+ - **A Neon Postgres database**, attached to the project. Neon publishes the
1061
+ pooled connection string as \`DATABASE_URL\` and the direct one as
1062
+ \`DATABASE_URL_UNPOOLED\`.
1063
+ - **An Upstash Redis store**, attached the same way, for the shared cache. It
1064
+ publishes \`KV_URL\`, which the board reads as \`REDIS_URL\` — \`KV_REST_API_URL\`
1065
+ beside it is an HTTPS endpoint and is not used for this.
1066
+ - **A Vercel Blob store** for uploads, which publishes \`BLOB_STORE_ID\` into the
1067
+ project by itself. That is the whole credential: the board hands the id to
1068
+ Vercel's SDK, which authenticates with the deployment's own OIDC identity, so
1069
+ there is no token to copy. This is what used to be four hand-typed \`S3_*\`
1070
+ secrets.
1071
+ - **A Vercel project** carrying \`vercel.json\` — the build command
1072
+ \`${VERCEL_BUILD_COMMAND}\`,
1073
+ which applies the schema before it builds, materializes the board's app at
1074
+ the project root so the artefact lands where Vercel reads it, and the cron
1075
+ entry that drives the tick.
1076
+
1077
+ **Mail is the one thing the button does not set up**, and it takes one click
1078
+ after the deploy — see *Mail, in one click* below. That is also where the
1079
+ address the board sends from goes: it has to be at a domain your provider has
1080
+ verified, which cannot be true of any domain before a provider exists. The
1081
+ board boots and runs without mail, and delivers nothing, silently, until it is
1082
+ done.
1083
+
1084
+ ## What to type into the deploy form
1085
+
1086
+ **Two secrets**, generated rather than chosen. Thirty-two characters is a floor
1087
+ the board enforces at boot, not a suggestion:
1088
+
1089
+ \`\`\`sh
1090
+ openssl rand -hex 32 # AUTH_SECRET
1091
+ openssl rand -hex 32 # CRON_SECRET
1092
+ \`\`\`
1093
+
1094
+ \`CRON_SECRET\` is the name Vercel Cron sends, as \`Authorization: Bearer\`, and it
1095
+ cannot be told to send another — the caller is the platform, so this one has to
1096
+ be an environment variable both ends can read, and cannot be something the
1097
+ board makes up for itself. Note that this floor is stricter than the 16
1098
+ characters Vercel's own cron documentation suggests — a value generated by
1099
+ following those instructions is refused here, and the fix is a longer secret.
1100
+
1101
+ \`AUTH_SECRET\` seals members' two-factor secrets and signs the unsubscribe links
1102
+ in outgoing mail. It stays in the environment deliberately: a copy of the
1103
+ database is then not enough to forge either.
1104
+
1105
+ **That is the whole form.** Everything else the board works out from the stores
1106
+ this button just linked to the project:
1107
+
1108
+ \`\`\`ini
1109
+ ${VERCEL_DERIVED_DRIVERS.join('\n')}
1110
+ \`\`\`
1111
+
1112
+ \`DIRECT_DATABASE_URL\` comes from Neon's own \`DATABASE_URL_UNPOOLED\`, or
1113
+ \`POSTGRES_URL_NON_POOLING\` if that one is absent — migrations and the first-run
1114
+ installer each hold a session-level advisory lock, which the pooled
1115
+ \`DATABASE_URL\` cannot hold. \`REDIS_URL\` comes from Upstash's \`KV_URL\`, the one
1116
+ variable it publishes that speaks the Redis protocol.
1117
+
1118
+ Every one of those derivations is scoped to this platform, fires only where you
1119
+ have not set the variable yourself, and **refuses to boot rather than guess**.
1120
+ If a store is missing, or publishes a name this board does not know, the deploy
1121
+ stops with a message naming every variable it looked at — it will not fall back
1122
+ to caching inside each instance, or to uploads on a disk that is discarded with
1123
+ the instance. When the name is one we do not know, set \`REDIS_URL\` or
1124
+ \`DIRECT_DATABASE_URL\` in the project's environment settings and the derivation
1125
+ stands aside.
1126
+
1127
+ If you would rather keep uploads somewhere you hold yourself — see *Leaving
1128
+ Vercel* below for why that matters — set \`FILESTORE_DRIVER=s3\` and add
1129
+ \`S3_BUCKET\`, \`S3_REGION\`, \`S3_ACCESS_KEY_ID\` and \`S3_SECRET_ACCESS_KEY\` in the
1130
+ project's environment settings, with \`S3_ENDPOINT\` for a bucket that is not AWS
1131
+ (\`S3_REGION=auto\` for R2). The same board runs either way.
1132
+
1133
+ ## Mail, in one click
1134
+
1135
+ A board that cannot send mail cannot reset a password, so do this before you
1136
+ invite anybody.
1137
+
1138
+ 1. Open your project on Vercel, go to **Storage → Marketplace** (or
1139
+ **Integrations**), and add **Resend**. It creates a Resend account linked to
1140
+ the project and connects your sending domain.
1141
+ 2. Verify that domain in the Resend dashboard if you have not already. Resend
1142
+ refuses to send from an address at a domain it has not verified.
1143
+ 3. Add \`MAIL_FROM\` to the project's environment settings — an address at that
1144
+ verified domain, and the only mail value you ever type. The deploy form does
1145
+ not ask for it, because a sender address is not something you can know
1146
+ before there is a provider to verify it.
1147
+ 4. Redeploy, or let the next push redeploy.
1148
+
1149
+ That is all. The integration publishes its key into the project as
1150
+ \`RESEND_API_KEY\`, and the board reads that name: with it set, and \`MAIL_FROM\`
1151
+ beside it, mail sends over Resend's HTTPS API with nothing further to
1152
+ configure.
1153
+
1154
+ The board is not tied to Resend. Its mail driver is a plain JSON-over-HTTPS
1155
+ sender that posts \`{from, to, subject, text, html, reply_to}\` with a bearer
1156
+ token — Resend's \`POST /emails\` happens to be exactly that shape, which is why
1157
+ it needs no adapter. Any provider with the same shape works: set
1158
+ \`MAIL_HTTP_ENDPOINT\`, \`MAIL_HTTP_TOKEN\` and \`MAIL_DRIVER=http\` in the
1159
+ project's environment settings, and set the first two **together** — either
1160
+ one on its own stands the Resend bridge down, so a key issued for Resend is
1161
+ never presented to an endpoint you chose. Only \`RESEND_API_KEY\` turns the
1162
+ driver on by itself. Delete it once you have moved off Resend.
1163
+
1164
+ Check it worked: sign in as the administrator and use the test button on
1165
+ **/admin → Settings → Mail**.
1166
+
1167
+ ## First run: \`/install\`
1168
+
1169
+ The build applies migrations, but an empty schema is not yet a board. Open
1170
+ \`https://<your-deployment>/install\` once the first deploy is green. It asks for
1171
+ the board's name and address and for the first administrator's username, email
1172
+ and password, creates the board and that account, and then **seals itself**:
1173
+ \`/install\` answers 404 from then on. Run it against the database you intend to
1174
+ keep — the screens are the ones
1175
+ [docs/quickstart.md](${repositoryUrl}/blob/main/docs/quickstart.md#4-run-the-installer)
1176
+ walks through.
1177
+
1178
+ ## The tick
1179
+
1180
+ \`vercel.json\` asks Vercel to call \`${TICK_PATH}\` on \`${TICK_SCHEDULE}\`. That
1181
+ route is how bans expire, digests send, mail leaves the outbox and the queue
1182
+ drains; nothing here runs it on its own, because there is no worker process on
1183
+ a function platform. Two things about it are worth knowing **before** you
1184
+ deploy rather than after:
1185
+
1186
+ - **A per-minute schedule needs a paid plan.** Hobby allows a couple of cron
1187
+ jobs and runs each of them roughly once a day, at an hour Vercel chooses;
1188
+ only paid plans accept an arbitrary cron expression. A board ticking daily
1189
+ still loses nothing — tasks are written so a missed run delays work rather
1190
+ than dropping it — but "as it happens" notifications become a daily digest in
1191
+ all but name. To keep a minute-by-minute tick on Hobby, drive
1192
+ \`${TICK_PATH}\` from something else that can call a URL on a schedule — a
1193
+ GitHub Actions workflow, a systemd timer, an uptime pinger — presenting
1194
+ \`TICK_SECRET\` instead.
1195
+ - **\`maxDuration = 300\` is validated when the project builds, not when the
1196
+ function runs.** A plan that does not allow 300 seconds therefore **fails the
1197
+ deployment** rather than clamping the request. With Fluid Compute — the
1198
+ default for new projects — Hobby allows 300 and this builds as written. With
1199
+ Fluid Compute switched off, Hobby caps a function at 60 seconds and the build
1200
+ fails. Turn Fluid Compute back on.
1201
+
1202
+ A tick that reaches the tasks and runs them answers \`200\` even when one of them
1203
+ threw, with \`ok: false\` and the failure named in \`ran\`. That is deliberate:
1204
+ schedulers retry non-2xx answers, and a task that fails every time would turn
1205
+ each retry into another attempt against whatever it is failing against.
1206
+
1207
+ ## Upgrading
1208
+
1209
+ \`\`\`sh
1210
+ npm install --save-exact @meith/web@latest @meith/cli@latest @meith/theme-default@latest
1211
+ npm install --save-exact next@$(node -p "require('./node_modules/@meith/web/package.json').dependencies.next")
1212
+ git commit -am "Upgrade Meith and the Next.js version it builds with"
1213
+ git push
1214
+ \`\`\`
1215
+
1216
+ Vercel rebuilds on the push, and the build command applies the new migrations
1217
+ before it builds. \`--save-exact\` matters and \`.npmrc\` already sets it for
1218
+ everything else installed here.
1219
+
1220
+ The second command is not optional. This board pins \`next\` itself — Vercel
1221
+ reads that pin to pick its Next.js builder — and nothing bumps it for you.
1222
+ Upgrading only the \`@meith/*\` packages leaves two versions of Next
1223
+ installed, the board built with one and the platform configured for the
1224
+ other. Reading the version out of the freshly installed \`@meith/web\` keeps
1225
+ them the same without anybody having to know the number.
1226
+
1227
+ Migrations are forward-only. Recovery is by restore, so take a backup first —
1228
+ there is no down migration to undo a destructive one.
1229
+
1230
+ ## Leaving Vercel
1231
+
1232
+ A board must stay movable, and the Blob store is the one part of this shape that
1233
+ is not portable: Neon and Upstash hand out ordinary Postgres and Redis strings
1234
+ that any host accepts, but a Vercel Blob store is reachable only through Vercel's
1235
+ own API and there is no bucket to sync out of it. **The uploads are the thing you
1236
+ have to carry out deliberately, and \`community backup\` is how.**
1237
+
1238
+ Under \`FILESTORE_DRIVER=blob\`, \`community backup\` includes the uploads **by
1239
+ default** — it walks the Blob store, pulls every object, and puts them in the
1240
+ bundle beside the database dump. This is the opposite of the \`s3\` default, which
1241
+ skips them, because a bucket has its own backup story you can drive yourself and
1242
+ a Blob store does not:
1243
+
1244
+ \`\`\`sh
1245
+ DATABASE_URL=… # Neon's pooled string
1246
+ DIRECT_DATABASE_URL=… # Neon's DATABASE_URL_UNPOOLED
1247
+ FILESTORE_DRIVER=blob
1248
+ BLOB_READ_WRITE_TOKEN=… # create one on the store; see below
1249
+ npm run community -- backup
1250
+ \`\`\`
1251
+
1252
+ Run that from a checkout of this repository, with those four values in the
1253
+ environment — the CLI talks to Neon and to the Blob store over the network, so
1254
+ it does not have to run on Vercel.
1255
+
1256
+ That last one is the one value this route asks you to make by hand, and only
1257
+ here. On the deployment the board reaches the store with \`BLOB_STORE_ID\` and
1258
+ the deployment's OIDC identity, which a command on your own machine does not
1259
+ have. Open the store under **Storage**, create a read-write token, and use it
1260
+ for the backup; the board itself never needs it. The bundle it writes holds the dump *and*
1261
+ every object. Check the last line it prints: if it says *no uploads*, the
1262
+ uploads are not in the bundle and restoring it gives a board whose posts have
1263
+ broken images.
1264
+
1265
+ Restoring puts them wherever the *restoring* board's \`FILESTORE_DRIVER\` points,
1266
+ so the same bundle moves the board either onward or away:
1267
+
1268
+ \`\`\`sh
1269
+ # onto a self-hosted board with a bucket
1270
+ FILESTORE_DRIVER=s3 S3_BUCKET=… RESTORE_DATABASE_URL=… npm run community -- restore bundle.tar.gz
1271
+
1272
+ # onto a board that keeps uploads on its own disk
1273
+ RESTORE_DATABASE_URL=… npm run community -- restore bundle.tar.gz --uploads-dir ./uploads
1274
+ \`\`\`
1275
+
1276
+ Take one before you need it. A Blob store deleted with the Vercel project takes
1277
+ the attachments with it, and there is no second copy anywhere unless you made
1278
+ one.
1279
+
1280
+ ## Somewhere other than Vercel
1281
+
1282
+ Everything above is one deployment shape.
1283
+ [docs/self-hosting.md](${repositoryUrl}/blob/main/docs/self-hosting.md) is the
1284
+ same board as containers you run yourself, and \`npx create-meith <name>\`
1285
+ scaffolds that shape instead — a Dockerfile, a compose file and a workflow that
1286
+ builds the image. [docs/scaling.md](${repositoryUrl}/blob/main/docs/scaling.md)
1287
+ explains why the drivers above are what they are, and why an S3-compatible
1288
+ bucket is the portable choice for uploads everywhere but here.
1289
+ `
1290
+ }
1291
+
673
1292
  export function nextSteps(name: string): readonly string[] {
674
1293
  return [`cd ${name}`, 'npm install', 'cp .env.example .env.local', 'npm run dev']
675
1294
  }