@whyshu/argent-erp 0.3.0 → 0.3.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/README.md +20 -2
- package/package.json +3 -2
- package/src/index.js +123 -17
- package/src/vendor/compat.mjs +332 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @whyshu/argent-erp(v0.3.
|
|
1
|
+
# @whyshu/argent-erp(v0.3.1)
|
|
2
2
|
|
|
3
3
|
**两件东西,一个货**:
|
|
4
4
|
|
|
@@ -126,7 +126,7 @@ ERP 网页里那个「办公室聊一聊」**保持原样**(服务端 SSE +
|
|
|
126
126
|
|
|
127
127
|
| 路径 | 作用 |
|
|
128
128
|
|---|---|
|
|
129
|
-
| `/api/argent-erp/health` | 存活 + 版本 + `erpTools`(ERP 工具数;0=缺 @whyshu/argent)+ `officePreset` + `clientPingAt`(bundle 加载)/ `viewPingAt`(面板打开/渲染)|
|
|
129
|
+
| `/api/argent-erp/health` | 存活 + 版本 + `dsh`(识别到的宿主版本)+ `supportedDshRange` + `erpTools`(ERP 工具数;0=缺 @whyshu/argent)+ `officePreset` + `clientPingAt`(bundle 加载)/ `viewPingAt`(面板打开/渲染)|
|
|
130
130
|
| `/api/argent-erp/whoami` | 账号 + 租户列表 + 默认租户 |
|
|
131
131
|
| `/api/argent-erp/overview?tenant=` | 办公室现场(KPI/在岗/执行机/待办 20/动态 10,脱敏) |
|
|
132
132
|
| `/api/argent-erp/timeline?tenant=&days=` | 时间线(`/sku-flows`,按天分组) |
|
|
@@ -182,6 +182,24 @@ node plugins/argent/test/office-preset-default.test.mjs # 机器执行器 pres
|
|
|
182
182
|
- 宿主侧改动**需要用户手动重启 dsh**(⛔ agent 不重启);客户端 bundle 改动只需浏览器硬刷新。
|
|
183
183
|
- 回滚:`plugin_manager remove_bundle @whyshu/argent-erp` 后重启(视图与办公室 preset 一起下线)。
|
|
184
184
|
|
|
185
|
+
## 7.1 dsh 版本门禁(双层,0.3.1 起)
|
|
186
|
+
|
|
187
|
+
- **第一层(宿主侧,首选)**:`package.json#engines.dsh = ">=0.2.0-0 <0.3.0-0"`。
|
|
188
|
+
dsh 0.2.x 在**导入插件代码之前**按 manifest 判兼容:不匹配的 bundle 被 skip、entry 被 disable,
|
|
189
|
+
只打一行原因、**不阻断 boot**(语义与 `@whyshu/argent` 同一套:下界 `-0` 让 rc 预发布算进来,
|
|
190
|
+
上界 `-0` 把 0.3.0 的所有预发布都挡在外面)。
|
|
191
|
+
- **第二层(插件侧兜底)**:`src/index.js` 的 `apply()` 顶部自查版本(`src/vendor/compat.mjs` 与
|
|
192
|
+
`plugins/argent/src/compat.mjs` 逐字节同源,由 `scripts/tests/argent-erp-compat-guard.test.mjs` 守着):
|
|
193
|
+
- 版本不支持 ⇒ 打一行 `[@whyshu/argent-erp] 插件已自我禁用(dsh x.y.z 未适配)` 后 **return**:
|
|
194
|
+
**零宿主调用、不抛**;dsh 照常启动,升级到适配版本后自动恢复;
|
|
195
|
+
- 支持版本 ⇒ 用 `ctx.inject(['webServer'], …)` 等宿主服务就绪再注册端点(**不声明 `inject`**:
|
|
196
|
+
否则在缺该服务的宿主上会永远 pending,连"自我禁用"日志都打不出来);
|
|
197
|
+
- 所有宿主调用(`ctx.inject` / `ctx.webServer.register` / `ctx.effect`)都经 `safeStep()`:
|
|
198
|
+
缺失或抛错 ⇒ no-op + 同一原因**只告警一次**;`apply` 整体 try/catch,**永不抛**。
|
|
199
|
+
- 自检:`/api/argent-erp/health` 里 `dsh` + `supportedDshRange`;不兼容时端点不存在(插件已自禁)。
|
|
200
|
+
- 注意:`package.json#engines.dsh` 在插件侧检查不可用(老宿主/手工 patch 挂载);
|
|
201
|
+
开发机可用 `ARGENT_DSH_VERSION=0.3.0-rc.1` 显式模拟未适配宿主。
|
|
202
|
+
|
|
185
203
|
## 8. 已知限制
|
|
186
204
|
|
|
187
205
|
- 只读:写操作(派单/开票执行/聊天写动作)仍走 ERP web;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyshu/argent-erp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Argent ERP 左侧栏入口 + 主视图面板(dsh sidebar.panellist/main)+ 六禾园办公室助理 preset + 办公室聊一聊",
|
|
6
6
|
"keywords": [
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"directory": "plugins/argent-erp"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
22
|
-
"node": ">=24.2.0"
|
|
22
|
+
"node": ">=24.2.0",
|
|
23
|
+
"dsh": ">=0.2.0-0 <0.3.0-0"
|
|
23
24
|
},
|
|
24
25
|
"main": "src/index.js",
|
|
25
26
|
"exports": {
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* argent-erp · 宿主侧插件(v0.
|
|
2
|
+
* argent-erp · 宿主侧插件(v0.3.1)
|
|
3
3
|
*
|
|
4
4
|
* 职责只有两件:
|
|
5
5
|
* 1. 注册本地只读端点 `/api/argent-erp/*`(带账号 token 去 whyshu.com,**token 不下发前端**);
|
|
6
|
-
* 2. 由 bundle 的客户端部分(dist/client.js
|
|
6
|
+
* 2. 由 bundle 的客户端部分(dist/client.js)在左侧栏注册「ERP」入口 + 主视图面板。
|
|
7
7
|
*
|
|
8
8
|
* 红线:只读;不重启宿主;不改任何核心配置项;ERP 不可达时**降级为错误卡片**,不阻塞 dsh。
|
|
9
|
+
*
|
|
10
|
+
* ## dsh 版本门禁(双层,2026-11-08)
|
|
11
|
+
* - 第一层:`package.json#engines.dsh = ">=0.2.0-0 <0.3.0-0"` —— dsh 0.2.x 在**导入插件代码之前**
|
|
12
|
+
* 就按 manifest 判兼容,不匹配则 skip/disable 并只打一行原因,不阻断 boot;
|
|
13
|
+
* - 第二层:本文件 `apply()` 顶部再自查一次(更老宿主 / 手工 patch 挂载 / 已授豁免时宿主不认 manifest)
|
|
14
|
+
* ⇒ 不支持就只打一行「插件已自我禁用」后 return,**零宿主 API 调用、不抛**。
|
|
15
|
+
*
|
|
16
|
+
* 所有宿主调用(`ctx.inject` / `ctx.webServer.register` / `ctx.effect`)都经 `safeStep()` 包裹:
|
|
17
|
+
* 缺失或抛错 ⇒ no-op + 同一原因只告警一次。**apply 永不抛出**(入口整体 try/catch)。
|
|
9
18
|
*/
|
|
10
19
|
import { readFileSync } from 'node:fs'
|
|
11
20
|
import { dirname, join } from 'node:path'
|
|
@@ -15,8 +24,14 @@ import {
|
|
|
15
24
|
whoami, overview, shipments, shipmentDetail, caseDetail, timeline, flowDetail, badge,
|
|
16
25
|
chatSessions, chatSession, ErpError,
|
|
17
26
|
} from './data.mjs'
|
|
27
|
+
// ⚠️ 用 src/vendor 的副本(不许相对 import 别的插件):插件是**可发布产物**,安装到 profile 后
|
|
28
|
+
// 跨包相对路径不存在。漂移由 scripts/tests/argent-erp-compat-guard.test.mjs 逐字节拦截。
|
|
29
|
+
import { SUPPORTED_DSH_RANGE, readDshVersion, evaluateCompatibility } from './vendor/compat.mjs'
|
|
18
30
|
|
|
19
|
-
|
|
31
|
+
const NAME = 'argent-erp'
|
|
32
|
+
const LOG_PREFIX = '[@whyshu/argent-erp]'
|
|
33
|
+
|
|
34
|
+
/** 每次读盘:ESM 模块缓存会让"重装后 version 还显示旧值",健康检查必须反映当前磁盘版本 */
|
|
20
35
|
function pkgVersion() {
|
|
21
36
|
try {
|
|
22
37
|
return JSON.parse(readFileSync(join(dirname(dirname(fileURLToPath(import.meta.url))), 'package.json'), 'utf8')).version || '0.0.0'
|
|
@@ -26,9 +41,42 @@ function pkgVersion() {
|
|
|
26
41
|
/** apply 时注入的宿主 ctx(只用于健康检查里数 ERP 工具,不缓存业务数据) */
|
|
27
42
|
let HOST_CTX = null
|
|
28
43
|
|
|
44
|
+
/** apply 成功后拿到的 webServer scope(诊断用;异常路径保持 null) */
|
|
45
|
+
let SERVER_SCOPE = null
|
|
46
|
+
|
|
47
|
+
/** 已告警过原因的键(同一原因只告警一次,避免宿主契约变化时刷屏) */
|
|
48
|
+
const WARNED = new Set()
|
|
49
|
+
|
|
50
|
+
/** 同一原因只告警一次;日志本身失败也绝不影响宿主。 */
|
|
51
|
+
function warnOnce(key, message, error) {
|
|
52
|
+
if (WARNED.has(key)) return
|
|
53
|
+
WARNED.add(key)
|
|
54
|
+
try {
|
|
55
|
+
const detail = error ? `:${String((error && error.message) || error).slice(0, 200)}` : ''
|
|
56
|
+
console.warn(`${LOG_PREFIX} ${message}${detail}`)
|
|
57
|
+
} catch { /* noop */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 把"取宿主服务 / 调宿主 API"包成 no-op:缺失或抛错 ⇒ 告警一次并返回 undefined。
|
|
62
|
+
* @template T
|
|
63
|
+
* @param {string} key - 告警去重键
|
|
64
|
+
* @param {string} label - 人话描述(进日志)
|
|
65
|
+
* @param {() => T} fn - 真正的宿主调用
|
|
66
|
+
* @returns {T|undefined}
|
|
67
|
+
*/
|
|
68
|
+
function safeStep(key, label, fn) {
|
|
69
|
+
try {
|
|
70
|
+
return fn()
|
|
71
|
+
} catch (error) {
|
|
72
|
+
warnOnce(key, `${label} 不可用/调用失败,已降级为 no-op`, error)
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
29
77
|
/**
|
|
30
78
|
* 当前可见的 ERP 只读工具数(= 0 说明没装 @whyshu/argent / MCP 没起;-1 = 读不到 tools 服务)
|
|
31
|
-
* ⚠️ 用 ctx.get('tools') 而不是 ctx.tools
|
|
79
|
+
* ⚠️ 用 ctx.get('tools') 而不是 ctx.tools:本插件没有 inject tools,直接取会抛。
|
|
32
80
|
*/
|
|
33
81
|
function erpToolCount() {
|
|
34
82
|
try {
|
|
@@ -39,17 +87,24 @@ function erpToolCount() {
|
|
|
39
87
|
} catch { return -1 }
|
|
40
88
|
}
|
|
41
89
|
|
|
42
|
-
export const name =
|
|
43
|
-
|
|
90
|
+
export const name = NAME
|
|
91
|
+
/**
|
|
92
|
+
* ⛔ 刻意**不声明** inject:版本闸必须在任何宿主上先跑一次(声明 webServer 会让本插件在
|
|
93
|
+
* 缺该服务的宿主上永远 pending、连"自我禁用"那行日志都打不出来)。webServer 改为
|
|
94
|
+
* apply 内用 `ctx.inject(['webServer'], …)` 等它就绪(Cordis 标准写法;缺失只告警一次)。
|
|
95
|
+
*/
|
|
96
|
+
export const inject = []
|
|
44
97
|
|
|
45
98
|
/** 客户端 bundle 是否真的被加载过(区分"bundle 没加载" vs "加载了但没显示") */
|
|
46
99
|
const CLIENT_PING = { at: null, ua: '' }
|
|
47
|
-
/**
|
|
100
|
+
/** 面板是否真的**渲染**过(只有打开 ERP 主视图才会打) */
|
|
48
101
|
const VIEW_PING = { at: null, ua: '' }
|
|
49
102
|
|
|
50
103
|
const json = (res, code, body) => {
|
|
51
|
-
|
|
52
|
-
|
|
104
|
+
try {
|
|
105
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
106
|
+
res.end(JSON.stringify(body))
|
|
107
|
+
} catch { /* 连接已断:吞掉,绝不让异常冒到宿主 */ }
|
|
53
108
|
}
|
|
54
109
|
|
|
55
110
|
async function handle(req, res) {
|
|
@@ -62,6 +117,8 @@ async function handle(req, res) {
|
|
|
62
117
|
const erpTools = erpToolCount()
|
|
63
118
|
return json(res, 200, {
|
|
64
119
|
ok: true, version: pkgVersion(),
|
|
120
|
+
dsh: safeStep('health-version', '健康检查读取 dsh 版本', () => readDshVersion()) || null,
|
|
121
|
+
supportedDshRange: SUPPORTED_DSH_RANGE,
|
|
65
122
|
erpTools, // 0 ⇒ 只装了本插件、没装 @whyshu/argent
|
|
66
123
|
officePreset: 'argent-office', // 本插件声明的办公室角色 preset id
|
|
67
124
|
clientPingAt: CLIENT_PING.at, clientUa: CLIENT_PING.ua,
|
|
@@ -107,17 +164,66 @@ async function handle(req, res) {
|
|
|
107
164
|
}
|
|
108
165
|
}
|
|
109
166
|
|
|
167
|
+
/** 版本闸(导出给用例):不传 version 时读当前宿主;任何异常都按"不支持"处理。 */
|
|
168
|
+
export function evaluateHostCompat({ version } = {}) {
|
|
169
|
+
try {
|
|
170
|
+
const v = version === undefined ? readDshVersion() : version
|
|
171
|
+
return evaluateCompatibility(v)
|
|
172
|
+
} catch {
|
|
173
|
+
return { supported: false, version: null, range: SUPPORTED_DSH_RANGE, reason: '版本评估异常(按未适配处理)' }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 打印「插件已自我禁用」并返回(零宿主调用路径)。 */
|
|
178
|
+
function selfDisable(compat, version) {
|
|
179
|
+
const shown = (compat && compat.version) || version || '未知版本'
|
|
180
|
+
const reason = (compat && compat.reason) || `不在支持范围 ${SUPPORTED_DSH_RANGE} 内`
|
|
181
|
+
try {
|
|
182
|
+
console.warn(`${LOG_PREFIX} 插件已自我禁用(dsh ${shown} 未适配):${reason}。`
|
|
183
|
+
+ `要求 ${SUPPORTED_DSH_RANGE};dsh 可继续启动,升级 @whyshu/argent-erp 到适配版本后自动恢复。`)
|
|
184
|
+
} catch { /* noop */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
110
187
|
export function apply(ctx) {
|
|
111
|
-
// ⛔
|
|
188
|
+
// ⛔ 入口整体 try/catch:apply 抛错会让宿主把插件的激活失败升级成启动问题。
|
|
112
189
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
190
|
+
// ── ① 版本闸(第一层在 manifest#engines.dsh;这里是兜底)────────────────
|
|
191
|
+
// 不支持:只打一行日志后 return —— **不碰 ctx 的任何属性/方法**(有用例断言调用次数为 0)。
|
|
192
|
+
const version = safeStep('detect-version', '读取 dsh 版本', () => readDshVersion()) ?? null
|
|
193
|
+
const compat = evaluateHostCompat({ version })
|
|
194
|
+
if (!compat.supported) {
|
|
195
|
+
selfDisable(compat, version)
|
|
116
196
|
return
|
|
117
197
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
198
|
+
|
|
199
|
+
// ── ② 等宿主 webServer 就绪再注册端点(未 inject:见上方 inject 注释)────
|
|
200
|
+
safeStep('webServer-wait', '等待宿主 webServer 服务', () => {
|
|
201
|
+
if (!ctx || typeof ctx.inject !== 'function') throw new Error('ctx.inject 不可用')
|
|
202
|
+
ctx.inject(['webServer'], (scope) => {
|
|
203
|
+
safeStep('webServer-register', '注册 /api/argent-erp/* 端点', () => {
|
|
204
|
+
if (!scope || !scope.webServer || typeof scope.webServer.register !== 'function') {
|
|
205
|
+
throw new Error('webServer.register 不可用')
|
|
206
|
+
}
|
|
207
|
+
const route = () => scope.webServer.register({ kind: 'prefix', path: '/api/argent-erp', handler: handle })
|
|
208
|
+
// ⛔ ctx.effect 缺失时退化为直接注册(都失败只告警一次)
|
|
209
|
+
if (typeof scope.effect === 'function') scope.effect(route, 'argent-erp: read-only endpoints')
|
|
210
|
+
else route()
|
|
211
|
+
SERVER_SCOPE = scope
|
|
212
|
+
HOST_CTX = scope
|
|
213
|
+
try { console.log(`${LOG_PREFIX} 已注册 /api/argent-erp/*(v${pkgVersion()},dsh ${compat.version},只读)`) } catch { /* noop */ }
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
} catch (error) {
|
|
218
|
+
warnOnce('apply', '插件初始化异常已兜住(dsh 可继续启动)', error)
|
|
122
219
|
}
|
|
123
220
|
}
|
|
221
|
+
|
|
222
|
+
/** 仅测试用:清空"只告警一次"状态 + 读内部诊断。⛔ 不属于插件对外契约。 */
|
|
223
|
+
export const __test__ = {
|
|
224
|
+
resetWarnings() { WARNED.clear() },
|
|
225
|
+
warnedKeys() { return [...WARNED] },
|
|
226
|
+
pkgVersion,
|
|
227
|
+
supportedRange: SUPPORTED_DSH_RANGE,
|
|
228
|
+
serverScope() { return SERVER_SCOPE },
|
|
229
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh 宿主兼容闸(0.2.13,2026-11-08)
|
|
3
|
+
*
|
|
4
|
+
* 用户诉求:**dsh 升级到未适配版本时,插件自己降级/关闭,但绝不能让 dsh web 启动失败或卡住。**
|
|
5
|
+
*
|
|
6
|
+
* 背景:插件用到的宿主能力(`ctx.webServer.register` / `ctx.plugin(dsh-mcp-client)` /
|
|
7
|
+
* `ctx.on('dispose')` / 客户端 `ctx.slots`)属于 dsh 内部契约,主/次版本一升就可能变。
|
|
8
|
+
* 2026-11-08 遇到过 boot 卡死(真因是 MCP 网关注册时 `office-guard` 重入;表现却是
|
|
9
|
+
* 「端口已 bound、HTTP 永远不响应」)⇒ 这类风险必须在插件侧自己兜住。
|
|
10
|
+
*
|
|
11
|
+
* 双层防线(本文件是第二层,第一层在 package.json#engines.dsh):
|
|
12
|
+
* ① **宿主侧(首选)**:`package.json#engines.dsh` 声明支持范围。dsh(0.2.x 起)在**导入插件代码之前**
|
|
13
|
+
* 就用 `evaluatePluginCompatibility()` 检查 manifest:不匹配的 bundle 被 skip、不匹配的 entry 被
|
|
14
|
+
* disable,只在 stderr 打一行原因,**不阻断 boot**(已核对 dsh 0.2.2-rc.1 的 dsh-app-boot 实现)。
|
|
15
|
+
* ⇒ 宿主升级到未适配版本时,插件根本不会被加载。
|
|
16
|
+
* ② **插件侧(本文件,兜底)**:宿主不认 manifest 门禁(更老版本 / 手工 patch 挂载 / 已授豁免)时,
|
|
17
|
+
* `apply()` 顶部自行评估版本:不支持 ⇒ 只打一条明确日志后 return,**不注册任何宿主 API**。
|
|
18
|
+
*
|
|
19
|
+
* 纯函数边界:本文件**不 import 任何宿主模块**(`@deepseek-ai/*`、`cordis`、`dsh-*`);
|
|
20
|
+
* 只用 node 内置模块(fs/module/path/url)做只读探测,且所有解析失败都返回 null、绝不抛。
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ 范围语义与 dsh 自己的门禁保持一致:dsh 用
|
|
23
|
+
* `semver.satisfies(runtimeVersion, range, { includePrerelease: true })`
|
|
24
|
+
* (见 `@deepseek-ai/dsh-app-boot` 的 `plugin-compatibility`)。dsh 日常发的是 rc 预发布
|
|
25
|
+
* (本机 0.2.2-rc.1、客户机 0.2.0-rc.2 / 0.2.1-rc.2),所以下界写成 `0.2.0-0` 才能把
|
|
26
|
+
* `0.2.0-rc.2` 算进来(严格 `>=0.2.0` 会把 0.2.0-rc.2 判为不支持,那是**错的**);
|
|
27
|
+
* 上界写成 `0.3.0-0` 则把 0.3.0 的所有预发布也挡在外面(次版本升级按未适配处理,保守优先)。
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
30
|
+
import { createRequire } from 'node:module'
|
|
31
|
+
import { join, resolve as resolvePath } from 'node:path'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
|
|
34
|
+
/** 支持范围(唯一真源;package.json#engines.dsh 必须与它逐字一致,有用例守着)。 */
|
|
35
|
+
export const SUPPORTED_DSH_RANGE = '>=0.2.0-0 <0.3.0-0'
|
|
36
|
+
/** 下界(含)。`-0` 是"该版本线最早的预发布"哨兵,不是笔误。 */
|
|
37
|
+
export const SUPPORTED_DSH_MIN = '0.2.0-0'
|
|
38
|
+
/** 上界(不含)。`-0` 让 0.3.0-rc.x 也算"高于上界" ⇒ 未适配。 */
|
|
39
|
+
export const SUPPORTED_DSH_MAX = '0.3.0-0'
|
|
40
|
+
|
|
41
|
+
/** 可显式覆盖版本探测的环境变量(排障/离线验证/宿主未暴露版本时用;顺序即优先级)。 */
|
|
42
|
+
export const DSH_VERSION_ENV_KEYS = ['ARGENT_DSH_VERSION', 'DSH_VERSION']
|
|
43
|
+
|
|
44
|
+
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
|
45
|
+
const NUMERIC_RE = /^\d+$/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 解析 dsh 版本号(semver;允许 `v` 前缀与 `+build` 元数据)。
|
|
49
|
+
* @param {unknown} value
|
|
50
|
+
* @returns {{raw:string, version:string, major:number, minor:number, patch:number, prerelease:string[]}|null}
|
|
51
|
+
* 解析不了返回 null(**绝不抛** —— 未知版本必须走"降级"而不是炸 boot)。
|
|
52
|
+
*/
|
|
53
|
+
export function parseDshVersion(value) {
|
|
54
|
+
if (typeof value !== 'string') return null
|
|
55
|
+
const raw = value.trim()
|
|
56
|
+
if (!raw) return null
|
|
57
|
+
const m = SEMVER_RE.exec(raw)
|
|
58
|
+
if (!m) return null
|
|
59
|
+
const major = Number(m[1])
|
|
60
|
+
const minor = Number(m[2])
|
|
61
|
+
const patch = Number(m[3])
|
|
62
|
+
if (![major, minor, patch].every(Number.isSafeInteger)) return null
|
|
63
|
+
const prerelease = m[4] ? m[4].split('.') : []
|
|
64
|
+
return {
|
|
65
|
+
raw,
|
|
66
|
+
version: `${major}.${minor}.${patch}${m[4] ? `-${m[4]}` : ''}`,
|
|
67
|
+
major,
|
|
68
|
+
minor,
|
|
69
|
+
patch,
|
|
70
|
+
prerelease,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** semver 预发布段比较(数字段 < 非数字段;数字按数值比;前缀短的小)。 */
|
|
75
|
+
function comparePrerelease(a, b) {
|
|
76
|
+
if (a.length === 0 && b.length === 0) return 0
|
|
77
|
+
if (a.length === 0) return 1 // 正式版 > 预发布
|
|
78
|
+
if (b.length === 0) return -1
|
|
79
|
+
const n = Math.max(a.length, b.length)
|
|
80
|
+
for (let i = 0; i < n; i += 1) {
|
|
81
|
+
if (i >= a.length) return -1
|
|
82
|
+
if (i >= b.length) return 1
|
|
83
|
+
const x = a[i]
|
|
84
|
+
const y = b[i]
|
|
85
|
+
const xn = NUMERIC_RE.test(x)
|
|
86
|
+
const yn = NUMERIC_RE.test(y)
|
|
87
|
+
if (xn && yn) {
|
|
88
|
+
const d = Number(x) - Number(y)
|
|
89
|
+
if (d !== 0) return d < 0 ? -1 : 1
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (xn) return -1
|
|
93
|
+
if (yn) return 1
|
|
94
|
+
if (x !== y) return x < y ? -1 : 1
|
|
95
|
+
}
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 两个已解析版本的 semver 优先级比较(build 元数据不参与)。 */
|
|
100
|
+
function compareParsed(a, b) {
|
|
101
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
102
|
+
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
|
|
103
|
+
}
|
|
104
|
+
return comparePrerelease(a.prerelease, b.prerelease)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* semver 优先级比较(导出给用例/排障用)。
|
|
109
|
+
* @returns {number|null} -1 / 0 / 1;任一侧无法解析返回 null(不抛)
|
|
110
|
+
*/
|
|
111
|
+
export function compareDshVersions(a, b) {
|
|
112
|
+
const x = parseDshVersion(a)
|
|
113
|
+
const y = parseDshVersion(b)
|
|
114
|
+
if (!x || !y) return null
|
|
115
|
+
return compareParsed(x, y)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const MIN_BOUND = parseDshVersion(SUPPORTED_DSH_MIN)
|
|
119
|
+
const MAX_BOUND = parseDshVersion(SUPPORTED_DSH_MAX)
|
|
120
|
+
|
|
121
|
+
/** 版本是否落在支持范围内(等价于 `evaluateCompatibility(version).supported`)。 */
|
|
122
|
+
export function isVersionSupported(version) {
|
|
123
|
+
const parsed = parseDshVersion(typeof version === 'string' ? version : '')
|
|
124
|
+
if (!parsed) return false
|
|
125
|
+
return compareParsed(parsed, MIN_BOUND) >= 0 && compareParsed(parsed, MAX_BOUND) < 0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 日志里的版本串截断(防御超长 env 值刷屏)。 */
|
|
129
|
+
const show = (v) => {
|
|
130
|
+
const s = String(v == null ? '' : v).trim()
|
|
131
|
+
return s.length > 40 ? `${s.slice(0, 39)}…` : s
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 评估宿主兼容性。**永不抛**:任何拿不准的情况都按 `supported:false`(降级)处理。
|
|
136
|
+
* @param {unknown} version dsh 版本号;null/''/无法解析 = 未知 = 不支持
|
|
137
|
+
* @returns {{supported:boolean, version:string|null, range:string, severity:'ok'|'warn', reason?:string}}
|
|
138
|
+
*/
|
|
139
|
+
export function evaluateCompatibility(version) {
|
|
140
|
+
const input = version == null ? '' : String(version).trim()
|
|
141
|
+
if (!input) {
|
|
142
|
+
return {
|
|
143
|
+
supported: false,
|
|
144
|
+
version: null,
|
|
145
|
+
range: SUPPORTED_DSH_RANGE,
|
|
146
|
+
severity: 'warn',
|
|
147
|
+
reason: '未能识别 dsh 版本(视为未适配,插件自动禁用)',
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const parsed = parseDshVersion(input)
|
|
151
|
+
if (!parsed) {
|
|
152
|
+
return {
|
|
153
|
+
supported: false,
|
|
154
|
+
version: input,
|
|
155
|
+
range: SUPPORTED_DSH_RANGE,
|
|
156
|
+
severity: 'warn',
|
|
157
|
+
reason: `dsh 版本号无法解析(${show(input)}),视为未适配,插件自动禁用`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (compareParsed(parsed, MIN_BOUND) < 0) {
|
|
161
|
+
return {
|
|
162
|
+
supported: false,
|
|
163
|
+
version: parsed.raw,
|
|
164
|
+
range: SUPPORTED_DSH_RANGE,
|
|
165
|
+
severity: 'warn',
|
|
166
|
+
reason: `dsh ${parsed.raw} 低于适配下限(要求 ${SUPPORTED_DSH_RANGE})`,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (compareParsed(parsed, MAX_BOUND) >= 0) {
|
|
170
|
+
return {
|
|
171
|
+
supported: false,
|
|
172
|
+
version: parsed.raw,
|
|
173
|
+
range: SUPPORTED_DSH_RANGE,
|
|
174
|
+
severity: 'warn',
|
|
175
|
+
reason: `dsh ${parsed.raw} 高于适配上限(要求 ${SUPPORTED_DSH_RANGE})`,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { supported: true, version: parsed.raw, range: SUPPORTED_DSH_RANGE, severity: 'ok' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 读 package.json 的 version;读不到/不是字符串返回 ''。 */
|
|
182
|
+
function readPackageVersion(packageJsonPath) {
|
|
183
|
+
try {
|
|
184
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
|
185
|
+
return typeof pkg?.version === 'string' ? pkg.version.trim() : ''
|
|
186
|
+
} catch {
|
|
187
|
+
return ''
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 安全 realpath(路径可能不存在/是虚拟路径)。 */
|
|
192
|
+
function safeRealpath(p) {
|
|
193
|
+
try { return realpathSync(p) } catch { return '' }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 直接从"路径里含 `node_modules/@deepseek-ai/dsh/`"的位置推出包目录并读 package.json。
|
|
198
|
+
*
|
|
199
|
+
* 为什么需要这条:`require.resolve('@deepseek-ai/dsh/package.json')` 依赖包的 `exports`
|
|
200
|
+
* 是否导出 `./package.json`。dsh 0.2.x 导出了,但**更老/更新的 dsh 布局不保证**——
|
|
201
|
+
* 而版本探测失败会退化成"未知 ⇒ 自动禁用",在支持版本上那是**假降级**。
|
|
202
|
+
* 直接看路径不依赖 exports:npx 缓存 / 全局安装 / 工作区 checkout 三种布局都能命中。
|
|
203
|
+
* @returns {string} 版本号;不是 dsh 路径或读不到返回 ''
|
|
204
|
+
*/
|
|
205
|
+
function versionFromNodeModulesPath(p) {
|
|
206
|
+
if (typeof p !== 'string' || !p) return ''
|
|
207
|
+
for (const marker of ['/node_modules/@deepseek-ai/dsh/', '/node_modules/@deepseek-ai/dsh-app-boot/']) {
|
|
208
|
+
const at = p.indexOf(marker)
|
|
209
|
+
if (at < 0) continue
|
|
210
|
+
const dir = p.slice(0, at + marker.length - 1) // 含 .../@deepseek-ai/dsh
|
|
211
|
+
const version = readPackageVersion(join(dir, 'package.json'))
|
|
212
|
+
if (version) return version
|
|
213
|
+
}
|
|
214
|
+
return ''
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 默认的"从已安装包解析 dsh 版本"实现。
|
|
219
|
+
*
|
|
220
|
+
* 候选锚点(按顺序,全部 try/catch):
|
|
221
|
+
* ① `process.argv` 里的脚本路径 —— `npx @deepseek-ai/dsh web` 时 argv[1] 是
|
|
222
|
+
* `.../_npx/<hash>/node_modules/.bin/dsh`,从它做 node 模块解析能找到
|
|
223
|
+
* `<npx根>/node_modules/@deepseek-ai/dsh/package.json`;
|
|
224
|
+
* ② 本插件模块自身的路径(profile 依赖树有时把宿主 peer 装在相邻位置);
|
|
225
|
+
* ③ 当前工作目录。
|
|
226
|
+
* 每个锚点两条路:`require.resolve('<包>/package.json')`(依赖 exports)
|
|
227
|
+
* + 路径直推(不依赖 exports,见 versionFromNodeModulesPath)。
|
|
228
|
+
* 另外兼容 `@deepseek-ai/dsh-app-boot`:dsh 自己的 `getDshRuntimeVersion()` 读的就是 app-boot 的
|
|
229
|
+
* package.json(两者 lockstep 发布),所以 dsh 主包解析不到时用它兜底。
|
|
230
|
+
*
|
|
231
|
+
* @returns {string} 版本号;解析不到返回 ''
|
|
232
|
+
*/
|
|
233
|
+
function resolveDshPackageVersion({ argv = process.argv, cwd = process.cwd() } = {}) {
|
|
234
|
+
const candidates = []
|
|
235
|
+
for (const a of Array.isArray(argv) ? argv : []) {
|
|
236
|
+
if (typeof a !== 'string' || !a.trim()) continue
|
|
237
|
+
if (a === process.execPath) continue
|
|
238
|
+
if (a.startsWith('-')) continue
|
|
239
|
+
// 只认"看起来像路径"的参数:绝对路径、相对路径、带分隔符;`web`/`--profile` 之类跳过。
|
|
240
|
+
if (!(a.startsWith('/') || a.startsWith('.') || a.includes('/') || a.includes('\\'))) continue
|
|
241
|
+
try {
|
|
242
|
+
candidates.push(a.startsWith('/') ? a : resolvePath(a))
|
|
243
|
+
} catch { /* 非法路径跳过 */ }
|
|
244
|
+
}
|
|
245
|
+
candidates.push(fileURLToPath(import.meta.url), join(cwd, 'package.json'))
|
|
246
|
+
|
|
247
|
+
for (const base of candidates) {
|
|
248
|
+
for (const name of ['@deepseek-ai/dsh/package.json', '@deepseek-ai/dsh-app-boot/package.json']) {
|
|
249
|
+
try {
|
|
250
|
+
const req = createRequire(base)
|
|
251
|
+
const version = readPackageVersion(req.resolve(name))
|
|
252
|
+
if (version) return version
|
|
253
|
+
} catch { /* 下一个锚点/包名 */ }
|
|
254
|
+
}
|
|
255
|
+
for (const p of [base, safeRealpath(base)]) {
|
|
256
|
+
const version = versionFromNodeModulesPath(p)
|
|
257
|
+
if (version) return version
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return ''
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 尽力识别当前 dsh 版本。**永不抛**,失败返回 null。
|
|
265
|
+
*
|
|
266
|
+
* 优先级:
|
|
267
|
+
* ① env `ARGENT_DSH_VERSION` / `DSH_VERSION`(显式覆盖;离线验证/宿主未暴露版本时用);
|
|
268
|
+
* ② env `npm_package_name === '@deepseek-ai/dsh'` 时的 `npm_package_version`;
|
|
269
|
+
* ③ argv 里字面出现的 `@deepseek-ai/dsh@x.y.z`(`npm exec` 形态会留下);
|
|
270
|
+
* ④ 包解析(见 resolveDshPackageVersion)。
|
|
271
|
+
*
|
|
272
|
+
* @param {{env?:Record<string,string|undefined>, argv?:string[], resolvePackageVersion?:Function}} [options]
|
|
273
|
+
* 仅供测试注入;生产调用不传参。
|
|
274
|
+
* @returns {string|null}
|
|
275
|
+
*/
|
|
276
|
+
export function readDshVersion(options = {}) {
|
|
277
|
+
try {
|
|
278
|
+
const env = (options.env && typeof options.env === 'object') ? options.env : process.env
|
|
279
|
+
const argv = Array.isArray(options.argv) ? options.argv : process.argv
|
|
280
|
+
|
|
281
|
+
for (const key of DSH_VERSION_ENV_KEYS) {
|
|
282
|
+
const v = env && typeof env[key] === 'string' ? env[key].trim() : ''
|
|
283
|
+
if (v) return v
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (env && env.npm_package_name === '@deepseek-ai/dsh' && typeof env.npm_package_version === 'string') {
|
|
287
|
+
const v = env.npm_package_version.trim()
|
|
288
|
+
if (v) return v
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const literal = /@deepseek-ai\/dsh@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/
|
|
292
|
+
for (const a of argv) {
|
|
293
|
+
if (typeof a !== 'string') continue
|
|
294
|
+
const m = literal.exec(a)
|
|
295
|
+
if (m) return m[1]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const resolvePackageVersion = typeof options.resolvePackageVersion === 'function'
|
|
299
|
+
? options.resolvePackageVersion
|
|
300
|
+
: resolveDshPackageVersion
|
|
301
|
+
const resolved = resolvePackageVersion({ argv })
|
|
302
|
+
if (typeof resolved === 'string' && resolved.trim()) return resolved.trim()
|
|
303
|
+
return null
|
|
304
|
+
} catch {
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 兼容闸的降级动作:**只打日志,不碰任何宿主 API**(不注册端点、不监听事件、不加载子插件)。
|
|
311
|
+
*
|
|
312
|
+
* 为什么这么克制:用户要的是"插件不能用就别拖累 dsh"。降级路径越简单,越不可能成为新的 boot 风险;
|
|
313
|
+
* 排障信息走 `[argent]` 日志即可(dsh web 的 stderr 用户能看到)。
|
|
314
|
+
*
|
|
315
|
+
* @param {object|null} ctx 宿主 context;当前刻意不使用(保留给将来"最小诊断注册";
|
|
316
|
+
* 降级路径零宿主 API 调用这一点有用例断言)
|
|
317
|
+
* @param {{version?:string|null, reason?:string, log?:Console}} [options]
|
|
318
|
+
* @returns {{supported:false, degraded:true, version:string|null, reason:string}}
|
|
319
|
+
*/
|
|
320
|
+
export function installCompatGuard(ctx, { version = null, reason = '', log = console } = {}) {
|
|
321
|
+
const shown = version ? show(version) : '未知版本'
|
|
322
|
+
const detail = String(reason || '').trim() || `不在支持范围 ${SUPPORTED_DSH_RANGE} 内`
|
|
323
|
+
const line = `[argent] 插件已自我禁用(dsh ${shown} 未适配):${detail}。`
|
|
324
|
+
+ 'dsh 可继续启动;升级 @whyshu/argent 到适配版本后会自动恢复。'
|
|
325
|
+
+ `(排障:可设 ${DSH_VERSION_ENV_KEYS[0]} 显式指定宿主版本)`
|
|
326
|
+
try {
|
|
327
|
+
if (log && typeof log.warn === 'function') log.warn(line)
|
|
328
|
+
else if (typeof log === 'function') log(line)
|
|
329
|
+
else console.warn(line)
|
|
330
|
+
} catch { /* 日志本身失败也绝不能影响宿主启动 */ }
|
|
331
|
+
return { supported: false, degraded: true, version: version || null, reason: detail }
|
|
332
|
+
}
|