@wwkit/cft 1.0.30 → 1.0.32

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/installer.js CHANGED
@@ -1,369 +1,369 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import https from 'node:https'
4
- import http from 'node:http'
5
-
6
- import extractZip from 'extract-zip'
7
-
8
- import { getOs, VALID_OS_TYPES } from './platform.js'
9
- import { find as findExe } from './exefinder.js'
10
- import { check as checkDeps, mapToPackages, install as installDeps, isSupported as depsSupported, detectPackageManager } from './depchecker.js'
11
- import { getVersion, getRegistry, getProxy, getLocation, copyBuiltinConfig } from './config.js'
12
- import { parseProxy, openTunnel } from '@wwkit/shared'
13
-
14
- const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 cft-installer'
15
- const MANUAL_DOWNLOAD_HINT = '资源下载失败,请在 http://deepbluenet.com/webwork/#/mirror 手动下载资源,并将资源解压到 ~/.config/cft 目录,然后将地址添加到 ~/.config/webwork/webwork.json5 中的 env.SELENIUM_CFT_DIR 属性上'
16
-
17
- function log(msg) {
18
- console.log(` ${msg}`)
19
- }
20
-
21
- function sendRequest(url, proxyValue, cb, method = 'GET') {
22
- const proxy = parseProxy(proxyValue)
23
- const target = new URL(url)
24
- const headers = { 'user-agent': USER_AGENT }
25
- const onError = (e) => cb(e, null)
26
-
27
- if (!proxy) {
28
- const proto = target.protocol === 'https:' ? https : http
29
- const req = proto.request(url, { headers, method }, (res) => cb(null, res))
30
- req.on('error', onError)
31
- req.end()
32
- return
33
- }
34
-
35
- if (proxy.protocol === 'http:' && target.protocol === 'http:') {
36
- const req = http.request({
37
- host: proxy.hostname,
38
- port: Number(proxy.port) || 80,
39
- path: url,
40
- method,
41
- headers: { ...headers, host: target.host },
42
- }, (res) => cb(null, res))
43
- req.on('error', onError)
44
- req.end()
45
- return
46
- }
47
-
48
- openTunnel(proxy, target).then((socket) => {
49
- const proto = target.protocol === 'https:' ? https : http
50
- const req = proto.request({
51
- method,
52
- hostname: target.hostname,
53
- port: Number(target.port) || (target.protocol === 'https:' ? 443 : 80),
54
- path: target.pathname + target.search,
55
- headers: { ...headers, host: target.host },
56
- agent: false,
57
- createConnection: () => socket,
58
- }, (res) => cb(null, res))
59
- req.on('error', onError)
60
- socket.on('error', onError)
61
- req.end()
62
- }).catch(onError)
63
- }
64
-
65
- /**
66
- * 访问前预检完整路径是否可达(HEAD 请求,非 2xx/3xx 视为不可达)
67
- * @param {string} url - 完整下载 URL
68
- * @param {string} [proxyValue] - 代理地址
69
- * @returns {Promise<boolean>}
70
- */
71
- function checkUrlReachable(url, proxyValue) {
72
- return new Promise((resolve) => {
73
- sendRequest(url, proxyValue, (err, response) => {
74
- if (err) { resolve(false); return }
75
- response.resume()
76
- if (response.statusCode >= 200 && response.statusCode < 400) {
77
- resolve(true)
78
- } else if (response.statusCode === 405) {
79
- // 405 Method Not Allowed — 服务器已响应,仅不支持 HEAD
80
- resolve(true)
81
- } else {
82
- resolve(false)
83
- }
84
- }, 'HEAD')
85
- })
86
- }
87
-
88
- /**
89
- * 下载失败时的统一终止提示
90
- * @param {Error} [cause] - 原始错误
91
- */
92
- function downloadError(cause) {
93
- const err = new Error(MANUAL_DOWNLOAD_HINT)
94
- if (cause) err.cause = cause
95
- return err
96
- }
97
-
98
- function downloadFile(url, destPath, proxyValue, redirects = 0) {
99
- return new Promise((resolve, reject) => {
100
- if (redirects > 5) {
101
- reject(new Error(`Too many redirects: ${url}`))
102
- return
103
- }
104
- const filename = path.basename(destPath)
105
- log(`下载 ${filename} ...`)
106
-
107
- sendRequest(url, proxyValue, (err, response) => {
108
- if (err) { reject(err); return }
109
- if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308) {
110
- const location = response.headers.location
111
- response.resume()
112
- if (!location) {
113
- reject(new Error(`Redirect without Location header: ${url}`))
114
- return
115
- }
116
- downloadFile(location, destPath, proxyValue, redirects + 1).then(resolve).catch(reject)
117
- return
118
- }
119
- if (response.statusCode !== 200) {
120
- reject(new Error(`HTTP ${response.statusCode}: ${url}`))
121
- return
122
- }
123
- const total = parseInt(response.headers['content-length'] || '0', 10)
124
- let downloaded = 0
125
- let lastLog = 0
126
- response.on('data', (chunk) => {
127
- downloaded += chunk.length
128
- if (total > 0) {
129
- const pct = Math.floor(downloaded * 100 / total)
130
- if (pct >= lastLog + 10 || downloaded === total) {
131
- log(`[${filename}] ${pct}% (${Math.floor(downloaded / 1048576)}MB / ${Math.floor(total / 1048576)}MB)`)
132
- lastLog = pct
133
- }
134
- }
135
- })
136
- const file = fs.createWriteStream(destPath)
137
- response.pipe(file)
138
- file.on('finish', () => {
139
- file.close()
140
- resolve()
141
- })
142
- file.on('error', reject)
143
- response.on('error', reject)
144
- })
145
- })
146
- }
147
-
148
- async function downloadExtract(url, saveDir, filename, extractDir, proxyValue) {
149
- const filePath = path.join(saveDir, filename)
150
- await downloadFile(url, filePath, proxyValue)
151
-
152
- log(`解压到 ${path.basename(extractDir)}`)
153
- if (fs.existsSync(extractDir)) {
154
- fs.rmSync(extractDir, { recursive: true, force: true })
155
- }
156
- await extractZip(filePath, { dir: saveDir })
157
- fs.unlinkSync(filePath)
158
- }
159
-
160
- function chmodExec(filePath) {
161
- if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
162
- try { fs.chmodSync(filePath, 0o755) } catch {}
163
- }
164
- }
165
-
166
- function chmodExecDir(directory) {
167
- if (!directory || !fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) return
168
- for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
169
- const fullPath = path.join(directory, entry.name)
170
- if (entry.isDirectory()) {
171
- chmodExecDir(fullPath)
172
- } else {
173
- try { fs.chmodSync(fullPath, 0o755) } catch {}
174
- }
175
- }
176
- }
177
-
178
- function prepareDir(location) {
179
- if (!fs.existsSync(location)) {
180
- fs.mkdirSync(location, { recursive: true })
181
- return
182
- }
183
- const entries = fs.readdirSync(location)
184
- if (!entries.length) return
185
- log(`清空安装目录: ${location}`)
186
- for (const name of entries) {
187
- if (name === 'config.json5') continue
188
- const itemPath = path.join(location, name)
189
- if (fs.statSync(itemPath).isFile()) {
190
- fs.unlinkSync(itemPath)
191
- } else {
192
- fs.rmSync(itemPath, { recursive: true, force: true })
193
- }
194
- }
195
- }
196
-
197
- function readInstalledInfo(location) {
198
- const jsonPath = path.join(location, 'installed.json')
199
- if (!fs.existsSync(jsonPath)) return null
200
- try {
201
- return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
202
- } catch {
203
- return null
204
- }
205
- }
206
-
207
- function checkInstalled(version, location, osType) {
208
- const info = readInstalledInfo(location)
209
- if (!info) return { installed: false, info: {} }
210
- const chromeDir = path.join(location, `chrome-${osType}`)
211
- const driverDir = path.join(location, `chromedriver-${osType}`)
212
- if (info.version === version && info.os === osType &&
213
- fs.existsSync(chromeDir) && fs.existsSync(driverDir)) {
214
- return { installed: true, info }
215
- }
216
- return { installed: false, info: {} }
217
- }
218
-
219
- function saveInstallInfo(location, info) {
220
- const jsonPath = path.join(location, 'installed.json')
221
- fs.writeFileSync(jsonPath, JSON.stringify(info, null, 2), 'utf-8')
222
- log(`安装信息已保存到 ${jsonPath}`)
223
- }
224
-
225
- function checkSystemDeps(chromeExe, driverExe) {
226
- if (!depsSupported()) return
227
- const packageManager = detectPackageManager()
228
- for (const [label, exePath] of [['Chrome', chromeExe], ['ChromeDriver', driverExe]]) {
229
- if (!exePath || !fs.existsSync(exePath)) continue
230
- const missing = checkDeps(exePath)
231
- if (!missing.length) continue
232
- if (!packageManager) {
233
- log(`${label}: 缺少系统依赖 ${missing.join(', ')},未检测到 apt-get/dnf,请手动安装`)
234
- continue
235
- }
236
- const { packages, unknown } = mapToPackages(missing, packageManager)
237
- if (packages.length) {
238
- log(`${label}: 自动安装系统依赖 ${packages.join(', ')}`)
239
- installDeps(packages, packageManager)
240
- }
241
- if (unknown.length) {
242
- log(`${label}: 无法自动映射的库 ${unknown.join(', ')},请手动安装`)
243
- }
244
- }
245
- }
246
-
247
- /**
248
- * 安装 CFT(Chrome + ChromeDriver)
249
- * @param {object} [opts]
250
- * @param {string} [opts.proxy] - 显式代理地址(socks5://host:port 等);传入则直接使用,不读配置
251
- * @returns {Promise<object>}
252
- */
253
- export async function install(opts = {}) {
254
- copyBuiltinConfig()
255
- const version = getVersion()
256
- const registry = getRegistry()
257
- const location = getLocation()
258
- const osType = getOs()
259
- const proxy = opts.proxy || getProxy()
260
-
261
- if (!VALID_OS_TYPES.includes(osType)) {
262
- throw new Error(`Unsupported OS: ${osType}`)
263
- }
264
-
265
- const { installed, info } = checkInstalled(version, location, osType)
266
- if (installed) {
267
- log(`CFT ${version} 已安装,跳过`)
268
- return info
269
- }
270
-
271
- prepareDir(location)
272
-
273
- const chromeUrl = `${registry}/${version}/${osType}/chrome-${osType}.zip`
274
- const driverUrl = `${registry}/${version}/${osType}/chromedriver-${osType}.zip`
275
- const chromeDir = path.join(location, `chrome-${osType}`)
276
- const driverDir = path.join(location, `chromedriver-${osType}`)
277
-
278
- log(`从网络下载 CFT ${version} (${osType})`)
279
- if (proxy) log(`使用代理: ${proxy}`)
280
-
281
- for (const url of [chromeUrl, driverUrl]) {
282
- const ok = await checkUrlReachable(url, proxy)
283
- if (ok) {
284
- log(`[OK] 资源可达: ${url}`)
285
- } else {
286
- log(`[FAIL] 资源不可达: ${url}`)
287
- throw downloadError()
288
- }
289
- }
290
-
291
- try {
292
- await downloadExtract(chromeUrl, location, `chrome-${osType}.zip`, chromeDir, proxy)
293
- await downloadExtract(driverUrl, location, `chromedriver-${osType}.zip`, driverDir, proxy)
294
- } catch (cause) {
295
- throw downloadError(cause)
296
- }
297
-
298
- const chromeExe = findExe(chromeDir, 'chrome')
299
- const driverExe = findExe(driverDir, 'chromedriver')
300
-
301
- chmodExecDir(chromeDir)
302
- chmodExecDir(driverDir)
303
- chmodExec(chromeExe)
304
- chmodExec(driverExe)
305
-
306
- checkSystemDeps(chromeExe, driverExe)
307
-
308
- const installInfo = {
309
- location,
310
- version,
311
- os: osType,
312
- chrome_path: chromeExe,
313
- driver_path: driverExe,
314
- }
315
- saveInstallInfo(location, installInfo)
316
- log(`Chrome 路径: ${chromeExe}`)
317
- log(`ChromeDriver 路径: ${driverExe}`)
318
- return installInfo
319
- }
320
-
321
- export function status() {
322
- const location = getLocation()
323
- const info = readInstalledInfo(location)
324
- if (!info) {
325
- console.log('CFT 未安装,请运行: cft install')
326
- return null
327
- }
328
- const chromeOk = fs.existsSync(info.chrome_path || '')
329
- const driverOk = fs.existsSync(info.driver_path || '')
330
- console.log(`版本: ${info.version || 'unknown'} (os: ${info.os || 'unknown'})`)
331
- console.log(`Chrome: ${info.chrome_path || ''} ${chromeOk ? 'OK' : '文件不存在'}`)
332
- console.log(`ChromeDriver: ${info.driver_path || ''} ${driverOk ? 'OK' : '文件不存在'}`)
333
- return info
334
- }
335
-
336
- export function clear() {
337
- const location = getLocation()
338
- if (!fs.existsSync(location)) {
339
- console.log(`安装目录不存在: ${location}`)
340
- return false
341
- }
342
-
343
- const info = readInstalledInfo(location)
344
- const label = info ? `CFT ${info.version || ''} (${info.os || ''})` : 'CFT'
345
-
346
- // only remove installation artifacts, keep config.json5
347
- const artifacts = []
348
- if (info?.os) {
349
- artifacts.push(`chrome-${info.os}`, `chromedriver-${info.os}`)
350
- }
351
- artifacts.push('installed.json')
352
-
353
- let removed = false
354
- for (const name of artifacts) {
355
- const p = path.join(location, name)
356
- if (fs.existsSync(p)) {
357
- fs.rmSync(p, { recursive: true, force: true })
358
- removed = true
359
- }
360
- }
361
-
362
- if (removed) {
363
- console.log(` ✓ 已删除 ${label}`)
364
- console.log(` ${location}`)
365
- } else {
366
- console.log(` 无安装产物可删除`)
367
- }
368
- return removed
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import https from 'node:https'
4
+ import http from 'node:http'
5
+
6
+ import extractZip from 'extract-zip'
7
+
8
+ import { getOs, VALID_OS_TYPES } from './platform.js'
9
+ import { find as findExe } from './exefinder.js'
10
+ import { check as checkDeps, mapToPackages, install as installDeps, isSupported as depsSupported, detectPackageManager } from './depchecker.js'
11
+ import { getVersion, getRegistry, getProxy, getLocation, copyBuiltinConfig } from './config.js'
12
+ import { parseProxy, openTunnel } from '@wwkit/shared'
13
+
14
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 cft-installer'
15
+ const MANUAL_DOWNLOAD_HINT = '资源下载失败,请在 http://deepbluenet.com/webwork/#/mirror 手动下载资源,并将资源解压到 ~/.config/cft 目录,然后将地址添加到 ~/.config/webwork/webwork.json5 中的 env.SELENIUM_CFT_DIR 属性上'
16
+
17
+ function log(msg) {
18
+ console.log(` ${msg}`)
19
+ }
20
+
21
+ function sendRequest(url, proxyValue, cb, method = 'GET') {
22
+ const proxy = parseProxy(proxyValue)
23
+ const target = new URL(url)
24
+ const headers = { 'user-agent': USER_AGENT }
25
+ const onError = (e) => cb(e, null)
26
+
27
+ if (!proxy) {
28
+ const proto = target.protocol === 'https:' ? https : http
29
+ const req = proto.request(url, { headers, method }, (res) => cb(null, res))
30
+ req.on('error', onError)
31
+ req.end()
32
+ return
33
+ }
34
+
35
+ if (proxy.protocol === 'http:' && target.protocol === 'http:') {
36
+ const req = http.request({
37
+ host: proxy.hostname,
38
+ port: Number(proxy.port) || 80,
39
+ path: url,
40
+ method,
41
+ headers: { ...headers, host: target.host },
42
+ }, (res) => cb(null, res))
43
+ req.on('error', onError)
44
+ req.end()
45
+ return
46
+ }
47
+
48
+ openTunnel(proxy, target).then((socket) => {
49
+ const proto = target.protocol === 'https:' ? https : http
50
+ const req = proto.request({
51
+ method,
52
+ hostname: target.hostname,
53
+ port: Number(target.port) || (target.protocol === 'https:' ? 443 : 80),
54
+ path: target.pathname + target.search,
55
+ headers: { ...headers, host: target.host },
56
+ agent: false,
57
+ createConnection: () => socket,
58
+ }, (res) => cb(null, res))
59
+ req.on('error', onError)
60
+ socket.on('error', onError)
61
+ req.end()
62
+ }).catch(onError)
63
+ }
64
+
65
+ /**
66
+ * 访问前预检完整路径是否可达(HEAD 请求,非 2xx/3xx 视为不可达)
67
+ * @param {string} url - 完整下载 URL
68
+ * @param {string} [proxyValue] - 代理地址
69
+ * @returns {Promise<boolean>}
70
+ */
71
+ function checkUrlReachable(url, proxyValue) {
72
+ return new Promise((resolve) => {
73
+ sendRequest(url, proxyValue, (err, response) => {
74
+ if (err) { resolve(false); return }
75
+ response.resume()
76
+ if (response.statusCode >= 200 && response.statusCode < 400) {
77
+ resolve(true)
78
+ } else if (response.statusCode === 405) {
79
+ // 405 Method Not Allowed — 服务器已响应,仅不支持 HEAD
80
+ resolve(true)
81
+ } else {
82
+ resolve(false)
83
+ }
84
+ }, 'HEAD')
85
+ })
86
+ }
87
+
88
+ /**
89
+ * 下载失败时的统一终止提示
90
+ * @param {Error} [cause] - 原始错误
91
+ */
92
+ function downloadError(cause) {
93
+ const err = new Error(MANUAL_DOWNLOAD_HINT)
94
+ if (cause) err.cause = cause
95
+ return err
96
+ }
97
+
98
+ function downloadFile(url, destPath, proxyValue, redirects = 0) {
99
+ return new Promise((resolve, reject) => {
100
+ if (redirects > 5) {
101
+ reject(new Error(`Too many redirects: ${url}`))
102
+ return
103
+ }
104
+ const filename = path.basename(destPath)
105
+ log(`下载 ${filename} ...`)
106
+
107
+ sendRequest(url, proxyValue, (err, response) => {
108
+ if (err) { reject(err); return }
109
+ if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308) {
110
+ const location = response.headers.location
111
+ response.resume()
112
+ if (!location) {
113
+ reject(new Error(`Redirect without Location header: ${url}`))
114
+ return
115
+ }
116
+ downloadFile(location, destPath, proxyValue, redirects + 1).then(resolve).catch(reject)
117
+ return
118
+ }
119
+ if (response.statusCode !== 200) {
120
+ reject(new Error(`HTTP ${response.statusCode}: ${url}`))
121
+ return
122
+ }
123
+ const total = parseInt(response.headers['content-length'] || '0', 10)
124
+ let downloaded = 0
125
+ let lastLog = 0
126
+ response.on('data', (chunk) => {
127
+ downloaded += chunk.length
128
+ if (total > 0) {
129
+ const pct = Math.floor(downloaded * 100 / total)
130
+ if (pct >= lastLog + 10 || downloaded === total) {
131
+ log(`[${filename}] ${pct}% (${Math.floor(downloaded / 1048576)}MB / ${Math.floor(total / 1048576)}MB)`)
132
+ lastLog = pct
133
+ }
134
+ }
135
+ })
136
+ const file = fs.createWriteStream(destPath)
137
+ response.pipe(file)
138
+ file.on('finish', () => {
139
+ file.close()
140
+ resolve()
141
+ })
142
+ file.on('error', reject)
143
+ response.on('error', reject)
144
+ })
145
+ })
146
+ }
147
+
148
+ async function downloadExtract(url, saveDir, filename, extractDir, proxyValue) {
149
+ const filePath = path.join(saveDir, filename)
150
+ await downloadFile(url, filePath, proxyValue)
151
+
152
+ log(`解压到 ${path.basename(extractDir)}`)
153
+ if (fs.existsSync(extractDir)) {
154
+ fs.rmSync(extractDir, { recursive: true, force: true })
155
+ }
156
+ await extractZip(filePath, { dir: saveDir })
157
+ fs.unlinkSync(filePath)
158
+ }
159
+
160
+ function chmodExec(filePath) {
161
+ if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
162
+ try { fs.chmodSync(filePath, 0o755) } catch {}
163
+ }
164
+ }
165
+
166
+ function chmodExecDir(directory) {
167
+ if (!directory || !fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) return
168
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
169
+ const fullPath = path.join(directory, entry.name)
170
+ if (entry.isDirectory()) {
171
+ chmodExecDir(fullPath)
172
+ } else {
173
+ try { fs.chmodSync(fullPath, 0o755) } catch {}
174
+ }
175
+ }
176
+ }
177
+
178
+ function prepareDir(location) {
179
+ if (!fs.existsSync(location)) {
180
+ fs.mkdirSync(location, { recursive: true })
181
+ return
182
+ }
183
+ const entries = fs.readdirSync(location)
184
+ if (!entries.length) return
185
+ log(`清空安装目录: ${location}`)
186
+ for (const name of entries) {
187
+ if (name === 'config.json5') continue
188
+ const itemPath = path.join(location, name)
189
+ if (fs.statSync(itemPath).isFile()) {
190
+ fs.unlinkSync(itemPath)
191
+ } else {
192
+ fs.rmSync(itemPath, { recursive: true, force: true })
193
+ }
194
+ }
195
+ }
196
+
197
+ function readInstalledInfo(location) {
198
+ const jsonPath = path.join(location, 'installed.json')
199
+ if (!fs.existsSync(jsonPath)) return null
200
+ try {
201
+ return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
202
+ } catch {
203
+ return null
204
+ }
205
+ }
206
+
207
+ function checkInstalled(version, location, osType) {
208
+ const info = readInstalledInfo(location)
209
+ if (!info) return { installed: false, info: {} }
210
+ const chromeDir = path.join(location, `chrome-${osType}`)
211
+ const driverDir = path.join(location, `chromedriver-${osType}`)
212
+ if (info.version === version && info.os === osType &&
213
+ fs.existsSync(chromeDir) && fs.existsSync(driverDir)) {
214
+ return { installed: true, info }
215
+ }
216
+ return { installed: false, info: {} }
217
+ }
218
+
219
+ function saveInstallInfo(location, info) {
220
+ const jsonPath = path.join(location, 'installed.json')
221
+ fs.writeFileSync(jsonPath, JSON.stringify(info, null, 2), 'utf-8')
222
+ log(`安装信息已保存到 ${jsonPath}`)
223
+ }
224
+
225
+ function checkSystemDeps(chromeExe, driverExe) {
226
+ if (!depsSupported()) return
227
+ const packageManager = detectPackageManager()
228
+ for (const [label, exePath] of [['Chrome', chromeExe], ['ChromeDriver', driverExe]]) {
229
+ if (!exePath || !fs.existsSync(exePath)) continue
230
+ const missing = checkDeps(exePath)
231
+ if (!missing.length) continue
232
+ if (!packageManager) {
233
+ log(`${label}: 缺少系统依赖 ${missing.join(', ')},未检测到 apt-get/dnf,请手动安装`)
234
+ continue
235
+ }
236
+ const { packages, unknown } = mapToPackages(missing, packageManager)
237
+ if (packages.length) {
238
+ log(`${label}: 自动安装系统依赖 ${packages.join(', ')}`)
239
+ installDeps(packages, packageManager)
240
+ }
241
+ if (unknown.length) {
242
+ log(`${label}: 无法自动映射的库 ${unknown.join(', ')},请手动安装`)
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * 安装 CFT(Chrome + ChromeDriver)
249
+ * @param {object} [opts]
250
+ * @param {string} [opts.proxy] - 显式代理地址(socks5://host:port 等);传入则直接使用,不读配置
251
+ * @returns {Promise<object>}
252
+ */
253
+ export async function install(opts = {}) {
254
+ copyBuiltinConfig()
255
+ const version = getVersion()
256
+ const registry = getRegistry()
257
+ const location = getLocation()
258
+ const osType = getOs()
259
+ const proxy = opts.proxy || getProxy()
260
+
261
+ if (!VALID_OS_TYPES.includes(osType)) {
262
+ throw new Error(`Unsupported OS: ${osType}`)
263
+ }
264
+
265
+ const { installed, info } = checkInstalled(version, location, osType)
266
+ if (installed) {
267
+ log(`CFT ${version} 已安装,跳过`)
268
+ return info
269
+ }
270
+
271
+ prepareDir(location)
272
+
273
+ const chromeUrl = `${registry}/${version}/${osType}/chrome-${osType}.zip`
274
+ const driverUrl = `${registry}/${version}/${osType}/chromedriver-${osType}.zip`
275
+ const chromeDir = path.join(location, `chrome-${osType}`)
276
+ const driverDir = path.join(location, `chromedriver-${osType}`)
277
+
278
+ log(`从网络下载 CFT ${version} (${osType})`)
279
+ if (proxy) log(`使用代理: ${proxy}`)
280
+
281
+ for (const url of [chromeUrl, driverUrl]) {
282
+ const ok = await checkUrlReachable(url, proxy)
283
+ if (ok) {
284
+ log(`[OK] 资源可达: ${url}`)
285
+ } else {
286
+ log(`[FAIL] 资源不可达: ${url}`)
287
+ throw downloadError()
288
+ }
289
+ }
290
+
291
+ try {
292
+ await downloadExtract(chromeUrl, location, `chrome-${osType}.zip`, chromeDir, proxy)
293
+ await downloadExtract(driverUrl, location, `chromedriver-${osType}.zip`, driverDir, proxy)
294
+ } catch (cause) {
295
+ throw downloadError(cause)
296
+ }
297
+
298
+ const chromeExe = findExe(chromeDir, 'chrome')
299
+ const driverExe = findExe(driverDir, 'chromedriver')
300
+
301
+ chmodExecDir(chromeDir)
302
+ chmodExecDir(driverDir)
303
+ chmodExec(chromeExe)
304
+ chmodExec(driverExe)
305
+
306
+ checkSystemDeps(chromeExe, driverExe)
307
+
308
+ const installInfo = {
309
+ location,
310
+ version,
311
+ os: osType,
312
+ chrome_path: chromeExe,
313
+ driver_path: driverExe,
314
+ }
315
+ saveInstallInfo(location, installInfo)
316
+ log(`Chrome 路径: ${chromeExe}`)
317
+ log(`ChromeDriver 路径: ${driverExe}`)
318
+ return installInfo
319
+ }
320
+
321
+ export function status() {
322
+ const location = getLocation()
323
+ const info = readInstalledInfo(location)
324
+ if (!info) {
325
+ console.log('CFT 未安装,请运行: cft install')
326
+ return null
327
+ }
328
+ const chromeOk = fs.existsSync(info.chrome_path || '')
329
+ const driverOk = fs.existsSync(info.driver_path || '')
330
+ console.log(`版本: ${info.version || 'unknown'} (os: ${info.os || 'unknown'})`)
331
+ console.log(`Chrome: ${info.chrome_path || ''} ${chromeOk ? 'OK' : '文件不存在'}`)
332
+ console.log(`ChromeDriver: ${info.driver_path || ''} ${driverOk ? 'OK' : '文件不存在'}`)
333
+ return info
334
+ }
335
+
336
+ export function clear() {
337
+ const location = getLocation()
338
+ if (!fs.existsSync(location)) {
339
+ console.log(`安装目录不存在: ${location}`)
340
+ return false
341
+ }
342
+
343
+ const info = readInstalledInfo(location)
344
+ const label = info ? `CFT ${info.version || ''} (${info.os || ''})` : 'CFT'
345
+
346
+ // only remove installation artifacts, keep config.json5
347
+ const artifacts = []
348
+ if (info?.os) {
349
+ artifacts.push(`chrome-${info.os}`, `chromedriver-${info.os}`)
350
+ }
351
+ artifacts.push('installed.json')
352
+
353
+ let removed = false
354
+ for (const name of artifacts) {
355
+ const p = path.join(location, name)
356
+ if (fs.existsSync(p)) {
357
+ fs.rmSync(p, { recursive: true, force: true })
358
+ removed = true
359
+ }
360
+ }
361
+
362
+ if (removed) {
363
+ console.log(` ✓ 已删除 ${label}`)
364
+ console.log(` ${location}`)
365
+ } else {
366
+ console.log(` 无安装产物可删除`)
367
+ }
368
+ return removed
369
369
  }