@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.
- package/LICENSE.md +165 -0
- package/bin/community.mjs +117 -0
- package/package.json +47 -0
- package/src/args.ts +63 -0
- package/src/backup.ts +677 -0
- package/src/board-eject.ts +180 -0
- package/src/commands.ts +269 -0
- package/src/context.ts +77 -0
- package/src/demo.ts +61 -0
- package/src/import-files.ts +189 -0
- package/src/import.ts +142 -0
- package/src/index.ts +394 -0
- package/src/plugin-manifest.ts +176 -0
- package/src/plugins.ts +65 -0
- package/src/profile-fields.ts +107 -0
- package/src/push.ts +47 -0
- package/src/redaction.ts +23 -0
- package/src/search.ts +33 -0
- package/src/tasks.ts +69 -0
- package/src/upgrade.ts +125 -0
|
@@ -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
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -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
|
+
}
|