dsh-plugin 1.4.7 → 1.4.9

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.
@@ -486,15 +486,23 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
486
486
  const child = spawn(bin, args);
487
487
  let out = '';
488
488
  child.stdout.on('data', (d) => { out += String(d); });
489
- child.on('error', () => sendJson(response, 500, { error: 'directory picker unavailable' }));
490
- child.on('close', (code) => {
489
+ // spawn 失败(如 Linux 无 zenity)会先触发 error 再触发 close(code=-2):
490
+ // 只允许第一个事件应答,否则第二次 sendJson 对已结束的响应再 writeHead,
491
+ // 抛 ERR_HTTP_HEADERS_SENT 成为宿主进程内的未捕获异常。
492
+ let done = false;
493
+ const finish = (reply) => { if (!done) {
494
+ done = true;
495
+ reply();
496
+ } };
497
+ child.on('error', () => finish(() => sendJson(response, 500, { error: 'directory picker unavailable' })));
498
+ child.on('close', (code) => finish(() => {
491
499
  // macOS osascript 输出带引号(如 "/Users/x"),统一剥掉首尾空白与引号
492
500
  const picked = out.trim().replace(/^"|"$/g, '');
493
501
  if (code === 0 && picked !== '')
494
502
  sendJson(response, 200, { path: picked });
495
503
  else
496
504
  sendJson(response, 200, { cancelled: true });
497
- });
505
+ }));
498
506
  }
499
507
  catch {
500
508
  sendJson(response, 500, { error: 'directory picker unavailable' });
@@ -12,14 +12,16 @@
12
12
  * 避免同一会话内重复查询 registry。
13
13
  */
14
14
  import { get } from 'node:https';
15
- /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包)。 */
15
+ /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包;网络异常不缓存)。 */
16
16
  const cache = new Map();
17
17
  /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
18
18
  const REQUEST_TIMEOUT_MS = 8000;
19
19
  /**
20
20
  * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
21
21
  * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
22
- * 原有错误路径,反查只是额外的一次尝试。
22
+ * 原有错误路径,反查只是额外的一次尝试。只有「已确认」的结果(包名,或 200
23
+ * 响应下确认无匹配)会进缓存;网络异常/超时/解析失败不缓存,同一会话内下次
24
+ * 安装仍会重试反查,避免一次瞬时故障把整个会话锁死在 git 通道。
23
25
  * registry 参数:npm 镜像源地址,空串 = 官方源;与安装通道吃同一 registry,
24
26
  * 保证「配置了镜像」时反查和安装走同一个源(镜像节点同步完整时结果一致)。
25
27
  * 慢网络下失败不阻塞安装。
@@ -29,10 +31,14 @@ export function resolveNpmPackage(repo, registry = '') {
29
31
  if (cache.has(key))
30
32
  return Promise.resolve(cache.get(key) ?? null);
31
33
  const name = searchRepo(repo, registry);
32
- name.then((found) => cache.set(key, found)).catch(() => cache.set(key, null));
33
- return name;
34
+ void name.then((found) => {
35
+ if (found !== undefined)
36
+ cache.set(key, found);
37
+ });
38
+ return name.then((found) => found ?? null);
34
39
  }
35
- /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 */
40
+ /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
41
+ * 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */
36
42
  function searchRepo(repo, registry) {
37
43
  const [owner, name] = repo.split('/');
38
44
  if (owner === undefined || name === undefined || name === '')
@@ -43,7 +49,7 @@ function searchRepo(repo, registry) {
43
49
  const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
44
50
  if (res.statusCode !== 200) {
45
51
  res.resume();
46
- resolve(null);
52
+ resolve(undefined);
47
53
  return;
48
54
  }
49
55
  let body = '';
@@ -65,11 +71,11 @@ function searchRepo(repo, registry) {
65
71
  resolve(null);
66
72
  }
67
73
  catch {
68
- resolve(null);
74
+ resolve(undefined);
69
75
  }
70
76
  });
71
77
  });
72
78
  req.on('timeout', () => req.destroy());
73
- req.on('error', () => resolve(null));
79
+ req.on('error', () => resolve(undefined));
74
80
  });
75
81
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * 包入口解析:从 package.json 的 exports["."] / main 推出用于校验的入口文件。
7
+ * git 通道(tar 预检)与 npm 通道(装后 statSync 校验)共用,保证两条通道
8
+ * 对同一个包判定一致。
9
+ */
10
+ /** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */
11
+ const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'];
12
+ /**
13
+ * 解析包的入口文件(相对包根,不带 `./` 前缀)。
14
+ *
15
+ * Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
16
+ * 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
17
+ * (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
18
+ * 个字符串值。无 exports 时回退 main,再回退 index.js。
19
+ */
20
+ export function resolvePackageEntry(meta) {
21
+ const dot = meta.exports?.['.'];
22
+ const fromExports = typeof dot === 'string'
23
+ ? dot
24
+ : dot !== null && typeof dot === 'object'
25
+ ? firstStringOf(dot)
26
+ : undefined;
27
+ const candidate = typeof fromExports === 'string' ? fromExports
28
+ : typeof meta.main === 'string' ? meta.main
29
+ : 'index.js';
30
+ return candidate.replace(/^\.\//u, '');
31
+ }
32
+ function firstStringOf(conditions) {
33
+ for (const key of EXPORT_CONDITION_KEYS) {
34
+ const value = conditions[key];
35
+ if (typeof value === 'string' && value !== '')
36
+ return value;
37
+ }
38
+ return undefined;
39
+ }
@@ -18,6 +18,7 @@ import { join } from 'node:path';
18
18
  import { pipeline } from 'node:stream/promises';
19
19
  import { promisify } from 'node:util';
20
20
  import { githubRepoOf } from '../profile/profile.js';
21
+ import { resolvePackageEntry } from './package-entry.js';
21
22
  const execFileAsync = promisify(execFile);
22
23
  /** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
23
24
  * 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
@@ -47,13 +48,7 @@ export async function preflightTarget(target) {
47
48
  const meta = await readTarJson(tar, 'package/package.json');
48
49
  if (meta === null)
49
50
  return { ok: true, missing: null, name: null };
50
- const dot = meta.exports?.['.'];
51
- const resolved = typeof dot === 'string'
52
- ? dot
53
- : dot !== null && typeof dot === 'object'
54
- ? dot.default
55
- : undefined;
56
- const entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js';
51
+ const entry = resolvePackageEntry(meta);
57
52
  const inTar = await hasTarEntry(tar, `package/${entry}`);
58
53
  return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null };
59
54
  }
@@ -8,6 +8,7 @@
8
8
  * 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
9
9
  */
10
10
  import { spawn, spawnSync } from 'node:child_process';
11
+ import { resolvePackageEntry } from './package-entry.js';
11
12
  import { readFileSync, statSync } from 'node:fs';
12
13
  import { dirname, join, resolve } from 'node:path';
13
14
  import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
@@ -222,7 +223,19 @@ function pumpQueue() {
222
223
  return;
223
224
  const { task, options } = item;
224
225
  task.status = 'running';
225
- void runPluginMutation({ ...options, task }).finally(() => {
226
+ void runPluginMutation({ ...options, task })
227
+ .catch((error) => {
228
+ // runPluginMutation 的恢复路径(如 allowBuilds 写回 pnpm-workspace.yaml)
229
+ // 可能抛出;落到 failed 终态并留痕,否则该 Promise 链成为未处理 rejection
230
+ //(Node >= 15 默认直接终止进程),而队列自身却在 finally 里继续 pump。
231
+ if (task.status !== 'cancelled') {
232
+ task.status = 'failed';
233
+ task.exitCode = null;
234
+ task.progress = 0;
235
+ pushLine(task, `[internal] ${error instanceof Error ? error.message : String(error)}`);
236
+ }
237
+ })
238
+ .finally(() => {
226
239
  runningChildren.delete(task.id);
227
240
  // 系统日志:任务终态(成功/失败/取消),按动作归类安装/卸载/更新日志
228
241
  const show = task.displayTarget ?? task.target;
@@ -510,12 +523,7 @@ export function verifyInstalledEntry(profile, target) {
510
523
  let entry = null;
511
524
  try {
512
525
  const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
513
- const dot = meta.exports?.['.'];
514
- const resolved = typeof dot === 'string' ? dot
515
- : dot !== null && typeof dot === 'object'
516
- ? dot.default
517
- : undefined;
518
- entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js';
526
+ entry = resolvePackageEntry(meta);
519
527
  }
520
528
  catch {
521
529
  return { name, missing: null };
@@ -71,6 +71,20 @@ function macSystemProxy() {
71
71
  return null;
72
72
  }
73
73
  }
74
+ /**
75
+ * 从 `reg query … /v ProxyServer` 的输出里解析出 `http://host:port`;解析不出返回 null。
76
+ * 注册表里的值常见三种写法:`http://127.0.0.1:10793`(带 scheme)、`127.0.0.1:10793`、
77
+ * `http=host:port;https=host:port`(按协议分别配置)。
78
+ * 字符类必须同时排除 `/` 与 `=`:只排除 `=` 时,带 scheme 的值会让「主机名」吞掉 `//`,
79
+ * 拼出 `http:////127.0.0.1:10793` 这种立刻失败的非法地址 —— 代理本来是通的,却把诊断、
80
+ * 目录拉取、npm/git 预检全判成「不可达」(dsh-plugin-hub#66)。
81
+ */
82
+ export function parseWinProxyServer(stdout) {
83
+ const m = /([^:\s=/]+):(\d+)/.exec(stdout);
84
+ if (m === null)
85
+ return null;
86
+ return `http://${m[1]}:${m[2]}`;
87
+ }
74
88
  /** Windows 系统代理:WinINET Internet 设置注册表;未开启返回 null。 */
75
89
  function winSystemProxy() {
76
90
  try {
@@ -79,10 +93,7 @@ function winSystemProxy() {
79
93
  const server = spawnSync('reg', ['query', key, '/v', 'ProxyServer'], { encoding: 'utf8', timeout: 1500 });
80
94
  if (!/0x1/i.test(enable.stdout ?? ''))
81
95
  return null;
82
- const m = /([^:\s=]+):(\d+)/.exec(server.stdout ?? '');
83
- if (m === null)
84
- return null;
85
- return `http://${m[1]}:${m[2]}`;
96
+ return parseWinProxyServer(server.stdout ?? '');
86
97
  }
87
98
  catch {
88
99
  return null;
@@ -8,13 +8,42 @@
8
8
  */
9
9
  import { readFileSync, writeFileSync } from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
- import { join } from 'node:path';
11
+ import { basename, dirname, join } from 'node:path';
12
12
  import { PACKAGE_RE, REPO_RE } from '../install/install-types.js';
13
13
  /** Resolve the active profile from the booted CLI args, falling back to `web`. */
14
14
  export function readProfileArg(fallback = 'web') {
15
- const index = process.argv.indexOf('--profile');
16
- const candidate = index >= 0 ? process.argv[index + 1] : undefined;
17
- return candidate !== undefined && !candidate.startsWith('-') ? candidate : fallback;
15
+ return profileFromArgv(process.argv, fallback);
16
+ }
17
+ /**
18
+ * 从启动参数解析当前 profile,两种宿主形态都认:
19
+ * - 官方 CLI:`--profile <name>`(也兼容 `--profile=<name>` 等号形式);
20
+ * - Electron 桌面宿主:不传 `--profile`,而是把 profile 目录作为位置参数传入
21
+ * (`… <dshRoot> <home>\profiles\desktop …`)。
22
+ * 桌面端只认 `--profile` 会永远落回 fallback,把路由/日志/安装全挂到错误的 profile 上
23
+ * (日志写进 profiles/web/hub.log、市场必然「插件数据加载失败」,dsh-plugin-hub#65)。
24
+ * 位置参数按「父目录名为 profiles」精确识别 profile 目录,避免误取其它位置参数。
25
+ */
26
+ export function profileFromArgv(argv, fallback = 'web') {
27
+ const index = argv.indexOf('--profile');
28
+ const candidate = index >= 0 ? argv[index + 1] : undefined;
29
+ if (candidate !== undefined && !candidate.startsWith('-'))
30
+ return candidate;
31
+ const equals = argv.find((arg) => arg.startsWith('--profile='));
32
+ if (equals !== undefined) {
33
+ const name = equals.slice('--profile='.length);
34
+ if (name !== '')
35
+ return name;
36
+ }
37
+ for (const arg of argv.slice(2)) {
38
+ if (arg === '' || arg.startsWith('-'))
39
+ continue;
40
+ if (basename(dirname(arg)) !== 'profiles')
41
+ continue;
42
+ const name = basename(arg);
43
+ if (name !== '')
44
+ return name;
45
+ }
46
+ return fallback;
18
47
  }
19
48
  /** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */
20
49
  export function profileDirectory(profile) {
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
3
3
  * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
4
- * 原有错误路径,反查只是额外的一次尝试。
4
+ * 原有错误路径,反查只是额外的一次尝试。只有「已确认」的结果(包名,或 200
5
+ * 响应下确认无匹配)会进缓存;网络异常/超时/解析失败不缓存,同一会话内下次
6
+ * 安装仍会重试反查,避免一次瞬时故障把整个会话锁死在 git 通道。
5
7
  * registry 参数:npm 镜像源地址,空串 = 官方源;与安装通道吃同一 registry,
6
8
  * 保证「配置了镜像」时反查和安装走同一个源(镜像节点同步完整时结果一致)。
7
9
  * 慢网络下失败不阻塞安装。
@@ -0,0 +1,21 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * 包入口解析:从 package.json 的 exports["."] / main 推出用于校验的入口文件。
7
+ * git 通道(tar 预检)与 npm 通道(装后 statSync 校验)共用,保证两条通道
8
+ * 对同一个包判定一致。
9
+ */
10
+ /**
11
+ * 解析包的入口文件(相对包根,不带 `./` 前缀)。
12
+ *
13
+ * Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
14
+ * 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
15
+ * (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
16
+ * 个字符串值。无 exports 时回退 main,再回退 index.js。
17
+ */
18
+ export declare function resolvePackageEntry(meta: {
19
+ main?: unknown;
20
+ exports?: unknown;
21
+ }): string;
@@ -27,6 +27,15 @@ export declare function curlTlsArgs(): string[];
27
27
  export declare function curlRevokeFlagUnsupported(code: number | null, stderr: string): boolean;
28
28
  /** stderr 是否指向 Schannel 取不到证书吊销信息(据此归因为环境 TLS 问题,而非网络不通)。 */
29
29
  export declare function tlsRevocationBlocked(stderr: string): boolean;
30
+ /**
31
+ * 从 `reg query … /v ProxyServer` 的输出里解析出 `http://host:port`;解析不出返回 null。
32
+ * 注册表里的值常见三种写法:`http://127.0.0.1:10793`(带 scheme)、`127.0.0.1:10793`、
33
+ * `http=host:port;https=host:port`(按协议分别配置)。
34
+ * 字符类必须同时排除 `/` 与 `=`:只排除 `=` 时,带 scheme 的值会让「主机名」吞掉 `//`,
35
+ * 拼出 `http:////127.0.0.1:10793` 这种立刻失败的非法地址 —— 代理本来是通的,却把诊断、
36
+ * 目录拉取、npm/git 预检全判成「不可达」(dsh-plugin-hub#66)。
37
+ */
38
+ export declare function parseWinProxyServer(stdout: string): string | null;
30
39
  /**
31
40
  * 操作系统级代理(macOS 系统网络设置 / Windows Internet 设置)。
32
41
  * Node 内置 http(s) 不读系统代理,浏览器挂的代理 Node 看不见 —— 这里读出来
@@ -1,5 +1,15 @@
1
1
  /** Resolve the active profile from the booted CLI args, falling back to `web`. */
2
2
  export declare function readProfileArg(fallback?: string): string;
3
+ /**
4
+ * 从启动参数解析当前 profile,两种宿主形态都认:
5
+ * - 官方 CLI:`--profile <name>`(也兼容 `--profile=<name>` 等号形式);
6
+ * - Electron 桌面宿主:不传 `--profile`,而是把 profile 目录作为位置参数传入
7
+ * (`… <dshRoot> <home>\profiles\desktop …`)。
8
+ * 桌面端只认 `--profile` 会永远落回 fallback,把路由/日志/安装全挂到错误的 profile 上
9
+ * (日志写进 profiles/web/hub.log、市场必然「插件数据加载失败」,dsh-plugin-hub#65)。
10
+ * 位置参数按「父目录名为 profiles」精确识别 profile 目录,避免误取其它位置参数。
11
+ */
12
+ export declare function profileFromArgv(argv: readonly string[], fallback?: string): string;
3
13
  /** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */
4
14
  export declare function profileDirectory(profile: string): string;
5
15
  /** Build a safe explicit HTTPS Git target, or null when the repo is unsafe. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.4.7",
4
- "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 9000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):9000+ 人工精选社区插件,每日更新。",
3
+ "version": "1.4.9",
4
+ "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -495,13 +495,18 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
495
495
  const child = spawn(bin, args)
496
496
  let out = ''
497
497
  child.stdout.on('data', (d) => { out += String(d) })
498
- child.on('error', () => sendJson(response, 500, { error: 'directory picker unavailable' }))
499
- child.on('close', (code) => {
498
+ // spawn 失败(如 Linux 无 zenity)会先触发 error 再触发 close(code=-2):
499
+ // 只允许第一个事件应答,否则第二次 sendJson 对已结束的响应再 writeHead,
500
+ // 抛 ERR_HTTP_HEADERS_SENT 成为宿主进程内的未捕获异常。
501
+ let done = false
502
+ const finish = (reply: () => void) => { if (!done) { done = true; reply() } }
503
+ child.on('error', () => finish(() => sendJson(response, 500, { error: 'directory picker unavailable' })))
504
+ child.on('close', (code) => finish(() => {
500
505
  // macOS osascript 输出带引号(如 "/Users/x"),统一剥掉首尾空白与引号
501
506
  const picked = out.trim().replace(/^"|"$/g, '')
502
507
  if (code === 0 && picked !== '') sendJson(response, 200, { path: picked })
503
508
  else sendJson(response, 200, { cancelled: true })
504
- })
509
+ }))
505
510
  } catch {
506
511
  sendJson(response, 500, { error: 'directory picker unavailable' })
507
512
  }
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { get } from 'node:https'
15
15
 
16
- /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包)。 */
16
+ /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包;网络异常不缓存)。 */
17
17
  const cache = new Map<string, string | null>()
18
18
 
19
19
  /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
@@ -22,7 +22,9 @@ const REQUEST_TIMEOUT_MS = 8000
22
22
  /**
23
23
  * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
24
24
  * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
25
- * 原有错误路径,反查只是额外的一次尝试。
25
+ * 原有错误路径,反查只是额外的一次尝试。只有「已确认」的结果(包名,或 200
26
+ * 响应下确认无匹配)会进缓存;网络异常/超时/解析失败不缓存,同一会话内下次
27
+ * 安装仍会重试反查,避免一次瞬时故障把整个会话锁死在 git 通道。
26
28
  * registry 参数:npm 镜像源地址,空串 = 官方源;与安装通道吃同一 registry,
27
29
  * 保证「配置了镜像」时反查和安装走同一个源(镜像节点同步完整时结果一致)。
28
30
  * 慢网络下失败不阻塞安装。
@@ -31,12 +33,15 @@ export function resolveNpmPackage(repo: string, registry = ''): Promise<string |
31
33
  const key = repo.toLowerCase()
32
34
  if (cache.has(key)) return Promise.resolve(cache.get(key) ?? null)
33
35
  const name = searchRepo(repo, registry)
34
- name.then((found) => cache.set(key, found)).catch(() => cache.set(key, null))
35
- return name
36
+ void name.then((found) => {
37
+ if (found !== undefined) cache.set(key, found)
38
+ })
39
+ return name.then((found) => found ?? null)
36
40
  }
37
41
 
38
- /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 */
39
- function searchRepo(repo: string, registry: string): Promise<string | null> {
42
+ /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
43
+ * 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */
44
+ function searchRepo(repo: string, registry: string): Promise<string | null | undefined> {
40
45
  const [owner, name] = repo.split('/')
41
46
  if (owner === undefined || name === undefined || name === '') return Promise.resolve(null)
42
47
  const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '')
@@ -45,7 +50,7 @@ function searchRepo(repo: string, registry: string): Promise<string | null> {
45
50
  const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
46
51
  if (res.statusCode !== 200) {
47
52
  res.resume()
48
- resolve(null)
53
+ resolve(undefined)
49
54
  return
50
55
  }
51
56
  let body = ''
@@ -65,11 +70,11 @@ function searchRepo(repo: string, registry: string): Promise<string | null> {
65
70
  }
66
71
  resolve(null)
67
72
  } catch {
68
- resolve(null)
73
+ resolve(undefined)
69
74
  }
70
75
  })
71
76
  })
72
77
  req.on('timeout', () => req.destroy())
73
- req.on('error', () => resolve(null))
78
+ req.on('error', () => resolve(undefined))
74
79
  })
75
80
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * 包入口解析:从 package.json 的 exports["."] / main 推出用于校验的入口文件。
7
+ * git 通道(tar 预检)与 npm 通道(装后 statSync 校验)共用,保证两条通道
8
+ * 对同一个包判定一致。
9
+ */
10
+
11
+ /** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */
12
+ const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'] as const
13
+
14
+ /**
15
+ * 解析包的入口文件(相对包根,不带 `./` 前缀)。
16
+ *
17
+ * Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
18
+ * 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
19
+ * (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
20
+ * 个字符串值。无 exports 时回退 main,再回退 index.js。
21
+ */
22
+ export function resolvePackageEntry(meta: { main?: unknown; exports?: unknown }): string {
23
+ const dot = (meta.exports as Record<string, unknown> | undefined)?.['.']
24
+ const fromExports = typeof dot === 'string'
25
+ ? dot
26
+ : dot !== null && typeof dot === 'object'
27
+ ? firstStringOf(dot as Record<string, unknown>)
28
+ : undefined
29
+ const candidate = typeof fromExports === 'string' ? fromExports
30
+ : typeof meta.main === 'string' ? meta.main
31
+ : 'index.js'
32
+ return candidate.replace(/^\.\//u, '')
33
+ }
34
+
35
+ function firstStringOf(conditions: Record<string, unknown>): string | undefined {
36
+ for (const key of EXPORT_CONDITION_KEYS) {
37
+ const value = conditions[key]
38
+ if (typeof value === 'string' && value !== '') return value
39
+ }
40
+ return undefined
41
+ }
@@ -18,6 +18,7 @@ import { join } from 'node:path'
18
18
  import { pipeline } from 'node:stream/promises'
19
19
  import { promisify } from 'node:util'
20
20
  import { githubRepoOf } from '../profile/profile.ts'
21
+ import { resolvePackageEntry } from './package-entry.ts'
21
22
 
22
23
  const execFileAsync = promisify(execFile)
23
24
 
@@ -56,13 +57,7 @@ export async function preflightTarget(target: string): Promise<PreflightResult>
56
57
  await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar)
57
58
  const meta = await readTarJson(tar, 'package/package.json')
58
59
  if (meta === null) return { ok: true, missing: null, name: null }
59
- const dot = (meta.exports as Record<string, unknown> | undefined)?.['.']
60
- const resolved = typeof dot === 'string'
61
- ? dot
62
- : dot !== null && typeof dot === 'object'
63
- ? (dot as Record<string, unknown>).default
64
- : undefined
65
- const entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js'
60
+ const entry = resolvePackageEntry(meta)
66
61
  const inTar = await hasTarEntry(tar, `package/${entry}`)
67
62
  return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null }
68
63
  } catch {
@@ -8,6 +8,7 @@
8
8
  * 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
9
9
  */
10
10
  import { spawn, spawnSync } from 'node:child_process'
11
+ import { resolvePackageEntry } from './package-entry.ts'
11
12
  import { readFileSync, statSync } from 'node:fs'
12
13
  import { dirname, join, resolve } from 'node:path'
13
14
  import type { InstallResult, InstallTask, Invocation, QueueItem } from './install-types.ts'
@@ -258,7 +259,19 @@ function pumpQueue(): void {
258
259
  if (!item) return
259
260
  const { task, options } = item
260
261
  task.status = 'running'
261
- void runPluginMutation({ ...options, task }).finally(() => {
262
+ void runPluginMutation({ ...options, task })
263
+ .catch((error) => {
264
+ // runPluginMutation 的恢复路径(如 allowBuilds 写回 pnpm-workspace.yaml)
265
+ // 可能抛出;落到 failed 终态并留痕,否则该 Promise 链成为未处理 rejection
266
+ //(Node >= 15 默认直接终止进程),而队列自身却在 finally 里继续 pump。
267
+ if (task.status !== 'cancelled') {
268
+ task.status = 'failed'
269
+ task.exitCode = null
270
+ task.progress = 0
271
+ pushLine(task, `[internal] ${error instanceof Error ? error.message : String(error)}`)
272
+ }
273
+ })
274
+ .finally(() => {
262
275
  runningChildren.delete(task.id)
263
276
  // 系统日志:任务终态(成功/失败/取消),按动作归类安装/卸载/更新日志
264
277
  const show = task.displayTarget ?? task.target
@@ -547,12 +560,7 @@ export function verifyInstalledEntry(profile: string, target: string): { name: s
547
560
  main?: unknown
548
561
  exports?: unknown
549
562
  }
550
- const dot = (meta.exports as Record<string, unknown> | undefined)?.['.']
551
- const resolved = typeof dot === 'string' ? dot
552
- : dot !== null && typeof dot === 'object'
553
- ? (dot as Record<string, unknown>).default
554
- : undefined
555
- entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js'
563
+ entry = resolvePackageEntry(meta)
556
564
  } catch {
557
565
  return { name, missing: null }
558
566
  }
@@ -90,6 +90,20 @@ function macSystemProxy(): string | null {
90
90
  }
91
91
  }
92
92
 
93
+ /**
94
+ * 从 `reg query … /v ProxyServer` 的输出里解析出 `http://host:port`;解析不出返回 null。
95
+ * 注册表里的值常见三种写法:`http://127.0.0.1:10793`(带 scheme)、`127.0.0.1:10793`、
96
+ * `http=host:port;https=host:port`(按协议分别配置)。
97
+ * 字符类必须同时排除 `/` 与 `=`:只排除 `=` 时,带 scheme 的值会让「主机名」吞掉 `//`,
98
+ * 拼出 `http:////127.0.0.1:10793` 这种立刻失败的非法地址 —— 代理本来是通的,却把诊断、
99
+ * 目录拉取、npm/git 预检全判成「不可达」(dsh-plugin-hub#66)。
100
+ */
101
+ export function parseWinProxyServer(stdout: string): string | null {
102
+ const m = /([^:\s=/]+):(\d+)/.exec(stdout)
103
+ if (m === null) return null
104
+ return `http://${m[1]}:${m[2]}`
105
+ }
106
+
93
107
  /** Windows 系统代理:WinINET Internet 设置注册表;未开启返回 null。 */
94
108
  function winSystemProxy(): string | null {
95
109
  try {
@@ -97,9 +111,7 @@ function winSystemProxy(): string | null {
97
111
  const enable = spawnSync('reg', ['query', key, '/v', 'ProxyEnable'], { encoding: 'utf8', timeout: 1500 })
98
112
  const server = spawnSync('reg', ['query', key, '/v', 'ProxyServer'], { encoding: 'utf8', timeout: 1500 })
99
113
  if (!/0x1/i.test(enable.stdout ?? '')) return null
100
- const m = /([^:\s=]+):(\d+)/.exec(server.stdout ?? '')
101
- if (m === null) return null
102
- return `http://${m[1]}:${m[2]}`
114
+ return parseWinProxyServer(server.stdout ?? '')
103
115
  } catch {
104
116
  return null
105
117
  }
@@ -8,14 +8,39 @@
8
8
  */
9
9
  import { readFileSync, writeFileSync } from 'node:fs'
10
10
  import { homedir } from 'node:os'
11
- import { join } from 'node:path'
11
+ import { basename, dirname, join } from 'node:path'
12
12
  import { PACKAGE_RE, REPO_RE } from '../install/install-types.ts'
13
13
 
14
14
  /** Resolve the active profile from the booted CLI args, falling back to `web`. */
15
15
  export function readProfileArg(fallback = 'web'): string {
16
- const index = process.argv.indexOf('--profile')
17
- const candidate = index >= 0 ? process.argv[index + 1] : undefined
18
- return candidate !== undefined && !candidate.startsWith('-') ? candidate : fallback
16
+ return profileFromArgv(process.argv, fallback)
17
+ }
18
+
19
+ /**
20
+ * 从启动参数解析当前 profile,两种宿主形态都认:
21
+ * - 官方 CLI:`--profile <name>`(也兼容 `--profile=<name>` 等号形式);
22
+ * - Electron 桌面宿主:不传 `--profile`,而是把 profile 目录作为位置参数传入
23
+ * (`… <dshRoot> <home>\profiles\desktop …`)。
24
+ * 桌面端只认 `--profile` 会永远落回 fallback,把路由/日志/安装全挂到错误的 profile 上
25
+ * (日志写进 profiles/web/hub.log、市场必然「插件数据加载失败」,dsh-plugin-hub#65)。
26
+ * 位置参数按「父目录名为 profiles」精确识别 profile 目录,避免误取其它位置参数。
27
+ */
28
+ export function profileFromArgv(argv: readonly string[], fallback = 'web'): string {
29
+ const index = argv.indexOf('--profile')
30
+ const candidate = index >= 0 ? argv[index + 1] : undefined
31
+ if (candidate !== undefined && !candidate.startsWith('-')) return candidate
32
+ const equals = argv.find((arg) => arg.startsWith('--profile='))
33
+ if (equals !== undefined) {
34
+ const name = equals.slice('--profile='.length)
35
+ if (name !== '') return name
36
+ }
37
+ for (const arg of argv.slice(2)) {
38
+ if (arg === '' || arg.startsWith('-')) continue
39
+ if (basename(dirname(arg)) !== 'profiles') continue
40
+ const name = basename(arg)
41
+ if (name !== '') return name
42
+ }
43
+ return fallback
19
44
  }
20
45
 
21
46
  /** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */