dsh-plugin 1.6.4 → 1.6.6
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.en.md +11 -11
- package/README.md +11 -11
- package/client/client.js +450 -405
- package/client/client.js.map +1 -1
- package/lib/http/catalog.js +132 -0
- package/lib/http/diagnostics.js +201 -0
- package/lib/http/install.js +399 -0
- package/lib/http/logs.js +164 -0
- package/lib/http/routes.js +19 -1261
- package/lib/http/settings.js +77 -0
- package/lib/http/shared.js +169 -0
- package/lib/http/task.js +208 -0
- package/lib/services/install/install.js +1 -1
- package/lib/services/install/npm-resolve.js +44 -15
- package/lib/services/install/task-queue.js +45 -16
- package/lib/services/probe.js +18 -1
- package/lib/types/http/catalog.d.ts +10 -0
- package/lib/types/http/diagnostics.d.ts +4 -0
- package/lib/types/http/install.d.ts +11 -0
- package/lib/types/http/logs.d.ts +2 -0
- package/lib/types/http/routes.d.ts +15 -20
- package/lib/types/http/settings.d.ts +2 -0
- package/lib/types/http/shared.d.ts +44 -0
- package/lib/types/http/task.d.ts +3 -0
- package/lib/types/services/install/install.d.ts +1 -1
- package/lib/types/services/install/npm-resolve.d.ts +3 -2
- package/lib/types/services/probe.d.ts +8 -0
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +7 -2
- package/src/client/components/modals/HubUpdateModal.tsx +19 -10
- package/src/client/components/modals/NotificationsModal.tsx +34 -18
- package/src/client/components/modals/modals.tsx +38 -28
- package/src/client/components/views/InstalledView.tsx +23 -11
- package/src/client/hooks/useCatalog.ts +13 -9
- package/src/client/locales.ts +28 -8
- package/src/client/logic/failures.ts +36 -157
- package/src/client/logic/urls.ts +72 -1
- package/src/client/types.ts +11 -0
- package/src/server/http/catalog.ts +143 -0
- package/src/server/http/diagnostics.ts +193 -0
- package/src/server/http/install.ts +391 -0
- package/src/server/http/logs.ts +149 -0
- package/src/server/http/routes.ts +22 -1220
- package/src/server/http/settings.ts +73 -0
- package/src/server/http/shared.ts +191 -0
- package/src/server/http/task.ts +194 -0
- package/src/server/services/install/install-types.ts +9 -1
- package/src/server/services/install/install.ts +11 -1
- package/src/server/services/install/npm-resolve.ts +41 -13
- package/src/server/services/install/task-queue.ts +45 -16
- package/src/server/services/probe.ts +19 -1
package/lib/http/routes.js
CHANGED
|
@@ -1,335 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* (see services/install/install.ts) and reports the captured result back.
|
|
10
|
-
*/
|
|
11
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
-
import { homedir, release } from 'node:os';
|
|
13
|
-
import { dirname, join, resolve } from 'node:path';
|
|
14
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
15
|
-
import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/probe.js';
|
|
16
|
-
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install/install.js';
|
|
17
|
-
import { githubReleaseTarget } from '../services/install/release-target.js';
|
|
18
|
-
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.js';
|
|
19
|
-
import { isDshNpmPackageForRepo, resolveNpmPackage } from '../services/install/npm-resolve.js';
|
|
20
|
-
import { preflightTarget } from '../services/install/preflight.js';
|
|
21
|
-
import { isDshPlugin, isEntryLoaded } from '../services/loader.js';
|
|
22
|
-
import { loadSettings, saveSettings, resetSettings } from '../services/settings.js';
|
|
23
|
-
import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.js';
|
|
24
|
-
/**
|
|
25
|
-
* 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
|
|
26
|
-
* 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
27
|
-
* 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
28
|
-
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
29
|
-
* 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
|
|
30
|
-
*/
|
|
31
|
-
const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
|
|
32
|
-
const F = require('node:fs')
|
|
33
|
-
const P = require('node:path')
|
|
34
|
-
const O = require('node:os')
|
|
35
|
-
const win = process.platform === 'win32'
|
|
36
|
-
const port = Number(process.argv[1])
|
|
37
|
-
|
|
38
|
-
function findPids() {
|
|
39
|
-
if (win) {
|
|
40
|
-
const out = C.spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
|
|
41
|
-
const found = []
|
|
42
|
-
for (const line of String(out.stdout || '').split(/\r?\n/)) {
|
|
43
|
-
if (!/LISTENING/i.test(line)) continue
|
|
44
|
-
const parts = line.trim().split(/\s+/)
|
|
45
|
-
const addr = parts[1] || ''
|
|
46
|
-
if (!addr.endsWith(':' + port)) continue
|
|
47
|
-
const pid = parts[parts.length - 1]
|
|
48
|
-
if (pid && /\d+/.test(pid)) found.push(pid)
|
|
49
|
-
}
|
|
50
|
-
return found
|
|
51
|
-
}
|
|
52
|
-
const out = C.spawnSync('lsof', ['-ti', 'tcp:' + port, '-sTCP:LISTEN'], { encoding: 'utf8' })
|
|
53
|
-
return String(out.stdout || '').split(/\s+/).filter(Boolean)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const targets = findPids()
|
|
57
|
-
// 一个监听进程都没找到 = 端口探测本身就失败了(netstat 输出格式 / 本地化差异),
|
|
58
|
-
// 旧宿主仍占着端口。此时再拉一个 dsh web 只会在日志里以 EADDRINUSE 静默退出,
|
|
59
|
-
// 用户却看到「重启完成」——不制造这种假象,留一行可诊断的记录后退出。
|
|
60
|
-
if (targets.length === 0) {
|
|
61
|
-
logLine('restart aborted: no listener found on port ' + port)
|
|
62
|
-
process.exit(0)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
for (const pid of targets) {
|
|
66
|
-
try {
|
|
67
|
-
if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
|
|
68
|
-
else process.kill(Number(pid), 'SIGTERM')
|
|
69
|
-
} catch {}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
let tries = 0
|
|
73
|
-
;(function waitForStop() {
|
|
74
|
-
if (findPids().length === 0 || ++tries > 20) return startHost()
|
|
75
|
-
setTimeout(waitForStop, 250)
|
|
76
|
-
})()
|
|
77
|
-
|
|
78
|
-
function logLine(msg) {
|
|
79
|
-
try {
|
|
80
|
-
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
81
|
-
F.mkdirSync(dir, { recursive: true })
|
|
82
|
-
F.appendFileSync(P.join(dir, 'dsh-web-' + port + '.log'), '[' + new Date().toISOString() + '] ' + msg + '\n')
|
|
83
|
-
} catch {}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function startHost() {
|
|
87
|
-
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
88
|
-
F.mkdirSync(dir, { recursive: true })
|
|
89
|
-
const fd = F.openSync(P.join(dir, 'dsh-web-' + port + '.log'), 'a')
|
|
90
|
-
const child = C.spawn('dsh', ['web', '--port', String(port)], {
|
|
91
|
-
detached: true,
|
|
92
|
-
shell: win,
|
|
93
|
-
stdio: ['ignore', fd, fd],
|
|
94
|
-
})
|
|
95
|
-
child.unref()
|
|
96
|
-
}`;
|
|
97
|
-
/**
|
|
98
|
-
* 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
|
|
99
|
-
* 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
|
|
100
|
-
*/
|
|
101
|
-
export function isDesktopHost() {
|
|
102
|
-
return process.env.ELECTRON_RUN_AS_NODE === '1';
|
|
103
|
-
}
|
|
104
|
-
const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
105
|
-
const BODY_LIMIT_BYTES = 4 * 1024;
|
|
106
|
-
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
107
|
-
/** 目录/统计数据代理的本地缓存时长:1 小时内重复打开插件市场直接读盘,
|
|
108
|
-
* 不重复走 curl 拉远程 —— 重启宿主后首次打开同样秒开(缓存跨重启存活)。 */
|
|
109
|
-
const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
110
|
-
function catalogCacheFile(profile, key) {
|
|
111
|
-
return join(profileDirectory(profile), 'cache', `catalog-${key}.json`);
|
|
112
|
-
}
|
|
113
|
-
/** 读缓存:文件存在、JSON 合法、未超过 maxAgeMs → 返回原始响应字符串;否则 null(视为 miss)。
|
|
114
|
-
* maxAgeMs 传 Infinity 即「任意年龄的缓存」,供实时拉取失败时兜底使用。 */
|
|
115
|
-
function readCatalogCache(file, maxAgeMs) {
|
|
116
|
-
try {
|
|
117
|
-
const entry = JSON.parse(readFileSync(file, 'utf8'));
|
|
118
|
-
if (entry && typeof entry.at === 'number' && typeof entry.body === 'string'
|
|
119
|
-
&& Date.now() - entry.at < maxAgeMs) {
|
|
120
|
-
return entry.body;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
// 文件缺失 / 解析失败 / 字段不完整 → miss,重新拉取覆盖写盘
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
/** 写缓存:目录不存在则创建;写失败静默忽略(缓存只是加速,不影响功能)。 */
|
|
129
|
-
function writeCatalogCache(file, body) {
|
|
130
|
-
try {
|
|
131
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
132
|
-
writeFileSync(file, JSON.stringify({ at: Date.now(), body }));
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
// 磁盘只读/无权限等极端情况:忽略,继续走直连
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
function profileDirectory(profile) {
|
|
139
|
-
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile);
|
|
140
|
-
}
|
|
141
|
-
/** Read non-official dependencies installed into one profile. */
|
|
142
|
-
export function readInstalled(profile) {
|
|
143
|
-
try {
|
|
144
|
-
const manifest = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
|
|
145
|
-
return Object.fromEntries(Object.entries(manifest.dependencies ?? {}).filter(([name]) => !name.startsWith('@deepseek-ai/')));
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
return {};
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
/** 读已装依赖实际落在磁盘上的仓库身份(`owner/repo`):解析 node_modules/<name>/package.json
|
|
152
|
-
* 的 repository 字段。已装 spec 可能是纯版本号(丢了仓库身份),但真实包元数据仍指向来源仓库,
|
|
153
|
-
* 据此可区分「同名依赖其实是同一仓库的残留(应转 update)」还是「跨仓库包名冲突(应报错)」。
|
|
154
|
-
* 读不到 / 解析不出返回 null(交给调用方按冲突处理)。 */
|
|
155
|
-
function installedRepoOf(profile, name) {
|
|
156
|
-
try {
|
|
157
|
-
const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'node_modules', name, 'package.json'), 'utf8'));
|
|
158
|
-
const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
|
|
159
|
-
return repo ? githubRepoOf(repo) : null;
|
|
160
|
-
}
|
|
161
|
-
catch {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
/** 宿主 dsh CLI 版本:优先直接跑 `dsh --version`(与 pnpm/npm/git 同款同步探测,1500ms 超时兜底,
|
|
166
|
-
* 绝不挂起);拿不到再退回从入口 `process.argv[1]` 向上找 package.json(dsh 不在 PATH 时兜底)。 */
|
|
167
|
-
function hostDshVersion() {
|
|
168
|
-
const viaCli = toolVersion('dsh', ['--version']);
|
|
169
|
-
if (viaCli)
|
|
170
|
-
return viaCli;
|
|
171
|
-
const entry = process.argv[1];
|
|
172
|
-
if (entry === undefined)
|
|
173
|
-
return null;
|
|
174
|
-
let dir = dirname(resolve(entry));
|
|
175
|
-
for (let depth = 0; depth < 4; depth++) {
|
|
176
|
-
try {
|
|
177
|
-
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
178
|
-
const name = manifest.name ?? '';
|
|
179
|
-
if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
|
|
180
|
-
return manifest.version;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
/* not a package root — keep walking up */
|
|
185
|
-
}
|
|
186
|
-
const parent = dirname(dir);
|
|
187
|
-
if (parent === dir)
|
|
188
|
-
break;
|
|
189
|
-
dir = parent;
|
|
190
|
-
}
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
/** 探测命令行工具的版本号(pnpm --version / npm --version / git --version):
|
|
194
|
-
* 未安装 / 不在 PATH / 超时一律返回 null —— issue 环境快照如实写 unknown,绝不挂起。
|
|
195
|
-
* (同步探测仅用于启动期/请求期的低频率 /env 快照,安装通道不经过这里。) */
|
|
196
|
-
function toolVersion(cmd, args) {
|
|
197
|
-
try {
|
|
198
|
-
const r = spawnSync(cmd, args, { timeout: 1500, encoding: 'utf8' });
|
|
199
|
-
if (r.error || r.status !== 0)
|
|
200
|
-
return null;
|
|
201
|
-
const v = (r.stdout ?? '').trim().split(/\r?\n/)[0];
|
|
202
|
-
return v || null;
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
return null;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
/** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
|
|
209
|
-
function hostEnv(profile) {
|
|
210
|
-
return {
|
|
211
|
-
dshVersion: hostDshVersion(),
|
|
212
|
-
nodeVersion: process.version,
|
|
213
|
-
pnpmVersion: toolVersion('pnpm', ['--version']),
|
|
214
|
-
npmVersion: toolVersion('npm', ['--version']),
|
|
215
|
-
gitVersion: toolVersion('git', ['--version']),
|
|
216
|
-
platform: process.platform,
|
|
217
|
-
arch: process.arch,
|
|
218
|
-
release: release(),
|
|
219
|
-
profile,
|
|
220
|
-
dshHome: process.env.DSH_HOME ?? join(homedir(), '.dsh'),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
/** 安装/卸载 CLI 的子进程环境:透传宿主环境,并按设置注入 HTTP(S) 代理。
|
|
224
|
-
* 不注入 npm registry —— 安装完全沿用用户本机 npm 配置(~/.npmrc / 全局配置)。
|
|
225
|
-
* 代理优先级:设置里的代理 → 系统代理(macOS scutil / Windows 注册表)→ 宿主 env 原有值。
|
|
226
|
-
* 使安装通道与诊断使用相同的代理来源。 */
|
|
227
|
-
function mutationEnv(settings) {
|
|
228
|
-
const env = { ...process.env, CI: 'true' };
|
|
229
|
-
const proxy = settings.proxy !== '' ? settings.proxy : (systemProxy() ?? '');
|
|
230
|
-
if (proxy !== '') {
|
|
231
|
-
env.HTTP_PROXY = proxy;
|
|
232
|
-
env.HTTPS_PROXY = proxy;
|
|
233
|
-
env.http_proxy = proxy;
|
|
234
|
-
env.https_proxy = proxy;
|
|
235
|
-
}
|
|
236
|
-
return env;
|
|
237
|
-
}
|
|
238
|
-
/** Match a GitHub install target against the installed table; returns the package name when already installed. */
|
|
239
|
-
function installedByRepo(installed, target) {
|
|
240
|
-
const repo = githubRepoOf(target);
|
|
241
|
-
if (repo === null)
|
|
242
|
-
return null;
|
|
243
|
-
const needle = repo.toLowerCase();
|
|
244
|
-
for (const [name, spec] of Object.entries(installed)) {
|
|
245
|
-
if (githubRepoOf(spec)?.toLowerCase() === needle)
|
|
246
|
-
return name;
|
|
247
|
-
}
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
function sendJson(response, status, value) {
|
|
251
|
-
response.writeHead(status, {
|
|
252
|
-
'cache-control': 'no-store',
|
|
253
|
-
'content-type': 'application/json; charset=utf-8',
|
|
254
|
-
});
|
|
255
|
-
response.end(JSON.stringify(value));
|
|
256
|
-
}
|
|
257
|
-
/** 本机回环主机名:`localhost` / `127.0.0.1` / `[::1]` 指向同一台机器的同一个端口,
|
|
258
|
-
* 字符串不同但同源,必须按同一来源比较(dsh-plugin-hub#72)。 */
|
|
259
|
-
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
260
|
-
/** 把 `host[:port]` 形式的 authority 归一化成 `loopback:端口` —— 三个回环别名
|
|
261
|
-
* 映射成同一个身份,端口缺省按 http 的 80 补齐;非回环主机名、以及解析不出来的值
|
|
262
|
-
* 都返回 null。 */
|
|
263
|
-
function loopbackAuthority(authority) {
|
|
264
|
-
try {
|
|
265
|
-
const parsed = new URL(`http://${authority}`);
|
|
266
|
-
if (!LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase()))
|
|
267
|
-
return null;
|
|
268
|
-
return `loopback:${parsed.port === '' ? '80' : parsed.port}`;
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
/** POST mutations are only accepted from the local web server origin or the desktop host page.
|
|
275
|
-
* Exported for tests (tests/routes.test.ts) — the origin check is the whole CSRF defence. */
|
|
276
|
-
export function isSameOrigin(request) {
|
|
277
|
-
// 服务只绑定本机回环地址:Host 不是回环(局域网 IP、被改写的域名)一律拒绝,
|
|
278
|
-
// 顺带挡掉 DNS rebinding(Host 是攻击者域名,即便解析到 127.0.0.1)。
|
|
279
|
-
const host = request.headers.host;
|
|
280
|
-
const hostKey = host === undefined ? null : loopbackAuthority(host);
|
|
281
|
-
if (hostKey === null)
|
|
282
|
-
return false;
|
|
283
|
-
const origin = request.headers.origin;
|
|
284
|
-
// 桌面端宿主把 `/dsh-plugin-hub/*` 转发给本地 http server 时可能不带 Origin 头,
|
|
285
|
-
// 此时所有 POST 都会被判成 untrusted origin(dsh-plugin-hub#72)。浏览器对 POST 请求
|
|
286
|
-
// 必定携带 Origin(含同源 POST 与跨站表单),能省略该头的只有本机原生客户端,
|
|
287
|
-
// 而它们本来就能伪造任意 Origin,因此放行不扩大 CSRF 面。
|
|
288
|
-
if (origin === undefined || origin === '')
|
|
289
|
-
return true;
|
|
290
|
-
try {
|
|
291
|
-
const url = new URL(origin);
|
|
292
|
-
// 桌面端(Electron 宿主)的页面跑在宿主自定义协议下,origin 形如 `dsh-app://app`,
|
|
293
|
-
// 与 Host(如 localhost:3081)必然不等,因此必须显式放行该协议,否则桌面端全部 POST
|
|
294
|
-
// 都会被判成 untrusted origin。该协议只有宿主自身能产生,外部网页无法把 Origin
|
|
295
|
-
// 伪造成 `dsh-app://…`,放行不扩大 CSRF 面。
|
|
296
|
-
if (url.protocol === 'dsh-app:')
|
|
297
|
-
return true;
|
|
298
|
-
// 按归一化后的「主机名:端口」比较:`localhost` / `127.0.0.1` / `[::1]` 不再互相拒绝,
|
|
299
|
-
// 端口仍然必须一致(另一个本机进程不能被当成同一来源)。
|
|
300
|
-
return loopbackAuthority(url.host) === hostKey;
|
|
301
|
-
}
|
|
302
|
-
catch {
|
|
303
|
-
return false;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
function requireMethod(request, response, method) {
|
|
307
|
-
if (request.method === method)
|
|
308
|
-
return true;
|
|
309
|
-
response.writeHead(405, { allow: method });
|
|
310
|
-
response.end();
|
|
311
|
-
return false;
|
|
312
|
-
}
|
|
313
|
-
function requireTrustedPost(request, response) {
|
|
314
|
-
if (!requireMethod(request, response, 'POST'))
|
|
315
|
-
return false;
|
|
316
|
-
if (isSameOrigin(request))
|
|
317
|
-
return true;
|
|
318
|
-
sendJson(response, 403, { error: 'untrusted origin' });
|
|
319
|
-
return false;
|
|
320
|
-
}
|
|
321
|
-
async function readJsonBody(request) {
|
|
322
|
-
const chunks = [];
|
|
323
|
-
let size = 0;
|
|
324
|
-
for await (const chunk of request) {
|
|
325
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
326
|
-
size += buffer.length;
|
|
327
|
-
if (size > BODY_LIMIT_BYTES)
|
|
328
|
-
throw new Error('request body too large');
|
|
329
|
-
chunks.push(buffer);
|
|
330
|
-
}
|
|
331
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
332
|
-
}
|
|
1
|
+
import { appendLog } from '../services/log.js';
|
|
2
|
+
import { PROFILE_RE } from './shared.js';
|
|
3
|
+
import { registerCatalogRoutes } from './catalog.js';
|
|
4
|
+
import { registerInstallRoutes } from './install.js';
|
|
5
|
+
import { registerSettingsRoutes } from './settings.js';
|
|
6
|
+
import { registerLogsRoutes } from './logs.js';
|
|
7
|
+
import { registerDiagnosticsRoutes } from './diagnostics.js';
|
|
8
|
+
import { registerTaskRoutes } from './task.js';
|
|
333
9
|
/**
|
|
334
10
|
* Register the Plugin Hub API on the host web server and return a disposer.
|
|
335
11
|
* @param webServer - DSH web server service.
|
|
@@ -349,939 +25,21 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
349
25
|
event: 'system.start',
|
|
350
26
|
message: `Plugin Hub 已启动(profile=${profile})`,
|
|
351
27
|
});
|
|
352
|
-
const
|
|
353
|
-
webServer
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
sendJson(response, 200, { entries: dumpLoaderEntries(loader) });
|
|
360
|
-
},
|
|
361
|
-
}),
|
|
362
|
-
webServer.register({
|
|
363
|
-
kind: 'exact',
|
|
364
|
-
path: '/dsh-plugin-hub/settings',
|
|
365
|
-
// GET 读 / POST 写:webServer.register 按 (kind, path) 唯一,多方法必须合并到同一个 handler 按 method 分派
|
|
366
|
-
handler: async (request, response) => {
|
|
367
|
-
if (request.method === 'GET') {
|
|
368
|
-
sendJson(response, 200, loadSettings(profile));
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
if (!requireTrustedPost(request, response))
|
|
372
|
-
return;
|
|
373
|
-
try {
|
|
374
|
-
const body = await readJsonBody(request);
|
|
375
|
-
// 白名单字段:只接受已知设置项,防客户端把任意键写进文件
|
|
376
|
-
const patch = {};
|
|
377
|
-
const source = typeof body === 'object' && body !== null ? body : {};
|
|
378
|
-
for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'enableGitInstall', 'enableDshInstall', 'npmRegistry', 'proxy', 'logPath']) {
|
|
379
|
-
if (source[key] !== undefined)
|
|
380
|
-
patch[key] = source[key];
|
|
381
|
-
}
|
|
382
|
-
// 日志位置:非空时预建目录验证可写,失败直接 400 拦截(默认位置永远合法,空串 = 回默认)
|
|
383
|
-
if (typeof patch.logPath === 'string' && patch.logPath.trim() !== '') {
|
|
384
|
-
try {
|
|
385
|
-
mkdirSync(dirname(customLogFile(patch.logPath)), { recursive: true });
|
|
386
|
-
}
|
|
387
|
-
catch {
|
|
388
|
-
sendJson(response, 400, { error: 'log path is not writable' });
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
sendJson(response, 200, saveSettings(profile, patch));
|
|
393
|
-
// 系统日志:设置项变更
|
|
394
|
-
appendLog(profile, {
|
|
395
|
-
at: Date.now(),
|
|
396
|
-
level: 'info',
|
|
397
|
-
category: 'settings',
|
|
398
|
-
event: 'settings.update',
|
|
399
|
-
message: `更新设置:${Object.keys(patch).join(', ')}`,
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
catch (error) {
|
|
403
|
-
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
404
|
-
}
|
|
405
|
-
},
|
|
406
|
-
}),
|
|
407
|
-
webServer.register({
|
|
408
|
-
kind: 'exact',
|
|
409
|
-
path: '/dsh-plugin-hub/settings/reset',
|
|
410
|
-
handler: async (request, response) => {
|
|
411
|
-
if (!requireTrustedPost(request, response))
|
|
412
|
-
return;
|
|
413
|
-
sendJson(response, 200, resetSettings(profile));
|
|
414
|
-
appendLog(profile, {
|
|
415
|
-
at: Date.now(),
|
|
416
|
-
level: 'warn',
|
|
417
|
-
category: 'settings',
|
|
418
|
-
event: 'settings.reset',
|
|
419
|
-
message: '恢复全部默认设置',
|
|
420
|
-
});
|
|
421
|
-
},
|
|
422
|
-
}),
|
|
423
|
-
webServer.register({
|
|
424
|
-
kind: 'exact',
|
|
425
|
-
path: '/dsh-plugin-hub/logs',
|
|
426
|
-
handler: (request, response) => {
|
|
427
|
-
if (!requireMethod(request, response, 'GET'))
|
|
428
|
-
return;
|
|
429
|
-
// 日志查看器分页接口:?offset=&limit=&category=&level=&query=,返回过滤后的倒序分页
|
|
430
|
-
try {
|
|
431
|
-
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
432
|
-
const num = (name) => {
|
|
433
|
-
const v = url.searchParams.get(name);
|
|
434
|
-
if (v === null || v === '')
|
|
435
|
-
return undefined;
|
|
436
|
-
const n = Number(v);
|
|
437
|
-
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined;
|
|
438
|
-
};
|
|
439
|
-
const limit = Math.min(num('limit') ?? 200, 500);
|
|
440
|
-
const category = url.searchParams.get('category') ?? 'all';
|
|
441
|
-
const level = url.searchParams.get('level') ?? 'all';
|
|
442
|
-
const query = url.searchParams.get('query') ?? '';
|
|
443
|
-
const result = readLog(profile, {
|
|
444
|
-
offset: num('offset') ?? 0,
|
|
445
|
-
limit,
|
|
446
|
-
category: category,
|
|
447
|
-
level: level,
|
|
448
|
-
query,
|
|
449
|
-
});
|
|
450
|
-
sendJson(response, 200, {
|
|
451
|
-
entries: result.entries,
|
|
452
|
-
total: result.total,
|
|
453
|
-
offset: num('offset') ?? 0,
|
|
454
|
-
limit,
|
|
455
|
-
hasMore: result.total > (num('offset') ?? 0) + result.entries.length,
|
|
456
|
-
path: logFilePath(profile),
|
|
457
|
-
defaultPath: defaultLogFilePath(profile),
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
catch {
|
|
461
|
-
sendJson(response, 400, { error: 'invalid log query' });
|
|
462
|
-
}
|
|
463
|
-
},
|
|
464
|
-
}),
|
|
465
|
-
webServer.register({
|
|
466
|
-
kind: 'exact',
|
|
467
|
-
path: '/dsh-plugin-hub/clear-log',
|
|
468
|
-
handler: (request, response) => {
|
|
469
|
-
if (!requireTrustedPost(request, response))
|
|
470
|
-
return;
|
|
471
|
-
// 清空所有日志:截断当前日志文件为空
|
|
472
|
-
try {
|
|
473
|
-
const ok = clearLog(profile);
|
|
474
|
-
if (!ok) {
|
|
475
|
-
sendJson(response, 500, { error: 'failed to clear log' });
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
appendLog(profile, { at: Date.now(), level: 'info', category: 'system', event: 'system.clear', message: '日志已由用户清空' });
|
|
479
|
-
sendJson(response, 200, { ok: true });
|
|
480
|
-
}
|
|
481
|
-
catch {
|
|
482
|
-
sendJson(response, 500, { error: 'failed to clear log' });
|
|
483
|
-
}
|
|
484
|
-
},
|
|
485
|
-
}),
|
|
486
|
-
webServer.register({
|
|
487
|
-
kind: 'exact',
|
|
488
|
-
path: '/dsh-plugin-hub/open-log',
|
|
489
|
-
handler: (request, response) => {
|
|
490
|
-
if (!requireTrustedPost(request, response))
|
|
491
|
-
return;
|
|
492
|
-
// 在系统文件管理器里定位日志文件(macOS Finder -R / Windows explorer /select, / Linux xdg-open)
|
|
493
|
-
try {
|
|
494
|
-
const file = logFilePath(profile);
|
|
495
|
-
const bin = process.platform === 'darwin' ? 'open'
|
|
496
|
-
: process.platform === 'win32' ? 'explorer'
|
|
497
|
-
: 'xdg-open';
|
|
498
|
-
const args = process.platform === 'darwin' ? ['-R', file]
|
|
499
|
-
: process.platform === 'win32' ? ['/select,', file]
|
|
500
|
-
: [file];
|
|
501
|
-
const child = spawn(bin, args, { stdio: 'ignore', detached: true });
|
|
502
|
-
child.once('error', () => sendJson(response, 500, { error: 'failed to open log file' }));
|
|
503
|
-
child.once('spawn', () => sendJson(response, 200, { ok: true }));
|
|
504
|
-
}
|
|
505
|
-
catch {
|
|
506
|
-
sendJson(response, 500, { error: 'failed to open log file' });
|
|
507
|
-
}
|
|
508
|
-
},
|
|
509
|
-
}),
|
|
510
|
-
// 系统目录选择器:弹原生文件夹对话框让用户挑日志存放目录(macOS osascript /
|
|
511
|
-
// Windows FolderBrowserDialog / Linux zenity),选中返回绝对路径,取消返回 cancelled。
|
|
512
|
-
// 与 open-log 不同,这里必须等对话框关闭才能拿结果,所以阻塞收集 stdout 再应答。
|
|
513
|
-
// 对话框默认定位到当前日志文件所在目录 —— 每台机器/平台的默认位置都不同,
|
|
514
|
-
// 这里动态取 logFilePath() 的目录,用户打开就在「它原来的位置」附近。
|
|
515
|
-
webServer.register({
|
|
516
|
-
kind: 'exact',
|
|
517
|
-
path: '/dsh-plugin-hub/choose-log-dir',
|
|
518
|
-
handler: (request, response) => {
|
|
519
|
-
if (!requireTrustedPost(request, response))
|
|
520
|
-
return;
|
|
521
|
-
try {
|
|
522
|
-
const title = 'DSH Plugin Hub — choose log folder';
|
|
523
|
-
const startDir = dirname(logFilePath(profile));
|
|
524
|
-
let bin;
|
|
525
|
-
let args;
|
|
526
|
-
if (process.platform === 'darwin') {
|
|
527
|
-
bin = 'osascript';
|
|
528
|
-
args = ['-e', `POSIX path of (choose folder with prompt "${title}" default location (POSIX file "${startDir}"))`];
|
|
529
|
-
}
|
|
530
|
-
else if (process.platform === 'win32') {
|
|
531
|
-
bin = 'powershell.exe';
|
|
532
|
-
args = ['-NoProfile', '-STA', '-Command', [
|
|
533
|
-
'Add-Type -AssemblyName System.Windows.Forms',
|
|
534
|
-
'$f = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
535
|
-
`$f.Description = '${title}'`,
|
|
536
|
-
// 初始定位到当前日志目录(单引号字符串内反斜杠不转义,Windows 路径原样传入)
|
|
537
|
-
`$f.SelectedPath = '${startDir}'`,
|
|
538
|
-
'if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $f.SelectedPath } else { "" }',
|
|
539
|
-
].join('; ')];
|
|
540
|
-
}
|
|
541
|
-
else {
|
|
542
|
-
bin = 'zenity';
|
|
543
|
-
args = ['--file-selection', '--directory', `--title=${title}`, `--filename=${startDir}`];
|
|
544
|
-
}
|
|
545
|
-
const child = spawn(bin, args);
|
|
546
|
-
let out = '';
|
|
547
|
-
child.stdout.on('data', (d) => { out += String(d); });
|
|
548
|
-
// spawn 失败(如 Linux 无 zenity)会先触发 error 再触发 close(code=-2):
|
|
549
|
-
// 只允许第一个事件应答,否则第二次 sendJson 对已结束的响应再 writeHead,
|
|
550
|
-
// 抛 ERR_HTTP_HEADERS_SENT 成为宿主进程内的未捕获异常。
|
|
551
|
-
let done = false;
|
|
552
|
-
const finish = (reply) => { if (!done) {
|
|
553
|
-
done = true;
|
|
554
|
-
reply();
|
|
555
|
-
} };
|
|
556
|
-
child.on('error', () => finish(() => sendJson(response, 500, { error: 'directory picker unavailable' })));
|
|
557
|
-
child.on('close', (code) => finish(() => {
|
|
558
|
-
// macOS osascript 输出带引号(如 "/Users/x"),统一剥掉首尾空白与引号
|
|
559
|
-
const picked = out.trim().replace(/^"|"$/g, '');
|
|
560
|
-
if (code === 0 && picked !== '')
|
|
561
|
-
sendJson(response, 200, { path: picked });
|
|
562
|
-
else
|
|
563
|
-
sendJson(response, 200, { cancelled: true });
|
|
564
|
-
}));
|
|
565
|
-
}
|
|
566
|
-
catch {
|
|
567
|
-
sendJson(response, 500, { error: 'directory picker unavailable' });
|
|
568
|
-
}
|
|
569
|
-
},
|
|
570
|
-
}),
|
|
571
|
-
webServer.register({
|
|
572
|
-
kind: 'exact',
|
|
573
|
-
path: '/dsh-plugin-hub/diagnostics',
|
|
574
|
-
handler: async (request, response) => {
|
|
575
|
-
if (!requireTrustedPost(request, response))
|
|
576
|
-
return;
|
|
577
|
-
// 连通性自检:npm registry(镜像/官方)/ GitHub API / 目录站点,逐路探测并测速。
|
|
578
|
-
// 与安装通道一一对应 —— npm 反查与 npm 安装走 registry、git 源走 github、目录数据走 dsh-plugin.org。
|
|
579
|
-
// 流式(NDJSON)逐条上报:每个探测先发命令行,完成后发结果行,客户端以黑窗口终端实时展示。
|
|
580
|
-
// 可选 body { key }:只重测指定通道(页面列表「重测」单项时用),缺省全量串行探测。
|
|
581
|
-
let onlyKey = '';
|
|
582
|
-
try {
|
|
583
|
-
const body = await readJsonBody(request);
|
|
584
|
-
if (body !== null && typeof body === 'object' && typeof body.key === 'string') {
|
|
585
|
-
onlyKey = body.key;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
catch {
|
|
589
|
-
// 空 body / 非 JSON:视为全量探测
|
|
590
|
-
}
|
|
591
|
-
const settings = loadSettings(profile);
|
|
592
|
-
// npm 通道:设置里配置了镜像源就探测该镜像(安装通道吃同一个 registry);
|
|
593
|
-
// 未配置(空串)时探测官方源作为「跟随本机 npm 配置」的基础连通性参考。
|
|
594
|
-
const registry = settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org';
|
|
595
|
-
// 连通性自检直接打安装通道真实访问的轻量资源:npm 源拉包元数据、git 通道探
|
|
596
|
-
// 仓库主页、目录站探 badge 小文件,避免拉取目录全量 JSON。
|
|
597
|
-
// npm 行 display:配置了镜像就显示镜像地址;未配置置空 —— 客户端显示「未配置(跟随本机 npm 配置)」。
|
|
598
|
-
const allChecks = [
|
|
599
|
-
{ key: 'npm', url: `${registry}/dsh-plugin`, display: settings.npmRegistry !== '' ? registry : '', cmd: `npm view dsh-plugin version --registry ${registry}` },
|
|
600
|
-
// GitHub 行用真实 git 克隆握手(git ls-remote)打 dshplugin/hello-dsh 小仓库:
|
|
601
|
-
// 「网页能打开」和「git 能克隆」是两码事,握手成功才算通道通;小仓库秒级完成,不打 17MB 的 dsh-plugin-hub。
|
|
602
|
-
{ key: 'github', url: 'https://github.com/dshplugin/hello-dsh', display: 'github.com', cmd: 'git ls-remote https://github.com/dshplugin/hello-dsh', git: true },
|
|
603
|
-
{ key: 'catalog', url: 'https://api.dsh-plugin.org/stats.json', display: '', cmd: 'curl -s https://api.dsh-plugin.org/stats.json' },
|
|
604
|
-
];
|
|
605
|
-
// 配置了 HTTP 代理:追加一行代理诊断 —— 用该代理打 github.com(安装通道真实访问的地址),
|
|
606
|
-
// 验证「代理能不能把请求带出去」。与安装同口径:curl 子进程注入该代理 env。
|
|
607
|
-
// 设置里未配置代理时不显示该行(跟随系统代理/直连,无「代理本身」可验证)。
|
|
608
|
-
if (settings.proxy !== '') {
|
|
609
|
-
allChecks.push({ key: 'proxy', url: 'https://github.com', display: settings.proxy, cmd: `curl -s -o /dev/null -x ${settings.proxy} https://github.com`, proxy: settings.proxy });
|
|
610
|
-
}
|
|
611
|
-
const checks = onlyKey !== '' ? allChecks.filter((c) => c.key === onlyKey) : allChecks;
|
|
612
|
-
response.writeHead(200, {
|
|
613
|
-
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
614
|
-
'cache-control': 'no-store',
|
|
615
|
-
});
|
|
616
|
-
const send = (line) => { response.write(`${JSON.stringify(line)}\n`); };
|
|
617
|
-
// 探测走代理的优先级:设置里的代理 → 系统代理(macOS scutil / Windows 注册表)→ 环境变量 HTTPS_PROXY → 直连。
|
|
618
|
-
// 与安装通道一致(npm/git 靠 HTTP_PROXY 环境变量走代理),且自动跟随系统代理 ——
|
|
619
|
-
// 浏览器挂着代理能开、诊断就能测通,不用用户手动把代理填进设置。
|
|
620
|
-
// proxy 行例外:它验证的正是「设置里配的那个代理」,必须显式用它(而非 effectiveProxy)去探测。
|
|
621
|
-
const effectiveProxy = settings.proxy !== ''
|
|
622
|
-
? settings.proxy
|
|
623
|
-
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
624
|
-
const probe = (url, proxy) => probeUrl(url, proxy ?? effectiveProxy, 6000);
|
|
625
|
-
// 逐路串行:一条命令一条结果地推送给终端,保持「程序在跑」的真实感
|
|
626
|
-
const results = [];
|
|
627
|
-
for (const c of checks) {
|
|
628
|
-
send({ type: 'probe', key: c.key, cmd: c.cmd, display: c.display });
|
|
629
|
-
// git 行走真实克隆握手(gitLsRemote),其余走 curl 探测,两路都带超时兜底
|
|
630
|
-
const r = c.git
|
|
631
|
-
? await gitLsRemote(c.url, c.proxy ?? effectiveProxy, 6000)
|
|
632
|
-
: await probe(c.url, c.proxy);
|
|
633
|
-
results.push({ key: c.key, ok: r.ok });
|
|
634
|
-
// git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码。
|
|
635
|
-
// reason 透出失败细分原因(如 Windows 证书吊销受阻),客户端据此给对症提示而非笼统「不可达」。
|
|
636
|
-
send({
|
|
637
|
-
type: r.ok ? 'ok' : 'fail',
|
|
638
|
-
key: c.key,
|
|
639
|
-
display: c.display,
|
|
640
|
-
ms: r.ms,
|
|
641
|
-
status: c.git ? null : r.status,
|
|
642
|
-
reason: r.ok ? null : (r.reason ?? null),
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
send({ type: 'end', at: Date.now() });
|
|
646
|
-
response.end();
|
|
647
|
-
// 系统日志:诊断汇总(全部通过 / 存在不可达)
|
|
648
|
-
const failed = results.filter((r) => !r.ok);
|
|
649
|
-
appendLog(profile, {
|
|
650
|
-
at: Date.now(),
|
|
651
|
-
level: failed.length === 0 ? 'success' : 'error',
|
|
652
|
-
category: 'diagnostics',
|
|
653
|
-
event: 'diagnostics.done',
|
|
654
|
-
message: failed.length === 0
|
|
655
|
-
? `连通性检测通过(${results.length} 个通道)`
|
|
656
|
-
: `连通性检测:${failed.length}/${results.length} 个通道不可达`,
|
|
657
|
-
});
|
|
658
|
-
},
|
|
659
|
-
}),
|
|
660
|
-
webServer.register({
|
|
661
|
-
kind: 'exact',
|
|
662
|
-
path: '/dsh-plugin-hub/proxy-check',
|
|
663
|
-
handler: async (request, response) => {
|
|
664
|
-
if (!requireTrustedPost(request, response))
|
|
665
|
-
return;
|
|
666
|
-
// 代理连通性校验:用待测代理打 dshplugin/hello-dsh 真实仓库页(权威可达目标),
|
|
667
|
-
// 供设置页输入代理时实时反馈「这个地址通不通」。不通过也允许保存 ——
|
|
668
|
-
// 用户可能是先填地址后开代理,因此这里只报告探测结果、不拦截保存。
|
|
669
|
-
let proxy = '';
|
|
670
|
-
let target = 'https://github.com/dshplugin/hello-dsh';
|
|
671
|
-
try {
|
|
672
|
-
const body = await readJsonBody(request);
|
|
673
|
-
if (body !== null && typeof body === 'object') {
|
|
674
|
-
const b = body;
|
|
675
|
-
if (typeof b.proxy === 'string')
|
|
676
|
-
proxy = b.proxy.trim();
|
|
677
|
-
if (typeof b.target === 'string' && b.target.trim() !== '')
|
|
678
|
-
target = b.target.trim();
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
catch {
|
|
682
|
-
// 空 body / 非 JSON:按默认目标探测
|
|
683
|
-
}
|
|
684
|
-
if (proxy === '') {
|
|
685
|
-
sendJson(response, 200, { ok: false, ms: null, status: null, target, reason: 'empty' });
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
// 探测走该代理本身(不是 effectiveProxy):验证的就是用户填的这个地址。
|
|
689
|
-
// reason 带上失败细分原因(如证书吊销受阻),设置页才能提示「不是代理地址的问题」。
|
|
690
|
-
const r = await probeUrl(target, proxy, 6000);
|
|
691
|
-
sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target, reason: r.reason ?? null });
|
|
692
|
-
},
|
|
693
|
-
}),
|
|
694
|
-
webServer.register({
|
|
695
|
-
kind: 'exact',
|
|
696
|
-
path: '/dsh-plugin-hub/catalog',
|
|
697
|
-
handler: async (request, response) => {
|
|
698
|
-
if (!requireMethod(request, response, 'GET'))
|
|
699
|
-
return;
|
|
700
|
-
// 目录/统计数据服务端代理:浏览器不直连 dsh-plugin.org,统一经此路由
|
|
701
|
-
// 转发(curl 子进程注入代理 env),与 npm / git 安装通道走同一代理口径,
|
|
702
|
-
// 「npm / git / 目录数据请求统一走该代理」的设置文案因此真实生效。
|
|
703
|
-
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
704
|
-
const settings = loadSettings(profile);
|
|
705
|
-
// 代理优先级与安装/诊断一致:设置里的代理 → 系统代理 → 环境变量 → 直连
|
|
706
|
-
const proxy = settings.proxy !== ''
|
|
707
|
-
? settings.proxy
|
|
708
|
-
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
709
|
-
const isStats = url.searchParams.get('stats') === '1';
|
|
710
|
-
const lang = url.searchParams.get('lang') === 'en' ? 'en' : 'zh';
|
|
711
|
-
const target = isStats
|
|
712
|
-
? 'https://api.dsh-plugin.org/stats.json'
|
|
713
|
-
: `https://api.dsh-plugin.org/plugins.${lang}.json`;
|
|
714
|
-
// 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
|
|
715
|
-
const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`);
|
|
716
|
-
const cached = readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS);
|
|
717
|
-
if (cached !== null) {
|
|
718
|
-
try {
|
|
719
|
-
sendJson(response, 200, JSON.parse(cached));
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
catch {
|
|
723
|
-
// 缓存正文损坏:忽略,走重新拉取并覆盖写盘
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
const r = await fetchViaCurl(target, proxy, 20000);
|
|
727
|
-
if (!r.ok || r.body === '') {
|
|
728
|
-
// 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
|
|
729
|
-
// 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
|
|
730
|
-
const stale = readCatalogCache(cacheFile, Number.POSITIVE_INFINITY);
|
|
731
|
-
if (stale !== null) {
|
|
732
|
-
try {
|
|
733
|
-
sendJson(response, 200, JSON.parse(stale));
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
catch {
|
|
737
|
-
// 兜底缓存也损坏:落到下面的 502
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
sendJson(response, 502, { error: 'catalog fetch failed' });
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
try {
|
|
744
|
-
sendJson(response, 200, JSON.parse(r.body));
|
|
745
|
-
writeCatalogCache(cacheFile, r.body);
|
|
746
|
-
}
|
|
747
|
-
catch {
|
|
748
|
-
sendJson(response, 502, { error: 'catalog fetch returned invalid JSON' });
|
|
749
|
-
}
|
|
750
|
-
},
|
|
751
|
-
}),
|
|
752
|
-
webServer.register({
|
|
753
|
-
kind: 'exact',
|
|
754
|
-
path: '/dsh-plugin-hub/install',
|
|
755
|
-
handler: async (request, response) => {
|
|
756
|
-
if (!requireTrustedPost(request, response))
|
|
757
|
-
return;
|
|
758
|
-
// 任务自动入队:即使已有插件操作在跑也接受请求(FIFO 串行执行),不做 409 拒绝
|
|
759
|
-
try {
|
|
760
|
-
const body = await readJsonBody(request);
|
|
761
|
-
// mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)。
|
|
762
|
-
// 后续「同仓库残留」检测可能把 install 就地升级为 update(见包名冲突检测),故用 let。
|
|
763
|
-
let mode = body !== null && typeof body === 'object' && body.mode === 'update'
|
|
764
|
-
? 'update'
|
|
765
|
-
: 'install';
|
|
766
|
-
// installTargetOf 先把完整 DSH 命令(`dsh plugin [--profile <p>] add <target>`)剥成裸目标,
|
|
767
|
-
// 兼容用户直接粘贴命令行(防输错),真正语法判定交给下面 githubRepoOf / validPackageName。
|
|
768
|
-
const rawRepo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
|
|
769
|
-
? installTargetOf(body.repo)
|
|
770
|
-
: '';
|
|
771
|
-
// 前端决策的展示用仓库名(owner/repo):npm 安装时 body.repo 是包名,display 用于
|
|
772
|
-
// 待重启行 / 任务恢复时展示仓库名,保证用户无感知(安装通道变化不改变所见目标)
|
|
773
|
-
const displayRepo = typeof body === 'object' && body !== null && typeof body.display === 'string'
|
|
774
|
-
? body.display.trim()
|
|
775
|
-
: rawRepo;
|
|
776
|
-
// 请求来源:目录插件安装(catalog)走目录白名单;命令行安装(custom)按通道受安全开关控制
|
|
777
|
-
const source = typeof body === 'object' && body !== null && typeof body.source === 'string'
|
|
778
|
-
? body.source
|
|
779
|
-
: '';
|
|
780
|
-
// 自定义安装入口渠道(客户端三张卡片:NPM 包 / GitHub 源码 / DSH 命令):
|
|
781
|
-
// 客户端显式上报,服务端日志/报错据此精准溯源「从哪个入口发起」;目录插件安装不传。
|
|
782
|
-
const installChannel = (() => {
|
|
783
|
-
const raw = body !== null && typeof body === 'object' ? body.installChannel : undefined;
|
|
784
|
-
return raw === 'git' || raw === 'dsh' || raw === 'npm' ? raw : undefined;
|
|
785
|
-
})();
|
|
786
|
-
const settings = loadSettings(profile);
|
|
787
|
-
// 界面语言(客户端随请求携带):错误消息按用户当前语言提示(中文界面给中文,英文界面给英文)
|
|
788
|
-
const lang = typeof body === 'object' && body !== null && body.lang === 'en'
|
|
789
|
-
? 'en'
|
|
790
|
-
: 'zh';
|
|
791
|
-
// 全局 npm 安装(官方 README 的 `npm install -g <pkgs>`,如 dsh-tui 的安装命令):
|
|
792
|
-
// 直接从命令框输入格式执行全局安装,不进任何 profile、无需宿主重启。
|
|
793
|
-
// 包列表优先取 body.globalNpm(客户端命令框解析后显式携带);兜底再从 rawRepo 解析,
|
|
794
|
-
// 保证直接调 API 传完整命令也能识别。
|
|
795
|
-
const bodyGlobalNpm = Array.isArray(body.globalNpm)
|
|
796
|
-
? body.globalNpm.filter((x) => typeof x === 'string' && x !== '')
|
|
797
|
-
: [];
|
|
798
|
-
let globalNpm = bodyGlobalNpm.length > 0 ? bodyGlobalNpm : (globalNpmPackagesOf(rawRepo) ?? []);
|
|
799
|
-
if (globalNpm.length > 0) {
|
|
800
|
-
// 包名逐个按 npm 包名语法校验 + 拒绝以 - 开头的 token(npm 会把 --xxx 当参数而非包名),数量封顶防滥用
|
|
801
|
-
if (globalNpm.length > 20 || globalNpm.some((p) => !validPackageName(p) || p.startsWith('-'))) {
|
|
802
|
-
sendJson(response, 400, { error: 'unsupported install target' });
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
// 通道门禁与安装通道同构:全局安装本质是 npm 操作,吃「启用 NPM 安装」开关
|
|
806
|
-
if (source !== 'catalog' && !settings.enableNpmInstall) {
|
|
807
|
-
sendJson(response, 403, { error: 'npm installs are disabled by the security settings' });
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
// 队列级去重:同一包列表已在排队/执行中时拒绝
|
|
811
|
-
const identity = globalNpm.join(' ');
|
|
812
|
-
if (hasQueuedTarget(identity)) {
|
|
813
|
-
sendJson(response, 409, { error: `already queued: ${identity}` });
|
|
814
|
-
return;
|
|
815
|
-
}
|
|
816
|
-
const task = startPluginMutation({
|
|
817
|
-
action: 'add',
|
|
818
|
-
profile,
|
|
819
|
-
target: identity,
|
|
820
|
-
displayTarget: identity,
|
|
821
|
-
globalNpm,
|
|
822
|
-
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
823
|
-
env: mutationEnv(settings),
|
|
824
|
-
// 全局 npm 安装由 NPM 包卡片/官方命令发起:日志体现入口渠道
|
|
825
|
-
installChannel: installChannel ?? 'npm',
|
|
826
|
-
});
|
|
827
|
-
sendJson(response, 200, { ok: true, task: task.id });
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
// 目标语法三态:固定 GitHub release .tgz(保留其包根目录,不转成仓库 HEAD);
|
|
831
|
-
// GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式 HTTPS Git 源
|
|
832
|
-
// (走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
|
|
833
|
-
// githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
|
|
834
|
-
const gitRepo = githubRepoOf(rawRepo);
|
|
835
|
-
const release = githubReleaseTarget(rawRepo);
|
|
836
|
-
// 显式 pin 的 Git ref(`#tag` / `#commit`)是用户意图:保留它,且不让 npm 反查
|
|
837
|
-
// 把这个精确修订换成 registry 上的默认版本(见 issue #130)。
|
|
838
|
-
const pinnedRef = githubRefOf(rawRepo);
|
|
839
|
-
const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo, pinnedRef) : null;
|
|
840
|
-
let target = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null);
|
|
841
|
-
if (target === null) {
|
|
842
|
-
sendJson(response, 400, { error: 'unsupported install target' });
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
const attempts = [];
|
|
846
|
-
// Catalog metadata may outlive a packaging split: one repository can publish both a
|
|
847
|
-
// CLI/helper package and a dedicated DSH plugin package. Keep the catalog target when
|
|
848
|
-
// registry metadata confirms it is a DSH package for this repo. Otherwise inspect the
|
|
849
|
-
// repository root package.json and use its package name only when that exact registry
|
|
850
|
-
// package also points back to the repo and identifies as a DSH plugin. This avoids
|
|
851
|
-
// relying on npm search ranking, which is not a repository index and can omit the
|
|
852
|
-
// correct package even with a large result window.
|
|
853
|
-
const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null;
|
|
854
|
-
if (catalogRepo !== null && release === null && repoTarget === null && validPackageName(rawRepo)) {
|
|
855
|
-
const catalogTargetIsDsh = await isDshNpmPackageForRepo(rawRepo, catalogRepo, settings.npmRegistry);
|
|
856
|
-
if (catalogTargetIsDsh) {
|
|
857
|
-
recordResolvedNpmPackage(profile, catalogRepo, rawRepo);
|
|
858
|
-
}
|
|
859
|
-
else {
|
|
860
|
-
const catalogGitTarget = githubTarget(catalogRepo);
|
|
861
|
-
if (catalogGitTarget !== null) {
|
|
862
|
-
const root = await preflightTarget(catalogGitTarget);
|
|
863
|
-
const rootName = root.ok && root.name !== null && validPackageName(root.name) ? root.name : null;
|
|
864
|
-
if (rootName !== null && rootName !== rawRepo
|
|
865
|
-
&& await isDshNpmPackageForRepo(rootName, catalogRepo, settings.npmRegistry)) {
|
|
866
|
-
attempts.push(`catalog npm target: \`${target}\` → repository root DSH package \`${rootName}\``);
|
|
867
|
-
target = rootName;
|
|
868
|
-
recordResolvedNpmPackage(profile, catalogRepo, rootName);
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
// npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
|
|
874
|
-
// git 分发常缺构建产物/子模块,npm 包是作者发布的完整产物;未命中或
|
|
875
|
-
// 查询失败保留 github 直装(不阻塞安装,预检仍会拦截缺入口文件的情况)。
|
|
876
|
-
// 通用机制,不针对具体插件。
|
|
877
|
-
// 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
|
|
878
|
-
// 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
|
|
879
|
-
// 显式 pin 了 Git ref 时跳过 npm 反查:用户要的就是那个精确修订,
|
|
880
|
-
// 不能用 registry 上的默认版本覆盖它(issue #130)。
|
|
881
|
-
if (repoTarget !== null && pinnedRef === null) {
|
|
882
|
-
// 反查/记录统一用归一化后的 owner/repo(gitRepo),保证「输入完整链接」也走同一套 npm 反查
|
|
883
|
-
const repoIdentity = gitRepo ?? rawRepo;
|
|
884
|
-
const npmName = await resolveNpmPackage(repoIdentity, settings.npmRegistry);
|
|
885
|
-
if (npmName !== null) {
|
|
886
|
-
target = npmName;
|
|
887
|
-
attempts.push(`npm registry search: \`npm search repository:${repoIdentity}\` → found \`${npmName}\``);
|
|
888
|
-
// 持久化 repo → npm 包名映射:目录数据未下发 npmPackage(组织 scope 与 GitHub
|
|
889
|
-
// 用户名不一致)时,客户端仍能通过 /installed 的 versions 把依赖 key 匹配回仓库,
|
|
890
|
-
// 列表立即显示「已安装」,避免安装成功后仍显示可安装
|
|
891
|
-
recordResolvedNpmPackage(profile, repoIdentity, npmName);
|
|
892
|
-
}
|
|
893
|
-
else {
|
|
894
|
-
attempts.push(`npm registry search: \`npm search repository:${repoIdentity}\` → no matching package found (falling back to git install)`);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
// 命令行安装(目录外)按通道门禁:GitHub 源码通道受「启用 GitHub 源码安装」控制,
|
|
898
|
-
// npm 通道受「启用 NPM 安装」控制 —— 目录插件安装走目录白名单,不受开关影响。
|
|
899
|
-
// 通道判定看反查后的最终 target:release 包与 git 目标同属 GitHub 通道,否则走 npm。
|
|
900
|
-
if (source !== 'catalog') {
|
|
901
|
-
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget);
|
|
902
|
-
if (isGitChannel && !settings.enableGitInstall) {
|
|
903
|
-
sendJson(response, 403, { error: 'git installs are disabled by the security settings' });
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
if (!isGitChannel && !settings.enableNpmInstall) {
|
|
907
|
-
sendJson(response, 403, { error: 'npm installs are disabled by the security settings' });
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
// 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
|
|
912
|
-
// (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
|
|
913
|
-
// 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装。
|
|
914
|
-
// 命中判定双通道:npm 包名按 dependencies 键直接命中,GitHub 目标按仓库身份匹配。
|
|
915
|
-
const installed = readInstalled(profile);
|
|
916
|
-
const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null);
|
|
917
|
-
if (already !== null && mode !== 'update') {
|
|
918
|
-
sendJson(response, 409, { error: `already installed: ${already}` });
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
// 队列级去重:同一目标已在排队/执行中时拒绝,避免客户端防重竞态导致重复入队
|
|
922
|
-
if (hasQueuedTarget(target)) {
|
|
923
|
-
sendJson(response, 409, { error: `already queued: ${target}` });
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
// 安装前网络连通性预检:npm 通道探 registry、git 通道探 github.com 连通性。
|
|
927
|
-
// 探测目标固定为通道的稳定入口(registry 根 / github.com 主页),不探具体包或仓库
|
|
928
|
-
// 页 —— 404 表示「目标不存在」而非网络不通,不该被当成网络故障拦截。
|
|
929
|
-
// 仅新安装预检(更新是已信任目标的覆盖重装,跳过);不通直接 400 拦下并打
|
|
930
|
-
// [network] 标记,客户端据此提示「你的网络不通」,而不是把网络失败当成
|
|
931
|
-
// 插件侧问题引导去作者仓库提 Issue。
|
|
932
|
-
// 代理口径与诊断一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
|
|
933
|
-
if (already === null) {
|
|
934
|
-
const effectiveProxy = settings.proxy !== ''
|
|
935
|
-
? settings.proxy
|
|
936
|
-
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
937
|
-
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget);
|
|
938
|
-
// release 包是 https://github.com/ 直链下载(非 git 克隆),探针按 HTTP 打 github.com 主页;
|
|
939
|
-
// npm 通道探 registry 根。两者都是通道的稳定入口,不探具体包/仓库(404 会被误判成网络故障)。
|
|
940
|
-
const httpProbeTarget = release !== null
|
|
941
|
-
? 'https://github.com/'
|
|
942
|
-
: `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`;
|
|
943
|
-
// git 通道的探针换成克隆握手本身:git ls-remote 走的就是 pnpm 克隆前的同一套
|
|
944
|
-
// https 传输。curl 打 github.com 主页会被按协议/端口区分的防火墙与 TLS 复检
|
|
945
|
-
// 拦截(能 git 克隆、打不开网页),把可装的仓库误判成 [network] 而中止
|
|
946
|
-
// (见 issue #71)。握手失败时再用 curl 复核一次主页连通性:只有两者都不通
|
|
947
|
-
// 才判「网络不通」;仅握手失败(仓库不存在、私有仓库、需要凭据)放行,
|
|
948
|
-
// 交给后续预检与真实安装给出准确原因。
|
|
949
|
-
// 探针只握手仓库本身(去掉 git+ 前缀与 #ref 片段):ls-remote 认的是仓库地址,
|
|
950
|
-
// 带 ref 片段会干扰 URL 解析
|
|
951
|
-
const gitProbeTarget = repoTarget === null ? null : repoTarget.replace(/^git\+/, '').replace(/#.*$/, '');
|
|
952
|
-
let net;
|
|
953
|
-
if (gitProbeTarget !== null) {
|
|
954
|
-
net = await gitLsRemote(gitProbeTarget, effectiveProxy, 6000);
|
|
955
|
-
if (!net.ok) {
|
|
956
|
-
const reachable = await probeUrl('https://github.com/', effectiveProxy, 6000);
|
|
957
|
-
if (reachable.ok)
|
|
958
|
-
net = reachable;
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
else {
|
|
962
|
-
net = await probeUrl(httpProbeTarget, effectiveProxy, 6000);
|
|
963
|
-
}
|
|
964
|
-
const probeTarget = gitProbeTarget ?? httpProbeTarget;
|
|
965
|
-
if (!net.ok) {
|
|
966
|
-
// 错误消息按客户端界面语言提示:中文界面给中文、英文界面给英文,
|
|
967
|
-
// 让用户一眼看懂是网络问题而非插件问题
|
|
968
|
-
const channelName = isGitChannel
|
|
969
|
-
? (lang === 'zh' ? 'GitHub' : 'github.com')
|
|
970
|
-
: (lang === 'zh' ? 'npm 源' : 'the npm registry');
|
|
971
|
-
const error = lang === 'zh'
|
|
972
|
-
? `[network] 安装已中止:安装前无法连接到 ${channelName}(${probeTarget})—— 您的网络似乎不通或被拦截(断网 / DNS 解析失败 / 防火墙 / 代理配置问题)。这不是插件本身的问题,请检查网络连接或代理设置,运行「系统诊断」检测各通道连通性后重试。`
|
|
973
|
-
: `[network] install aborted: cannot reach ${channelName} (${probeTarget}) before install — your network connection appears to be down or blocked (DNS / proxy / firewall). This is not a problem with the plugin itself. Check your connection or proxy settings, run the connectivity diagnostic, then retry.`;
|
|
974
|
-
sendJson(response, 400, { error, attempts });
|
|
975
|
-
return;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
// 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
|
|
979
|
-
// 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
|
|
980
|
-
// npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
|
|
981
|
-
// 供客户端归类为「插件分发不完整」并引导去仓库提 Issue
|
|
982
|
-
if (already === null) {
|
|
983
|
-
const preflight = await preflightTarget(target);
|
|
984
|
-
if (!preflight.ok) {
|
|
985
|
-
// 预检拦截(git 分发缺入口文件):同步 400 返回,附上已尝试的安装方式,
|
|
986
|
-
// 客户端失败弹窗提 Issue 时一并贴给作者
|
|
987
|
-
sendJson(response, 400, {
|
|
988
|
-
error: `[packaging] ${target}: plugin distribution is incomplete — the entry file ${preflight.missing ?? '?'} declared in package.json is not present in the git distribution (build output not committed). Report to the author.`,
|
|
989
|
-
attempts,
|
|
990
|
-
});
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
// 包名冲突检测(仅 git 通道):git 目标包内声明的 name 若与 profile 已装依赖同名,
|
|
994
|
-
// pnpm 会以该 name 做依赖键撞车,抛出的 CLI 报错晦涩难懂 —— 且该包名在 registry 上
|
|
995
|
-
// 可能被别的仓库占用,npm 反查搜不到,git 直装就会撞上已装的同名依赖。
|
|
996
|
-
// 到这里已装依赖要么来源不同仓库、要么 spec 是版本号解析不出仓库身份。
|
|
997
|
-
// 读 node_modules 里已装包的真实 repository 区分两种情况:
|
|
998
|
-
// - 指向同一仓库 → 是「同仓库残留」(spec 丢了仓库身份),就地转 update 覆盖重装(幂等);
|
|
999
|
-
// - 指向别的仓库 / 读不到 → 跨仓库包名冲突,入队前转成明确 409,指导用户先卸载或改装。
|
|
1000
|
-
if (preflight.name !== null && repoTarget !== null && target === repoTarget) {
|
|
1001
|
-
const declared = preflight.name;
|
|
1002
|
-
if (installed[declared] !== undefined) {
|
|
1003
|
-
const installedRepo = installedRepoOf(profile, declared);
|
|
1004
|
-
if (installedRepo !== null && gitRepo !== null && installedRepo.toLowerCase() === gitRepo.toLowerCase()) {
|
|
1005
|
-
// 同名且同源:已装 spec 丢了仓库身份导致的残留,直接按 update 覆盖重装,不打扰用户
|
|
1006
|
-
attempts.push(`same-repo residual: \`${declared}\` resolves to \`${installedRepo}\` → reinstall as update`);
|
|
1007
|
-
mode = 'update';
|
|
1008
|
-
}
|
|
1009
|
-
else {
|
|
1010
|
-
attempts.push(`package name conflict: \`${gitRepo}\` declares npm name \`${declared}\`, which is already present in the profile and cannot be matched to this repo (that npm name is likely registered by another repository)`);
|
|
1011
|
-
const error = lang === 'zh'
|
|
1012
|
-
? `already installed: ${declared} —— ${gitRepo} 声明的 npm 包名「${declared}」已存在于当前 profile,且无法匹配到本仓库(该包名在 npm 上可能被其他仓库占用)。请先移除现有的 ${declared}(dsh plugin remove ${declared}),再重新安装;或改装该包名对应的官方 npm 包。`
|
|
1013
|
-
: `already installed: ${declared} — ${gitRepo} declares the npm package name "${declared}", which is already present in the profile and cannot be matched to this repo (that npm name is likely registered by another repository). Remove the existing ${declared} dependency (dsh plugin remove ${declared}) first, then retry; or install the official npm package under that name.`;
|
|
1014
|
-
sendJson(response, 409, { error, attempts });
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
// 入队前复检(与上方检查同语义,但必须在同步块内):preflight 的 await 期间并发请求
|
|
1021
|
-
// 可能已通过上方检查、任务却尚未入队——此处检查与 startPluginMutation 之间无 await,
|
|
1022
|
-
// JS 单线程保证同一目标至多一个任务,杜绝双击/并发产生双任务
|
|
1023
|
-
if (hasQueuedTarget(target)) {
|
|
1024
|
-
sendJson(response, 409, { error: `already queued: ${target}` });
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
// Kick off the CLI in the background; the client polls /status for progress
|
|
1028
|
-
const task = startPluginMutation({
|
|
1029
|
-
// mode=update(已安装目标的覆盖更新)以 update 动作入队:
|
|
1030
|
-
// 日志按 update 类别落盘(update.start/done/fail),实际 CLI 执行仍是 add <target>@latest
|
|
1031
|
-
action: mode === 'update' ? 'update' : 'add',
|
|
1032
|
-
profile,
|
|
1033
|
-
target,
|
|
1034
|
-
// npm 安装时 target 是包名:displayTarget 固定展示仓库名,待重启行/前端恢复不受通道变化影响
|
|
1035
|
-
displayTarget: githubRepoOf(displayRepo) ?? undefined,
|
|
1036
|
-
// 已尝试的安装方式(npm 反查):实际执行命令由 spawn 时追加,失败 issue 一并展示
|
|
1037
|
-
attempts,
|
|
1038
|
-
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
1039
|
-
env: mutationEnv(settings),
|
|
1040
|
-
// 覆盖更新:npm 通道命令显式 @latest(pnpm add 无版本对已存在依赖是幂等的,不加版本不会真更新)
|
|
1041
|
-
updateNpm: mode === 'update',
|
|
1042
|
-
// 运行中 loader:安装成功后把新插件热挂进 loader,非桌面端 + 纯服务端插件无需重启即生效
|
|
1043
|
-
loader,
|
|
1044
|
-
// 自定义安装入口渠道(NPM 包 / GitHub 源码 / DSH 命令卡片):日志/报错据此溯源
|
|
1045
|
-
installChannel,
|
|
1046
|
-
});
|
|
1047
|
-
sendJson(response, 200, { ok: true, task: task.id });
|
|
1048
|
-
}
|
|
1049
|
-
catch (error) {
|
|
1050
|
-
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1051
|
-
}
|
|
1052
|
-
},
|
|
1053
|
-
}),
|
|
1054
|
-
webServer.register({
|
|
1055
|
-
kind: 'exact',
|
|
1056
|
-
path: '/dsh-plugin-hub/uninstall',
|
|
1057
|
-
handler: async (request, response) => {
|
|
1058
|
-
if (!requireTrustedPost(request, response))
|
|
1059
|
-
return;
|
|
1060
|
-
// 与安装一致:卸载也进入同一队列,FIFO 串行执行
|
|
1061
|
-
try {
|
|
1062
|
-
const body = await readJsonBody(request);
|
|
1063
|
-
const name = typeof body === 'object' && body !== null && typeof body.name === 'string'
|
|
1064
|
-
? body.name
|
|
1065
|
-
: '';
|
|
1066
|
-
// 展示用的 owner/repo:卸载命令只需要 npm 包名,但待重启行要与安装行一致地显示仓库名
|
|
1067
|
-
const repo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
|
|
1068
|
-
? body.repo
|
|
1069
|
-
: '';
|
|
1070
|
-
if (!validPackageName(name) || name === 'dsh-plugin') {
|
|
1071
|
-
sendJson(response, 400, { error: 'plugin cannot be uninstalled here' });
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
if (readInstalled(profile)[name] === undefined) {
|
|
1075
|
-
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
// 队列级去重:同一包已在排队/执行中卸载时拒绝
|
|
1079
|
-
if (hasQueuedTarget(name)) {
|
|
1080
|
-
sendJson(response, 409, { error: `already queued: ${name}` });
|
|
1081
|
-
return;
|
|
1082
|
-
}
|
|
1083
|
-
const task = startPluginMutation({
|
|
1084
|
-
action: 'remove',
|
|
1085
|
-
profile,
|
|
1086
|
-
target: name,
|
|
1087
|
-
// 卸载的 mutation 目标是 npm 包名;待重启行要显示 owner/repo(与安装行一致),非法/缺失时回退包名
|
|
1088
|
-
displayTarget: githubRepoOf(repo) ?? undefined,
|
|
1089
|
-
// 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
|
|
1090
|
-
loader,
|
|
1091
|
-
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
1092
|
-
env: mutationEnv(loadSettings(profile)),
|
|
1093
|
-
});
|
|
1094
|
-
sendJson(response, 200, { ok: true, task: task.id });
|
|
1095
|
-
}
|
|
1096
|
-
catch (error) {
|
|
1097
|
-
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1098
|
-
}
|
|
1099
|
-
},
|
|
1100
|
-
}),
|
|
1101
|
-
webServer.register({
|
|
1102
|
-
kind: 'exact',
|
|
1103
|
-
path: '/dsh-plugin-hub/status',
|
|
1104
|
-
handler: (request, response) => {
|
|
1105
|
-
if (!requireMethod(request, response, 'GET'))
|
|
1106
|
-
return;
|
|
1107
|
-
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
1108
|
-
const id = Number(url.searchParams.get('task'));
|
|
1109
|
-
const task = Number.isInteger(id) ? getTask(id) : undefined;
|
|
1110
|
-
if (!task) {
|
|
1111
|
-
sendJson(response, 404, { error: 'task not found' });
|
|
1112
|
-
return;
|
|
1113
|
-
}
|
|
1114
|
-
sendJson(response, 200, { task });
|
|
1115
|
-
},
|
|
1116
|
-
}),
|
|
1117
|
-
webServer.register({
|
|
1118
|
-
kind: 'exact',
|
|
1119
|
-
path: '/dsh-plugin-hub/active',
|
|
1120
|
-
handler: (request, response) => {
|
|
1121
|
-
if (!requireMethod(request, response, 'GET'))
|
|
1122
|
-
return;
|
|
1123
|
-
// 整个任务队列(running 在前、pending 在后)+ 待重启列表:客户端刷新后据此恢复
|
|
1124
|
-
// 进行中/排队任务与「装完没重启」的持久提醒
|
|
1125
|
-
sendJson(response, 200, { tasks: activeTask(), pendingRestarts: listPendingRestarts() });
|
|
1126
|
-
},
|
|
1127
|
-
}),
|
|
1128
|
-
webServer.register({
|
|
1129
|
-
kind: 'exact',
|
|
1130
|
-
path: '/dsh-plugin-hub/env',
|
|
1131
|
-
handler: (request, response) => {
|
|
1132
|
-
if (!requireMethod(request, response, 'GET'))
|
|
1133
|
-
return;
|
|
1134
|
-
// 宿主机器环境快照:提交 bug 的 issue 正文会带上,便于作者复现
|
|
1135
|
-
sendJson(response, 200, hostEnv(profile));
|
|
1136
|
-
},
|
|
1137
|
-
}),
|
|
1138
|
-
webServer.register({
|
|
1139
|
-
kind: 'exact',
|
|
1140
|
-
path: '/dsh-plugin-hub/cancel',
|
|
1141
|
-
handler: async (request, response) => {
|
|
1142
|
-
if (!requireTrustedPost(request, response))
|
|
1143
|
-
return;
|
|
1144
|
-
try {
|
|
1145
|
-
const body = await readJsonBody(request);
|
|
1146
|
-
const id = typeof body === 'object' && body !== null && typeof body.id === 'number'
|
|
1147
|
-
? body.id
|
|
1148
|
-
: NaN;
|
|
1149
|
-
const ok = Number.isInteger(id) && cancelTask(id);
|
|
1150
|
-
sendJson(response, 200, { ok });
|
|
1151
|
-
}
|
|
1152
|
-
catch (error) {
|
|
1153
|
-
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1154
|
-
}
|
|
1155
|
-
},
|
|
1156
|
-
}),
|
|
1157
|
-
webServer.register({
|
|
1158
|
-
kind: 'exact',
|
|
1159
|
-
path: '/dsh-plugin-hub/restart',
|
|
1160
|
-
handler: (request, response) => {
|
|
1161
|
-
if (!requireTrustedPost(request, response))
|
|
1162
|
-
return;
|
|
1163
|
-
// 桌面端:宿主由应用壳托管,插件重启不了它。Windows 上 `taskkill /T` 会把发起
|
|
1164
|
-
// 重启的助手一起结束(助手是宿主的子进程),壳再把这次外部 kill 判成宿主崩溃
|
|
1165
|
-
// 弹「应用无法启动或已意外停止」;退一步说,脱离壳拉起的 `dsh web` 也服务不了
|
|
1166
|
-
// 桌面 UI(缺壳注入的 desktop:boot)。故不 kill、不拉新宿主,由客户端改提示
|
|
1167
|
-
// 用户从菜单/托盘退出后重新打开。
|
|
1168
|
-
if (isDesktopHost()) {
|
|
1169
|
-
sendJson(response, 200, { ok: false, desktop: true });
|
|
1170
|
-
return;
|
|
1171
|
-
}
|
|
1172
|
-
// 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
|
|
1173
|
-
const host = request.headers.host ?? '';
|
|
1174
|
-
const portMatch = host.match(/:(\d+)$/);
|
|
1175
|
-
const port = portMatch ? Number(portMatch[1]) : 7923;
|
|
1176
|
-
// detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
|
|
1177
|
-
// 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
|
|
1178
|
-
// 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
|
|
1179
|
-
// 故孵化 node(process.execPath) 跑跨平台脚本 —— Windows 用
|
|
1180
|
-
// netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
|
|
1181
|
-
spawn(process.execPath, ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref();
|
|
1182
|
-
sendJson(response, 200, { ok: true, port });
|
|
1183
|
-
},
|
|
1184
|
-
}),
|
|
1185
|
-
webServer.register({
|
|
1186
|
-
kind: 'exact',
|
|
1187
|
-
path: '/dsh-plugin-hub/installed-version',
|
|
1188
|
-
handler: async (request, response) => {
|
|
1189
|
-
if (!requireTrustedPost(request, response))
|
|
1190
|
-
return;
|
|
1191
|
-
try {
|
|
1192
|
-
const body = await readJsonBody(request);
|
|
1193
|
-
const repo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
|
|
1194
|
-
? body.repo
|
|
1195
|
-
: '';
|
|
1196
|
-
if (githubTarget(repo) === null) {
|
|
1197
|
-
sendJson(response, 400, { error: 'invalid install target' });
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
// version 为空 = 卸载清理记录;否则记录安装时的目录信号(最新版本 + 仓库更新时间),
|
|
1201
|
-
// 供「有更新」比对:有版本比版本,无版本比更新时间
|
|
1202
|
-
const version = body !== null && typeof body.version === 'string'
|
|
1203
|
-
? body.version
|
|
1204
|
-
: null;
|
|
1205
|
-
if (version === null) {
|
|
1206
|
-
removeInstalledVersion(profile, repo);
|
|
1207
|
-
}
|
|
1208
|
-
else {
|
|
1209
|
-
const updatedAt = body !== null && typeof body.updatedAt === 'string'
|
|
1210
|
-
? body.updatedAt
|
|
1211
|
-
: '';
|
|
1212
|
-
recordInstalledVersion(profile, repo, version, updatedAt);
|
|
1213
|
-
}
|
|
1214
|
-
sendJson(response, 200, { ok: true });
|
|
1215
|
-
}
|
|
1216
|
-
catch (error) {
|
|
1217
|
-
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1218
|
-
}
|
|
1219
|
-
},
|
|
1220
|
-
}),
|
|
1221
|
-
webServer.register({
|
|
1222
|
-
kind: 'exact',
|
|
1223
|
-
path: '/dsh-plugin-hub/open-path',
|
|
1224
|
-
handler: async (request, response) => {
|
|
1225
|
-
if (!requireTrustedPost(request, response))
|
|
1226
|
-
return;
|
|
1227
|
-
// 在系统文件管理器里定位并打开已安装插件目录(详情视图「在文件夹中显示」)。
|
|
1228
|
-
// 只接受已安装依赖的包名、服务端自行拼接路径——杜绝任意路径注入
|
|
1229
|
-
try {
|
|
1230
|
-
const body = await readJsonBody(request);
|
|
1231
|
-
const name = typeof body === 'object' && body !== null && typeof body.name === 'string'
|
|
1232
|
-
? body.name
|
|
1233
|
-
: '';
|
|
1234
|
-
if (!validPackageName(name) || readInstalled(profile)[name] === undefined) {
|
|
1235
|
-
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
1236
|
-
return;
|
|
1237
|
-
}
|
|
1238
|
-
const dir = join(profileDirectory(profile), 'node_modules', name);
|
|
1239
|
-
// macOS Finder 用 -R 定位选中;Windows explorer 用 /select,;Linux 打开目录(xdg-open)
|
|
1240
|
-
const bin = process.platform === 'darwin' ? 'open'
|
|
1241
|
-
: process.platform === 'win32' ? 'explorer'
|
|
1242
|
-
: 'xdg-open';
|
|
1243
|
-
const args = process.platform === 'darwin' ? ['-R', dir]
|
|
1244
|
-
: process.platform === 'win32' ? ['/select,', dir]
|
|
1245
|
-
: [dir];
|
|
1246
|
-
spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
|
|
1247
|
-
sendJson(response, 200, { ok: true });
|
|
1248
|
-
}
|
|
1249
|
-
catch (error) {
|
|
1250
|
-
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1251
|
-
}
|
|
1252
|
-
},
|
|
1253
|
-
}),
|
|
1254
|
-
webServer.register({
|
|
1255
|
-
kind: 'exact',
|
|
1256
|
-
path: '/dsh-plugin-hub/installed',
|
|
1257
|
-
handler: (request, response) => {
|
|
1258
|
-
if (!requireMethod(request, response, 'GET'))
|
|
1259
|
-
return;
|
|
1260
|
-
// installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本);
|
|
1261
|
-
// paths:每个依赖在系统上的安装目录(profile/node_modules/<包名>),详情视图展示用;
|
|
1262
|
-
// loaded:已加载进运行中 loader 的包名(官方 ctx.loader.entries(),未重启装的新插件不在其中);
|
|
1263
|
-
// dshCapable:真正的 dsh 插件(包内声明 dsh 配置 / 在 profile bundles 清单)——
|
|
1264
|
-
// 非 dsh 插件(如 GitHub 官方示例仓库)装上也不会被宿主加载,客户端据此不再提示「待重启」。
|
|
1265
|
-
// 客户端合并这些判断「是否有更新」「运行状态」并展示安装路径/时间等运行时信息。
|
|
1266
|
-
const installed = readInstalled(profile);
|
|
1267
|
-
const paths = {};
|
|
1268
|
-
const loaded = [];
|
|
1269
|
-
const dshCapable = [];
|
|
1270
|
-
for (const name of Object.keys(installed)) {
|
|
1271
|
-
paths[name] = join(profileDirectory(profile), 'node_modules', name);
|
|
1272
|
-
if (isEntryLoaded(loader, name))
|
|
1273
|
-
loaded.push(name);
|
|
1274
|
-
if (isDshPlugin(profile, name))
|
|
1275
|
-
dshCapable.push(name);
|
|
1276
|
-
}
|
|
1277
|
-
sendJson(response, 200, { profile, installed, versions: readInstalledVersions(profile), paths, loaded, dshCapable });
|
|
1278
|
-
},
|
|
1279
|
-
}),
|
|
28
|
+
const routes = [
|
|
29
|
+
...registerCatalogRoutes(webServer, profile),
|
|
30
|
+
...registerInstallRoutes(webServer, profile, loader),
|
|
31
|
+
...registerSettingsRoutes(webServer, profile),
|
|
32
|
+
...registerLogsRoutes(webServer, profile),
|
|
33
|
+
...registerDiagnosticsRoutes(webServer, profile),
|
|
34
|
+
...registerTaskRoutes(webServer, profile, loader),
|
|
1280
35
|
];
|
|
36
|
+
const disposers = routes.map((route) => webServer.register(route));
|
|
1281
37
|
return () => {
|
|
1282
38
|
for (const dispose of disposers)
|
|
1283
39
|
dispose();
|
|
1284
40
|
};
|
|
1285
41
|
}
|
|
1286
42
|
/** Profile resolution shared with the client route docs. */
|
|
1287
|
-
export { readProfileArg };
|
|
43
|
+
export { readProfileArg } from '../services/install/install.js';
|
|
44
|
+
/** Re-exported so tests/routes.test.ts keeps importing these from this module. */
|
|
45
|
+
export { isDesktopHost, isSameOrigin } from './shared.js';
|