@mzzsfy/dsh-maintain 0.5.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/src/core.mjs ADDED
@@ -0,0 +1,215 @@
1
+ // dsh-maintain 纯逻辑层:零宿主依赖,host 半区 import,单测直接覆盖。
2
+ // 版本比较内嵌 semver@7 的解析与比较语义(部署位置为 pnpm 布局,npm 依赖不可解析,
3
+ // 手写易错,故忠实照抄库规则:主次修订数值序,prerelease 标识符数值/字母双规则)。
4
+
5
+ import { posix, win32 } from 'node:path'
6
+ import { readFile } from 'node:fs/promises'
7
+ import { createRequire } from 'node:module'
8
+
9
+ export const VERDICT_OUTDATED = 'outdated'
10
+ export const VERDICT_UP_TO_DATE = 'up-to-date'
11
+ export const VERDICT_UNKNOWN = 'unknown'
12
+
13
+ export const TARGET_PACKAGE = '@deepseek-ai/dsh'
14
+ export const TAG_PLACEHOLDER = '{tag}'
15
+ export const DIST_TAGS_PATH = '/-/package/' + encodeURIComponent(TARGET_PACKAGE) + '/dist-tags'
16
+
17
+ const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
18
+
19
+ const NUMERIC_PATTERN = /^\d+$/
20
+
21
+ // 解析失败一律 null,判定层据此给 unknown,不抛错。
22
+ export function parseSemver(version) {
23
+ const text = typeof version === 'string' ? version.trim() : ''
24
+ const match = SEMVER_PATTERN.exec(text)
25
+ if (!match) return null
26
+ return {
27
+ major: Number(match[1]),
28
+ minor: Number(match[2]),
29
+ patch: Number(match[3]),
30
+ prerelease: match[4] ? match[4].split('.').map((id) => (NUMERIC_PATTERN.test(id) ? Number(id) : id)) : [],
31
+ build: match[5] || null,
32
+ }
33
+ }
34
+
35
+ // semver 规则:数字标识符按数值,字母按 ASCII,数字恒小于字母;前缀全等时短者小;数字与字母比较时数字小。
36
+ function compareIdentifiers(a, b) {
37
+ const aNumeric = NUMERIC_PATTERN.test(a)
38
+ const bNumeric = NUMERIC_PATTERN.test(b)
39
+ if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b))
40
+ if (aNumeric) return -1
41
+ if (bNumeric) return 1
42
+ return a < b ? -1 : a > b ? 1 : 0
43
+ }
44
+
45
+ function comparePrerelease(a, b) {
46
+ if (a.length === 0 && b.length === 0) return 0
47
+ if (a.length === 0) return 1
48
+ if (b.length === 0) return -1
49
+ const length = Math.min(a.length, b.length)
50
+ for (let index = 0; index < length; index++) {
51
+ const order = compareIdentifiers(a[index], b[index])
52
+ if (order !== 0) return order
53
+ }
54
+ return Math.sign(a.length - b.length)
55
+ }
56
+
57
+ // 任一版本非法返回 NaN 表示不可比较;gt 语义下按 false 处理。
58
+ export function compareSemver(a, b) {
59
+ const left = parseSemver(a)
60
+ const right = parseSemver(b)
61
+ if (!left || !right) return NaN
62
+ const main = Math.sign(left.major - right.major) || Math.sign(left.minor - right.minor) || Math.sign(left.patch - right.patch)
63
+ return main !== 0 ? main : comparePrerelease(left.prerelease, right.prerelease)
64
+ }
65
+
66
+ export function gtSemver(a, b) {
67
+ return compareSemver(a, b) === 1
68
+ }
69
+
70
+ // 判定当前版本相对追踪通道是否落后;信息不足一律 unknown 并给 reason,不抛错。
71
+ export function judgeVersion({ currentVersion, tags, channel }) {
72
+ if (!currentVersion || !tags || typeof tags !== 'object') {
73
+ return { channelLatest: null, verdict: VERDICT_UNKNOWN, reason: '版本信息尚未就绪' }
74
+ }
75
+ if (!Object.prototype.hasOwnProperty.call(tags, channel) || typeof tags[channel] !== 'string') {
76
+ return { channelLatest: null, verdict: VERDICT_UNKNOWN, reason: '通道 ' + channel + ' 不在 dist-tags 中' }
77
+ }
78
+ const channelLatest = tags[channel]
79
+ if (!parseSemver(currentVersion)) {
80
+ return { channelLatest, verdict: VERDICT_UNKNOWN, reason: '当前版本不是合法 semver: ' + currentVersion }
81
+ }
82
+ if (!parseSemver(channelLatest)) {
83
+ return { channelLatest, verdict: VERDICT_UNKNOWN, reason: '通道版本不是合法 semver: ' + channelLatest }
84
+ }
85
+ const verdict = gtSemver(channelLatest, currentVersion) ? VERDICT_OUTDATED : VERDICT_UP_TO_DATE
86
+ return { channelLatest, verdict, reason: null }
87
+ }
88
+
89
+ // 模板占位符执行时替换;模板允许不含占位符(用户整体自改命令),空模板拒绝。
90
+ // tag 经白名单校验:tag 名来自远端 registry 数据,拼入 shell 命令前单点拦截
91
+ // shell 元字符,封死"远端数据回流成命令"通路。
92
+ // 形态对齐 npm dist-tag 规则:仅 ASCII 字母数字-._,首尾为字母数字,长度上限 214。
93
+ const CHANNEL_NAME_PATTERN = /^[0-9A-Za-z][0-9A-Za-z-_.]*[0-9A-Za-z]$|^[0-9A-Za-z]$/
94
+ const CHANNEL_NAME_MAX_LENGTH = 214
95
+
96
+ export function isValidChannelName(channel) {
97
+ return typeof channel === 'string'
98
+ && channel.length > 0
99
+ && channel.length <= CHANNEL_NAME_MAX_LENGTH
100
+ && CHANNEL_NAME_PATTERN.test(channel)
101
+ }
102
+
103
+ export function buildUpgradeCommand({ template, tag }) {
104
+ const text = typeof template === 'string' ? template.trim() : ''
105
+ if (text.length === 0) throw new Error('升级命令模板为空,拒绝执行')
106
+ if (!isValidChannelName(tag)) throw new Error('通道名含非法字符,拒绝执行: ' + tag)
107
+ return text.split(TAG_PLACEHOLDER).join(tag)
108
+ }
109
+
110
+ // 重启后是否整页刷新:prev/next 为 {lost,pid,bootAt} 快照。
111
+ // lost=经历失联后恢复(强信号);bootAt=宿主进程启动时刻,变化即重启——容器内 pid 恒 1
112
+ // 且停机时长小于轮询间隔(零失联)时这是唯一可靠信号;pid 比对是 bootAt 缺失(旧宿主
113
+ // 未上报)时的退化路径。判定必须收敛在本函数,core 与 client LOGIC 段镜像,parity 对拍。
114
+ export function shouldReloadAfterRestart(prev, next) {
115
+ if (prev.lost) return true
116
+ if (typeof prev.bootAt === 'number' && typeof next.bootAt === 'number') return prev.bootAt !== next.bootAt
117
+ return typeof prev.pid === 'number' && typeof next.pid === 'number' && prev.pid !== next.pid
118
+ }
119
+
120
+ // registry 基地址必须是 http(s) 地址(RFC 3986,scheme 大小写不敏感)且不带 query/hash:
121
+ // 带查询串的输入拼接 dist-tags API 路径时 search 会吞掉路径,检查恒败且错误难反推根因。
122
+ // 保存侧与请求侧共用同一判定。
123
+ export function isValidRegistryBase(base) {
124
+ if (typeof base !== 'string') return false
125
+ try {
126
+ const parsed = new URL(base.trim())
127
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.search === '' && parsed.hash === ''
128
+ } catch {
129
+ return false
130
+ }
131
+ }
132
+
133
+ // dist-tags 响应体上限:正常响应远小于此;流式累计读取,超限即断
134
+ const DIST_TAGS_MAX_BYTES = 64 * 1024
135
+
136
+ // 拉取 dist-tags 轻量端点;fetchImpl 注入便于单测,错误一律抛出由调用方决定保留上次结果。
137
+ // redirect 拒绝跟随:镜像 302 跳内网/他源属配置外行为,直接失败交调用方展示。
138
+ export async function fetchDistTags({ registryBase, fetchImpl = fetch, timeoutMs }) {
139
+ if (!isValidRegistryBase(registryBase)) throw new Error('registry 基地址无效: ' + registryBase)
140
+ if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) throw new Error('timeoutMs 必须为正数')
141
+ const base = registryBase.trim().replace(/\/+$/, '')
142
+ const response = await fetchImpl(base + DIST_TAGS_PATH, {
143
+ headers: { accept: 'application/json' },
144
+ redirect: 'error',
145
+ signal: AbortSignal.timeout(timeoutMs),
146
+ })
147
+ if (!response.ok) throw new Error('registry HTTP ' + response.status)
148
+ // 流式累计限量:恶意/异常源的超大响应体在传输中途即被断开,不整量入内存
149
+ const reader = response.body.getReader()
150
+ const chunks = []
151
+ let total = 0
152
+ try {
153
+ for (;;) {
154
+ const { done, value } = await reader.read()
155
+ if (done) break
156
+ total += value.byteLength
157
+ if (total > DIST_TAGS_MAX_BYTES) throw new Error('dist-tags 响应超过上限')
158
+ chunks.push(value)
159
+ }
160
+ } finally {
161
+ await reader.cancel().catch(() => {})
162
+ }
163
+ const merged = new Uint8Array(total)
164
+ let offset = 0
165
+ for (const chunk of chunks) {
166
+ merged.set(chunk, offset)
167
+ offset += chunk.byteLength
168
+ }
169
+ // json() 对 BOM 有容忍;text 路径显式剥除保持等价
170
+ const text = new TextDecoder().decode(merged).replace(/^\uFEFF/, '')
171
+ let body
172
+ try {
173
+ body = JSON.parse(text)
174
+ } catch {
175
+ throw new Error('dist-tags 响应不是合法 JSON')
176
+ }
177
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('dist-tags 响应格式无效')
178
+ const tags = {}
179
+ for (const entry of Object.entries(body)) {
180
+ if (typeof entry[1] === 'string') tags[entry[0]] = entry[1]
181
+ }
182
+ if (Object.keys(tags).length === 0) throw new Error('dist-tags 响应为空')
183
+ return tags
184
+ }
185
+
186
+ // npm 全局布局下宿主包清单位置的候选序列;win 与 posix 目录结构不同,按声明平台选实现,与宿主 OS 解耦。
187
+ export function hostPackageCandidates({ execPath, platform }) {
188
+ const pathImpl = platform === 'win32' ? win32 : posix
189
+ const nodeDir = pathImpl.dirname(execPath)
190
+ const globalDir = platform === 'win32'
191
+ ? pathImpl.join(nodeDir, 'node_modules')
192
+ : pathImpl.join(pathImpl.dirname(nodeDir), 'lib', 'node_modules')
193
+ return [pathImpl.join(globalDir, '@deepseek-ai', 'dsh', 'package.json')]
194
+ }
195
+
196
+ // 宿主实际安装版本:先按全局布局候选读文件,再退 createRequire 解析(插件可能随宿主树部署)。
197
+ // 全部失败返回 null,面板显示未知,不影响宿主。
198
+ export async function resolveHostVersion({ execPath, platform, readFileImpl = readFile, resolveImpl }) {
199
+ const candidates = hostPackageCandidates({ execPath, platform })
200
+ let requireResolved = null
201
+ try {
202
+ requireResolved = resolveImpl ? resolveImpl(TARGET_PACKAGE + '/package.json') : createRequire(import.meta.url)(TARGET_PACKAGE + '/package.json')
203
+ } catch {
204
+ requireResolved = null
205
+ }
206
+ for (const candidate of requireResolved ? candidates.concat(requireResolved) : candidates) {
207
+ try {
208
+ const parsed = JSON.parse(await readFileImpl(candidate, 'utf8'))
209
+ if (parsed && typeof parsed.version === 'string' && parsed.version.length > 0) return parsed.version
210
+ } catch {
211
+ // 换下一候选
212
+ }
213
+ }
214
+ return null
215
+ }