create-meith 0.16.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 +731 -0
- package/package.json +9 -2
- package/src/bin.ts +1 -1
- package/src/cli.ts +37 -1
- package/src/scaffold.ts +69 -35
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.
|
|
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": "./
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
392
|
-
#
|
|
393
|
-
# variables in a compose file
|
|
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
|
|
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
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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\`
|
|
524
|
-
pin that only moves when you say so —
|
|
525
|
-
without this set, with a message saying
|
|
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**:
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
623
|
+
git commit -am "Upgrade @meith/web and @meith/cli"
|
|
624
|
+
git push
|
|
591
625
|
\`\`\`
|
|
592
626
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
\`
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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 migrations — see
|
|
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
|