@meith/cli 0.27.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/cli",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
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": {
@@ -23,18 +23,18 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "tsx": "^4.23.12",
26
- "@meith/accounts": "0.27.0",
27
- "@meith/core": "0.27.0",
28
- "@meith/db": "0.27.0",
29
- "@meith/demo": "0.27.0",
30
- "@meith/drivers": "0.27.0",
31
- "@meith/forums": "0.27.0",
32
- "@meith/plugin-kit": "0.27.0",
33
- "@meith/profile-fields": "0.27.0",
34
- "@meith/runtime": "0.27.0",
35
- "@meith/settings": "0.27.0",
36
- "@meith/tasks": "0.27.0",
37
- "create-meith": "0.27.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/index.ts CHANGED
@@ -211,7 +211,7 @@ 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.',
214
+ summary: 'Install a plugin, add it to board.plugins.json, and regenerate meith.plugins.ts.',
215
215
  usage: 'meith plugin:add <package> [--key <key>] [--disabled]',
216
216
  async run(args: readonly string[]) {
217
217
  const { pluginAdd } = await import('./plugin-manifest')
@@ -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
  })
@@ -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}.`,
@@ -176,37 +359,41 @@ export async function pluginRemove(args: readonly string[]): Promise<number> {
176
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
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.27.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) => ({