create-meith 0.17.0 → 0.17.1

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 ADDED
@@ -0,0 +1,731 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { execFile } from "node:child_process";
5
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { promisify } from "node:util";
8
+
9
+ // src/scaffold.ts
10
+ var DEFAULT_REPOSITORY_URL = "https://github.com/meith-dev/meith";
11
+ var NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,213}$/;
12
+ function validateName(name) {
13
+ if (name === "") return "A project name is required.";
14
+ if (name === "." || name === "..") return "That name would write outside the new directory.";
15
+ if (name.includes("/") || name.includes("\\"))
16
+ return "A project name cannot contain a path separator.";
17
+ if (name !== name.toLowerCase()) return "npm package names must be lower-case.";
18
+ if (!NAME_PATTERN.test(name)) {
19
+ return "Use lower-case letters, digits, dots, hyphens and underscores, starting with a letter or digit.";
20
+ }
21
+ return null;
22
+ }
23
+ function scaffold(options) {
24
+ const { name, version, repositoryUrl } = options;
25
+ const files = /* @__PURE__ */ new Map();
26
+ files.set(
27
+ "package.json",
28
+ `${JSON.stringify(
29
+ {
30
+ name,
31
+ version: "0.1.0",
32
+ private: true,
33
+ type: "module",
34
+ scripts: {
35
+ dev: "forum-web dev",
36
+ build: "forum-web build",
37
+ start: "forum-web start",
38
+ community: "community"
39
+ },
40
+ dependencies: {
41
+ "@meith/web": version,
42
+ "@meith/cli": version,
43
+ "@meith/theme-default": version
44
+ },
45
+ engines: { node: ">=22" }
46
+ },
47
+ null,
48
+ 2
49
+ )}
50
+ `
51
+ );
52
+ files.set(
53
+ "community.config.ts",
54
+ `/**
55
+ * The board's build-time registry.
56
+ *
57
+ * Everything installable is named here, statically, so the bundler can see it
58
+ * and the compiler can check it. Nothing is discovered by scanning a directory
59
+ * at runtime \u2014 a production build contains only what the bundler could see, so a
60
+ * directory walked at request time is empty and a plugin "installed" that way is
61
+ * not there at all.
62
+ *
63
+ * Adding a theme is: \`npm install\` it, add a line here, redeploy. Adding a
64
+ * plugin is the same, through board.plugins.json and community.plugins.ts \u2014
65
+ * see docs/plugin-api.md.
66
+ */
67
+ import { defineForumConfig } from '@meith/web/config'
68
+ import {
69
+ BROWSER_THEME_COLOR,
70
+ DARK_TOKENS,
71
+ defaultTheme,
72
+ LIGHT_TOKENS,
73
+ } from '@meith/theme-default'
74
+
75
+ import { INSTALLED_PLUGINS } from './community.plugins'
76
+
77
+ export default defineForumConfig({
78
+ themes: {
79
+ default: {
80
+ key: 'default',
81
+ title: 'Default',
82
+ tokens: { light: LIGHT_TOKENS, dark: DARK_TOKENS },
83
+ browserThemeColor: BROWSER_THEME_COLOR,
84
+ theme: defaultTheme,
85
+ },
86
+ },
87
+ defaultTheme: 'default',
88
+
89
+ plugins: INSTALLED_PLUGINS,
90
+ })
91
+ `
92
+ );
93
+ files.set("board.plugins.json", `${JSON.stringify({ plugins: [] }, null, 2)}
94
+ `);
95
+ files.set(
96
+ "community.plugins.ts",
97
+ `/**
98
+ * The board's installed-plugin list.
99
+ *
100
+ * Inside the Meith monorepo this file is generated from board.plugins.json
101
+ * by \`pnpm board:gen\` (see docs/plugin-api.md) \u2014 that generator is
102
+ * repository tooling, not something this workspace carries, so this file
103
+ * starts as a plain, valid file with the same shape instead. Add a plugin by
104
+ * importing its \`plugin\`/\`messages\` exports and adding an entry:
105
+ *
106
+ * import { messages as greeterMessages, plugin as greeterPlugin } from '@meith/plugin-greeter'
107
+ *
108
+ * export const INSTALLED_PLUGINS: readonly InstalledPlugin<PluginDefinition>[] = [
109
+ * { key: 'greeter', enabled: true, plugin: greeterPlugin, messages: greeterMessages },
110
+ * ]
111
+ *
112
+ * and the matching entry in board.plugins.json, which is what
113
+ * \`community plugin:add\`/\`plugin:remove\` read inside the monorepo \u2014 kept
114
+ * here too so the two files agree about what is installed.
115
+ */
116
+ import type { InstalledPlugin } from '@meith/web/config'
117
+
118
+ export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = []
119
+
120
+ export function installedPluginDefinitions() {
121
+ return INSTALLED_PLUGINS.filter(
122
+ (entry) => entry.enabled !== false && entry.plugin !== undefined,
123
+ ).map((entry) => entry.plugin)
124
+ }
125
+ `
126
+ );
127
+ files.set(
128
+ ".env.example",
129
+ `# ${name} \u2014 environment.
130
+ #
131
+ # Copy to .env.local for development. On the server this is \`.env\` beside the
132
+ # compose file; nothing here belongs in git.
133
+
134
+ # \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
135
+
136
+ # Your Postgres connection string.
137
+ #
138
+ # If it is a managed database that offers a TRANSACTION-MODE POOLER string, use
139
+ # that rather than the direct one \u2014 Neon, Supabase and their kind hand out both,
140
+ # and on the direct string a board works in testing and starts refusing
141
+ # connections under the first real traffic, with an error that names the
142
+ # database rather than the cause. Your own Postgres, with a fixed number of
143
+ # processes in front of it, does not need one.
144
+ DATABASE_URL=
145
+
146
+ # Session and token signing. No default, deliberately: a shipped default is a
147
+ # board every reader of the source can sign a session for.
148
+ #
149
+ # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
150
+ AUTH_SECRET=
151
+
152
+ # The shared secret the tick caller presents to GET /api/system/tick. Generate
153
+ # it the same way. Without it the tick is unauthenticated, and the tick is how
154
+ # bans expire and digests send.
155
+ TICK_SECRET=
156
+
157
+ # \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
158
+
159
+ # fixture = deterministic in-memory sample data, no database needed. This is
160
+ # what \`npm run build\` uses, and what a checkout with no database falls back to.
161
+ DATA_SOURCE=postgres
162
+
163
+ # Absolute, no trailing slash. Used in mail, feeds and canonical URLs \u2014 every
164
+ # place a relative URL cannot work because there is no request to be relative to.
165
+ #
166
+ # Optional: leave it blank and the installer asks, prefilled from the address you
167
+ # load /install at, and stores the answer on the board where the settings screen
168
+ # can change it without a redeploy. Set it here and it wins outright.
169
+ APP_URL=
170
+
171
+ # Mail. Leave these alone and the installer asks for mail on first run, storing
172
+ # it on the board \u2014 a settings screen with a test button, no redeploy. Set
173
+ # MAIL_DRIVER here instead and the environment wins outright, which is what you
174
+ # want if the credential must not live in the database.
175
+ #
176
+ # The default sends NOTHING: each message goes to the server log, so password
177
+ # reset fails silently until mail is configured one way or the other.
178
+ # MAIL_DRIVER=smtp
179
+ # MAIL_SMTP_HOST=smtp.example.com
180
+ # MAIL_SMTP_PORT=465
181
+ # MAIL_SMTP_SECURITY=tls # tls (465) | starttls (587) | none
182
+ # MAIL_SMTP_USERNAME=
183
+ # MAIL_SMTP_PASSWORD=
184
+ # MAIL_FROM=noreply@yourdomain.com
185
+
186
+ `
187
+ );
188
+ files.set(
189
+ ".gitignore",
190
+ `node_modules
191
+ .next
192
+ .meith
193
+ .env
194
+ .env.local
195
+ .env*.local
196
+ *.log
197
+ .DS_Store
198
+ `
199
+ );
200
+ files.set(
201
+ "Dockerfile",
202
+ `# syntax=docker/dockerfile:1.7-labs
203
+ # check=skip=InvalidDefaultArgInFrom
204
+ # ${name}'s deploy image.
205
+ #
206
+ # FROM the published framework base image \u2014 deps + framework layers only,
207
+ # locked to this exact release (see the meith repository's
208
+ # docs/self-hosting.md, "Custom boards", and docker/Dockerfile.base for what
209
+ # it is and is not). This board's own Dockerfile only ever installs its own
210
+ # delta on top of it \u2014 a new plugin's own dependency, typically nothing more
211
+ # \u2014 which is what keeps a rebuild after \`npm install some-plugin\` a matter
212
+ # of minutes rather than a cold toolchain build.
213
+ #
214
+ # Two stages, not three: unlike the official image, this does not prune down
215
+ # to Next's own standalone output. The migrate role below runs \`community
216
+ # migrate\`, and \`community\` materializes @meith/cli's sources and runs them
217
+ # with tsx at the moment it runs (see the meith repository's
218
+ # docs/development.md, "Consuming the board from a workspace") \u2014 it needs
219
+ # the full, un-pruned node_modules tree this board installed, not what Next
220
+ # traced as reachable from the web server alone. The tick itself is driven
221
+ # by docker-compose.yml's own \`worker\` service \u2014 a lightweight loop against
222
+ # /api/system/tick, not a compiled worker process, because @meith/worker is
223
+ # not published (see the meith repository's docs/release.md).
224
+ ARG MEITH_VERSION
225
+ FROM ghcr.io/meith-dev/meith-base:\${MEITH_VERSION} AS deps
226
+ WORKDIR /board
227
+
228
+ # This board's own manifest, cached independently of its source \u2014 editing
229
+ # community.config.ts should not re-run npm install. The base image above
230
+ # already carries node_modules for @meith/web, @meith/cli and
231
+ # @meith/theme-default at this exact version, so installing this file on top
232
+ # of it only fetches what changed: a plugin newly added to \`dependencies\`,
233
+ # typically nothing at all.
234
+ COPY package.json ./
235
+ RUN npm install
236
+
237
+ FROM deps AS runtime
238
+ WORKDIR /board
239
+ COPY . .
240
+
241
+ ENV NEXT_TELEMETRY_DISABLED=1
242
+ ENV NODE_ENV=production
243
+
244
+ # DATA_SOURCE is scoped to this one RUN, not declared with ENV \u2014 an ENV
245
+ # persists into every container started from this image afterward, and this
246
+ # Dockerfile has no later stage to reset it in (see "Two stages, not three"
247
+ # above). The build needs neither a database nor a production secret (see
248
+ # the meith repository's docs/development.md, "Fixture mode"), but baking
249
+ # DATA_SOURCE=fixture into the image itself would silently force fixture
250
+ # mode \u2014 and with it the in-memory queue driver \u2014 at runtime too, no matter
251
+ # what DATABASE_URL an operator supplies to \`docker run\`.
252
+ RUN DATA_SOURCE=fixture npx forum-web build
253
+
254
+ ENV PORT=3000
255
+ ENV HOSTNAME=0.0.0.0
256
+ EXPOSE 3000
257
+
258
+ # node:alpine already carries a non-root "node" user; the board's own files
259
+ # are copied in as root above, so they need handing over before this drops
260
+ # privilege.
261
+ RUN chown -R node:node /board
262
+ USER node
263
+
264
+ COPY --chown=node:node docker-entrypoint.sh docker-healthcheck.sh ./
265
+ RUN chmod +x docker-entrypoint.sh docker-healthcheck.sh
266
+
267
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \\
268
+ CMD ["./docker-healthcheck.sh"]
269
+
270
+ ENTRYPOINT ["./docker-entrypoint.sh"]
271
+ `
272
+ );
273
+ files.set(
274
+ "docker-entrypoint.sh",
275
+ `#!/bin/sh
276
+ # One image, two roles \u2014 see Dockerfile and README.md.
277
+ #
278
+ # "web" (the default) runs the board; "migrate" applies the schema and
279
+ # exits. There is no "worker" role in this image: @meith/worker is not
280
+ # published, so nothing here can run it \u2014 docker-compose.yml's own \`worker\`
281
+ # service drives the tick a different way, calling this image's web role
282
+ # over HTTP instead of running as a role of this image.
283
+ set -e
284
+
285
+ # An explicit command wins over the role, the same as the official image \u2014
286
+ # \`docker run <image> node_modules/.bin/community --help\` should still run
287
+ # the CLI rather than silently starting the web server.
288
+ if [ "$#" -gt 0 ]; then
289
+ exec "$@"
290
+ fi
291
+
292
+ case "\${COMMUNITY_ROLE:-web}" in
293
+ migrate)
294
+ # Runs to completion and exits; compose's one-shot service waits on it.
295
+ exec node_modules/.bin/community migrate
296
+ ;;
297
+ web)
298
+ exec node_modules/.bin/forum-web start
299
+ ;;
300
+ *)
301
+ echo "Unknown COMMUNITY_ROLE: \${COMMUNITY_ROLE}. Expected 'web' or 'migrate'." >&2
302
+ exit 1
303
+ ;;
304
+ esac
305
+ `
306
+ );
307
+ files.set(
308
+ "docker-healthcheck.sh",
309
+ `#!/bin/sh
310
+ # What "healthy" means depends on the role \u2014 see docker-entrypoint.sh.
311
+ # "migrate" runs to completion and exits; its exit code is the verdict, and
312
+ # a health probe taken while it runs has no opinion.
313
+ set -e
314
+
315
+ if [ "\${COMMUNITY_ROLE:-web}" = "migrate" ]; then
316
+ exit 0
317
+ fi
318
+
319
+ node -e "fetch('http://127.0.0.1:3000/api/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
320
+ `
321
+ );
322
+ files.set(
323
+ ".dockerignore",
324
+ `node_modules
325
+ .next
326
+ .meith
327
+ .git
328
+ .env
329
+ .env.local
330
+ *.log
331
+ `
332
+ );
333
+ files.set(
334
+ ".github/workflows/build.yml",
335
+ `# Builds this board's image and pushes it to your own GHCR, on every push to
336
+ # main. No secret to configure: GITHUB_TOKEN is provided automatically by
337
+ # GitHub Actions and is enough to push to ghcr.io/<this repository>. See
338
+ # README.md for the rest of the three-step deploy story.
339
+ name: Build and push
340
+
341
+ on:
342
+ push:
343
+ branches: [main]
344
+ workflow_dispatch:
345
+
346
+ permissions:
347
+ contents: read
348
+ packages: write
349
+
350
+ jobs:
351
+ image:
352
+ name: Build and push the board image
353
+ runs-on: ubuntu-latest
354
+ steps:
355
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
356
+
357
+ - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
358
+ with:
359
+ registry: ghcr.io
360
+ username: \${{ github.actor }}
361
+ password: \${{ secrets.GITHUB_TOKEN }}
362
+
363
+ # GHCR requires a lower-case image name, and neither your GitHub
364
+ # username nor this repository's name is guaranteed to be.
365
+ - name: Build and push
366
+ run: |
367
+ IMAGE=$(echo "ghcr.io/\${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
368
+ MEITH_VERSION=$(node -p "require('./package.json').dependencies['@meith/web']")
369
+ docker build --build-arg MEITH_VERSION="$MEITH_VERSION" -t "$IMAGE:\${{ github.sha }}" -t "$IMAGE:latest" .
370
+ docker push "$IMAGE:\${{ github.sha }}"
371
+ docker push "$IMAGE:latest"
372
+
373
+ - name: Summary
374
+ run: |
375
+ IMAGE=$(echo "ghcr.io/\${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
376
+ REPO_LOWER=$(echo "\${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
377
+ PKG_NAME=$(echo "$REPO_LOWER" | cut -d/ -f2)
378
+ PKG_URL="https://github.com/$REPO_LOWER/pkgs/container/$PKG_NAME"
379
+ {
380
+ echo "## Deploy this image"
381
+ echo
382
+ echo "Paste this into the MEITH_IMAGE variable on the Coolify resource:"
383
+ echo
384
+ echo " $IMAGE:latest"
385
+ echo
386
+ echo "Once you want a pin that only moves when you say so:"
387
+ echo
388
+ echo " $IMAGE:\${{ github.sha }}"
389
+ echo
390
+ echo "## One-time: make the package public"
391
+ echo
392
+ echo "This package starts private. Coolify's pull fails until you visit"
393
+ echo "$PKG_URL and change its visibility \u2014 **Package settings** \u2192"
394
+ echo "**Change visibility** \u2192 **Public**."
395
+ } >> "$GITHUB_STEP_SUMMARY"
396
+ `
397
+ );
398
+ files.set(
399
+ "docker-compose.yml",
400
+ `# ${name}, deployed by Coolify \u2014 the same shape as the meith repository's own
401
+ # docker/compose.coolify.yml: db, migrate, web, worker. See README.md for
402
+ # the three-step deploy story this file is the last step of.
403
+ #
404
+ # No published ports \u2014 Coolify's proxy routes to the container and issues
405
+ # the certificate. The two secrets and the database password are Coolify's
406
+ # own "magic variables": it fills them in on the first deploy and shows them
407
+ # in the panel, so nothing here needs a value typed into it except
408
+ # MEITH_IMAGE, which only you can know \u2014 the build workflow's own Summary
409
+ # tab prints it, ready to paste, the moment it finishes. Requires Coolify
410
+ # v4.0.0-beta.411 or newer, which is when magic variables in a compose file
411
+ # from a Git source arrived.
412
+ services:
413
+ postgres:
414
+ image: postgres:18-alpine@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2
415
+ restart: unless-stopped
416
+ mem_limit: \${POSTGRES_MEM_LIMIT:-1g}
417
+ cpus: \${POSTGRES_CPUS:-1}
418
+ environment:
419
+ POSTGRES_USER: community
420
+ POSTGRES_PASSWORD: $SERVICE_PASSWORD_POSTGRES
421
+ POSTGRES_DB: community
422
+ volumes:
423
+ - pgdata:/var/lib/postgresql
424
+ healthcheck:
425
+ test: ['CMD-SHELL', 'pg_isready -U community -d community']
426
+ interval: 10s
427
+ timeout: 5s
428
+ retries: 5
429
+
430
+ # Runs to completion, then exits. web waits for it, so the schema is
431
+ # always applied before the first request rather than racing it.
432
+ migrate:
433
+ image: \${MEITH_IMAGE:?set this to the image the build workflow's Summary just printed, e.g. ghcr.io/<you>/${name}:latest}
434
+ environment:
435
+ COMMUNITY_ROLE: migrate
436
+ DATABASE_URL: postgres://community:$SERVICE_PASSWORD_POSTGRES@postgres:5432/community
437
+ AUTH_SECRET: $SERVICE_BASE64_64_AUTH
438
+ TICK_SECRET: $SERVICE_BASE64_64_TICK
439
+ depends_on:
440
+ postgres:
441
+ condition: service_healthy
442
+ restart: 'no'
443
+
444
+ web:
445
+ image: \${MEITH_IMAGE:?set this to the image the build workflow's Summary just printed, e.g. ghcr.io/<you>/${name}:latest}
446
+ restart: unless-stopped
447
+ mem_limit: \${WEB_MEM_LIMIT:-1g}
448
+ cpus: \${WEB_CPUS:-2}
449
+ environment:
450
+ # Ask Coolify for a domain on port 3000, then hand the board the same
451
+ # thing with a scheme in front.
452
+ - SERVICE_FQDN_WEB_3000
453
+ - APP_URL=$SERVICE_URL_WEB
454
+ - DATABASE_URL=postgres://community:$SERVICE_PASSWORD_POSTGRES@postgres:5432/community
455
+ - AUTH_SECRET=$SERVICE_BASE64_64_AUTH
456
+ - TICK_SECRET=$SERVICE_BASE64_64_TICK
457
+ - QUEUE_DRIVER=postgres
458
+ - CACHE_DRIVER=next
459
+ - FILESTORE_DRIVER=local
460
+ # Left unset, mail is configured on the board itself \u2014 the installer
461
+ # asks on first run. Set MAIL_DRIVER here and this file wins instead.
462
+ - MAIL_DRIVER=\${MAIL_DRIVER:-log}
463
+ - MAIL_SMTP_HOST=\${MAIL_SMTP_HOST:-}
464
+ - MAIL_SMTP_PORT=\${MAIL_SMTP_PORT:-}
465
+ - MAIL_SMTP_SECURITY=\${MAIL_SMTP_SECURITY:-}
466
+ - MAIL_SMTP_USERNAME=\${MAIL_SMTP_USERNAME:-}
467
+ - MAIL_SMTP_PASSWORD=\${MAIL_SMTP_PASSWORD:-}
468
+ - MAIL_FROM=\${MAIL_FROM:-}
469
+ volumes:
470
+ - uploads:/app/.uploads
471
+ depends_on:
472
+ postgres:
473
+ condition: service_healthy
474
+ migrate:
475
+ condition: service_completed_successfully
476
+
477
+ # @meith/worker is not published (see the meith repository's
478
+ # docs/release.md), so there is no compiled worker binary a scaffolded
479
+ # board can run \u2014 this drives the tick the alternative way the meith
480
+ # repository documents in docs/self-hosting.md, "Running the tick without
481
+ # a second set of credentials": a small loop calling /api/system/tick.
482
+ worker:
483
+ image: alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
484
+ restart: unless-stopped
485
+ mem_limit: \${WORKER_MEM_LIMIT:-64m}
486
+ cpus: \${WORKER_CPUS:-0.25}
487
+ environment:
488
+ TICK_SECRET: $SERVICE_BASE64_64_TICK
489
+ command:
490
+ - sh
491
+ - -c
492
+ - |
493
+ apk add --no-cache curl >/dev/null
494
+ while true; do
495
+ curl -fsS -m 55 -H "Authorization: Bearer $$TICK_SECRET" \\
496
+ http://web:3000/api/system/tick >/dev/null 2>&1 \\
497
+ || echo "tick failed at $$(date -Is)"
498
+ sleep 60
499
+ done
500
+ depends_on:
501
+ - web
502
+
503
+ volumes:
504
+ pgdata:
505
+ uploads:
506
+ `
507
+ );
508
+ files.set(
509
+ "README.md",
510
+ `# ${name}
511
+
512
+ A forum, built on [Meith](${repositoryUrl}).
513
+
514
+ ## Deploy
515
+
516
+ Nothing here builds on your own server \u2014 a 2 GB VPS OOMs on a Next.js build,
517
+ which is the whole reason \`Dockerfile\`, \`docker-compose.yml\` and
518
+ \`.github/workflows/build.yml\` exist: something else builds the image, the
519
+ server only ever pulls one. Three steps, nothing to configure by hand beyond
520
+ one value only you know:
521
+
522
+ 1. **Push this repository to GitHub.** \`.github/workflows/build.yml\` builds
523
+ \`Dockerfile\` on every push to \`main\` and pushes the result to your own
524
+ GitHub Container Registry, \`ghcr.io/<you>/${name}\` \u2014 using only the
525
+ \`GITHUB_TOKEN\` every GitHub Actions run already carries. No secret to
526
+ add, no registry account beyond the GitHub account you already have.
527
+
528
+ Open the run under the repository's **Actions** tab once it finishes \u2014
529
+ its **Summary** prints the two things left: the exact image to paste
530
+ into step 2 below, and a direct link to the one-time step of making the
531
+ package public. It starts **private**, and Coolify's pull fails with an
532
+ authentication error no operator can act on until that is done.
533
+
534
+ 2. **Point [Coolify](https://coolify.io) at \`docker-compose.yml\`** \u2014 a Docker
535
+ Compose resource, this repository as its source. \`docker-compose.yml\` already
536
+ carries Coolify's own "magic variables" for \`AUTH_SECRET\`,
537
+ \`TICK_SECRET\` and the database password, generated on the first deploy
538
+ and never typed in. The one thing Coolify cannot generate is the image
539
+ step 1 just pushed: set \`MEITH_IMAGE\` in the resource's own environment
540
+ to the value that run's Summary printed \u2014 \`ghcr.io/<you>/${name}:latest\`
541
+ (or a commit sha, once you want a pin that only moves when you say so \u2014
542
+ \`docker-compose.yml\` refuses to start without this set, with a message saying
543
+ why).
544
+
545
+ 3. **Deploy, then \`/install\` on your own domain.** Coolify issues the
546
+ certificate; the installer from there is the one
547
+ [docs/quickstart.md](${repositoryUrl}/blob/main/docs/quickstart.md#4-run-the-installer)
548
+ walks through, screen for screen. It seals itself when it finishes, and
549
+ \`/install\` answers 404 from then on \u2014 run it **against the database you
550
+ are going to keep**. Every push to \`main\` after this rebuilds the
551
+ image; Coolify's own **Redeploy** button is what actually pulls it \u2014
552
+ pushing alone does not.
553
+
554
+ No Docker Hub, no paid CI: GitHub Actions' free tier and GHCR are the whole
555
+ build side of this, for a board of any size.
556
+
557
+ **Building it yourself**: works on any machine with Docker, if you would
558
+ rather not use GitHub Actions for the build \u2014 push the result wherever
559
+ \`docker-compose.yml\`'s \`MEITH_IMAGE\` can reach.
560
+
561
+ \`\`\`sh
562
+ docker build --build-arg MEITH_VERSION=$(node -p "require('./package.json').dependencies['@meith/web']") -t ${name} .
563
+ \`\`\`
564
+
565
+ **Without a panel**: [docs/self-hosting.md](${repositoryUrl}/blob/main/docs/self-hosting.md)
566
+ is the same four containers by hand \u2014 your own \`.env\`, a reverse proxy you
567
+ already run, no Coolify. \`Dockerfile\` and \`docker-compose.yml\` here are this
568
+ board's own version of exactly that shape.
569
+
570
+ Two things nothing configures for you:
571
+
572
+ - **Mail.** Until \`MAIL_DRIVER\` and its three settings exist, every message is
573
+ written to the log and delivered to nobody, so password reset fails silently.
574
+ - **The tick.** \`docker-compose.yml\`'s \`worker\` service drives it here \u2014 a small
575
+ loop calling \`/api/system/tick\` once a minute, since \`@meith/web\`'s own
576
+ worker package is not something a board outside the meith monorepo can
577
+ depend on yet. Deploy some other way and something still has to call that
578
+ route (or run \`community task:run\`) every minute, or nothing catches up
579
+ and nothing errors.
580
+
581
+ ## Local
582
+
583
+ \`\`\`sh
584
+ npm install
585
+ cp .env.example .env.local
586
+ npm run dev
587
+ \`\`\`
588
+
589
+ With no \`DATABASE_URL\`, the board runs on deterministic in-memory sample data \u2014
590
+ enough to click through every reading surface. Posting needs a database:
591
+
592
+ \`\`\`sh
593
+ npm run forum -- migrate
594
+ npm run forum -- user:create --admin
595
+ \`\`\`
596
+
597
+ ## Configuring
598
+
599
+ - **\`community.config.ts\`** \u2014 installed themes and plugins. Everything installable
600
+ is named here so the bundler can see it; nothing is found by scanning a
601
+ directory at runtime.
602
+ - **\`/admin\`** \u2014 settings, forums, groups, members, themes, maintenance. An
603
+ administrator re-enters their password to get in, and again for anything
604
+ destructive.
605
+ - **\`npm run forum -- --help\`** \u2014 the operator CLI. Everything the panel does
606
+ and a few things it cannot, without a browser.
607
+
608
+ ## Upgrading
609
+
610
+ \`\`\`sh
611
+ npm install @meith/web@latest @meith/cli@latest
612
+ git commit -am "Upgrade @meith/web and @meith/cli"
613
+ git push
614
+ \`\`\`
615
+
616
+ That one \`package.json\` change is the whole pin: \`Dockerfile\`'s own
617
+ \`FROM\` line takes the version as a build argument, and
618
+ \`.github/workflows/build.yml\` reads it straight out of \`package.json\`'s
619
+ own \`@meith/web\` dependency when it rebuilds \u2014 nothing in \`Dockerfile\`
620
+ itself to keep in sync by hand. Once the rebuilt image is deployed, run
621
+ \`npm run forum -- upgrade\` against it for the plugin migrations \u2014 see
622
+ [the operator CLI](${repositoryUrl}/blob/main/docs/operating.md#the-operator-cli)
623
+ for running it against this deployment.
624
+
625
+ Migrations are forward-only. Recovery is by restore, so take a backup first \u2014
626
+ there is no down migration to undo a destructive one, and a button that pretended
627
+ otherwise would be worse than its absence.
628
+ `
629
+ );
630
+ return files;
631
+ }
632
+ function nextSteps(name) {
633
+ return [`cd ${name}`, "npm install", "cp .env.example .env.local", "npm run dev"];
634
+ }
635
+
636
+ // src/cli.ts
637
+ var execFileAsync = promisify(execFile);
638
+ async function isSafeTarget(target) {
639
+ try {
640
+ return (await readdir(target)).length === 0;
641
+ } catch {
642
+ return true;
643
+ }
644
+ }
645
+ async function initGit(target) {
646
+ try {
647
+ await execFileAsync("git", ["init", "-q", "-b", "main"], { cwd: target });
648
+ await execFileAsync("git", ["add", "-A"], { cwd: target });
649
+ return true;
650
+ } catch {
651
+ return false;
652
+ }
653
+ }
654
+ async function run(argv, version) {
655
+ const positional = argv.filter((arg) => !arg.startsWith("-"));
656
+ const name = positional[0] ?? "";
657
+ if (argv.includes("--help") || argv.includes("-h")) {
658
+ return {
659
+ code: 0,
660
+ lines: [
661
+ "create-meith \u2014 scaffold a forum project.",
662
+ "",
663
+ " npx create-meith <name> [--repo <url>] [--no-git]",
664
+ "",
665
+ "Writes package.json, community.config.ts, .env.example, .gitignore and",
666
+ "README.md into ./<name>, then tells you what to run.",
667
+ "",
668
+ "--no-git skips initializing a git repository in the new directory."
669
+ ]
670
+ };
671
+ }
672
+ const invalid = validateName(name);
673
+ if (invalid !== null) {
674
+ return { code: 1, lines: [`create-meith: ${invalid}`, "", "Usage: npx create-meith <name>"] };
675
+ }
676
+ const repoIndex = argv.indexOf("--repo");
677
+ const repositoryUrl = repoIndex === -1 ? DEFAULT_REPOSITORY_URL : argv[repoIndex + 1] ?? DEFAULT_REPOSITORY_URL;
678
+ const target = resolve(process.cwd(), name);
679
+ if (!await isSafeTarget(target)) {
680
+ return {
681
+ code: 1,
682
+ lines: [
683
+ `create-meith: ${name} already exists and is not empty.`,
684
+ "Refusing to write into it \u2014 pick another name, or empty it first."
685
+ ]
686
+ };
687
+ }
688
+ const files = scaffold({ name, version, repositoryUrl });
689
+ for (const [relative, contents] of files) {
690
+ const path = join(target, relative);
691
+ await mkdir(dirname(path), { recursive: true });
692
+ await writeFile(path, contents, "utf8");
693
+ }
694
+ const gitReady = argv.includes("--no-git") ? false : await initGit(target);
695
+ return {
696
+ code: 0,
697
+ lines: [
698
+ `Created ${name} \u2014 ${files.size} files.`,
699
+ "",
700
+ ...nextSteps(name).map((step) => ` ${step}`),
701
+ "",
702
+ ...gitReady ? [
703
+ "Initialized a git repository here and staged every file. Commit it,",
704
+ "add a GitHub remote and push:",
705
+ "",
706
+ ` git commit -m "Scaffold ${name}"`,
707
+ ` git remote add origin https://github.com/<you>/${name}.git`,
708
+ " git push -u origin main"
709
+ ] : [
710
+ "Push it to a new, empty repository on GitHub:",
711
+ "",
712
+ ` cd ${name}`,
713
+ ` git init && git add -A && git commit -m "Scaffold ${name}"`,
714
+ ` git remote add origin https://github.com/<you>/${name}.git`,
715
+ " git push -u origin main"
716
+ ],
717
+ "",
718
+ "Then set DATABASE_URL, AUTH_SECRET and TICK_SECRET and deploy.",
719
+ "Something must run the tick every minute \u2014 the worker process, or",
720
+ "`community task:run`. Without it nothing catches up, and nothing errors."
721
+ ]
722
+ };
723
+ }
724
+
725
+ // src/bin.ts
726
+ var result = await run(process.argv.slice(2), "0.17.1");
727
+ for (const line of result.lines) {
728
+ if (result.code === 0) console.log(line);
729
+ else console.error(line);
730
+ }
731
+ process.exit(result.code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-meith",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Scaffold a Meith board — npx create-meith <name> writes a workspace that depends on @meith/web and @meith/cli.",
5
5
  "license": "LGPL-3.0-or-later",
6
6
  "repository": {
@@ -12,13 +12,20 @@
12
12
  "main": "./src/index.ts",
13
13
  "types": "./src/index.ts",
14
14
  "bin": {
15
- "create-meith": "./src/bin.ts"
15
+ "create-meith": "./dist/bin.mjs"
16
16
  },
17
17
  "files": [
18
+ "dist",
18
19
  "src",
19
20
  "!src/**/*.test.*"
20
21
  ],
22
+ "devDependencies": {
23
+ "esbuild": "^0.28.2"
24
+ },
21
25
  "publishConfig": {
22
26
  "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "esbuild --bundle --platform=node --format=esm --target=node22 src/bin.ts --outfile=dist/bin.mjs"
23
30
  }
24
31
  }
package/src/bin.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from './cli'
3
3
 
4
- const result = await run(process.argv.slice(2), '0.17.0')
4
+ const result = await run(process.argv.slice(2), '0.17.1')
5
5
  for (const line of result.lines) {
6
6
  if (result.code === 0) console.log(line)
7
7
  else console.error(line)
package/src/cli.ts CHANGED
@@ -1,8 +1,12 @@
1
+ import { execFile } from 'node:child_process'
1
2
  import { mkdir, readdir, writeFile } from 'node:fs/promises'
2
3
  import { dirname, join, resolve } from 'node:path'
4
+ import { promisify } from 'node:util'
3
5
 
4
6
  import { DEFAULT_REPOSITORY_URL, nextSteps, scaffold, validateName } from './scaffold'
5
7
 
8
+ const execFileAsync = promisify(execFile)
9
+
6
10
  export interface CliResult {
7
11
  readonly code: number
8
12
  readonly lines: readonly string[]
@@ -16,6 +20,16 @@ async function isSafeTarget(target: string): Promise<boolean> {
16
20
  }
17
21
  }
18
22
 
23
+ async function initGit(target: string): Promise<boolean> {
24
+ try {
25
+ await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: target })
26
+ await execFileAsync('git', ['add', '-A'], { cwd: target })
27
+ return true
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
19
33
  export async function run(argv: readonly string[], version: string): Promise<CliResult> {
20
34
  const positional = argv.filter((arg) => !arg.startsWith('-'))
21
35
  const name = positional[0] ?? ''
@@ -26,10 +40,12 @@ export async function run(argv: readonly string[], version: string): Promise<Cli
26
40
  lines: [
27
41
  'create-meith — scaffold a forum project.',
28
42
  '',
29
- ' npx create-meith <name> [--repo <url>]',
43
+ ' npx create-meith <name> [--repo <url>] [--no-git]',
30
44
  '',
31
45
  'Writes package.json, community.config.ts, .env.example, .gitignore and',
32
46
  'README.md into ./<name>, then tells you what to run.',
47
+ '',
48
+ '--no-git skips initializing a git repository in the new directory.',
33
49
  ],
34
50
  }
35
51
  }
@@ -61,6 +77,8 @@ export async function run(argv: readonly string[], version: string): Promise<Cli
61
77
  await writeFile(path, contents, 'utf8')
62
78
  }
63
79
 
80
+ const gitReady = argv.includes('--no-git') ? false : await initGit(target)
81
+
64
82
  return {
65
83
  code: 0,
66
84
  lines: [
@@ -68,6 +86,24 @@ export async function run(argv: readonly string[], version: string): Promise<Cli
68
86
  '',
69
87
  ...nextSteps(name).map((step) => ` ${step}`),
70
88
  '',
89
+ ...(gitReady
90
+ ? [
91
+ 'Initialized a git repository here and staged every file. Commit it,',
92
+ 'add a GitHub remote and push:',
93
+ '',
94
+ ` git commit -m "Scaffold ${name}"`,
95
+ ` git remote add origin https://github.com/<you>/${name}.git`,
96
+ ' git push -u origin main',
97
+ ]
98
+ : [
99
+ 'Push it to a new, empty repository on GitHub:',
100
+ '',
101
+ ` cd ${name}`,
102
+ ` git init && git add -A && git commit -m "Scaffold ${name}"`,
103
+ ` git remote add origin https://github.com/<you>/${name}.git`,
104
+ ' git push -u origin main',
105
+ ]),
106
+ '',
71
107
  'Then set DATABASE_URL, AUTH_SECRET and TICK_SECRET and deploy.',
72
108
  'Something must run the tick every minute — the worker process, or',
73
109
  '`community task:run`. Without it nothing catches up, and nothing errors.',
package/src/scaffold.ts CHANGED
@@ -205,6 +205,7 @@ APP_URL=
205
205
  files.set(
206
206
  'Dockerfile',
207
207
  `# syntax=docker/dockerfile:1.7-labs
208
+ # check=skip=InvalidDefaultArgInFrom
208
209
  # ${name}'s deploy image.
209
210
  #
210
211
  # FROM the published framework base image — deps + framework layers only,
@@ -222,10 +223,11 @@ APP_URL=
222
223
  # docs/development.md, "Consuming the board from a workspace") — it needs
223
224
  # the full, un-pruned node_modules tree this board installed, not what Next
224
225
  # traced as reachable from the web server alone. The tick itself is driven
225
- # by compose.yml's own \`worker\` service — a lightweight loop against
226
+ # by docker-compose.yml's own \`worker\` service — a lightweight loop against
226
227
  # /api/system/tick, not a compiled worker process, because @meith/worker is
227
228
  # not published (see the meith repository's docs/release.md).
228
- FROM ghcr.io/meith-dev/meith-base:${version} AS deps
229
+ ARG MEITH_VERSION
230
+ FROM ghcr.io/meith-dev/meith-base:\${MEITH_VERSION} AS deps
229
231
  WORKDIR /board
230
232
 
231
233
  # This board's own manifest, cached independently of its source — editing
@@ -281,7 +283,7 @@ ENTRYPOINT ["./docker-entrypoint.sh"]
281
283
  #
282
284
  # "web" (the default) runs the board; "migrate" applies the schema and
283
285
  # exits. There is no "worker" role in this image: @meith/worker is not
284
- # published, so nothing here can run it — compose.yml's own \`worker\`
286
+ # published, so nothing here can run it — docker-compose.yml's own \`worker\`
285
287
  # service drives the tick a different way, calling this image's web role
286
288
  # over HTTP instead of running as a role of this image.
287
289
  set -e
@@ -372,14 +374,39 @@ jobs:
372
374
  - name: Build and push
373
375
  run: |
374
376
  IMAGE=$(echo "ghcr.io/\${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
375
- docker build -t "$IMAGE:\${{ github.sha }}" -t "$IMAGE:latest" .
377
+ MEITH_VERSION=$(node -p "require('./package.json').dependencies['@meith/web']")
378
+ docker build --build-arg MEITH_VERSION="$MEITH_VERSION" -t "$IMAGE:\${{ github.sha }}" -t "$IMAGE:latest" .
376
379
  docker push "$IMAGE:\${{ github.sha }}"
377
380
  docker push "$IMAGE:latest"
381
+
382
+ - name: Summary
383
+ run: |
384
+ IMAGE=$(echo "ghcr.io/\${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
385
+ REPO_LOWER=$(echo "\${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
386
+ PKG_NAME=$(echo "$REPO_LOWER" | cut -d/ -f2)
387
+ PKG_URL="https://github.com/$REPO_LOWER/pkgs/container/$PKG_NAME"
388
+ {
389
+ echo "## Deploy this image"
390
+ echo
391
+ echo "Paste this into the MEITH_IMAGE variable on the Coolify resource:"
392
+ echo
393
+ echo " $IMAGE:latest"
394
+ echo
395
+ echo "Once you want a pin that only moves when you say so:"
396
+ echo
397
+ echo " $IMAGE:\${{ github.sha }}"
398
+ echo
399
+ echo "## One-time: make the package public"
400
+ echo
401
+ echo "This package starts private. Coolify's pull fails until you visit"
402
+ echo "$PKG_URL and change its visibility — **Package settings** →"
403
+ echo "**Change visibility** → **Public**."
404
+ } >> "$GITHUB_STEP_SUMMARY"
378
405
  `,
379
406
  )
380
407
 
381
408
  files.set(
382
- 'compose.yml',
409
+ 'docker-compose.yml',
383
410
  `# ${name}, deployed by Coolify — the same shape as the meith repository's own
384
411
  # docker/compose.coolify.yml: db, migrate, web, worker. See README.md for
385
412
  # the three-step deploy story this file is the last step of.
@@ -388,9 +415,10 @@ jobs:
388
415
  # the certificate. The two secrets and the database password are Coolify's
389
416
  # own "magic variables": it fills them in on the first deploy and shows them
390
417
  # in the panel, so nothing here needs a value typed into it except
391
- # MEITH_IMAGE, which only you can know — see README.md for where it comes
392
- # from. Requires Coolify v4.0.0-beta.411 or newer, which is when magic
393
- # variables in a compose file from a Git source arrived.
418
+ # MEITH_IMAGE, which only you can know — the build workflow's own Summary
419
+ # tab prints it, ready to paste, the moment it finishes. Requires Coolify
420
+ # v4.0.0-beta.411 or newer, which is when magic variables in a compose file
421
+ # from a Git source arrived.
394
422
  services:
395
423
  postgres:
396
424
  image: postgres:18-alpine@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2
@@ -412,7 +440,7 @@ services:
412
440
  # Runs to completion, then exits. web waits for it, so the schema is
413
441
  # always applied before the first request rather than racing it.
414
442
  migrate:
415
- image: \${MEITH_IMAGE:?set this to the image .github/workflows/build.yml just pushed, e.g. ghcr.io/<you>/${name}:latest — see README.md}
443
+ image: \${MEITH_IMAGE:?set this to the image the build workflow's Summary just printed, e.g. ghcr.io/<you>/${name}:latest}
416
444
  environment:
417
445
  COMMUNITY_ROLE: migrate
418
446
  DATABASE_URL: postgres://community:$SERVICE_PASSWORD_POSTGRES@postgres:5432/community
@@ -424,7 +452,7 @@ services:
424
452
  restart: 'no'
425
453
 
426
454
  web:
427
- image: \${MEITH_IMAGE:?set this to the image .github/workflows/build.yml just pushed, e.g. ghcr.io/<you>/${name}:latest — see README.md}
455
+ image: \${MEITH_IMAGE:?set this to the image the build workflow's Summary just printed, e.g. ghcr.io/<you>/${name}:latest}
428
456
  restart: unless-stopped
429
457
  mem_limit: \${WEB_MEM_LIMIT:-1g}
430
458
  cpus: \${WEB_CPUS:-2}
@@ -497,7 +525,7 @@ A forum, built on [Meith](${repositoryUrl}).
497
525
  ## Deploy
498
526
 
499
527
  Nothing here builds on your own server — a 2 GB VPS OOMs on a Next.js build,
500
- which is the whole reason \`Dockerfile\`, \`compose.yml\` and
528
+ which is the whole reason \`Dockerfile\`, \`docker-compose.yml\` and
501
529
  \`.github/workflows/build.yml\` exist: something else builds the image, the
502
530
  server only ever pulls one. Three steps, nothing to configure by hand beyond
503
531
  one value only you know:
@@ -508,21 +536,22 @@ one value only you know:
508
536
  \`GITHUB_TOKEN\` every GitHub Actions run already carries. No secret to
509
537
  add, no registry account beyond the GitHub account you already have.
510
538
 
511
- The package GitHub creates for it starts **private**. Make it public
512
- the package's own Settings Change visibility or Coolify's pull fails
513
- with an authentication error no operator can act on; the meith project's
514
- own release process has the identical one-time step for its official
515
- image.
539
+ Open the run under the repository's **Actions** tab once it finishes
540
+ its **Summary** prints the two things left: the exact image to paste
541
+ into step 2 below, and a direct link to the one-time step of making the
542
+ package public. It starts **private**, and Coolify's pull fails with an
543
+ authentication error no operator can act on until that is done.
516
544
 
517
- 2. **Point [Coolify](https://coolify.io) at \`compose.yml\`** — a Docker
518
- Compose resource, this repository as its source. \`compose.yml\` already
545
+ 2. **Point [Coolify](https://coolify.io) at \`docker-compose.yml\`** — a Docker
546
+ Compose resource, this repository as its source. \`docker-compose.yml\` already
519
547
  carries Coolify's own "magic variables" for \`AUTH_SECRET\`,
520
548
  \`TICK_SECRET\` and the database password, generated on the first deploy
521
549
  and never typed in. The one thing Coolify cannot generate is the image
522
550
  step 1 just pushed: set \`MEITH_IMAGE\` in the resource's own environment
523
- to \`ghcr.io/<you>/${name}:latest\` (or a commit sha, once you want a
524
- pin that only moves when you say so — \`compose.yml\` refuses to start
525
- without this set, with a message saying why).
551
+ to the value that run's Summary printed — \`ghcr.io/<you>/${name}:latest\`
552
+ (or a commit sha, once you want a pin that only moves when you say so —
553
+ \`docker-compose.yml\` refuses to start without this set, with a message saying
554
+ why).
526
555
 
527
556
  3. **Deploy, then \`/install\` on your own domain.** Coolify issues the
528
557
  certificate; the installer from there is the one
@@ -536,20 +565,24 @@ one value only you know:
536
565
  No Docker Hub, no paid CI: GitHub Actions' free tier and GHCR are the whole
537
566
  build side of this, for a board of any size.
538
567
 
539
- **Building it yourself**: \`docker build -t ${name} .\` works on any machine
540
- with Docker, if you would rather not use GitHub Actions for the build —
541
- push the result wherever \`compose.yml\`'s \`MEITH_IMAGE\` can reach.
568
+ **Building it yourself**: works on any machine with Docker, if you would
569
+ rather not use GitHub Actions for the build — push the result wherever
570
+ \`docker-compose.yml\`'s \`MEITH_IMAGE\` can reach.
571
+
572
+ \`\`\`sh
573
+ docker build --build-arg MEITH_VERSION=$(node -p "require('./package.json').dependencies['@meith/web']") -t ${name} .
574
+ \`\`\`
542
575
 
543
576
  **Without a panel**: [docs/self-hosting.md](${repositoryUrl}/blob/main/docs/self-hosting.md)
544
577
  is the same four containers by hand — your own \`.env\`, a reverse proxy you
545
- already run, no Coolify. \`Dockerfile\` and \`compose.yml\` here are this
578
+ already run, no Coolify. \`Dockerfile\` and \`docker-compose.yml\` here are this
546
579
  board's own version of exactly that shape.
547
580
 
548
581
  Two things nothing configures for you:
549
582
 
550
583
  - **Mail.** Until \`MAIL_DRIVER\` and its three settings exist, every message is
551
584
  written to the log and delivered to nobody, so password reset fails silently.
552
- - **The tick.** \`compose.yml\`'s \`worker\` service drives it here — a small
585
+ - **The tick.** \`docker-compose.yml\`'s \`worker\` service drives it here — a small
553
586
  loop calling \`/api/system/tick\` once a minute, since \`@meith/web\`'s own
554
587
  worker package is not something a board outside the meith monorepo can
555
588
  depend on yet. Deploy some other way and something still has to call that
@@ -587,17 +620,18 @@ npm run forum -- user:create --admin
587
620
 
588
621
  \`\`\`sh
589
622
  npm install @meith/web@latest @meith/cli@latest
590
- npm run forum -- upgrade
623
+ git commit -am "Upgrade @meith/web and @meith/cli"
624
+ git push
591
625
  \`\`\`
592
626
 
593
- \`Dockerfile\`'s \`FROM ghcr.io/meith-dev/meith-base:...\` line pins the same
594
- version as \`@meith/web\` bump it to match what \`npm install\` just
595
- resolved (\`npm ls @meith/web\` prints it) in the same commit as the
596
- \`package.json\` change above, before the next \`git push\` triggers a
597
- rebuild. The two pins drifting is not a broken board \`npm install\` inside
598
- the Docker build still resolves the newer \`@meith/web\` correctly only a
599
- slower one, rebuilding more than the base image already had to. There is no
600
- script for this yet: it is one line, by hand.
627
+ That one \`package.json\` change is the whole pin: \`Dockerfile\`'s own
628
+ \`FROM\` line takes the version as a build argument, and
629
+ \`.github/workflows/build.yml\` reads it straight out of \`package.json\`'s
630
+ own \`@meith/web\` dependency when it rebuilds nothing in \`Dockerfile\`
631
+ itself to keep in sync by hand. Once the rebuilt image is deployed, run
632
+ \`npm run forum -- upgrade\` against it for the plugin migrationssee
633
+ [the operator CLI](${repositoryUrl}/blob/main/docs/operating.md#the-operator-cli)
634
+ for running it against this deployment.
601
635
 
602
636
  Migrations are forward-only. Recovery is by restore, so take a backup first —
603
637
  there is no down migration to undo a destructive one, and a button that pretended