@meith/cli 0.17.1 → 0.17.2

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 CHANGED
@@ -92,11 +92,13 @@ function materialize() {
92
92
  return join(srcTarget, 'index.ts')
93
93
  }
94
94
 
95
+ /**
96
+ * Resolved from this package's own directory — `tsx` is `@meith/cli`'s
97
+ * dependency, found either hoisted to the workspace root or nested under
98
+ * this package's own `node_modules`, the same reasoning as forum-web's
99
+ * `resolveNextBin()`.
100
+ */
95
101
  function resolveTsx() {
96
- // Resolved from this package's own directory — `tsx` is @meith/cli's
97
- // dependency, found either hoisted to the workspace root or nested under
98
- // this package's own node_modules, the same reasoning as forum-web's
99
- // resolveNextBin().
100
102
  const require = createRequire(join(packageRoot, 'package.json'))
101
103
  try {
102
104
  return require.resolve('tsx/cli')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/cli",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
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.",
5
5
  "license": "LGPL-3.0-or-later",
6
6
  "repository": {
@@ -23,18 +23,18 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "tsx": "^4.23.12",
26
- "@meith/accounts": "0.17.1",
27
- "@meith/core": "0.17.1",
28
- "@meith/db": "0.17.1",
29
- "@meith/demo": "0.17.1",
30
- "@meith/forums": "0.17.1",
31
- "@meith/drivers": "0.17.1",
32
- "@meith/plugin-kit": "0.17.1",
33
- "@meith/profile-fields": "0.17.1",
34
- "@meith/settings": "0.17.1",
35
- "create-meith": "0.17.1",
36
- "@meith/tasks": "0.17.1",
37
- "@meith/runtime": "0.17.1"
26
+ "@meith/accounts": "0.17.2",
27
+ "@meith/db": "0.17.2",
28
+ "@meith/drivers": "0.17.2",
29
+ "@meith/demo": "0.17.2",
30
+ "@meith/forums": "0.17.2",
31
+ "@meith/core": "0.17.2",
32
+ "@meith/plugin-kit": "0.17.2",
33
+ "@meith/profile-fields": "0.17.2",
34
+ "@meith/tasks": "0.17.2",
35
+ "@meith/runtime": "0.17.2",
36
+ "@meith/settings": "0.17.2",
37
+ "create-meith": "0.17.2"
38
38
  },
39
39
  "devDependencies": {
40
40
  "esbuild": "^0.28.2"
@@ -17,8 +17,12 @@ import { CODE_VERSION } from './upgrade'
17
17
  * bundled `dist/cli.cjs` does not sit at that same distance — Docker's own
18
18
  * `COPY apps/cli/dist/ ./apps/cli/` drops the `dist` segment.
19
19
  */
20
- const ROOT = fileURLToPath(new URL('../../../', import.meta.url))
21
- const DEFAULT_MANIFEST_PATH = join(ROOT, 'boards/stock/board.plugins.json')
20
+ function defaultManifestPath(): string {
21
+ return join(
22
+ fileURLToPath(new URL('../../../', import.meta.url)),
23
+ 'boards/stock/board.plugins.json',
24
+ )
25
+ }
22
26
 
23
27
  interface ManifestEntry {
24
28
  readonly key: string
@@ -31,7 +35,81 @@ interface Manifest {
31
35
  }
32
36
 
33
37
  function manifestPath(): string {
34
- return process.env.BOARD_PLUGINS_MANIFEST ?? DEFAULT_MANIFEST_PATH
38
+ return process.env.BOARD_PLUGINS_MANIFEST ?? defaultManifestPath()
39
+ }
40
+
41
+ /**
42
+ * Duplicated from scripts/board-plugins.mjs's own PLUGIN_KEY_PATTERN, IDENTIFIER_PATTERN
43
+ * and NPM_PACKAGE_NAME_PATTERN rather than imported — see docs/plugin-api.md, "The board
44
+ * plugin manifests" — and pinned to agree with it by board-eject.test.ts.
45
+ */
46
+ const PLUGIN_KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
47
+ const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
48
+ const NPM_PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/
49
+
50
+ /**
51
+ * The same shape of refusal scripts/board-plugins.mjs's validateManifest makes for
52
+ * apps/community and boards/stock, minus the dependency check — a deployed image
53
+ * carries no package.json listing what it was actually built with. A manifest that
54
+ * gets this far already passed that check once, when plugin:add wrote it; this is
55
+ * defense against a manifest hand-edited after the fact, not the primary gate.
56
+ */
57
+ export function validateEjectManifest(plugins: readonly ManifestEntry[], path: string): void {
58
+ const seen = new Set<string>()
59
+ const identifiers = new Map<string, string>()
60
+
61
+ for (const entry of plugins) {
62
+ if (typeof entry.key !== 'string' || typeof entry.package !== 'string') {
63
+ throw new ValidationError(
64
+ `${path}: every entry needs a string "key" and "package". Got ${JSON.stringify(entry)}.`,
65
+ )
66
+ }
67
+
68
+ if (seen.has(entry.key)) {
69
+ throw new ValidationError(`${path}: "${entry.key}" is listed twice.`)
70
+ }
71
+ seen.add(entry.key)
72
+
73
+ if (!PLUGIN_KEY_PATTERN.test(entry.key)) {
74
+ throw new ValidationError(
75
+ `${path}: "${entry.key}" is not a valid plugin key. definePlugin requires lower-case ` +
76
+ 'letters, digits and hyphens, starting with a letter, 2-40 characters long.',
77
+ )
78
+ }
79
+
80
+ if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') {
81
+ throw new ValidationError(
82
+ `${path}: "${entry.key}" has a non-boolean "enabled" (${JSON.stringify(entry.enabled)}). ` +
83
+ 'Omit the field to enable the plugin, or set it to true or false.',
84
+ )
85
+ }
86
+
87
+ if (!NPM_PACKAGE_NAME_PATTERN.test(entry.package) || entry.package.length > 214) {
88
+ throw new ValidationError(
89
+ `${path}: "${entry.package}" (key "${entry.key}") is not a valid npm package name.`,
90
+ )
91
+ }
92
+
93
+ const identifier = toIdentifier(entry.key)
94
+ if (!IDENTIFIER_PATTERN.test(identifier)) {
95
+ throw new ValidationError(
96
+ `${path}: "${entry.key}" is a valid plugin key, but the identifier ` +
97
+ `community.plugins.ts would bind for it, "${identifier}", is not a valid TypeScript ` +
98
+ 'identifier. Each hyphen must be followed by exactly one lower-case letter or digit, ' +
99
+ 'and a key cannot end in a hyphen.',
100
+ )
101
+ }
102
+
103
+ const collidingKey = identifiers.get(identifier)
104
+ if (collidingKey !== undefined) {
105
+ throw new ValidationError(
106
+ `${path}: "${entry.key}" and "${collidingKey}" both generate the identifier ` +
107
+ `"${identifier}" for community.plugins.ts. Rename one of the keys so the generated ` +
108
+ 'imports do not collide.',
109
+ )
110
+ }
111
+ identifiers.set(identifier, entry.key)
112
+ }
35
113
  }
36
114
 
37
115
  /**
@@ -63,6 +141,7 @@ async function readManifest(): Promise<Manifest> {
63
141
  if (!Array.isArray(parsed.plugins)) {
64
142
  throw new ValidationError(`${path} must have a "plugins" array.`)
65
143
  }
144
+ validateEjectManifest(parsed.plugins, path)
66
145
  return { plugins: parsed.plugins }
67
146
  }
68
147
 
@@ -75,10 +154,33 @@ async function isEmptyOrMissing(target: string): Promise<boolean> {
75
154
  }
76
155
  }
77
156
 
78
- function toIdentifier(key: string): string {
157
+ export function toIdentifier(key: string): string {
79
158
  return key.replace(/-([a-z0-9])/g, (_match, char) => char.toUpperCase())
80
159
  }
81
160
 
161
+ interface EjectedPackageJson {
162
+ dependencies: Record<string, string>
163
+ [key: string]: unknown
164
+ }
165
+
166
+ /**
167
+ * Every manifest entry's package pinned into the scaffolded package.json's
168
+ * dependencies, at this exact running version — see docs/marketplace.md,
169
+ * "1. Eject", for why never `latest`, and for the collision policy this
170
+ * implements: a package scaffold() already pins is left exactly where it
171
+ * is, never duplicated or reordered, because both pins are always this
172
+ * same CODE_VERSION.
173
+ */
174
+ function mergePluginDependencies(packageJson: string, plugins: readonly ManifestEntry[]): string {
175
+ const parsed = JSON.parse(packageJson) as EjectedPackageJson
176
+ for (const entry of plugins) {
177
+ if (!(entry.package in parsed.dependencies)) {
178
+ parsed.dependencies[entry.package] = CODE_VERSION
179
+ }
180
+ }
181
+ return `${JSON.stringify(parsed, null, 2)}\n`
182
+ }
183
+
82
184
  /**
83
185
  * The ejected workspace's own community.plugins.ts — the same shape
84
186
  * create-meith's scaffold() writes for a plugin-free board (see
@@ -122,6 +224,30 @@ export function installedPluginDefinitions() {
122
224
  `
123
225
  }
124
226
 
227
+ function isFsPermissionError(error: unknown): error is NodeJS.ErrnoException {
228
+ const code = (error as NodeJS.ErrnoException | undefined)?.code
229
+ return code === 'EACCES' || code === 'EPERM'
230
+ }
231
+
232
+ /**
233
+ * Turns an EACCES/EPERM from the write loop below into this instead of a
234
+ * bare Node stack trace — see docs/marketplace.md, "1. Eject", for the
235
+ * bind-mount ownership rule this message is naming. Anything else passes
236
+ * through unchanged.
237
+ */
238
+ function translateWriteError(error: unknown, target: string, path: string): never {
239
+ if (isFsPermissionError(error)) {
240
+ throw new ValidationError(
241
+ `board:eject could not write to ${path}: permission denied. The account running this ` +
242
+ `command needs write access to ${target} — inside the official image that account is ` +
243
+ 'a fixed, non-root user, so a bind-mounted target directory has to already be owned by ' +
244
+ '(or writable by) that same account before eject runs. See docs/marketplace.md, ' +
245
+ '"Moving to a custom board", for the exact invocation.',
246
+ )
247
+ }
248
+ throw error
249
+ }
250
+
125
251
  export async function boardEject(args: readonly string[]): Promise<number> {
126
252
  const positional = args.filter((arg) => !arg.startsWith('-'))
127
253
  const [dir] = positional
@@ -150,13 +276,22 @@ export async function boardEject(args: readonly string[]): Promise<number> {
150
276
  const files = new Map(
151
277
  scaffold({ name, version: CODE_VERSION, repositoryUrl: DEFAULT_REPOSITORY_URL }),
152
278
  )
279
+ const packageJson = files.get('package.json')
280
+ if (packageJson === undefined) {
281
+ throw new Error('scaffold() did not emit package.json')
282
+ }
283
+ files.set('package.json', mergePluginDependencies(packageJson, manifest.plugins))
153
284
  files.set('board.plugins.json', `${JSON.stringify({ plugins: manifest.plugins }, null, 2)}\n`)
154
285
  files.set('community.plugins.ts', renderInstalledPluginsModule(manifest.plugins))
155
286
 
156
287
  for (const [relative, contents] of files) {
157
288
  const path = join(target, relative)
158
- await mkdir(dirname(path), { recursive: true })
159
- await writeFile(path, contents, 'utf8')
289
+ try {
290
+ await mkdir(dirname(path), { recursive: true })
291
+ await writeFile(path, contents, 'utf8')
292
+ } catch (error) {
293
+ translateWriteError(error, target, path)
294
+ }
160
295
  }
161
296
 
162
297
  console.log(`Ejected ${files.size} files to ${target}, pinned to meith ${CODE_VERSION}.`)
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
5
5
 
6
6
  import { ValidationError } from '@meith/core'
7
7
 
8
+ import BOARDS_JSON from '../../../scripts/boards.json'
8
9
  import { optional, parseFlags } from './args'
9
10
 
10
11
  /**
@@ -12,9 +13,33 @@ import { optional, parseFlags } from './args'
12
13
  * distance from the repository root (apps/cli/{src,dist}/<file> either way), so this
13
14
  * offset holds whether these commands run from source (tsx) or the built dist/cli.cjs.
14
15
  */
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')
16
+ function repoRoot(): string {
17
+ return fileURLToPath(new URL('../../../', import.meta.url))
18
+ }
19
+
20
+ function generatorScript(): string {
21
+ return join(repoRoot(), 'scripts/board-plugins-gen.mjs')
22
+ }
23
+
24
+ /**
25
+ * Every board's board.plugins.json is edited together, from the one list in
26
+ * scripts/boards.json that scripts/board-plugins-gen.mjs reads too. That file
27
+ * and the MEITH_BOARD_PLUGINS_ROOT override are described in
28
+ * docs/development.md, "The board plugin manifests".
29
+ */
30
+ interface Board {
31
+ readonly manifestFile: string
32
+ readonly packageFile: string
33
+ readonly outputFile: string
34
+ readonly packageLabel: string
35
+ readonly filterName: string
36
+ }
37
+
38
+ const BOARDS = BOARDS_JSON as readonly Board[]
39
+
40
+ function boardsRoot(): string {
41
+ return process.env.MEITH_BOARD_PLUGINS_ROOT ?? repoRoot()
42
+ }
18
43
 
19
44
  interface ManifestEntry {
20
45
  readonly key: string
@@ -32,19 +57,21 @@ interface Manifest {
32
57
  * one meant to run as `docker compose run --rm web community plugin:purge`. The deployed
33
58
  * image is built `FROM node:26-alpine` with only `.next/standalone`, the worker and this
34
59
  * 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.
60
+ * no Biome. Reading a manifest is where that shows up first, so this is where it is named.
36
61
  */
37
- async function readManifest(): Promise<Manifest> {
62
+ async function readManifestFor(board: Board): Promise<Manifest> {
63
+ const path = join(boardsRoot(), board.manifestFile)
38
64
  let raw: string
39
65
  try {
40
- raw = await readFile(MANIFEST_PATH, 'utf8')
66
+ raw = await readFile(path, 'utf8')
41
67
  } catch (error) {
42
68
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
43
69
  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.',
70
+ `${path} does not exist. This command edits source files for every board this ` +
71
+ 'repository carries apps/community and boards/stock and reruns the generator, ' +
72
+ 'so it needs a checkout of the repository, not the deployed image — run it where you ' +
73
+ 'would run `pnpm add`, commit both board.plugins.json files and both ' +
74
+ 'community.plugins.ts files, then rebuild and redeploy.',
48
75
  )
49
76
  }
50
77
  throw error
@@ -52,13 +79,25 @@ async function readManifest(): Promise<Manifest> {
52
79
 
53
80
  const parsed = JSON.parse(raw) as Partial<Manifest>
54
81
  if (!Array.isArray(parsed.plugins)) {
55
- throw new ValidationError('apps/community/board.plugins.json must have a "plugins" array.')
82
+ throw new ValidationError(`${path} must have a "plugins" array.`)
56
83
  }
84
+
85
+ const extraFields = Object.keys(parsed).filter((field) => field !== 'plugins')
86
+ if (extraFields.length > 0) {
87
+ throw new ValidationError(
88
+ `${path} has ${extraFields.length === 1 ? 'a field' : 'fields'} plugin:add/plugin:remove ` +
89
+ `do not know how to carry forward: ${extraFields.join(', ')}. "plugins" is the ` +
90
+ "manifest's only field — remove the rest by hand, since rewriting the file here would " +
91
+ 'otherwise drop them silently.',
92
+ )
93
+ }
94
+
57
95
  return { plugins: parsed.plugins }
58
96
  }
59
97
 
60
- async function writeManifest(manifest: Manifest): Promise<void> {
61
- await writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
98
+ async function writeManifestFor(board: Board, manifest: Manifest): Promise<void> {
99
+ const path = join(boardsRoot(), board.manifestFile)
100
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
62
101
  }
63
102
 
64
103
  interface GeneratorResult {
@@ -68,14 +107,15 @@ interface GeneratorResult {
68
107
 
69
108
  /**
70
109
  * 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.
110
+ * generator `pnpm board:gen` runs, for every board it carries. Shelling out — rather
111
+ * than importing scripts/board-plugins.mjs — keeps a plain script and a workspace
112
+ * TypeScript package from needing to share a module; it also means a failed add or
113
+ * remove is reported in exactly the words a person typing `pnpm board:gen` themselves
114
+ * would see, board by board.
75
115
  */
76
116
  function runGenerator(): GeneratorResult {
77
117
  try {
78
- const output = execFileSync(process.execPath, [GENERATOR_SCRIPT], {
118
+ const output = execFileSync(process.execPath, [generatorScript()], {
79
119
  encoding: 'utf8',
80
120
  stdio: 'pipe',
81
121
  })
@@ -109,7 +149,7 @@ export async function pluginAdd(args: readonly string[]): Promise<number> {
109
149
  throw new ValidationError(
110
150
  `community plugin:add does not take plugin configuration (--${configFlags[0]}). The ` +
111
151
  "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 ' +
152
+ "configuration lives now, the way MEI-74 moved plugins/dues's plans there. Export a " +
113
153
  'zero-argument plugin and add it with just its package name.',
114
154
  )
115
155
  }
@@ -122,23 +162,35 @@ export async function pluginAdd(args: readonly string[]): Promise<number> {
122
162
  )
123
163
  }
124
164
 
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
- }
165
+ const originals = await Promise.all(BOARDS.map((board) => readManifestFor(board)))
166
+
167
+ originals.forEach(({ plugins }, index) => {
168
+ if (plugins.some((entry) => entry.key === key)) {
169
+ throw new ValidationError(`"${key}" is already in ${BOARDS[index]?.manifestFile}.`)
170
+ }
171
+ })
129
172
 
130
173
  const entry: ManifestEntry = { key, package: packageName, enabled: !flags.has('disabled') }
131
- await writeManifest({ plugins: [...manifest.plugins, entry] })
174
+
175
+ await Promise.all(
176
+ BOARDS.map((board, index) =>
177
+ writeManifestFor(board, { plugins: [...(originals[index]?.plugins ?? []), entry] }),
178
+ ),
179
+ )
132
180
 
133
181
  const result = runGenerator()
134
182
  if (!result.ok) {
135
- await writeManifest(manifest)
183
+ await Promise.all(
184
+ BOARDS.map((board, index) => writeManifestFor(board, originals[index] ?? { plugins: [] })),
185
+ )
136
186
  throw new ValidationError(`Could not add "${key}":\n\n${result.output}`)
137
187
  }
138
188
 
189
+ const manifestFiles = BOARDS.map((board) => board.manifestFile).join(' and ')
190
+ const outputFiles = BOARDS.map((board) => board.outputFile).join(' and ')
139
191
  console.log(
140
- `Added "${key}" (${packageName}${entry.enabled ? '' : ', disabled'}) to board.plugins.json ` +
141
- 'and regenerated community.plugins.ts.',
192
+ `Added "${key}" (${packageName}${entry.enabled ? '' : ', disabled'}) to ${manifestFiles} ` +
193
+ `and regenerated ${outputFiles}.`,
142
194
  )
143
195
  console.log('Rebuild and redeploy for it to take effect.')
144
196
  return 0
@@ -152,25 +204,38 @@ export async function pluginRemove(args: readonly string[]): Promise<number> {
152
204
  throw new ValidationError('Usage: community plugin:remove <key>')
153
205
  }
154
206
 
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
- }
207
+ const originals = await Promise.all(BOARDS.map((board) => readManifestFor(board)))
164
208
 
165
- await writeManifest({ plugins: manifest.plugins.filter((entry) => entry.key !== key) })
209
+ originals.forEach(({ plugins }, index) => {
210
+ if (!plugins.some((entry) => entry.key === key)) {
211
+ const present = plugins.map((entry) => entry.key)
212
+ throw new ValidationError(
213
+ present.length === 0
214
+ ? `"${key}" is not in ${BOARDS[index]?.manifestFile} — it lists no plugins.`
215
+ : `"${key}" is not in ${BOARDS[index]?.manifestFile}. Present: ${present.join(', ')}.`,
216
+ )
217
+ }
218
+ })
219
+
220
+ await Promise.all(
221
+ BOARDS.map((board, index) =>
222
+ writeManifestFor(board, {
223
+ plugins: (originals[index]?.plugins ?? []).filter((entry) => entry.key !== key),
224
+ }),
225
+ ),
226
+ )
166
227
 
167
228
  const result = runGenerator()
168
229
  if (!result.ok) {
169
- await writeManifest(manifest)
230
+ await Promise.all(
231
+ BOARDS.map((board, index) => writeManifestFor(board, originals[index] ?? { plugins: [] })),
232
+ )
170
233
  throw new ValidationError(`Could not remove "${key}":\n\n${result.output}`)
171
234
  }
172
235
 
173
- console.log(`Removed "${key}" from board.plugins.json and regenerated community.plugins.ts.`)
236
+ const manifestFiles = BOARDS.map((board) => board.manifestFile).join(' and ')
237
+ const outputFiles = BOARDS.map((board) => board.outputFile).join(' and ')
238
+ console.log(`Removed "${key}" from ${manifestFiles} and regenerated ${outputFiles}.`)
174
239
  console.log('Rebuild and redeploy for it to take effect.')
175
240
  return 0
176
241
  }
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.17.1'
14
+ export const CODE_VERSION = '0.17.2'
15
15
 
16
16
  export function pluginUpgrades(plugins: readonly PluginDefinition[]): readonly PluginUpgrade[] {
17
17
  return plugins.map((plugin) => ({