@meith/cli 0.33.4 → 0.35.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 +14 -13
- package/src/backup.ts +187 -826
- package/src/board-eject.ts +1 -0
- package/src/commands.ts +5 -3
- package/src/import-files.ts +35 -31
- package/src/index.ts +10 -1
- package/src/redaction.ts +2 -0
- package/src/upgrade.ts +5 -2
- package/src/backup-store.ts +0 -221
package/src/board-eject.ts
CHANGED
|
@@ -244,6 +244,7 @@ export async function boardEject(args: readonly string[]): Promise<number> {
|
|
|
244
244
|
' # to the pushed image (or docker-compose.yaml instead, which builds on the server)',
|
|
245
245
|
)
|
|
246
246
|
console.log(' # redeploy — same database, same uploads, same secrets, new image source')
|
|
247
|
+
console.log(' # no panel? docker-compose.byhand.yaml is the same board without Coolify')
|
|
247
248
|
console.log('')
|
|
248
249
|
console.log(
|
|
249
250
|
'See docs/customization/marketplace.md, "Moving to a custom board", for the full walkthrough.',
|
package/src/commands.ts
CHANGED
|
@@ -61,11 +61,13 @@ export async function userCreate(args: readonly string[]): Promise<number> {
|
|
|
61
61
|
const password = await readPassword(flags)
|
|
62
62
|
|
|
63
63
|
const ctx = await createContext()
|
|
64
|
-
const result = await ctx.identity.register({ username, email, password })
|
|
65
64
|
|
|
66
65
|
const groupRef = optional(flags, 'group')
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
const group = groupRef === undefined ? null : await findGroup(ctx, groupRef)
|
|
67
|
+
|
|
68
|
+
const result = await ctx.identity.register({ username, email, password })
|
|
69
|
+
|
|
70
|
+
if (group !== null) {
|
|
69
71
|
await ctx.admin.setPrimaryGroup(result.account.id, group.id)
|
|
70
72
|
console.log(`Created user ${username} (id ${result.account.id}) in group ${group.key}.`)
|
|
71
73
|
} else {
|
package/src/import-files.ts
CHANGED
|
@@ -76,22 +76,24 @@ export class UploadsDirCopier implements ImportFileCopier {
|
|
|
76
76
|
return { outcome: 'failed', reason: `avatar ${input.path} is not a png or jpeg` }
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
let processed: Awaited<ReturnType<ImageProcessor['process']>>
|
|
79
80
|
try {
|
|
80
|
-
|
|
81
|
+
processed = await this.images.process({
|
|
81
82
|
bytes,
|
|
82
83
|
codec: type.codec,
|
|
83
84
|
fit: AVATAR_BOX,
|
|
84
85
|
thumbnail: false,
|
|
85
86
|
})
|
|
86
|
-
const key = storageKey('avatars', input.legacyUserId, EXTENSIONS[processed.contentType] ?? '')
|
|
87
|
-
await this.files.put(key, processed.bytes, {
|
|
88
|
-
contentType: processed.contentType,
|
|
89
|
-
visibility: 'public',
|
|
90
|
-
})
|
|
91
|
-
return { outcome: 'ready', key, width: processed.width, height: processed.height }
|
|
92
87
|
} catch {
|
|
93
88
|
return { outcome: 'failed', reason: `avatar ${input.path} could not be decoded` }
|
|
94
89
|
}
|
|
90
|
+
|
|
91
|
+
const key = storageKey('avatars', input.legacyUserId, EXTENSIONS[processed.contentType] ?? '')
|
|
92
|
+
await this.files.put(key, processed.bytes, {
|
|
93
|
+
contentType: processed.contentType,
|
|
94
|
+
visibility: 'public',
|
|
95
|
+
})
|
|
96
|
+
return { outcome: 'ready', key, width: processed.width, height: processed.height }
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
async #reencode(
|
|
@@ -104,34 +106,36 @@ export class UploadsDirCopier implements ImportFileCopier {
|
|
|
104
106
|
return { outcome: 'failed', reason: `file ${path} could not be decoded` }
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
let processed: Awaited<ReturnType<ImageProcessor['process']>>
|
|
107
110
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
processed = await this.images.process({ bytes, codec: type.codec })
|
|
112
|
+
} catch {
|
|
113
|
+
return { outcome: 'failed', reason: `file ${path} could not be decoded` }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const key = storageKey('attachments', legacyId, EXTENSIONS[processed.contentType] ?? '')
|
|
117
|
+
await this.files.put(key, processed.bytes, {
|
|
118
|
+
contentType: processed.contentType,
|
|
119
|
+
visibility: 'private',
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
let thumbnailKey: string | null = null
|
|
123
|
+
if (processed.thumbnail !== undefined) {
|
|
124
|
+
thumbnailKey = storageKey('attachments', legacyId, '.thumb.jpg')
|
|
125
|
+
await this.files.put(thumbnailKey, processed.thumbnail.bytes, {
|
|
126
|
+
contentType: processed.thumbnail.contentType,
|
|
112
127
|
visibility: 'private',
|
|
113
128
|
})
|
|
129
|
+
}
|
|
114
130
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
outcome: 'ready',
|
|
126
|
-
storageKey: key,
|
|
127
|
-
thumbnailKey,
|
|
128
|
-
contentType: processed.contentType,
|
|
129
|
-
sizeBytes: processed.bytes.byteLength,
|
|
130
|
-
width: processed.width,
|
|
131
|
-
height: processed.height,
|
|
132
|
-
}
|
|
133
|
-
} catch {
|
|
134
|
-
return { outcome: 'failed', reason: `file ${path} could not be decoded` }
|
|
131
|
+
return {
|
|
132
|
+
outcome: 'ready',
|
|
133
|
+
storageKey: key,
|
|
134
|
+
thumbnailKey,
|
|
135
|
+
contentType: processed.contentType,
|
|
136
|
+
sizeBytes: processed.bytes.byteLength,
|
|
137
|
+
width: processed.width,
|
|
138
|
+
height: processed.height,
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
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:
|
|
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.
|
|
15
|
+
export const CODE_VERSION = '0.35.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
|
|
package/src/backup-store.ts
DELETED
|
@@ -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
|
-
}
|