@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.
@@ -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.1",
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",