@whyshu/argent-erp 0.2.3 → 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 +50 -21
- package/dist/client.js +54 -12
- package/package.json +4 -3
- package/src/index.js +123 -17
- package/src/vendor/compat.mjs +332 -0
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# @whyshu/argent-erp(v0.
|
|
1
|
+
# @whyshu/argent-erp(v0.3.1)
|
|
2
2
|
|
|
3
3
|
**两件东西,一个货**:
|
|
4
4
|
|
|
5
|
-
1.
|
|
6
|
-
办公室现场 / 待办 / 时间线 / 货件 /
|
|
5
|
+
1. **左侧栏 ERP 入口 + 主视图面板**:侧栏多一个「ERP」图标,点一下主视图(会话区)整页切到 ERP——
|
|
6
|
+
办公室现场 / 待办 / 时间线 / 货件 / 聊一聊;点会话行或面板里的「← 会话」即可回到对话;
|
|
7
7
|
2. **「六禾园办公室助理」preset**:让「和办公室聊一聊」变成一个**真正的 dsh 会话**
|
|
8
8
|
(原生 Chat,含轨迹/统计/中断),但**边界写死在 preset 里**——只聊办公室、只读 ERP、无本机能力。
|
|
9
9
|
|
|
@@ -18,31 +18,40 @@ src/data.mjs 数据层:whoami / overview / timeline / flow / ship
|
|
|
18
18
|
src/office-guard.js 办公室会话的**工具边界**(preset 内挂载:只放行 mcp__argent__erp_*)
|
|
19
19
|
src/vendor/ account-token.mjs + redact.mjs 的**副本**(插件可发布,不能相对 import core/lib)
|
|
20
20
|
presets/argent-office.patch.yml 「六禾园办公室助理」preset 声明行(dsh 0.2.x 行式 preset)
|
|
21
|
-
dist/client.js 客户端 bundle(手写维护):注册
|
|
21
|
+
dist/client.js 客户端 bundle(手写维护):注册 sidebar.panellist(id=erp,图标)+ main(key=erp,面板)
|
|
22
22
|
test/ 客户端 bundle + preset 边界冒烟测试(纯夹具,不启 dsh、不联网)
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
`package.json#dsh.bundle.patch` 是**数组**:`[cordis.patch.yml, presets/argent-office.patch.yml]`
|
|
26
26
|
⇒ 一次 `install_bundle` 同时落「ERP 视图」与「办公室助理 preset」。
|
|
27
27
|
|
|
28
|
-
## 2.
|
|
28
|
+
## 2. 它是「左侧栏入口 + 主视图面板」,不是会话 tab、也不是右侧栏
|
|
29
29
|
|
|
30
|
-
dsh 0.2.x
|
|
31
|
-
|
|
30
|
+
dsh 0.2.x 的两项原生扩展点(`dsh-client-ui-sidebar` / `dsh-client-ui-layout`):
|
|
31
|
+
|
|
32
|
+
- `sidebar.panellist`:左侧栏那排**全局面板图标**(插件、定时任务…);列表条目的 `id` 就是"对应哪个主视图";
|
|
33
|
+
- `main`(keyed):主视图区,`activePanelId` 命中哪个 key 就渲染哪个;`null` = 显示对话。
|
|
34
|
+
|
|
35
|
+
本插件注册两项,**id 与 key 必须相同**:
|
|
32
36
|
|
|
33
37
|
```js
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
id: "erp",
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// ① 左侧栏图标(owner props: { size, active })
|
|
39
|
+
ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
|
|
40
|
+
name: "sidebar.panellist", id: "erp", order: 15, label: () => "ERP",
|
|
41
|
+
}, ErpIcon))
|
|
42
|
+
|
|
43
|
+
// ② 主视图面板(root 作用域:没有 Session 绑定)
|
|
44
|
+
ctx.slots.inject("main", () => ctx.slots.register({
|
|
45
|
+
name: "main", key: "erp",
|
|
46
|
+
inject: () => ({ sessions, workspaceNav, remote, closePanel }), // 给「聊一聊」建会话 / 返回按钮用
|
|
40
47
|
}, ErpView))
|
|
41
48
|
```
|
|
42
49
|
|
|
43
|
-
-
|
|
44
|
-
|
|
45
|
-
-
|
|
50
|
+
- 点击侧栏图标由 sidebar 调 `ctx.layout.selectPanel("erp")`;点任意会话行由 workspace 调
|
|
51
|
+
`selectPanel(null)` 回到对话;面板内再给一个「← 会话」按钮(同样走 `selectPanel(null)`)。
|
|
52
|
+
- 主视图面板是 **root 作用域**(没有 `sessionId`):"在哪个 workspace 建办公室会话"取
|
|
53
|
+
`useWorkspaces` 的第 1 个 workspace(有当前会话时优先当前会话所属 workspace)。
|
|
54
|
+
- 面板只在被选中时挂载 ⇒ 轮询/请求随关闭自动停。
|
|
46
55
|
|
|
47
56
|
## 3. 视图内容
|
|
48
57
|
|
|
@@ -117,7 +126,7 @@ ERP 网页里那个「办公室聊一聊」**保持原样**(服务端 SSE +
|
|
|
117
126
|
|
|
118
127
|
| 路径 | 作用 |
|
|
119
128
|
|---|---|
|
|
120
|
-
| `/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`(面板打开/渲染)|
|
|
121
130
|
| `/api/argent-erp/whoami` | 账号 + 租户列表 + 默认租户 |
|
|
122
131
|
| `/api/argent-erp/overview?tenant=` | 办公室现场(KPI/在岗/执行机/待办 20/动态 10,脱敏) |
|
|
123
132
|
| `/api/argent-erp/timeline?tenant=&days=` | 时间线(`/sku-flows`,按天分组) |
|
|
@@ -142,7 +151,7 @@ dsh 设置 → 插件(Plugins)→ 安装 bundle → @whyshu/argent-erp
|
|
|
142
151
|
```
|
|
143
152
|
|
|
144
153
|
- 装完**重启 dsh**(bundle patch 的宿主行与 preset 行在启动时装配);
|
|
145
|
-
-
|
|
154
|
+
- 之后**左侧栏出现「ERP」图标**(点击切主视图),新建会话的 **preset 选择器**里出现「六禾园办公室助理」;
|
|
146
155
|
- ⚠️ **同机需要 `@whyshu/argent`**(≥0.2.0):它提供 `mcp__argent__erp_*` 工具与 `whyshu` provider。
|
|
147
156
|
缺它时插件仍能装、视图仍能看(端点会报 no_token),但办公室角色会被 `office-guard` **安全降级为无工具**;
|
|
148
157
|
宿主 `/api/argent-erp/health` 的 `erpTools` 会返回 `0`(= 没有 ERP 工具),`-1` = 读不到 tools 服务。
|
|
@@ -159,9 +168,11 @@ curl -s http://127.0.0.1:3080/api/argent-erp/health # {"ok":true,"version":
|
|
|
159
168
|
# 3) preset 行是否落了(应含 include:preset-argent-office)
|
|
160
169
|
# cordis_inspect_query host/Config/listConfigs name='@deepseek-ai/dsh-agent-preset'
|
|
161
170
|
|
|
162
|
-
# 4)
|
|
163
|
-
# cordis_inspect_query client/Slots/listSubTree root='
|
|
164
|
-
# ⇒ occupants 含 { registrant: "argent-erp-client", id: "erp", order:
|
|
171
|
+
# 4) 客户端两处注册
|
|
172
|
+
# cordis_inspect_query client/Slots/listSubTree root='sidebar.panellist'
|
|
173
|
+
# ⇒ occupants 含 { registrant: "argent-erp-client", id: "erp", order: 15, active: true }
|
|
174
|
+
# cordis_inspect_query client/Slots/listSubTree root='main'
|
|
175
|
+
# ⇒ occupants 含 { registrant: "argent-erp-client", key: "erp", active: true }
|
|
165
176
|
|
|
166
177
|
# 5) 单测(15 项,含 preset 边界断言)
|
|
167
178
|
node plugins/argent-erp/test/client-bundle.test.mjs
|
|
@@ -171,6 +182,24 @@ node plugins/argent/test/office-preset-default.test.mjs # 机器执行器 pres
|
|
|
171
182
|
- 宿主侧改动**需要用户手动重启 dsh**(⛔ agent 不重启);客户端 bundle 改动只需浏览器硬刷新。
|
|
172
183
|
- 回滚:`plugin_manager remove_bundle @whyshu/argent-erp` 后重启(视图与办公室 preset 一起下线)。
|
|
173
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
|
+
|
|
174
203
|
## 8. 已知限制
|
|
175
204
|
|
|
176
205
|
- 只读:写操作(派单/开票执行/聊天写动作)仍走 ERP web;
|
package/dist/client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* argent-erp · 客户端(dsh browser bundle 格式,**手写维护**)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 形态:**左侧栏一个「ERP」图标**(`sidebar.panellist`,id=erp)+ **主视图面板**(`main`,key=erp)——
|
|
5
|
+
* 点侧栏图标,主视图(会话区)切到 ERP;点会话行或面板里的「← 会话」回到对话。
|
|
5
6
|
* 渲染 ERP 的只读视图:办公室现场 / 待办 / 时间线 / 货件 / 聊一聊。
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
+
* 为什么不是右侧栏:ERP 是"一整页工作台"(KPI/待办/时间线/货件/聊天入口),塞进窄侧栏只能看数字;
|
|
9
|
+
* 主视图面板能给它完整宽度,同时左侧栏一点就回来,不打断会话。
|
|
8
10
|
*
|
|
9
11
|
* 数据全部来自宿主端点 `/api/argent-erp/*`(token 在宿主侧,浏览器拿不到)。
|
|
10
12
|
* ⛔ 不做写操作;重编辑一律跳 ERP web。
|
|
@@ -521,7 +523,7 @@ window.__ModuleLoader__.load({
|
|
|
521
523
|
const [tenant, setTenant] = React.useState("")
|
|
522
524
|
const [detail, setDetail] = React.useState(null) // { kind:'flow'|'shipment', id }
|
|
523
525
|
const [nonce, setNonce] = React.useState(0) // 刷新时让各自取数的面板重挂
|
|
524
|
-
//
|
|
526
|
+
// 渲染信标:只有打开 ERP 面板才会打 ⇒ /health 的 viewPingAt 能证明"真的渲染了"
|
|
525
527
|
React.useEffect(() => { try { fetch(API + "/ping?from=view") } catch { /* noop */ } }, [])
|
|
526
528
|
const who = useApi("/whoami")
|
|
527
529
|
const health = useApi("/health")
|
|
@@ -556,6 +558,12 @@ window.__ModuleLoader__.load({
|
|
|
556
558
|
|
|
557
559
|
return h("div", { className: "aerp-root" },
|
|
558
560
|
h("div", { className: "aerp-head" },
|
|
561
|
+
props && typeof props.closePanel === "function"
|
|
562
|
+
? h("button", {
|
|
563
|
+
type: "button", className: "aerp-btn", title: "回到会话",
|
|
564
|
+
onClick: () => { try { props.closePanel() } catch { /* noop */ } },
|
|
565
|
+
}, "← 会话")
|
|
566
|
+
: null,
|
|
559
567
|
h("span", { className: "aerp-title" }, "Argent ERP"),
|
|
560
568
|
tenants.length > 1
|
|
561
569
|
? h("select", {
|
|
@@ -581,26 +589,60 @@ window.__ModuleLoader__.load({
|
|
|
581
589
|
const name = "argent-erp-client"
|
|
582
590
|
const inject = ["slots"]
|
|
583
591
|
|
|
592
|
+
/** 侧栏入口 id 与主视图面板 key 必须一致(sidebar 用 id 去 main 槽取面板) */
|
|
593
|
+
const PANEL_ID = "erp"
|
|
594
|
+
|
|
595
|
+
/** 左侧栏「ERP」图标(owner props: { size, active };用 currentColor,跟随主题) */
|
|
596
|
+
function ErpIcon(props) {
|
|
597
|
+
const size = (props && props.size) || 16
|
|
598
|
+
const active = Boolean(props && props.active)
|
|
599
|
+
const color = active ? "var(--dsw-alias-brand-primary)" : "currentColor"
|
|
600
|
+
return h("svg", {
|
|
601
|
+
width: size, height: size, viewBox: "0 0 24 24", fill: "none",
|
|
602
|
+
stroke: color, strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round",
|
|
603
|
+
style: { display: "block" }, "aria-hidden": "true", focusable: "false",
|
|
604
|
+
},
|
|
605
|
+
h("path", { d: "M3.6 9.6 5.3 4.3h13.4l1.7 5.3" }),
|
|
606
|
+
h("path", { d: "M4.3 9.6h15.4v9.1a1.3 1.3 0 0 1-1.3 1.3H5.6a1.3 1.3 0 0 1-1.3-1.3V9.6Z" }),
|
|
607
|
+
h("path", { d: "M9.4 20v-4.7h5.2V20" }))
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 取服务;没有/未注入时返回 undefined(动态插件 ctx 的白名单可能不含某个服务) */
|
|
611
|
+
const getService = (ctx, name) => { try { return ctx.get(name) } catch { return undefined } }
|
|
612
|
+
|
|
584
613
|
function apply(ctx) {
|
|
585
614
|
injectCss()
|
|
586
615
|
// 自检信标:宿主 /health 会回报 clientPingAt ⇒ 能区分"bundle 没加载"与"加载了但没显示"
|
|
587
616
|
try { fetch(API + "/ping?from=client").catch(() => {}) } catch { /* noop */ }
|
|
588
617
|
|
|
589
|
-
// ⛔
|
|
618
|
+
// ⛔ 永不抛出:注册失败只影响 ERP 入口,不能让 web boot 报 "entry did not activate"
|
|
619
|
+
|
|
620
|
+
// ① 左侧栏入口(图标按钮):点击后 sidebar 调 ctx.layout.selectPanel('erp') 切主视图
|
|
590
621
|
try {
|
|
591
|
-
ctx.slots.inject("
|
|
592
|
-
name: "
|
|
593
|
-
id:
|
|
594
|
-
order:
|
|
622
|
+
ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
|
|
623
|
+
name: "sidebar.panellist",
|
|
624
|
+
id: PANEL_ID,
|
|
625
|
+
order: 15,
|
|
595
626
|
label: () => "ERP",
|
|
627
|
+
}, ErpIcon))
|
|
628
|
+
} catch (e) {
|
|
629
|
+
console.warn("[argent-erp] 侧栏入口注册失败(不影响 dsh):", (e && e.message) || e)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ② 主视图面板:key 与侧栏 id 相同;root 作用域(没有 Session 绑定)
|
|
633
|
+
try {
|
|
634
|
+
ctx.slots.inject("main", () => ctx.slots.register({
|
|
635
|
+
name: "main",
|
|
636
|
+
key: PANEL_ID,
|
|
596
637
|
inject: () => ({
|
|
597
|
-
sessions: (
|
|
598
|
-
workspaceNav: (
|
|
599
|
-
remote: (
|
|
638
|
+
sessions: getService(ctx, "sessions"),
|
|
639
|
+
workspaceNav: getService(ctx, "uiWorkspace"),
|
|
640
|
+
remote: getService(ctx, "remote"),
|
|
641
|
+
closePanel: () => { try { getService(ctx, "layout")?.selectPanel?.(null) } catch { /* noop */ } },
|
|
600
642
|
}),
|
|
601
643
|
}, ErpView))
|
|
602
644
|
} catch (e) {
|
|
603
|
-
console.warn("[argent-erp]
|
|
645
|
+
console.warn("[argent-erp] 主视图面板注册失败(不影响 dsh):", (e && e.message) || e)
|
|
604
646
|
}
|
|
605
647
|
}
|
|
606
648
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyshu/argent-erp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Argent ERP
|
|
5
|
+
"description": "Argent ERP 左侧栏入口 + 主视图面板(dsh sidebar.panellist/main)+ 六禾园办公室助理 preset + 办公室聊一聊",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
8
8
|
"deepseek-harness",
|
|
@@ -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
|
+
}
|