@meith/cli 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { basename, dirname, join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { DEFAULT_REPOSITORY_URL, scaffold, validateName } from 'create-meith'
6
+
7
+ import { ValidationError } from '@meith/core'
8
+
9
+ import { CODE_VERSION } from './upgrade'
10
+
11
+ /**
12
+ * apps/cli/src/plugin-manifest.ts and this file are the same distance from
13
+ * the repository root (apps/cli/{src,dist}/<file> either way) — see that
14
+ * file's own comment. This default is only reached from *this* checkout
15
+ * (`pnpm community board:eject`, tests): the deployed image sets
16
+ * `BOARD_PLUGINS_MANIFEST` explicitly (see docker/Dockerfile), because a
17
+ * bundled `dist/cli.cjs` does not sit at that same distance — Docker's own
18
+ * `COPY apps/cli/dist/ ./apps/cli/` drops the `dist` segment.
19
+ */
20
+ const ROOT = fileURLToPath(new URL('../../../', import.meta.url))
21
+ const DEFAULT_MANIFEST_PATH = join(ROOT, 'boards/stock/board.plugins.json')
22
+
23
+ interface ManifestEntry {
24
+ readonly key: string
25
+ readonly package: string
26
+ readonly enabled?: boolean
27
+ }
28
+
29
+ interface Manifest {
30
+ readonly plugins: readonly ManifestEntry[]
31
+ }
32
+
33
+ function manifestPath(): string {
34
+ return process.env.BOARD_PLUGINS_MANIFEST ?? DEFAULT_MANIFEST_PATH
35
+ }
36
+
37
+ /**
38
+ * The manifest this build actually compiled in. Unlike
39
+ * apps/cli/src/plugin-manifest.ts's readManifest — which is written for a
40
+ * checkout and refuses to run against a deployed image — this one is meant
41
+ * to run *only* against a deployed image (or this checkout's own
42
+ * boards/stock, standing in for one), so a missing file is the real failure
43
+ * this command exists to report, not an expected outcome.
44
+ */
45
+ async function readManifest(): Promise<Manifest> {
46
+ const path = manifestPath()
47
+ let raw: string
48
+ try {
49
+ raw = await readFile(path, 'utf8')
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
52
+ throw new ValidationError(
53
+ `board:eject could not find this build's plugin manifest at ${path}. This command ` +
54
+ 'runs inside the official image (docker compose run --rm web community board:eject ' +
55
+ '<dir>), where it is baked in — or, in this repository, against boards/stock. It is ' +
56
+ 'not meant to run against a workspace board:eject already produced.',
57
+ )
58
+ }
59
+ throw error
60
+ }
61
+
62
+ const parsed = JSON.parse(raw) as Partial<Manifest>
63
+ if (!Array.isArray(parsed.plugins)) {
64
+ throw new ValidationError(`${path} must have a "plugins" array.`)
65
+ }
66
+ return { plugins: parsed.plugins }
67
+ }
68
+
69
+ async function isEmptyOrMissing(target: string): Promise<boolean> {
70
+ try {
71
+ return (await readdir(target)).length === 0
72
+ } catch (error) {
73
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
74
+ throw error
75
+ }
76
+ }
77
+
78
+ function toIdentifier(key: string): string {
79
+ return key.replace(/-([a-z0-9])/g, (_match, char) => char.toUpperCase())
80
+ }
81
+
82
+ /**
83
+ * The ejected workspace's own community.plugins.ts — the same shape
84
+ * create-meith's scaffold() writes for a plugin-free board (see
85
+ * packages/create-meith/src/scaffold.ts), extended with one entry per
86
+ * manifest plugin. Never the showcase-wired shape scripts/board-plugins.mjs
87
+ * generates for apps/community and boards/stock: `./community.demo.plugins`
88
+ * is this monorepo's own demo/test scaffolding and does not exist in a
89
+ * workspace outside it — the same reason scaffold.ts's own template omits
90
+ * it. In practice the manifest a real stock image compiles in is always
91
+ * empty (plugin:add refuses to run against a deployed image — see
92
+ * plugin-manifest.ts — so a running stock image can never have grown one),
93
+ * but this reads the real file rather than assuming that, so a future
94
+ * default plugin would still be captured correctly.
95
+ */
96
+ function renderInstalledPluginsModule(plugins: readonly ManifestEntry[]): string {
97
+ const importLines = plugins.map((entry) => {
98
+ const name = toIdentifier(entry.key)
99
+ return `import { messages as ${name}Messages, plugin as ${name}Plugin } from '${entry.package}'`
100
+ })
101
+ const entryLines = plugins.map((entry) => {
102
+ const name = toIdentifier(entry.key)
103
+ const enabled = entry.enabled === false ? 'false' : 'true'
104
+ return (
105
+ ` { key: '${entry.key}', enabled: ${enabled}, ` +
106
+ `plugin: ${name}Plugin, messages: ${name}Messages },`
107
+ )
108
+ })
109
+ const body = entryLines.length > 0 ? `\n${entryLines.join('\n')}\n` : ''
110
+
111
+ return `${
112
+ importLines.length > 0 ? `${importLines.join('\n')}\n\n` : ''
113
+ }import type { InstalledPlugin } from '@meith/web/config'
114
+
115
+ export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = [${body}]
116
+
117
+ export function installedPluginDefinitions() {
118
+ return INSTALLED_PLUGINS.filter(
119
+ (entry) => entry.enabled !== false && entry.plugin !== undefined,
120
+ ).map((entry) => entry.plugin)
121
+ }
122
+ `
123
+ }
124
+
125
+ export async function boardEject(args: readonly string[]): Promise<number> {
126
+ const positional = args.filter((arg) => !arg.startsWith('-'))
127
+ const [dir] = positional
128
+
129
+ if (dir === undefined || positional.length > 1) {
130
+ throw new ValidationError('Usage: community board:eject <dir>')
131
+ }
132
+
133
+ const target = resolve(process.cwd(), dir)
134
+ const name = basename(target)
135
+
136
+ const invalidName = validateName(name)
137
+ if (invalidName !== null) {
138
+ throw new ValidationError(
139
+ `board:eject: "${name}" (from ${target}) is not a usable project name — ${invalidName} ` +
140
+ 'Pick a target directory whose name is a valid npm package name.',
141
+ )
142
+ }
143
+
144
+ if (!(await isEmptyOrMissing(target))) {
145
+ throw new ValidationError(`${target} already exists and is not empty. Pick an empty target.`)
146
+ }
147
+
148
+ const manifest = await readManifest()
149
+
150
+ const files = new Map(
151
+ scaffold({ name, version: CODE_VERSION, repositoryUrl: DEFAULT_REPOSITORY_URL }),
152
+ )
153
+ files.set('board.plugins.json', `${JSON.stringify({ plugins: manifest.plugins }, null, 2)}\n`)
154
+ files.set('community.plugins.ts', renderInstalledPluginsModule(manifest.plugins))
155
+
156
+ for (const [relative, contents] of files) {
157
+ const path = join(target, relative)
158
+ await mkdir(dirname(path), { recursive: true })
159
+ await writeFile(path, contents, 'utf8')
160
+ }
161
+
162
+ console.log(`Ejected ${files.size} files to ${target}, pinned to meith ${CODE_VERSION}.`)
163
+ console.log('')
164
+ console.log(
165
+ "What doesn't move: the database, the uploads volume and every environment variable " +
166
+ 'stay exactly where they are — only where the image comes from changes.',
167
+ )
168
+ console.log('')
169
+ console.log('Next:')
170
+ console.log(` cd ${target}`)
171
+ console.log(' git init && git add -A && git commit -m "Graduate from the stock image"')
172
+ console.log(' # push it to a new GitHub repository')
173
+ console.log(' # .github/workflows/build.yml builds and pushes the image on every push to main —')
174
+ console.log(" # point Coolify at this repository's compose.yml and set MEITH_IMAGE to it")
175
+ console.log(' # redeploy — same database, same uploads, same secrets, new image source')
176
+ console.log('')
177
+ console.log('See docs/marketplace.md, "Moving to a custom board", for the full walkthrough.')
178
+
179
+ return 0
180
+ }
@@ -0,0 +1,269 @@
1
+ import { clearSecondFactor, foldIdentifier } from '@meith/accounts'
2
+ import { ValidationError } from '@meith/core'
3
+ import { FORUM_TYPES, type ForumType } from '@meith/forums'
4
+ import {
5
+ SETTING_DEFINITION_BY_KEY,
6
+ type SettingDefinition,
7
+ type SettingKey,
8
+ SettingsSnapshot,
9
+ saveSettings,
10
+ } from '@meith/settings'
11
+
12
+ import { type Flags, integer, optional, parseFlags, required } from './args'
13
+ import { type CliContext, createContext } from './context'
14
+
15
+ async function readPassword(flags: Flags): Promise<string> {
16
+ const inline = optional(flags, 'password')
17
+ if (inline !== undefined) {
18
+ console.warn(
19
+ 'warning: --password is visible in shell history and to `ps`. ' +
20
+ 'Prefer: echo "secret" | community user:create --username u --email e@x.com',
21
+ )
22
+ return inline
23
+ }
24
+
25
+ if (process.stdin.isTTY) {
26
+ throw new ValidationError(
27
+ 'No password supplied. Pipe one in:\n' +
28
+ ' echo "correct horse battery staple" | community user:create --username u --email u@example.com',
29
+ )
30
+ }
31
+
32
+ const chunks: Buffer[] = []
33
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
34
+ const password = Buffer.concat(chunks)
35
+ .toString('utf8')
36
+ .replace(/\r?\n$/, '')
37
+
38
+ if (password === '') throw new ValidationError('The password read from stdin was empty.')
39
+ return password
40
+ }
41
+
42
+ async function findUser(ctx: CliContext, reference: string) {
43
+ const user = await ctx.admin.findUser(reference, foldIdentifier(reference))
44
+ if (!user) throw new ValidationError(`No such user: ${reference}`)
45
+ return user
46
+ }
47
+
48
+ async function findGroup(ctx: CliContext, reference: string) {
49
+ const group = await ctx.admin.findGroup(reference)
50
+ if (!group) {
51
+ const keys = await ctx.admin.listGroupKeys()
52
+ throw new ValidationError(`No such group: ${reference}. Available: ${keys.join(', ')}`)
53
+ }
54
+ return group
55
+ }
56
+
57
+ export async function userCreate(args: readonly string[]): Promise<number> {
58
+ const { flags } = parseFlags(args)
59
+ const username = required(flags, 'username')
60
+ const email = required(flags, 'email')
61
+ const password = await readPassword(flags)
62
+
63
+ const ctx = await createContext()
64
+ const result = await ctx.identity.register({ username, email, password })
65
+
66
+ const groupRef = optional(flags, 'group')
67
+ if (groupRef !== undefined) {
68
+ const group = await findGroup(ctx, groupRef)
69
+ await ctx.admin.setPrimaryGroup(result.account.id, group.id)
70
+ console.log(`Created user ${username} (id ${result.account.id}) in group ${group.key}.`)
71
+ } else {
72
+ console.log(`Created user ${username} (id ${result.account.id}).`)
73
+ }
74
+
75
+ return 0
76
+ }
77
+
78
+ export async function userPromote(args: readonly string[]): Promise<number> {
79
+ const { flags } = parseFlags(args)
80
+ const userRef = required(flags, 'user')
81
+ const groupRef = required(flags, 'group')
82
+
83
+ const ctx = await createContext()
84
+ const user = await findUser(ctx, userRef)
85
+ const group = await findGroup(ctx, groupRef)
86
+
87
+ if (user.primaryGroupId === group.id) {
88
+ console.log(`${user.username} is already in ${group.key}. Nothing to do.`)
89
+ return 0
90
+ }
91
+
92
+ await ctx.admin.setPrimaryGroup(user.id, group.id)
93
+ console.log(`${user.username} is now in ${group.key} (${group.title}).`)
94
+ return 0
95
+ }
96
+
97
+ export async function userClearSecondFactor(args: readonly string[]): Promise<number> {
98
+ const { flags } = parseFlags(args)
99
+ const userRef = required(flags, 'user')
100
+
101
+ const ctx = await createContext()
102
+ const user = await findUser(ctx, userRef)
103
+
104
+ const removed = await clearSecondFactor(ctx.accounts, user.id)
105
+
106
+ if (!removed) {
107
+ console.log(`${user.username} holds no second factor. Nothing to do.`)
108
+ return 0
109
+ }
110
+
111
+ await ctx.accounts.sessions.revokeAllForUser(user.id)
112
+ await ctx.accounts.authEvents.record({
113
+ userId: user.id,
114
+ kind: 'second_factor_cleared',
115
+ detail: { by: 'cli' },
116
+ at: new Date(),
117
+ })
118
+
119
+ console.log(
120
+ `Cleared the second factor on ${user.username} and signed every session out. ` +
121
+ 'They sign in with their password alone until they set one up again.',
122
+ )
123
+ return 0
124
+ }
125
+
126
+ export async function forumCreate(args: readonly string[]): Promise<number> {
127
+ const { flags } = parseFlags(args)
128
+
129
+ const rawType = optional(flags, 'type') ?? 'forum'
130
+ if (!(FORUM_TYPES as readonly string[]).includes(rawType)) {
131
+ throw new ValidationError(`--type must be one of ${FORUM_TYPES.join(', ')}, got "${rawType}".`)
132
+ }
133
+
134
+ const input = {
135
+ type: rawType as ForumType,
136
+ title: required(flags, 'title'),
137
+ slug: required(flags, 'slug'),
138
+ description: optional(flags, 'description'),
139
+ parentId: integer(flags, 'parent') ?? null,
140
+ linkUrl: optional(flags, 'link-url'),
141
+ }
142
+
143
+ const ctx = await createContext()
144
+ const created = await ctx.forums.create(input)
145
+
146
+ console.log(
147
+ `Created ${created.type} "${created.title}" (id ${created.id}, path ${created.path}).`,
148
+ )
149
+ return 0
150
+ }
151
+
152
+ export function settingDisplayValue(definition: SettingDefinition, value: unknown): string {
153
+ if (definition.secret) {
154
+ return Object.is(value, definition.default) ? '<unset>' : '<set>'
155
+ }
156
+ return JSON.stringify(value)
157
+ }
158
+
159
+ export async function settingsGet(args: readonly string[]): Promise<number> {
160
+ const { positional } = parseFlags(args)
161
+ const key = positional[0]
162
+ if (key === undefined) throw new ValidationError('Usage: community settings:get <key>')
163
+
164
+ const definition = SETTING_DEFINITION_BY_KEY.get(key)
165
+ if (!definition) throw new ValidationError(`Unknown setting "${key}".`)
166
+
167
+ const ctx = await createContext()
168
+ const snapshot = SettingsSnapshot.fromOverrides(await ctx.settings.loadAll())
169
+ const value = snapshot.get(key as SettingKey)
170
+ const isDefault = Object.is(value, definition.default)
171
+ console.log(`${key} = ${settingDisplayValue(definition, value)}${isDefault ? ' (default)' : ''}`)
172
+ return 0
173
+ }
174
+
175
+ interface SecretInput {
176
+ readonly isTTY?: boolean
177
+ [Symbol.asyncIterator](): AsyncIterator<Buffer | string>
178
+ }
179
+
180
+ export async function readSecretSettingValue(
181
+ flags: Flags,
182
+ input: SecretInput,
183
+ environment: NodeJS.ProcessEnv,
184
+ ): Promise<string> {
185
+ const environmentName = flags.get('from-env')
186
+ if (environmentName !== undefined) {
187
+ if (environmentName.trim() === '' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(environmentName)) {
188
+ throw new ValidationError('--from-env needs a valid environment variable name.')
189
+ }
190
+ const value = environment[environmentName]
191
+ if (value === undefined) {
192
+ throw new ValidationError(`Environment variable ${environmentName} is not set.`)
193
+ }
194
+ return value
195
+ }
196
+
197
+ if (input.isTTY) {
198
+ throw new ValidationError('No secret supplied. Pipe it on stdin or use --from-env <name>.')
199
+ }
200
+
201
+ const chunks: Buffer[] = []
202
+ for await (const chunk of input) {
203
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
204
+ }
205
+ return Buffer.concat(chunks)
206
+ .toString('utf8')
207
+ .replace(/\r?\n$/, '')
208
+ }
209
+
210
+ export async function settingsSet(args: readonly string[]): Promise<number> {
211
+ const { flags, positional } = parseFlags(args)
212
+ const [key, positionalValue, ...extra] = positional
213
+ if (key === undefined || extra.length > 0) {
214
+ throw new ValidationError(
215
+ 'Usage: community settings:set <key> <value>\n' +
216
+ ' community settings:set <secret-key> --from-env <name>\n' +
217
+ ' printf %s "$SECRET" | community settings:set <secret-key>',
218
+ )
219
+ }
220
+
221
+ const definition = SETTING_DEFINITION_BY_KEY.get(key)
222
+ if (!definition) throw new ValidationError(`Unknown setting "${key}".`)
223
+
224
+ let raw: string
225
+ if (definition.secret) {
226
+ if (positionalValue !== undefined) {
227
+ throw new ValidationError(
228
+ 'Secret settings cannot be supplied as arguments. Pipe the value on stdin or use --from-env <name>.',
229
+ )
230
+ }
231
+ raw = await readSecretSettingValue(flags, process.stdin, process.env)
232
+ } else {
233
+ if (flags.has('from-env')) {
234
+ throw new ValidationError('--from-env is only available for secret settings.')
235
+ }
236
+ if (positionalValue === undefined) {
237
+ throw new ValidationError('Usage: community settings:set <key> <value>')
238
+ }
239
+ raw = positionalValue
240
+ }
241
+
242
+ const ctx = await createContext()
243
+ const snapshot = SettingsSnapshot.fromOverrides(await ctx.settings.loadAll())
244
+ const value = definition.secret ? raw : coerce(raw, definition)
245
+ const result = await saveSettings(ctx.settings, { [key]: value }, snapshot)
246
+ const displayed = definition.secret ? settingDisplayValue(definition, value) : raw
247
+
248
+ console.log(
249
+ result.changed.length === 0
250
+ ? `${key} was already ${displayed}. Nothing written.`
251
+ : `${key} = ${displayed}`,
252
+ )
253
+ return 0
254
+ }
255
+
256
+ function coerce(raw: string, definition: SettingDefinition): unknown {
257
+ const type = typeof definition.default
258
+ if (type === 'boolean') {
259
+ if (['1', 'true', 'yes', 'on'].includes(raw.toLowerCase())) return true
260
+ if (['0', 'false', 'no', 'off'].includes(raw.toLowerCase())) return false
261
+ throw new ValidationError(`Expected a boolean, got "${raw}".`)
262
+ }
263
+ if (type === 'number') {
264
+ const value = Number(raw)
265
+ if (Number.isNaN(value)) throw new ValidationError(`Expected a number, got "${raw}".`)
266
+ return value
267
+ }
268
+ return raw
269
+ }
package/src/context.ts ADDED
@@ -0,0 +1,77 @@
1
+ import {
2
+ type AccountStore,
3
+ type AuthConfig,
4
+ DEFAULT_AUTH_POLICY,
5
+ IdentityService,
6
+ resolveAuthPolicy,
7
+ } from '@meith/accounts'
8
+ import { ConfigurationError, env } from '@meith/core'
9
+ import {
10
+ createPostgresAccountStore,
11
+ type Database,
12
+ getDb,
13
+ PostgresAdminRepository,
14
+ PostgresForumRepository,
15
+ PostgresSettingsRepository,
16
+ } from '@meith/db'
17
+ import { SettingsSnapshot } from '@meith/settings'
18
+
19
+ export interface CliContext {
20
+ readonly db: Database
21
+ readonly accounts: AccountStore
22
+ readonly identity: IdentityService
23
+ readonly forums: PostgresForumRepository
24
+ readonly settings: PostgresSettingsRepository
25
+ readonly admin: PostgresAdminRepository
26
+ }
27
+
28
+ export function requirePostgres(): void {
29
+ if (env.DATA_SOURCE !== 'postgres') {
30
+ throw new ConfigurationError(
31
+ 'This command needs a database: DATA_SOURCE is "fixture".\n' +
32
+ 'Set DATABASE_URL (see .env.example) and run `community migrate` first.',
33
+ )
34
+ }
35
+ }
36
+
37
+ export async function defaultMemberGroupId(admin: PostgresAdminRepository): Promise<number> {
38
+ const id = await admin.registeredGroupId()
39
+ if (id === null) {
40
+ throw new ConfigurationError(
41
+ 'No "registered" usergroup found. Run `community migrate` to seed the group ladder.',
42
+ )
43
+ }
44
+ return id
45
+ }
46
+
47
+ export async function createContext(): Promise<CliContext> {
48
+ requirePostgres()
49
+ const db = getDb()
50
+ const admin = new PostgresAdminRepository(db)
51
+
52
+ const settings = new PostgresSettingsRepository(db)
53
+
54
+ const stored = SettingsSnapshot.fromOverrides(await settings.loadAll())
55
+
56
+ const config: AuthConfig = {
57
+ ...DEFAULT_AUTH_POLICY,
58
+ ...resolveAuthPolicy((key) => stored.get(key as never), {
59
+ ...DEFAULT_AUTH_POLICY,
60
+ activationMethod: 'none',
61
+ }),
62
+ registrationEnabled: true,
63
+ activationMethod: 'none',
64
+ defaultMemberGroupId: await defaultMemberGroupId(admin),
65
+ }
66
+
67
+ const store = createPostgresAccountStore(db)
68
+
69
+ return {
70
+ db,
71
+ accounts: store,
72
+ identity: new IdentityService({ store, config }),
73
+ forums: new PostgresForumRepository(db),
74
+ settings,
75
+ admin,
76
+ }
77
+ }
package/src/demo.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { ConfigurationError, env } from '@meith/core'
2
+ import { getDb } from '@meith/db'
3
+ import { resetDemoBoard, seedDemoBoard } from '@meith/demo'
4
+ import type { PluginDefinition } from '@meith/plugin-kit'
5
+
6
+ import { requirePostgres } from './context'
7
+
8
+ function requireDemoMode(): void {
9
+ if (!env.DEMO_MODE) {
10
+ throw new ConfigurationError(
11
+ 'DEMO_MODE is not set, and this command destroys the board it is pointed ' +
12
+ 'at. If this really is a demo, set DEMO_MODE=1 in the environment; if it ' +
13
+ 'is not, you have the wrong DATABASE_URL.',
14
+ )
15
+ }
16
+ }
17
+
18
+ async function plugins(): Promise<readonly PluginDefinition[]> {
19
+ const { installedPluginDefinitions } = await import('@board/plugins')
20
+ return installedPluginDefinitions()
21
+ }
22
+
23
+ export async function demoSeed(): Promise<number> {
24
+ requirePostgres()
25
+ requireDemoMode()
26
+
27
+ const summary = await seedDemoBoard(getDb(), new Date(), { plugins: await plugins() })
28
+
29
+ console.log(
30
+ `Seeded ${summary.members} member(s), ${summary.forums} forum(s), ` +
31
+ `${summary.threads} thread(s) and ${summary.posts} post(s).`,
32
+ )
33
+ if (summary.plugins.length > 0) {
34
+ console.log(`Furnished plugin(s): ${summary.plugins.join(', ')}.`)
35
+ }
36
+ return 0
37
+ }
38
+
39
+ export async function demoReset(args: readonly string[]): Promise<number> {
40
+ requirePostgres()
41
+ requireDemoMode()
42
+
43
+ if (!args.includes('--yes')) {
44
+ console.error(
45
+ 'This drops every table in the database and writes the demo board back.\n' +
46
+ 'Nothing survives it. Re-run with --yes if that is what you want.',
47
+ )
48
+ return 1
49
+ }
50
+
51
+ const result = await resetDemoBoard({ db: getDb(), plugins: await plugins() })
52
+
53
+ console.log(
54
+ `Reset in ${Math.round(result.elapsedMs / 1000)}s: ` +
55
+ `${result.migrationsApplied} migration(s), ${result.members} member(s), ` +
56
+ `${result.forums} forum(s), ${result.threads} thread(s), ${result.posts} post(s)` +
57
+ `${result.plugins.length === 0 ? '' : `, plugin(s) ${result.plugins.join(', ')}`}.`,
58
+ )
59
+ console.log('Uploaded files are untouched — the web container clears those on its own reset.')
60
+ return 0
61
+ }