@wwkit/opm 1.0.1 → 1.0.2

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,214 @@
1
+ /**
2
+ * FTP 客户端封装 — 基于 basic-ftp
3
+ *
4
+ * 提供连接、上传、下载、列表、删除、建目录等操作。
5
+ * 自动重试 + 超时 + 被动模式由配置驱动。
6
+ */
7
+
8
+ import ftp from 'basic-ftp'
9
+ import { Readable } from 'node:stream'
10
+ import { getConfig } from '../../config.js'
11
+
12
+ const { Client } = ftp
13
+
14
+ /**
15
+ * 从 opm config 读取 FTP 配置
16
+ * @returns {{ host: string, port: number, user: string, password: string, remote_root: string, base_url: string, timeout: number, max_retries: number, pasv: boolean }}
17
+ */
18
+ export function getFtpConfig() {
19
+ const cfg = getConfig().ftp || {}
20
+ return {
21
+ host: cfg.host || '',
22
+ port: parseInt(cfg.port, 10) || 21,
23
+ user: cfg.user || '',
24
+ password: cfg.password || '',
25
+ remote_root: (cfg.remote_root || '/htdocs/webwork').replace(/\/+$/, ''),
26
+ base_url: (cfg.base_url || '').replace(/\/+$/, ''),
27
+ timeout: parseInt(cfg.timeout, 10) || 120,
28
+ max_retries: parseInt(cfg.max_retries, 10) || 2,
29
+ pasv: cfg.pasv !== undefined ? cfg.pasv : false,
30
+ }
31
+ }
32
+
33
+ /**
34
+ * 创建并连接 FTP 客户端
35
+ * @returns {Promise<import('basic-ftp').Client>}
36
+ */
37
+ export async function connect() {
38
+ const cfg = getFtpConfig()
39
+ const client = new Client(cfg.timeout * 1000)
40
+ await client.access({
41
+ host: cfg.host,
42
+ port: cfg.port,
43
+ user: cfg.user,
44
+ password: cfg.password,
45
+ secure: false,
46
+ })
47
+ return client
48
+ }
49
+
50
+ /**
51
+ * 测试连接
52
+ * @returns {Promise<boolean>}
53
+ */
54
+ export async function checkConnection() {
55
+ let client
56
+ try {
57
+ client = await connect()
58
+ return true
59
+ } catch {
60
+ return false
61
+ } finally {
62
+ if (client) client.close()
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 确保远程目录存在(递归创建)
68
+ * @param {import('basic-ftp').FTPClient} client
69
+ * @param {string} dirPath - 绝对路径
70
+ */
71
+ export async function ensureDir(client, dirPath) {
72
+ const parts = dirPath.split('/').filter(Boolean)
73
+ let current = ''
74
+ for (const part of parts) {
75
+ current += '/' + part
76
+ try {
77
+ await client.cd(current)
78
+ } catch {
79
+ await client.send('MKD ' + current)
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 上传 Buffer 到远程路径
86
+ * @param {Buffer} data
87
+ * @param {string} remotePath - 绝对路径
88
+ * @param {number} [maxRetries]
89
+ * @returns {Promise<boolean>}
90
+ */
91
+ export async function uploadBuffer(data, remotePath, maxRetries) {
92
+ const cfg = getFtpConfig()
93
+ const retries = maxRetries !== undefined ? maxRetries : cfg.max_retries
94
+ for (let attempt = 0; attempt <= retries; attempt++) {
95
+ let client
96
+ try {
97
+ client = await connect()
98
+ const dir = remotePath.substring(0, remotePath.lastIndexOf('/'))
99
+ if (dir) await ensureDir(client, dir)
100
+ await client.uploadFrom(Readable.from(data), remotePath)
101
+ return true
102
+ } catch (err) {
103
+ if (attempt === retries) throw err
104
+ } finally {
105
+ if (client) client.close()
106
+ }
107
+ }
108
+ return false
109
+ }
110
+
111
+ /**
112
+ * 上传本地文件到远程路径
113
+ * @param {string} localPath
114
+ * @param {string} remotePath - 绝对路径
115
+ * @param {number} [maxRetries]
116
+ * @returns {Promise<boolean>}
117
+ */
118
+ export async function uploadFile(localPath, remotePath, maxRetries) {
119
+ const cfg = getFtpConfig()
120
+ const retries = maxRetries !== undefined ? maxRetries : cfg.max_retries
121
+ for (let attempt = 0; attempt <= retries; attempt++) {
122
+ let client
123
+ try {
124
+ client = await connect()
125
+ const dir = remotePath.substring(0, remotePath.lastIndexOf('/'))
126
+ if (dir) await ensureDir(client, dir)
127
+ await client.uploadFrom(localPath, remotePath)
128
+ return true
129
+ } catch (err) {
130
+ if (attempt === retries) throw err
131
+ } finally {
132
+ if (client) client.close()
133
+ }
134
+ }
135
+ return false
136
+ }
137
+
138
+ /**
139
+ * 下载远程文件到本地
140
+ * @param {string} remotePath - 绝对路径
141
+ * @param {string} localPath
142
+ * @returns {Promise<boolean>}
143
+ */
144
+ export async function downloadFile(remotePath, localPath) {
145
+ let client
146
+ try {
147
+ client = await connect()
148
+ const dir = localPath.substring(0, localPath.lastIndexOf('/'))
149
+ if (dir) {
150
+ const fs = await import('node:fs')
151
+ fs.mkdirSync(dir, { recursive: true })
152
+ }
153
+ await client.downloadTo(localPath, remotePath)
154
+ return true
155
+ } catch {
156
+ return false
157
+ } finally {
158
+ if (client) client.close()
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 列出远程目录下的文件(递归),返回相对路径 + size
164
+ * @param {string} remoteDir - 绝对路径
165
+ * @returns {Promise<Map<string, number>>} Map<relPath, size>
166
+ */
167
+ export async function listRemoteFiles(remoteDir) {
168
+ let client
169
+ try {
170
+ client = await connect()
171
+ const result = new Map()
172
+ await walkRemote(client, remoteDir, '', result)
173
+ return result
174
+ } finally {
175
+ if (client) client.close()
176
+ }
177
+ }
178
+
179
+ async function walkRemote(client, baseDir, relDir, result) {
180
+ let entries
181
+ try {
182
+ entries = await client.list(baseDir + '/' + relDir)
183
+ } catch {
184
+ return
185
+ }
186
+ for (const entry of entries) {
187
+ const name = entry.name
188
+ if (name === '.' || name === '..') continue
189
+ const rel = relDir ? `${relDir}/${name}` : name
190
+ if (entry.isDirectory) {
191
+ await walkRemote(client, baseDir, rel, result)
192
+ } else if (entry.isFile) {
193
+ result.set(rel, entry.size)
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 删除远程文件
200
+ * @param {string} remotePath - 绝对路径
201
+ * @returns {Promise<boolean>}
202
+ */
203
+ export async function removeFile(remotePath) {
204
+ let client
205
+ try {
206
+ client = await connect()
207
+ await client.remove(remotePath)
208
+ return true
209
+ } catch {
210
+ return false
211
+ } finally {
212
+ if (client) client.close()
213
+ }
214
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * opm ftp 命令组 — FTP 文件管理
3
+ *
4
+ * 命令:connect / upload / rsync / download / root / config
5
+ * 各命令接口独立,不强制统一结构。
6
+ */
7
+
8
+ import path from 'node:path'
9
+ import fs from 'node:fs'
10
+ import { parseFlags } from '../../cli/helpers/args.js'
11
+ import { getConfig } from '../../config.js'
12
+ import { output } from '../../formatter.js'
13
+ import {
14
+ getFtpConfig, checkConnection, uploadBuffer, uploadFile,
15
+ downloadFile, listRemoteFiles, removeFile, ensureDir, connect,
16
+ } from './client.js'
17
+ import {
18
+ isExcluded, md5File, detectImageExt, extFromName, makeFilename,
19
+ todayStr, readData, collectFiles,
20
+ } from './util.js'
21
+
22
+ export class FtpGroup {
23
+ constructor() {
24
+ this.name = 'ftp'
25
+ this.desc = 'FTP file management (upload/rsync/download)'
26
+ }
27
+
28
+ async run(argv) {
29
+ const [action, ...rest] = argv
30
+
31
+ if (!action || action === '-h' || action === '--help' || action === 'help') {
32
+ this.printHelp()
33
+ return
34
+ }
35
+
36
+ switch (action) {
37
+ case 'connect':
38
+ return this._connect()
39
+ case 'upload':
40
+ return this._upload(parseFlags(rest))
41
+ case 'rsync':
42
+ return this._rsync(parseFlags(rest))
43
+ case 'download':
44
+ return this._download(parseFlags(rest))
45
+ case 'root':
46
+ return this._root()
47
+ case 'config':
48
+ return this._config()
49
+ default:
50
+ console.error(`Unknown action: ${action}`)
51
+ this.printHelp()
52
+ process.exit(1)
53
+ }
54
+ }
55
+
56
+ async _connect() {
57
+ const ok = await checkConnection()
58
+ output({ connected: ok })
59
+ }
60
+
61
+ async _upload(parsed) {
62
+ const file = parsed.positional[0]
63
+ if (!file) {
64
+ console.error('Usage: opm ftp upload <file> [-n name] [-s subdir]')
65
+ process.exit(1)
66
+ }
67
+ const name = parsed.flags.name || parsed.flags.n || ''
68
+ const subdir = parsed.flags.subdir || parsed.flags.s || ''
69
+ const cfg = getFtpConfig()
70
+
71
+ const data = await readData(file, cfg.timeout * 1000)
72
+ if (!data || data.length === 0) {
73
+ console.error('Failed to read data (empty or not found)')
74
+ process.exit(1)
75
+ }
76
+
77
+ let ext = detectImageExt(data) || extFromName(name || file) || ''
78
+ if (!ext) {
79
+ const fromPath = path.extname(file).slice(1).toLowerCase()
80
+ ext = fromPath || 'bin'
81
+ }
82
+
83
+ let filename
84
+ if (name) {
85
+ const nameExt = path.extname(name).slice(1).toLowerCase()
86
+ if (nameExt && nameExt !== ext) ext = nameExt
87
+ filename = name.endsWith(`.${ext}`) ? name : `${name}.${ext}`
88
+ } else {
89
+ filename = makeFilename(ext)
90
+ }
91
+
92
+ const date = todayStr()
93
+ const parts = [cfg.remote_root, date]
94
+ if (subdir) parts.push(subdir.replace(/^\/+/, ''))
95
+ parts.push(filename)
96
+ const remotePath = parts.join('/')
97
+
98
+ const ok = await uploadBuffer(data, remotePath)
99
+ if (!ok) {
100
+ console.error('Upload failed')
101
+ process.exit(1)
102
+ }
103
+
104
+ const urlParts = [cfg.base_url, date]
105
+ if (subdir) urlParts.push(subdir.replace(/^\/+/, ''))
106
+ urlParts.push(filename)
107
+ output({ uploaded: true, remote: remotePath, url: urlParts.join('/') })
108
+ }
109
+
110
+ async _rsync(parsed) {
111
+ const local = parsed.positional[0]
112
+ if (!local) {
113
+ console.error('Usage: opm ftp rsync <local> [-s subdir] [-x pattern] [--dry-run] [--prune]')
114
+ process.exit(1)
115
+ }
116
+ const subdir = parsed.flags.subdir || parsed.flags.s || ''
117
+ const excludes = parsed.flags.exclude || parsed.flags.x || []
118
+ const excludeList = Array.isArray(excludes) ? excludes : [excludes]
119
+ const dryRun = parsed.flags['dry-run'] === 'true'
120
+ const prune = parsed.flags.prune === 'true'
121
+
122
+ const cfg = getFtpConfig()
123
+ const localDir = path.resolve(local)
124
+ if (!fs.existsSync(localDir) || !fs.statSync(localDir).isDirectory()) {
125
+ console.error(`Local directory not found: ${localDir}`)
126
+ process.exit(1)
127
+ }
128
+
129
+ const remoteBase = subdir
130
+ ? `${cfg.remote_root}/${subdir.replace(/^\/+/, '')}`
131
+ : cfg.remote_root
132
+
133
+ const allFiles = collectFiles(localDir)
134
+ const toUpload = allFiles.filter((rel) => !isExcluded(rel, excludeList))
135
+ const skippedExcluded = allFiles.filter((rel) => isExcluded(rel, excludeList))
136
+
137
+ const remoteFiles = await listRemoteFiles(remoteBase)
138
+
139
+ const uploadPlan = []
140
+ const skippedSame = []
141
+ for (const rel of toUpload) {
142
+ const localPath = path.join(localDir, ...rel.split('/'))
143
+ const stat = fs.statSync(localPath)
144
+ const remoteSize = remoteFiles.get(rel)
145
+ if (remoteSize !== undefined && remoteSize === stat.size) {
146
+ skippedSame.push(rel)
147
+ } else {
148
+ uploadPlan.push(rel)
149
+ }
150
+ }
151
+
152
+ const deletePlan = []
153
+ if (prune) {
154
+ for (const [rel] of remoteFiles) {
155
+ if (!toUpload.includes(rel)) {
156
+ deletePlan.push(rel)
157
+ }
158
+ }
159
+ }
160
+
161
+ if (dryRun) {
162
+ output({
163
+ dryRun: true,
164
+ remote: remoteBase,
165
+ upload: uploadPlan,
166
+ skipSame: skippedSame.length,
167
+ skipExcluded: skippedExcluded,
168
+ delete: deletePlan,
169
+ })
170
+ return
171
+ }
172
+
173
+ const uploaded = []
174
+ const failed = []
175
+ const total = uploadPlan.length
176
+ for (let i = 0; i < uploadPlan.length; i++) {
177
+ const rel = uploadPlan[i]
178
+ const localPath = path.join(localDir, ...rel.split('/'))
179
+ const remotePath = `${remoteBase}/${rel}`
180
+ try {
181
+ await uploadFile(localPath, remotePath)
182
+ uploaded.push(rel)
183
+ } catch (err) {
184
+ failed.push({ path: rel, error: err.message })
185
+ }
186
+ process.stderr.write(`\r[${i + 1}/${total}] ${rel}`)
187
+ }
188
+ if (total > 0) process.stderr.write('\n')
189
+
190
+ const deleted = []
191
+ const deleteFailed = []
192
+ for (const rel of deletePlan) {
193
+ const remotePath = `${remoteBase}/${rel}`
194
+ const ok = await removeFile(remotePath)
195
+ if (ok) deleted.push(rel)
196
+ else deleteFailed.push(rel)
197
+ }
198
+
199
+ output({
200
+ remote: remoteBase,
201
+ uploaded: uploaded.length,
202
+ uploadedFiles: uploaded,
203
+ skippedSame: skippedSame.length,
204
+ skippedExcluded,
205
+ failed,
206
+ pruned: deleted.length,
207
+ prunedFiles: deleted,
208
+ pruneFailed: deleteFailed,
209
+ })
210
+ }
211
+
212
+ async _download(parsed) {
213
+ const remote = parsed.positional[0]
214
+ const local = parsed.positional[1]
215
+ if (!remote || !local) {
216
+ console.error('Usage: opm ftp download <remote> <local>')
217
+ process.exit(1)
218
+ }
219
+ const cfg = getFtpConfig()
220
+ const remotePath = remote.startsWith('/') ? remote : `${cfg.remote_root}/${remote}`
221
+ const ok = await downloadFile(remotePath, local)
222
+ output({ downloaded: ok, remote: remotePath, local })
223
+ }
224
+
225
+ _root() {
226
+ const cfg = getFtpConfig()
227
+ output({ remote_root: cfg.remote_root })
228
+ }
229
+
230
+ _config() {
231
+ const cfg = getFtpConfig()
232
+ output(cfg)
233
+ }
234
+
235
+ printHelp() {
236
+ console.log(`
237
+ Usage: opm ftp <action> [args] [options]
238
+
239
+ Actions:
240
+ connect Test FTP connection
241
+ upload <file> [-n name] [-s subdir] Upload file (local path or HTTP URL)
242
+ rsync <local> [options] Recursive upload directory (FTP-side dedup)
243
+ download <remote> <local> Download file from FTP
244
+ root Show configured remote root directory
245
+ config Show FTP config
246
+ help Show this help
247
+
248
+ rsync options:
249
+ -s, --subdir <dir> Remote subdirectory (under remote_root)
250
+ -x, --exclude <pattern> Exclude glob pattern (repeatable)
251
+ --dry-run Preview without uploading
252
+ --prune Delete remote files not in local
253
+
254
+ Options:
255
+ -h, --help Show this help
256
+
257
+ Examples:
258
+ opm ftp connect
259
+ opm ftp upload ./img.png
260
+ opm ftp upload ./img.png -n cover -s banner
261
+ opm ftp upload https://example.com/a.jpg -n cover.jpg
262
+ opm ftp rsync ./webwork/
263
+ opm ftp rsync ./webwork/ -s deploy -x "vendor/*" -x "*.log"
264
+ opm ftp rsync ./webwork/ --dry-run
265
+ opm ftp rsync ./webwork/ --prune
266
+ opm ftp download 20260913/cover.jpg ./cover.jpg
267
+ opm ftp root
268
+ opm ftp config
269
+ `)
270
+ }
271
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * FTP 工具函数 — exclude 匹配、MD5、图片检测、自动命名、HTTP 源下载
3
+ */
4
+
5
+ import crypto from 'node:crypto'
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+
9
+ const IMAGE_MAGIC = {
10
+ jpg: Buffer.from([0xff, 0xd8, 0xff]),
11
+ png: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
12
+ gif: Buffer.from([0x47, 0x49, 0x46, 0x38]),
13
+ webp: Buffer.from([0x52, 0x49, 0x46, 0x46]),
14
+ }
15
+
16
+ /**
17
+ * glob 通配符匹配(支持 * 和 **)
18
+ * @param {string} relPath - 相对路径(posix 风格)
19
+ * @param {string} pattern - glob 模式
20
+ * @returns {boolean}
21
+ */
22
+ export function matchGlob(relPath, pattern) {
23
+ const regex = globToRegex(pattern)
24
+ return regex.test(relPath)
25
+ }
26
+
27
+ function globToRegex(pattern) {
28
+ let re = ''
29
+ let i = 0
30
+ while (i < pattern.length) {
31
+ if (pattern[i] === '*' && pattern[i + 1] === '*') {
32
+ re += '.*'
33
+ i += 2
34
+ } else if (pattern[i] === '*') {
35
+ re += '[^/]*'
36
+ i++
37
+ } else if (pattern[i] === '?') {
38
+ re += '[^/]'
39
+ i++
40
+ } else if ('.+^$(){}|[]\\'.includes(pattern[i])) {
41
+ re += '\\' + pattern[i]
42
+ i++
43
+ } else {
44
+ re += pattern[i]
45
+ i++
46
+ }
47
+ }
48
+ return new RegExp('^' + re + '$')
49
+ }
50
+
51
+ /**
52
+ * 判断路径是否被排除
53
+ * @param {string} relPath - 相对路径(posix)
54
+ * @param {string[]} excludes - 排除模式列表
55
+ * @returns {boolean}
56
+ */
57
+ export function isExcluded(relPath, excludes) {
58
+ for (const pattern of excludes) {
59
+ const p = pattern.trim()
60
+ if (!p) continue
61
+ if (matchGlob(relPath, p) || matchGlob(relPath, `${p}/**`)) {
62
+ return true
63
+ }
64
+ }
65
+ return false
66
+ }
67
+
68
+ /**
69
+ * 计算文件 MD5(流式,避免大文件占内存)
70
+ * @param {string} filePath
71
+ * @returns {Promise<string>}
72
+ */
73
+ export async function md5File(filePath) {
74
+ return new Promise((resolve, reject) => {
75
+ const hash = crypto.createHash('md5')
76
+ const stream = fs.createReadStream(filePath)
77
+ stream.on('data', (chunk) => hash.update(chunk))
78
+ stream.on('end', () => resolve(hash.digest('hex')))
79
+ stream.on('error', reject)
80
+ })
81
+ }
82
+
83
+ /**
84
+ * 计算 Buffer MD5
85
+ * @param {Buffer} data
86
+ * @returns {string}
87
+ */
88
+ export function md5Buffer(data) {
89
+ return crypto.createHash('md5').update(data).digest('hex')
90
+ }
91
+
92
+ /**
93
+ * 从 magic bytes 检测图片扩展名
94
+ * @param {Buffer} data
95
+ * @returns {string|null}
96
+ */
97
+ export function detectImageExt(data) {
98
+ for (const [ext, magic] of Object.entries(IMAGE_MAGIC)) {
99
+ if (data.subarray(0, magic.length).equals(magic)) {
100
+ return ext
101
+ }
102
+ }
103
+ return null
104
+ }
105
+
106
+ /**
107
+ * 从文件名提取扩展名(仅限图片类型)
108
+ * @param {string} name
109
+ * @returns {string|null}
110
+ */
111
+ export function extFromName(name) {
112
+ const clean = name.split('?')[0]
113
+ const ext = path.extname(clean).slice(1).toLowerCase()
114
+ return ext in IMAGE_MAGIC ? ext : null
115
+ }
116
+
117
+ /**
118
+ * 生成随机文件名
119
+ * @param {string} ext
120
+ * @returns {string}
121
+ */
122
+ export function makeFilename(ext) {
123
+ const id = crypto.randomUUID().replace(/-/g, '').slice(0, 8)
124
+ return `${id}.${ext}`
125
+ }
126
+
127
+ /**
128
+ * 今天的日期字符串 YYYYMMDD
129
+ * @returns {string}
130
+ */
131
+ export function todayStr() {
132
+ const d = new Date()
133
+ const y = d.getFullYear()
134
+ const m = String(d.getMonth() + 1).padStart(2, '0')
135
+ const day = String(d.getDate()).padStart(2, '0')
136
+ return `${y}${m}${day}`
137
+ }
138
+
139
+ /**
140
+ * 读取本地文件或下载 HTTP URL 为 Buffer
141
+ * @param {string} urlOrPath
142
+ * @param {number} [timeout=120000]
143
+ * @returns {Promise<Buffer>}
144
+ */
145
+ export async function readData(urlOrPath, timeout = 120000) {
146
+ if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) {
147
+ const resp = await fetch(urlOrPath, { signal: AbortSignal.timeout(timeout) })
148
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${urlOrPath}`)
149
+ return Buffer.from(await resp.arrayBuffer())
150
+ }
151
+ if (!fs.existsSync(urlOrPath)) throw new Error(`File not found: ${urlOrPath}`)
152
+ return fs.readFileSync(urlOrPath)
153
+ }
154
+
155
+ /**
156
+ * 递归收集目录下所有文件(posix 相对路径)
157
+ * @param {string} dir
158
+ * @returns {string[]}
159
+ */
160
+ export function collectFiles(dir) {
161
+ const result = []
162
+ function walk(base) {
163
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
164
+ const full = path.join(base, entry.name)
165
+ if (entry.isDirectory()) {
166
+ walk(full)
167
+ } else if (entry.isFile()) {
168
+ result.push(path.relative(dir, full).split(path.sep).join('/'))
169
+ }
170
+ }
171
+ }
172
+ walk(dir)
173
+ return result.sort()
174
+ }