@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
|
|
4
|
+
import { type LoadedEnvFiles, loadEnvFiles } from '@meith/core/env-files'
|
|
5
|
+
|
|
6
|
+
import { backupCommand, restoreCommand } from './backup'
|
|
7
|
+
import {
|
|
8
|
+
forumCreate,
|
|
9
|
+
settingDisplayValue,
|
|
10
|
+
settingsGet,
|
|
11
|
+
settingsSet,
|
|
12
|
+
userClearSecondFactor,
|
|
13
|
+
userCreate,
|
|
14
|
+
userPromote,
|
|
15
|
+
} from './commands'
|
|
16
|
+
import { demoReset, demoSeed } from './demo'
|
|
17
|
+
import { importCommand } from './import'
|
|
18
|
+
import { profileFieldAdd, profileFieldList, profileFieldRemove } from './profile-fields'
|
|
19
|
+
import { pushKeys } from './push'
|
|
20
|
+
import { SECRET_ENV_KEYS } from './redaction'
|
|
21
|
+
import { searchReindex } from './search'
|
|
22
|
+
import { taskList, taskRun } from './tasks'
|
|
23
|
+
|
|
24
|
+
interface Command {
|
|
25
|
+
readonly name: string
|
|
26
|
+
readonly summary: string
|
|
27
|
+
readonly usage?: string
|
|
28
|
+
run(args: readonly string[]): Promise<number>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function usage(commands: readonly Command[]): string {
|
|
32
|
+
const width = Math.max(...commands.map((c) => c.name.length))
|
|
33
|
+
const lines = commands.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`)
|
|
34
|
+
return [
|
|
35
|
+
'community — operator CLI',
|
|
36
|
+
'',
|
|
37
|
+
'Usage: community <command> [options]',
|
|
38
|
+
'',
|
|
39
|
+
'Commands:',
|
|
40
|
+
...lines,
|
|
41
|
+
'',
|
|
42
|
+
].join('\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const commands: Command[] = [
|
|
46
|
+
{
|
|
47
|
+
name: 'env:check',
|
|
48
|
+
summary: 'Validate environment variables and print the resolved config.',
|
|
49
|
+
async run() {
|
|
50
|
+
const { assertEnv } = await import('@meith/core')
|
|
51
|
+
|
|
52
|
+
let env: ReturnType<typeof assertEnv>
|
|
53
|
+
try {
|
|
54
|
+
env = assertEnv()
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Environment is invalid:\n')
|
|
57
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const rows = Object.entries(env)
|
|
62
|
+
.filter(([, v]) => v !== undefined)
|
|
63
|
+
.map(([k, v]) => [k, SECRET_ENV_KEYS.has(k) ? '<set>' : String(v)] as const)
|
|
64
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
65
|
+
|
|
66
|
+
const width = Math.max(...rows.map(([k]) => k.length))
|
|
67
|
+
for (const [k, v] of rows) console.log(`${k.padEnd(width)} ${v}`)
|
|
68
|
+
|
|
69
|
+
console.log('\nEnvironment is valid.')
|
|
70
|
+
console.log(
|
|
71
|
+
envFiles.loaded.length > 0
|
|
72
|
+
? `Loaded ${envFiles.loaded.join(', ')} from ${envFiles.root}.`
|
|
73
|
+
: envFiles.root === undefined
|
|
74
|
+
? 'No workspace root found — configuration came from the environment.'
|
|
75
|
+
: `No .env files at ${envFiles.root} — configuration came from the environment.`,
|
|
76
|
+
)
|
|
77
|
+
if (env.DATA_SOURCE === 'fixture') {
|
|
78
|
+
console.log('DATA_SOURCE=fixture — in-memory sample data. Set DATABASE_URL for Postgres.')
|
|
79
|
+
}
|
|
80
|
+
return 0
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
name: 'migrate',
|
|
86
|
+
summary: 'Apply pending database migrations.',
|
|
87
|
+
async run() {
|
|
88
|
+
const { assertEnv } = await import('@meith/core')
|
|
89
|
+
const env = assertEnv()
|
|
90
|
+
|
|
91
|
+
if (env.DATA_SOURCE !== 'postgres') {
|
|
92
|
+
console.error('Nothing to migrate: DATA_SOURCE is "fixture". Set DATABASE_URL first.')
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { runMigrations } = await import('@meith/db')
|
|
97
|
+
const applied = await runMigrations()
|
|
98
|
+
console.log(applied === 0 ? 'Already up to date.' : `Applied ${applied} migration(s).`)
|
|
99
|
+
return 0
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
name: 'import',
|
|
105
|
+
summary: 'Import a MyBB or phpBB board. Resumable — run it again to continue.',
|
|
106
|
+
usage:
|
|
107
|
+
'IMPORT_SOURCE_PASSWORD=… forum import --host H --user U --database D ' +
|
|
108
|
+
'[--source mybb|phpbb] [--prefix mybb_] [--uploads-dir /path/to/uploads] ' +
|
|
109
|
+
'[--port 3306] [--charset utf8mb4] [--ssl] ' +
|
|
110
|
+
'[--budget 20000] [--page-size 200]',
|
|
111
|
+
async run(args: readonly string[]) {
|
|
112
|
+
const { assertEnv } = await import('@meith/core')
|
|
113
|
+
const env = assertEnv()
|
|
114
|
+
|
|
115
|
+
if (env.DATA_SOURCE !== 'postgres') {
|
|
116
|
+
console.error('Nothing to import into: DATA_SOURCE is "fixture". Set DATABASE_URL first.')
|
|
117
|
+
return 1
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return importCommand(args)
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
name: 'upgrade',
|
|
126
|
+
summary: 'Apply core and plugin migrations, then record the version.',
|
|
127
|
+
usage: 'community upgrade [--dry-run]',
|
|
128
|
+
async run(args: readonly string[]) {
|
|
129
|
+
const { assertEnv } = await import('@meith/core')
|
|
130
|
+
const env = assertEnv()
|
|
131
|
+
|
|
132
|
+
if (env.DATA_SOURCE !== 'postgres') {
|
|
133
|
+
console.error('Nothing to upgrade: DATA_SOURCE is "fixture". Set DATABASE_URL first.')
|
|
134
|
+
return 1
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { upgrade } = await import('./upgrade')
|
|
138
|
+
const { installedPluginDefinitions } = await import('@board/plugins')
|
|
139
|
+
return upgrade({
|
|
140
|
+
dryRun: args.includes('--dry-run'),
|
|
141
|
+
plugins: installedPluginDefinitions(),
|
|
142
|
+
log: (line) => console.log(line),
|
|
143
|
+
})
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
name: 'backup',
|
|
149
|
+
summary: 'Dump the database and the uploads into one restorable bundle.',
|
|
150
|
+
usage: 'community backup [--out <path>] [--uploads include|skip]',
|
|
151
|
+
run: backupCommand,
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
name: 'restore',
|
|
156
|
+
summary: 'Restore a backup bundle into a new, empty database.',
|
|
157
|
+
usage:
|
|
158
|
+
'RESTORE_DATABASE_URL=<postgres://…> community restore <bundle.tar.gz> ' +
|
|
159
|
+
'[--uploads-dir <dir>] [--skip-uploads]',
|
|
160
|
+
run: restoreCommand,
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
name: 'plugin:purge',
|
|
165
|
+
summary: 'Run a plugin’s onUninstall and remove its data. Do this before removing the code.',
|
|
166
|
+
usage: 'community plugin:purge <key> [--yes]',
|
|
167
|
+
async run(args: readonly string[]) {
|
|
168
|
+
const { assertEnv } = await import('@meith/core')
|
|
169
|
+
const env = assertEnv()
|
|
170
|
+
|
|
171
|
+
if (env.DATA_SOURCE !== 'postgres') {
|
|
172
|
+
console.error('Nothing to purge: DATA_SOURCE is "fixture". Set DATABASE_URL first.')
|
|
173
|
+
return 1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const key = args.find((arg) => !arg.startsWith('--'))
|
|
177
|
+
if (key === undefined) {
|
|
178
|
+
console.error('Usage: community plugin:purge <key> [--yes]')
|
|
179
|
+
return 1
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { purge } = await import('./plugins')
|
|
183
|
+
const { installedPluginDefinitions } = await import('@board/plugins')
|
|
184
|
+
return purge({
|
|
185
|
+
key,
|
|
186
|
+
plugins: installedPluginDefinitions(),
|
|
187
|
+
confirmed: args.includes('--yes'),
|
|
188
|
+
log: (line) => console.log(line),
|
|
189
|
+
})
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
{
|
|
194
|
+
name: 'plugin:add',
|
|
195
|
+
summary: 'Add a package to board.plugins.json and regenerate community.plugins.ts.',
|
|
196
|
+
usage: 'community plugin:add <package> [--key <key>] [--disabled]',
|
|
197
|
+
async run(args: readonly string[]) {
|
|
198
|
+
const { pluginAdd } = await import('./plugin-manifest')
|
|
199
|
+
return pluginAdd(args)
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
{
|
|
204
|
+
name: 'plugin:remove',
|
|
205
|
+
summary: 'Remove a plugin from board.plugins.json and regenerate community.plugins.ts.',
|
|
206
|
+
usage: 'community plugin:remove <key>',
|
|
207
|
+
async run(args: readonly string[]) {
|
|
208
|
+
const { pluginRemove } = await import('./plugin-manifest')
|
|
209
|
+
return pluginRemove(args)
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
{
|
|
214
|
+
name: 'board:eject',
|
|
215
|
+
summary:
|
|
216
|
+
'Write this build as a standalone workspace — the first step of graduating off the stock image.',
|
|
217
|
+
usage: 'community board:eject <dir>',
|
|
218
|
+
async run(args: readonly string[]) {
|
|
219
|
+
const { boardEject } = await import('./board-eject')
|
|
220
|
+
return boardEject(args)
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
{
|
|
225
|
+
name: 'settings:list',
|
|
226
|
+
summary: 'Print the setting registry with default values.',
|
|
227
|
+
async run() {
|
|
228
|
+
const { SETTING_DEFINITIONS } = await import('@meith/settings')
|
|
229
|
+
|
|
230
|
+
const width = Math.max(...SETTING_DEFINITIONS.map((d) => d.key.length))
|
|
231
|
+
let group = ''
|
|
232
|
+
|
|
233
|
+
for (const d of SETTING_DEFINITIONS) {
|
|
234
|
+
if (d.group !== group) {
|
|
235
|
+
group = d.group
|
|
236
|
+
console.log(`\n[${group}]`)
|
|
237
|
+
}
|
|
238
|
+
console.log(` ${d.key.padEnd(width)} ${settingDisplayValue(d, d.default)}`)
|
|
239
|
+
}
|
|
240
|
+
console.log()
|
|
241
|
+
return 0
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
name: 'user:create',
|
|
247
|
+
summary: 'Create a user account. Pipe the password in on stdin.',
|
|
248
|
+
usage:
|
|
249
|
+
'echo "<password>" | community user:create --username <name> --email <addr> [--group <key>]',
|
|
250
|
+
run: userCreate,
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
name: 'user:promote',
|
|
255
|
+
summary: "Change a user's primary group.",
|
|
256
|
+
usage: 'community user:promote --user <id|username> --group <key|id>',
|
|
257
|
+
run: userPromote,
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
{
|
|
261
|
+
name: 'user:2fa-clear',
|
|
262
|
+
summary: "Clear a user's second factor when they have lost it, and sign them out.",
|
|
263
|
+
usage: 'community user:2fa-clear --user <id|username>',
|
|
264
|
+
run: userClearSecondFactor,
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
{
|
|
268
|
+
name: 'forum:create',
|
|
269
|
+
summary: 'Create a category, forum or link.',
|
|
270
|
+
usage:
|
|
271
|
+
'community forum:create --title <title> --slug <slug> [--parent <id>] ' +
|
|
272
|
+
'[--type category|forum|link] [--description <text>] [--link-url <url>]',
|
|
273
|
+
run: forumCreate,
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
{
|
|
277
|
+
name: 'settings:get',
|
|
278
|
+
summary: 'Print one resolved setting value.',
|
|
279
|
+
usage: 'community settings:get <key>',
|
|
280
|
+
run: settingsGet,
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
{
|
|
284
|
+
name: 'settings:set',
|
|
285
|
+
summary: 'Set one setting, validated by the registry.',
|
|
286
|
+
usage:
|
|
287
|
+
'community settings:set <key> <value>\n' +
|
|
288
|
+
'community settings:set <secret-key> --from-env <name>\n' +
|
|
289
|
+
'printf %s "$SECRET" | community settings:set <secret-key>',
|
|
290
|
+
run: settingsSet,
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
{
|
|
294
|
+
name: 'profile-field:list',
|
|
295
|
+
summary: 'List the custom profile fields this board defines.',
|
|
296
|
+
run: profileFieldList,
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
{
|
|
300
|
+
name: 'profile-field:add',
|
|
301
|
+
summary: 'Define a custom profile field.',
|
|
302
|
+
usage:
|
|
303
|
+
'community profile-field:add --key <key> --label <label> ' +
|
|
304
|
+
'--type text|textarea|select|checkbox|url|number ' +
|
|
305
|
+
'[--options a,b,c] [--required] [--postbit] [--order <n>]',
|
|
306
|
+
run: profileFieldAdd,
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
{
|
|
310
|
+
name: 'profile-field:remove',
|
|
311
|
+
summary: "Delete a custom profile field and every member's answer to it.",
|
|
312
|
+
usage: 'community profile-field:remove <key>',
|
|
313
|
+
run: profileFieldRemove,
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
{
|
|
317
|
+
name: 'push:keys',
|
|
318
|
+
summary: 'Generate a VAPID key pair for web push. --save writes it to the board.',
|
|
319
|
+
usage: 'community push:keys [--save]',
|
|
320
|
+
run: pushKeys,
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
{
|
|
324
|
+
name: 'task:list',
|
|
325
|
+
summary: 'List the scheduled tasks this build registers.',
|
|
326
|
+
run: taskList,
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
{
|
|
330
|
+
name: 'task:run',
|
|
331
|
+
summary: 'Run every task that is due now, or one named task if it is due.',
|
|
332
|
+
usage: 'community task:run [<task-id>]',
|
|
333
|
+
run: taskRun,
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
{
|
|
337
|
+
name: 'search:reindex',
|
|
338
|
+
summary: 'Build the full-text index for posts that have none. Resumable.',
|
|
339
|
+
run: searchReindex,
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
{
|
|
343
|
+
name: 'demo:seed',
|
|
344
|
+
summary: 'Write the demo board into an empty database. Needs DEMO_MODE.',
|
|
345
|
+
run: demoSeed,
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
{
|
|
349
|
+
name: 'demo:reset',
|
|
350
|
+
summary: 'Drop everything and rebuild the demo board. Needs DEMO_MODE.',
|
|
351
|
+
usage: 'community demo:reset --yes',
|
|
352
|
+
run: demoReset,
|
|
353
|
+
},
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
let envFiles: LoadedEnvFiles = { root: undefined, loaded: [] }
|
|
357
|
+
|
|
358
|
+
async function main(): Promise<number> {
|
|
359
|
+
envFiles = loadEnvFiles()
|
|
360
|
+
|
|
361
|
+
const [name, ...rest] = process.argv.slice(2)
|
|
362
|
+
|
|
363
|
+
if (!name || name === '--help' || name === '-h') {
|
|
364
|
+
console.log(usage(commands))
|
|
365
|
+
return name ? 0 : 1
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const command = commands.find((c) => c.name === name)
|
|
369
|
+
if (!command) {
|
|
370
|
+
console.error(`Unknown command: ${name}\n`)
|
|
371
|
+
console.error(usage(commands))
|
|
372
|
+
return 1
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (rest.includes('--help')) {
|
|
376
|
+
console.log(command.usage ?? `community ${command.name}`)
|
|
377
|
+
return 0
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return command.run(rest)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
main()
|
|
384
|
+
.then((code) => process.exit(code))
|
|
385
|
+
.catch(async (error: unknown) => {
|
|
386
|
+
const { isAppError } = await import('@meith/core')
|
|
387
|
+
|
|
388
|
+
if (isAppError(error)) {
|
|
389
|
+
console.error(error.message)
|
|
390
|
+
} else {
|
|
391
|
+
console.error(error instanceof Error ? error.stack : String(error))
|
|
392
|
+
}
|
|
393
|
+
process.exit(1)
|
|
394
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
import { ValidationError } from '@meith/core'
|
|
7
|
+
|
|
8
|
+
import { optional, parseFlags } from './args'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* apps/cli/src/plugin-manifest.ts and scripts/board-plugins-gen.mjs are the same
|
|
12
|
+
* distance from the repository root (apps/cli/{src,dist}/<file> either way), so this
|
|
13
|
+
* offset holds whether these commands run from source (tsx) or the built dist/cli.cjs.
|
|
14
|
+
*/
|
|
15
|
+
const ROOT = fileURLToPath(new URL('../../../', import.meta.url))
|
|
16
|
+
const MANIFEST_PATH = join(ROOT, 'apps/community/board.plugins.json')
|
|
17
|
+
const GENERATOR_SCRIPT = join(ROOT, 'scripts/board-plugins-gen.mjs')
|
|
18
|
+
|
|
19
|
+
interface ManifestEntry {
|
|
20
|
+
readonly key: string
|
|
21
|
+
readonly package: string
|
|
22
|
+
readonly enabled?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Manifest {
|
|
26
|
+
readonly plugins: readonly ManifestEntry[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `plugin:add`/`plugin:remove` edit source files and rebuild output that only exists in
|
|
31
|
+
* a checkout — unlike `plugin:purge`, which acts on a running board's database and is the
|
|
32
|
+
* one meant to run as `docker compose run --rm web community plugin:purge`. The deployed
|
|
33
|
+
* image is built `FROM node:26-alpine` with only `.next/standalone`, the worker and this
|
|
34
|
+
* CLI's own bundle copied in (see docker/Dockerfile) — no `scripts/`, no `board.plugins.json`,
|
|
35
|
+
* no Biome. Reading the manifest is where that shows up first, so this is where it is named.
|
|
36
|
+
*/
|
|
37
|
+
async function readManifest(): Promise<Manifest> {
|
|
38
|
+
let raw: string
|
|
39
|
+
try {
|
|
40
|
+
raw = await readFile(MANIFEST_PATH, 'utf8')
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
43
|
+
throw new ValidationError(
|
|
44
|
+
`${MANIFEST_PATH} does not exist. This command edits source files and reruns the ` +
|
|
45
|
+
'generator, so it needs a checkout of the repository, not the deployed image — run ' +
|
|
46
|
+
'it where you would run `pnpm add`, commit board.plugins.json and ' +
|
|
47
|
+
'community.plugins.ts, then rebuild and redeploy.',
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parsed = JSON.parse(raw) as Partial<Manifest>
|
|
54
|
+
if (!Array.isArray(parsed.plugins)) {
|
|
55
|
+
throw new ValidationError('apps/community/board.plugins.json must have a "plugins" array.')
|
|
56
|
+
}
|
|
57
|
+
return { plugins: parsed.plugins }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function writeManifest(manifest: Manifest): Promise<void> {
|
|
61
|
+
await writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface GeneratorResult {
|
|
65
|
+
readonly ok: boolean
|
|
66
|
+
readonly output: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The one thing this file trusts to know whether a manifest edit is valid: the same
|
|
71
|
+
* generator `pnpm board:gen` runs. Shelling out — rather than importing
|
|
72
|
+
* scripts/board-plugins.mjs — keeps a plain script and a workspace TypeScript package
|
|
73
|
+
* from needing to share a module; it also means a failed add or remove is reported in
|
|
74
|
+
* exactly the words a person typing `pnpm board:gen` themselves would see.
|
|
75
|
+
*/
|
|
76
|
+
function runGenerator(): GeneratorResult {
|
|
77
|
+
try {
|
|
78
|
+
const output = execFileSync(process.execPath, [GENERATOR_SCRIPT], {
|
|
79
|
+
encoding: 'utf8',
|
|
80
|
+
stdio: 'pipe',
|
|
81
|
+
})
|
|
82
|
+
return { ok: true, output }
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const failure = error as { stdout?: string; stderr?: string; message: string }
|
|
85
|
+
const output = [failure.stdout, failure.stderr].filter(Boolean).join('\n').trim()
|
|
86
|
+
return { ok: false, output: output === '' ? failure.message : output }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const PACKAGE_KEY_PATTERN = /^@[^/]+\/plugin-([a-z][a-z0-9-]*)$/
|
|
91
|
+
|
|
92
|
+
/** `@scope/plugin-<key>` is the only shape a key can be read from without asking. */
|
|
93
|
+
export function inferKey(packageName: string): string | undefined {
|
|
94
|
+
return PACKAGE_KEY_PATTERN.exec(packageName)?.[1]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ADD_FLAGS = new Set(['key', 'disabled'])
|
|
98
|
+
|
|
99
|
+
export async function pluginAdd(args: readonly string[]): Promise<number> {
|
|
100
|
+
const { flags, positional } = parseFlags(args)
|
|
101
|
+
const [packageName, ...extraPositional] = positional
|
|
102
|
+
|
|
103
|
+
if (packageName === undefined || extraPositional.length > 0) {
|
|
104
|
+
throw new ValidationError('Usage: community plugin:add <package> [--key <key>] [--disabled]')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const configFlags = [...flags.keys()].filter((name) => !ADD_FLAGS.has(name))
|
|
108
|
+
if (configFlags.length > 0) {
|
|
109
|
+
throw new ValidationError(
|
|
110
|
+
`community plugin:add does not take plugin configuration (--${configFlags[0]}). The ` +
|
|
111
|
+
"manifest has no field for it — a plugin's own settings are the only place its " +
|
|
112
|
+
'configuration lives now, the way plugins/dues moved its plans there. Export a ' +
|
|
113
|
+
'zero-argument plugin and add it with just its package name.',
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const key = optional(flags, 'key') ?? inferKey(packageName)
|
|
118
|
+
if (key === undefined) {
|
|
119
|
+
throw new ValidationError(
|
|
120
|
+
`Can't infer a plugin key from "${packageName}". Pass --key <key> — it must be the same ` +
|
|
121
|
+
"key the package's plugin declares, or defineForumConfig refuses it at build time.",
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const manifest = await readManifest()
|
|
126
|
+
if (manifest.plugins.some((entry) => entry.key === key)) {
|
|
127
|
+
throw new ValidationError(`"${key}" is already in board.plugins.json.`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const entry: ManifestEntry = { key, package: packageName, enabled: !flags.has('disabled') }
|
|
131
|
+
await writeManifest({ plugins: [...manifest.plugins, entry] })
|
|
132
|
+
|
|
133
|
+
const result = runGenerator()
|
|
134
|
+
if (!result.ok) {
|
|
135
|
+
await writeManifest(manifest)
|
|
136
|
+
throw new ValidationError(`Could not add "${key}":\n\n${result.output}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(
|
|
140
|
+
`Added "${key}" (${packageName}${entry.enabled ? '' : ', disabled'}) to board.plugins.json ` +
|
|
141
|
+
'and regenerated community.plugins.ts.',
|
|
142
|
+
)
|
|
143
|
+
console.log('Rebuild and redeploy for it to take effect.')
|
|
144
|
+
return 0
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function pluginRemove(args: readonly string[]): Promise<number> {
|
|
148
|
+
const { positional } = parseFlags(args)
|
|
149
|
+
const [key, ...extraPositional] = positional
|
|
150
|
+
|
|
151
|
+
if (key === undefined || extraPositional.length > 0) {
|
|
152
|
+
throw new ValidationError('Usage: community plugin:remove <key>')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const manifest = await readManifest()
|
|
156
|
+
if (!manifest.plugins.some((entry) => entry.key === key)) {
|
|
157
|
+
const present = manifest.plugins.map((entry) => entry.key)
|
|
158
|
+
throw new ValidationError(
|
|
159
|
+
present.length === 0
|
|
160
|
+
? `"${key}" is not in board.plugins.json — it lists no plugins.`
|
|
161
|
+
: `"${key}" is not in board.plugins.json. Present: ${present.join(', ')}.`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await writeManifest({ plugins: manifest.plugins.filter((entry) => entry.key !== key) })
|
|
166
|
+
|
|
167
|
+
const result = runGenerator()
|
|
168
|
+
if (!result.ok) {
|
|
169
|
+
await writeManifest(manifest)
|
|
170
|
+
throw new ValidationError(`Could not remove "${key}":\n\n${result.output}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log(`Removed "${key}" from board.plugins.json and regenerated community.plugins.ts.`)
|
|
174
|
+
console.log('Rebuild and redeploy for it to take effect.')
|
|
175
|
+
return 0
|
|
176
|
+
}
|
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getDb, pluginOwnedTables, purgePlugin } from '@meith/db'
|
|
2
|
+
import type { PluginDefinition } from '@meith/plugin-kit'
|
|
3
|
+
import { runPluginLifecycle } from '@meith/runtime'
|
|
4
|
+
|
|
5
|
+
export interface PurgeOptions {
|
|
6
|
+
readonly key: string
|
|
7
|
+
readonly plugins: readonly PluginDefinition[]
|
|
8
|
+
readonly confirmed: boolean
|
|
9
|
+
readonly log: (line: string) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one moment `onUninstall` can run: the operator has decided the plugin is
|
|
14
|
+
* going, and its code is still in the build. Afterwards there is nothing left
|
|
15
|
+
* to call — removing a plugin is `pnpm remove`, a line out of
|
|
16
|
+
* `community.plugins.ts` and a redeploy, and by then the function is gone.
|
|
17
|
+
*/
|
|
18
|
+
export async function purge(options: PurgeOptions): Promise<number> {
|
|
19
|
+
const definition = options.plugins.find((plugin) => plugin.key === options.key)
|
|
20
|
+
|
|
21
|
+
if (definition === undefined) {
|
|
22
|
+
options.log(`No plugin named "${options.key}" is in this build.`)
|
|
23
|
+
options.log('')
|
|
24
|
+
options.log('Purging runs the plugin’s own onUninstall before dropping its data, so it')
|
|
25
|
+
options.log('has to happen while the code is still installed. Put the plugin back into')
|
|
26
|
+
options.log('community.plugins.ts, deploy, purge, and then remove it.')
|
|
27
|
+
return 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const db = getDb()
|
|
31
|
+
const tables = await pluginOwnedTables(db, options.key)
|
|
32
|
+
|
|
33
|
+
if (!options.confirmed) {
|
|
34
|
+
options.log(`Would purge "${options.key}":`)
|
|
35
|
+
options.log(
|
|
36
|
+
definition.onUninstall === undefined
|
|
37
|
+
? ' - it declares no onUninstall'
|
|
38
|
+
: ' - run its onUninstall',
|
|
39
|
+
)
|
|
40
|
+
options.log(tables.length === 0 ? ' - no tables of its own' : ` - drop ${tables.join(', ')}`)
|
|
41
|
+
options.log(' - delete its settings, migration records, navigation items and health row')
|
|
42
|
+
options.log('')
|
|
43
|
+
options.log('This cannot be undone. Run again with --yes to do it.')
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { ran } = await runPluginLifecycle({ db, plugin: definition, phase: 'uninstall' })
|
|
48
|
+
if (ran) options.log(`${options.key}: onUninstall.`)
|
|
49
|
+
|
|
50
|
+
const result = await purgePlugin(db, options.key)
|
|
51
|
+
|
|
52
|
+
options.log(
|
|
53
|
+
result.tables.length === 0
|
|
54
|
+
? `Purged "${options.key}": it had no tables of its own.`
|
|
55
|
+
: `Purged "${options.key}": dropped ${result.tables.join(', ')}.`,
|
|
56
|
+
)
|
|
57
|
+
options.log(
|
|
58
|
+
`Removed ${result.settings} setting(s), ${result.migrations} migration record(s), ` +
|
|
59
|
+
`${result.navigation} navigation item(s).`,
|
|
60
|
+
)
|
|
61
|
+
options.log('')
|
|
62
|
+
options.log('Now take it out of community.plugins.ts, pnpm remove it, and redeploy.')
|
|
63
|
+
|
|
64
|
+
return 0
|
|
65
|
+
}
|