@meith/cli 0.30.0 → 0.31.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.30.0",
3
+ "version": "0.31.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": {
@@ -22,19 +22,20 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
+ "@aws-sdk/client-s3": "3.1111.0",
25
26
  "tsx": "^4.23.12",
26
- "@meith/accounts": "0.30.0",
27
- "@meith/core": "0.30.0",
28
- "@meith/db": "0.30.0",
29
- "@meith/demo": "0.30.0",
30
- "@meith/drivers": "0.30.0",
31
- "@meith/forums": "0.30.0",
32
- "@meith/plugin-kit": "0.30.0",
33
- "@meith/profile-fields": "0.30.0",
34
- "@meith/settings": "0.30.0",
35
- "@meith/runtime": "0.30.0",
36
- "@meith/tasks": "0.30.0",
37
- "create-meith": "0.30.0"
27
+ "@meith/accounts": "0.31.0",
28
+ "@meith/core": "0.31.0",
29
+ "@meith/db": "0.31.0",
30
+ "@meith/demo": "0.31.0",
31
+ "@meith/drivers": "0.31.0",
32
+ "@meith/forums": "0.31.0",
33
+ "@meith/plugin-kit": "0.31.0",
34
+ "@meith/profile-fields": "0.31.0",
35
+ "@meith/runtime": "0.31.0",
36
+ "@meith/settings": "0.31.0",
37
+ "@meith/tasks": "0.31.0",
38
+ "create-meith": "0.31.0"
38
39
  },
39
40
  "devDependencies": {
40
41
  "esbuild": "^0.28.2"
@@ -0,0 +1,221 @@
1
+ import { createReadStream, createWriteStream } from 'node:fs'
2
+ import { pipeline } from 'node:stream/promises'
3
+
4
+ import {
5
+ DeleteObjectCommand,
6
+ GetObjectCommand,
7
+ ListObjectsV2Command,
8
+ PutObjectCommand,
9
+ S3Client,
10
+ } from '@aws-sdk/client-s3'
11
+
12
+ import { ConfigurationError, ValidationError } from '@meith/core'
13
+
14
+ export const BUNDLE_NAME_PATTERN = /^meith-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\.tar\.gz$/
15
+
16
+ export function isBundleName(name: string): boolean {
17
+ return BUNDLE_NAME_PATTERN.test(name)
18
+ }
19
+
20
+ export function pruneCandidates(names: readonly string[], keep: number): readonly string[] {
21
+ return names
22
+ .filter((name) => isBundleName(name))
23
+ .sort()
24
+ .reverse()
25
+ .slice(keep)
26
+ }
27
+
28
+ export const DEFAULT_KEEP = 7
29
+
30
+ export function resolveKeep(flag: string | undefined): number {
31
+ if (flag === undefined) return DEFAULT_KEEP
32
+ if (!/^\d+$/.test(flag) || Number(flag) < 1 || !Number.isSafeInteger(Number(flag))) {
33
+ throw new ValidationError(`--keep must be a whole number of bundles, 1 or more, got "${flag}".`)
34
+ }
35
+ return Number(flag)
36
+ }
37
+
38
+ export interface BackupDestinationConfig {
39
+ readonly bucket: string
40
+ readonly region: string
41
+ readonly accessKeyId: string
42
+ readonly secretAccessKey: string
43
+ readonly endpoint?: string | undefined
44
+ readonly prefix?: string | undefined
45
+ }
46
+
47
+ const BACKUP_DESTINATION_KEYS = [
48
+ 'BACKUP_S3_BUCKET',
49
+ 'BACKUP_S3_REGION',
50
+ 'BACKUP_S3_ACCESS_KEY_ID',
51
+ 'BACKUP_S3_SECRET_ACCESS_KEY',
52
+ ] as const
53
+
54
+ export function backupDestinationFromEnv(
55
+ environment: NodeJS.ProcessEnv,
56
+ ): BackupDestinationConfig | undefined {
57
+ const set = BACKUP_DESTINATION_KEYS.filter(
58
+ (key) => environment[key] !== undefined && environment[key] !== '',
59
+ )
60
+ if (set.length === 0) return undefined
61
+ if (set.length < BACKUP_DESTINATION_KEYS.length) {
62
+ const missing = BACKUP_DESTINATION_KEYS.filter((key) => !set.includes(key))
63
+ throw new ConfigurationError(
64
+ `An off-site backup destination is partly configured: ${set.join(', ')} without ` +
65
+ `${missing.join(', ')}. Set all four, or none.`,
66
+ )
67
+ }
68
+
69
+ const prefix = environment.BACKUP_S3_PREFIX?.replace(/^\/+|\/+$/g, '')
70
+ if (
71
+ prefix !== undefined &&
72
+ prefix !== '' &&
73
+ prefix.split('/').some((segment) => !/^[\w!.*'()-]+$/.test(segment) || /^\.+$/.test(segment))
74
+ ) {
75
+ throw new ConfigurationError(
76
+ 'BACKUP_S3_PREFIX must be one or more path segments of unreserved characters.',
77
+ )
78
+ }
79
+
80
+ return {
81
+ bucket: environment.BACKUP_S3_BUCKET as string,
82
+ region: environment.BACKUP_S3_REGION as string,
83
+ accessKeyId: environment.BACKUP_S3_ACCESS_KEY_ID as string,
84
+ secretAccessKey: environment.BACKUP_S3_SECRET_ACCESS_KEY as string,
85
+ endpoint: environment.BACKUP_S3_ENDPOINT || undefined,
86
+ prefix: prefix === '' ? undefined : prefix,
87
+ }
88
+ }
89
+
90
+ export interface S3Like {
91
+ send(command: unknown): Promise<unknown>
92
+ }
93
+
94
+ function isNotFound(error: unknown): boolean {
95
+ const name = (error as { name?: string } | null)?.name
96
+ const status = (error as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata
97
+ ?.httpStatusCode
98
+
99
+ return name === 'NoSuchKey' || name === 'NotFound' || status === 404
100
+ }
101
+
102
+ export interface RemoteBundle {
103
+ readonly name: string
104
+ readonly size: number
105
+ }
106
+
107
+ export class BackupStore {
108
+ private readonly sender: S3Like
109
+
110
+ constructor(
111
+ private readonly config: BackupDestinationConfig,
112
+ sender?: S3Like,
113
+ ) {
114
+ this.sender =
115
+ sender ??
116
+ new S3Client({
117
+ region: config.region,
118
+ credentials: {
119
+ accessKeyId: config.accessKeyId,
120
+ secretAccessKey: config.secretAccessKey,
121
+ },
122
+ ...(config.endpoint === undefined
123
+ ? {}
124
+ : ({
125
+ endpoint: config.endpoint,
126
+ forcePathStyle: true,
127
+ requestChecksumCalculation: 'WHEN_REQUIRED',
128
+ } as const)),
129
+ })
130
+ }
131
+
132
+ get destination(): string {
133
+ return this.config.prefix === undefined
134
+ ? `the ${this.config.bucket} bucket`
135
+ : `the ${this.config.bucket} bucket under ${this.config.prefix}/`
136
+ }
137
+
138
+ private key(name: string): string {
139
+ if (!isBundleName(name)) {
140
+ throw new ValidationError(`Not a backup bundle name: ${JSON.stringify(name)}`)
141
+ }
142
+ return this.config.prefix === undefined ? name : `${this.config.prefix}/${name}`
143
+ }
144
+
145
+ async putFile(name: string, filePath: string, size: number): Promise<void> {
146
+ await this.sender.send(
147
+ new PutObjectCommand({
148
+ Bucket: this.config.bucket,
149
+ Key: this.key(name),
150
+ Body: createReadStream(filePath),
151
+ ContentLength: size,
152
+ ContentType: 'application/gzip',
153
+ }),
154
+ )
155
+ }
156
+
157
+ async list(): Promise<readonly RemoteBundle[]> {
158
+ const prefix = this.config.prefix === undefined ? '' : `${this.config.prefix}/`
159
+ const bundles: RemoteBundle[] = []
160
+ let continuationToken: string | undefined
161
+
162
+ do {
163
+ const response = (await this.sender.send(
164
+ new ListObjectsV2Command({
165
+ Bucket: this.config.bucket,
166
+ ...(prefix === '' ? {} : { Prefix: prefix }),
167
+ ...(continuationToken === undefined ? {} : { ContinuationToken: continuationToken }),
168
+ }),
169
+ )) as {
170
+ Contents?: readonly { Key?: string; Size?: number }[]
171
+ IsTruncated?: boolean
172
+ NextContinuationToken?: string
173
+ }
174
+
175
+ for (const object of response.Contents ?? []) {
176
+ if (object.Key === undefined || !object.Key.startsWith(prefix)) continue
177
+ const name = object.Key.slice(prefix.length)
178
+ if (isBundleName(name)) bundles.push({ name, size: object.Size ?? 0 })
179
+ }
180
+
181
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
182
+ } while (continuationToken !== undefined)
183
+
184
+ return bundles.sort((a, b) => a.name.localeCompare(b.name))
185
+ }
186
+
187
+ async getToFile(name: string, outPath: string): Promise<void> {
188
+ let response: { Body?: NodeJS.ReadableStream }
189
+ try {
190
+ response = (await this.sender.send(
191
+ new GetObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
192
+ )) as { Body?: NodeJS.ReadableStream }
193
+ } catch (error) {
194
+ if (isNotFound(error)) {
195
+ throw new ValidationError(
196
+ `${this.destination} has no bundle named ${name}. meith backup:list names what it holds.`,
197
+ )
198
+ }
199
+ throw error
200
+ }
201
+ if (response.Body === undefined) {
202
+ throw new ConfigurationError(`${this.destination} answered without a body for ${name}.`)
203
+ }
204
+ await pipeline(response.Body, createWriteStream(outPath, { mode: 0o600 }))
205
+ }
206
+
207
+ async delete(name: string): Promise<void> {
208
+ await this.sender.send(
209
+ new DeleteObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
210
+ )
211
+ }
212
+
213
+ async prune(keep: number): Promise<readonly string[]> {
214
+ const stale = pruneCandidates(
215
+ (await this.list()).map((bundle) => bundle.name),
216
+ keep,
217
+ )
218
+ for (const name of stale) await this.delete(name)
219
+ return stale
220
+ }
221
+ }
package/src/backup.ts CHANGED
@@ -19,6 +19,13 @@ import { migrationUrl, runMigrations } from '@meith/db'
19
19
  import { BlobFileStore, S3FileStore, unusableKeyReason } from '@meith/drivers'
20
20
 
21
21
  import { optional, parseFlags } from './args'
22
+ import {
23
+ BackupStore,
24
+ backupDestinationFromEnv,
25
+ isBundleName,
26
+ pruneCandidates,
27
+ resolveKeep,
28
+ } from './backup-store'
22
29
  import { requirePostgres } from './context'
23
30
  import { CODE_VERSION } from './upgrade'
24
31
  import { translateWriteError } from './write-errors'
@@ -537,8 +544,24 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
537
544
  requirePostgres()
538
545
 
539
546
  const mode = resolveUploadsMode(env.FILESTORE_DRIVER, optional(flags, 'uploads'))
547
+ const outFlag = optional(flags, 'out')
548
+ const dirFlag = optional(flags, 'dir')
549
+ if (outFlag !== undefined && dirFlag !== undefined) {
550
+ throw new ValidationError('--out and --dir are two answers to one question; pass one.')
551
+ }
552
+ const offsite = backupDestinationFromEnv(process.env)
553
+ const keepFlag = optional(flags, 'keep')
554
+ if (keepFlag !== undefined && dirFlag === undefined && offsite === undefined) {
555
+ throw new ValidationError(
556
+ '--keep prunes a ring of bundles, so it needs --dir, an off-site destination ' +
557
+ '(BACKUP_S3_*), or both.',
558
+ )
559
+ }
560
+ const keep = resolveKeep(keepFlag)
540
561
  const now = new Date()
541
- const out = path.resolve(optional(flags, 'out') ?? bundleName(now))
562
+ const name = bundleName(now)
563
+ if (dirFlag !== undefined) await mkdir(path.resolve(dirFlag), { recursive: true })
564
+ const out = path.resolve(dirFlag === undefined ? (outFlag ?? name) : path.join(dirFlag, name))
542
565
 
543
566
  const stage = await mkdtemp(path.join(tmpdir(), 'meith-backup-'))
544
567
  let destinationCreated = false
@@ -579,6 +602,7 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
579
602
  await chmod(out, 0o600)
580
603
 
581
604
  const size = (await stat(out)).size
605
+ destinationCreated = false
582
606
  console.log(
583
607
  `Wrote ${out} (${formatBytes(size)}): the database dump${
584
608
  uploads === 'included' ? ' and the uploads' : ', no uploads'
@@ -593,11 +617,37 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
593
617
  if (uploads === 'skipped' && mode === 'skip') {
594
618
  console.log('Restoring this bundle gives a board whose posts have broken images.')
595
619
  }
596
- console.log(
597
- 'Copy the bundle off this machine: a backup on the server is a backup of the ' +
598
- 'thing most likely to fail.',
599
- )
600
- destinationCreated = false
620
+
621
+ if (offsite !== undefined) {
622
+ const store = new BackupStore(offsite)
623
+ await store.putFile(name, out, size)
624
+ console.log(`Shipped ${name} to ${store.destination}.`)
625
+ const prunedRemote = await store.prune(keep)
626
+ if (prunedRemote.length > 0) {
627
+ console.log(
628
+ `Pruned ${prunedRemote.length} bundle(s) there beyond the newest ${keep}: ` +
629
+ `${prunedRemote.join(', ')}.`,
630
+ )
631
+ }
632
+ } else {
633
+ console.log(
634
+ 'Copy the bundle off this machine: a backup on the server is a backup of the ' +
635
+ 'thing most likely to fail.',
636
+ )
637
+ }
638
+
639
+ if (dirFlag !== undefined) {
640
+ const dir = path.resolve(dirFlag)
641
+ const stale = pruneCandidates(await readdir(dir), keep)
642
+ for (const staleName of stale) await rm(path.join(dir, staleName), { force: true })
643
+ if (stale.length > 0) {
644
+ console.log(
645
+ `Pruned ${stale.length} bundle(s) in ${dir} beyond the newest ${keep}: ` +
646
+ `${stale.join(', ')}.`,
647
+ )
648
+ }
649
+ }
650
+
601
651
  if (skippedKeys.length === 0) return 0
602
652
 
603
653
  console.warn(
@@ -821,3 +871,87 @@ export async function restoreCommand(args: readonly string[]): Promise<number> {
821
871
  await rm(stage, { recursive: true, force: true })
822
872
  }
823
873
  }
874
+
875
+ async function localBundles(dir: string): Promise<readonly { name: string; size: number }[]> {
876
+ const names = await readdir(dir).catch((error: NodeJS.ErrnoException) => {
877
+ if (error.code === 'ENOENT') return []
878
+ throw error
879
+ })
880
+ const bundles = []
881
+ for (const name of names.filter((entry) => isBundleName(entry)).sort()) {
882
+ bundles.push({ name, size: (await stat(path.join(dir, name))).size })
883
+ }
884
+ return bundles
885
+ }
886
+
887
+ export async function backupListCommand(args: readonly string[]): Promise<number> {
888
+ const { flags } = parseFlags(args)
889
+ const dirFlag = optional(flags, 'dir')
890
+ const offsite = backupDestinationFromEnv(process.env)
891
+ if (dirFlag === undefined && offsite === undefined) {
892
+ throw new ValidationError(
893
+ 'Nothing to list: pass --dir <dir> for a local ring, or set BACKUP_S3_BUCKET, ' +
894
+ 'BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID and BACKUP_S3_SECRET_ACCESS_KEY ' +
895
+ 'for the off-site destination.',
896
+ )
897
+ }
898
+
899
+ if (dirFlag !== undefined) {
900
+ const dir = path.resolve(dirFlag)
901
+ const bundles = await localBundles(dir)
902
+ console.log(`${dir}:`)
903
+ if (bundles.length === 0) console.log(' no bundles')
904
+ for (const bundle of bundles) {
905
+ console.log(` ${bundle.name} ${formatBytes(bundle.size)}`)
906
+ }
907
+ }
908
+
909
+ if (offsite !== undefined) {
910
+ const store = new BackupStore(offsite)
911
+ const bundles = await store.list()
912
+ console.log(`${store.destination}:`)
913
+ if (bundles.length === 0) console.log(' no bundles')
914
+ for (const bundle of bundles) {
915
+ console.log(` ${bundle.name} ${formatBytes(bundle.size)}`)
916
+ }
917
+ }
918
+
919
+ return 0
920
+ }
921
+
922
+ const FETCH_USAGE = 'Usage: meith backup:fetch <meith-backup-….tar.gz> [--out <path>]'
923
+
924
+ export async function backupFetchCommand(args: readonly string[]): Promise<number> {
925
+ const { flags, positional } = parseFlags(args)
926
+
927
+ const name = positional[0]
928
+ if (name === undefined) throw new ValidationError(FETCH_USAGE)
929
+ if (!isBundleName(path.basename(name))) {
930
+ throw new ValidationError(
931
+ `Not a backup bundle name: ${JSON.stringify(name)}. meith backup:list names what ` +
932
+ 'the destination holds.',
933
+ )
934
+ }
935
+
936
+ const offsite = backupDestinationFromEnv(process.env)
937
+ if (offsite === undefined) {
938
+ throw new ConfigurationError(
939
+ 'backup:fetch downloads from the off-site destination, so it needs BACKUP_S3_BUCKET, ' +
940
+ 'BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID and BACKUP_S3_SECRET_ACCESS_KEY.',
941
+ )
942
+ }
943
+
944
+ const store = new BackupStore(offsite)
945
+ const out = path.resolve(optional(flags, 'out') ?? path.basename(name))
946
+ await claimBackupDestination(out)
947
+ try {
948
+ await store.getToFile(path.basename(name), out)
949
+ } catch (error) {
950
+ await rm(out, { force: true })
951
+ throw error
952
+ }
953
+
954
+ console.log(`Fetched ${out} (${formatBytes((await stat(out)).size)}) from ${store.destination}.`)
955
+ console.log(`Restore it with: ${RESTORE_USAGE.replace('Usage: ', '')}`)
956
+ return 0
957
+ }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import process from 'node:process'
3
3
 
4
4
  import { type LoadedEnvFiles, loadEnvFiles } from '@meith/core/env-files'
5
5
 
6
- import { backupCommand, restoreCommand } from './backup'
6
+ import { backupCommand, backupFetchCommand, backupListCommand, restoreCommand } from './backup'
7
7
  import {
8
8
  forumCreate,
9
9
  settingDisplayValue,
@@ -166,10 +166,33 @@ const commands: Command[] = [
166
166
  {
167
167
  name: 'backup',
168
168
  summary: 'Dump the database and the uploads into one restorable bundle.',
169
- usage: 'meith backup [--out <path>] [--uploads include|skip]',
169
+ usage: [
170
+ 'meith backup [--out <path> | --dir <dir>] [--keep <n>] [--uploads include|skip]',
171
+ '',
172
+ '--dir writes a timestamped bundle into a directory and, after a successful',
173
+ 'write, prunes bundles there beyond the newest --keep (7 unless set).',
174
+ 'With BACKUP_S3_BUCKET, BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID and',
175
+ 'BACKUP_S3_SECRET_ACCESS_KEY set (BACKUP_S3_ENDPOINT and BACKUP_S3_PREFIX',
176
+ 'optional), every bundle is also shipped to that S3-compatible destination',
177
+ 'and pruned there to the same --keep.',
178
+ ].join('\n'),
170
179
  run: backupCommand,
171
180
  },
172
181
 
182
+ {
183
+ name: 'backup:list',
184
+ summary: 'List the backup bundles on local disk and at the off-site destination.',
185
+ usage: 'meith backup:list [--dir <dir>]',
186
+ run: backupListCommand,
187
+ },
188
+
189
+ {
190
+ name: 'backup:fetch',
191
+ summary: 'Download one bundle from the off-site destination (BACKUP_S3_*).',
192
+ usage: 'meith backup:fetch <meith-backup-….tar.gz> [--out <path>]',
193
+ run: backupFetchCommand,
194
+ },
195
+
173
196
  {
174
197
  name: 'restore',
175
198
  summary: 'Restore a backup bundle into a new, empty database.',
package/src/redaction.ts CHANGED
@@ -10,6 +10,7 @@ export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
10
10
  'RESEND_API_KEY',
11
11
  'REDIS_URL',
12
12
  'S3_SECRET_ACCESS_KEY',
13
+ 'BACKUP_S3_SECRET_ACCESS_KEY',
13
14
  'BLOB_READ_WRITE_TOKEN',
14
15
  ])
15
16
 
@@ -19,6 +20,8 @@ export const NOT_SECRET_DESPITE_THE_NAME: ReadonlySet<string> = new Set([
19
20
  'S3_PUBLIC_BASE_URL',
20
21
  'APP_URL',
21
22
  'S3_ACCESS_KEY_ID',
23
+ 'BACKUP_S3_ACCESS_KEY_ID',
24
+ 'BACKUP_S3_ENDPOINT',
22
25
  'MAIL_SMTP_USERNAME',
23
26
  ])
24
27
 
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.30.0'
14
+ export const CODE_VERSION = '0.31.0'
15
15
 
16
16
  export function pluginUpgrades(plugins: readonly PluginDefinition[]): readonly PluginUpgrade[] {
17
17
  return plugins.map((plugin) => ({