@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.
- package/LICENSE.md +165 -0
- package/bin/community.mjs +117 -0
- package/package.json +47 -0
- package/src/args.ts +63 -0
- package/src/backup.ts +677 -0
- package/src/board-eject.ts +180 -0
- package/src/commands.ts +269 -0
- package/src/context.ts +77 -0
- package/src/demo.ts +61 -0
- package/src/import-files.ts +189 -0
- package/src/import.ts +142 -0
- package/src/index.ts +394 -0
- package/src/plugin-manifest.ts +176 -0
- package/src/plugins.ts +65 -0
- package/src/profile-fields.ts +107 -0
- package/src/push.ts +47 -0
- package/src/redaction.ts +23 -0
- package/src/search.ts +33 -0
- package/src/tasks.ts +69 -0
- package/src/upgrade.ts +125 -0
|
@@ -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
|
+
}
|