@wwkit/cft 1.0.1
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/LICENSE +21 -0
- package/README.md +68 -0
- package/bin/index.js +38 -0
- package/package.json +49 -0
- package/src/config.js +35 -0
- package/src/config.json5 +4 -0
- package/src/depchecker.js +159 -0
- package/src/exefinder.js +110 -0
- package/src/index.js +3 -0
- package/src/installer.js +260 -0
- package/src/platform.js +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 bluesliu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @wwkit/cft
|
|
2
|
+
|
|
3
|
+
Chrome for Testing (CFT) 安装器 — 下载、解压、chmod、Linux 依赖检查。
|
|
4
|
+
|
|
5
|
+
## 用法
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cft install # 下载并安装 Chrome for Testing(已装同版本则跳过)
|
|
9
|
+
cft status # 显示安装状态
|
|
10
|
+
cft clear # 删除安装产物(~/.config/cft/)
|
|
11
|
+
cft help # 显示帮助
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 安装目录
|
|
15
|
+
|
|
16
|
+
`~/.config/cft/`(或 `$XDG_CONFIG_HOME/cft/`)
|
|
17
|
+
|
|
18
|
+
## installed.json
|
|
19
|
+
|
|
20
|
+
安装完成后生成 `~/.config/cft/installed.json`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"location": "~/.config/cft",
|
|
25
|
+
"version": "141.0.7390.54",
|
|
26
|
+
"os": "mac-arm64",
|
|
27
|
+
"chrome_path": ".../chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
28
|
+
"driver_path": ".../chromedriver-mac-arm64/chromedriver"
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 配置
|
|
33
|
+
|
|
34
|
+
版本和镜像源硬编码在包内,不接受外部参数。
|
|
35
|
+
|
|
36
|
+
- 默认版本: `141.0.7390.54`
|
|
37
|
+
- 默认镜像: `https://npmmirror.com/mirrors/chrome-for-testing`
|
|
38
|
+
|
|
39
|
+
## 本地开发
|
|
40
|
+
|
|
41
|
+
### 环境要求
|
|
42
|
+
|
|
43
|
+
- Node.js >= 18
|
|
44
|
+
- pnpm(workspace 根目录 `pnpm install` 自动链接 `@wwkit/shared`)
|
|
45
|
+
|
|
46
|
+
### 直接调用命令
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# 通过 bin 入口(推荐,模拟 npm 全局安装后的行为)
|
|
50
|
+
node bin status
|
|
51
|
+
node bin install
|
|
52
|
+
node bin help
|
|
53
|
+
|
|
54
|
+
# 或通过 pnpm script
|
|
55
|
+
pnpm start install
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 运行测试
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pnpm test # 单元测试
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 发布
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pnpm release # pnpm version patch && pnpm publish
|
|
68
|
+
```
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { install, status, clear } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
const command = process.argv[2]
|
|
6
|
+
|
|
7
|
+
if (command === 'install') {
|
|
8
|
+
install()
|
|
9
|
+
.then((info) => {
|
|
10
|
+
console.log()
|
|
11
|
+
console.log(' ✓ CFT 安装完成')
|
|
12
|
+
process.exit(0)
|
|
13
|
+
})
|
|
14
|
+
.catch((err) => {
|
|
15
|
+
console.error(` ✗ CFT 安装失败: ${err.message}`)
|
|
16
|
+
process.exit(1)
|
|
17
|
+
})
|
|
18
|
+
} else if (command === 'status') {
|
|
19
|
+
const info = status()
|
|
20
|
+
process.exit(info ? 0 : 1)
|
|
21
|
+
} else if (command === 'clear') {
|
|
22
|
+
clear()
|
|
23
|
+
process.exit(0)
|
|
24
|
+
} else if (command === 'help' || command === undefined) {
|
|
25
|
+
console.log(`cft - Chrome for Testing 安装器
|
|
26
|
+
|
|
27
|
+
用法:
|
|
28
|
+
cft install 下载并安装 Chrome for Testing(已装同版本则跳过)
|
|
29
|
+
cft status 显示安装状态
|
|
30
|
+
cft clear 删除安装产物(~/.config/cft/)
|
|
31
|
+
cft help 显示本帮助
|
|
32
|
+
|
|
33
|
+
安装目录: ~/.config/cft/`)
|
|
34
|
+
} else {
|
|
35
|
+
console.error(`未知命令: ${command}`)
|
|
36
|
+
console.error('运行 cft help 查看用法')
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wwkit/cft",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"author": "bluesliu <langcai163@163.com>",
|
|
5
|
+
"description": "Chrome for Testing (CFT) installer — download, extract, chmod, dep-check",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"cft": "./bin/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"jest": {
|
|
18
|
+
"transform": {},
|
|
19
|
+
"testMatch": [
|
|
20
|
+
"**/tests/unit/**/*.test.js"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"chrome",
|
|
25
|
+
"chromedriver",
|
|
26
|
+
"chrome-for-testing",
|
|
27
|
+
"cft",
|
|
28
|
+
"selenium",
|
|
29
|
+
"webdriver"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"registry": "https://registry.npmjs.org/",
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"extract-zip": "^2.0.1",
|
|
38
|
+
"@wwkit/shared": "1.0.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"jest": "^29.7.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"start": "node bin/index.js",
|
|
45
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --no-cache tests/unit",
|
|
46
|
+
"test:all": "node --experimental-vm-modules node_modules/jest/bin/jest.js --no-cache tests/unit",
|
|
47
|
+
"release": "pnpm version patch && pnpm publish"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { createConfigLoader, getXdgConfigDir } from '@wwkit/shared'
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
|
|
7
|
+
const INSTALL_DIR = getXdgConfigDir('cft')
|
|
8
|
+
|
|
9
|
+
const _loader = createConfigLoader({
|
|
10
|
+
builtinConfigPath: path.join(__dirname, 'config.json5'),
|
|
11
|
+
userConfigDir: INSTALL_DIR,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const INSTALL_LOCATION = INSTALL_DIR
|
|
15
|
+
export const USER_CONFIG_FILE = path.join(INSTALL_DIR, 'config.json5')
|
|
16
|
+
|
|
17
|
+
export function getConfig() {
|
|
18
|
+
return _loader.getConfig()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function copyBuiltinConfig() {
|
|
22
|
+
return _loader.copyBuiltinConfig()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getVersion() {
|
|
26
|
+
return getConfig().version
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getRegistry() {
|
|
30
|
+
return getConfig().registry
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getLocation() {
|
|
34
|
+
return INSTALL_LOCATION
|
|
35
|
+
}
|
package/src/config.json5
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
const APT_MAP = {
|
|
6
|
+
'libnss3.so': 'libnss3',
|
|
7
|
+
'libatk-1.0.so.0': 'libatk1.0-0t64',
|
|
8
|
+
'libatk-bridge-2.0.so.0': 'libatk-bridge2.0-0t64',
|
|
9
|
+
'libcups.so.2': 'libcups2t64',
|
|
10
|
+
'libdrm.so.2': 'libdrm2',
|
|
11
|
+
'libgbm.so.1': 'libgbm1',
|
|
12
|
+
'libxkbcommon.so.0': 'libxkbcommon0',
|
|
13
|
+
'libXcomposite.so.1': 'libxcomposite1',
|
|
14
|
+
'libXdamage.so.1': 'libxdamage1',
|
|
15
|
+
'libXfixes.so.3': 'libxfixes3',
|
|
16
|
+
'libXrandr.so.2': 'libxrandr2',
|
|
17
|
+
'libasound.so.2': 'libasound2t64',
|
|
18
|
+
'libpango-1.0.so.0': 'libpango-1.0-0',
|
|
19
|
+
'libcairo.so.2': 'libcairo2',
|
|
20
|
+
'libpangoft2-1.0.so.0': 'libpango1.0-0',
|
|
21
|
+
'libharfbuzz.so.0': 'libharfbuzz0b',
|
|
22
|
+
'libxss.so.1': 'libxss1',
|
|
23
|
+
'libatspi.so.0': 'libatspi2.0-0t64',
|
|
24
|
+
'libxcb.so.1': 'libxcb1',
|
|
25
|
+
'libx11.so.6': 'libx11-6',
|
|
26
|
+
'libxext.so.6': 'libxext6',
|
|
27
|
+
'libnssutil3.so': 'libnss3',
|
|
28
|
+
'libsmime3.so': 'libnss3',
|
|
29
|
+
'libnspr4.so': 'libnspr4',
|
|
30
|
+
'libdbus-1.so.3': 'libdbus-1-3',
|
|
31
|
+
'libexpat.so.1': 'libexpat1',
|
|
32
|
+
'libxrender.so.1': 'libxrender1',
|
|
33
|
+
'libglib-2.0.so.0': 'libglib2.0-0',
|
|
34
|
+
'libgobject-2.0.so.0': 'libglib2.0-0',
|
|
35
|
+
'libpango-1.0-0.so.0': 'libpango-1.0-0',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DNF_MAP = {
|
|
39
|
+
'libnss3.so': 'nss',
|
|
40
|
+
'libatk-1.0.so.0': 'atk',
|
|
41
|
+
'libatk-bridge-2.0.so.0': 'at-spi2-atk',
|
|
42
|
+
'libcups.so.2': 'libcups',
|
|
43
|
+
'libdrm.so.2': 'libdrm',
|
|
44
|
+
'libgbm.so.1': 'mesa-libgbm',
|
|
45
|
+
'libxkbcommon.so.0': 'libxkbcommon',
|
|
46
|
+
'libXcomposite.so.1': 'libXcomposite',
|
|
47
|
+
'libXdamage.so.1': 'libXdamage',
|
|
48
|
+
'libXfixes.so.3': 'libXfixes',
|
|
49
|
+
'libXrandr.so.2': 'libXrandr',
|
|
50
|
+
'libasound.so.2': 'alsa-lib',
|
|
51
|
+
'libpango-1.0.so.0': 'pango',
|
|
52
|
+
'libcairo.so.2': 'cairo',
|
|
53
|
+
'libpangoft2-1.0.so.0': 'pango',
|
|
54
|
+
'libharfbuzz.so.0': 'harfbuzz',
|
|
55
|
+
'libxss.so.1': 'libXScrnSaver',
|
|
56
|
+
'libatspi.so.0': 'at-spi2-core',
|
|
57
|
+
'libxcb.so.1': 'libxcb',
|
|
58
|
+
'libx11.so.6': 'libX11',
|
|
59
|
+
'libxext.so.6': 'libXext',
|
|
60
|
+
'libnssutil3.so': 'nss',
|
|
61
|
+
'libsmime3.so': 'nss',
|
|
62
|
+
'libnspr4.so': 'nspr',
|
|
63
|
+
'libdbus-1.so.3': 'dbus-libs',
|
|
64
|
+
'libexpat.so.1': 'expat',
|
|
65
|
+
'libxrender.so.1': 'libXrender',
|
|
66
|
+
'libglib-2.0.so.0': 'glib2',
|
|
67
|
+
'libgobject-2.0.so.0': 'glib2',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isSupported() {
|
|
71
|
+
return process.platform === 'linux'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function check(binaryPath) {
|
|
75
|
+
if (!isSupported()) return []
|
|
76
|
+
if (!binaryPath || !fs.existsSync(binaryPath)) return []
|
|
77
|
+
try {
|
|
78
|
+
execSync('which ldd', { stdio: 'pipe' })
|
|
79
|
+
} catch {
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
let result
|
|
83
|
+
try {
|
|
84
|
+
result = execSync(`ldd "${binaryPath}"`, { captureOutput: true, timeout: 20000 })
|
|
85
|
+
} catch {
|
|
86
|
+
return []
|
|
87
|
+
}
|
|
88
|
+
const stdout = result.stdout?.toString() || ''
|
|
89
|
+
const missing = []
|
|
90
|
+
for (const line of stdout.split('\n')) {
|
|
91
|
+
const m = line.match(/^\s*(\S+)\s+=>\s+not found/)
|
|
92
|
+
if (m) missing.push(m[1])
|
|
93
|
+
}
|
|
94
|
+
return missing
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function mapToPackages(libs, packageManager) {
|
|
98
|
+
const table = packageManager === 'apt' ? APT_MAP : DNF_MAP
|
|
99
|
+
const packages = []
|
|
100
|
+
const unknown = []
|
|
101
|
+
const seen = new Set()
|
|
102
|
+
for (const lib of libs) {
|
|
103
|
+
const pkg = table[lib]
|
|
104
|
+
if (pkg && !seen.has(pkg)) {
|
|
105
|
+
seen.add(pkg)
|
|
106
|
+
packages.push(pkg)
|
|
107
|
+
} else if (!pkg) {
|
|
108
|
+
unknown.push(lib)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { packages, unknown }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function install(packages, packageManager) {
|
|
115
|
+
if (!packages.length) return true
|
|
116
|
+
const cmd = packageManager === 'apt'
|
|
117
|
+
? ['apt-get', 'install', '-y', ...packages]
|
|
118
|
+
: ['dnf', 'install', '-y', ...packages]
|
|
119
|
+
try {
|
|
120
|
+
const result = execSync(['sudo', ...cmd].join(' '), { timeout: 600000 })
|
|
121
|
+
return true
|
|
122
|
+
} catch {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function detectPackageManager() {
|
|
128
|
+
try { execSync('which apt-get', { stdio: 'pipe' }); return 'apt' } catch {}
|
|
129
|
+
try { execSync('which dnf', { stdio: 'pipe' }); return 'dnf' } catch {}
|
|
130
|
+
return 'apt'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { detectPackageManager }
|
|
134
|
+
|
|
135
|
+
export function verify(binaryPath) {
|
|
136
|
+
if (!isSupported()) {
|
|
137
|
+
return { missing: [], packages: [], unknown: [], ok: true }
|
|
138
|
+
}
|
|
139
|
+
const packageManager = detectPackageManager()
|
|
140
|
+
const missing = check(binaryPath)
|
|
141
|
+
const result = { missing, packages: [], unknown: [], ok: !missing.length }
|
|
142
|
+
if (!missing.length) return result
|
|
143
|
+
|
|
144
|
+
const { packages, unknown } = mapToPackages(missing, packageManager)
|
|
145
|
+
result.packages = packages
|
|
146
|
+
result.unknown = unknown
|
|
147
|
+
|
|
148
|
+
if (packages.length) {
|
|
149
|
+
if (install(packages, packageManager)) {
|
|
150
|
+
const stillMissing = check(binaryPath)
|
|
151
|
+
result.ok = !stillMissing.length
|
|
152
|
+
} else {
|
|
153
|
+
result.ok = false
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
result.ok = false
|
|
157
|
+
}
|
|
158
|
+
return result
|
|
159
|
+
}
|
package/src/exefinder.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
function getPossibleNames(baseName) {
|
|
6
|
+
const names = new Set()
|
|
7
|
+
if (baseName === 'chrome') {
|
|
8
|
+
['chrome', 'google chrome', 'google chrome for testing'].forEach(n => {
|
|
9
|
+
names.add(n.toLowerCase())
|
|
10
|
+
names.add(n.toLowerCase() + '.exe')
|
|
11
|
+
})
|
|
12
|
+
} else if (baseName === 'chromedriver') {
|
|
13
|
+
names.add('chromedriver')
|
|
14
|
+
names.add('chromedriver.exe')
|
|
15
|
+
} else {
|
|
16
|
+
names.add(baseName.toLowerCase())
|
|
17
|
+
names.add(baseName.toLowerCase() + '.exe')
|
|
18
|
+
}
|
|
19
|
+
return names
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findFallback(directory, baseName, isMacos) {
|
|
23
|
+
if (baseName === 'chrome' && isMacos) {
|
|
24
|
+
const paths = [
|
|
25
|
+
path.join(directory, 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'),
|
|
26
|
+
path.join(directory, 'Chrome.app', 'Contents', 'MacOS', 'Chrome'),
|
|
27
|
+
path.join(directory, 'Google Chrome.app', 'Contents', 'MacOS', 'Google Chrome'),
|
|
28
|
+
]
|
|
29
|
+
for (const p of paths) {
|
|
30
|
+
if (fs.existsSync(p)) return p
|
|
31
|
+
}
|
|
32
|
+
} else if (baseName === 'chromedriver') {
|
|
33
|
+
const paths = [
|
|
34
|
+
path.join(directory, 'chromedriver'),
|
|
35
|
+
path.join(directory, 'chromedriver.exe'),
|
|
36
|
+
]
|
|
37
|
+
for (const p of paths) {
|
|
38
|
+
if (fs.existsSync(p)) return p
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setAppPermissions(appBundlePath) {
|
|
45
|
+
if (!fs.existsSync(appBundlePath)) return
|
|
46
|
+
for (const entry of fs.readdirSync(appBundlePath, { withFileTypes: true })) {
|
|
47
|
+
const fullPath = path.join(appBundlePath, entry.name)
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
setAppPermissions(fullPath)
|
|
50
|
+
} else {
|
|
51
|
+
try { fs.chmodSync(fullPath, 0o755) } catch {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensureExecutable(filePath, isMacos = false, baseName = '') {
|
|
57
|
+
if (process.platform === 'win32') return filePath
|
|
58
|
+
try {
|
|
59
|
+
fs.accessSync(filePath, fs.constants.X_OK)
|
|
60
|
+
return filePath
|
|
61
|
+
} catch {}
|
|
62
|
+
try {
|
|
63
|
+
fs.chmodSync(filePath, 0o755)
|
|
64
|
+
if (isMacos && baseName === 'chrome') {
|
|
65
|
+
const parts = filePath.split('/Contents/MacOS/')
|
|
66
|
+
if (parts.length > 1 && parts[0].endsWith('.app')) {
|
|
67
|
+
setAppPermissions(parts[0])
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
return filePath
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function find(directory, baseName) {
|
|
75
|
+
const isMacos = process.platform === 'darwin'
|
|
76
|
+
|
|
77
|
+
if (!fs.existsSync(directory)) {
|
|
78
|
+
throw new Error(`Directory not found: ${directory}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (fs.statSync(directory).isFile()) {
|
|
82
|
+
try {
|
|
83
|
+
fs.accessSync(directory, fs.constants.X_OK)
|
|
84
|
+
return ensureExecutable(directory)
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const possibleNames = getPossibleNames(baseName)
|
|
89
|
+
|
|
90
|
+
function walkDir(dir) {
|
|
91
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
92
|
+
const fullPath = path.join(dir, entry.name)
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
const result = walkDir(fullPath)
|
|
95
|
+
if (result) return result
|
|
96
|
+
} else if (possibleNames.has(entry.name.toLowerCase())) {
|
|
97
|
+
return fullPath
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const found = walkDir(directory)
|
|
104
|
+
if (found) return ensureExecutable(found, isMacos, baseName)
|
|
105
|
+
|
|
106
|
+
const fallback = findFallback(directory, baseName, isMacos)
|
|
107
|
+
if (fallback) return ensureExecutable(fallback, isMacos, baseName)
|
|
108
|
+
|
|
109
|
+
throw new Error(`Executable not found: ${baseName} in ${directory}`)
|
|
110
|
+
}
|
package/src/index.js
ADDED
package/src/installer.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
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, getLocation, copyBuiltinConfig } from './config.js'
|
|
12
|
+
|
|
13
|
+
function log(msg) {
|
|
14
|
+
console.log(` ${msg}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function downloadFile(url, destPath, redirects = 0) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
if (redirects > 5) {
|
|
20
|
+
reject(new Error(`Too many redirects: ${url}`))
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const protocol = url.startsWith('https:') ? https : http
|
|
24
|
+
const filename = path.basename(destPath)
|
|
25
|
+
log(`下载 ${filename} ...`)
|
|
26
|
+
|
|
27
|
+
protocol.get(url, (response) => {
|
|
28
|
+
if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308) {
|
|
29
|
+
const location = response.headers.location
|
|
30
|
+
response.resume()
|
|
31
|
+
if (!location) {
|
|
32
|
+
reject(new Error(`Redirect without Location header: ${url}`))
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
downloadFile(location, destPath, redirects + 1).then(resolve).catch(reject)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if (response.statusCode !== 200) {
|
|
39
|
+
reject(new Error(`HTTP ${response.statusCode}: ${url}`))
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
const total = parseInt(response.headers['content-length'] || '0', 10)
|
|
43
|
+
let downloaded = 0
|
|
44
|
+
let lastLog = 0
|
|
45
|
+
response.on('data', (chunk) => {
|
|
46
|
+
downloaded += chunk.length
|
|
47
|
+
if (total > 0) {
|
|
48
|
+
const pct = Math.floor(downloaded * 100 / total)
|
|
49
|
+
if (pct >= lastLog + 10 || downloaded === total) {
|
|
50
|
+
log(`[${filename}] ${pct}% (${Math.floor(downloaded / 1048576)}MB / ${Math.floor(total / 1048576)}MB)`)
|
|
51
|
+
lastLog = pct
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
const file = fs.createWriteStream(destPath)
|
|
56
|
+
response.pipe(file)
|
|
57
|
+
file.on('finish', () => {
|
|
58
|
+
file.close()
|
|
59
|
+
resolve()
|
|
60
|
+
})
|
|
61
|
+
}).on('error', reject)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function downloadExtract(url, saveDir, filename, extractDir) {
|
|
66
|
+
const filePath = path.join(saveDir, filename)
|
|
67
|
+
await downloadFile(url, filePath)
|
|
68
|
+
|
|
69
|
+
log(`解压到 ${path.basename(extractDir)}`)
|
|
70
|
+
if (fs.existsSync(extractDir)) {
|
|
71
|
+
fs.rmSync(extractDir, { recursive: true, force: true })
|
|
72
|
+
}
|
|
73
|
+
await extractZip(filePath, { dir: saveDir })
|
|
74
|
+
fs.unlinkSync(filePath)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function chmodExec(filePath) {
|
|
78
|
+
if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
79
|
+
try { fs.chmodSync(filePath, 0o755) } catch {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function chmodExecDir(directory) {
|
|
84
|
+
if (!directory || !fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) return
|
|
85
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
86
|
+
const fullPath = path.join(directory, entry.name)
|
|
87
|
+
if (entry.isDirectory()) {
|
|
88
|
+
chmodExecDir(fullPath)
|
|
89
|
+
} else {
|
|
90
|
+
try { fs.chmodSync(fullPath, 0o755) } catch {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function prepareDir(location) {
|
|
96
|
+
if (!fs.existsSync(location)) {
|
|
97
|
+
fs.mkdirSync(location, { recursive: true })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const entries = fs.readdirSync(location)
|
|
101
|
+
if (!entries.length) return
|
|
102
|
+
log(`清空安装目录: ${location}`)
|
|
103
|
+
for (const name of entries) {
|
|
104
|
+
if (name === 'config.json5') continue
|
|
105
|
+
const itemPath = path.join(location, name)
|
|
106
|
+
if (fs.statSync(itemPath).isFile()) {
|
|
107
|
+
fs.unlinkSync(itemPath)
|
|
108
|
+
} else {
|
|
109
|
+
fs.rmSync(itemPath, { recursive: true, force: true })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readInstalledInfo(location) {
|
|
115
|
+
const jsonPath = path.join(location, 'installed.json')
|
|
116
|
+
if (!fs.existsSync(jsonPath)) return null
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function checkInstalled(version, location, osType) {
|
|
125
|
+
const info = readInstalledInfo(location)
|
|
126
|
+
if (!info) return { installed: false, info: {} }
|
|
127
|
+
const chromeDir = path.join(location, `chrome-${osType}`)
|
|
128
|
+
const driverDir = path.join(location, `chromedriver-${osType}`)
|
|
129
|
+
if (info.version === version && info.os === osType &&
|
|
130
|
+
fs.existsSync(chromeDir) && fs.existsSync(driverDir)) {
|
|
131
|
+
return { installed: true, info }
|
|
132
|
+
}
|
|
133
|
+
return { installed: false, info: {} }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function saveInstallInfo(location, info) {
|
|
137
|
+
const jsonPath = path.join(location, 'installed.json')
|
|
138
|
+
fs.writeFileSync(jsonPath, JSON.stringify(info, null, 2), 'utf-8')
|
|
139
|
+
log(`安装信息已保存到 ${jsonPath}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkSystemDeps(chromeExe, driverExe) {
|
|
143
|
+
if (!depsSupported()) return
|
|
144
|
+
const packageManager = detectPackageManager()
|
|
145
|
+
for (const [label, exePath] of [['Chrome', chromeExe], ['ChromeDriver', driverExe]]) {
|
|
146
|
+
if (!exePath || !fs.existsSync(exePath)) continue
|
|
147
|
+
const missing = checkDeps(exePath)
|
|
148
|
+
if (!missing.length) continue
|
|
149
|
+
const { packages, unknown } = mapToPackages(missing, packageManager)
|
|
150
|
+
if (packages.length) {
|
|
151
|
+
log(`${label}: 自动安装系统依赖 ${packages.join(', ')}`)
|
|
152
|
+
installDeps(packages, packageManager)
|
|
153
|
+
}
|
|
154
|
+
if (unknown.length) {
|
|
155
|
+
log(`${label}: 无法自动映射的库 ${unknown.join(', ')},请手动安装`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function install() {
|
|
161
|
+
copyBuiltinConfig()
|
|
162
|
+
const version = getVersion()
|
|
163
|
+
const registry = getRegistry()
|
|
164
|
+
const location = getLocation()
|
|
165
|
+
const osType = getOs()
|
|
166
|
+
|
|
167
|
+
if (!VALID_OS_TYPES.includes(osType)) {
|
|
168
|
+
throw new Error(`Unsupported OS: ${osType}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { installed, info } = checkInstalled(version, location, osType)
|
|
172
|
+
if (installed) {
|
|
173
|
+
log(`CFT ${version} 已安装,跳过`)
|
|
174
|
+
return info
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
prepareDir(location)
|
|
178
|
+
|
|
179
|
+
const chromeUrl = `${registry}/${version}/${osType}/chrome-${osType}.zip`
|
|
180
|
+
const driverUrl = `${registry}/${version}/${osType}/chromedriver-${osType}.zip`
|
|
181
|
+
log(`从网络下载 CFT ${version} (${osType})`)
|
|
182
|
+
|
|
183
|
+
const chromeDir = path.join(location, `chrome-${osType}`)
|
|
184
|
+
const driverDir = path.join(location, `chromedriver-${osType}`)
|
|
185
|
+
|
|
186
|
+
await downloadExtract(chromeUrl, location, `chrome-${osType}.zip`, chromeDir)
|
|
187
|
+
await downloadExtract(driverUrl, location, `chromedriver-${osType}.zip`, driverDir)
|
|
188
|
+
|
|
189
|
+
const chromeExe = findExe(chromeDir, 'chrome')
|
|
190
|
+
const driverExe = findExe(driverDir, 'chromedriver')
|
|
191
|
+
|
|
192
|
+
chmodExecDir(chromeDir)
|
|
193
|
+
chmodExecDir(driverDir)
|
|
194
|
+
chmodExec(chromeExe)
|
|
195
|
+
chmodExec(driverExe)
|
|
196
|
+
|
|
197
|
+
checkSystemDeps(chromeExe, driverExe)
|
|
198
|
+
|
|
199
|
+
const installInfo = {
|
|
200
|
+
location,
|
|
201
|
+
version,
|
|
202
|
+
os: osType,
|
|
203
|
+
chrome_path: chromeExe,
|
|
204
|
+
driver_path: driverExe,
|
|
205
|
+
}
|
|
206
|
+
saveInstallInfo(location, installInfo)
|
|
207
|
+
log(`Chrome 路径: ${chromeExe}`)
|
|
208
|
+
log(`ChromeDriver 路径: ${driverExe}`)
|
|
209
|
+
return installInfo
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function status() {
|
|
213
|
+
const location = getLocation()
|
|
214
|
+
const info = readInstalledInfo(location)
|
|
215
|
+
if (!info) {
|
|
216
|
+
console.log('CFT 未安装,请运行: cft install')
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
const chromeOk = fs.existsSync(info.chrome_path || '')
|
|
220
|
+
const driverOk = fs.existsSync(info.driver_path || '')
|
|
221
|
+
console.log(`版本: ${info.version || 'unknown'} (os: ${info.os || 'unknown'})`)
|
|
222
|
+
console.log(`Chrome: ${info.chrome_path || ''} ${chromeOk ? 'OK' : '文件不存在'}`)
|
|
223
|
+
console.log(`ChromeDriver: ${info.driver_path || ''} ${driverOk ? 'OK' : '文件不存在'}`)
|
|
224
|
+
return info
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function clear() {
|
|
228
|
+
const location = getLocation()
|
|
229
|
+
if (!fs.existsSync(location)) {
|
|
230
|
+
console.log(`安装目录不存在: ${location}`)
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const info = readInstalledInfo(location)
|
|
235
|
+
const label = info ? `CFT ${info.version || ''} (${info.os || ''})` : 'CFT'
|
|
236
|
+
|
|
237
|
+
// only remove installation artifacts, keep config.json5
|
|
238
|
+
const artifacts = []
|
|
239
|
+
if (info?.os) {
|
|
240
|
+
artifacts.push(`chrome-${info.os}`, `chromedriver-${info.os}`)
|
|
241
|
+
}
|
|
242
|
+
artifacts.push('installed.json')
|
|
243
|
+
|
|
244
|
+
let removed = false
|
|
245
|
+
for (const name of artifacts) {
|
|
246
|
+
const p = path.join(location, name)
|
|
247
|
+
if (fs.existsSync(p)) {
|
|
248
|
+
fs.rmSync(p, { recursive: true, force: true })
|
|
249
|
+
removed = true
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (removed) {
|
|
254
|
+
console.log(` ✓ 已删除 ${label}`)
|
|
255
|
+
console.log(` ${location}`)
|
|
256
|
+
} else {
|
|
257
|
+
console.log(` 无安装产物可删除`)
|
|
258
|
+
}
|
|
259
|
+
return removed
|
|
260
|
+
}
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
|
|
4
|
+
const VALID_OS_TYPES = ['linux64', 'win64', 'mac-x64', 'mac-arm64']
|
|
5
|
+
|
|
6
|
+
export function getOs() {
|
|
7
|
+
const platform = process.platform
|
|
8
|
+
const arch = process.arch
|
|
9
|
+
if (platform === 'win32') return 'win64'
|
|
10
|
+
if (platform === 'darwin') return arch === 'arm64' ? 'mac-arm64' : 'mac-x64'
|
|
11
|
+
if (platform === 'linux') return 'linux64'
|
|
12
|
+
throw new Error(`Unsupported platform: ${platform}`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isWindows() {
|
|
16
|
+
return process.platform === 'win32'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isMacos() {
|
|
20
|
+
return process.platform === 'darwin'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isLinux() {
|
|
24
|
+
return process.platform === 'linux'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { VALID_OS_TYPES }
|