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