@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.
@@ -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(`community: ${message}`)
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 community from a board's own ` +
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.26.1",
4
- "description": "The operator CLI: migrations, backup and restore, imports, users and settings — and the community bin that runs it against an external board workspace.",
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
- "community": "./bin/community.mjs"
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.26.1",
27
- "@meith/core": "0.26.1",
28
- "@meith/db": "0.26.1",
29
- "@meith/demo": "0.26.1",
30
- "@meith/drivers": "0.26.1",
31
- "@meith/forums": "0.26.1",
32
- "@meith/plugin-kit": "0.26.1",
33
- "@meith/profile-fields": "0.26.1",
34
- "@meith/runtime": "0.26.1",
35
- "@meith/settings": "0.26.1",
36
- "@meith/tasks": "0.26.1",
37
- "create-meith": "0.26.1"
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://…> community restore <bundle.tar.gz> ' +
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(
@@ -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 community board:eject ' +
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: community board:eject <dir>')
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" | community user:create --username u --email e@x.com',
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" | community user:create --username u --email u@example.com',
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: community settings:get <key>')
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: 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>',
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: community settings:set <key> <value>')
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 `community migrate` first.',
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 `community migrate` to seed the group ladder.',
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 `community task:run counters.reconcile` before opening the board.',
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
- 'community — operator CLI',
35
+ 'meith — operator CLI',
36
36
  '',
37
- 'Usage: community <command> [options]',
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
- 'community migrate',
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: 'community upgrade [--dry-run]',
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: 'community backup [--out <path>] [--uploads include|skip]',
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://…> community restore <bundle.tar.gz> ' +
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: 'community plugin:purge <key> [--yes]',
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: community plugin:purge <key> [--yes]')
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: 'Add a package to board.plugins.json and regenerate meith.plugins.ts.',
215
- usage: 'community plugin:add <package> [--key <key>] [--disabled]',
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: 'community plugin:remove <key>',
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: 'community board:eject <dir>',
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: 'community user:promote --user <id|username> --group <key|id>',
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: 'community user:2fa-clear --user <id|username>',
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
- 'community forum:create --title <title> --slug <slug> [--parent <id>] ' +
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: 'community settings:get <key>',
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
- 'community settings:set <key> <value>\n' +
307
- 'community settings:set <secret-key> --from-env <name>\n' +
308
- 'printf %s "$SECRET" | community settings:set <secret-key>',
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
- 'community profile-field:add --key <key> --label <label> ' +
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: 'community profile-field:remove <key>',
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: 'community push:keys [--save]',
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: 'community task:run [<task-id>]',
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: 'community demo:reset --yes',
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 ?? `community ${command.name}`)
394
+ console.log(command.usage ?? `meith ${command.name}`)
396
395
  return 0
397
396
  }
398
397
 
@@ -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
- async function readManifestFor(board: Board): Promise<Manifest> {
44
- const path = join(boardsRoot(), board.manifestFile)
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(boardsRoot(), board.manifestFile)
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
- interface GeneratorResult {
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: community plugin:add <package> [--key <key>] [--disabled]')
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
- `community plugin:add does not take plugin configuration (--${configFlags[0]}). The ` +
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 originals = await Promise.all(BOARDS.map((board) => readManifestFor(board)))
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 ${BOARDS[index]?.manifestFile}.`)
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
- BOARDS.map((board, index) =>
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 = runGenerator()
334
+ const result = mode.regenerate()
154
335
  if (!result.ok) {
155
336
  await Promise.all(
156
- BOARDS.map((board, index) => writeManifestFor(board, originals[index] ?? { plugins: [] })),
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 = BOARDS.map((board) => board.manifestFile).join(' and ')
162
- const outputFiles = BOARDS.map((board) => board.outputFile).join(' and ')
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: community plugin:remove <key>')
359
+ throw new ValidationError('Usage: meith plugin:remove <key>')
177
360
  }
178
361
 
179
- const originals = await Promise.all(BOARDS.map((board) => readManifestFor(board)))
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 ${BOARDS[index]?.manifestFile} — it lists no plugins.`
187
- : `"${key}" is not in ${BOARDS[index]?.manifestFile}. Present: ${present.join(', ')}.`,
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
- BOARDS.map((board, index) =>
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 = runGenerator()
385
+ const result = mode.regenerate()
201
386
  if (!result.ok) {
202
387
  await Promise.all(
203
- BOARDS.map((board, index) => writeManifestFor(board, originals[index] ?? { plugins: [] })),
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 = BOARDS.map((board) => board.manifestFile).join(' and ')
209
- const outputFiles = BOARDS.map((board) => board.outputFile).join(' and ')
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
@@ -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: community profile-field:add --key pronouns --label Pronouns --type text',
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: community profile-field:remove <key>')
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.26.1'
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) => ({