@meith/cli 0.33.4 → 0.34.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/src/index.ts CHANGED
@@ -113,6 +113,10 @@ const commands: Command[] = [
113
113
  )
114
114
 
115
115
  const { runMigrations } = await import('@meith/db')
116
+ const { backupBeforeMigrating } = await import('@meith/runtime')
117
+ if ((await backupBeforeMigrating()) === 'taken') {
118
+ console.log('Took the backup the settings ask for before a migration.')
119
+ }
116
120
  const applied = await runMigrations()
117
121
  console.log(applied === 0 ? 'Already up to date.' : `Applied ${applied} migration(s).`)
118
122
  return 0
@@ -182,7 +186,12 @@ const commands: Command[] = [
182
186
  {
183
187
  name: 'backup:list',
184
188
  summary: 'List the backup bundles on local disk and at the off-site destination.',
185
- usage: 'meith backup:list [--dir <dir>]',
189
+ usage: [
190
+ 'meith backup:list [--dir <dir>]',
191
+ '',
192
+ '--dir defaults to BACKUP_DIR, the ring the admin panel and the scheduler use',
193
+ '(/backups in the shipped image).',
194
+ ].join('\n'),
186
195
  run: backupListCommand,
187
196
  },
188
197
 
package/src/redaction.ts CHANGED
@@ -11,6 +11,7 @@ export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
11
11
  'REDIS_URL',
12
12
  'S3_SECRET_ACCESS_KEY',
13
13
  'BACKUP_S3_SECRET_ACCESS_KEY',
14
+ 'BACKUP_WEBDAV_PASSWORD',
14
15
  'BLOB_READ_WRITE_TOKEN',
15
16
  ])
16
17
 
@@ -22,6 +23,7 @@ export const NOT_SECRET_DESPITE_THE_NAME: ReadonlySet<string> = new Set([
22
23
  'S3_ACCESS_KEY_ID',
23
24
  'BACKUP_S3_ACCESS_KEY_ID',
24
25
  'BACKUP_S3_ENDPOINT',
26
+ 'BACKUP_WEBDAV_URL',
25
27
  'MAIL_SMTP_USERNAME',
26
28
  ])
27
29
 
package/src/upgrade.ts CHANGED
@@ -9,10 +9,10 @@ import {
9
9
  runMigrations,
10
10
  } from '@meith/db'
11
11
  import { type PluginDefinition, pluginNavigationPlacements } from '@meith/plugin-kit'
12
- import { runPluginLifecycle } from '@meith/runtime'
12
+ import { backupBeforeMigrating, runPluginLifecycle } from '@meith/runtime'
13
13
  import { type PluginUpgrade, planUpgrade, upgradeNotice } from '@meith/upgrade'
14
14
 
15
- export const CODE_VERSION = '0.33.4'
15
+ export const CODE_VERSION = '0.34.0'
16
16
 
17
17
  export function pluginUpgrades(plugins: readonly PluginDefinition[]): readonly PluginUpgrade[] {
18
18
  return plugins.map((plugin) => ({
@@ -88,6 +88,9 @@ export async function upgrade(options: UpgradeOptions): Promise<number> {
88
88
  return 0
89
89
  }
90
90
 
91
+ if ((await backupBeforeMigrating()) === 'taken') {
92
+ options.log('Core: took the backup the settings ask for before a migration.')
93
+ }
91
94
  const count = await runMigrations()
92
95
  options.log(count === 0 ? 'Core: already up to date.' : `Core: applied ${count} migration(s).`)
93
96
 
@@ -1,221 +0,0 @@
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
- }