@1e0zj/dsh-plugin-mall 0.1.11 → 0.1.13
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 +172 -123
- package/cordis.patch.yml +12 -0
- package/package.json +1 -1
- package/src/client.js +12 -1
- package/src/github.js +164 -56
- package/src/index.js +41 -14
- package/src/installer.js +112 -3
package/README.md
CHANGED
|
@@ -1,123 +1,172 @@
|
|
|
1
|
-
# dsh-plugin-mall
|
|
2
|
-
|
|
3
|
-
**An open plugin marketplace for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh): search every GitHub repo tagged `topic:dsh-plugin`, automatically verify which ones are real dsh plugins, install and update with one click.**
|
|
4
|
-
|
|
5
|
-
[中文说明](#中文说明) · [Install](#install) · [Why another marketplace](#why-another-marketplace)
|
|
6
|
-
|
|
7
|
-
Two surfaces: a **Settings → Plugins → Marketplace** tab in the dsh web UI, and five agent tools usable from any session.
|
|
8
|
-
|
|
9
|
-
## Why another marketplace?
|
|
10
|
-
|
|
11
|
-
Curated lists only show what has been reviewed and merged. This marketplace is open by construction: **any repo tagged `topic:dsh-plugin` is discoverable the moment it is pushed** — no submission, no approval queue. To keep that openness usable:
|
|
12
|
-
|
|
13
|
-
- **Automatic verification** — every search result's `package.json` is fetched (jsDelivr/raw dual-source CDN, no API quota) and checked for the official `dsh.bundle` / `dsh.client` manifest. Verified plugins get a green badge; the default "verified only" view filters out ~73% of topic noise (empty repos and unrelated projects riding the tag).
|
|
14
|
-
- **Anti-squatting** — an install prefers the npm tarball only when the registry entry's `repository` URL points back to the same GitHub repo; anything else falls back to the explicit `github:` spec.
|
|
15
|
-
- **npm-first installs** — registry tarballs are smaller than whole-repo GitHub downloads and come with integrity checks.
|
|
16
|
-
- **Update management** — installed plugins are compared against the registry `latest`; one-click update per plugin.
|
|
17
|
-
- **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable).
|
|
18
|
-
|
|
19
|
-
## Install
|
|
20
|
-
|
|
21
|
-
```powershell
|
|
22
|
-
# from npm
|
|
23
|
-
dsh plugin --profile web add @1e0zj/dsh-plugin-mall
|
|
24
|
-
|
|
25
|
-
# from GitHub
|
|
26
|
-
dsh plugin --profile web add github:1e0zj/dsh-plugin-mall
|
|
27
|
-
|
|
28
|
-
# local development
|
|
29
|
-
dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Restart dsh after installing.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
# dsh-plugin-mall
|
|
2
|
+
|
|
3
|
+
**An open plugin marketplace for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh): search every GitHub repo tagged `topic:dsh-plugin`, automatically verify which ones are real dsh plugins, install and update with one click.**
|
|
4
|
+
|
|
5
|
+
[中文说明](#中文说明) · [Install](#install) · [Why another marketplace](#why-another-marketplace)
|
|
6
|
+
|
|
7
|
+
Two surfaces: a **Settings → Plugins → Marketplace** tab in the dsh web UI, and five agent tools usable from any session.
|
|
8
|
+
|
|
9
|
+
## Why another marketplace?
|
|
10
|
+
|
|
11
|
+
Curated lists only show what has been reviewed and merged. This marketplace is open by construction: **any repo tagged `topic:dsh-plugin` is discoverable the moment it is pushed** — no submission, no approval queue. To keep that openness usable:
|
|
12
|
+
|
|
13
|
+
- **Automatic verification** — every search result's `package.json` is fetched (jsDelivr/raw dual-source CDN, no API quota) and checked for the official `dsh.bundle` / `dsh.client` manifest. Verified plugins get a green badge; the default "verified only" view filters out ~73% of topic noise (empty repos and unrelated projects riding the tag).
|
|
14
|
+
- **Anti-squatting** — an install prefers the npm tarball only when the registry entry's `repository` URL points back to the same GitHub repo; anything else falls back to the explicit `github:` spec.
|
|
15
|
+
- **npm-first installs** — registry tarballs are smaller than whole-repo GitHub downloads and come with integrity checks. Lookups follow the registry pnpm actually installs from (profile `.npmrc` → `pnpm config get registry` → npmjs), so a mirror user keeps npm-first instead of silently falling back to whole-repo clones.
|
|
16
|
+
- **Update management** — installed plugins are compared against the registry `latest`; one-click update per plugin.
|
|
17
|
+
- **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
# from npm
|
|
23
|
+
dsh plugin --profile web add @1e0zj/dsh-plugin-mall
|
|
24
|
+
|
|
25
|
+
# from GitHub
|
|
26
|
+
dsh plugin --profile web add github:1e0zj/dsh-plugin-mall
|
|
27
|
+
|
|
28
|
+
# local development — currently unusable, see the note below
|
|
29
|
+
dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Restart dsh after installing.
|
|
33
|
+
|
|
34
|
+
> **`link:` cannot be set up right now**, for reasons upstream of this plugin. Under
|
|
35
|
+
> `link:` Node loads from the project's real path, so bare imports must resolve from
|
|
36
|
+
> the project's own `node_modules` — which requires `npm install` inside the project
|
|
37
|
+
> first. That fails: the framework packages' registry dist-tags currently straddle two
|
|
38
|
+
> release trains (`dsh-tools` latest is still `0.0.1-rc.x` while `dsh-app-boot` is on
|
|
39
|
+
> `0.1.0-rc.x`), their peer ranges do not intersect, and npm stops with ERESOLVE.
|
|
40
|
+
> Neither escape hatch helps — `--legacy-peer-deps` skips peers so bare imports still
|
|
41
|
+
> will not resolve, and `--force` plants a mixed-train copy of the framework inside the
|
|
42
|
+
> project, which `link:` would then load instead of the host's, causing exactly the
|
|
43
|
+
> duplicate-module crash described under 开发说明. Until those dist-tags line up,
|
|
44
|
+
> develop against a local tarball — `npm pack`, then install the `.tgz` with a `file:`
|
|
45
|
+
> spec (recipe in the 安装 section below). Do not hand-overwrite files under
|
|
46
|
+
> `node_modules`: they are hard-linked into pnpm's global store, and any later
|
|
47
|
+
> `pnpm add/remove` rebuilds the tree and restores them anyway.
|
|
48
|
+
|
|
49
|
+
## Agent tools
|
|
50
|
+
|
|
51
|
+
| Tool | What it does |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `market_search` | Search GitHub repos tagged `topic:dsh-plugin` (star-ranked, keyword filter, server-side `stars:>=1` noise floor) |
|
|
54
|
+
| `market_info` | Inspect one repo: stars, license, package.json, whether it declares `dsh.bundle.patch` / `dsh.client` |
|
|
55
|
+
| `market_install` | Install a plugin into a profile as a background job (npm-first spec resolution) |
|
|
56
|
+
| `market_uninstall` | Remove a plugin: `pnpm remove` + bundle-layer reconcile + client-row cleanup |
|
|
57
|
+
| `market_installed` | List a profile's installed plugins and their bundle status |
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# 中文说明
|
|
62
|
+
|
|
63
|
+
**dsh 插件市场** — 搜索 GitHub `dsh-plugin` 话题下的 DeepSeek Harness 插件仓库,自动验证哪些是真 dsh 插件,一键安装与更新。
|
|
64
|
+
|
|
65
|
+
与策展列表不同:**任何打上 `topic:dsh-plugin` 的仓库推送后立即可被发现**——无需投稿、无需审批。为保证开放性可用,做了这些事:
|
|
66
|
+
|
|
67
|
+
- **自动验证**:逐仓库拉取 `package.json`(jsDelivr/raw 双源 CDN,不占 API 配额),按官方 `dsh.bundle` / `dsh.client` 声明打徽章;默认"只看已验证"视图过滤约 73% 的话题噪音
|
|
68
|
+
- **防抢注**:仅当 npm registry 条目的 `repository` 指回同一 GitHub 仓库时才用 npm 安装,否则回退 `github:` 源
|
|
69
|
+
- **npm 优先安装**:registry tarball 比整仓库下载更小且带完整性校验;查询用的 registry 跟随 pnpm 实际安装源(profile `.npmrc` → `pnpm config get registry` → npmjs),换了镜像也不会退化成整仓库克隆
|
|
70
|
+
- **更新管理**:已装插件与 registry `latest` 比对,逐个一键更新
|
|
71
|
+
- **工程韧性**:限流熔断、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false` 关闭)
|
|
72
|
+
|
|
73
|
+
## 安装
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
# 从 npm
|
|
77
|
+
dsh plugin --profile web add @1e0zj/dsh-plugin-mall
|
|
78
|
+
|
|
79
|
+
# 从 GitHub
|
|
80
|
+
dsh plugin --profile web add github:1e0zj/dsh-plugin-mall
|
|
81
|
+
|
|
82
|
+
# 本地开发 —— 目前装不起来,见下方说明
|
|
83
|
+
dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
装完**重启 dsh**(`dsh web` 进程)后生效。
|
|
87
|
+
|
|
88
|
+
> **`link:` 目前用不了**,原因在上游、与本插件无关。`link:` 下 Node 从项目的真实
|
|
89
|
+
> 路径加载模块,裸导入只能从项目自己的 `node_modules` 解析,所以得先在项目里
|
|
90
|
+
> `npm install` 一次 —— 而这一步会失败:框架包在 registry 上的 dist-tags 眼下横跨
|
|
91
|
+
> 两条发布线(`dsh-tools` 的 latest 还停在 `0.0.1-rc.x`,`dsh-app-boot` 已经是
|
|
92
|
+
> `0.1.0-rc.x`),二者对 `dsh-invariants` 的 peer 区间无交集(`^0.0.1-rc.x` 只收
|
|
93
|
+
> `0.0.1-rc.x`,`^0.1.0-rc.x` 只收 `0.1.x`),npm 以 ERESOLVE 中止。
|
|
94
|
+
> 两个逃生口都不解决问题:`--legacy-peer-deps` 跳过 peer,裸导入照样解析不了;
|
|
95
|
+
> `--force` 会在项目里装一套**混版本的框架副本**,`link:` 加载的就是那套而不是宿主
|
|
96
|
+
> 那套 —— 正好踩中下面「开发说明」里讲的双副本身份分裂崩溃。
|
|
97
|
+
>
|
|
98
|
+
> 在上游 dist-tags 对齐前,本地开发用**本地 tarball**:
|
|
99
|
+
>
|
|
100
|
+
> ```bash
|
|
101
|
+
> npm pack # 产出 1e0zj-dsh-plugin-mall-<ver>.tgz
|
|
102
|
+
> dsh plugin --profile web remove @1e0zj/dsh-plugin-mall
|
|
103
|
+
> dsh plugin --profile web add file:C:\code\dsh-plugin-mall\1e0zj-dsh-plugin-mall-0.1.13.tgz
|
|
104
|
+
> ```
|
|
105
|
+
>
|
|
106
|
+
> 这样 pnpm 的规范副本本身就是新代码,后续任何 `pnpm add/remove` 重建依赖树都不会
|
|
107
|
+
> 把它换掉;顺带还验证了 `files` 字段没漏文件。改完代码重新 `npm pack` + 重装即可。
|
|
108
|
+
>
|
|
109
|
+
> **不要用直接覆盖 `node_modules` 里文件的办法。** 它有两个坑:
|
|
110
|
+
> 一是 pnpm 装出来的文件是**硬链接**(与全局 store 共享 inode),直接 `cp` 覆盖会
|
|
111
|
+
> 穿透硬链接改掉 store 里的内容且 pnpm 不会察觉,必须先 `rm` 再写;二是**任何一次
|
|
112
|
+
> pnpm 操作都会重建整棵树**,按 lockfile 从 store 把你覆盖的文件还原回去 —— 而通过
|
|
113
|
+
> 本插件装/卸任何插件都会触发 `pnpm add/remove`,也就是说测试市场的安装功能这个动作
|
|
114
|
+
> 本身就会抹掉被测代码。(已加载进内存的模块不受影响,重启后才会退回旧版。)
|
|
115
|
+
>
|
|
116
|
+
> 通过 npm/GitHub 安装则没有上述任何问题:pnpm 把真实拷贝装进 profile 的
|
|
117
|
+
> `node_modules`,框架包由宿主经 `profiles/node_modules` 里指向全局 dsh 的软链提供,
|
|
118
|
+
> 版本天然一致。
|
|
119
|
+
>
|
|
120
|
+
> 另:Windows 上 `file:`/`link:` 的路径**不能含空格**。pnpm 是经 cmd 拉起的,
|
|
121
|
+
> Node 只把参数用空格拼接、不逐参加引号,带空格的路径会被拆成两个参数;
|
|
122
|
+
> 自己加引号也不行(`"` 属于被拦截的 shell 元字符)。市场会直接拒绝并说明原因。
|
|
123
|
+
|
|
124
|
+
## 工作原理
|
|
125
|
+
|
|
126
|
+
- 双面包(dual-face)插件:`dsh.bundle` 半边挂在 **host 平面**(profile bundle 层),
|
|
127
|
+
注册 5 个 agent 工具(进 global 层,所有会话可见,与 MCP 工具同理);
|
|
128
|
+
`dsh.client` 半边是浏览器插件,往设置页插件区注册「插件市场」tab
|
|
129
|
+
(`settings.plugins.tab` slot;手写无构建,经 `window.__ModuleLoader__` 加载)。
|
|
130
|
+
- 浏览器 → 服务端走 Connection 服务的独立 RPC 通道 `/market`
|
|
131
|
+
(loopback-only,与 `/api` 通道互不干扰);页面发起的安装任务用进程内
|
|
132
|
+
tracker 跟踪 —— web host 层没有 job 控制器,`ctx.jobs` 无法在会话外起任务。
|
|
133
|
+
- `market_install` 复刻官方 `dsh plugin add` 的流程:在 profile 目录跑
|
|
134
|
+
`pnpm add <spec>`,成功后把声明了 `dsh.bundle.patch` 的依赖登记进
|
|
135
|
+
`dsh.profile.bundles`(layer 列表),声明了 `dsh.client` 的依赖自动在
|
|
136
|
+
profile 的 `cordis.patch.yml` 注册加载行,与官方 reconcile 逻辑一致。
|
|
137
|
+
- `market_uninstall` 复刻官方 `dsh plugin remove` 的流程:在 profile 目录跑
|
|
138
|
+
`pnpm remove <package>`,成功后从 `dsh.profile.bundles` 剔除该依赖的
|
|
139
|
+
bundle 条目,并删掉 `cordis.patch.yml` 里由安装流程注册的客户端加载行
|
|
140
|
+
(文本级精准移除,用户手写的行不受影响)。
|
|
141
|
+
- 安装源解析:`github:owner/repo` 优先改写为同名 npm 包(仅当 registry
|
|
142
|
+
条目的 repository 指回该仓库,防止抢注),否则用 GitHub 全仓库 spec。
|
|
143
|
+
- GitHub 源的插件安装时要跑 prepare 构建脚本,pnpm 默认拦截;本插件检测到
|
|
144
|
+
拦截后会把包名自动合并进 profile 的 `pnpm-workspace.yaml` 的 `allowBuilds`
|
|
145
|
+
并自动重试一次(解析只认合法 npm 包名,写入一律加引号,不会写坏 YAML)。
|
|
146
|
+
- 配置(`cordis.patch.yml` 中可改):`defaultProfile`(默认装进哪个 profile,
|
|
147
|
+
默认 `web`)、`apiBase`(GitHub API 地址)、`npmRegistry`(npm 查询源,留空
|
|
148
|
+
则跟随 pnpm 实际安装源)、`rawSources`(验证用的 package.json 源模板列表,
|
|
149
|
+
`{repo}` 会替换成 owner/name,留空用内置的 jsDelivr + raw 双源)、
|
|
150
|
+
`perPageMax`(搜索单页上限)、`allowRestart`(是否允许一键重启,默认 `true`)。
|
|
151
|
+
|
|
152
|
+
## 发布
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm publish --access public
|
|
156
|
+
# 或在 GitHub 建仓库并打上 topic:dsh-plugin
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## 开发说明
|
|
160
|
+
|
|
161
|
+
- 插件形态:`package.json` 里 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,
|
|
162
|
+
patch 文件把 `dsh-plugin-mall` 这一行 insert 进装配树;**同一行同时是
|
|
163
|
+
client 插件行**(`dsh.client` 声明让 client-modules 扫描并服务
|
|
164
|
+
`/plugins/<id>/client.js`)。
|
|
165
|
+
- **`@deepseek-ai/*` 框架包必须声明为 `peerDependencies`**:装成 dependencies
|
|
166
|
+
会把宿主模块副本 hoist 进 profile,cordis loader 双副本加载、Symbol 身份
|
|
167
|
+
分裂,宿主的工具调度全线崩溃。宿主经 `profiles/node_modules` fallback
|
|
168
|
+
提供框架包。
|
|
169
|
+
- node 半边导出具名成员 `{ name, inject, Config, apply }`,**不要** `export default`
|
|
170
|
+
(cordis loader 会做 `exports.default ?? exports` 解包,default 会吞掉 inject/Config);
|
|
171
|
+
client 半边是 `window.__ModuleLoader__.load({id, factory})`,导出 `{ apply, inject }`。
|
|
172
|
+
- 单测 `src/github.js`(无 harness 依赖):`node src/github.js --self-test`。
|
package/cordis.patch.yml
CHANGED
|
@@ -9,5 +9,17 @@
|
|
|
9
9
|
defaultProfile: web
|
|
10
10
|
# GitHub REST API base (override for self-hosted mirrors/proxies).
|
|
11
11
|
apiBase: https://api.github.com
|
|
12
|
+
# npm registry for anti-squatting, update checks and the host-dependency
|
|
13
|
+
# guard. Empty = follow whatever pnpm installs from (the profile's
|
|
14
|
+
# .npmrc, then `pnpm config get registry`, then npmjs), which is what
|
|
15
|
+
# you want on a mirror — set this only to pin a different one.
|
|
16
|
+
npmRegistry: ''
|
|
17
|
+
# package.json sources for plugin verification, tried in order until one
|
|
18
|
+
# answers; `{repo}` is replaced with owner/name. Empty = jsDelivr then
|
|
19
|
+
# raw.githubusercontent.com. Override for a self-hosted reverse proxy.
|
|
20
|
+
rawSources: []
|
|
12
21
|
# Upper bound for market_search perPage.
|
|
13
22
|
perPageMax: 30
|
|
23
|
+
# Whether the marketplace tab may restart the dsh process. Set false when
|
|
24
|
+
# something else (systemd, pm2, …) owns the process lifecycle.
|
|
25
|
+
allowRestart: true
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -351,11 +351,16 @@ window.__ModuleLoader__.load({
|
|
|
351
351
|
// 无限滚动:哨兵进入视口时拉下一页并追加。GitHub topic 的翻页间数据
|
|
352
352
|
// 可能移动造成重复,按 fullName 去重;已显示数追上 total 即到底。
|
|
353
353
|
var canLoadMore = results !== null && results.items.length < results.total && !reachedLimit;
|
|
354
|
+
var loadMoreLock = useRef(false);
|
|
354
355
|
var loadMore = useCallback(function () {
|
|
355
356
|
if (!canLoadMore || loading || loadingMore) return;
|
|
357
|
+
// 同一 tick 内 observer 可能连续触发两次,state 更新不阻塞闭包读值,
|
|
358
|
+
// 用 ref 做重入闸。
|
|
359
|
+
if (loadMoreLock.current) return;
|
|
356
360
|
// 限流熔断:失败后 60s 内不再自动请求(哨兵重渲染会反复触发
|
|
357
361
|
// IntersectionObserver,不熔断会连环 500 直到限流窗口过去)。
|
|
358
362
|
if (Date.now() < retryAt) return;
|
|
363
|
+
loadMoreLock.current = true;
|
|
359
364
|
setLoadingMore(true);
|
|
360
365
|
call("search", { query: query, sort: sort, perPage: 20, page: page + 1 }).then(function (value) {
|
|
361
366
|
if (value.truncated === true) { setReachedLimit(true); return; }
|
|
@@ -376,6 +381,7 @@ window.__ModuleLoader__.load({
|
|
|
376
381
|
setError(errorText(e));
|
|
377
382
|
setRetryAt(Date.now() + 60000);
|
|
378
383
|
}).finally(function () {
|
|
384
|
+
loadMoreLock.current = false;
|
|
379
385
|
setLoadingMore(false);
|
|
380
386
|
});
|
|
381
387
|
}, [call, canLoadMore, loading, loadingMore, page, query, sort, retryAt]);
|
|
@@ -451,7 +457,12 @@ window.__ModuleLoader__.load({
|
|
|
451
457
|
var tries = 0;
|
|
452
458
|
var ping = setInterval(function () {
|
|
453
459
|
tries++;
|
|
454
|
-
if (tries > 40) {
|
|
460
|
+
if (tries > 40) {
|
|
461
|
+
clearInterval(ping);
|
|
462
|
+
setRestarting(false);
|
|
463
|
+
setError("2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
455
466
|
rpc.call("/market", "installed", {}).then(function () {
|
|
456
467
|
clearInterval(ping);
|
|
457
468
|
window.location.reload();
|
package/src/github.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// GitHub API helpers for the dsh plugin marketplace.
|
|
2
2
|
// Pure functions with no harness imports, so this module is unit-testable
|
|
3
3
|
// standalone (node src/github.js --self-test).
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
4
6
|
|
|
5
7
|
const SEARCH_TOPIC = "topic:dsh-plugin";
|
|
6
8
|
/** GitHub search never serves past the first 1000 results. */
|
|
@@ -114,36 +116,68 @@ export async function searchPlugins({ query, sort = "stars", perPage = 10, page
|
|
|
114
116
|
// points back at that GitHub repo, which doubles as an anti-squatting check
|
|
115
117
|
// (an unrelated package squatting the name never matches, install falls back
|
|
116
118
|
// to the explicit github: spec).
|
|
119
|
+
//
|
|
120
|
+
// The registry to query is the CALLER's business (see resolveRegistry in
|
|
121
|
+
// installer.js): it has to be the one pnpm installs from. Querying npmjs while
|
|
122
|
+
// pnpm installs from a mirror fails three ways at once and all of them are
|
|
123
|
+
// silent — anti-squatting never matches (every install degrades to a whole-repo
|
|
124
|
+
// GitHub clone plus a prepare build), `latest` comes back null (the update
|
|
125
|
+
// button disappears), and assertSafeToInstall sees no manifest, so the
|
|
126
|
+
// host-shadow guard stops guarding.
|
|
127
|
+
|
|
128
|
+
export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
129
|
+
// `${registry}|${name}` -> {info, at}. Keyed by registry so a config change
|
|
130
|
+
// cannot serve answers from a different registry.
|
|
131
|
+
const npmCache = new Map();
|
|
132
|
+
// Success and failure expire alike. A never-expiring success cache means
|
|
133
|
+
// `hasUpdate` is frozen for the process lifetime: the author publishes, and no
|
|
134
|
+
// amount of "刷新已装" shows it until dsh restarts — self-defeating for a
|
|
135
|
+
// marketplace whose selling point is update management.
|
|
136
|
+
const NPM_CACHE_TTL = 300000;
|
|
117
137
|
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
/** Strip trailing slashes so `${base}/${name}` never doubles up. */
|
|
139
|
+
function normalizeRegistry(registry) {
|
|
140
|
+
const value = String(registry ?? "").trim();
|
|
141
|
+
return (value.length > 0 ? value : DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
|
|
142
|
+
}
|
|
120
143
|
|
|
121
144
|
/**
|
|
122
|
-
* Look up a package on
|
|
123
|
-
*
|
|
145
|
+
* Look up a package's `latest` manifest on an npm registry.
|
|
146
|
+
* @param name - the npm package name.
|
|
147
|
+
* @param options - `registry` defaults to npmjs; pass what pnpm installs from.
|
|
148
|
+
* @returns `{latest, repositoryUrl, hostDeps}`, or null when unknown/unreachable.
|
|
124
149
|
*/
|
|
125
|
-
export async function npmPackageInfo(name) {
|
|
150
|
+
export async function npmPackageInfo(name, { registry } = {}) {
|
|
126
151
|
const clean = String(name ?? "").trim();
|
|
127
152
|
if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
|
|
128
|
-
|
|
153
|
+
const base = normalizeRegistry(registry);
|
|
154
|
+
const key = `${base}|${clean}`;
|
|
155
|
+
const cached = npmCache.get(key);
|
|
156
|
+
if (cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
|
|
129
157
|
let info = null;
|
|
130
158
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
159
|
+
// 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
|
|
160
|
+
// abbreviated packument 不含 repository,防抢注比对会永远落空。
|
|
161
|
+
// 镜像(npmmirror 等)的同名端点响应格式一致,两个字段都保留。
|
|
162
|
+
const response = await fetch(`${base}/${clean.replace("/", "%2F")}/latest`, {
|
|
163
|
+
headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
|
|
133
164
|
});
|
|
134
165
|
if (response.ok) {
|
|
135
166
|
const body = await response.json();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const rawRepository = body?.repository;
|
|
167
|
+
if (typeof body?.version === "string") {
|
|
168
|
+
const rawRepository = body.repository;
|
|
139
169
|
const repositoryUrl = typeof rawRepository === "string" ? rawRepository : rawRepository?.url;
|
|
140
|
-
info = {
|
|
170
|
+
info = {
|
|
171
|
+
latest: body.version,
|
|
172
|
+
repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined,
|
|
173
|
+
hostDeps: hostShadowDependencies(body),
|
|
174
|
+
};
|
|
141
175
|
}
|
|
142
176
|
}
|
|
143
177
|
} catch {
|
|
144
178
|
info = null; // registry unreachable — caller falls back
|
|
145
179
|
}
|
|
146
|
-
npmCache.set(
|
|
180
|
+
npmCache.set(key, { info, at: Date.now() });
|
|
147
181
|
return info;
|
|
148
182
|
}
|
|
149
183
|
|
|
@@ -152,15 +186,20 @@ export async function npmPackageInfo(name) {
|
|
|
152
186
|
* that package exists on npm AND its repository URL points back at the repo
|
|
153
187
|
* (anti-squatting). Anything else passes through untouched.
|
|
154
188
|
*/
|
|
155
|
-
export async function preferNpmSpec({ spec }) {
|
|
189
|
+
export async function preferNpmSpec({ spec, registry, sources }) {
|
|
156
190
|
const raw = String(spec ?? "");
|
|
191
|
+
// A scoped npm name ("@scope/name", "@scope/name@1.2.3") is shaped exactly
|
|
192
|
+
// like owner/repo and matches the regex below, sending every such install
|
|
193
|
+
// off to verify a repository that cannot exist — two wasted CDN requests and
|
|
194
|
+
// a bogus cache entry. Same guard normalizeSpec already uses.
|
|
195
|
+
if (raw.startsWith("@")) return raw;
|
|
157
196
|
const githubMatch = /^(?:github:)?([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
|
|
158
197
|
if (githubMatch === null) return raw;
|
|
159
198
|
const repo = githubMatch[1];
|
|
160
|
-
const { results } = await verifyPlugins({ repos: [repo] }); // cache hit after first verify
|
|
199
|
+
const { results } = await verifyPlugins({ repos: [repo], sources }); // cache hit after first verify
|
|
161
200
|
const declaredName = results[repo]?.name;
|
|
162
201
|
if (typeof declaredName !== "string") return raw;
|
|
163
|
-
const info = await npmPackageInfo(declaredName);
|
|
202
|
+
const info = await npmPackageInfo(declaredName, { registry });
|
|
164
203
|
if (info === null || info.repositoryUrl === undefined) return raw;
|
|
165
204
|
const pointsBack = new RegExp(`github\\.com[/:]${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|\\.git|$)`, "i").test(info.repositoryUrl);
|
|
166
205
|
return pointsBack ? declaredName : raw;
|
|
@@ -174,25 +213,56 @@ export async function preferNpmSpec({ spec }) {
|
|
|
174
213
|
* Sources: registry abbreviated metadata for npm names, the verify cache
|
|
175
214
|
* (raw package.json) for github: specs.
|
|
176
215
|
*/
|
|
177
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Extract the bare npm package name: "name", "name@version", "@s/n@version"
|
|
218
|
+
* → "name"/"@s/n". Returns null for anything that is not an npm name shape.
|
|
219
|
+
*/
|
|
220
|
+
function npmNameOf(raw) {
|
|
221
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
222
|
+
if (raw.startsWith("@")) {
|
|
223
|
+
const match = /^(@[^/@\s]+\/[^/@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
|
|
224
|
+
return match === null ? null : match[1];
|
|
225
|
+
}
|
|
226
|
+
const match = /^([^@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
|
|
227
|
+
return match === null ? null : match[1];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function assertSafeToInstall({ spec, registry, sources }) {
|
|
178
231
|
const raw = String(spec ?? "");
|
|
179
232
|
let hostDeps;
|
|
180
|
-
if (/^(
|
|
181
|
-
|
|
182
|
-
|
|
233
|
+
if (/^(?:file:|link:)/i.test(raw)) {
|
|
234
|
+
// 本地路径直通:直接读它的 package.json 查 dependencies。
|
|
235
|
+
const dir = raw.replace(/^(?:file:|link:)/i, "").replace(/[\\/]+$/, "");
|
|
236
|
+
try {
|
|
237
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
238
|
+
hostDeps = hostShadowDependencies(pkg);
|
|
239
|
+
} catch {
|
|
240
|
+
return; // 读不到清单——pnpm 会给出真实错误,这里放行
|
|
241
|
+
}
|
|
242
|
+
} else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
|
|
243
|
+
const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
|
|
244
|
+
const { results } = await verifyPlugins({ repos: [repo], sources });
|
|
245
|
+
hostDeps = results[repo]?.hostDeps;
|
|
246
|
+
} else if (/^https?:\/\//i.test(raw)) {
|
|
247
|
+
return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
|
|
183
248
|
} else {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
249
|
+
// npm 名(含带版本形式):剥掉 @version 再查。
|
|
250
|
+
const name = npmNameOf(raw);
|
|
251
|
+
if (name === null || name.length === 0) {
|
|
252
|
+
// 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
|
|
253
|
+
throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
|
|
188
254
|
}
|
|
255
|
+
const info = await npmPackageInfo(name, { registry });
|
|
256
|
+
hostDeps = info?.hostDeps;
|
|
189
257
|
}
|
|
190
|
-
if (hostDeps !== undefined && hostDeps.length > 0) {
|
|
258
|
+
if (hostDeps !== undefined && hostDeps !== null && hostDeps.length > 0) {
|
|
191
259
|
throw new Error(`${raw} declares ${hostDeps.length} host framework package(s) as dependencies (${hostDeps.slice(0, 3).join(", ")}${hostDeps.length > 3 ? ", …" : ""}) — installing it would duplicate host modules and crash dsh tool scheduling. Ask the plugin author to move @deepseek-ai/* to peerDependencies.`);
|
|
192
260
|
}
|
|
193
261
|
}
|
|
194
262
|
|
|
195
|
-
/** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts
|
|
263
|
+
/** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts
|
|
264
|
+
* read as 0; numerically equal versions where one carries a pre-release
|
|
265
|
+
* segment ("-rc.1") sort below the plain release ("2.0.0-beta" < "2.0.0"). */
|
|
196
266
|
export function compareVersions(a, b) {
|
|
197
267
|
const pa = String(a ?? "").split(".");
|
|
198
268
|
const pb = String(b ?? "").split(".");
|
|
@@ -201,6 +271,9 @@ export function compareVersions(a, b) {
|
|
|
201
271
|
const nb = Number(pb[index]) || 0;
|
|
202
272
|
if (na !== nb) return na < nb ? -1 : 1;
|
|
203
273
|
}
|
|
274
|
+
const preA = String(a ?? "").includes("-");
|
|
275
|
+
const preB = String(b ?? "").includes("-");
|
|
276
|
+
if (preA !== preB) return preA ? -1 : 1;
|
|
204
277
|
return 0;
|
|
205
278
|
}
|
|
206
279
|
// ── plugin verification (raw CDN, no API quota) ─────────────────────────────
|
|
@@ -214,25 +287,52 @@ export function compareVersions(a, b) {
|
|
|
214
287
|
// for the process lifetime; a fetch failure caches "unknown" rather than
|
|
215
288
|
// retrying forever.
|
|
216
289
|
|
|
217
|
-
|
|
218
|
-
const
|
|
290
|
+
/** Cap on concurrent outbound requests — shared by verification and update checks. */
|
|
291
|
+
export const NETWORK_CONCURRENCY = 8;
|
|
219
292
|
const verifyCache = new Map();
|
|
220
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Run `task` over `items` with at most `limit` workers in flight. The one
|
|
296
|
+
* fan-out primitive for this module's network work, so nothing accidentally
|
|
297
|
+
* bursts every item at once.
|
|
298
|
+
*/
|
|
299
|
+
export async function mapLimit(items, limit, task) {
|
|
300
|
+
const list = Array.isArray(items) ? items : [];
|
|
301
|
+
let cursor = 0;
|
|
302
|
+
const worker = async () => {
|
|
303
|
+
while (cursor < list.length) {
|
|
304
|
+
const index = cursor++;
|
|
305
|
+
await task(list[index], index);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
|
|
309
|
+
}
|
|
310
|
+
|
|
221
311
|
// package.json is fetched from CDNs, not the REST API, so verification never
|
|
222
312
|
// burns API quota. jsDelivr first (reachable where raw.githubusercontent.com
|
|
223
313
|
// is blocked), raw as fallback; a 404 only means "no manifest" once EVERY
|
|
224
314
|
// reachable source 404s (jsDelivr lags new pushes, so one 404 is not final).
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
315
|
+
// URL templates, `{repo}` substituted with owner/name — overridable through
|
|
316
|
+
// the `rawSources` config for self-hosted reverse proxies.
|
|
317
|
+
export const DEFAULT_RAW_SOURCES = [
|
|
318
|
+
"https://cdn.jsdelivr.net/gh/{repo}@HEAD/package.json",
|
|
319
|
+
"https://raw.githubusercontent.com/{repo}/HEAD/package.json",
|
|
228
320
|
];
|
|
229
321
|
|
|
230
|
-
|
|
322
|
+
/** The configured source templates, or the built-in pair. */
|
|
323
|
+
function rawSourcesOf(sources) {
|
|
324
|
+
const list = (Array.isArray(sources) ? sources : [])
|
|
325
|
+
.map((entry) => String(entry ?? "").trim())
|
|
326
|
+
.filter((entry) => entry.length > 0 && entry.includes("{repo}"));
|
|
327
|
+
return list.length > 0 ? list : DEFAULT_RAW_SOURCES;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function fetchRawPackageJson(repo, signal, sources) {
|
|
231
331
|
let saw404 = false;
|
|
232
332
|
let lastError;
|
|
233
|
-
for (const
|
|
333
|
+
for (const template of rawSourcesOf(sources)) {
|
|
234
334
|
try {
|
|
235
|
-
const response = await fetch(
|
|
335
|
+
const response = await fetch(template.replace("{repo}", repo), {
|
|
236
336
|
headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
|
|
237
337
|
signal,
|
|
238
338
|
});
|
|
@@ -267,33 +367,41 @@ function hostShadowDependencies(pkg) {
|
|
|
267
367
|
|
|
268
368
|
/**
|
|
269
369
|
* Verify repositories as real dsh plugins by their package.json declaration.
|
|
270
|
-
* @param {{repos: string[], signal?: AbortSignal}} options - "owner/name" list.
|
|
370
|
+
* @param {{repos: string[], signal?: AbortSignal, sources?: string[]}} options - "owner/name" list.
|
|
271
371
|
* @returns the {results} map: fullName -> {kind: "bundle"|"client"|"plain"|"no-manifest"|"unknown", name?, version?, hostDeps?}.
|
|
272
372
|
*/
|
|
273
|
-
export async function verifyPlugins({ repos, signal }) {
|
|
373
|
+
export async function verifyPlugins({ repos, signal, sources }) {
|
|
374
|
+
// A GitHub owner never starts with "@", so rejecting that shape here keeps
|
|
375
|
+
// scoped npm names ("@scope/name") out of repository verification no matter
|
|
376
|
+
// which caller passes them in.
|
|
274
377
|
const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
|
|
275
|
-
.filter((repo) => /^[^/\s]
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
378
|
+
.filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
|
|
379
|
+
// "unknown"(网络失败)60s 后过期重试;成功结果进程内永久缓存。
|
|
380
|
+
const pending = wanted.filter((repo) => {
|
|
381
|
+
const cached = verifyCache.get(repo);
|
|
382
|
+
if (cached === undefined) return true;
|
|
383
|
+
return cached.kind === "unknown" && Date.now() - (cached.ts ?? 0) > 60000;
|
|
384
|
+
});
|
|
385
|
+
await mapLimit(pending, NETWORK_CONCURRENCY, async (repo) => {
|
|
386
|
+
try {
|
|
387
|
+
const pkg = await fetchRawPackageJson(repo, signal, sources);
|
|
388
|
+
const kind = pkg === undefined ? "no-manifest"
|
|
389
|
+
: typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
|
|
390
|
+
: pkg.dsh?.client !== undefined ? "client"
|
|
391
|
+
: "plain";
|
|
392
|
+
verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg) });
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (error?.name === "AbortError") throw error;
|
|
395
|
+
verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
|
|
292
396
|
}
|
|
293
|
-
};
|
|
294
|
-
await Promise.all(Array.from({ length: Math.min(VERIFY_CONCURRENCY, pending.length) }, worker));
|
|
397
|
+
});
|
|
295
398
|
const results = {};
|
|
296
|
-
for (const repo of wanted)
|
|
399
|
+
for (const repo of wanted) {
|
|
400
|
+
const cached = verifyCache.get(repo);
|
|
401
|
+
results[repo] = cached === undefined || cached.ts !== undefined
|
|
402
|
+
? { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps }
|
|
403
|
+
: cached;
|
|
404
|
+
}
|
|
297
405
|
return { results };
|
|
298
406
|
}
|
|
299
407
|
|
package/src/index.js
CHANGED
|
@@ -19,8 +19,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
21
|
import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
22
|
-
import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall } from "./github.js";
|
|
23
|
-
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker } from "./installer.js";
|
|
22
|
+
import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
|
|
23
|
+
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker, assertSafeSpec, resolveRegistry } from "./installer.js";
|
|
24
24
|
|
|
25
25
|
export const name = "@1e0zj/dsh-plugin-mall";
|
|
26
26
|
export const inject = ["tools", "jobs", "systemPrompt"];
|
|
@@ -28,10 +28,23 @@ export const inject = ["tools", "jobs", "systemPrompt"];
|
|
|
28
28
|
export const Config = z.object({
|
|
29
29
|
defaultProfile: z.string().default("web"),
|
|
30
30
|
apiBase: z.string().default("https://api.github.com"),
|
|
31
|
+
npmRegistry: z.string().default(""),
|
|
32
|
+
rawSources: z.array(z.string()).default([]),
|
|
31
33
|
perPageMax: z.number().default(30),
|
|
32
34
|
allowRestart: z.boolean().default(true),
|
|
33
35
|
});
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The registry to query for a profile: an explicit `npmRegistry` config wins,
|
|
39
|
+
* otherwise follow whatever pnpm installs from (profile .npmrc → pnpm config →
|
|
40
|
+
* npmjs). Never npmjs-by-assumption: a mirror user would silently lose
|
|
41
|
+
* anti-squatting, update checks, and the host-shadow guard all at once.
|
|
42
|
+
*/
|
|
43
|
+
async function registryFor(profile, npmRegistry) {
|
|
44
|
+
const explicit = String(npmRegistry ?? "").trim();
|
|
45
|
+
return explicit.length > 0 ? explicit.replace(/\/+$/, "") : await resolveRegistry(profile);
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
/** Clip long strings for compact model-facing output. */
|
|
36
49
|
function clip(text, max) {
|
|
37
50
|
const trimmed = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
@@ -150,7 +163,7 @@ function rpcFail(error) {
|
|
|
150
163
|
* @returns the {ok, value|error} envelope.
|
|
151
164
|
*/
|
|
152
165
|
async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
153
|
-
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, allowRestart = true } = config;
|
|
166
|
+
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, allowRestart = true, npmRegistry = "", rawSources = [] } = config;
|
|
154
167
|
switch (endpoint) {
|
|
155
168
|
case "search": {
|
|
156
169
|
const perPage = Math.min(Math.max(Math.trunc(payload?.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
|
|
@@ -166,7 +179,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
166
179
|
return rpcOk(result);
|
|
167
180
|
}
|
|
168
181
|
case "verify": {
|
|
169
|
-
const result = await verifyPlugins({ repos: payload?.repos });
|
|
182
|
+
const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources });
|
|
170
183
|
return rpcOk(result);
|
|
171
184
|
}
|
|
172
185
|
case "updates": {
|
|
@@ -178,14 +191,16 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
178
191
|
} catch (error) {
|
|
179
192
|
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
180
193
|
}
|
|
194
|
+
const registry = await registryFor(profile, npmRegistry);
|
|
181
195
|
const results = {};
|
|
182
|
-
|
|
196
|
+
// 同 verifyPlugins 的 worker 池,而不是对所有依赖一次性扇出。
|
|
197
|
+
await mapLimit(deps, NETWORK_CONCURRENCY, async (dep) => {
|
|
183
198
|
if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
|
|
184
|
-
const info = await npmPackageInfo(dep.name);
|
|
199
|
+
const info = await npmPackageInfo(dep.name, { registry });
|
|
185
200
|
results[dep.name] = info === null
|
|
186
201
|
? { latest: null }
|
|
187
202
|
: { latest: info.latest, hasUpdate: compareVersions(info.latest, dep.version) > 0 };
|
|
188
|
-
})
|
|
203
|
+
});
|
|
189
204
|
return rpcOk(results);
|
|
190
205
|
}
|
|
191
206
|
case "info": {
|
|
@@ -206,16 +221,19 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
206
221
|
let spec;
|
|
207
222
|
try {
|
|
208
223
|
spec = normalizeSpec(payload?.spec);
|
|
224
|
+
assertSafeSpec(spec);
|
|
209
225
|
} catch (error) {
|
|
210
226
|
return rpcFail(error);
|
|
211
227
|
}
|
|
212
228
|
// npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
|
|
213
|
-
// 视为抢注,回退 github: 全仓库 spec
|
|
214
|
-
|
|
229
|
+
// 视为抢注,回退 github: 全仓库 spec。查的 registry 必须是 pnpm 实际
|
|
230
|
+
// 安装用的那个,否则镜像用户这里永远比对不上、次次退化成全仓库克隆。
|
|
231
|
+
const registry = await registryFor(profile, npmRegistry);
|
|
232
|
+
spec = await preferNpmSpec({ spec, registry, sources: rawSources });
|
|
215
233
|
// 宿主依赖硬拦:dependencies 里拖着 @deepseek-ai/* 的包装进 profile
|
|
216
234
|
// 就是双模块实例 + 全工具调度崩溃(宿主无任何护栏,市场是最后防线)。
|
|
217
235
|
try {
|
|
218
|
-
await assertSafeToInstall({ spec });
|
|
236
|
+
await assertSafeToInstall({ spec, registry, sources: rawSources });
|
|
219
237
|
} catch (error) {
|
|
220
238
|
return rpcFail(error);
|
|
221
239
|
}
|
|
@@ -236,6 +254,11 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
236
254
|
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
237
255
|
const packageName = String(payload?.package ?? "").trim();
|
|
238
256
|
if (packageName.length === 0) return rpcFail(new Error("uninstall: package name is required"));
|
|
257
|
+
try {
|
|
258
|
+
assertSafeSpec(packageName);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return rpcFail(error);
|
|
261
|
+
}
|
|
239
262
|
try {
|
|
240
263
|
const profileDir = resolveProfileDir(profile);
|
|
241
264
|
if (!existsSync(join(profileDir, "package.json"))) {
|
|
@@ -274,7 +297,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
274
297
|
: `sleep 2 && ${relaunch}`;
|
|
275
298
|
const child = spawn(launcher, { shell: true, detached: true, stdio: "ignore", cwd: process.cwd(), windowsHide: true });
|
|
276
299
|
child.unref();
|
|
277
|
-
setTimeout(() => process.exit(0),
|
|
300
|
+
setTimeout(() => process.exit(0), 1500);
|
|
278
301
|
return rpcOk({ restarting: true });
|
|
279
302
|
}
|
|
280
303
|
case "jobCancel": {
|
|
@@ -316,7 +339,7 @@ function registerRpcChannel(ctx, config, token) {
|
|
|
316
339
|
}
|
|
317
340
|
|
|
318
341
|
export function apply(ctx, config = {}) {
|
|
319
|
-
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30 } = config;
|
|
342
|
+
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
|
|
320
343
|
const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
|
|
321
344
|
|
|
322
345
|
ctx.systemPrompt.section({
|
|
@@ -425,8 +448,11 @@ export function apply(ctx, config = {}) {
|
|
|
425
448
|
},
|
|
426
449
|
async execute(args, exec) {
|
|
427
450
|
const profile = String(args.profile ?? defaultProfile).trim();
|
|
428
|
-
const
|
|
429
|
-
|
|
451
|
+
const normalized = normalizeSpec(args.spec);
|
|
452
|
+
assertSafeSpec(normalized);
|
|
453
|
+
const registry = await registryFor(profile, npmRegistry);
|
|
454
|
+
const spec = await preferNpmSpec({ spec: normalized, registry, sources: rawSources });
|
|
455
|
+
await assertSafeToInstall({ spec, registry, sources: rawSources });
|
|
430
456
|
let profileDir;
|
|
431
457
|
try {
|
|
432
458
|
profileDir = resolveProfileDir(profile);
|
|
@@ -481,6 +507,7 @@ export function apply(ctx, config = {}) {
|
|
|
481
507
|
const profile = String(args.profile ?? defaultProfile).trim();
|
|
482
508
|
const packageName = String(args.package ?? "").trim();
|
|
483
509
|
if (packageName.length === 0) throw new Error("market_uninstall: package name is required");
|
|
510
|
+
assertSafeSpec(packageName);
|
|
484
511
|
try {
|
|
485
512
|
resolveProfileDir(profile);
|
|
486
513
|
} catch (error) {
|
package/src/installer.js
CHANGED
|
@@ -144,6 +144,91 @@ export function listInstalled(profile) {
|
|
|
144
144
|
return { dir, deps };
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// ── npm registry resolution ─────────────────────────────────────────────────
|
|
148
|
+
//
|
|
149
|
+
// Registry lookups (anti-squatting, update checks, the host-shadow guard) have
|
|
150
|
+
// to hit the same registry pnpm installs from. Hardcoding npmjs while the user
|
|
151
|
+
// is on a mirror breaks all three silently — see the header comment in
|
|
152
|
+
// github.js. Resolution order mirrors pnpm's own: the profile's .npmrc, then
|
|
153
|
+
// `pnpm config get registry` (which folds in the user and global .npmrc
|
|
154
|
+
// chain), then npmjs. Cached per profile for the process lifetime; changing a
|
|
155
|
+
// registry needs a dsh restart anyway, like every other profile setting.
|
|
156
|
+
|
|
157
|
+
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
158
|
+
const registryCache = new Map(); // profile -> Promise<string>
|
|
159
|
+
|
|
160
|
+
/** The `registry=` value from a profile-local .npmrc, if it sets one. */
|
|
161
|
+
function registryFromNpmrc(profileDir) {
|
|
162
|
+
const npmrcPath = join(profileDir, ".npmrc");
|
|
163
|
+
if (!existsSync(npmrcPath)) return undefined;
|
|
164
|
+
try {
|
|
165
|
+
for (const line of readFileSync(npmrcPath, "utf8").split("\n")) {
|
|
166
|
+
const match = /^\s*registry\s*=\s*(\S+)\s*$/.exec(line);
|
|
167
|
+
if (match !== null) return match[1];
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
/* unreadable .npmrc — fall through to pnpm */
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `pnpm config get registry`, or undefined if pnpm is missing, slow, or unset. */
|
|
176
|
+
function registryFromPnpm() {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
let proc;
|
|
179
|
+
try {
|
|
180
|
+
proc = spawn("pnpm", ["config", "get", "registry"], {
|
|
181
|
+
env: process.env,
|
|
182
|
+
shell: process.platform === "win32",
|
|
183
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
184
|
+
windowsHide: true,
|
|
185
|
+
});
|
|
186
|
+
} catch {
|
|
187
|
+
resolve(undefined);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
let out = "";
|
|
191
|
+
// 一次安装不该被一个探测子进程拖住:5s 没结果就当没有,走兜底。
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
proc.kill();
|
|
194
|
+
resolve(undefined);
|
|
195
|
+
}, 5000);
|
|
196
|
+
proc.stdout?.on("data", (data) => { out += data.toString(); });
|
|
197
|
+
proc.on("error", () => { clearTimeout(timer); resolve(undefined); });
|
|
198
|
+
proc.on("close", (code) => {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
const value = out.trim();
|
|
201
|
+
// pnpm prints "undefined" for an unset key — only take a real URL.
|
|
202
|
+
resolve(code === 0 && /^https?:\/\//i.test(value) ? value : undefined);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The registry pnpm installs from for this profile.
|
|
209
|
+
* @param profile - profile name.
|
|
210
|
+
* @returns a promise for the registry base URL, without a trailing slash.
|
|
211
|
+
*/
|
|
212
|
+
export function resolveRegistry(profile) {
|
|
213
|
+
const key = String(profile ?? "");
|
|
214
|
+
const cached = registryCache.get(key);
|
|
215
|
+
if (cached !== undefined) return cached;
|
|
216
|
+
const pending = (async () => {
|
|
217
|
+
let dir;
|
|
218
|
+
try {
|
|
219
|
+
dir = resolveProfileDir(key);
|
|
220
|
+
} catch {
|
|
221
|
+
dir = undefined; // invalid profile name — the caller reports it, we just fall back
|
|
222
|
+
}
|
|
223
|
+
const fromNpmrc = dir === undefined ? undefined : registryFromNpmrc(dir);
|
|
224
|
+
if (fromNpmrc !== undefined) return fromNpmrc.replace(/\/+$/, "");
|
|
225
|
+
const fromPnpm = await registryFromPnpm();
|
|
226
|
+
return (fromPnpm ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
|
|
227
|
+
})();
|
|
228
|
+
registryCache.set(key, pending);
|
|
229
|
+
return pending;
|
|
230
|
+
}
|
|
231
|
+
|
|
147
232
|
// ── client-plugin row registration ──────────────────────────────────────────
|
|
148
233
|
|
|
149
234
|
/** Profile patch file name (the user's own layer, applied after bundle layers). */
|
|
@@ -361,6 +446,28 @@ export function createJobTracker() {
|
|
|
361
446
|
};
|
|
362
447
|
}
|
|
363
448
|
|
|
449
|
+
// ── spec shape guard ────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
// Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
|
|
452
|
+
// 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
|
|
453
|
+
// shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。
|
|
454
|
+
const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
|
|
455
|
+
|
|
456
|
+
/** Reject install/remove specs carrying shell metacharacters. */
|
|
457
|
+
export function assertSafeSpec(spec) {
|
|
458
|
+
const value = String(spec ?? "");
|
|
459
|
+
if (UNSAFE_SPEC_RE.test(value)) {
|
|
460
|
+
throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(value)}`);
|
|
461
|
+
}
|
|
462
|
+
// Windows 下 pnpm 走 shell,而 Node 只是把参数用空格 join 后交给 cmd,
|
|
463
|
+
// 不逐参加引号——带空格的本地路径会被拆成两个参数,pnpm 报一个和路径
|
|
464
|
+
// 毫无关系的错。用户也没法自己加引号绕过:`"` 就在上面的黑名单里。
|
|
465
|
+
// 与其让它以看不懂的方式失败,不如在这里说清楚。
|
|
466
|
+
if (process.platform === "win32" && /^(?:file:|link:)/i.test(value) && /\s/.test(value)) {
|
|
467
|
+
throw new Error(`local path specs cannot contain spaces on Windows — pnpm is spawned through cmd, which would split the path into two arguments: ${JSON.stringify(value)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
364
471
|
// ── pnpm self-heal (corepack) ───────────────────────────────────────────────
|
|
365
472
|
|
|
366
473
|
/**
|
|
@@ -565,10 +672,12 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
|
|
|
565
672
|
proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
|
|
566
673
|
}).then(async (outcome) => {
|
|
567
674
|
if (outcome.spawnError !== undefined) {
|
|
568
|
-
// pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer
|
|
675
|
+
// pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑,
|
|
676
|
+
// 这里必须返回它的 done outcome——返回 producer 本体会让 tracker
|
|
677
|
+
// 把成功任务记成 failed)。
|
|
569
678
|
if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
|
|
570
|
-
const healed = await enablePnpmViaCorepack(
|
|
571
|
-
if (healed) return runRemove({ profile, packageName }, true);
|
|
679
|
+
const healed = await enablePnpmViaCorepack(push);
|
|
680
|
+
if (healed) return await runRemove({ profile, packageName }, true).done;
|
|
572
681
|
}
|
|
573
682
|
const hint = outcome.spawnError.code === "ENOENT"
|
|
574
683
|
? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
|