@meith/cli 0.16.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.
@@ -0,0 +1,189 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { basename, dirname, join, normalize, resolve, sep } from 'node:path'
3
+
4
+ import { type AttachmentType, type ImageProcessor, sniff } from '@meith/attachments'
5
+ import { AVATAR_BOX } from '@meith/avatars'
6
+ import type { FileStore } from '@meith/core'
7
+ import type { CopiedAttachment, CopiedAvatar, ImportFileCopier } from '@meith/db'
8
+ import { imageProcessor } from '@meith/drivers/images'
9
+
10
+ const EXTENSIONS: Readonly<Record<string, string>> = {
11
+ 'image/png': '.png',
12
+ 'image/jpeg': '.jpg',
13
+ 'application/pdf': '.pdf',
14
+ 'application/zip': '.zip',
15
+ }
16
+
17
+ export class UploadsDirCopier implements ImportFileCopier {
18
+ private readonly root: string
19
+
20
+ constructor(
21
+ root: string,
22
+ private readonly files: FileStore,
23
+ private readonly images: ImageProcessor = imageProcessor,
24
+ ) {
25
+ this.root = resolve(root)
26
+ }
27
+
28
+ async attachment(input: {
29
+ readonly legacyId: number
30
+ readonly path: string
31
+ readonly thumbnailPath: string | null
32
+ readonly filename: string
33
+ readonly contentType: string | null
34
+ }): Promise<CopiedAttachment> {
35
+ const bytes = await this.#read(input.path)
36
+ if (bytes === null) {
37
+ return { outcome: 'missing', reason: `file ${input.path} missing from uploads directory` }
38
+ }
39
+
40
+ const type = sniff(bytes)
41
+ if (type === undefined) {
42
+ return {
43
+ outcome: 'failed',
44
+ reason: `file ${input.path} is not a type this board accepts (png, jpeg, pdf, zip)`,
45
+ }
46
+ }
47
+
48
+ if (type.handling === 'opaque') {
49
+ const key = storageKey('attachments', input.legacyId, EXTENSIONS[type.contentType] ?? '')
50
+ await this.files.put(key, bytes, { contentType: type.contentType, visibility: 'private' })
51
+ return {
52
+ outcome: 'ready',
53
+ storageKey: key,
54
+ thumbnailKey: null,
55
+ contentType: type.contentType,
56
+ sizeBytes: bytes.byteLength,
57
+ width: null,
58
+ height: null,
59
+ }
60
+ }
61
+
62
+ return this.#reencode(input.path, input.legacyId, bytes, type)
63
+ }
64
+
65
+ async avatar(input: {
66
+ readonly legacyUserId: number
67
+ readonly path: string
68
+ }): Promise<CopiedAvatar> {
69
+ const bytes = await this.#read(input.path)
70
+ if (bytes === null) {
71
+ return { outcome: 'missing', reason: `avatar ${input.path} missing from uploads directory` }
72
+ }
73
+
74
+ const type = sniff(bytes)
75
+ if (type === undefined || type.codec === null) {
76
+ return { outcome: 'failed', reason: `avatar ${input.path} is not a png or jpeg` }
77
+ }
78
+
79
+ try {
80
+ const processed = await this.images.process({
81
+ bytes,
82
+ codec: type.codec,
83
+ fit: AVATAR_BOX,
84
+ thumbnail: false,
85
+ })
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
+ } catch {
93
+ return { outcome: 'failed', reason: `avatar ${input.path} could not be decoded` }
94
+ }
95
+ }
96
+
97
+ async #reencode(
98
+ path: string,
99
+ legacyId: number,
100
+ bytes: Uint8Array,
101
+ type: AttachmentType,
102
+ ): Promise<CopiedAttachment> {
103
+ if (type.codec === null) {
104
+ return { outcome: 'failed', reason: `file ${path} could not be decoded` }
105
+ }
106
+
107
+ try {
108
+ const processed = await this.images.process({ bytes, codec: type.codec })
109
+ const key = storageKey('attachments', legacyId, EXTENSIONS[processed.contentType] ?? '')
110
+ await this.files.put(key, processed.bytes, {
111
+ contentType: processed.contentType,
112
+ visibility: 'private',
113
+ })
114
+
115
+ let thumbnailKey: string | null = null
116
+ if (processed.thumbnail !== undefined) {
117
+ thumbnailKey = storageKey('attachments', legacyId, '.thumb.jpg')
118
+ await this.files.put(thumbnailKey, processed.thumbnail.bytes, {
119
+ contentType: processed.thumbnail.contentType,
120
+ visibility: 'private',
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` }
135
+ }
136
+ }
137
+
138
+ async #read(relative: string): Promise<Uint8Array | null> {
139
+ const target = this.#within(relative)
140
+ if (target === null) return null
141
+
142
+ if (basename(target).includes('*')) return this.#readGlob(target)
143
+
144
+ try {
145
+ return await readFile(target)
146
+ } catch (error) {
147
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
148
+ throw error
149
+ }
150
+ }
151
+
152
+ async #readGlob(target: string): Promise<Uint8Array | null> {
153
+ const dir = dirname(target)
154
+ const pattern = basename(target)
155
+ const star = pattern.indexOf('*')
156
+ const prefix = pattern.slice(0, star)
157
+ const suffix = pattern.slice(star + 1)
158
+
159
+ let entries: readonly string[]
160
+ try {
161
+ entries = await readdir(dir)
162
+ } catch {
163
+ return null
164
+ }
165
+
166
+ const match = entries.find(
167
+ (entry) =>
168
+ entry.startsWith(prefix) && entry.endsWith(suffix) && entry.length >= pattern.length - 1,
169
+ )
170
+ if (match === undefined) return null
171
+
172
+ try {
173
+ return await readFile(join(dir, match))
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+
179
+ #within(relative: string): string | null {
180
+ if (relative.includes('\0') || relative.trim() === '') return null
181
+ const target = resolve(this.root, normalize(relative))
182
+ if (target !== this.root && !target.startsWith(this.root + sep)) return null
183
+ return target
184
+ }
185
+ }
186
+
187
+ function storageKey(kind: string, legacyId: number, extension: string): string {
188
+ return `import/${kind}/${legacyId}${extension}`
189
+ }
package/src/import.ts ADDED
@@ -0,0 +1,142 @@
1
+ import {
2
+ currentImportRun,
3
+ finishImportRun,
4
+ getDb,
5
+ type ImportFileCopier,
6
+ type ImportSourceName,
7
+ PostgresImportSink,
8
+ saveImportProgress,
9
+ startImportRun,
10
+ } from '@meith/db'
11
+ import { drivers } from '@meith/drivers'
12
+ import {
13
+ type Cursors,
14
+ type ImportReport,
15
+ MysqlMybbSource,
16
+ MysqlPhpbbSource,
17
+ NO_PROGRESS,
18
+ runImport,
19
+ } from '@meith/import'
20
+
21
+ import { integer, optional, parseFlags, required } from './args'
22
+ import { UploadsDirCopier } from './import-files'
23
+
24
+ interface ClosableSource {
25
+ close(): Promise<void>
26
+ }
27
+
28
+ export async function importCommand(args: readonly string[]): Promise<number> {
29
+ const { flags } = parseFlags(args)
30
+
31
+ const sourceName = (optional(flags, 'source') ?? 'mybb') as ImportSourceName
32
+ if (sourceName !== 'mybb' && sourceName !== 'phpbb') {
33
+ console.error(`Unknown import source "${sourceName}". Supported sources: mybb, phpbb.`)
34
+ return 1
35
+ }
36
+
37
+ const password = process.env.IMPORT_SOURCE_PASSWORD ?? process.env.MYBB_PASSWORD
38
+ if (password === undefined) {
39
+ console.error(
40
+ 'Set IMPORT_SOURCE_PASSWORD (MYBB_PASSWORD is also accepted). It is read from the\n' +
41
+ 'environment rather than a flag because a password in argv is in your shell\n' +
42
+ 'history and in `ps` for every user on the box.',
43
+ )
44
+ return 1
45
+ }
46
+
47
+ const options = {
48
+ host: required(flags, 'host'),
49
+ port: integer(flags, 'port'),
50
+ user: required(flags, 'user'),
51
+ password,
52
+ database: required(flags, 'database'),
53
+ tablePrefix: optional(flags, 'prefix'),
54
+ charset: optional(flags, 'charset'),
55
+ ssl: flags.has('ssl'),
56
+ }
57
+
58
+ const source =
59
+ sourceName === 'mybb'
60
+ ? await MysqlMybbSource.connect(options)
61
+ : await MysqlPhpbbSource.connect(options)
62
+
63
+ try {
64
+ const db = getDb()
65
+
66
+ const uploadsDir = optional(flags, 'uploads-dir')
67
+ const copier: ImportFileCopier | undefined =
68
+ uploadsDir === undefined ? undefined : new UploadsDirCopier(uploadsDir, drivers().files)
69
+
70
+ const sink = new PostgresImportSink(db, { copier })
71
+
72
+ const existing = await currentImportRun(db, sourceName)
73
+ const from: Cursors = { ...NO_PROGRESS, ...existing?.cursors }
74
+ const runId = existing?.id ?? (await startImportRun(db, sourceName))
75
+
76
+ if (existing !== null) {
77
+ console.log(`Resuming import run ${runId} from ${describe(from)}.`)
78
+ }
79
+
80
+ const report = await runImport({
81
+ source,
82
+ sink,
83
+ pageSize: integer(flags, 'page-size'),
84
+ budget: integer(flags, 'budget'),
85
+ from,
86
+ })
87
+
88
+ await saveImportProgress(db, runId, report.cursors, report.readThisRun, report.kinds)
89
+ if (report.finished) await finishImportRun(db, runId, 'finished', null)
90
+
91
+ print(report, uploadsDir !== undefined)
92
+ return 0
93
+ } finally {
94
+ await (source as ClosableSource).close()
95
+ }
96
+ }
97
+
98
+ const describe = (cursors: Cursors): string =>
99
+ Object.entries(cursors)
100
+ .filter(([, id]) => id > 0)
101
+ .map(([kind, id]) => `${kind}:${id}`)
102
+ .join(' ')
103
+
104
+ function print(report: ImportReport, copiedFiles: boolean): void {
105
+ const width = Math.max(...Object.keys(report.kinds).map((k) => k.length))
106
+
107
+ for (const [kind, result] of Object.entries(report.kinds)) {
108
+ console.log(
109
+ ` ${kind.padEnd(width)} read ${String(result.read).padStart(7)} ` +
110
+ `inserted ${String(result.inserted).padStart(7)} ` +
111
+ `updated ${String(result.updated).padStart(7)} ` +
112
+ `skipped ${String(result.skipped.length).padStart(5)}`,
113
+ )
114
+ }
115
+
116
+ for (const [kind, result] of Object.entries(report.kinds)) {
117
+ for (const skip of result.skipped.slice(0, 20)) {
118
+ console.log(` skipped ${kind} ${skip.legacyId}: ${skip.reason}`)
119
+ }
120
+ if (result.skipped.length > 20) {
121
+ console.log(` … and ${result.skipped.length - 20} more skipped ${kind}`)
122
+ }
123
+ }
124
+
125
+ console.log('')
126
+ if (report.finished) {
127
+ console.log(
128
+ 'Import complete. Run `community task:run counters.reconcile` before opening the board.',
129
+ )
130
+ if (!copiedFiles) {
131
+ console.log(
132
+ 'Attachment and avatar files were not copied (no --uploads-dir). ' +
133
+ 'Run the same command again with --uploads-dir to bring the files across.',
134
+ )
135
+ }
136
+ } else {
137
+ console.log(
138
+ `Stopped after ${report.readThisRun.toLocaleString()} rows (the budget). ` +
139
+ 'Not an error — run the same command again to continue from here.',
140
+ )
141
+ }
142
+ }