@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/README.md +194 -11
- package/bin/index.js +23 -0
- package/docs/managers/git.md +103 -0
- package/docs/managers/proc.md +77 -0
- package/docs/managers/uv.md +9 -3
- package/docs/opm.html +154 -11
- package/docs/packages/opencode.md +46 -15
- package/docs/test/linux-environment-test-report.md +147 -0
- package/package.json +4 -2
- package/src/cli/groups/docker.js +121 -0
- package/src/cli/groups/homebrew.js +9 -5
- package/src/cli/groups/nvm.js +6 -2
- package/src/cli/groups/ping.js +461 -0
- package/src/cli/groups/proc.js +232 -0
- package/src/cli/groups/uv.js +9 -8
- package/src/cli/groups/version-manager.js +34 -8
- package/src/cli/groups/wsl.js +220 -0
- package/src/cli/index.js +12 -0
- package/src/config.json5 +80 -6
- package/src/harnesses/base.js +34 -0
- package/src/harnesses/opencode/index.js +56 -0
- package/src/index.js +19 -0
- package/src/managers/brew.js +6 -6
- package/src/managers/docker.js +298 -0
- package/src/managers/node.js +8 -2
- package/src/managers/proc.js +424 -0
- package/src/managers/python.js +7 -4
- package/src/managers/wsl.js +258 -0
- package/src/presets.js +3 -0
- package/src/tools/ftp/client.js +214 -0
- package/src/tools/ftp/index.js +406 -0
- package/src/tools/ftp/util.js +174 -0
- package/src/tools/git/config.js +374 -0
- package/src/tools/git/index.js +378 -0
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
|
+
}
|