@meith/cli 0.21.1 → 0.22.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 CHANGED
@@ -1,28 +1,4 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * `community` — the bin that makes `@meith/cli` runnable against an external
4
- * board workspace, on the same footing as `forum-web` (see
5
- * apps/community/bin/forum-web.mjs and docs/development.md).
6
- *
7
- * `apps/cli/src/index.ts` reaches the board-config seam with a *dynamic*
8
- * `await import('@board/plugins')` (never a static one — see
9
- * docs/architecture.md), so unlike the release image's own bundled CLI —
10
- * which bakes in whichever board it was built next to — this one resolves
11
- * that seam at the moment it actually runs, against whichever workspace it
12
- * was invoked from. So the same materialize-then-run trick as `forum-web`
13
- * applies, with `tsx` standing in for `next`: this package ships TypeScript
14
- * source with no build step, `tsx` is already how it runs inside this
15
- * monorepo (`pnpm community` is `tsx src/index.ts`), and a tsconfig it reads
16
- * from the nearest ancestor directory is a mechanism it already has, so
17
- * pointing that tsconfig's `@board/config` / `@board/plugins` at the
18
- * invoking workspace's own files is the same seam-resolution mechanism
19
- * `forum-web` uses for the Next app, applied through the tool this package
20
- * already runs through.
21
- *
22
- * Unlike `forum-web`, this does not change the working directory: the CLI's
23
- * own `.env` loading is relative to wherever the operator invoked it from,
24
- * and that should stay the workspace root.
25
- */
26
2
  import { spawn } from 'node:child_process'
27
3
  import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
28
4
  import { createRequire } from 'node:module'
@@ -92,12 +68,6 @@ function materialize() {
92
68
  return join(srcTarget, 'index.ts')
93
69
  }
94
70
 
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
- */
101
71
  function resolveTsx() {
102
72
  const require = createRequire(join(packageRoot, 'package.json'))
103
73
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/cli",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
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": "MIT",
6
6
  "repository": {
@@ -23,18 +23,18 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "tsx": "^4.23.12",
26
- "@meith/accounts": "0.21.1",
27
- "@meith/core": "0.21.1",
28
- "@meith/db": "0.21.1",
29
- "@meith/demo": "0.21.1",
30
- "@meith/drivers": "0.21.1",
31
- "@meith/forums": "0.21.1",
32
- "@meith/plugin-kit": "0.21.1",
33
- "@meith/profile-fields": "0.21.1",
34
- "@meith/settings": "0.21.1",
35
- "@meith/tasks": "0.21.1",
36
- "create-meith": "0.21.1",
37
- "@meith/runtime": "0.21.1"
26
+ "@meith/accounts": "0.22.0",
27
+ "@meith/core": "0.22.0",
28
+ "@meith/demo": "0.22.0",
29
+ "@meith/db": "0.22.0",
30
+ "@meith/drivers": "0.22.0",
31
+ "@meith/plugin-kit": "0.22.0",
32
+ "@meith/profile-fields": "0.22.0",
33
+ "@meith/forums": "0.22.0",
34
+ "@meith/runtime": "0.22.0",
35
+ "@meith/settings": "0.22.0",
36
+ "@meith/tasks": "0.22.0",
37
+ "create-meith": "0.22.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "esbuild": "^0.28.2"
package/src/backup.ts CHANGED
@@ -16,7 +16,7 @@ import path from 'node:path'
16
16
 
17
17
  import { ConfigurationError, env, type FileStore, ValidationError } from '@meith/core'
18
18
  import { migrationUrl, runMigrations } from '@meith/db'
19
- import { BlobFileStore, S3FileStore } from '@meith/drivers'
19
+ import { BlobFileStore, S3FileStore, unusableKeyReason } from '@meith/drivers'
20
20
 
21
21
  import { optional, parseFlags } from './args'
22
22
  import { requirePostgres } from './context'
@@ -33,8 +33,11 @@ export interface BackupManifest {
33
33
  readonly filestore: FilestoreDriver
34
34
  readonly uploads: 'included' | 'skipped'
35
35
  readonly bucket?: string
36
+ readonly skippedKeys?: readonly string[]
36
37
  }
37
38
 
39
+ const INCOMPLETE_BUNDLE_EXIT_CODE = 2
40
+
38
41
  export function resolveUploadsMode(driver: FilestoreDriver, flag: string | undefined): UploadsMode {
39
42
  if (flag === undefined) return driver === 's3' ? 'skip' : 'include'
40
43
  if (flag === 'include' || flag === 'skip') return flag
@@ -77,6 +80,14 @@ export function parseManifest(raw: string): BackupManifest {
77
80
  throw new ValidationError('The bundle manifest does not name a known file driver.')
78
81
  }
79
82
 
83
+ const skippedKeys = manifest.skippedKeys
84
+ if (
85
+ skippedKeys !== undefined &&
86
+ (!Array.isArray(skippedKeys) || skippedKeys.some((key) => typeof key !== 'string'))
87
+ ) {
88
+ throw new ValidationError('The bundle manifest lists skipped objects in a form it cannot read.')
89
+ }
90
+
80
91
  return {
81
92
  format: 1,
82
93
  createdAt: manifest.createdAt,
@@ -84,6 +95,7 @@ export function parseManifest(raw: string): BackupManifest {
84
95
  filestore: manifest.filestore,
85
96
  uploads: manifest.uploads,
86
97
  ...(typeof manifest.bucket === 'string' ? { bucket: manifest.bucket } : {}),
98
+ ...(skippedKeys === undefined || skippedKeys.length === 0 ? {} : { skippedKeys }),
87
99
  }
88
100
  }
89
101
 
@@ -370,18 +382,35 @@ export async function reserveBackupDestination(destination: string): Promise<voi
370
382
  await file.close()
371
383
  }
372
384
 
373
- async function stageLocalUploads(stage: string): Promise<'included' | 'skipped'> {
385
+ interface StagedUploads {
386
+ readonly uploads: 'included' | 'skipped'
387
+ readonly skippedKeys: readonly string[]
388
+ }
389
+
390
+ const SKIPPED_KEYS_LISTED = 10
391
+
392
+ export function skippedKeyLines(keys: readonly string[]): readonly string[] {
393
+ const shown = keys.slice(0, SKIPPED_KEYS_LISTED)
394
+ return [
395
+ ...shown.map((key) => ` ${JSON.stringify(key)}`),
396
+ ...(keys.length > shown.length
397
+ ? [` …and ${keys.length - shown.length} more, listed in the bundle's manifest.json.`]
398
+ : []),
399
+ ]
400
+ }
401
+
402
+ async function stageLocalUploads(stage: string): Promise<StagedUploads> {
374
403
  const exists = await stat(env.UPLOADS_DIR).then(
375
404
  (info) => info.isDirectory(),
376
405
  () => false,
377
406
  )
378
407
  if (!exists) {
379
408
  console.log(`No uploads directory at ${env.UPLOADS_DIR}; the bundle carries none.`)
380
- return 'skipped'
409
+ return { uploads: 'skipped', skippedKeys: [] }
381
410
  }
382
411
 
383
412
  await run('tar', ['czf', path.join(stage, 'uploads.tar.gz'), '-C', env.UPLOADS_DIR, '.'])
384
- return 'included'
413
+ return { uploads: 'included', skippedKeys: [] }
385
414
  }
386
415
 
387
416
  export interface ListableStore {
@@ -389,13 +418,29 @@ export interface ListableStore {
389
418
  get(key: string): Promise<Uint8Array | undefined>
390
419
  }
391
420
 
392
- export async function drainStoreToDirectory(store: ListableStore, dir: string): Promise<number> {
421
+ export interface DrainedStore {
422
+ readonly pulled: number
423
+ readonly skipped: readonly string[]
424
+ }
425
+
426
+ export async function drainStoreToDirectory(
427
+ store: ListableStore,
428
+ dir: string,
429
+ ): Promise<DrainedStore> {
393
430
  let pulled = 0
431
+ const skipped: string[] = []
394
432
 
395
433
  for await (const key of store.listKeys()) {
396
434
  const target = path.resolve(dir, key)
397
435
  if (target !== dir && !target.startsWith(dir + path.sep)) {
398
- console.warn(`Skipping object with an unsafe key: ${key}`)
436
+ console.warn(`Skipping the object at ${JSON.stringify(key)}: its key escapes ${dir}.`)
437
+ skipped.push(key)
438
+ continue
439
+ }
440
+ const unusable = unusableKeyReason(key)
441
+ if (unusable !== undefined) {
442
+ console.warn(`Skipping the object at ${JSON.stringify(key)}: its key ${unusable}.`)
443
+ skipped.push(key)
399
444
  continue
400
445
  }
401
446
  const body = await store.get(key)
@@ -405,7 +450,7 @@ export async function drainStoreToDirectory(store: ListableStore, dir: string):
405
450
  pulled++
406
451
  }
407
452
 
408
- return pulled
453
+ return { pulled, skipped }
409
454
  }
410
455
 
411
456
  export async function uploadDirectoryToStore(
@@ -430,26 +475,26 @@ async function stageObjectStoreUploads(
430
475
  stage: string,
431
476
  store: ListableStore,
432
477
  origin: string,
433
- ): Promise<'included' | 'skipped'> {
478
+ ): Promise<StagedUploads> {
434
479
  const dir = path.join(stage, 'uploads')
435
480
  await mkdir(dir, { recursive: true })
436
481
 
437
- const pulled = await drainStoreToDirectory(store, dir)
482
+ const { pulled, skipped } = await drainStoreToDirectory(store, dir)
438
483
 
439
484
  if (pulled === 0) {
440
485
  console.log(`Found no objects in ${origin}; the bundle carries no uploads.`)
441
486
  await rm(dir, { recursive: true, force: true })
442
- return 'skipped'
487
+ return { uploads: 'skipped', skippedKeys: skipped }
443
488
  }
444
489
 
445
490
  await run('tar', ['czf', path.join(stage, 'uploads.tar.gz'), '-C', dir, '.'])
446
491
  await rm(dir, { recursive: true, force: true })
447
492
  console.log(`Pulled ${pulled} object(s) from ${origin}.`)
448
- return 'included'
493
+ return { uploads: 'included', skippedKeys: skipped }
449
494
  }
450
495
 
451
- async function stageUploads(stage: string, mode: UploadsMode): Promise<'included' | 'skipped'> {
452
- if (mode === 'skip') return 'skipped'
496
+ async function stageUploads(stage: string, mode: UploadsMode): Promise<StagedUploads> {
497
+ if (mode === 'skip') return { uploads: 'skipped', skippedKeys: [] }
453
498
 
454
499
  switch (env.FILESTORE_DRIVER) {
455
500
  case 's3':
@@ -490,7 +535,7 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
490
535
  databaseEnvironment,
491
536
  )
492
537
 
493
- const uploads = await stageUploads(stage, mode)
538
+ const { uploads, skippedKeys } = await stageUploads(stage, mode)
494
539
 
495
540
  const manifest: BackupManifest = {
496
541
  format: 1,
@@ -499,6 +544,7 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
499
544
  filestore: env.FILESTORE_DRIVER,
500
545
  uploads,
501
546
  ...(env.S3_BUCKET === undefined ? {} : { bucket: env.S3_BUCKET }),
547
+ ...(skippedKeys.length === 0 ? {} : { skippedKeys }),
502
548
  }
503
549
  await writeFile(path.join(stage, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
504
550
 
@@ -529,7 +575,19 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
529
575
  'thing most likely to fail.',
530
576
  )
531
577
  destinationCreated = false
532
- return 0
578
+ if (skippedKeys.length === 0) return 0
579
+
580
+ console.warn(
581
+ `\nThis bundle is missing ${skippedKeys.length} object(s) whose keys nothing can read:`,
582
+ )
583
+ for (const line of skippedKeyLines(skippedKeys)) console.warn(line)
584
+ console.warn(
585
+ 'The bundle itself is sound and restores normally — posts referring to those ' +
586
+ 'objects will have broken images. The manifest carries the list, so the ' +
587
+ `restore says so too. Exiting ${INCOMPLETE_BUNDLE_EXIT_CODE} rather than 0 so a ` +
588
+ 'scheduled backup does not record this run as a clean one.',
589
+ )
590
+ return INCOMPLETE_BUNDLE_EXIT_CODE
533
591
  } finally {
534
592
  if (destinationCreated) await rm(out, { force: true })
535
593
  await rm(stage, { recursive: true, force: true })
@@ -719,6 +777,18 @@ export async function restoreCommand(args: readonly string[]): Promise<number> {
719
777
  )
720
778
  }
721
779
 
780
+ if (manifest.skippedKeys !== undefined) {
781
+ console.warn(
782
+ `\nThe backup that made this bundle could not read ${manifest.skippedKeys.length} ` +
783
+ 'object(s), so they are not here:',
784
+ )
785
+ for (const line of skippedKeyLines(manifest.skippedKeys)) console.warn(line)
786
+ console.warn(
787
+ 'Posts referring to them have broken images. Those keys were unusable in the ' +
788
+ 'source store, so another backup of the same board would skip them again.',
789
+ )
790
+ }
791
+
722
792
  console.log(
723
793
  'Point a staging deployment at the restored database, sign in as an ' +
724
794
  'administrator, and open a thread with attachments before trusting it.',
@@ -8,15 +8,6 @@ import { ValidationError } from '@meith/core'
8
8
 
9
9
  import { CODE_VERSION } from './upgrade'
10
10
 
11
- /**
12
- * apps/cli/src/plugin-manifest.ts and this file are the same distance from
13
- * the repository root (apps/cli/{src,dist}/<file> either way) — see that
14
- * file's own comment. This default is only reached from *this* checkout
15
- * (`pnpm community board:eject`, tests): the deployed image sets
16
- * `BOARD_PLUGINS_MANIFEST` explicitly (see docker/Dockerfile), because a
17
- * bundled `dist/cli.cjs` does not sit at that same distance — Docker's own
18
- * `COPY apps/cli/dist/ ./apps/cli/` drops the `dist` segment.
19
- */
20
11
  function defaultManifestPath(): string {
21
12
  return join(
22
13
  fileURLToPath(new URL('../../../', import.meta.url)),
@@ -38,22 +29,10 @@ function manifestPath(): string {
38
29
  return process.env.BOARD_PLUGINS_MANIFEST ?? defaultManifestPath()
39
30
  }
40
31
 
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
32
  const PLUGIN_KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
47
33
  const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
48
34
  const NPM_PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/
49
35
 
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
36
  export function validateEjectManifest(plugins: readonly ManifestEntry[], path: string): void {
58
37
  const seen = new Set<string>()
59
38
  const identifiers = new Map<string, string>()
@@ -112,14 +91,6 @@ export function validateEjectManifest(plugins: readonly ManifestEntry[], path: s
112
91
  }
113
92
  }
114
93
 
115
- /**
116
- * The manifest this build actually compiled in. Unlike
117
- * apps/cli/src/plugin-manifest.ts's readManifest — which is written for a
118
- * checkout and refuses to run against a deployed image — this one is meant
119
- * to run *only* against a deployed image (or this checkout's own
120
- * boards/stock, standing in for one), so a missing file is the real failure
121
- * this command exists to report, not an expected outcome.
122
- */
123
94
  async function readManifest(): Promise<Manifest> {
124
95
  const path = manifestPath()
125
96
  let raw: string
@@ -163,14 +134,6 @@ interface EjectedPackageJson {
163
134
  [key: string]: unknown
164
135
  }
165
136
 
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
137
  function mergePluginDependencies(packageJson: string, plugins: readonly ManifestEntry[]): string {
175
138
  const parsed = JSON.parse(packageJson) as EjectedPackageJson
176
139
  for (const entry of plugins) {
@@ -181,20 +144,6 @@ function mergePluginDependencies(packageJson: string, plugins: readonly Manifest
181
144
  return `${JSON.stringify(parsed, null, 2)}\n`
182
145
  }
183
146
 
184
- /**
185
- * The ejected workspace's own community.plugins.ts — the same shape
186
- * create-meith's scaffold() writes for a plugin-free board (see
187
- * packages/create-meith/src/scaffold.ts), extended with one entry per
188
- * manifest plugin. Never the showcase-wired shape scripts/board-plugins.mjs
189
- * generates for apps/community and boards/stock: `./community.demo.plugins`
190
- * is this monorepo's own demo/test scaffolding and does not exist in a
191
- * workspace outside it — the same reason scaffold.ts's own template omits
192
- * it. In practice the manifest a real stock image compiles in is always
193
- * empty (plugin:add refuses to run against a deployed image — see
194
- * plugin-manifest.ts — so a running stock image can never have grown one),
195
- * but this reads the real file rather than assuming that, so a future
196
- * default plugin would still be captured correctly.
197
- */
198
147
  function renderInstalledPluginsModule(plugins: readonly ManifestEntry[]): string {
199
148
  const importLines = plugins.map((entry) => {
200
149
  const name = toIdentifier(entry.key)
@@ -229,19 +178,13 @@ function isFsPermissionError(error: unknown): error is NodeJS.ErrnoException {
229
178
  return code === 'EACCES' || code === 'EPERM'
230
179
  }
231
180
 
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
181
  function translateWriteError(error: unknown, target: string, path: string): never {
239
182
  if (isFsPermissionError(error)) {
240
183
  throw new ValidationError(
241
184
  `board:eject could not write to ${path}: permission denied. The account running this ` +
242
185
  `command needs write access to ${target} — inside the official image that account is ` +
243
186
  '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, ' +
187
+ '(or writable by) that same account before eject runs. See docs/customization/marketplace.md, ' +
245
188
  '"Moving to a custom board", for the exact invocation.',
246
189
  )
247
190
  }
@@ -309,7 +252,9 @@ export async function boardEject(args: readonly string[]): Promise<number> {
309
252
  console.log(' # point Coolify at this repository and set MEITH_IMAGE to the pushed image')
310
253
  console.log(' # redeploy — same database, same uploads, same secrets, new image source')
311
254
  console.log('')
312
- console.log('See docs/marketplace.md, "Moving to a custom board", for the full walkthrough.')
255
+ console.log(
256
+ 'See docs/customization/marketplace.md, "Moving to a custom board", for the full walkthrough.',
257
+ )
313
258
 
314
259
  return 0
315
260
  }
@@ -8,11 +8,6 @@ import { ValidationError } from '@meith/core'
8
8
  import BOARDS_JSON from '../../../scripts/boards.json'
9
9
  import { optional, parseFlags } from './args'
10
10
 
11
- /**
12
- * apps/cli/src/plugin-manifest.ts and scripts/board-plugins-gen.mjs are the same
13
- * distance from the repository root (apps/cli/{src,dist}/<file> either way), so this
14
- * offset holds whether these commands run from source (tsx) or the built dist/cli.cjs.
15
- */
16
11
  function repoRoot(): string {
17
12
  return fileURLToPath(new URL('../../../', import.meta.url))
18
13
  }
@@ -21,12 +16,6 @@ function generatorScript(): string {
21
16
  return join(repoRoot(), 'scripts/board-plugins-gen.mjs')
22
17
  }
23
18
 
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
19
  interface Board {
31
20
  readonly manifestFile: string
32
21
  readonly packageFile: string
@@ -51,14 +40,6 @@ interface Manifest {
51
40
  readonly plugins: readonly ManifestEntry[]
52
41
  }
53
42
 
54
- /**
55
- * `plugin:add`/`plugin:remove` edit source files and rebuild output that only exists in
56
- * a checkout — unlike `plugin:purge`, which acts on a running board's database and is the
57
- * one meant to run as `docker compose run --rm web community plugin:purge`. The deployed
58
- * image is built `FROM node:26-alpine` with only `.next/standalone`, the worker and this
59
- * CLI's own bundle copied in (see docker/Dockerfile) — no `scripts/`, no `board.plugins.json`,
60
- * no Biome. Reading a manifest is where that shows up first, so this is where it is named.
61
- */
62
43
  async function readManifestFor(board: Board): Promise<Manifest> {
63
44
  const path = join(boardsRoot(), board.manifestFile)
64
45
  let raw: string
@@ -105,14 +86,6 @@ interface GeneratorResult {
105
86
  readonly output: string
106
87
  }
107
88
 
108
- /**
109
- * The one thing this file trusts to know whether a manifest edit is valid: the same
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.
115
- */
116
89
  function runGenerator(): GeneratorResult {
117
90
  try {
118
91
  const output = execFileSync(process.execPath, [generatorScript()], {
@@ -129,7 +102,6 @@ function runGenerator(): GeneratorResult {
129
102
 
130
103
  const PACKAGE_KEY_PATTERN = /^@[^/]+\/plugin-([a-z][a-z0-9-]*)$/
131
104
 
132
- /** `@scope/plugin-<key>` is the only shape a key can be read from without asking. */
133
105
  export function inferKey(packageName: string): string | undefined {
134
106
  return PACKAGE_KEY_PATTERN.exec(packageName)?.[1]
135
107
  }
package/src/plugins.ts CHANGED
@@ -9,12 +9,6 @@ export interface PurgeOptions {
9
9
  readonly log: (line: string) => void
10
10
  }
11
11
 
12
- /**
13
- * The one moment `onUninstall` can run: the operator has decided the plugin is
14
- * going, and its code is still in the build. Afterwards there is nothing left
15
- * to call — removing a plugin is `pnpm remove`, a line out of
16
- * `community.plugins.ts` and a redeploy, and by then the function is gone.
17
- */
18
12
  export async function purge(options: PurgeOptions): Promise<number> {
19
13
  const definition = options.plugins.find((plugin) => plugin.key === options.key)
20
14
 
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.21.1'
14
+ export const CODE_VERSION = '0.22.0'
15
15
 
16
16
  export function pluginUpgrades(plugins: readonly PluginDefinition[]): readonly PluginUpgrade[] {
17
17
  return plugins.map((plugin) => ({
@@ -90,12 +90,6 @@ export async function upgrade(options: UpgradeOptions): Promise<number> {
90
90
  const ran = await applyPluginMigration(db, plugin.key, migration.id, migration.statements)
91
91
  if (ran) options.log(`${plugin.key}: applied ${migration.id}.`)
92
92
  }
93
- /*
94
- * onInstall runs after this plugin's migrations, so its tables exist, and
95
- * before the version row that will stop it running again. A throw here
96
- * stops the upgrade: a plugin that could not finish installing is one the
97
- * board should not start serving.
98
- */
99
93
  if (fresh.includes(plugin.key) && definition !== undefined) {
100
94
  const { ran } = await runPluginLifecycle({ db, plugin: definition, phase: 'install' })
101
95
  if (ran) options.log(`${plugin.key}: onInstall.`)