@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 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
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ version: "141.0.7390.54",
3
+ registry: "https://npmmirror.com/mirrors/chrome-for-testing",
4
+ }
@@ -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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ export { install, status, clear } from './installer.js'
2
+ export { getOs, isWindows, isMacos, isLinux, VALID_OS_TYPES } from './platform.js'
3
+ export { getConfig, getVersion, getRegistry, getLocation, copyBuiltinConfig, INSTALL_LOCATION, USER_CONFIG_FILE } from './config.js'
@@ -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
+ }
@@ -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 }