@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.
- package/package.json +2 -1
- package/src/cli/groups/docker.js +116 -0
- package/src/cli/groups/wsl.js +220 -0
- package/src/cli/index.js +6 -0
- package/src/config.json5 +15 -0
- package/src/index.js +15 -0
- package/src/managers/docker.js +295 -0
- package/src/managers/wsl.js +258 -0
- package/src/tools/ftp/client.js +214 -0
- package/src/tools/ftp/index.js +271 -0
- package/src/tools/ftp/util.js +174 -0
|
@@ -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
|
+
}
|