@wwkit/opm 1.0.1 → 1.0.3

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/src/presets.js CHANGED
@@ -19,6 +19,9 @@ export const NODE_PRESETS = _getPresets('node')
19
19
  export const PHPENV_PRESETS = _getPresets('phpenv')
20
20
  export const PHP_PRESETS = _getPresets('php')
21
21
  export const PYTHON_PRESETS = _getPresets('python')
22
+ export const UV_PRESETS = _getPresets('uv')
23
+ export const DOCKER_PRESETS = _getPresets('docker')
24
+ export const HOMEBREW_PRESETS = _getPresets('homebrew')
22
25
 
23
26
  export const getPresets = _getPresets
24
27
  export const resolveRegistryInput = _resolveRegistryInput
@@ -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,406 @@
1
+ /**
2
+ * opm ftp 命令组 — FTP 文件管理
3
+ *
4
+ * 命令:version / installed / install / uninstall / upgrade /
5
+ * connect / upload / rsync / download / root
6
+ */
7
+
8
+ import path from 'node:path'
9
+ import fs from 'node:fs'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { createRequire } from 'node:module'
12
+ import { spawnSync } from 'node:child_process'
13
+ import { parseFlags } from '../../cli/helpers/args.js'
14
+ import { getActiveProxy, getActiveRegistry } from '../../config.js'
15
+ import { output } from '../../formatter.js'
16
+ import {
17
+ getFtpConfig, checkConnection, uploadBuffer, uploadFile,
18
+ downloadFile, listRemoteFiles, removeFile, ensureDir, connect,
19
+ } from './client.js'
20
+ import {
21
+ isExcluded, md5File, detectImageExt, extFromName, makeFilename,
22
+ todayStr, readData, collectFiles,
23
+ } from './util.js'
24
+
25
+ const FTP_DEP = 'basic-ftp'
26
+
27
+ /**
28
+ * 检查 basic-ftp 是否已安装,返回版本号或 null
29
+ * @returns {string|null}
30
+ */
31
+ function checkBasicFtp() {
32
+ try {
33
+ const require = createRequire(import.meta.url)
34
+ const pkg = require(`${FTP_DEP}/package.json`)
35
+ return pkg.version || 'unknown'
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 获取 opm 包根目录(package.json 所在目录)
43
+ * @returns {string}
44
+ */
45
+ function getPkgRoot() {
46
+ const here = path.dirname(fileURLToPath(import.meta.url))
47
+ return path.resolve(here, '../../..')
48
+ }
49
+
50
+ export class FtpGroup {
51
+ constructor() {
52
+ this.name = 'ftp'
53
+ this.desc = 'FTP file management (upload/rsync/download)'
54
+ }
55
+
56
+ async run(argv) {
57
+ const [action, ...rest] = argv
58
+
59
+ if (!action || action === '-h' || action === '--help' || action === 'help') {
60
+ this.printHelp()
61
+ return
62
+ }
63
+
64
+ switch (action) {
65
+ case 'version':
66
+ return this._version()
67
+ case 'installed':
68
+ return this._installed()
69
+ case 'install':
70
+ return this._install(rest)
71
+ case 'uninstall':
72
+ return this._uninstall()
73
+ case 'upgrade':
74
+ return this._upgrade(rest)
75
+ case 'connect':
76
+ return this._guard(() => this._connect())
77
+ case 'upload':
78
+ return this._guard(() => this._upload(parseFlags(rest)))
79
+ case 'rsync':
80
+ return this._guard(() => this._rsync(parseFlags(rest)))
81
+ case 'download':
82
+ return this._guard(() => this._download(parseFlags(rest)))
83
+ case 'root':
84
+ return this._root()
85
+ default:
86
+ console.error(`Unknown action: ${action}`)
87
+ this.printHelp()
88
+ process.exit(1)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 检查 basic-ftp 是否可用,不可用则提示安装并退出
94
+ * @param {() => Promise<void>} fn
95
+ * @private
96
+ */
97
+ async _guard(fn) {
98
+ const ver = checkBasicFtp()
99
+ if (!ver) {
100
+ console.error(`basic-ftp is not installed. Run \`opm ftp install\` to install it.`)
101
+ process.exit(1)
102
+ }
103
+ return fn()
104
+ }
105
+
106
+ /**
107
+ * 解析 proxy(-p/--proxy 优先,否则回退配置 active proxy)
108
+ * @param {string[]} argv
109
+ * @returns {string}
110
+ * @private
111
+ */
112
+ _resolveProxy(argv) {
113
+ const parsed = parseFlags(argv)
114
+ let proxy = parsed.flags.proxy || parsed.flags.p || ''
115
+ if (proxy === 'true') proxy = ''
116
+ if (!proxy) proxy = getActiveProxy()
117
+ return proxy
118
+ }
119
+
120
+ /**
121
+ * 在 opm 包根目录执行 npm 命令(自动附加 registry + proxy)
122
+ * @param {string[]} args - npm 子命令参数
123
+ * @param {string} proxy
124
+ * @private
125
+ */
126
+ _npmExec(args, proxy) {
127
+ const registry = getActiveRegistry('npm')
128
+ const fullArgs = [...args]
129
+ if (registry) fullArgs.push('--registry', registry)
130
+ if (proxy) fullArgs.push('--proxy', proxy)
131
+
132
+ const pkgRoot = getPkgRoot()
133
+ const result = spawnSync('npm', fullArgs, { stdio: 'inherit', shell: true, cwd: pkgRoot })
134
+ if (result.error) {
135
+ throw new Error(result.error.message)
136
+ }
137
+ if (result.status !== 0) {
138
+ throw new Error(`npm ${fullArgs.join(' ')} exited with code ${result.status}`)
139
+ }
140
+ }
141
+
142
+ async _version() {
143
+ const ver = checkBasicFtp()
144
+ output(ver || '')
145
+ }
146
+
147
+ async _installed() {
148
+ const ver = checkBasicFtp()
149
+ output({
150
+ name: FTP_DEP,
151
+ installed: !!ver,
152
+ ...(ver ? { version: ver } : { hint: `Run \`opm ftp install\` to install` }),
153
+ })
154
+ }
155
+
156
+ async _install(rest) {
157
+ const ver = checkBasicFtp()
158
+ if (ver) {
159
+ output({ name: FTP_DEP, installed: true, version: ver })
160
+ return
161
+ }
162
+ const proxy = this._resolveProxy(rest)
163
+ console.log(`Installing ${FTP_DEP} ...`)
164
+ this._npmExec(['install', FTP_DEP], proxy)
165
+ const newVer = checkBasicFtp()
166
+ output({ name: FTP_DEP, installed: true, version: newVer || 'unknown', action: 'installed' })
167
+ }
168
+
169
+ async _uninstall() {
170
+ const ver = checkBasicFtp()
171
+ if (!ver) {
172
+ output({ name: FTP_DEP, installed: false, action: 'not installed' })
173
+ return
174
+ }
175
+ console.log(`Uninstalling ${FTP_DEP} ...`)
176
+ this._npmExec(['uninstall', FTP_DEP], '')
177
+ output({ name: FTP_DEP, action: 'uninstalled' })
178
+ }
179
+
180
+ async _upgrade(rest) {
181
+ const proxy = this._resolveProxy(rest)
182
+ console.log(`Upgrading ${FTP_DEP} ...`)
183
+ this._npmExec(['install', `${FTP_DEP}@latest`], proxy)
184
+ const newVer = checkBasicFtp()
185
+ output({ name: FTP_DEP, installed: true, version: newVer || 'unknown', action: 'upgraded' })
186
+ }
187
+
188
+ async _connect() {
189
+ const ok = await checkConnection()
190
+ output({ connected: ok })
191
+ }
192
+
193
+ async _upload(parsed) {
194
+ const file = parsed.positional[0]
195
+ if (!file) {
196
+ console.error('Usage: opm ftp upload <file> [-n name] [-s subdir]')
197
+ process.exit(1)
198
+ }
199
+ const name = parsed.flags.name || parsed.flags.n || ''
200
+ const subdir = parsed.flags.subdir || parsed.flags.s || ''
201
+ const cfg = getFtpConfig()
202
+
203
+ const data = await readData(file, cfg.timeout * 1000)
204
+ if (!data || data.length === 0) {
205
+ console.error('Failed to read data (empty or not found)')
206
+ process.exit(1)
207
+ }
208
+
209
+ let ext = detectImageExt(data) || extFromName(name || file) || ''
210
+ if (!ext) {
211
+ const fromPath = path.extname(file).slice(1).toLowerCase()
212
+ ext = fromPath || 'bin'
213
+ }
214
+
215
+ let filename
216
+ if (name) {
217
+ const nameExt = path.extname(name).slice(1).toLowerCase()
218
+ if (nameExt && nameExt !== ext) ext = nameExt
219
+ filename = name.endsWith(`.${ext}`) ? name : `${name}.${ext}`
220
+ } else {
221
+ filename = makeFilename(ext)
222
+ }
223
+
224
+ const date = todayStr()
225
+ const parts = [cfg.remote_root, date]
226
+ if (subdir) parts.push(subdir.replace(/^\/+/, ''))
227
+ parts.push(filename)
228
+ const remotePath = parts.join('/')
229
+
230
+ const ok = await uploadBuffer(data, remotePath)
231
+ if (!ok) {
232
+ console.error('Upload failed')
233
+ process.exit(1)
234
+ }
235
+
236
+ const urlParts = [cfg.base_url, date]
237
+ if (subdir) urlParts.push(subdir.replace(/^\/+/, ''))
238
+ urlParts.push(filename)
239
+ output({ uploaded: true, remote: remotePath, url: urlParts.join('/') })
240
+ }
241
+
242
+ async _rsync(parsed) {
243
+ const local = parsed.positional[0]
244
+ if (!local) {
245
+ console.error('Usage: opm ftp rsync <local> [-s subdir] [-x pattern] [--dry-run] [--prune]')
246
+ process.exit(1)
247
+ }
248
+ const subdir = parsed.flags.subdir || parsed.flags.s || ''
249
+ const excludes = parsed.flags.exclude || parsed.flags.x || []
250
+ const excludeList = Array.isArray(excludes) ? excludes : [excludes]
251
+ const dryRun = parsed.flags['dry-run'] === 'true'
252
+ const prune = parsed.flags.prune === 'true'
253
+
254
+ const cfg = getFtpConfig()
255
+ const localDir = path.resolve(local)
256
+ if (!fs.existsSync(localDir) || !fs.statSync(localDir).isDirectory()) {
257
+ console.error(`Local directory not found: ${localDir}`)
258
+ process.exit(1)
259
+ }
260
+
261
+ const remoteBase = subdir
262
+ ? `${cfg.remote_root}/${subdir.replace(/^\/+/, '')}`
263
+ : cfg.remote_root
264
+
265
+ const allFiles = collectFiles(localDir)
266
+ const toUpload = allFiles.filter((rel) => !isExcluded(rel, excludeList))
267
+ const skippedExcluded = allFiles.filter((rel) => isExcluded(rel, excludeList))
268
+
269
+ const remoteFiles = await listRemoteFiles(remoteBase)
270
+
271
+ const uploadPlan = []
272
+ const skippedSame = []
273
+ for (const rel of toUpload) {
274
+ const localPath = path.join(localDir, ...rel.split('/'))
275
+ const stat = fs.statSync(localPath)
276
+ const remoteSize = remoteFiles.get(rel)
277
+ if (remoteSize !== undefined && remoteSize === stat.size) {
278
+ skippedSame.push(rel)
279
+ } else {
280
+ uploadPlan.push(rel)
281
+ }
282
+ }
283
+
284
+ const deletePlan = []
285
+ if (prune) {
286
+ for (const [rel] of remoteFiles) {
287
+ if (!toUpload.includes(rel)) {
288
+ deletePlan.push(rel)
289
+ }
290
+ }
291
+ }
292
+
293
+ if (dryRun) {
294
+ output({
295
+ dryRun: true,
296
+ remote: remoteBase,
297
+ upload: uploadPlan,
298
+ skipSame: skippedSame.length,
299
+ skipExcluded: skippedExcluded,
300
+ delete: deletePlan,
301
+ })
302
+ return
303
+ }
304
+
305
+ const uploaded = []
306
+ const failed = []
307
+ const total = uploadPlan.length
308
+ for (let i = 0; i < uploadPlan.length; i++) {
309
+ const rel = uploadPlan[i]
310
+ const localPath = path.join(localDir, ...rel.split('/'))
311
+ const remotePath = `${remoteBase}/${rel}`
312
+ try {
313
+ await uploadFile(localPath, remotePath)
314
+ uploaded.push(rel)
315
+ } catch (err) {
316
+ failed.push({ path: rel, error: err.message })
317
+ }
318
+ process.stderr.write(`\r[${i + 1}/${total}] ${rel}`)
319
+ }
320
+ if (total > 0) process.stderr.write('\n')
321
+
322
+ const deleted = []
323
+ const deleteFailed = []
324
+ for (const rel of deletePlan) {
325
+ const remotePath = `${remoteBase}/${rel}`
326
+ const ok = await removeFile(remotePath)
327
+ if (ok) deleted.push(rel)
328
+ else deleteFailed.push(rel)
329
+ }
330
+
331
+ output({
332
+ remote: remoteBase,
333
+ uploaded: uploaded.length,
334
+ uploadedFiles: uploaded,
335
+ skippedSame: skippedSame.length,
336
+ skippedExcluded,
337
+ failed,
338
+ pruned: deleted.length,
339
+ prunedFiles: deleted,
340
+ pruneFailed: deleteFailed,
341
+ })
342
+ }
343
+
344
+ async _download(parsed) {
345
+ const remote = parsed.positional[0]
346
+ const local = parsed.positional[1]
347
+ if (!remote || !local) {
348
+ console.error('Usage: opm ftp download <remote> <local>')
349
+ process.exit(1)
350
+ }
351
+ const cfg = getFtpConfig()
352
+ const remotePath = remote.startsWith('/') ? remote : `${cfg.remote_root}/${remote}`
353
+ const ok = await downloadFile(remotePath, local)
354
+ output({ downloaded: ok, remote: remotePath, local })
355
+ }
356
+
357
+ _root() {
358
+ const cfg = getFtpConfig()
359
+ output({ remote_root: cfg.remote_root })
360
+ }
361
+
362
+ printHelp() {
363
+ console.log(`
364
+ Usage: opm ftp <action> [args] [options]
365
+
366
+ Actions:
367
+ version Show installed basic-ftp version
368
+ installed Check if basic-ftp is installed
369
+ install Install basic-ftp (uses opm npm registry + proxy)
370
+ uninstall Uninstall basic-ftp
371
+ upgrade Upgrade basic-ftp to latest (uses opm npm registry + proxy)
372
+ connect Test FTP connection
373
+ upload <file> [-n name] [-s subdir] Upload file (local path or HTTP URL)
374
+ rsync <local> [options] Recursive upload directory (FTP-side dedup)
375
+ download <remote> <local> Download file from FTP
376
+ root Show configured remote root directory
377
+ help Show this help
378
+
379
+ rsync options:
380
+ -s, --subdir <dir> Remote subdirectory (under remote_root)
381
+ -x, --exclude <pattern> Exclude glob pattern (repeatable)
382
+ --dry-run Preview without uploading
383
+ --prune Delete remote files not in local
384
+
385
+ Options:
386
+ -p, --proxy <url> Proxy for install/upgrade (overrides config proxy.active)
387
+ -h, --help Show this help
388
+
389
+ Examples:
390
+ opm ftp version
391
+ opm ftp installed
392
+ opm ftp install
393
+ opm ftp upgrade
394
+ opm ftp connect
395
+ opm ftp upload ./img.png
396
+ opm ftp upload ./img.png -n cover -s banner
397
+ opm ftp upload https://example.com/a.jpg -n cover.jpg
398
+ opm ftp rsync ./webwork/
399
+ opm ftp rsync ./webwork/ -s deploy -x "vendor/*" -x "*.log"
400
+ opm ftp rsync ./webwork/ --dry-run
401
+ opm ftp rsync ./webwork/ --prune
402
+ opm ftp download 20260913/cover.jpg ./cover.jpg
403
+ opm ftp root
404
+ `)
405
+ }
406
+ }