@jonathanleelx/meddle 0.3.1 → 0.4.0-beta.2
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/bin/commands/update.js +187 -0
- package/bin/index.js +4 -0
- package/bin/lib/update-check.js +438 -0
- package/index.js +3 -0
- package/package.json +2 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meddle update - Check for updates and upgrade meddle
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* meddle update check and upgrade to the latest version
|
|
6
|
+
* meddle update --check only check, print the available version
|
|
7
|
+
* meddle update --version <x.y.z> install a specific version
|
|
8
|
+
* meddle update --auto on|off enable/disable auto-update (default off)
|
|
9
|
+
* meddle update status show install method / versions / auto-update
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const os = require('os')
|
|
13
|
+
const path = require('path')
|
|
14
|
+
const { spawnSync } = require('child_process')
|
|
15
|
+
const { resolveMeddleHome } = require('../lib/meddle-home')
|
|
16
|
+
const output = require('../lib/output')
|
|
17
|
+
const {
|
|
18
|
+
compareVersions,
|
|
19
|
+
getLatestVersionNpm,
|
|
20
|
+
getLatestVersionGithub,
|
|
21
|
+
getInstallMethod,
|
|
22
|
+
checkForUpdate,
|
|
23
|
+
downloadBinaryAsset,
|
|
24
|
+
getAutoUpdate,
|
|
25
|
+
setAutoUpdate,
|
|
26
|
+
} = require('../lib/update-check')
|
|
27
|
+
|
|
28
|
+
const PACKAGE_NAME = '@jonathanleelx/meddle'
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2)
|
|
31
|
+
const jsonFlag = args.includes('--json')
|
|
32
|
+
output.setJsonMode(jsonFlag)
|
|
33
|
+
|
|
34
|
+
const current = require(path.join(__dirname, '../../package.json')).version
|
|
35
|
+
const home = resolveMeddleHome()
|
|
36
|
+
const installMethod = getInstallMethod({ moduleDir: __dirname })
|
|
37
|
+
|
|
38
|
+
function isNpmInstall() {
|
|
39
|
+
return installMethod === 'npm'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function showHelp() {
|
|
43
|
+
output.plain(`
|
|
44
|
+
meddle update - 检查并升级 meddle
|
|
45
|
+
|
|
46
|
+
用法:
|
|
47
|
+
meddle update 检查并升级到最新版本
|
|
48
|
+
meddle update --check 仅检查,提示可用版本 (有新版本时退出码为 2)
|
|
49
|
+
meddle update --version <x.y.z> 安装指定版本
|
|
50
|
+
meddle update --auto on|off 启用/禁用自动更新 (默认关闭)
|
|
51
|
+
meddle update status 查看安装方式、当前版本和自动更新开关
|
|
52
|
+
meddle update --help, -h 显示帮助
|
|
53
|
+
`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function resolveLatest({ force = false, notify = true } = {}) {
|
|
57
|
+
const info = await checkForUpdate({
|
|
58
|
+
home,
|
|
59
|
+
installMethod,
|
|
60
|
+
current,
|
|
61
|
+
force,
|
|
62
|
+
})
|
|
63
|
+
if (notify) {
|
|
64
|
+
if (info.outdated) {
|
|
65
|
+
output.info(`发现新版本 ${info.latest} (当前 ${info.current})`)
|
|
66
|
+
} else {
|
|
67
|
+
output.success(`已是最新版本 (${info.current})`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return info
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function installVersion(version) {
|
|
74
|
+
if (!isNpmInstall()) {
|
|
75
|
+
const binDir = process.env.MEDDLE_BIN_DIR || path.join(home, 'bin')
|
|
76
|
+
const destFile = path.join(binDir, os.platform() === 'win32' ? 'meddle.exe' : 'meddle')
|
|
77
|
+
output.info(`下载 meddle ${version} (${os.platform()}/${os.arch()})...`)
|
|
78
|
+
try {
|
|
79
|
+
const result = await downloadBinaryAsset({
|
|
80
|
+
version,
|
|
81
|
+
destFile,
|
|
82
|
+
platform: os.platform(),
|
|
83
|
+
arch: os.arch(),
|
|
84
|
+
})
|
|
85
|
+
output.success(`已安装 meddle ${version} 到 ${result.installed}`)
|
|
86
|
+
if (result.backup) output.info(`旧版本已备份到 ${result.backup}`)
|
|
87
|
+
output.info('重启 meddle 后新版本生效')
|
|
88
|
+
} catch (err) {
|
|
89
|
+
output.error(`升级失败: ${err && err.message ? err.message : err}`)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
output.info(`正在通过 npm 升级 ${PACKAGE_NAME}@${version}...`)
|
|
96
|
+
const child = spawnSync('npm', ['install', '-g', `${PACKAGE_NAME}@${version}`], {
|
|
97
|
+
stdio: 'inherit',
|
|
98
|
+
// On Windows `npm` resolves to npm.cmd which Node cannot spawn directly
|
|
99
|
+
// unless run through a shell (Node >=18.20.2/20.12.2/22.3.0: EINVAL; older: ENOENT).
|
|
100
|
+
shell: os.platform() === 'win32',
|
|
101
|
+
})
|
|
102
|
+
if (child.error) {
|
|
103
|
+
output.error(`npm 升级失败: ${child.error.message}`)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
if (child.status !== 0) {
|
|
107
|
+
output.error(`npm 升级失败 (exit ${child.status})`)
|
|
108
|
+
process.exit(child.status || 1)
|
|
109
|
+
}
|
|
110
|
+
output.success(`已升级到 ${PACKAGE_NAME}@${version}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function run() {
|
|
114
|
+
const flag = args.find((a) => a === '--check' || a === 'status' || a === '--help' || a === '-h')
|
|
115
|
+
const versionFlagIndex = args.indexOf('--version')
|
|
116
|
+
const autoFlagIndex = args.indexOf('--auto')
|
|
117
|
+
|
|
118
|
+
if (flag === '--help' || flag === '-h') {
|
|
119
|
+
showHelp()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (flag === 'status') {
|
|
124
|
+
const info = await resolveLatest({ notify: false })
|
|
125
|
+
output.header('Meddle Update Status')
|
|
126
|
+
output.kv('Install method', installMethod)
|
|
127
|
+
output.kv('Current', info.current)
|
|
128
|
+
output.kv('Latest', info.latest)
|
|
129
|
+
output.kv('Auto-update', getAutoUpdate(home) ? 'on' : 'off')
|
|
130
|
+
output.info(info.outdated ? '有新版本可用,运行 meddle update 升级' : '已是最新版本')
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (autoFlagIndex >= 0) {
|
|
135
|
+
const value = args[autoFlagIndex + 1]
|
|
136
|
+
if (value !== 'on' && value !== 'off') {
|
|
137
|
+
output.error('--auto 需要 on 或 off')
|
|
138
|
+
process.exit(1)
|
|
139
|
+
}
|
|
140
|
+
setAutoUpdate(home, value === 'on')
|
|
141
|
+
if (value === 'on' && isNpmInstall()) {
|
|
142
|
+
output.success('自动更新已启用(npm 安装下仅启动时提示,仍需要运行 meddle update 升级)')
|
|
143
|
+
} else {
|
|
144
|
+
output.success(
|
|
145
|
+
`自动更新已${value === 'on' ? '启用' : '关闭'}${value === 'on' ? '(重启 meddle 后生效)' : ''}`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (versionFlagIndex >= 0) {
|
|
152
|
+
const version = args[versionFlagIndex + 1]
|
|
153
|
+
if (!version) {
|
|
154
|
+
output.error('--version 需要版本号')
|
|
155
|
+
process.exit(1)
|
|
156
|
+
}
|
|
157
|
+
const currentCompare = (() => {
|
|
158
|
+
try { return compareVersions(version, current) } catch (_) { return null }
|
|
159
|
+
})()
|
|
160
|
+
if (currentCompare === null) {
|
|
161
|
+
output.error(`无效版本号: ${version}`)
|
|
162
|
+
process.exit(1)
|
|
163
|
+
}
|
|
164
|
+
if (currentCompare <= 0) {
|
|
165
|
+
output.info(`当前已是 ${current},无需降级`)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
await installVersion(version)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (flag === '--check') {
|
|
173
|
+
const info = await resolveLatest()
|
|
174
|
+
if (info.outdated) process.exit(2)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// default: check + upgrade
|
|
179
|
+
const info = await resolveLatest()
|
|
180
|
+
if (!info.outdated) return
|
|
181
|
+
await installVersion(info.latest)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
run().catch((err) => {
|
|
185
|
+
output.error(`检查更新失败: ${err && err.message ? err.message : err}`)
|
|
186
|
+
process.exit(1)
|
|
187
|
+
})
|
package/bin/index.js
CHANGED
|
@@ -32,6 +32,7 @@ const commands = {
|
|
|
32
32
|
'url': './commands/url.js',
|
|
33
33
|
'status': './commands/status.js',
|
|
34
34
|
'version': './commands/version.js',
|
|
35
|
+
'update': './commands/update.js',
|
|
35
36
|
'session': './commands/session/index.js',
|
|
36
37
|
'mock': './commands/mock/index.js',
|
|
37
38
|
'route': './commands/route/index.js'
|
|
@@ -77,6 +78,9 @@ Meddle - 开发代理工具
|
|
|
77
78
|
meddle status 查看代理状态
|
|
78
79
|
meddle version 查看版本信息
|
|
79
80
|
meddle --version, -v 查看版本信息 (快捷别名)
|
|
81
|
+
meddle update 检查并升级到最新版本
|
|
82
|
+
meddle update --check 仅检查更新
|
|
83
|
+
meddle update --auto on|off 启用/禁用自动更新 (默认关闭)
|
|
80
84
|
|
|
81
85
|
Session 命令 (多实例隔离,预览功能):
|
|
82
86
|
meddle session create [--name <label>] [--port <port>]
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meddle update-check — version check + update download utilities.
|
|
3
|
+
*
|
|
4
|
+
* Pure JS so it runs unmodified in the npm package and in the deno-compiled
|
|
5
|
+
* binary (bin/ is bundled at compile time). Network is injected via
|
|
6
|
+
* `fetchImpl` so specs can point at local fixtures without touching the
|
|
7
|
+
* real npm registry / GitHub.
|
|
8
|
+
*
|
|
9
|
+
* Version sources:
|
|
10
|
+
* - npm installs → npm registry (https://registry.npmjs.org/@jonathanleeelx/meddle/latest)
|
|
11
|
+
* - binary installs → GitHub releases/latest redirect (same logic as scripts/install-binary.sh)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs')
|
|
15
|
+
const os = require('os')
|
|
16
|
+
const path = require('path')
|
|
17
|
+
const crypto = require('crypto')
|
|
18
|
+
const { resolveMeddleHome } = require('./meddle-home')
|
|
19
|
+
|
|
20
|
+
const PACKAGE_NAME = '@jonathanleelx/meddle'
|
|
21
|
+
const REPO = 'JonathanLee-LX/meddle'
|
|
22
|
+
const DEFAULT_NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
|
|
23
|
+
const DEFAULT_GITHUB_LATEST_URL = `https://github.com/${REPO}/releases/latest`
|
|
24
|
+
const DEFAULT_DOWNLOAD_BASE_URL = `https://github.com/${REPO}/releases/download`
|
|
25
|
+
const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 5000
|
|
27
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120 * 1000
|
|
28
|
+
|
|
29
|
+
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a semver-ish string. Returns { major, minor, patch, prerelease } or
|
|
33
|
+
* null when the input is not a valid version.
|
|
34
|
+
*/
|
|
35
|
+
function parseVersion(value) {
|
|
36
|
+
if (typeof value !== 'string') return null
|
|
37
|
+
const m = VERSION_RE.exec(value.trim())
|
|
38
|
+
if (!m) return null
|
|
39
|
+
return {
|
|
40
|
+
major: Number(m[1]),
|
|
41
|
+
minor: Number(m[2]),
|
|
42
|
+
patch: Number(m[3]),
|
|
43
|
+
prerelease: m[4] ? m[4].split('.') : [],
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {unknown} value
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function isValidVersion(value) {
|
|
52
|
+
return parseVersion(value) !== null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compare two semver strings. -1 when a < b, 0 when equal, 1 when a > b.
|
|
57
|
+
* Prereleases sort before their release (0.4.0-beta.1 < 0.4.0).
|
|
58
|
+
* @throws {Error} when either argument is not a valid version.
|
|
59
|
+
*/
|
|
60
|
+
function compareVersions(a, b) {
|
|
61
|
+
const va = parseVersion(a)
|
|
62
|
+
const vb = parseVersion(b)
|
|
63
|
+
if (!va) throw new Error(`Invalid version: ${a}`)
|
|
64
|
+
if (!vb) throw new Error(`Invalid version: ${b}`)
|
|
65
|
+
|
|
66
|
+
const partsA = [va.major, va.minor, va.patch]
|
|
67
|
+
const partsB = [vb.major, vb.minor, vb.patch]
|
|
68
|
+
for (let i = 0; i < 3; i++) {
|
|
69
|
+
if (partsA[i] !== partsB[i]) return partsA[i] < partsB[i] ? -1 : 1
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (va.prerelease.length === 0 && vb.prerelease.length === 0) return 0
|
|
73
|
+
if (va.prerelease.length === 0) return 1
|
|
74
|
+
if (vb.prerelease.length === 0) return -1
|
|
75
|
+
|
|
76
|
+
const len = Math.max(va.prerelease.length, vb.prerelease.length)
|
|
77
|
+
for (let i = 0; i < len; i++) {
|
|
78
|
+
const pa = va.prerelease[i]
|
|
79
|
+
const pb = vb.prerelease[i]
|
|
80
|
+
if (pa === undefined) return -1
|
|
81
|
+
if (pb === undefined) return 1
|
|
82
|
+
const na = /^\d+$/.test(pa)
|
|
83
|
+
const nb = /^\d+$/.test(pb)
|
|
84
|
+
if (na && nb) {
|
|
85
|
+
if (Number(pa) !== Number(pb)) return Number(pa) < Number(pb) ? -1 : 1
|
|
86
|
+
} else if (na !== nb) {
|
|
87
|
+
// numeric identifiers always sort below non-numeric ones
|
|
88
|
+
return na ? -1 : 1
|
|
89
|
+
} else if (pa !== pb) {
|
|
90
|
+
return pa < pb ? -1 : 1
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fetch the latest version from the npm registry.
|
|
98
|
+
* @param {{ fetchImpl?: Function, registryUrl?: string, timeoutMs?: number }} opts
|
|
99
|
+
* @returns {Promise<string>}
|
|
100
|
+
*/
|
|
101
|
+
async function getLatestVersionNpm({ fetchImpl, registryUrl, timeoutMs } = {}) {
|
|
102
|
+
const doFetch = fetchImpl || fetch
|
|
103
|
+
const url = registryUrl || process.env.MEDDLE_NPM_REGISTRY_URL || DEFAULT_NPM_REGISTRY_URL
|
|
104
|
+
const response = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS) })
|
|
105
|
+
if (!response.ok) throw new Error(`npm registry responded ${response.status}`)
|
|
106
|
+
const data = await response.json()
|
|
107
|
+
const version = data && typeof data.version === 'string' ? data.version : ''
|
|
108
|
+
if (!isValidVersion(version)) throw new Error(`npm registry returned no valid version`)
|
|
109
|
+
return version
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fetch the latest version from the GitHub releases/latest redirect.
|
|
114
|
+
* @param {{ fetchImpl?: Function, latestUrl?: string, timeoutMs?: number }} opts
|
|
115
|
+
* @returns {Promise<string>}
|
|
116
|
+
*/
|
|
117
|
+
async function getLatestVersionGithub({ fetchImpl, latestUrl, timeoutMs } = {}) {
|
|
118
|
+
const doFetch = fetchImpl || fetch
|
|
119
|
+
const url = latestUrl || process.env.MEDDLE_GITHUB_LATEST_URL || DEFAULT_GITHUB_LATEST_URL
|
|
120
|
+
const response = await doFetch(url, {
|
|
121
|
+
redirect: 'manual',
|
|
122
|
+
signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS),
|
|
123
|
+
})
|
|
124
|
+
if (response.status < 300 || response.status >= 400) {
|
|
125
|
+
throw new Error(`GitHub latest release did not redirect (${response.status})`)
|
|
126
|
+
}
|
|
127
|
+
const location = response.headers.get('location') || ''
|
|
128
|
+
const match = /\/tag\/(v?[\d][\w.-]*)$/.exec(location)
|
|
129
|
+
if (!match) throw new Error(`Could not resolve latest release tag`)
|
|
130
|
+
return match[1].replace(/^v/, '')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Detect how meddle was installed.
|
|
135
|
+
* npm → the module lives inside a node_modules directory.
|
|
136
|
+
* binary → deno-compiled binary (bin/ bundled inside the executable).
|
|
137
|
+
* @param {{ moduleDir: string }} opts
|
|
138
|
+
* @returns {'npm' | 'binary'}
|
|
139
|
+
*/
|
|
140
|
+
function getInstallMethod({ moduleDir }) {
|
|
141
|
+
return /[\\/]node_modules[\\/]/.test(moduleDir) ? 'npm' : 'binary'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} home meddle home directory
|
|
146
|
+
* @returns {string} path of the update check cache file
|
|
147
|
+
*/
|
|
148
|
+
function updateCachePath(home) {
|
|
149
|
+
return path.join(home, '.cache', 'update-check.json')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {{ home: string, fetchImpl?: Function, installMethod: string, current: string,
|
|
154
|
+
* now?: number, ttlMs?: number, force?: boolean,
|
|
155
|
+
* registryUrl?: string, latestUrl?: string, timeoutMs?: number }} opts
|
|
156
|
+
* @returns {Promise<{ current: string, latest: string, outdated: boolean, fromCache: boolean, checkedAt: number }>}
|
|
157
|
+
*/
|
|
158
|
+
async function checkForUpdate(opts) {
|
|
159
|
+
const {
|
|
160
|
+
home,
|
|
161
|
+
installMethod,
|
|
162
|
+
current,
|
|
163
|
+
now = Date.now(),
|
|
164
|
+
ttlMs = DEFAULT_CACHE_TTL_MS,
|
|
165
|
+
force = false,
|
|
166
|
+
fetchImpl,
|
|
167
|
+
registryUrl,
|
|
168
|
+
latestUrl,
|
|
169
|
+
timeoutMs,
|
|
170
|
+
} = opts
|
|
171
|
+
|
|
172
|
+
const cacheFile = updateCachePath(home)
|
|
173
|
+
let cached = null
|
|
174
|
+
try {
|
|
175
|
+
cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
|
|
176
|
+
} catch (_) {}
|
|
177
|
+
|
|
178
|
+
if (!force && cached && typeof cached.version === 'string' && typeof cached.checkedAt === 'number') {
|
|
179
|
+
if (now - cached.checkedAt < ttlMs) {
|
|
180
|
+
return {
|
|
181
|
+
current,
|
|
182
|
+
latest: cached.version,
|
|
183
|
+
outdated: compareVersions(cached.version, current) > 0,
|
|
184
|
+
fromCache: true,
|
|
185
|
+
checkedAt: cached.checkedAt,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const latest = installMethod === 'binary'
|
|
191
|
+
? await getLatestVersionGithub({ fetchImpl, latestUrl, timeoutMs })
|
|
192
|
+
: await getLatestVersionNpm({ fetchImpl, registryUrl, timeoutMs })
|
|
193
|
+
|
|
194
|
+
const checkedAt = now
|
|
195
|
+
try {
|
|
196
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true })
|
|
197
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ version: latest, checkedAt }), 'utf8')
|
|
198
|
+
} catch (_) {}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
current,
|
|
202
|
+
latest,
|
|
203
|
+
outdated: compareVersions(latest, current) > 0,
|
|
204
|
+
fromCache: false,
|
|
205
|
+
checkedAt,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Map platform/arch to the GitHub release asset name.
|
|
211
|
+
* @param {{ platform: string, arch: string }} opts
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
function getAssetName({ platform, arch }) {
|
|
215
|
+
let osTag
|
|
216
|
+
switch (platform) {
|
|
217
|
+
case 'linux': osTag = 'linux'; break
|
|
218
|
+
case 'darwin': osTag = 'darwin'; break
|
|
219
|
+
case 'win32': osTag = 'windows'; break
|
|
220
|
+
default: throw new Error(`unsupported platform: ${platform}`)
|
|
221
|
+
}
|
|
222
|
+
let archTag
|
|
223
|
+
switch (arch) {
|
|
224
|
+
case 'x64': case 'amd64': archTag = 'x64'; break
|
|
225
|
+
case 'arm64': case 'aarch64': archTag = 'arm64'; break
|
|
226
|
+
default: throw new Error(`unsupported arch: ${arch}`)
|
|
227
|
+
}
|
|
228
|
+
const ext = platform === 'win32' ? '.exe' : ''
|
|
229
|
+
return `meddle-${osTag}-${archTag}${ext}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function sha256Hex(buffer) {
|
|
233
|
+
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Download a release binary, verify its SHA256 sidecar and atomically replace
|
|
238
|
+
* the existing binary. The previous binary is kept as `<destFile>.bak`. When
|
|
239
|
+
* the final replace fails the backup is restored and a descriptive error is
|
|
240
|
+
* thrown (Windows commonly fails with EPERM on a running binary).
|
|
241
|
+
* @param {{ version: string, destFile: string, platform: string, arch: string,
|
|
242
|
+
* baseUrl?: string, fetchImpl?: Function, timeoutMs?: number,
|
|
243
|
+
* fsImpl?: object }} opts
|
|
244
|
+
* @returns {Promise<{ installed: string, backup: string, version: string }>}
|
|
245
|
+
*/
|
|
246
|
+
async function downloadBinaryAsset(opts) {
|
|
247
|
+
const { version, destFile, platform, arch } = opts
|
|
248
|
+
const baseUrl = opts.baseUrl || process.env.MEDDLE_UPDATE_BASE_URL || DEFAULT_DOWNLOAD_BASE_URL
|
|
249
|
+
const doFetch = opts.fetchImpl || fetch
|
|
250
|
+
const fsImpl = opts.fsImpl || fs
|
|
251
|
+
const payloadTimeout = opts.timeoutMs || DEFAULT_DOWNLOAD_TIMEOUT_MS
|
|
252
|
+
|
|
253
|
+
const asset = getAssetName({ platform, arch })
|
|
254
|
+
const assetUrl = `${baseUrl}/v${version}/${asset}`
|
|
255
|
+
const shaUrl = `${assetUrl}.sha256`
|
|
256
|
+
|
|
257
|
+
// The binary is large (100MB+); the checksum sidecar is tiny. Use a long
|
|
258
|
+
// timeout for the payload and the regular timeout for the metadata fetch.
|
|
259
|
+
const [binaryResponse, shaResponse] = await Promise.all([
|
|
260
|
+
doFetch(assetUrl, { signal: AbortSignal.timeout(payloadTimeout) }),
|
|
261
|
+
doFetch(shaUrl, { signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) }),
|
|
262
|
+
])
|
|
263
|
+
if (!binaryResponse.ok) throw new Error(`download failed (${binaryResponse.status})`)
|
|
264
|
+
if (!shaResponse.ok) throw new Error(`checksum sidecar missing (${shaResponse.status})`)
|
|
265
|
+
|
|
266
|
+
const payload = Buffer.from(await binaryResponse.arrayBuffer())
|
|
267
|
+
const shaText = await shaResponse.text()
|
|
268
|
+
const expected = shaText.trim().split(/\s+/)[0]
|
|
269
|
+
if (!/^[0-9a-f]{64}$/i.test(expected)) throw new Error(`invalid checksum sidecar`)
|
|
270
|
+
if (sha256Hex(payload) !== expected.toLowerCase()) {
|
|
271
|
+
throw new Error(`checksum mismatch for ${asset}`)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const dir = path.dirname(destFile)
|
|
275
|
+
fsImpl.mkdirSync(dir, { recursive: true })
|
|
276
|
+
|
|
277
|
+
const tmpFile = `${destFile}.tmp-${process.pid}`
|
|
278
|
+
fsImpl.writeFileSync(tmpFile, payload)
|
|
279
|
+
if (platform !== 'win32') fsImpl.chmodSync(tmpFile, 0o755)
|
|
280
|
+
|
|
281
|
+
const backup = `${destFile}.bak`
|
|
282
|
+
try {
|
|
283
|
+
if (fsImpl.existsSync(backup)) fsImpl.unlinkSync(backup)
|
|
284
|
+
if (fsImpl.existsSync(destFile)) fsImpl.renameSync(destFile, backup)
|
|
285
|
+
} catch (_) {}
|
|
286
|
+
try {
|
|
287
|
+
fsImpl.renameSync(tmpFile, destFile)
|
|
288
|
+
} catch (err) {
|
|
289
|
+
try {
|
|
290
|
+
if (fsImpl.existsSync(backup) && !fsImpl.existsSync(destFile)) {
|
|
291
|
+
fsImpl.renameSync(backup, destFile)
|
|
292
|
+
}
|
|
293
|
+
} catch (_) {}
|
|
294
|
+
try { fsImpl.unlinkSync(tmpFile) } catch (_) {}
|
|
295
|
+
const hint = process.platform === 'win32'
|
|
296
|
+
? 'Windows 下无法替换运行中的 meddle.exe,请先停止 meddle 再运行 meddle update'
|
|
297
|
+
: '替换二进制失败,请检查文件权限'
|
|
298
|
+
throw new Error(`${hint}: ${(err && err.code) || (err && err.message) || err}`)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { installed: destFile, backup: fsImpl.existsSync(backup) ? backup : null, version }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Read the auto-update setting. Precedence: MEDDLE_AUTO_UPDATE env var
|
|
306
|
+
* (when set) → settings.json `autoUpdate` (default false).
|
|
307
|
+
* @param {string} home
|
|
308
|
+
* @returns {boolean}
|
|
309
|
+
*/
|
|
310
|
+
function getAutoUpdate(home) {
|
|
311
|
+
const env = process.env.MEDDLE_AUTO_UPDATE
|
|
312
|
+
if (env !== undefined && env !== '') {
|
|
313
|
+
return env === '1' || env.toLowerCase() === 'true'
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const settings = JSON.parse(fs.readFileSync(path.join(home, 'settings.json'), 'utf8'))
|
|
317
|
+
return settings.autoUpdate === true
|
|
318
|
+
} catch (_) {
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Persist the auto-update setting into settings.json, preserving other keys.
|
|
325
|
+
* @param {string} home
|
|
326
|
+
* @param {boolean} value
|
|
327
|
+
*/
|
|
328
|
+
function setAutoUpdate(home, value) {
|
|
329
|
+
const settingsPath = path.join(home, 'settings.json')
|
|
330
|
+
let settings = {}
|
|
331
|
+
try {
|
|
332
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'))
|
|
333
|
+
} catch (_) {}
|
|
334
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) settings = {}
|
|
335
|
+
settings.autoUpdate = value === true
|
|
336
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
|
|
337
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function printUpdateNotice(info) {
|
|
341
|
+
const channel = process.env.MEDDLE_UPDATE_SILENT ? undefined : console
|
|
342
|
+
if (!channel) return
|
|
343
|
+
if (info.autoUpdated) {
|
|
344
|
+
channel.log(`\n[meddle] 已自动升级到 ${info.latest}(重启 meddle 后生效)`)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
channel.log(
|
|
348
|
+
`\n[meddle] 发现新版本 ${info.latest}(当前 ${info.current}),运行 "meddle update" 升级`
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Fire-and-forget startup check. Never blocks, never throws, never keeps the
|
|
354
|
+
* process alive. When auto-update is enabled (binary installs) the new binary
|
|
355
|
+
* is downloaded and verified on disk — but only when the running executable
|
|
356
|
+
* actually matches the install dir, otherwise the download would land where it
|
|
357
|
+
* can never take effect. npm installs only get a notice.
|
|
358
|
+
* @param {{ home?: string, installMethod?: string, current: string,
|
|
359
|
+
* binDir?: string, execPath?: string, delayMs?: number,
|
|
360
|
+
* fetchImpl?: Function, onOutdated?: Function }} opts
|
|
361
|
+
* @returns {void}
|
|
362
|
+
*/
|
|
363
|
+
function resolveRealPath(p) {
|
|
364
|
+
try { return fs.realpathSync(p) } catch (_) { return path.resolve(p) }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function isSameBinary(a, b) {
|
|
368
|
+
return resolveRealPath(a) === resolveRealPath(b)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function runAsyncUpdateCheck(opts) {
|
|
372
|
+
const home = opts.home || resolveMeddleHome()
|
|
373
|
+
const installMethod = opts.installMethod || getInstallMethod({ moduleDir: __dirname })
|
|
374
|
+
const binDir = opts.binDir || process.env.MEDDLE_BIN_DIR || path.join(home, 'bin')
|
|
375
|
+
const delayMs = opts.delayMs === undefined ? 1500 : opts.delayMs
|
|
376
|
+
const execPath = opts.execPath || process.execPath
|
|
377
|
+
const exeBase = path.basename(execPath).toLowerCase()
|
|
378
|
+
const isMeddleExecutable = exeBase === 'meddle' || exeBase === 'meddle.exe'
|
|
379
|
+
|
|
380
|
+
const timer = setTimeout(() => {
|
|
381
|
+
;(async () => {
|
|
382
|
+
const info = await checkForUpdate({
|
|
383
|
+
home,
|
|
384
|
+
installMethod,
|
|
385
|
+
current: opts.current,
|
|
386
|
+
fetchImpl: opts.fetchImpl,
|
|
387
|
+
})
|
|
388
|
+
if (!info.outdated) return
|
|
389
|
+
let finalInfo = info
|
|
390
|
+
if (getAutoUpdate(home) && installMethod === 'binary') {
|
|
391
|
+
const destFile = path.join(binDir, os.platform() === 'win32' ? 'meddle.exe' : 'meddle')
|
|
392
|
+
if (isMeddleExecutable && !isSameBinary(destFile, execPath)) {
|
|
393
|
+
finalInfo = {
|
|
394
|
+
...info,
|
|
395
|
+
skipAutoUpdate: '安装路径与当前运行路径不一致,跳过自动更新',
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
try {
|
|
399
|
+
await downloadBinaryAsset({
|
|
400
|
+
version: info.latest,
|
|
401
|
+
destFile,
|
|
402
|
+
platform: os.platform(),
|
|
403
|
+
arch: os.arch(),
|
|
404
|
+
baseUrl: opts.baseUrl,
|
|
405
|
+
fetchImpl: opts.fetchImpl,
|
|
406
|
+
})
|
|
407
|
+
finalInfo = { ...info, autoUpdated: true, destFile }
|
|
408
|
+
} catch (err) {
|
|
409
|
+
finalInfo = { ...info, autoUpdateFailed: err && err.message ? err.message : String(err) }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (opts.onOutdated) {
|
|
414
|
+
opts.onOutdated(finalInfo)
|
|
415
|
+
} else {
|
|
416
|
+
printUpdateNotice(finalInfo)
|
|
417
|
+
}
|
|
418
|
+
})().catch(() => {})
|
|
419
|
+
}, delayMs)
|
|
420
|
+
|
|
421
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
module.exports = {
|
|
425
|
+
compareVersions,
|
|
426
|
+
isValidVersion,
|
|
427
|
+
parseVersion,
|
|
428
|
+
getLatestVersionNpm,
|
|
429
|
+
getLatestVersionGithub,
|
|
430
|
+
getInstallMethod,
|
|
431
|
+
checkForUpdate,
|
|
432
|
+
getAssetName,
|
|
433
|
+
downloadBinaryAsset,
|
|
434
|
+
getAutoUpdate,
|
|
435
|
+
setAutoUpdate,
|
|
436
|
+
runAsyncUpdateCheck,
|
|
437
|
+
DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
438
|
+
}
|
package/index.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
pluginClientIdentity,
|
|
22
22
|
} = require('./dist/core/client-identity')
|
|
23
23
|
const { createApplicationIdentityResolver } = require('./dist/core/application-identity')
|
|
24
|
+
const { runAsyncUpdateCheck } = require('./bin/lib/update-check')
|
|
24
25
|
const {
|
|
25
26
|
authorizeProxyClient,
|
|
26
27
|
buildRemoteAccessConfig,
|
|
@@ -493,6 +494,8 @@ localWSServer.on('connection', (client, req) => {})
|
|
|
493
494
|
|
|
494
495
|
// ===== 启动 =====
|
|
495
496
|
;(async () => {
|
|
497
|
+
// 非阻塞异步版本检查:不会阻塞启动,失败静默,1.5s 后发起且 timer unref
|
|
498
|
+
runAsyncUpdateCheck({ current: require('./package.json').version })
|
|
496
499
|
await ensureRootCA()
|
|
497
500
|
await pluginBoot.bootstrapBuiltinPlugins()
|
|
498
501
|
const port = await getFreePort()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jonathanleelx/meddle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-beta.2",
|
|
4
4
|
"description": "A simple development proxy server with custom routing rules, mock responses, HTTPS interception, and a plugin system",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "index.js",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"test:identity": "pnpm run build && node scripts/application-identity-smoke.js",
|
|
21
21
|
"test:e2e": "npm run build:web && npm run build && node tests/e2e/interactions.e2e.cjs",
|
|
22
22
|
"test:e2e:rule-drag": "npm run build:web && npm run build && node tests/e2e/rule-drag.e2e.cjs",
|
|
23
|
+
"test:e2e:update": "node tests/e2e/update.e2e.cjs",
|
|
23
24
|
"test:watch": "vitest",
|
|
24
25
|
"test:refactor": "npm run build && node -c index.js && npm test",
|
|
25
26
|
"bench:logs": "node scripts/log-performance-benchmark.js",
|