dsh-plugin 2.2.4 → 2.2.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 +12 -20
- package/README.md +12 -20
- package/client/client.js +386 -326
- package/client/client.js.map +1 -1
- package/lib/http/shared.js +1 -1
- package/lib/http/task.js +3 -2
- package/lib/services/install/task-queue.js +4 -2
- package/lib/services/runtime-exec.js +24 -0
- package/lib/types/http/shared.d.ts +1 -1
- package/lib/types/services/runtime-exec.d.ts +17 -0
- package/package.json +1 -1
- package/src/client/components/catalog/CatalogList.tsx +4 -1
- package/src/client/components/catalog/PluginCard.tsx +10 -3
- package/src/client/components/modals/InstalledDetailModal.tsx +5 -2
- package/src/client/components/views/InstalledView.tsx +5 -1
- package/src/client/components/views/MarketView.tsx +1 -0
- package/src/client/hooks/useCatalog.ts +35 -11
- package/src/client/locales.ts +6 -0
- package/src/client/logic/format.ts +10 -0
- package/src/client/logic/installed.ts +4 -0
- package/src/client/logic/release-age.ts +26 -5
- package/src/server/http/shared.ts +1 -1
- package/src/server/http/task.ts +3 -2
- package/src/server/services/install/task-queue.ts +4 -2
- package/src/server/services/runtime-exec.ts +28 -0
package/lib/http/shared.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
|
|
3
3
|
* 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
4
|
-
* 由 `spawn(
|
|
4
|
+
* 由 `spawn(runtimeExecPath(), ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
5
5
|
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
6
6
|
* 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
|
|
7
7
|
*/
|
package/lib/http/task.js
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from 'node:path';
|
|
|
10
10
|
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, listPendingRestarts, validPackageName } from '../services/install/install.js';
|
|
11
11
|
import { readInstalledVersions, recordInstalledVersion, removeInstalledVersion } from '../services/profile/installed-versions.js';
|
|
12
12
|
import { isDshPlugin, isEntryLoaded } from '../services/loader.js';
|
|
13
|
+
import { runtimeExecPath } from '../services/runtime-exec.js';
|
|
13
14
|
import { profileDirectory, readInstalled } from './catalog.js';
|
|
14
15
|
import { hostEnv } from './diagnostics.js';
|
|
15
16
|
import { HOST_RESTART_SCRIPT, isDesktopHost, readJsonBody, requireMethod, requireTrustedPost, sendJson } from './shared.js';
|
|
@@ -102,9 +103,9 @@ export function registerTaskRoutes(webServer, profile, loader) {
|
|
|
102
103
|
// detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
|
|
103
104
|
// 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
|
|
104
105
|
// 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
|
|
105
|
-
// 故孵化 node(
|
|
106
|
+
// 故孵化 node(runtimeExecPath()) 跑跨平台脚本 —— Windows 用
|
|
106
107
|
// netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
|
|
107
|
-
spawn(
|
|
108
|
+
spawn(runtimeExecPath(), ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref();
|
|
108
109
|
sendJson(response, 200, { ok: true, port });
|
|
109
110
|
},
|
|
110
111
|
},
|
|
@@ -24,6 +24,7 @@ import { commandOnPath, gitMissingDetail } from './preflight.js';
|
|
|
24
24
|
import { isTransportFailure, registryHost } from './registry-fallback.js';
|
|
25
25
|
import { dshTooOldMarker } from './dsh-compat.js';
|
|
26
26
|
import { OutputDecoder } from './decode-output.js';
|
|
27
|
+
import { runtimeExecPath } from '../runtime-exec.js';
|
|
27
28
|
/** 内存任务注册表,按任务 id 索引。 */
|
|
28
29
|
const tasks = new Map();
|
|
29
30
|
let nextTaskId = 1;
|
|
@@ -151,7 +152,8 @@ function cliInvocation(profile) {
|
|
|
151
152
|
const bootstrap = desktopCliBootstrap(entry);
|
|
152
153
|
if (bootstrap !== null) {
|
|
153
154
|
return {
|
|
154
|
-
|
|
155
|
+
// Android 上 execPath 是 linker64,直接用会把 --expose-internals 当可执行文件
|
|
156
|
+
file: runtimeExecPath(),
|
|
155
157
|
prefixArgs: ['--expose-internals', bootstrap],
|
|
156
158
|
// cwd 用 profile 目录(asar 路径不能作 cwd),不存在时由 spawnCwdFor 回退
|
|
157
159
|
cwd: spawnCwdFor(profile),
|
|
@@ -163,7 +165,7 @@ function cliInvocation(profile) {
|
|
|
163
165
|
if (/[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
|
|
164
166
|
const absoluteEntry = resolve(entry);
|
|
165
167
|
return {
|
|
166
|
-
file:
|
|
168
|
+
file: runtimeExecPath(),
|
|
167
169
|
prefixArgs: [...process.execArgv, absoluteEntry],
|
|
168
170
|
cwd: dirname(absoluteEntry),
|
|
169
171
|
useShell: false,
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
* 宿主运行时路径:需要 spawn 一个「和宿主同款」的 Node/Electron 进程时用它取可执行文件。
|
|
7
|
+
*/
|
|
8
|
+
/** Android 动态链接器的错误信息形如 `error: expected absolute path: "--expose-internals"`:
|
|
9
|
+
* linker64 的第一个参数必须是「被加载的可执行文件绝对路径」,传选项就会被拒绝。 */
|
|
10
|
+
const LINKER_RE = /(^|[\\/])linker(32|64)?$/i;
|
|
11
|
+
/**
|
|
12
|
+
* 宿主进程自身的可执行文件路径。
|
|
13
|
+
*
|
|
14
|
+
* 大多数平台就是 `process.execPath`;但 Android(bionic)上它常常是
|
|
15
|
+
* `/system/bin/linker64`(宿主由 `linker64 node …` 拉起)。这时拿 execPath 去 spawn
|
|
16
|
+
* 任何带参数的命令(如 `--expose-internals <cli.js>`)都会把第一个参数当成可执行文件,
|
|
17
|
+
* 直接报 `expected absolute path`,子进程什么都跑不起来。这类环境里真正的程序路径在
|
|
18
|
+
* `process.argv0`,故检测到链接器时改用它。
|
|
19
|
+
*/
|
|
20
|
+
export function runtimeExecPath(exec = process.execPath, argv0 = process.argv0) {
|
|
21
|
+
if (!LINKER_RE.test(exec))
|
|
22
|
+
return exec;
|
|
23
|
+
return argv0 && argv0 !== exec ? argv0 : exec;
|
|
24
|
+
}
|
|
@@ -10,7 +10,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
10
10
|
/**
|
|
11
11
|
* 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
|
|
12
12
|
* 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
13
|
-
* 由 `spawn(
|
|
13
|
+
* 由 `spawn(runtimeExecPath(), ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
14
14
|
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
15
15
|
* 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
|
|
16
16
|
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
* 宿主运行时路径:需要 spawn 一个「和宿主同款」的 Node/Electron 进程时用它取可执行文件。
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* 宿主进程自身的可执行文件路径。
|
|
10
|
+
*
|
|
11
|
+
* 大多数平台就是 `process.execPath`;但 Android(bionic)上它常常是
|
|
12
|
+
* `/system/bin/linker64`(宿主由 `linker64 node …` 拉起)。这时拿 execPath 去 spawn
|
|
13
|
+
* 任何带参数的命令(如 `--expose-internals <cli.js>`)都会把第一个参数当成可执行文件,
|
|
14
|
+
* 直接报 `expected absolute path`,子进程什么都跑不起来。这类环境里真正的程序路径在
|
|
15
|
+
* `process.argv0`,故检测到链接器时改用它。
|
|
16
|
+
*/
|
|
17
|
+
export declare function runtimeExecPath(exec?: string, argv0?: string): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.6",
|
|
4
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",
|
|
@@ -13,7 +13,7 @@ import { siteUrl } from '../../logic/urls.ts'
|
|
|
13
13
|
import { useIncrementalList } from '../../hooks/useIncrementalList.ts'
|
|
14
14
|
import { PluginCard } from './PluginCard.tsx'
|
|
15
15
|
|
|
16
|
-
export function CatalogList({ plugins, failed, visible, total, t, langPath, reload, forceReload, category, copied, installedName, installedVersion, hasUpdate, langKey, onInstall, onUninstall, hasProxy, onOpenDiagnostics }: {
|
|
16
|
+
export function CatalogList({ plugins, failed, visible, total, t, langPath, reload, forceReload, category, copied, installedName, installedVersion, hasUpdate, updatePendingUntil, langKey, onInstall, onUninstall, hasProxy, onOpenDiagnostics }: {
|
|
17
17
|
plugins: HubPlugin[] | null
|
|
18
18
|
failed: boolean
|
|
19
19
|
visible: HubPlugin[]
|
|
@@ -28,6 +28,8 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
|
|
|
28
28
|
installedName: (p: HubPlugin) => string | null
|
|
29
29
|
installedVersion: (p: HubPlugin) => string | null
|
|
30
30
|
hasUpdate: (p: HubPlugin) => boolean
|
|
31
|
+
/** 更新被供应链门禁暂缓时的可重试时刻(毫秒);无则 null */
|
|
32
|
+
updatePendingUntil: (p: HubPlugin) => number | null
|
|
31
33
|
langKey: LocaleId
|
|
32
34
|
onInstall: (p: HubPlugin, opts?: { update?: boolean }) => void
|
|
33
35
|
onUninstall: (p: HubPlugin) => void
|
|
@@ -72,6 +74,7 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
|
|
|
72
74
|
installedName,
|
|
73
75
|
installedVersion,
|
|
74
76
|
hasUpdate,
|
|
77
|
+
updatePendingUntil,
|
|
75
78
|
t,
|
|
76
79
|
langKey,
|
|
77
80
|
langPath,
|
|
@@ -11,16 +11,18 @@ import styles from '../../styles/List.module.css'
|
|
|
11
11
|
import type { HubPlugin, LocaleId, Translate } from '../../types.ts'
|
|
12
12
|
import { CATEGORY_LABELS, categoryLabel } from '../../logic/constants.ts'
|
|
13
13
|
import { pluginDetailUrl } from '../../logic/urls.ts'
|
|
14
|
-
import { fmtStars, relTime } from '../../logic/format.ts'
|
|
14
|
+
import { fmtStars, clockTime, relTime } from '../../logic/format.ts'
|
|
15
15
|
|
|
16
|
-
export function PluginCard({ plugin: p, copied, installedName, installedVersion, hasUpdate, t, langKey, langPath, onInstall, onUninstall }: {
|
|
16
|
+
export function PluginCard({ plugin: p, copied, installedName, installedVersion, hasUpdate, updatePendingUntil, t, langKey, langPath, onInstall, onUninstall }: {
|
|
17
17
|
plugin: HubPlugin
|
|
18
18
|
copied: string | null
|
|
19
19
|
installedName: (p: HubPlugin) => string | null
|
|
20
20
|
/** 已安装版本号(安装时从目录信号记录;无 release 的仓库为空,无版本号可展示) */
|
|
21
21
|
installedVersion: (p: HubPlugin) => string | null
|
|
22
|
-
/**
|
|
22
|
+
/** 已安装且目录有更新(有版本比版本、无版本比仓库更新时间);门禁复核未就绪时为 false */
|
|
23
23
|
hasUpdate: (p: HubPlugin) => boolean
|
|
24
|
+
/** 更新被供应链门禁暂缓时的可重试时刻(毫秒);无则 null */
|
|
25
|
+
updatePendingUntil: (p: HubPlugin) => number | null
|
|
24
26
|
t: Translate
|
|
25
27
|
langKey: LocaleId
|
|
26
28
|
langPath: string
|
|
@@ -33,6 +35,8 @@ export function PluginCard({ plugin: p, copied, installedName, installedVersion,
|
|
|
33
35
|
const pkg = installedName(p)
|
|
34
36
|
const isInstalled = pkg !== null
|
|
35
37
|
const update = hasUpdate(p)
|
|
38
|
+
// 门禁暂缓:更新确实存在但现在装不上,给确切时刻而不是让徽标静默消失
|
|
39
|
+
const pendingUntil = isInstalled && !update ? updatePendingUntil(p) : null
|
|
36
40
|
// 版本号展示:已安装 → 当前已装版本(最能反映用户状态);未安装 → 目录最新版本
|
|
37
41
|
const versionShown = isInstalled ? installedVersion(p) : p.version
|
|
38
42
|
|
|
@@ -44,6 +48,9 @@ export function PluginCard({ plugin: p, copied, installedName, installedVersion,
|
|
|
44
48
|
versionShown ? h('span', { className: styles.versionBadge, title: t('version') }, versionShown) : null,
|
|
45
49
|
// 已安装且有更新:醒目提示(有 release 比版本号、无 release 比仓库最近更新时间)
|
|
46
50
|
update ? h('span', { className: styles.updateBadge, title: t('updateAvailableHint') }, t('updateAvailable')) : null,
|
|
51
|
+
pendingUntil !== null
|
|
52
|
+
? h('span', { className: styles.updateBadge, title: t('updatePendingHint') }, t('updatePending', { time: clockTime(pendingUntil) }))
|
|
53
|
+
: null,
|
|
47
54
|
p.category ? h('span', { className: styles.categoryBadge }, categoryLabel(CATEGORY_LABELS, p.category, langKey)) : null,
|
|
48
55
|
p.compatibility?.status === 'verified'
|
|
49
56
|
? h('span', { className: styles.verified }, t('verified'))
|
|
@@ -14,7 +14,7 @@ import type { LocaleId, Translate } from '../../types.ts'
|
|
|
14
14
|
import type { InstalledItem } from '../../logic/installed.ts'
|
|
15
15
|
import { CATEGORY_LABELS, categoryLabel } from '../../logic/constants.ts'
|
|
16
16
|
import { pluginDetailUrl } from '../../logic/urls.ts'
|
|
17
|
-
import { fmtStars } from '../../logic/format.ts'
|
|
17
|
+
import { clockTime, fmtStars } from '../../logic/format.ts'
|
|
18
18
|
import { CloseIcon, CopyIcon, FolderIcon, LinkIcon } from '../ui/icons.tsx'
|
|
19
19
|
|
|
20
20
|
/** ISO 安装时间 → 本地可读;非法值返回 null(行内不显示)。 */
|
|
@@ -69,7 +69,10 @@ export function InstalledDetailModal({ item, t, lang, langPath, onClose, onUpdat
|
|
|
69
69
|
item.installedVersion && item.catalogVersion
|
|
70
70
|
? t('versionUpHint', { from: item.installedVersion, to: item.catalogVersion })
|
|
71
71
|
: t('updateAvailableHint'))
|
|
72
|
-
: null
|
|
72
|
+
: item.pendingUntil != null
|
|
73
|
+
// 门禁暂缓:给确切可重试时刻,而不是让「有更新」静默消失
|
|
74
|
+
? h('div', { className: styles.detailUpdateHint }, t('updatePending', { time: clockTime(item.pendingUntil) }))
|
|
75
|
+
: null,
|
|
73
76
|
// 详情信息网格:分类 / 状态 / 版本 / 收录入口 / 包名 / 统计 / 安装路径 / 安装时间 / 最后更新
|
|
74
77
|
h('div', { className: styles.detailGrid },
|
|
75
78
|
p?.category
|
|
@@ -17,6 +17,7 @@ import styles from '../../styles/InstalledView.module.css'
|
|
|
17
17
|
import type { LocaleId, Translate } from '../../types.ts'
|
|
18
18
|
import type { InstalledItem } from '../../logic/installed.ts'
|
|
19
19
|
import { CATEGORY_LABELS, categoryLabel } from '../../logic/constants.ts'
|
|
20
|
+
import { clockTime } from '../../logic/format.ts'
|
|
20
21
|
import { SortArrowIcon } from '../ui/icons.tsx'
|
|
21
22
|
|
|
22
23
|
const INSTALLED_SORTS = ['sortName', 'sortInstalledAt', 'sortStars', 'sortForks'] as const
|
|
@@ -108,7 +109,10 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
|
|
|
108
109
|
: null,
|
|
109
110
|
item.hasUpdate
|
|
110
111
|
? h('span', { className: styles.updateBadge, title: t('updateAvailableHint') }, t('updateAvailable'))
|
|
111
|
-
: null
|
|
112
|
+
: item.pendingUntil != null
|
|
113
|
+
// 门禁暂缓:更新存在但还装不上,给确切时刻,避免看起来「已是最新」
|
|
114
|
+
? h('span', { className: styles.updateBadge, title: t('updatePendingHint') }, t('updatePending', { time: clockTime(item.pendingUntil) }))
|
|
115
|
+
: null,
|
|
112
116
|
),
|
|
113
117
|
// 来源标签列(行尾、操作按钮前,右对齐固定):目录插件 →「插件市场」(品牌色),
|
|
114
118
|
// 命令行安装 →「手动安装」(琥珀色);两词 4 字等宽,所有条目标签同列对齐
|
|
@@ -59,6 +59,7 @@ export function MarketView({ catalog, t, langPath, langKey, copied, resultText,
|
|
|
59
59
|
installedName: catalog.installedName,
|
|
60
60
|
installedVersion: catalog.installedVersion,
|
|
61
61
|
hasUpdate: catalog.hasUpdate,
|
|
62
|
+
updatePendingUntil: catalog.updatePendingUntil,
|
|
62
63
|
langKey,
|
|
63
64
|
onInstall,
|
|
64
65
|
onUninstall,
|
|
@@ -16,7 +16,7 @@ import type { SortKey } from '../logic/constants.ts'
|
|
|
16
16
|
import { fetchCatalog, fetchStats } from '../data/catalog.ts'
|
|
17
17
|
import { fetchHubAbout, fetchHubUpdate } from '../data/hub.ts'
|
|
18
18
|
import { fetchInstalled, fetchNpmReleaseAge, type NpmReleaseInfo } from '../data/host.ts'
|
|
19
|
-
import { isReleaseTooFresh,
|
|
19
|
+
import { isReleaseTooFresh, pickHubUpdate, updateDecision } from '../logic/release-age.ts'
|
|
20
20
|
import {
|
|
21
21
|
hasUpdateOf, installedItemsOf, installedNameOf, installedVersionOf, npmPackageOf,
|
|
22
22
|
} from '../logic/installed.ts'
|
|
@@ -248,16 +248,24 @@ export function useCatalog(lang: LocaleId) {
|
|
|
248
248
|
* - npm 上最新版并不高于已装版本 —— 更新走的是 `pkg@latest`,此时无论目录标到哪个 tag,
|
|
249
249
|
* 都不可能装上(作者可能只打了 GitHub release 没发 npm),提示只会变成永远点不掉的循环。
|
|
250
250
|
*/
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
if (!releaseAgeReady) return
|
|
251
|
+
const updateDecisions = useMemo(() => {
|
|
252
|
+
const map = new Map<string, { offered: boolean; pendingUntil: number | null }>()
|
|
253
|
+
if (!releaseAgeReady) return map
|
|
254
254
|
const now = Date.now()
|
|
255
255
|
for (const pkg of releaseAgeCandidates) {
|
|
256
|
-
|
|
256
|
+
map.set(pkg, updateDecision(releaseAge.releases[pkg] ?? null, installedByPackage.get(pkg) ?? null, now))
|
|
257
257
|
}
|
|
258
|
-
return
|
|
258
|
+
return map
|
|
259
259
|
}, [releaseAgeReady, releaseAgeCandidates, releaseAge.releases, installedByPackage])
|
|
260
260
|
|
|
261
|
+
const updateBlocked = useMemo(() => {
|
|
262
|
+
const blocked = new Set<string>()
|
|
263
|
+
for (const [pkg, decision] of updateDecisions) {
|
|
264
|
+
if (!decision.offered) blocked.add(pkg)
|
|
265
|
+
}
|
|
266
|
+
return blocked
|
|
267
|
+
}, [updateDecisions])
|
|
268
|
+
|
|
261
269
|
/**
|
|
262
270
|
* 是否有更新:仅对已安装插件有意义。
|
|
263
271
|
* 双信号判定——有 release 版本的比版本;无版本(repo 不打 tag)的比仓库最近更新时间
|
|
@@ -267,18 +275,33 @@ export function useCatalog(lang: LocaleId) {
|
|
|
267
275
|
const hasUpdate = (p: HubPlugin): boolean => {
|
|
268
276
|
if (!hasUpdateOf(p, installed, versions)) return false
|
|
269
277
|
const pkg = npmPackageOf(p, versions)
|
|
270
|
-
|
|
278
|
+
if (pkg === null) return true
|
|
279
|
+
// 复核没回来时先不亮:否则会出现「先亮有更新、几秒后又撤掉」
|
|
280
|
+
return releaseAgeReady && !updateBlocked.has(pkg)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 更新被门禁暂缓时的可重试时刻(毫秒);未安装/无更新/非门禁原因 → null。 */
|
|
284
|
+
const updatePendingUntil = (p: HubPlugin): number | null => {
|
|
285
|
+
if (!hasUpdateOf(p, installed, versions)) return null
|
|
286
|
+
const pkg = npmPackageOf(p, versions)
|
|
287
|
+
if (pkg === null) return null
|
|
288
|
+
return updateDecisions.get(pkg)?.pendingUntil ?? null
|
|
271
289
|
}
|
|
272
290
|
|
|
273
|
-
/** 已安装项统一列表(目录元数据 +
|
|
291
|
+
/** 已安装项统一列表(目录元数据 + 运行时信息合并):校验未就绪/被拦下的去掉更新标记、
|
|
292
|
+
* 门禁暂缓的带上可重试时刻,驱动「已安装」tab。 */
|
|
274
293
|
const installedItems = useMemo<InstalledItem[]>(() => {
|
|
275
|
-
if (updateBlocked.size === 0) return rawInstalledItems
|
|
276
294
|
return rawInstalledItems.map((item) => {
|
|
277
295
|
if (!item.hasUpdate || !item.plugin) return item
|
|
278
296
|
const pkg = npmPackageOf(item.plugin, versions)
|
|
279
|
-
|
|
297
|
+
if (pkg === null) return item
|
|
298
|
+
if (!releaseAgeReady) return { ...item, hasUpdate: false }
|
|
299
|
+
const decision = updateDecisions.get(pkg)
|
|
300
|
+
return decision === undefined || decision.offered
|
|
301
|
+
? item
|
|
302
|
+
: { ...item, hasUpdate: false, pendingUntil: decision.pendingUntil }
|
|
280
303
|
})
|
|
281
|
-
}, [rawInstalledItems,
|
|
304
|
+
}, [rawInstalledItems, releaseAgeReady, updateDecisions, versions])
|
|
282
305
|
|
|
283
306
|
/**
|
|
284
307
|
* Hub 自身更新状态:Hub 就是当前运行的应用(始终已安装,无需 installedName 命中)。
|
|
@@ -353,6 +376,7 @@ export function useCatalog(lang: LocaleId) {
|
|
|
353
376
|
installedName,
|
|
354
377
|
installedVersion,
|
|
355
378
|
hasUpdate,
|
|
379
|
+
updatePendingUntil,
|
|
356
380
|
hubHasUpdate,
|
|
357
381
|
hubUpdateState,
|
|
358
382
|
hubUpdateTarget,
|
package/src/client/locales.ts
CHANGED
|
@@ -482,6 +482,10 @@ export const zh = {
|
|
|
482
482
|
update: '可更新',
|
|
483
483
|
updateAvailable: '有更新',
|
|
484
484
|
updateAvailableHint: '检测到更新:有版本号的仓库比对版本号,无版本号的仓库比对仓库最近更新时间',
|
|
485
|
+
// 门禁暂缓:新版本发布未满 24 小时,此时安装会被 pnpm 静默回退(或直接拒绝),
|
|
486
|
+
// 给出确切可重试时刻,而不是让徽标静默消失、让用户以为已经是最新
|
|
487
|
+
updatePending: '暂缓到 {time} 后可更新',
|
|
488
|
+
updatePendingHint: '新版本发布未满 24 小时,pnpm 供应链门禁会拦下;到时间后再更新即可',
|
|
485
489
|
hubUpdateHint: '检测到 DSH Plugin Hub 新版本,点击查看更新内容并更新',
|
|
486
490
|
hubUpdateTitle: 'DSH Plugin Hub 有新版本',
|
|
487
491
|
hubUpdateDesc: 'v{version} 已发布,点击下方按钮即可更新,完成后需重启宿主生效。',
|
|
@@ -977,6 +981,8 @@ export const en = {
|
|
|
977
981
|
update: 'Update',
|
|
978
982
|
updateAvailable: 'Update available',
|
|
979
983
|
updateAvailableHint: 'An update was detected: repos with a release are compared by tag, repos without one by last push time',
|
|
984
|
+
updatePending: 'Update available after {time}',
|
|
985
|
+
updatePendingHint: 'The new release is less than 24 hours old, so the pnpm supply-chain hold rejects it until then',
|
|
980
986
|
hubUpdateHint: 'A new DSH Plugin Hub version is available — click to view the changelog and update',
|
|
981
987
|
hubUpdateTitle: 'A new DSH Plugin Hub version is available',
|
|
982
988
|
hubUpdateDesc: 'v{version} has been released — click below to update, then restart the host to apply it.',
|
|
@@ -24,3 +24,13 @@ export function relTime(iso: string | undefined, t: Translate): string {
|
|
|
24
24
|
if (days < 365) return t('monthsAgo', { months: Math.floor(days / 30) })
|
|
25
25
|
return t('yearsAgo', { years: Math.floor(days / 365) })
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/** 具体时刻:当天只给「时:分」,跨天再补「月/日」,用于「暂缓到何时可更新」。 */
|
|
29
|
+
export function clockTime(ms: number): string {
|
|
30
|
+
const at = new Date(ms)
|
|
31
|
+
const now = new Date()
|
|
32
|
+
const hh = String(at.getHours()).padStart(2, '0')
|
|
33
|
+
const mm = String(at.getMinutes()).padStart(2, '0')
|
|
34
|
+
const sameDay = at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth() && at.getDate() === now.getDate()
|
|
35
|
+
return sameDay ? `${hh}:${mm}` : `${at.getMonth() + 1}/${at.getDate()} ${hh}:${mm}`
|
|
36
|
+
}
|
|
@@ -42,6 +42,8 @@ export interface InstalledItem {
|
|
|
42
42
|
catalogVersion: string | null
|
|
43
43
|
/** 是否有可用更新(有版本比版本,无版本比仓库更新时间) */
|
|
44
44
|
hasUpdate: boolean
|
|
45
|
+
/** 更新被供应链门禁暂缓时的可重试时刻(毫秒);无更新/非门禁原因 → null */
|
|
46
|
+
pendingUntil: number | null
|
|
45
47
|
/** 是否已加载进运行中 loader(官方 ctx.loader 对账);false = 已安装但未挂载(装完未重启) */
|
|
46
48
|
loaded: boolean
|
|
47
49
|
/** 是否真正的 dsh 插件(包内声明 dsh 配置 / 在 profile bundles 清单):
|
|
@@ -230,6 +232,7 @@ export function installedItemsOf(
|
|
|
230
232
|
installedAt: rec?.installedAt ?? null,
|
|
231
233
|
catalogVersion: p.version ?? null,
|
|
232
234
|
hasUpdate: hasUpdateOf(p, installed, versions),
|
|
235
|
+
pendingUntil: null,
|
|
233
236
|
loaded: loadedNames?.includes(name) ?? false,
|
|
234
237
|
dshCapable: dshCapableNames?.includes(name) ?? false,
|
|
235
238
|
})
|
|
@@ -254,6 +257,7 @@ export function installedItemsOf(
|
|
|
254
257
|
catalogVersion: null,
|
|
255
258
|
// 目录外没有目录信号可比,无法判断是否有更新
|
|
256
259
|
hasUpdate: false,
|
|
260
|
+
pendingUntil: null,
|
|
257
261
|
loaded: loadedNames?.includes(name) ?? false,
|
|
258
262
|
dshCapable: dshCapableNames?.includes(name) ?? false,
|
|
259
263
|
})
|
|
@@ -61,23 +61,44 @@ export function compareVersions(a: string | null | undefined, b: string | null |
|
|
|
61
61
|
return 0
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** 「可更新」判定结果:是否提示,以及被供应链门禁拦下时的到期时刻。 */
|
|
65
|
+
export interface UpdateDecision {
|
|
66
|
+
offered: boolean
|
|
67
|
+
/** 发布未满门槛 → 可重试时刻(毫秒);非门禁原因(npm 上没有更高版本)→ null */
|
|
68
|
+
pendingUntil: number | null
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
/**
|
|
65
72
|
* 是否值得提示「可更新」:npm 上最新可安装版本确实比已装版本新,且已过供应链门禁。
|
|
66
73
|
*
|
|
67
74
|
* 更新命令执行的是 `pkg@latest`,所以「目录里标的版本」不是最终能装上的版本:
|
|
68
75
|
* 作者可能只打了 GitHub release 没发 npm —— 此时提示会变成永远点不掉的循环。
|
|
69
|
-
* 任一信息缺失(查不到发布信息 / 没有已装版本记录)返回
|
|
76
|
+
* 任一信息缺失(查不到发布信息 / 没有已装版本记录)返回 offered,交回调用方原有判定:
|
|
70
77
|
* 宁可提示,也不因为一次查询失败把真实更新吃掉。
|
|
71
78
|
*/
|
|
79
|
+
export function updateDecision(
|
|
80
|
+
release: { version: string; publishedAt: string } | null,
|
|
81
|
+
installedVersion: string | null,
|
|
82
|
+
now: number,
|
|
83
|
+
): UpdateDecision {
|
|
84
|
+
if (release === null) return { offered: true, pendingUntil: null }
|
|
85
|
+
if (isReleaseTooFresh(release.publishedAt, now)) {
|
|
86
|
+
const ms = Date.parse(release.publishedAt)
|
|
87
|
+
return { offered: false, pendingUntil: Number.isNaN(ms) ? null : ms + MIN_RELEASE_AGE_MS }
|
|
88
|
+
}
|
|
89
|
+
if (installedVersion !== null && compareVersions(release.version, installedVersion) <= 0) {
|
|
90
|
+
return { offered: false, pendingUntil: null }
|
|
91
|
+
}
|
|
92
|
+
return { offered: true, pendingUntil: null }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 是否提示「可更新」(只要布尔结果的场景)。 */
|
|
72
96
|
export function isUpdateOffered(
|
|
73
97
|
release: { version: string; publishedAt: string } | null,
|
|
74
98
|
installedVersion: string | null,
|
|
75
99
|
now: number,
|
|
76
100
|
): boolean {
|
|
77
|
-
|
|
78
|
-
if (isReleaseTooFresh(release.publishedAt, now)) return false
|
|
79
|
-
if (installedVersion !== null && compareVersions(release.version, installedVersion) <= 0) return false
|
|
80
|
-
return true
|
|
101
|
+
return updateDecision(release, installedVersion, now).offered
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
/**
|
|
@@ -11,7 +11,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
11
11
|
/**
|
|
12
12
|
* 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
|
|
13
13
|
* 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
14
|
-
* 由 `spawn(
|
|
14
|
+
* 由 `spawn(runtimeExecPath(), ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
15
15
|
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
16
16
|
* 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
|
|
17
17
|
*/
|
package/src/server/http/task.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from 'node:path'
|
|
|
10
10
|
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, listPendingRestarts, validPackageName, type LoaderHandle } from '../services/install/install.ts'
|
|
11
11
|
import { readInstalledVersions, recordInstalledVersion, removeInstalledVersion } from '../services/profile/installed-versions.ts'
|
|
12
12
|
import { isDshPlugin, isEntryLoaded } from '../services/loader.ts'
|
|
13
|
+
import { runtimeExecPath } from '../services/runtime-exec.ts'
|
|
13
14
|
import { profileDirectory, readInstalled } from './catalog.ts'
|
|
14
15
|
import { hostEnv } from './diagnostics.ts'
|
|
15
16
|
import { HOST_RESTART_SCRIPT, isDesktopHost, readJsonBody, requireMethod, requireTrustedPost, sendJson, type WebRoute, type WebServerService } from './shared.ts'
|
|
@@ -96,9 +97,9 @@ export function registerTaskRoutes(webServer: WebServerService, profile: string,
|
|
|
96
97
|
// detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
|
|
97
98
|
// 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
|
|
98
99
|
// 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
|
|
99
|
-
// 故孵化 node(
|
|
100
|
+
// 故孵化 node(runtimeExecPath()) 跑跨平台脚本 —— Windows 用
|
|
100
101
|
// netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
|
|
101
|
-
spawn(
|
|
102
|
+
spawn(runtimeExecPath(), ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref()
|
|
102
103
|
sendJson(response, 200, { ok: true, port })
|
|
103
104
|
},
|
|
104
105
|
},
|
|
@@ -26,6 +26,7 @@ import { commandOnPath, gitMissingDetail } from './preflight.ts'
|
|
|
26
26
|
import { isTransportFailure, registryHost } from './registry-fallback.ts'
|
|
27
27
|
import { dshTooOldMarker } from './dsh-compat.ts'
|
|
28
28
|
import { OutputDecoder } from './decode-output.ts'
|
|
29
|
+
import { runtimeExecPath } from '../runtime-exec.ts'
|
|
29
30
|
|
|
30
31
|
/** 内存任务注册表,按任务 id 索引。 */
|
|
31
32
|
const tasks = new Map<number, InstallTask>()
|
|
@@ -169,7 +170,8 @@ function cliInvocation(profile: string): Invocation {
|
|
|
169
170
|
const bootstrap = desktopCliBootstrap(entry)
|
|
170
171
|
if (bootstrap !== null) {
|
|
171
172
|
return {
|
|
172
|
-
|
|
173
|
+
// Android 上 execPath 是 linker64,直接用会把 --expose-internals 当可执行文件
|
|
174
|
+
file: runtimeExecPath(),
|
|
173
175
|
prefixArgs: ['--expose-internals', bootstrap],
|
|
174
176
|
// cwd 用 profile 目录(asar 路径不能作 cwd),不存在时由 spawnCwdFor 回退
|
|
175
177
|
cwd: spawnCwdFor(profile),
|
|
@@ -181,7 +183,7 @@ function cliInvocation(profile: string): Invocation {
|
|
|
181
183
|
if (/[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
|
|
182
184
|
const absoluteEntry = resolve(entry)
|
|
183
185
|
return {
|
|
184
|
-
file:
|
|
186
|
+
file: runtimeExecPath(),
|
|
185
187
|
prefixArgs: [...process.execArgv, absoluteEntry],
|
|
186
188
|
cwd: dirname(absoluteEntry),
|
|
187
189
|
useShell: false,
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
* 宿主运行时路径:需要 spawn 一个「和宿主同款」的 Node/Electron 进程时用它取可执行文件。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Android 动态链接器的错误信息形如 `error: expected absolute path: "--expose-internals"`:
|
|
10
|
+
* linker64 的第一个参数必须是「被加载的可执行文件绝对路径」,传选项就会被拒绝。 */
|
|
11
|
+
const LINKER_RE = /(^|[\\/])linker(32|64)?$/i
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 宿主进程自身的可执行文件路径。
|
|
15
|
+
*
|
|
16
|
+
* 大多数平台就是 `process.execPath`;但 Android(bionic)上它常常是
|
|
17
|
+
* `/system/bin/linker64`(宿主由 `linker64 node …` 拉起)。这时拿 execPath 去 spawn
|
|
18
|
+
* 任何带参数的命令(如 `--expose-internals <cli.js>`)都会把第一个参数当成可执行文件,
|
|
19
|
+
* 直接报 `expected absolute path`,子进程什么都跑不起来。这类环境里真正的程序路径在
|
|
20
|
+
* `process.argv0`,故检测到链接器时改用它。
|
|
21
|
+
*/
|
|
22
|
+
export function runtimeExecPath(
|
|
23
|
+
exec: string = process.execPath,
|
|
24
|
+
argv0: string = process.argv0,
|
|
25
|
+
): string {
|
|
26
|
+
if (!LINKER_RE.test(exec)) return exec
|
|
27
|
+
return argv0 && argv0 !== exec ? argv0 : exec
|
|
28
|
+
}
|