@meith/cli 0.21.2 → 0.23.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 +0 -30
- package/package.json +13 -13
- package/src/backup.ts +85 -15
- package/src/board-eject.ts +4 -59
- package/src/context.ts +2 -1
- package/src/plugin-manifest.ts +0 -28
- package/src/plugins.ts +0 -6
- package/src/upgrade.ts +1 -7
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.
|
|
3
|
+
"version": "0.23.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/
|
|
27
|
-
"@meith/
|
|
28
|
-
"@meith/
|
|
29
|
-
"@meith/demo": "0.
|
|
30
|
-
"@meith/drivers": "0.
|
|
31
|
-
"@meith/forums": "0.
|
|
32
|
-
"@meith/plugin-kit": "0.
|
|
33
|
-
"@meith/profile-fields": "0.
|
|
34
|
-
"@meith/runtime": "0.
|
|
35
|
-
"@meith/
|
|
36
|
-
"
|
|
37
|
-
"
|
|
26
|
+
"@meith/accounts": "0.23.0",
|
|
27
|
+
"@meith/core": "0.23.0",
|
|
28
|
+
"@meith/db": "0.23.0",
|
|
29
|
+
"@meith/demo": "0.23.0",
|
|
30
|
+
"@meith/drivers": "0.23.0",
|
|
31
|
+
"@meith/forums": "0.23.0",
|
|
32
|
+
"@meith/plugin-kit": "0.23.0",
|
|
33
|
+
"@meith/profile-fields": "0.23.0",
|
|
34
|
+
"@meith/runtime": "0.23.0",
|
|
35
|
+
"@meith/settings": "0.23.0",
|
|
36
|
+
"@meith/tasks": "0.23.0",
|
|
37
|
+
"create-meith": "0.23.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
|
-
|
|
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
|
|
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
|
|
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<
|
|
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<
|
|
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.',
|
package/src/board-eject.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/src/context.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AccountStore,
|
|
3
3
|
type AuthConfig,
|
|
4
|
+
BAN_FILTERS_NOT_CONSULTED,
|
|
4
5
|
DEFAULT_AUTH_POLICY,
|
|
5
6
|
IdentityService,
|
|
6
7
|
resolveAuthPolicy,
|
|
@@ -69,7 +70,7 @@ export async function createContext(): Promise<CliContext> {
|
|
|
69
70
|
return {
|
|
70
71
|
db,
|
|
71
72
|
accounts: store,
|
|
72
|
-
identity: new IdentityService({ store, config }),
|
|
73
|
+
identity: new IdentityService({ store, config, banFilters: BAN_FILTERS_NOT_CONSULTED }),
|
|
73
74
|
forums: new PostgresForumRepository(db),
|
|
74
75
|
settings,
|
|
75
76
|
admin,
|
package/src/plugin-manifest.ts
CHANGED
|
@@ -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.
|
|
14
|
+
export const CODE_VERSION = '0.23.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.`)
|