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 +766 -0
- package/package.json +9 -2
- package/src/bin.ts +1 -1
- package/src/cli.ts +46 -2
- package/src/scaffold.ts +101 -39
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.
|
|
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": "./
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
392
|
-
#
|
|
393
|
-
# variables in a compose file
|
|
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
|
|
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
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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}
|
|
524
|
-
pin that only
|
|
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**:
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
572
|
-
npm run
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
\`
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|