@meith/cli 0.26.1 → 0.28.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/bin/{community.mjs → meith.mjs} +2 -2
- package/package.json +15 -15
- package/src/backup.ts +1 -1
- package/src/board-eject.ts +2 -2
- package/src/commands.ts +7 -7
- package/src/context.ts +2 -2
- package/src/import.ts +1 -1
- package/src/index.ts +26 -27
- package/src/plugin-manifest.ts +236 -49
- package/src/profile-fields.ts +2 -2
- package/src/tasks.ts +1 -3
- package/src/upgrade.ts +1 -1
|
@@ -11,7 +11,7 @@ const workspaceRoot = process.cwd()
|
|
|
11
11
|
const materializedDir = join(workspaceRoot, '.meith', 'cli')
|
|
12
12
|
|
|
13
13
|
function fail(message) {
|
|
14
|
-
console.error(`
|
|
14
|
+
console.error(`meith: ${message}`)
|
|
15
15
|
process.exit(1)
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -26,7 +26,7 @@ function materialize() {
|
|
|
26
26
|
|
|
27
27
|
if (!existsSync(boardConfig)) {
|
|
28
28
|
fail(
|
|
29
|
-
`no meith.config.ts in ${workspaceRoot}. Run
|
|
29
|
+
`no meith.config.ts in ${workspaceRoot}. Run meith from a board's own ` +
|
|
30
30
|
'directory — the one create-meith scaffolded, or one shaped like it.',
|
|
31
31
|
)
|
|
32
32
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The operator CLI: migrations, backup and restore, imports, users and settings — and the
|
|
3
|
+
"version": "0.28.0",
|
|
4
|
+
"description": "The operator CLI: migrations, backup and restore, imports, users and settings — and the meith bin that runs it against an external board workspace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"type": "module",
|
|
12
12
|
"bin": {
|
|
13
|
-
"
|
|
13
|
+
"meith": "./bin/meith.mjs"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"bin",
|
|
@@ -23,18 +23,18 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"tsx": "^4.23.12",
|
|
26
|
-
"@meith/accounts": "0.
|
|
27
|
-
"@meith/core": "0.
|
|
28
|
-
"@meith/db": "0.
|
|
29
|
-
"@meith/demo": "0.
|
|
30
|
-
"@meith/drivers": "0.
|
|
31
|
-
"@meith/forums": "0.
|
|
32
|
-
"@meith/plugin-kit": "0.
|
|
33
|
-
"@meith/profile-fields": "0.
|
|
34
|
-
"@meith/runtime": "0.
|
|
35
|
-
"@meith/settings": "0.
|
|
36
|
-
"@meith/tasks": "0.
|
|
37
|
-
"create-meith": "0.
|
|
26
|
+
"@meith/accounts": "0.28.0",
|
|
27
|
+
"@meith/core": "0.28.0",
|
|
28
|
+
"@meith/db": "0.28.0",
|
|
29
|
+
"@meith/demo": "0.28.0",
|
|
30
|
+
"@meith/drivers": "0.28.0",
|
|
31
|
+
"@meith/forums": "0.28.0",
|
|
32
|
+
"@meith/plugin-kit": "0.28.0",
|
|
33
|
+
"@meith/profile-fields": "0.28.0",
|
|
34
|
+
"@meith/runtime": "0.28.0",
|
|
35
|
+
"@meith/settings": "0.28.0",
|
|
36
|
+
"@meith/tasks": "0.28.0",
|
|
37
|
+
"create-meith": "0.28.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"esbuild": "^0.28.2"
|
package/src/backup.ts
CHANGED
|
@@ -674,7 +674,7 @@ async function pushUploadsToStore(
|
|
|
674
674
|
}
|
|
675
675
|
|
|
676
676
|
const RESTORE_USAGE =
|
|
677
|
-
'Usage: RESTORE_DATABASE_URL=<postgres://…>
|
|
677
|
+
'Usage: RESTORE_DATABASE_URL=<postgres://…> meith restore <bundle.tar.gz> ' +
|
|
678
678
|
'[--uploads-dir <dir>] [--skip-uploads]'
|
|
679
679
|
|
|
680
680
|
export function restoreDatabaseUrl(
|
package/src/board-eject.ts
CHANGED
|
@@ -101,7 +101,7 @@ async function readManifest(): Promise<Manifest> {
|
|
|
101
101
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
102
102
|
throw new ValidationError(
|
|
103
103
|
`board:eject could not find this build's plugin manifest at ${path}. This command ` +
|
|
104
|
-
'runs inside the official image (docker compose run --rm web
|
|
104
|
+
'runs inside the official image (docker compose run --rm web meith board:eject ' +
|
|
105
105
|
'<dir>), where it is baked in — or, in this repository, against boards/stock. It is ' +
|
|
106
106
|
'not meant to run against a workspace board:eject already produced.',
|
|
107
107
|
)
|
|
@@ -179,7 +179,7 @@ export async function boardEject(args: readonly string[]): Promise<number> {
|
|
|
179
179
|
const [dir] = positional
|
|
180
180
|
|
|
181
181
|
if (dir === undefined || positional.length > 1) {
|
|
182
|
-
throw new ValidationError('Usage:
|
|
182
|
+
throw new ValidationError('Usage: meith board:eject <dir>')
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
const target = resolve(process.cwd(), dir)
|
package/src/commands.ts
CHANGED
|
@@ -17,7 +17,7 @@ async function readPassword(flags: Flags): Promise<string> {
|
|
|
17
17
|
if (inline !== undefined) {
|
|
18
18
|
console.warn(
|
|
19
19
|
'warning: --password is visible in shell history and to `ps`. ' +
|
|
20
|
-
'Prefer: echo "secret" |
|
|
20
|
+
'Prefer: echo "secret" | meith user:create --username u --email e@x.com',
|
|
21
21
|
)
|
|
22
22
|
return inline
|
|
23
23
|
}
|
|
@@ -25,7 +25,7 @@ async function readPassword(flags: Flags): Promise<string> {
|
|
|
25
25
|
if (process.stdin.isTTY) {
|
|
26
26
|
throw new ValidationError(
|
|
27
27
|
'No password supplied. Pipe one in:\n' +
|
|
28
|
-
' echo "correct horse battery staple" |
|
|
28
|
+
' echo "correct horse battery staple" | meith user:create --username u --email u@example.com',
|
|
29
29
|
)
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -159,7 +159,7 @@ export function settingDisplayValue(definition: SettingDefinition, value: unknow
|
|
|
159
159
|
export async function settingsGet(args: readonly string[]): Promise<number> {
|
|
160
160
|
const { positional } = parseFlags(args)
|
|
161
161
|
const key = positional[0]
|
|
162
|
-
if (key === undefined) throw new ValidationError('Usage:
|
|
162
|
+
if (key === undefined) throw new ValidationError('Usage: meith settings:get <key>')
|
|
163
163
|
|
|
164
164
|
const definition = SETTING_DEFINITION_BY_KEY.get(key)
|
|
165
165
|
if (!definition) throw new ValidationError(`Unknown setting "${key}".`)
|
|
@@ -212,9 +212,9 @@ export async function settingsSet(args: readonly string[]): Promise<number> {
|
|
|
212
212
|
const [key, positionalValue, ...extra] = positional
|
|
213
213
|
if (key === undefined || extra.length > 0) {
|
|
214
214
|
throw new ValidationError(
|
|
215
|
-
'Usage:
|
|
216
|
-
'
|
|
217
|
-
' printf %s "$SECRET" |
|
|
215
|
+
'Usage: meith settings:set <key> <value>\n' +
|
|
216
|
+
' meith settings:set <secret-key> --from-env <name>\n' +
|
|
217
|
+
' printf %s "$SECRET" | meith settings:set <secret-key>',
|
|
218
218
|
)
|
|
219
219
|
}
|
|
220
220
|
|
|
@@ -234,7 +234,7 @@ export async function settingsSet(args: readonly string[]): Promise<number> {
|
|
|
234
234
|
throw new ValidationError('--from-env is only available for secret settings.')
|
|
235
235
|
}
|
|
236
236
|
if (positionalValue === undefined) {
|
|
237
|
-
throw new ValidationError('Usage:
|
|
237
|
+
throw new ValidationError('Usage: meith settings:set <key> <value>')
|
|
238
238
|
}
|
|
239
239
|
raw = positionalValue
|
|
240
240
|
}
|
package/src/context.ts
CHANGED
|
@@ -30,7 +30,7 @@ export function requirePostgres(): void {
|
|
|
30
30
|
if (env.DATA_SOURCE !== 'postgres') {
|
|
31
31
|
throw new ConfigurationError(
|
|
32
32
|
'This command needs a database: DATA_SOURCE is "fixture".\n' +
|
|
33
|
-
'Set DATABASE_URL (see .env.example) and run `
|
|
33
|
+
'Set DATABASE_URL (see .env.example) and run `meith migrate` first.',
|
|
34
34
|
)
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -39,7 +39,7 @@ export async function defaultMemberGroupId(admin: PostgresAdminRepository): Prom
|
|
|
39
39
|
const id = await admin.registeredGroupId()
|
|
40
40
|
if (id === null) {
|
|
41
41
|
throw new ConfigurationError(
|
|
42
|
-
'No "registered" usergroup found. Run `
|
|
42
|
+
'No "registered" usergroup found. Run `meith migrate` to seed the group ladder.',
|
|
43
43
|
)
|
|
44
44
|
}
|
|
45
45
|
return id
|
package/src/import.ts
CHANGED
|
@@ -125,7 +125,7 @@ function print(report: ImportReport, copiedFiles: boolean): void {
|
|
|
125
125
|
console.log('')
|
|
126
126
|
if (report.finished) {
|
|
127
127
|
console.log(
|
|
128
|
-
'Import complete. Run `
|
|
128
|
+
'Import complete. Run `meith task:run counters.reconcile` before opening the board.',
|
|
129
129
|
)
|
|
130
130
|
if (!copiedFiles) {
|
|
131
131
|
console.log(
|
package/src/index.ts
CHANGED
|
@@ -32,9 +32,9 @@ function usage(commands: readonly Command[]): string {
|
|
|
32
32
|
const width = Math.max(...commands.map((c) => c.name.length))
|
|
33
33
|
const lines = commands.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`)
|
|
34
34
|
return [
|
|
35
|
-
'
|
|
35
|
+
'meith — operator CLI',
|
|
36
36
|
'',
|
|
37
|
-
'Usage:
|
|
37
|
+
'Usage: meith <command> [options]',
|
|
38
38
|
'',
|
|
39
39
|
'Commands:',
|
|
40
40
|
...lines,
|
|
@@ -85,7 +85,7 @@ const commands: Command[] = [
|
|
|
85
85
|
name: 'migrate',
|
|
86
86
|
summary: 'Apply pending database migrations.',
|
|
87
87
|
usage: [
|
|
88
|
-
'
|
|
88
|
+
'meith migrate',
|
|
89
89
|
'',
|
|
90
90
|
'Applies every migration this release has that the board has not, then exits.',
|
|
91
91
|
'Safe to run concurrently: the runner holds a Postgres advisory lock for the',
|
|
@@ -143,7 +143,7 @@ const commands: Command[] = [
|
|
|
143
143
|
{
|
|
144
144
|
name: 'upgrade',
|
|
145
145
|
summary: 'Apply core and plugin migrations, then record the version.',
|
|
146
|
-
usage: '
|
|
146
|
+
usage: 'meith upgrade [--dry-run]',
|
|
147
147
|
async run(args: readonly string[]) {
|
|
148
148
|
const { assertEnv } = await import('@meith/core')
|
|
149
149
|
const env = assertEnv()
|
|
@@ -166,7 +166,7 @@ const commands: Command[] = [
|
|
|
166
166
|
{
|
|
167
167
|
name: 'backup',
|
|
168
168
|
summary: 'Dump the database and the uploads into one restorable bundle.',
|
|
169
|
-
usage: '
|
|
169
|
+
usage: 'meith backup [--out <path>] [--uploads include|skip]',
|
|
170
170
|
run: backupCommand,
|
|
171
171
|
},
|
|
172
172
|
|
|
@@ -174,7 +174,7 @@ const commands: Command[] = [
|
|
|
174
174
|
name: 'restore',
|
|
175
175
|
summary: 'Restore a backup bundle into a new, empty database.',
|
|
176
176
|
usage:
|
|
177
|
-
'RESTORE_DATABASE_URL=<postgres://…>
|
|
177
|
+
'RESTORE_DATABASE_URL=<postgres://…> meith restore <bundle.tar.gz> ' +
|
|
178
178
|
'[--uploads-dir <dir>] [--skip-uploads]',
|
|
179
179
|
run: restoreCommand,
|
|
180
180
|
},
|
|
@@ -182,7 +182,7 @@ const commands: Command[] = [
|
|
|
182
182
|
{
|
|
183
183
|
name: 'plugin:purge',
|
|
184
184
|
summary: 'Run a plugin’s onUninstall and remove its data. Do this before removing the code.',
|
|
185
|
-
usage: '
|
|
185
|
+
usage: 'meith plugin:purge <key> [--yes]',
|
|
186
186
|
async run(args: readonly string[]) {
|
|
187
187
|
const { assertEnv } = await import('@meith/core')
|
|
188
188
|
const env = assertEnv()
|
|
@@ -194,7 +194,7 @@ const commands: Command[] = [
|
|
|
194
194
|
|
|
195
195
|
const key = args.find((arg) => !arg.startsWith('--'))
|
|
196
196
|
if (key === undefined) {
|
|
197
|
-
console.error('Usage:
|
|
197
|
+
console.error('Usage: meith plugin:purge <key> [--yes]')
|
|
198
198
|
return 1
|
|
199
199
|
}
|
|
200
200
|
|
|
@@ -211,8 +211,8 @@ const commands: Command[] = [
|
|
|
211
211
|
|
|
212
212
|
{
|
|
213
213
|
name: 'plugin:add',
|
|
214
|
-
summary: '
|
|
215
|
-
usage: '
|
|
214
|
+
summary: 'Install a plugin, add it to board.plugins.json, and regenerate meith.plugins.ts.',
|
|
215
|
+
usage: 'meith plugin:add <package> [--key <key>] [--disabled]',
|
|
216
216
|
async run(args: readonly string[]) {
|
|
217
217
|
const { pluginAdd } = await import('./plugin-manifest')
|
|
218
218
|
return pluginAdd(args)
|
|
@@ -222,7 +222,7 @@ const commands: Command[] = [
|
|
|
222
222
|
{
|
|
223
223
|
name: 'plugin:remove',
|
|
224
224
|
summary: 'Remove a plugin from board.plugins.json and regenerate meith.plugins.ts.',
|
|
225
|
-
usage: '
|
|
225
|
+
usage: 'meith plugin:remove <key>',
|
|
226
226
|
async run(args: readonly string[]) {
|
|
227
227
|
const { pluginRemove } = await import('./plugin-manifest')
|
|
228
228
|
return pluginRemove(args)
|
|
@@ -233,7 +233,7 @@ const commands: Command[] = [
|
|
|
233
233
|
name: 'board:eject',
|
|
234
234
|
summary:
|
|
235
235
|
'Write this build as a standalone workspace — the first step of graduating off the stock image.',
|
|
236
|
-
usage: '
|
|
236
|
+
usage: 'meith board:eject <dir>',
|
|
237
237
|
async run(args: readonly string[]) {
|
|
238
238
|
const { boardEject } = await import('./board-eject')
|
|
239
239
|
return boardEject(args)
|
|
@@ -264,22 +264,21 @@ const commands: Command[] = [
|
|
|
264
264
|
{
|
|
265
265
|
name: 'user:create',
|
|
266
266
|
summary: 'Create a user account. Pipe the password in on stdin.',
|
|
267
|
-
usage:
|
|
268
|
-
'echo "<password>" | community user:create --username <name> --email <addr> [--group <key>]',
|
|
267
|
+
usage: 'echo "<password>" | meith user:create --username <name> --email <addr> [--group <key>]',
|
|
269
268
|
run: userCreate,
|
|
270
269
|
},
|
|
271
270
|
|
|
272
271
|
{
|
|
273
272
|
name: 'user:promote',
|
|
274
273
|
summary: "Change a user's primary group.",
|
|
275
|
-
usage: '
|
|
274
|
+
usage: 'meith user:promote --user <id|username> --group <key|id>',
|
|
276
275
|
run: userPromote,
|
|
277
276
|
},
|
|
278
277
|
|
|
279
278
|
{
|
|
280
279
|
name: 'user:2fa-clear',
|
|
281
280
|
summary: "Clear a user's second factor when they have lost it, and sign them out.",
|
|
282
|
-
usage: '
|
|
281
|
+
usage: 'meith user:2fa-clear --user <id|username>',
|
|
283
282
|
run: userClearSecondFactor,
|
|
284
283
|
},
|
|
285
284
|
|
|
@@ -287,7 +286,7 @@ const commands: Command[] = [
|
|
|
287
286
|
name: 'forum:create',
|
|
288
287
|
summary: 'Create a category, forum or link.',
|
|
289
288
|
usage:
|
|
290
|
-
'
|
|
289
|
+
'meith forum:create --title <title> --slug <slug> [--parent <id>] ' +
|
|
291
290
|
'[--type category|forum|link] [--description <text>] [--link-url <url>]',
|
|
292
291
|
run: forumCreate,
|
|
293
292
|
},
|
|
@@ -295,7 +294,7 @@ const commands: Command[] = [
|
|
|
295
294
|
{
|
|
296
295
|
name: 'settings:get',
|
|
297
296
|
summary: 'Print one resolved setting value.',
|
|
298
|
-
usage: '
|
|
297
|
+
usage: 'meith settings:get <key>',
|
|
299
298
|
run: settingsGet,
|
|
300
299
|
},
|
|
301
300
|
|
|
@@ -303,9 +302,9 @@ const commands: Command[] = [
|
|
|
303
302
|
name: 'settings:set',
|
|
304
303
|
summary: 'Set one setting, validated by the registry.',
|
|
305
304
|
usage:
|
|
306
|
-
'
|
|
307
|
-
'
|
|
308
|
-
'printf %s "$SECRET" |
|
|
305
|
+
'meith settings:set <key> <value>\n' +
|
|
306
|
+
'meith settings:set <secret-key> --from-env <name>\n' +
|
|
307
|
+
'printf %s "$SECRET" | meith settings:set <secret-key>',
|
|
309
308
|
run: settingsSet,
|
|
310
309
|
},
|
|
311
310
|
|
|
@@ -319,7 +318,7 @@ const commands: Command[] = [
|
|
|
319
318
|
name: 'profile-field:add',
|
|
320
319
|
summary: 'Define a custom profile field.',
|
|
321
320
|
usage:
|
|
322
|
-
'
|
|
321
|
+
'meith profile-field:add --key <key> --label <label> ' +
|
|
323
322
|
'--type text|textarea|select|checkbox|url|number ' +
|
|
324
323
|
'[--options a,b,c] [--required] [--postbit] [--order <n>]',
|
|
325
324
|
run: profileFieldAdd,
|
|
@@ -328,14 +327,14 @@ const commands: Command[] = [
|
|
|
328
327
|
{
|
|
329
328
|
name: 'profile-field:remove',
|
|
330
329
|
summary: "Delete a custom profile field and every member's answer to it.",
|
|
331
|
-
usage: '
|
|
330
|
+
usage: 'meith profile-field:remove <key>',
|
|
332
331
|
run: profileFieldRemove,
|
|
333
332
|
},
|
|
334
333
|
|
|
335
334
|
{
|
|
336
335
|
name: 'push:keys',
|
|
337
336
|
summary: 'Generate a VAPID key pair for web push. --save writes it to the board.',
|
|
338
|
-
usage: '
|
|
337
|
+
usage: 'meith push:keys [--save]',
|
|
339
338
|
run: pushKeys,
|
|
340
339
|
},
|
|
341
340
|
|
|
@@ -348,7 +347,7 @@ const commands: Command[] = [
|
|
|
348
347
|
{
|
|
349
348
|
name: 'task:run',
|
|
350
349
|
summary: 'Run every task that is due now, or one named task if it is due.',
|
|
351
|
-
usage: '
|
|
350
|
+
usage: 'meith task:run [<task-id>]',
|
|
352
351
|
run: taskRun,
|
|
353
352
|
},
|
|
354
353
|
|
|
@@ -367,7 +366,7 @@ const commands: Command[] = [
|
|
|
367
366
|
{
|
|
368
367
|
name: 'demo:reset',
|
|
369
368
|
summary: 'Drop everything and rebuild the demo board. Needs DEMO_MODE.',
|
|
370
|
-
usage: '
|
|
369
|
+
usage: 'meith demo:reset --yes',
|
|
371
370
|
run: demoReset,
|
|
372
371
|
},
|
|
373
372
|
]
|
|
@@ -392,7 +391,7 @@ async function main(): Promise<number> {
|
|
|
392
391
|
}
|
|
393
392
|
|
|
394
393
|
if (rest.includes('--help')) {
|
|
395
|
-
console.log(command.usage ?? `
|
|
394
|
+
console.log(command.usage ?? `meith ${command.name}`)
|
|
396
395
|
return 0
|
|
397
396
|
}
|
|
398
397
|
|
package/src/plugin-manifest.ts
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
3
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
import { fileURLToPath } from 'node:url'
|
|
5
6
|
|
|
6
7
|
import { ValidationError } from '@meith/core'
|
|
7
8
|
|
|
8
|
-
import BOARDS_JSON from '../../../scripts/boards.json'
|
|
9
9
|
import { optional, parseFlags } from './args'
|
|
10
10
|
|
|
11
11
|
function repoRoot(): string {
|
|
12
12
|
return fileURLToPath(new URL('../../../', import.meta.url))
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function generatorScript(): string {
|
|
16
|
-
return join(repoRoot(), 'scripts/board-plugins-gen.mjs')
|
|
17
|
-
}
|
|
18
|
-
|
|
19
15
|
interface Board {
|
|
20
16
|
readonly manifestFile: string
|
|
21
17
|
readonly packageFile: string
|
|
@@ -24,12 +20,6 @@ interface Board {
|
|
|
24
20
|
readonly filterName: string
|
|
25
21
|
}
|
|
26
22
|
|
|
27
|
-
const BOARDS = BOARDS_JSON as readonly Board[]
|
|
28
|
-
|
|
29
|
-
function boardsRoot(): string {
|
|
30
|
-
return process.env.MEITH_BOARD_PLUGINS_ROOT ?? repoRoot()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
23
|
interface ManifestEntry {
|
|
34
24
|
readonly key: string
|
|
35
25
|
readonly package: string
|
|
@@ -40,20 +30,212 @@ interface Manifest {
|
|
|
40
30
|
readonly plugins: readonly ManifestEntry[]
|
|
41
31
|
}
|
|
42
32
|
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
interface GeneratorResult {
|
|
34
|
+
readonly ok: boolean
|
|
35
|
+
readonly output: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Mode {
|
|
39
|
+
readonly root: string
|
|
40
|
+
readonly boards: readonly Board[]
|
|
41
|
+
assertEditable(): void
|
|
42
|
+
installPackage(packageName: string): void
|
|
43
|
+
regenerate(): GeneratorResult
|
|
44
|
+
missingManifest(path: string): string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertBoardCheckout(root: string): void {
|
|
48
|
+
if (existsSync(join(root, '.git'))) return
|
|
49
|
+
throw new ValidationError(
|
|
50
|
+
'plugin:add and plugin:remove edit this board’s source — package.json, board.plugins.json ' +
|
|
51
|
+
'and meith.plugins.ts — and only take effect when the image is rebuilt. This is not your ' +
|
|
52
|
+
'board’s git checkout (there is no .git here), which means it is most likely the deployed ' +
|
|
53
|
+
'container: a change made here would not rebuild anything and would be discarded on the next ' +
|
|
54
|
+
'redeploy. Run it where you edit and commit the board, then push and redeploy.',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function installBoardPackage(root: string, packageName: string): void {
|
|
59
|
+
try {
|
|
60
|
+
execFileSync('npm', ['install', '--save-exact', packageName], { cwd: root, stdio: 'inherit' })
|
|
61
|
+
} catch {
|
|
62
|
+
throw new ValidationError(
|
|
63
|
+
`Could not install ${packageName} — npm reported the error above. Fix that and rerun.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const BOARD_MODE_BOARD: Board = {
|
|
69
|
+
manifestFile: 'board.plugins.json',
|
|
70
|
+
packageFile: 'package.json',
|
|
71
|
+
outputFile: 'meith.plugins.ts',
|
|
72
|
+
packageLabel: 'this board',
|
|
73
|
+
filterName: '',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function monorepoMode(boardsFile: string): Mode {
|
|
77
|
+
const boards = JSON.parse(readFileSync(boardsFile, 'utf8')) as readonly Board[]
|
|
78
|
+
const root = process.env.MEITH_BOARD_PLUGINS_ROOT ?? repoRoot()
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
root,
|
|
82
|
+
boards,
|
|
83
|
+
assertEditable: () => {},
|
|
84
|
+
installPackage: () => {},
|
|
85
|
+
regenerate: () => runGenerator(join(repoRoot(), 'scripts/board-plugins-gen.mjs')),
|
|
86
|
+
missingManifest: (path) =>
|
|
87
|
+
`${path} does not exist. This command edits source files for every board this ` +
|
|
88
|
+
'repository carries — apps/community and boards/stock — and reruns the generator, ' +
|
|
89
|
+
'so it needs a checkout of the repository, not the deployed image — run it where you ' +
|
|
90
|
+
'would run `pnpm add`, commit both board.plugins.json files and both ' +
|
|
91
|
+
'meith.plugins.ts files, then rebuild and redeploy.',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function boardMode(): Mode {
|
|
96
|
+
const root = process.env.MEITH_BOARD_PLUGINS_ROOT ?? process.cwd()
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
root,
|
|
100
|
+
boards: [BOARD_MODE_BOARD],
|
|
101
|
+
assertEditable: () => assertBoardCheckout(root),
|
|
102
|
+
installPackage: (packageName) => installBoardPackage(root, packageName),
|
|
103
|
+
regenerate: () => regenerateBoard(root),
|
|
104
|
+
missingManifest: (path) =>
|
|
105
|
+
`${path} does not exist. Run this from the board create-meith scaffolded — the ` +
|
|
106
|
+
'directory with meith.config.ts and board.plugins.json in it.',
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveMode(): Mode {
|
|
111
|
+
const boardsFile = join(repoRoot(), 'scripts/boards.json')
|
|
112
|
+
return existsSync(boardsFile) ? monorepoMode(boardsFile) : boardMode()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const PLUGIN_KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
|
|
116
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
117
|
+
const NPM_PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/
|
|
118
|
+
|
|
119
|
+
function toIdentifier(key: string): string {
|
|
120
|
+
return key.replace(/-([a-z0-9])/g, (_match, char: string) => char.toUpperCase())
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function dependencyNames(packageFile: string): Set<string> {
|
|
124
|
+
const pkg = JSON.parse(readFileSync(packageFile, 'utf8')) as {
|
|
125
|
+
dependencies?: Record<string, string>
|
|
126
|
+
}
|
|
127
|
+
return new Set(Object.keys(pkg.dependencies ?? {}))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validateBoardManifest(
|
|
131
|
+
plugins: readonly ManifestEntry[],
|
|
132
|
+
dependencies: ReadonlySet<string>,
|
|
133
|
+
): void {
|
|
134
|
+
const identifiers = new Map<string, string>()
|
|
135
|
+
|
|
136
|
+
for (const entry of plugins) {
|
|
137
|
+
if (!PLUGIN_KEY_PATTERN.test(entry.key)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`"${entry.key}" is not a valid plugin key. It must be lower-case letters, digits and ` +
|
|
140
|
+
'hyphens, start with a letter, and be 2–40 characters long.',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const identifier = toIdentifier(entry.key)
|
|
145
|
+
if (!IDENTIFIER_PATTERN.test(identifier)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`"${entry.key}" is a valid key, but the identifier meith.plugins.ts would bind for it, ` +
|
|
148
|
+
`"${identifier}", is not a valid one — each hyphen must be followed by a letter or digit.`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const collision = identifiers.get(identifier)
|
|
153
|
+
if (collision !== undefined) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`"${entry.key}" and "${collision}" both generate the identifier "${identifier}". ` +
|
|
156
|
+
'Rename one so the generated imports do not collide.',
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
identifiers.set(identifier, entry.key)
|
|
160
|
+
|
|
161
|
+
if (!NPM_PACKAGE_NAME_PATTERN.test(entry.package) || entry.package.length > 214) {
|
|
162
|
+
throw new Error(`"${entry.package}" (key "${entry.key}") is not a valid npm package name.`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!dependencies.has(entry.package)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`"${entry.package}" (key "${entry.key}") is not installed. Run ` +
|
|
168
|
+
`\`npm install ${entry.package}\` first, then rerun this.`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const BOARD_HEADER = `// Generated from board.plugins.json by \`meith plugin:add\` and \`meith plugin:remove\`.
|
|
175
|
+
//
|
|
176
|
+
// The simple path is those commands, or editing board.plugins.json and running one of
|
|
177
|
+
// them. A plugin that does not fit that convention can be added here by hand instead —
|
|
178
|
+
// keep it out of board.plugins.json so a regenerate does not drop it.
|
|
179
|
+
//
|
|
180
|
+
// docs/customization/plugins.md explains both.`
|
|
181
|
+
|
|
182
|
+
export function renderBoardModule(plugins: readonly ManifestEntry[]): string {
|
|
183
|
+
const imports = plugins.map((entry) => {
|
|
184
|
+
const name = toIdentifier(entry.key)
|
|
185
|
+
return `import { messages as ${name}Messages, plugin as ${name}Plugin } from '${entry.package}'`
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const entries = plugins.map((entry) => {
|
|
189
|
+
const name = toIdentifier(entry.key)
|
|
190
|
+
const enabled = entry.enabled === false ? 'false' : 'true'
|
|
191
|
+
return (
|
|
192
|
+
` { key: '${entry.key}', enabled: ${enabled}, ` +
|
|
193
|
+
`plugin: ${name}Plugin, messages: ${name}Messages },`
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const importBlock = ["import type { InstalledPlugin } from '@meith/web/config'", ...imports].join(
|
|
198
|
+
'\n',
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const listBlock =
|
|
202
|
+
entries.length === 0
|
|
203
|
+
? 'export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = []'
|
|
204
|
+
: `export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = [\n${entries.join('\n')}\n]`
|
|
205
|
+
|
|
206
|
+
const functionBlock = `export function installedPluginDefinitions() {
|
|
207
|
+
return INSTALLED_PLUGINS.filter(
|
|
208
|
+
(entry) => entry.enabled !== false && entry.plugin !== undefined,
|
|
209
|
+
).map((entry) => entry.plugin)
|
|
210
|
+
}`
|
|
211
|
+
|
|
212
|
+
return `${[BOARD_HEADER, importBlock, listBlock, functionBlock].join('\n\n')}\n`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function regenerateBoard(root: string): GeneratorResult {
|
|
216
|
+
try {
|
|
217
|
+
const board = BOARD_MODE_BOARD
|
|
218
|
+
const raw = readFileSync(join(root, board.manifestFile), 'utf8')
|
|
219
|
+
const plugins = (JSON.parse(raw) as Manifest).plugins
|
|
220
|
+
const dependencies = dependencyNames(join(root, board.packageFile))
|
|
221
|
+
|
|
222
|
+
validateBoardManifest(plugins, dependencies)
|
|
223
|
+
writeFileSync(join(root, board.outputFile), renderBoardModule(plugins), 'utf8')
|
|
224
|
+
|
|
225
|
+
return { ok: true, output: `${plugins.length} plugin(s)` }
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return { ok: false, output: (error as Error).message }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function readManifestFor(mode: Mode, board: Board): Promise<Manifest> {
|
|
232
|
+
const path = join(mode.root, board.manifestFile)
|
|
45
233
|
let raw: string
|
|
46
234
|
try {
|
|
47
235
|
raw = await readFile(path, 'utf8')
|
|
48
236
|
} catch (error) {
|
|
49
237
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
50
|
-
throw new ValidationError(
|
|
51
|
-
`${path} does not exist. This command edits source files for every board this ` +
|
|
52
|
-
'repository carries — apps/community and boards/stock — and reruns the generator, ' +
|
|
53
|
-
'so it needs a checkout of the repository, not the deployed image — run it where you ' +
|
|
54
|
-
'would run `pnpm add`, commit both board.plugins.json files and both ' +
|
|
55
|
-
'meith.plugins.ts files, then rebuild and redeploy.',
|
|
56
|
-
)
|
|
238
|
+
throw new ValidationError(mode.missingManifest(path))
|
|
57
239
|
}
|
|
58
240
|
throw error
|
|
59
241
|
}
|
|
@@ -76,19 +258,14 @@ async function readManifestFor(board: Board): Promise<Manifest> {
|
|
|
76
258
|
return { plugins: parsed.plugins }
|
|
77
259
|
}
|
|
78
260
|
|
|
79
|
-
async function writeManifestFor(board: Board, manifest: Manifest): Promise<void> {
|
|
80
|
-
const path = join(
|
|
261
|
+
async function writeManifestFor(mode: Mode, board: Board, manifest: Manifest): Promise<void> {
|
|
262
|
+
const path = join(mode.root, board.manifestFile)
|
|
81
263
|
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
82
264
|
}
|
|
83
265
|
|
|
84
|
-
|
|
85
|
-
readonly ok: boolean
|
|
86
|
-
readonly output: string
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function runGenerator(): GeneratorResult {
|
|
266
|
+
function runGenerator(generatorScript: string): GeneratorResult {
|
|
90
267
|
try {
|
|
91
|
-
const output = execFileSync(process.execPath, [generatorScript
|
|
268
|
+
const output = execFileSync(process.execPath, [generatorScript], {
|
|
92
269
|
encoding: 'utf8',
|
|
93
270
|
stdio: 'pipe',
|
|
94
271
|
})
|
|
@@ -113,13 +290,13 @@ export async function pluginAdd(args: readonly string[]): Promise<number> {
|
|
|
113
290
|
const [packageName, ...extraPositional] = positional
|
|
114
291
|
|
|
115
292
|
if (packageName === undefined || extraPositional.length > 0) {
|
|
116
|
-
throw new ValidationError('Usage:
|
|
293
|
+
throw new ValidationError('Usage: meith plugin:add <package> [--key <key>] [--disabled]')
|
|
117
294
|
}
|
|
118
295
|
|
|
119
296
|
const configFlags = [...flags.keys()].filter((name) => !ADD_FLAGS.has(name))
|
|
120
297
|
if (configFlags.length > 0) {
|
|
121
298
|
throw new ValidationError(
|
|
122
|
-
`
|
|
299
|
+
`meith plugin:add does not take plugin configuration (--${configFlags[0]}). The ` +
|
|
123
300
|
"manifest has no field for it — a plugin's own settings are the only place its " +
|
|
124
301
|
"configuration lives now, the way MEI-74 moved plugins/dues's plans there. Export a " +
|
|
125
302
|
'zero-argument plugin and add it with just its package name.',
|
|
@@ -134,32 +311,38 @@ export async function pluginAdd(args: readonly string[]): Promise<number> {
|
|
|
134
311
|
)
|
|
135
312
|
}
|
|
136
313
|
|
|
137
|
-
const
|
|
314
|
+
const mode = resolveMode()
|
|
315
|
+
mode.assertEditable()
|
|
316
|
+
const originals = await Promise.all(mode.boards.map((board) => readManifestFor(mode, board)))
|
|
138
317
|
|
|
139
318
|
originals.forEach(({ plugins }, index) => {
|
|
140
319
|
if (plugins.some((entry) => entry.key === key)) {
|
|
141
|
-
throw new ValidationError(`"${key}" is already in ${
|
|
320
|
+
throw new ValidationError(`"${key}" is already in ${mode.boards[index]?.manifestFile}.`)
|
|
142
321
|
}
|
|
143
322
|
})
|
|
144
323
|
|
|
324
|
+
mode.installPackage(packageName)
|
|
325
|
+
|
|
145
326
|
const entry: ManifestEntry = { key, package: packageName, enabled: !flags.has('disabled') }
|
|
146
327
|
|
|
147
328
|
await Promise.all(
|
|
148
|
-
|
|
149
|
-
writeManifestFor(board, { plugins: [...(originals[index]?.plugins ?? []), entry] }),
|
|
329
|
+
mode.boards.map((board, index) =>
|
|
330
|
+
writeManifestFor(mode, board, { plugins: [...(originals[index]?.plugins ?? []), entry] }),
|
|
150
331
|
),
|
|
151
332
|
)
|
|
152
333
|
|
|
153
|
-
const result =
|
|
334
|
+
const result = mode.regenerate()
|
|
154
335
|
if (!result.ok) {
|
|
155
336
|
await Promise.all(
|
|
156
|
-
|
|
337
|
+
mode.boards.map((board, index) =>
|
|
338
|
+
writeManifestFor(mode, board, originals[index] ?? { plugins: [] }),
|
|
339
|
+
),
|
|
157
340
|
)
|
|
158
341
|
throw new ValidationError(`Could not add "${key}":\n\n${result.output}`)
|
|
159
342
|
}
|
|
160
343
|
|
|
161
|
-
const manifestFiles =
|
|
162
|
-
const outputFiles =
|
|
344
|
+
const manifestFiles = mode.boards.map((board) => board.manifestFile).join(' and ')
|
|
345
|
+
const outputFiles = mode.boards.map((board) => board.outputFile).join(' and ')
|
|
163
346
|
console.log(
|
|
164
347
|
`Added "${key}" (${packageName}${entry.enabled ? '' : ', disabled'}) to ${manifestFiles} ` +
|
|
165
348
|
`and regenerated ${outputFiles}.`,
|
|
@@ -173,40 +356,44 @@ export async function pluginRemove(args: readonly string[]): Promise<number> {
|
|
|
173
356
|
const [key, ...extraPositional] = positional
|
|
174
357
|
|
|
175
358
|
if (key === undefined || extraPositional.length > 0) {
|
|
176
|
-
throw new ValidationError('Usage:
|
|
359
|
+
throw new ValidationError('Usage: meith plugin:remove <key>')
|
|
177
360
|
}
|
|
178
361
|
|
|
179
|
-
const
|
|
362
|
+
const mode = resolveMode()
|
|
363
|
+
mode.assertEditable()
|
|
364
|
+
const originals = await Promise.all(mode.boards.map((board) => readManifestFor(mode, board)))
|
|
180
365
|
|
|
181
366
|
originals.forEach(({ plugins }, index) => {
|
|
182
367
|
if (!plugins.some((entry) => entry.key === key)) {
|
|
183
368
|
const present = plugins.map((entry) => entry.key)
|
|
184
369
|
throw new ValidationError(
|
|
185
370
|
present.length === 0
|
|
186
|
-
? `"${key}" is not in ${
|
|
187
|
-
: `"${key}" is not in ${
|
|
371
|
+
? `"${key}" is not in ${mode.boards[index]?.manifestFile} — it lists no plugins.`
|
|
372
|
+
: `"${key}" is not in ${mode.boards[index]?.manifestFile}. Present: ${present.join(', ')}.`,
|
|
188
373
|
)
|
|
189
374
|
}
|
|
190
375
|
})
|
|
191
376
|
|
|
192
377
|
await Promise.all(
|
|
193
|
-
|
|
194
|
-
writeManifestFor(board, {
|
|
378
|
+
mode.boards.map((board, index) =>
|
|
379
|
+
writeManifestFor(mode, board, {
|
|
195
380
|
plugins: (originals[index]?.plugins ?? []).filter((entry) => entry.key !== key),
|
|
196
381
|
}),
|
|
197
382
|
),
|
|
198
383
|
)
|
|
199
384
|
|
|
200
|
-
const result =
|
|
385
|
+
const result = mode.regenerate()
|
|
201
386
|
if (!result.ok) {
|
|
202
387
|
await Promise.all(
|
|
203
|
-
|
|
388
|
+
mode.boards.map((board, index) =>
|
|
389
|
+
writeManifestFor(mode, board, originals[index] ?? { plugins: [] }),
|
|
390
|
+
),
|
|
204
391
|
)
|
|
205
392
|
throw new ValidationError(`Could not remove "${key}":\n\n${result.output}`)
|
|
206
393
|
}
|
|
207
394
|
|
|
208
|
-
const manifestFiles =
|
|
209
|
-
const outputFiles =
|
|
395
|
+
const manifestFiles = mode.boards.map((board) => board.manifestFile).join(' and ')
|
|
396
|
+
const outputFiles = mode.boards.map((board) => board.outputFile).join(' and ')
|
|
210
397
|
console.log(`Removed "${key}" from ${manifestFiles} and regenerated ${outputFiles}.`)
|
|
211
398
|
console.log('Rebuild and redeploy for it to take effect.')
|
|
212
399
|
return 0
|
package/src/profile-fields.ts
CHANGED
|
@@ -25,7 +25,7 @@ export async function profileFieldList(): Promise<number> {
|
|
|
25
25
|
if (fields.length === 0) {
|
|
26
26
|
console.log(
|
|
27
27
|
'No custom profile fields.\n' +
|
|
28
|
-
'Add one:
|
|
28
|
+
'Add one: meith profile-field:add --key pronouns --label Pronouns --type text',
|
|
29
29
|
)
|
|
30
30
|
return 0
|
|
31
31
|
}
|
|
@@ -90,7 +90,7 @@ export async function profileFieldRemove(args: readonly string[]): Promise<numbe
|
|
|
90
90
|
const { positional } = parseFlags(args)
|
|
91
91
|
const key = positional[0]
|
|
92
92
|
if (key === undefined) {
|
|
93
|
-
throw new ValidationError('Usage:
|
|
93
|
+
throw new ValidationError('Usage: meith profile-field:remove <key>')
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const removed = await service().remove(key)
|
package/src/tasks.ts
CHANGED
|
@@ -31,9 +31,7 @@ export async function taskRun(args: readonly string[]): Promise<number> {
|
|
|
31
31
|
const { repository, tasks, onTaskFailure } = scheduler()
|
|
32
32
|
const selected = only === undefined ? tasks : tasks.filter((t) => t.id === only)
|
|
33
33
|
if (only !== undefined && selected.length === 0) {
|
|
34
|
-
console.error(
|
|
35
|
-
`No such task: ${only}\n` + `Run \`community task:list\` to see what is registered.`,
|
|
36
|
-
)
|
|
34
|
+
console.error(`No such task: ${only}\n` + `Run \`meith task:list\` to see what is registered.`)
|
|
37
35
|
return 1
|
|
38
36
|
}
|
|
39
37
|
|
package/src/upgrade.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { type PluginDefinition, pluginNavigationPlacements } from '@meith/plugin
|
|
|
11
11
|
import { runPluginLifecycle } from '@meith/runtime'
|
|
12
12
|
import { type PluginUpgrade, planUpgrade, upgradeNotice } from '@meith/upgrade'
|
|
13
13
|
|
|
14
|
-
export const CODE_VERSION = '0.
|
|
14
|
+
export const CODE_VERSION = '0.28.0'
|
|
15
15
|
|
|
16
16
|
export function pluginUpgrades(plugins: readonly PluginDefinition[]): readonly PluginUpgrade[] {
|
|
17
17
|
return plugins.map((plugin) => ({
|