create-meith 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,165 @@
1
+ GNU LESSER GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+
9
+ This version of the GNU Lesser General Public License incorporates
10
+ the terms and conditions of version 3 of the GNU General Public
11
+ License, supplemented by the additional permissions listed below.
12
+
13
+ 0. Additional Definitions.
14
+
15
+ As used herein, "this License" refers to version 3 of the GNU Lesser
16
+ General Public License, and the "GNU GPL" refers to version 3 of the GNU
17
+ General Public License.
18
+
19
+ "The Library" refers to a covered work governed by this License,
20
+ other than an Application or a Combined Work as defined below.
21
+
22
+ An "Application" is any work that makes use of an interface provided
23
+ by the Library, but which is not otherwise based on the Library.
24
+ Defining a subclass of a class defined by the Library is deemed a mode
25
+ of using an interface provided by the Library.
26
+
27
+ A "Combined Work" is a work produced by combining or linking an
28
+ Application with the Library. The particular version of the Library
29
+ with which the Combined Work was made is also called the "Linked
30
+ Version".
31
+
32
+ The "Minimal Corresponding Source" for a Combined Work means the
33
+ Corresponding Source for the Combined Work, excluding any source code
34
+ for portions of the Combined Work that, considered in isolation, are
35
+ based on the Application, and not on the Linked Version.
36
+
37
+ The "Corresponding Application Code" for a Combined Work means the
38
+ object code and/or source code for the Application, including any data
39
+ and utility programs needed for reproducing the Combined Work from the
40
+ Application, but excluding the System Libraries of the Combined Work.
41
+
42
+ 1. Exception to Section 3 of the GNU GPL.
43
+
44
+ You may convey a covered work under sections 3 and 4 of this License
45
+ without being bound by section 3 of the GNU GPL.
46
+
47
+ 2. Conveying Modified Versions.
48
+
49
+ If you modify a copy of the Library, and, in your modifications, a
50
+ facility refers to a function or data to be supplied by an Application
51
+ that uses the facility (other than as an argument passed when the
52
+ facility is invoked), then you may convey a copy of the modified
53
+ version:
54
+
55
+ a) under this License, provided that you make a good faith effort to
56
+ ensure that, in the event an Application does not supply the
57
+ function or data, the facility still operates, and performs
58
+ whatever part of its purpose remains meaningful, or
59
+
60
+ b) under the GNU GPL, with none of the additional permissions of
61
+ this License applicable to that copy.
62
+
63
+ 3. Object Code Incorporating Material from Library Header Files.
64
+
65
+ The object code form of an Application may incorporate material from
66
+ a header file that is part of the Library. You may convey such object
67
+ code under terms of your choice, provided that, if the incorporated
68
+ material is not limited to numerical parameters, data structure
69
+ layouts and accessors, or small macros, inline functions and templates
70
+ (ten or fewer lines in length), you do both of the following:
71
+
72
+ a) Give prominent notice with each copy of the object code that the
73
+ Library is used in it and that the Library and its use are
74
+ covered by this License.
75
+
76
+ b) Accompany the object code with a copy of the GNU GPL and this license
77
+ document.
78
+
79
+ 4. Combined Works.
80
+
81
+ You may convey a Combined Work under terms of your choice that,
82
+ taken together, effectively do not restrict modification of the
83
+ portions of the Library contained in the Combined Work and reverse
84
+ engineering for debugging such modifications, if you also do each of
85
+ the following:
86
+
87
+ a) Give prominent notice with each copy of the Combined Work that
88
+ the Library is used in it and that the Library and its use are
89
+ covered by this License.
90
+
91
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
92
+ document.
93
+
94
+ c) For a Combined Work that displays copyright notices during
95
+ execution, include the copyright notice for the Library among
96
+ these notices, as well as a reference directing the user to the
97
+ copies of the GNU GPL and this license document.
98
+
99
+ d) Do one of the following:
100
+
101
+ 0) Convey the Minimal Corresponding Source under the terms of this
102
+ License, and the Corresponding Application Code in a form
103
+ suitable for, and under terms that permit, the user to
104
+ recombine or relink the Application with a modified version of
105
+ the Linked Version to produce a modified Combined Work, in the
106
+ manner specified by section 6 of the GNU GPL for conveying
107
+ Corresponding Source.
108
+
109
+ 1) Use a suitable shared library mechanism for linking with the
110
+ Library. A suitable mechanism is one that (a) uses at run time
111
+ a copy of the Library already present on the user's computer
112
+ system, and (b) will operate properly with a modified version
113
+ of the Library that is interface-compatible with the Linked
114
+ Version.
115
+
116
+ e) Provide Installation Information, but only if you would otherwise
117
+ be required to provide such information under section 6 of the
118
+ GNU GPL, and only to the extent that such information is
119
+ necessary to install and execute a modified version of the
120
+ Combined Work produced by recombining or relinking the
121
+ Application with a modified version of the Linked Version. (If
122
+ you use option 4d0, the Installation Information must accompany
123
+ the Minimal Corresponding Source and Corresponding Application
124
+ Code. If you use option 4d1, you must provide the Installation
125
+ Information in the manner specified by section 6 of the GNU GPL
126
+ for conveying Corresponding Source.)
127
+
128
+ 5. Combined Libraries.
129
+
130
+ You may place library facilities that are a work based on the
131
+ Library side by side in a single library together with other library
132
+ facilities that are not Applications and are not covered by this
133
+ License, and convey such a combined library under terms of your
134
+ choice, if you do both of the following:
135
+
136
+ a) Accompany the combined library with a copy of the same work based
137
+ on the Library, uncombined with any other library facilities,
138
+ conveyed under the terms of this License.
139
+
140
+ b) Give prominent notice with the combined library that part of it
141
+ is a work based on the Library, and explaining where to find the
142
+ accompanying uncombined form of the same work.
143
+
144
+ 6. Revised Versions of the GNU Lesser General Public License.
145
+
146
+ The Free Software Foundation may publish revised and/or new versions
147
+ of the GNU Lesser General Public License from time to time. Such new
148
+ versions will be similar in spirit to the present version, but may
149
+ differ in detail to address new problems or concerns.
150
+
151
+ Each version is given a distinguishing version number. If the
152
+ Library as you received it specifies that a certain numbered version
153
+ of the GNU Lesser General Public License "or any later version"
154
+ applies to it, you have the option of following the terms and
155
+ conditions either of that published version or of any later version
156
+ published by the Free Software Foundation. If the Library as you
157
+ received it does not specify a version number of the GNU Lesser
158
+ General Public License, you may choose any version of the GNU Lesser
159
+ General Public License ever published by the Free Software Foundation.
160
+
161
+ If the Library as you received it specifies that a proxy can decide
162
+ whether future versions of the GNU Lesser General Public License shall
163
+ apply, that proxy's public statement of acceptance of any version is
164
+ permanent authorization for you to choose that version for the
165
+ Library.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "create-meith",
3
+ "version": "0.16.0",
4
+ "description": "Scaffold a Meith board — npx create-meith <name> writes a workspace that depends on @meith/web and @meith/cli.",
5
+ "license": "LGPL-3.0-or-later",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/meith-dev/meith.git",
9
+ "directory": "packages/create-meith"
10
+ },
11
+ "type": "module",
12
+ "main": "./src/index.ts",
13
+ "types": "./src/index.ts",
14
+ "bin": {
15
+ "create-meith": "./src/bin.ts"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "!src/**/*.test.*"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ }
24
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './cli'
3
+
4
+ const result = await run(process.argv.slice(2), '0.16.0')
5
+ for (const line of result.lines) {
6
+ if (result.code === 0) console.log(line)
7
+ else console.error(line)
8
+ }
9
+ process.exit(result.code)
package/src/cli.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { mkdir, readdir, writeFile } from 'node:fs/promises'
2
+ import { dirname, join, resolve } from 'node:path'
3
+
4
+ import { DEFAULT_REPOSITORY_URL, nextSteps, scaffold, validateName } from './scaffold'
5
+
6
+ export interface CliResult {
7
+ readonly code: number
8
+ readonly lines: readonly string[]
9
+ }
10
+
11
+ async function isSafeTarget(target: string): Promise<boolean> {
12
+ try {
13
+ return (await readdir(target)).length === 0
14
+ } catch {
15
+ return true
16
+ }
17
+ }
18
+
19
+ export async function run(argv: readonly string[], version: string): Promise<CliResult> {
20
+ const positional = argv.filter((arg) => !arg.startsWith('-'))
21
+ const name = positional[0] ?? ''
22
+
23
+ if (argv.includes('--help') || argv.includes('-h')) {
24
+ return {
25
+ code: 0,
26
+ lines: [
27
+ 'create-meith — scaffold a forum project.',
28
+ '',
29
+ ' npx create-meith <name> [--repo <url>]',
30
+ '',
31
+ 'Writes package.json, community.config.ts, .env.example, .gitignore and',
32
+ 'README.md into ./<name>, then tells you what to run.',
33
+ ],
34
+ }
35
+ }
36
+
37
+ const invalid = validateName(name)
38
+ if (invalid !== null) {
39
+ return { code: 1, lines: [`create-meith: ${invalid}`, '', 'Usage: npx create-meith <name>'] }
40
+ }
41
+
42
+ const repoIndex = argv.indexOf('--repo')
43
+ const repositoryUrl =
44
+ repoIndex === -1 ? DEFAULT_REPOSITORY_URL : (argv[repoIndex + 1] ?? DEFAULT_REPOSITORY_URL)
45
+
46
+ const target = resolve(process.cwd(), name)
47
+ if (!(await isSafeTarget(target))) {
48
+ return {
49
+ code: 1,
50
+ lines: [
51
+ `create-meith: ${name} already exists and is not empty.`,
52
+ 'Refusing to write into it — pick another name, or empty it first.',
53
+ ],
54
+ }
55
+ }
56
+
57
+ const files = scaffold({ name, version, repositoryUrl })
58
+ for (const [relative, contents] of files) {
59
+ const path = join(target, relative)
60
+ await mkdir(dirname(path), { recursive: true })
61
+ await writeFile(path, contents, 'utf8')
62
+ }
63
+
64
+ return {
65
+ code: 0,
66
+ lines: [
67
+ `Created ${name} — ${files.size} files.`,
68
+ '',
69
+ ...nextSteps(name).map((step) => ` ${step}`),
70
+ '',
71
+ 'Then set DATABASE_URL, AUTH_SECRET and TICK_SECRET and deploy.',
72
+ 'Something must run the tick every minute — the worker process, or',
73
+ '`community task:run`. Without it nothing catches up, and nothing errors.',
74
+ ],
75
+ }
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { type CliResult, run } from './cli'
2
+ export {
3
+ DEFAULT_REPOSITORY_URL,
4
+ nextSteps,
5
+ type ScaffoldOptions,
6
+ scaffold,
7
+ validateName,
8
+ } from './scaffold'
@@ -0,0 +1,613 @@
1
+ export interface ScaffoldOptions {
2
+ readonly name: string
3
+ readonly version: string
4
+ readonly repositoryUrl: string
5
+ }
6
+
7
+ export const DEFAULT_REPOSITORY_URL = 'https://github.com/meith-dev/meith'
8
+
9
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,213}$/
10
+
11
+ export function validateName(name: string): string | null {
12
+ if (name === '') return 'A project name is required.'
13
+ if (name === '.' || name === '..') return 'That name would write outside the new directory.'
14
+ if (name.includes('/') || name.includes('\\'))
15
+ return 'A project name cannot contain a path separator.'
16
+ if (name !== name.toLowerCase()) return 'npm package names must be lower-case.'
17
+ if (!NAME_PATTERN.test(name)) {
18
+ return 'Use lower-case letters, digits, dots, hyphens and underscores, starting with a letter or digit.'
19
+ }
20
+ return null
21
+ }
22
+
23
+ export function scaffold(options: ScaffoldOptions): ReadonlyMap<string, string> {
24
+ const { name, version, repositoryUrl } = options
25
+ const files = new Map<string, string>()
26
+
27
+ files.set(
28
+ 'package.json',
29
+ `${JSON.stringify(
30
+ {
31
+ name,
32
+ version: '0.1.0',
33
+ private: true,
34
+ type: 'module',
35
+ scripts: {
36
+ dev: 'forum-web dev',
37
+ build: 'forum-web build',
38
+ start: 'forum-web start',
39
+ community: 'community',
40
+ },
41
+ dependencies: {
42
+ '@meith/web': version,
43
+ '@meith/cli': version,
44
+ '@meith/theme-default': version,
45
+ },
46
+ engines: { node: '>=22' },
47
+ },
48
+ null,
49
+ 2,
50
+ )}\n`,
51
+ )
52
+
53
+ files.set(
54
+ 'community.config.ts',
55
+ `/**
56
+ * The board's build-time registry.
57
+ *
58
+ * Everything installable is named here, statically, so the bundler can see it
59
+ * and the compiler can check it. Nothing is discovered by scanning a directory
60
+ * at runtime — a production build contains only what the bundler could see, so a
61
+ * directory walked at request time is empty and a plugin "installed" that way is
62
+ * not there at all.
63
+ *
64
+ * Adding a theme is: \`npm install\` it, add a line here, redeploy. Adding a
65
+ * plugin is the same, through board.plugins.json and community.plugins.ts —
66
+ * see docs/plugin-api.md.
67
+ */
68
+ import { defineForumConfig } from '@meith/web/config'
69
+ import {
70
+ BROWSER_THEME_COLOR,
71
+ DARK_TOKENS,
72
+ defaultTheme,
73
+ LIGHT_TOKENS,
74
+ } from '@meith/theme-default'
75
+
76
+ import { INSTALLED_PLUGINS } from './community.plugins'
77
+
78
+ export default defineForumConfig({
79
+ themes: {
80
+ default: {
81
+ key: 'default',
82
+ title: 'Default',
83
+ tokens: { light: LIGHT_TOKENS, dark: DARK_TOKENS },
84
+ browserThemeColor: BROWSER_THEME_COLOR,
85
+ theme: defaultTheme,
86
+ },
87
+ },
88
+ defaultTheme: 'default',
89
+
90
+ plugins: INSTALLED_PLUGINS,
91
+ })
92
+ `,
93
+ )
94
+
95
+ files.set('board.plugins.json', `${JSON.stringify({ plugins: [] }, null, 2)}\n`)
96
+
97
+ files.set(
98
+ 'community.plugins.ts',
99
+ `/**
100
+ * The board's installed-plugin list.
101
+ *
102
+ * Inside the Meith monorepo this file is generated from board.plugins.json
103
+ * by \`pnpm board:gen\` (see docs/plugin-api.md) — that generator is
104
+ * repository tooling, not something this workspace carries, so this file
105
+ * starts as a plain, valid file with the same shape instead. Add a plugin by
106
+ * importing its \`plugin\`/\`messages\` exports and adding an entry:
107
+ *
108
+ * import { messages as greeterMessages, plugin as greeterPlugin } from '@meith/plugin-greeter'
109
+ *
110
+ * export const INSTALLED_PLUGINS: readonly InstalledPlugin<PluginDefinition>[] = [
111
+ * { key: 'greeter', enabled: true, plugin: greeterPlugin, messages: greeterMessages },
112
+ * ]
113
+ *
114
+ * and the matching entry in board.plugins.json, which is what
115
+ * \`community plugin:add\`/\`plugin:remove\` read inside the monorepo — kept
116
+ * here too so the two files agree about what is installed.
117
+ */
118
+ import type { InstalledPlugin } from '@meith/web/config'
119
+
120
+ export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = []
121
+
122
+ export function installedPluginDefinitions() {
123
+ return INSTALLED_PLUGINS.filter(
124
+ (entry) => entry.enabled !== false && entry.plugin !== undefined,
125
+ ).map((entry) => entry.plugin)
126
+ }
127
+ `,
128
+ )
129
+
130
+ files.set(
131
+ '.env.example',
132
+ `# ${name} — environment.
133
+ #
134
+ # Copy to .env.local for development. On the server this is \`.env\` beside the
135
+ # compose file; nothing here belongs in git.
136
+
137
+ # ─── Required ────────────────────────────────────────────────────────────────
138
+
139
+ # Your Postgres connection string.
140
+ #
141
+ # If it is a managed database that offers a TRANSACTION-MODE POOLER string, use
142
+ # that rather than the direct one — Neon, Supabase and their kind hand out both,
143
+ # and on the direct string a board works in testing and starts refusing
144
+ # connections under the first real traffic, with an error that names the
145
+ # database rather than the cause. Your own Postgres, with a fixed number of
146
+ # processes in front of it, does not need one.
147
+ DATABASE_URL=
148
+
149
+ # Session and token signing. No default, deliberately: a shipped default is a
150
+ # board every reader of the source can sign a session for.
151
+ #
152
+ # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
153
+ AUTH_SECRET=
154
+
155
+ # The shared secret the tick caller presents to GET /api/system/tick. Generate
156
+ # it the same way. Without it the tick is unauthenticated, and the tick is how
157
+ # bans expire and digests send.
158
+ TICK_SECRET=
159
+
160
+ # ─── Optional ────────────────────────────────────────────────────────────────
161
+
162
+ # fixture = deterministic in-memory sample data, no database needed. This is
163
+ # what \`npm run build\` uses, and what a checkout with no database falls back to.
164
+ DATA_SOURCE=postgres
165
+
166
+ # Absolute, no trailing slash. Used in mail, feeds and canonical URLs — every
167
+ # place a relative URL cannot work because there is no request to be relative to.
168
+ #
169
+ # Optional: leave it blank and the installer asks, prefilled from the address you
170
+ # load /install at, and stores the answer on the board where the settings screen
171
+ # can change it without a redeploy. Set it here and it wins outright.
172
+ APP_URL=
173
+
174
+ # Mail. Leave these alone and the installer asks for mail on first run, storing
175
+ # it on the board — a settings screen with a test button, no redeploy. Set
176
+ # MAIL_DRIVER here instead and the environment wins outright, which is what you
177
+ # want if the credential must not live in the database.
178
+ #
179
+ # The default sends NOTHING: each message goes to the server log, so password
180
+ # reset fails silently until mail is configured one way or the other.
181
+ # MAIL_DRIVER=smtp
182
+ # MAIL_SMTP_HOST=smtp.example.com
183
+ # MAIL_SMTP_PORT=465
184
+ # MAIL_SMTP_SECURITY=tls # tls (465) | starttls (587) | none
185
+ # MAIL_SMTP_USERNAME=
186
+ # MAIL_SMTP_PASSWORD=
187
+ # MAIL_FROM=noreply@yourdomain.com
188
+
189
+ `,
190
+ )
191
+
192
+ files.set(
193
+ '.gitignore',
194
+ `node_modules
195
+ .next
196
+ .meith
197
+ .env
198
+ .env.local
199
+ .env*.local
200
+ *.log
201
+ .DS_Store
202
+ `,
203
+ )
204
+
205
+ files.set(
206
+ 'Dockerfile',
207
+ `# syntax=docker/dockerfile:1.7-labs
208
+ # ${name}'s deploy image.
209
+ #
210
+ # FROM the published framework base image — deps + framework layers only,
211
+ # locked to this exact release (see the meith repository's
212
+ # docs/self-hosting.md, "Custom boards", and docker/Dockerfile.base for what
213
+ # it is and is not). This board's own Dockerfile only ever installs its own
214
+ # delta on top of it — a new plugin's own dependency, typically nothing more
215
+ # — which is what keeps a rebuild after \`npm install some-plugin\` a matter
216
+ # of minutes rather than a cold toolchain build.
217
+ #
218
+ # Two stages, not three: unlike the official image, this does not prune down
219
+ # to Next's own standalone output. The migrate role below runs \`community
220
+ # migrate\`, and \`community\` materializes @meith/cli's sources and runs them
221
+ # with tsx at the moment it runs (see the meith repository's
222
+ # docs/development.md, "Consuming the board from a workspace") — it needs
223
+ # the full, un-pruned node_modules tree this board installed, not what Next
224
+ # 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
+ # /api/system/tick, not a compiled worker process, because @meith/worker is
227
+ # not published (see the meith repository's docs/release.md).
228
+ FROM ghcr.io/meith-dev/meith-base:${version} AS deps
229
+ WORKDIR /board
230
+
231
+ # This board's own manifest, cached independently of its source — editing
232
+ # community.config.ts should not re-run npm install. The base image above
233
+ # already carries node_modules for @meith/web, @meith/cli and
234
+ # @meith/theme-default at this exact version, so installing this file on top
235
+ # of it only fetches what changed: a plugin newly added to \`dependencies\`,
236
+ # typically nothing at all.
237
+ COPY package.json ./
238
+ RUN npm install
239
+
240
+ FROM deps AS runtime
241
+ WORKDIR /board
242
+ COPY . .
243
+
244
+ ENV NEXT_TELEMETRY_DISABLED=1
245
+ ENV NODE_ENV=production
246
+
247
+ # DATA_SOURCE is scoped to this one RUN, not declared with ENV — an ENV
248
+ # persists into every container started from this image afterward, and this
249
+ # Dockerfile has no later stage to reset it in (see "Two stages, not three"
250
+ # above). The build needs neither a database nor a production secret (see
251
+ # the meith repository's docs/development.md, "Fixture mode"), but baking
252
+ # DATA_SOURCE=fixture into the image itself would silently force fixture
253
+ # mode — and with it the in-memory queue driver — at runtime too, no matter
254
+ # what DATABASE_URL an operator supplies to \`docker run\`.
255
+ RUN DATA_SOURCE=fixture npx forum-web build
256
+
257
+ ENV PORT=3000
258
+ ENV HOSTNAME=0.0.0.0
259
+ EXPOSE 3000
260
+
261
+ # node:alpine already carries a non-root "node" user; the board's own files
262
+ # are copied in as root above, so they need handing over before this drops
263
+ # privilege.
264
+ RUN chown -R node:node /board
265
+ USER node
266
+
267
+ COPY --chown=node:node docker-entrypoint.sh docker-healthcheck.sh ./
268
+ RUN chmod +x docker-entrypoint.sh docker-healthcheck.sh
269
+
270
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \\
271
+ CMD ["./docker-healthcheck.sh"]
272
+
273
+ ENTRYPOINT ["./docker-entrypoint.sh"]
274
+ `,
275
+ )
276
+
277
+ files.set(
278
+ 'docker-entrypoint.sh',
279
+ `#!/bin/sh
280
+ # One image, two roles — see Dockerfile and README.md.
281
+ #
282
+ # "web" (the default) runs the board; "migrate" applies the schema and
283
+ # 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\`
285
+ # service drives the tick a different way, calling this image's web role
286
+ # over HTTP instead of running as a role of this image.
287
+ set -e
288
+
289
+ # An explicit command wins over the role, the same as the official image —
290
+ # \`docker run <image> node_modules/.bin/community --help\` should still run
291
+ # the CLI rather than silently starting the web server.
292
+ if [ "$#" -gt 0 ]; then
293
+ exec "$@"
294
+ fi
295
+
296
+ case "\${COMMUNITY_ROLE:-web}" in
297
+ migrate)
298
+ # Runs to completion and exits; compose's one-shot service waits on it.
299
+ exec node_modules/.bin/community migrate
300
+ ;;
301
+ web)
302
+ exec node_modules/.bin/forum-web start
303
+ ;;
304
+ *)
305
+ echo "Unknown COMMUNITY_ROLE: \${COMMUNITY_ROLE}. Expected 'web' or 'migrate'." >&2
306
+ exit 1
307
+ ;;
308
+ esac
309
+ `,
310
+ )
311
+
312
+ files.set(
313
+ 'docker-healthcheck.sh',
314
+ `#!/bin/sh
315
+ # What "healthy" means depends on the role — see docker-entrypoint.sh.
316
+ # "migrate" runs to completion and exits; its exit code is the verdict, and
317
+ # a health probe taken while it runs has no opinion.
318
+ set -e
319
+
320
+ if [ "\${COMMUNITY_ROLE:-web}" = "migrate" ]; then
321
+ exit 0
322
+ fi
323
+
324
+ node -e "fetch('http://127.0.0.1:3000/api/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
325
+ `,
326
+ )
327
+
328
+ files.set(
329
+ '.dockerignore',
330
+ `node_modules
331
+ .next
332
+ .meith
333
+ .git
334
+ .env
335
+ .env.local
336
+ *.log
337
+ `,
338
+ )
339
+
340
+ files.set(
341
+ '.github/workflows/build.yml',
342
+ `# Builds this board's image and pushes it to your own GHCR, on every push to
343
+ # main. No secret to configure: GITHUB_TOKEN is provided automatically by
344
+ # GitHub Actions and is enough to push to ghcr.io/<this repository>. See
345
+ # README.md for the rest of the three-step deploy story.
346
+ name: Build and push
347
+
348
+ on:
349
+ push:
350
+ branches: [main]
351
+ workflow_dispatch:
352
+
353
+ permissions:
354
+ contents: read
355
+ packages: write
356
+
357
+ jobs:
358
+ image:
359
+ name: Build and push the board image
360
+ runs-on: ubuntu-latest
361
+ steps:
362
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
363
+
364
+ - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
365
+ with:
366
+ registry: ghcr.io
367
+ username: \${{ github.actor }}
368
+ password: \${{ secrets.GITHUB_TOKEN }}
369
+
370
+ # GHCR requires a lower-case image name, and neither your GitHub
371
+ # username nor this repository's name is guaranteed to be.
372
+ - name: Build and push
373
+ run: |
374
+ IMAGE=$(echo "ghcr.io/\${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
375
+ docker build -t "$IMAGE:\${{ github.sha }}" -t "$IMAGE:latest" .
376
+ docker push "$IMAGE:\${{ github.sha }}"
377
+ docker push "$IMAGE:latest"
378
+ `,
379
+ )
380
+
381
+ files.set(
382
+ 'compose.yml',
383
+ `# ${name}, deployed by Coolify — the same shape as the meith repository's own
384
+ # docker/compose.coolify.yml: db, migrate, web, worker. See README.md for
385
+ # the three-step deploy story this file is the last step of.
386
+ #
387
+ # No published ports — Coolify's proxy routes to the container and issues
388
+ # the certificate. The two secrets and the database password are Coolify's
389
+ # own "magic variables": it fills them in on the first deploy and shows them
390
+ # 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.
394
+ services:
395
+ postgres:
396
+ image: postgres:18-alpine@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2
397
+ restart: unless-stopped
398
+ mem_limit: \${POSTGRES_MEM_LIMIT:-1g}
399
+ cpus: \${POSTGRES_CPUS:-1}
400
+ environment:
401
+ POSTGRES_USER: community
402
+ POSTGRES_PASSWORD: $SERVICE_PASSWORD_POSTGRES
403
+ POSTGRES_DB: community
404
+ volumes:
405
+ - pgdata:/var/lib/postgresql
406
+ healthcheck:
407
+ test: ['CMD-SHELL', 'pg_isready -U community -d community']
408
+ interval: 10s
409
+ timeout: 5s
410
+ retries: 5
411
+
412
+ # Runs to completion, then exits. web waits for it, so the schema is
413
+ # always applied before the first request rather than racing it.
414
+ 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}
416
+ environment:
417
+ COMMUNITY_ROLE: migrate
418
+ DATABASE_URL: postgres://community:$SERVICE_PASSWORD_POSTGRES@postgres:5432/community
419
+ AUTH_SECRET: $SERVICE_BASE64_64_AUTH
420
+ TICK_SECRET: $SERVICE_BASE64_64_TICK
421
+ depends_on:
422
+ postgres:
423
+ condition: service_healthy
424
+ restart: 'no'
425
+
426
+ 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}
428
+ restart: unless-stopped
429
+ mem_limit: \${WEB_MEM_LIMIT:-1g}
430
+ cpus: \${WEB_CPUS:-2}
431
+ environment:
432
+ # Ask Coolify for a domain on port 3000, then hand the board the same
433
+ # thing with a scheme in front.
434
+ - SERVICE_FQDN_WEB_3000
435
+ - APP_URL=$SERVICE_URL_WEB
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
+ - QUEUE_DRIVER=postgres
440
+ - CACHE_DRIVER=next
441
+ - FILESTORE_DRIVER=local
442
+ # Left unset, mail is configured on the board itself — the installer
443
+ # asks on first run. Set MAIL_DRIVER here and this file wins instead.
444
+ - MAIL_DRIVER=\${MAIL_DRIVER:-log}
445
+ - MAIL_SMTP_HOST=\${MAIL_SMTP_HOST:-}
446
+ - MAIL_SMTP_PORT=\${MAIL_SMTP_PORT:-}
447
+ - MAIL_SMTP_SECURITY=\${MAIL_SMTP_SECURITY:-}
448
+ - MAIL_SMTP_USERNAME=\${MAIL_SMTP_USERNAME:-}
449
+ - MAIL_SMTP_PASSWORD=\${MAIL_SMTP_PASSWORD:-}
450
+ - MAIL_FROM=\${MAIL_FROM:-}
451
+ volumes:
452
+ - uploads:/app/.uploads
453
+ depends_on:
454
+ postgres:
455
+ condition: service_healthy
456
+ migrate:
457
+ condition: service_completed_successfully
458
+
459
+ # @meith/worker is not published (see the meith repository's
460
+ # docs/release.md), so there is no compiled worker binary a scaffolded
461
+ # board can run — this drives the tick the alternative way the meith
462
+ # repository documents in docs/self-hosting.md, "Running the tick without
463
+ # a second set of credentials": a small loop calling /api/system/tick.
464
+ worker:
465
+ image: alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
466
+ restart: unless-stopped
467
+ mem_limit: \${WORKER_MEM_LIMIT:-64m}
468
+ cpus: \${WORKER_CPUS:-0.25}
469
+ environment:
470
+ TICK_SECRET: $SERVICE_BASE64_64_TICK
471
+ command:
472
+ - sh
473
+ - -c
474
+ - |
475
+ apk add --no-cache curl >/dev/null
476
+ while true; do
477
+ curl -fsS -m 55 -H "Authorization: Bearer $$TICK_SECRET" \\
478
+ http://web:3000/api/system/tick >/dev/null 2>&1 \\
479
+ || echo "tick failed at $$(date -Is)"
480
+ sleep 60
481
+ done
482
+ depends_on:
483
+ - web
484
+
485
+ volumes:
486
+ pgdata:
487
+ uploads:
488
+ `,
489
+ )
490
+
491
+ files.set(
492
+ 'README.md',
493
+ `# ${name}
494
+
495
+ A forum, built on [Meith](${repositoryUrl}).
496
+
497
+ ## Deploy
498
+
499
+ 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
501
+ \`.github/workflows/build.yml\` exist: something else builds the image, the
502
+ server only ever pulls one. Three steps, nothing to configure by hand beyond
503
+ one value only you know:
504
+
505
+ 1. **Push this repository to GitHub.** \`.github/workflows/build.yml\` builds
506
+ \`Dockerfile\` on every push to \`main\` and pushes the result to your own
507
+ GitHub Container Registry, \`ghcr.io/<you>/${name}\` — using only the
508
+ \`GITHUB_TOKEN\` every GitHub Actions run already carries. No secret to
509
+ add, no registry account beyond the GitHub account you already have.
510
+
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.
516
+
517
+ 2. **Point [Coolify](https://coolify.io) at \`compose.yml\`** — a Docker
518
+ Compose resource, this repository as its source. \`compose.yml\` already
519
+ carries Coolify's own "magic variables" for \`AUTH_SECRET\`,
520
+ \`TICK_SECRET\` and the database password, generated on the first deploy
521
+ and never typed in. The one thing Coolify cannot generate is the image
522
+ 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).
526
+
527
+ 3. **Deploy, then \`/install\` on your own domain.** Coolify issues the
528
+ certificate; the installer from there is the one
529
+ [docs/quickstart.md](${repositoryUrl}/blob/main/docs/quickstart.md#4-run-the-installer)
530
+ walks through, screen for screen. It seals itself when it finishes, and
531
+ \`/install\` answers 404 from then on — run it **against the database you
532
+ are going to keep**. Every push to \`main\` after this rebuilds the
533
+ image; Coolify's own **Redeploy** button is what actually pulls it —
534
+ pushing alone does not.
535
+
536
+ No Docker Hub, no paid CI: GitHub Actions' free tier and GHCR are the whole
537
+ build side of this, for a board of any size.
538
+
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.
542
+
543
+ **Without a panel**: [docs/self-hosting.md](${repositoryUrl}/blob/main/docs/self-hosting.md)
544
+ 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
546
+ board's own version of exactly that shape.
547
+
548
+ Two things nothing configures for you:
549
+
550
+ - **Mail.** Until \`MAIL_DRIVER\` and its three settings exist, every message is
551
+ 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
553
+ loop calling \`/api/system/tick\` once a minute, since \`@meith/web\`'s own
554
+ worker package is not something a board outside the meith monorepo can
555
+ depend on yet. Deploy some other way and something still has to call that
556
+ route (or run \`community task:run\`) every minute, or nothing catches up
557
+ and nothing errors.
558
+
559
+ ## Local
560
+
561
+ \`\`\`sh
562
+ npm install
563
+ cp .env.example .env.local
564
+ npm run dev
565
+ \`\`\`
566
+
567
+ With no \`DATABASE_URL\`, the board runs on deterministic in-memory sample data —
568
+ enough to click through every reading surface. Posting needs a database:
569
+
570
+ \`\`\`sh
571
+ npm run forum -- migrate
572
+ npm run forum -- user:create --admin
573
+ \`\`\`
574
+
575
+ ## Configuring
576
+
577
+ - **\`community.config.ts\`** — installed themes and plugins. Everything installable
578
+ is named here so the bundler can see it; nothing is found by scanning a
579
+ directory at runtime.
580
+ - **\`/admin\`** — settings, forums, groups, members, themes, maintenance. An
581
+ administrator re-enters their password to get in, and again for anything
582
+ destructive.
583
+ - **\`npm run forum -- --help\`** — the operator CLI. Everything the panel does
584
+ and a few things it cannot, without a browser.
585
+
586
+ ## Upgrading
587
+
588
+ \`\`\`sh
589
+ npm install @meith/web@latest @meith/cli@latest
590
+ npm run forum -- upgrade
591
+ \`\`\`
592
+
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.
601
+
602
+ Migrations are forward-only. Recovery is by restore, so take a backup first —
603
+ there is no down migration to undo a destructive one, and a button that pretended
604
+ otherwise would be worse than its absence.
605
+ `,
606
+ )
607
+
608
+ return files
609
+ }
610
+
611
+ export function nextSteps(name: string): readonly string[] {
612
+ return [`cd ${name}`, 'npm install', 'cp .env.example .env.local', 'npm run dev']
613
+ }