create-meith 0.17.0 → 0.17.2

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