dsh-plugin 2.3.0 → 2.3.2
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 -12
- package/README.md +12 -12
- package/client/client.js +412 -345
- package/client/client.js.map +1 -1
- package/lib/http/install.js +12 -1
- package/lib/http/npm-release-age.js +7 -1
- package/lib/services/npm-release-age.js +41 -4
- package/lib/types/services/npm-release-age.d.ts +14 -2
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +40 -9
- package/src/client/components/catalog/PluginCard.tsx +26 -6
- package/src/client/components/modals/modals.tsx +4 -2
- package/src/client/components/views/InstalledView.tsx +62 -24
- package/src/client/data/host.ts +2 -0
- package/src/client/hooks/useTaskQueue.ts +1 -1
- package/src/client/locales.ts +12 -6
- package/src/client/logic/install-command.ts +6 -3
- package/src/client/styles/InstalledView.module.css +72 -19
- package/src/client/styles/List.module.css +33 -3
- package/src/server/http/install.ts +11 -1
- package/src/server/http/npm-release-age.ts +7 -1
- package/src/server/services/npm-release-age.ts +45 -5
package/lib/http/install.js
CHANGED
|
@@ -12,6 +12,7 @@ import { isDshNpmPackageForRepo } from '../services/install/npm-resolve.js';
|
|
|
12
12
|
import { probeUrl, systemProxy } from '../services/probe.js';
|
|
13
13
|
import { loadSettings } from '../services/settings.js';
|
|
14
14
|
import { registryCandidates, registryHost, userNpmRegistry } from '../services/install/registry-fallback.js';
|
|
15
|
+
import { lookupReleaseAge } from '../services/npm-release-age.js';
|
|
15
16
|
import { profileDirectory } from '../services/profile/profile.js';
|
|
16
17
|
import { readInstalled } from './catalog.js';
|
|
17
18
|
import { COMMAND_TIMEOUT_MS, readJsonBody, requireTrustedPost, sendJson } from './shared.js';
|
|
@@ -178,6 +179,16 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
178
179
|
}
|
|
179
180
|
ordered = [chosen, ...registries.filter((r) => r !== chosen)];
|
|
180
181
|
}
|
|
182
|
+
// 新安装钉住「已过供应链门禁」的版本:pnpm 对发布未满 24h 的版本会静默回退到很旧的版本,
|
|
183
|
+
// 用户装完才发现功能缺失且没有任何提示。更新已有目标时客户端已按门禁筛选,继续走 @latest。
|
|
184
|
+
let installSpec = pinVersion !== '' ? `${target}@${pinVersion}` : undefined;
|
|
185
|
+
if (installSpec === undefined && already === null) {
|
|
186
|
+
// 解析源与安装首选源一致,钉住的版本一定存在于真正会用的那个源;解析失败则不钉,不阻断安装。
|
|
187
|
+
// 超时压到 5s:网络刚探通,慢也只在极端情况,不能为查版本把安装请求拖住 8s
|
|
188
|
+
const release = await lookupReleaseAge(target, ordered[0] ?? '', effectiveProxy, 5000);
|
|
189
|
+
if (release !== null && release.installable !== null)
|
|
190
|
+
installSpec = `${target}@${release.installable.version}`;
|
|
191
|
+
}
|
|
181
192
|
// 入队前复检:网络探测的 await 期间并发请求可能已通过上方检查,而此处到 startPluginMutation
|
|
182
193
|
// 之间无 await,JS 单线程保证同一目标至多一个任务,杜绝双击/并发双任务。
|
|
183
194
|
if (hasQueuedTarget(target)) {
|
|
@@ -201,7 +212,7 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
201
212
|
env: mutationEnv(settings),
|
|
202
213
|
// 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
|
|
203
214
|
registries: ordered,
|
|
204
|
-
installSpec
|
|
215
|
+
installSpec,
|
|
205
216
|
// 自定义安装入口渠道:日志/报错据此溯源
|
|
206
217
|
installChannel,
|
|
207
218
|
});
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { systemProxy } from '../services/probe.js';
|
|
10
10
|
import { lookupReleaseAge, parseReleaseAgeRequest } from '../services/npm-release-age.js';
|
|
11
|
+
import { userNpmRegistry } from '../services/install/registry-fallback.js';
|
|
12
|
+
import { profileDirectory } from '../services/profile/profile.js';
|
|
11
13
|
import { loadSettings } from '../services/settings.js';
|
|
12
14
|
import { readJsonBody, requireTrustedPost, sendJson } from './shared.js';
|
|
13
15
|
export function registerNpmReleaseAgeRoutes(webServer, profile) {
|
|
@@ -30,7 +32,11 @@ export function registerNpmReleaseAgeRoutes(webServer, profile) {
|
|
|
30
32
|
? settings.proxy
|
|
31
33
|
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
32
34
|
// 并发查询:单包各自带超时且失败即 null,整体不因任一包失败而失败
|
|
33
|
-
|
|
35
|
+
// 查询源与安装首选源同口径(设置 → 本机 npm 配置):安装钉住的版本必须来自真正要用的那个源
|
|
36
|
+
const registry = settings.npmRegistry !== ''
|
|
37
|
+
? settings.npmRegistry
|
|
38
|
+
: userNpmRegistry(profileDirectory(profile));
|
|
39
|
+
const results = await Promise.all(packages.map(async (name) => [name, await lookupReleaseAge(name, registry, proxy)]));
|
|
34
40
|
sendJson(response, 200, { packages: Object.fromEntries(results) });
|
|
35
41
|
}
|
|
36
42
|
catch (error) {
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { get as httpsGet } from 'node:https';
|
|
12
12
|
import { validPackageName } from './profile/profile.js';
|
|
13
13
|
import { fetchViaCurl } from './probe.js';
|
|
14
|
+
/** pnpm profile 默认 minimumReleaseAge=1440 分钟;+30 分钟缓冲与客户端 release-age 判定同口径。 */
|
|
15
|
+
export const MIN_RELEASE_AGE_MS = 24 * 60 * 60 * 1000 + 30 * 60 * 1000;
|
|
14
16
|
/** 请求体包名数量上限:只为已安装插件按需查询,几十个足矣,封顶防滥用。 */
|
|
15
17
|
export const MAX_RELEASE_AGE_PACKAGES = 50;
|
|
16
18
|
/** 单包查询超时(ms):慢网络下失败即返回 null,不拖住整个响应。 */
|
|
@@ -38,6 +40,39 @@ export function latestReleaseOf(packument) {
|
|
|
38
40
|
return null;
|
|
39
41
|
return { version, publishedAt };
|
|
40
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* 已过供应链门禁的最高稳定版本:安装时钉住它,pnpm 就不会把最新版静默回退成很旧的版本。
|
|
45
|
+
* 只看纯 x.y.z(预发布版不参与,避免把用户带到不兼容预览版);时间表缺失或无满足项返回 null。
|
|
46
|
+
*/
|
|
47
|
+
export function installableReleaseOf(packument, now = Date.now()) {
|
|
48
|
+
if (packument === null || typeof packument !== 'object')
|
|
49
|
+
return null;
|
|
50
|
+
const time = packument.time;
|
|
51
|
+
if (time === null || typeof time !== 'object')
|
|
52
|
+
return null;
|
|
53
|
+
let best = null;
|
|
54
|
+
for (const [version, published] of Object.entries(time)) {
|
|
55
|
+
if (!/^\d+\.\d+\.\d+$/.test(version) || typeof published !== 'string')
|
|
56
|
+
continue;
|
|
57
|
+
const at = Date.parse(published);
|
|
58
|
+
if (Number.isNaN(at) || now - at < MIN_RELEASE_AGE_MS)
|
|
59
|
+
continue;
|
|
60
|
+
if (best === null || compareVersion(version, best.version) > 0)
|
|
61
|
+
best = { version, publishedAt: published };
|
|
62
|
+
}
|
|
63
|
+
return best;
|
|
64
|
+
}
|
|
65
|
+
/** 纯数字段版本比较(installableReleaseOf 只在 x.y.z 里用)。 */
|
|
66
|
+
function compareVersion(a, b) {
|
|
67
|
+
const pa = a.split('.');
|
|
68
|
+
const pb = b.split('.');
|
|
69
|
+
for (let i = 0; i < 3; i++) {
|
|
70
|
+
const d = Number.parseInt(pa[i] ?? '0', 10) - Number.parseInt(pb[i] ?? '0', 10);
|
|
71
|
+
if (d !== 0)
|
|
72
|
+
return d;
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
41
76
|
/** 请求体 → 去重后的合法包名列表;非数组 / 超量 / 含非法包名一律 null(拒绝非法输入)。 */
|
|
42
77
|
export function parseReleaseAgeRequest(body) {
|
|
43
78
|
if (body === null || typeof body !== 'object')
|
|
@@ -99,7 +134,7 @@ async function fetchPackument(url, proxy, timeoutMs) {
|
|
|
99
134
|
}
|
|
100
135
|
return getJson(url, timeoutMs);
|
|
101
136
|
}
|
|
102
|
-
/**
|
|
137
|
+
/** 查询一个包的最新版本、发布时间与可钉住的安装版本(成功结果按源缓存 10 分钟);registry 为空回退官方源,失败返回 null。 */
|
|
103
138
|
export async function lookupReleaseAge(packageName, registry, proxy, timeoutMs = RELEASE_AGE_TIMEOUT_MS) {
|
|
104
139
|
const base = registry.replace(/\/+$/, '') || DEFAULT_NPM_REGISTRY;
|
|
105
140
|
const key = `${base}\n${packageName}`;
|
|
@@ -116,8 +151,10 @@ export async function lookupReleaseAge(packageName, registry, proxy, timeoutMs =
|
|
|
116
151
|
catch {
|
|
117
152
|
return null;
|
|
118
153
|
}
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
|
|
154
|
+
const latest = latestReleaseOf(parsed);
|
|
155
|
+
if (latest === null)
|
|
156
|
+
return null;
|
|
157
|
+
const release = { ...latest, installable: installableReleaseOf(parsed) };
|
|
158
|
+
releaseCache.set(key, { at: Date.now(), release });
|
|
122
159
|
return release;
|
|
123
160
|
}
|
|
@@ -2,6 +2,13 @@ export interface NpmRelease {
|
|
|
2
2
|
version: string;
|
|
3
3
|
publishedAt: string;
|
|
4
4
|
}
|
|
5
|
+
/** 最新版 + 可钉住的安装版本:安装时钉住后者,pnpm 才不会把「发布未满门禁」的最新版回退成很旧的版本。 */
|
|
6
|
+
export interface NpmReleaseWithInstallable extends NpmRelease {
|
|
7
|
+
/** 已过供应链门禁的最高稳定版;时间表缺失或没有满足项时为 null(调用方退回不钉版本)。 */
|
|
8
|
+
installable: NpmRelease | null;
|
|
9
|
+
}
|
|
10
|
+
/** pnpm profile 默认 minimumReleaseAge=1440 分钟;+30 分钟缓冲与客户端 release-age 判定同口径。 */
|
|
11
|
+
export declare const MIN_RELEASE_AGE_MS: number;
|
|
5
12
|
/** 请求体包名数量上限:只为已安装插件按需查询,几十个足矣,封顶防滥用。 */
|
|
6
13
|
export declare const MAX_RELEASE_AGE_PACKAGES = 50;
|
|
7
14
|
/** 单包查询超时(ms):慢网络下失败即返回 null,不拖住整个响应。 */
|
|
@@ -9,7 +16,12 @@ export declare const RELEASE_AGE_TIMEOUT_MS = 8000;
|
|
|
9
16
|
export declare const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
10
17
|
/** 从完整 packument 取 dist-tags.latest 对应版本及其发布时间;缺任一字段返回 null。 */
|
|
11
18
|
export declare function latestReleaseOf(packument: unknown): NpmRelease | null;
|
|
19
|
+
/**
|
|
20
|
+
* 已过供应链门禁的最高稳定版本:安装时钉住它,pnpm 就不会把最新版静默回退成很旧的版本。
|
|
21
|
+
* 只看纯 x.y.z(预发布版不参与,避免把用户带到不兼容预览版);时间表缺失或无满足项返回 null。
|
|
22
|
+
*/
|
|
23
|
+
export declare function installableReleaseOf(packument: unknown, now?: number): NpmRelease | null;
|
|
12
24
|
/** 请求体 → 去重后的合法包名列表;非数组 / 超量 / 含非法包名一律 null(拒绝非法输入)。 */
|
|
13
25
|
export declare function parseReleaseAgeRequest(body: unknown): string[] | null;
|
|
14
|
-
/**
|
|
15
|
-
export declare function lookupReleaseAge(packageName: string, registry: string, proxy: string, timeoutMs?: number): Promise<
|
|
26
|
+
/** 查询一个包的最新版本、发布时间与可钉住的安装版本(成功结果按源缓存 10 分钟);registry 为空回退官方源,失败返回 null。 */
|
|
27
|
+
export declare function lookupReleaseAge(packageName: string, registry: string, proxy: string, timeoutMs?: number): Promise<NpmReleaseWithInstallable | null>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
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",
|
|
@@ -11,10 +11,10 @@ import type { MouseEvent } from 'react'
|
|
|
11
11
|
import styles from '../styles/Header.module.css'
|
|
12
12
|
import modalStyles from '../styles/Modal.module.css'
|
|
13
13
|
import type { EnvInfo, HubPlugin, InstallChannel, SectionProps, ToastState } from '../types.ts'
|
|
14
|
-
import { installCommandOf, repoFromInstallTarget } from '../logic/install-command.ts'
|
|
14
|
+
import { installCommandOf, installTargetOf, repoFromInstallTarget } from '../logic/install-command.ts'
|
|
15
15
|
import { PLUGIN_VERSION } from '../logic/constants.ts'
|
|
16
16
|
import { ensurePluginCss } from '../logic/ensure-plugin-css.ts'
|
|
17
|
-
import { getEnv, revealInstallFolder } from '../data/host.ts'
|
|
17
|
+
import { fetchNpmReleaseAge, getEnv, revealInstallFolder } from '../data/host.ts'
|
|
18
18
|
import { useCatalog } from '../hooks/useCatalog.ts'
|
|
19
19
|
import { useLanguage } from '../hooks/useLanguage.ts'
|
|
20
20
|
import { useSettings } from '../hooks/useSettings.ts'
|
|
@@ -76,9 +76,11 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
76
76
|
const [confirmPlugin, setConfirmPlugin] = useState<HubPlugin | null>(null)
|
|
77
77
|
/** 弹窗动作是否为「更新」:已安装插件点「更新」→ 走同一条 add 命令原位覆盖重装,文案区分安装/更新 */
|
|
78
78
|
const [confirmIsUpdate, setConfirmIsUpdate] = useState(false)
|
|
79
|
-
/** Hub
|
|
80
|
-
*
|
|
79
|
+
/** 安装要钉住的精确版本:Hub 连续发版时提示的旧一版、以及新安装的「已过供应链门禁」版本,
|
|
80
|
+
* 都必须钉住,否则 @latest 会被 pnpm 门禁带回门禁内的版本或静默回退到很旧的版本。 */
|
|
81
81
|
const [confirmPinVersion, setConfirmPinVersion] = useState<string | null>(null)
|
|
82
|
+
/** 门禁版本查询的在途序号:只采用最后一次弹窗的查询结果,避免切换插件后旧结果覆盖 */
|
|
83
|
+
const installPinSeq = useRef(0)
|
|
82
84
|
/** 命令行安装确认弹窗:记录待安装的裸目标(npm 包名)——
|
|
83
85
|
* 与应用商店同一套确认/进度/结果弹窗,提供时 plugin 传 null 走 customTarget 模式 */
|
|
84
86
|
const [confirmCustomTarget, setConfirmCustomTarget] = useState<string | null>(null)
|
|
@@ -321,9 +323,9 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
321
323
|
}
|
|
322
324
|
|
|
323
325
|
/** 弹窗动作一:复制安装命令到剪贴板,引导去终端粘贴执行(npm 安装显示包名命令)。 */
|
|
324
|
-
const copyCommand = async (p: HubPlugin) => {
|
|
326
|
+
const copyCommand = async (p: HubPlugin, version?: string) => {
|
|
325
327
|
const repo = p.source?.repo ?? ''
|
|
326
|
-
const ok = await doCopy(installCommandOf(p))
|
|
328
|
+
const ok = await doCopy(installCommandOf(p, false, version))
|
|
327
329
|
if (ok) {
|
|
328
330
|
setCopied(repo)
|
|
329
331
|
setToast({ id: Date.now(), kind: 'copied' })
|
|
@@ -332,6 +334,21 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
332
334
|
setConfirmPlugin(null)
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
/** 新安装先查「已过 pnpm 供应链门禁」的版本:命令条与执行都钉住它,
|
|
338
|
+
* 否则 pnpm 会把发布未满 24h 的最新版静默回退成很旧的版本。查不到则不钉,由服务端兜底解析。 */
|
|
339
|
+
const resolveInstallPin = (p: HubPlugin) => {
|
|
340
|
+
// 目录下发了官方安装命令的条目(CLI-only / 人工干预)只展示该命令,追加版本会改坏它
|
|
341
|
+
if (p.install?.command !== undefined && p.install.command !== '') return
|
|
342
|
+
const pkg = installTargetOf(p)
|
|
343
|
+
if (!pkg) return
|
|
344
|
+
const seq = ++installPinSeq.current
|
|
345
|
+
void fetchNpmReleaseAge([pkg]).then((res) => {
|
|
346
|
+
if (installPinSeq.current !== seq) return
|
|
347
|
+
const version = res?.[pkg]?.installable?.version
|
|
348
|
+
if (version !== undefined && version !== '') setConfirmPinVersion(version)
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
335
352
|
/** 卸载弹窗动作:复制卸载命令,万一直接卸载失败可去终端手动执行。
|
|
336
353
|
* 自定义安装(无目录数据)按已安装项包名直卸,同样给复制通道。 */
|
|
337
354
|
const copyUninstallCommand = async () => {
|
|
@@ -451,12 +468,17 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
451
468
|
? t('pluginsTotal', { n: count })
|
|
452
469
|
: t('filterResults', { n: count }),
|
|
453
470
|
onInstall: (p, opts) => {
|
|
471
|
+
const update = opts?.update ?? false
|
|
454
472
|
setInstallDone(false)
|
|
455
|
-
setConfirmIsUpdate(
|
|
473
|
+
setConfirmIsUpdate(update)
|
|
456
474
|
setConfirmPinVersion(null)
|
|
475
|
+
// 本次弹窗重新开始:作废上一次仍在途的门禁版本查询
|
|
476
|
+
++installPinSeq.current
|
|
457
477
|
// 与卸载一致:重置弹窗关联任务,避免新弹窗误匹配到进行中的任务而禁用安装按钮
|
|
458
478
|
queue.clearModalTask()
|
|
459
479
|
setConfirmPlugin(p)
|
|
480
|
+
// 更新走 @latest 语义(客户端已按门禁筛选),只有新安装需要钉住门禁内可用的版本
|
|
481
|
+
if (!update) resolveInstallPin(p)
|
|
460
482
|
},
|
|
461
483
|
onUninstall: (p) => {
|
|
462
484
|
setUninstallDone(false)
|
|
@@ -489,6 +511,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
489
511
|
setInstallDone(false)
|
|
490
512
|
setConfirmIsUpdate(true)
|
|
491
513
|
setConfirmPinVersion(null)
|
|
514
|
+
// 更新不钉版本:作废可能仍在途的新安装门禁查询
|
|
515
|
+
++installPinSeq.current
|
|
492
516
|
queue.clearModalTask()
|
|
493
517
|
setConfirmPlugin(item.plugin)
|
|
494
518
|
},
|
|
@@ -567,6 +591,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
567
591
|
setConfirmIsUpdate(true)
|
|
568
592
|
// 钉住本次提示的版本:最新版可能仍在门禁内,@latest 会被 pnpm 拒绝
|
|
569
593
|
setConfirmPinVersion(catalog.hubUpdateTarget?.version ?? null)
|
|
594
|
+
// 作废可能仍在途的新安装门禁查询,避免它覆盖本次钉住的版本
|
|
595
|
+
++installPinSeq.current
|
|
570
596
|
queue.clearModalTask()
|
|
571
597
|
setConfirmPlugin(hubEntry)
|
|
572
598
|
}
|
|
@@ -668,6 +694,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
668
694
|
langPath,
|
|
669
695
|
restarting,
|
|
670
696
|
update: confirmIsUpdate,
|
|
697
|
+
pinVersion: confirmPinVersion ?? undefined,
|
|
671
698
|
// 仅命令行插件(webInstallable=false / 人工干预 manualInstall):弹窗只展示命令供复制,不提供一键安装
|
|
672
699
|
cliOnly: confirmPlugin.install?.webInstallable === false || confirmPlugin.install?.manualInstall === true,
|
|
673
700
|
submitting: queue.submitting,
|
|
@@ -681,8 +708,12 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
681
708
|
setInstallBundleDeselected(false)
|
|
682
709
|
if (reload) window.location.reload()
|
|
683
710
|
},
|
|
684
|
-
onCopy: () => copyCommand(confirmPlugin),
|
|
685
|
-
onInstall: () => queue.installNow(confirmPlugin,
|
|
711
|
+
onCopy: () => copyCommand(confirmPlugin, confirmPinVersion ?? undefined),
|
|
712
|
+
onInstall: () => queue.installNow(confirmPlugin, {
|
|
713
|
+
update: confirmIsUpdate,
|
|
714
|
+
// 新安装钉住门禁版本:查询尚未回来时为空,服务端会自行解析
|
|
715
|
+
pinVersion: confirmPinVersion ?? undefined,
|
|
716
|
+
}),
|
|
686
717
|
// 重启:结果视图「立即重启」也会中断进行中的任务 → 先弹重启确认弹窗
|
|
687
718
|
onRestart: () => setShowRestartConfirm(true),
|
|
688
719
|
}),
|
|
@@ -39,11 +39,25 @@ export function PluginCard({ plugin: p, copied, installedName, installedVersion,
|
|
|
39
39
|
const pendingUntil = isInstalled && !update ? updatePendingUntil(p) : null
|
|
40
40
|
// 版本号展示:已安装 → 当前已装版本(最能反映用户状态);未安装 → 目录最新版本
|
|
41
41
|
const versionShown = isInstalled ? installedVersion(p) : p.version
|
|
42
|
+
// 目录只下发作者 GitHub 账号,缺省时从仓库地址推导
|
|
43
|
+
const author = p.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''
|
|
42
44
|
|
|
43
45
|
return h('div', { className: styles.card },
|
|
44
46
|
h('div', { className: styles.cardMain },
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
// 第一行只放名称 + 版本 + 更新状态:这三样是判断「这是什么、我装没装、要不要更新」的全部依据,
|
|
48
|
+
// 必须始终可见;长仓库名在此行内省略号收尾,不把版本与更新徽标挤出去
|
|
49
|
+
h('div', { className: styles.cardTitleRow },
|
|
50
|
+
// 名称本身是通往主站详情页的链接:悬停变色即可点,省掉「先找按钮再点」的一步。
|
|
51
|
+
// 无仓库地址时退回纯文本(没有可跳转的目标)
|
|
52
|
+
repo
|
|
53
|
+
? h('a', {
|
|
54
|
+
className: styles.cardTitle,
|
|
55
|
+
href: pluginDetailUrl(p, langPath),
|
|
56
|
+
target: '_blank',
|
|
57
|
+
rel: 'noopener noreferrer',
|
|
58
|
+
title: p.description ?? '',
|
|
59
|
+
}, p.displayName ?? p.slug)
|
|
60
|
+
: h('div', { className: styles.cardTitle, title: p.description ?? '' }, p.displayName ?? p.slug),
|
|
47
61
|
// 版本号紧跟插件名称:有版本才显示(无 release 的仓库不展示版本号)
|
|
48
62
|
versionShown ? h('span', { className: styles.versionBadge, title: t('version') }, versionShown) : null,
|
|
49
63
|
// 已安装且有更新:醒目提示(有 release 比版本号、无 release 比仓库最近更新时间)
|
|
@@ -51,11 +65,17 @@ export function PluginCard({ plugin: p, copied, installedName, installedVersion,
|
|
|
51
65
|
pendingUntil !== null
|
|
52
66
|
? h('span', { className: styles.updateBadge, title: t('updatePendingHint') }, t('updatePending', { time: clockTime(pendingUntil) }))
|
|
53
67
|
: null,
|
|
54
|
-
p.category ? h('span', { className: styles.categoryBadge }, categoryLabel(CATEGORY_LABELS, p.category, langKey)) : null,
|
|
55
|
-
p.compatibility?.status === 'verified'
|
|
56
|
-
? h('span', { className: styles.verified }, t('verified'))
|
|
57
|
-
: null,
|
|
58
68
|
),
|
|
69
|
+
// 作者与分类同属背景信息:单独一行,不与名称抢宽度,也不参与是否要装/更新的判断
|
|
70
|
+
(author || p.category || p.compatibility?.status === 'verified')
|
|
71
|
+
? h('div', { className: styles.cardBadgeRow },
|
|
72
|
+
author ? h('span', { className: styles.authorBadge, title: t('author') }, `@${author}`) : null,
|
|
73
|
+
p.category ? h('span', { className: styles.categoryBadge }, categoryLabel(CATEGORY_LABELS, p.category, langKey)) : null,
|
|
74
|
+
p.compatibility?.status === 'verified'
|
|
75
|
+
? h('span', { className: styles.verified }, t('verified'))
|
|
76
|
+
: null,
|
|
77
|
+
)
|
|
78
|
+
: null,
|
|
59
79
|
// 描述始终显示:站点英文数据缺翻译时 description 回退成中文也照常展示 ——
|
|
60
80
|
// 描述是了解插件的第一入口,语言不一致也不该省略。
|
|
61
81
|
p.description ? h('p', { className: styles.desc }, p.description) : null,
|
|
@@ -90,6 +90,8 @@ export interface InstallModalProps {
|
|
|
90
90
|
restarting: boolean
|
|
91
91
|
/** 已安装插件的覆盖更新(走同一条 add 命令原位重装,文案区分安装/更新) */
|
|
92
92
|
update: boolean
|
|
93
|
+
/** 已过供应链门禁的安装版本:命令条带上它,手抄执行与一键安装结果一致(未查到时不传) */
|
|
94
|
+
pinVersion?: string
|
|
93
95
|
/** 仅命令行插件(webInstallable=false):不提供一键安装,只展示命令供复制到 dsh 终端 */
|
|
94
96
|
cliOnly: boolean
|
|
95
97
|
/** 安装请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
|
|
@@ -112,7 +114,7 @@ export interface InstallModalProps {
|
|
|
112
114
|
* 目录插件与命令行安装(customTarget 模式)共用同一套确认/进度/结果流程。
|
|
113
115
|
*/
|
|
114
116
|
export function InstallModal(props: InstallModalProps) {
|
|
115
|
-
const { plugin, customTarget, globalNpm, done, task, t, langPath, restarting, submitting, update, cliOnly, needsRestart, needsReload, bundleDeselected, onClose, onCopy, onInstall, onRestart } = props
|
|
117
|
+
const { plugin, customTarget, globalNpm, done, task, t, langPath, restarting, submitting, update, pinVersion, cliOnly, needsRestart, needsReload, bundleDeselected, onClose, onCopy, onInstall, onRestart } = props
|
|
116
118
|
const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
|
|
117
119
|
// 进行中标题带上插件名(中文「XX 插件安装中」;英文状态词在前更自然),并用状态色区分;
|
|
118
120
|
// 命令行安装无插件元数据 → 直接用输入的目标展示;全局 npm 安装 → 包列表空格拼接
|
|
@@ -191,7 +193,7 @@ export function InstallModal(props: InstallModalProps) {
|
|
|
191
193
|
h('span', { className: styles.modalCmdText },
|
|
192
194
|
globalNpm !== undefined && globalNpm.length > 0
|
|
193
195
|
? `npm install -g ${globalNpm.join(' ')}`
|
|
194
|
-
: customTarget ? `pnpm add ${customTarget}` : installCommandOf(plugin
|
|
196
|
+
: customTarget ? `pnpm add ${customTarget}` : installCommandOf(plugin!, false, pinVersion)),
|
|
195
197
|
h('span', { className: styles.modalCmdCopy }, h(CopyIcon), t('copyCmdLabel')),
|
|
196
198
|
),
|
|
197
199
|
// 全局 npm 安装说明:装的是系统级 CLI 工具,不进插件列表、无需重启宿主
|
|
@@ -17,7 +17,8 @@ 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
|
+
import { clockTime, fmtStars } from '../../logic/format.ts'
|
|
21
|
+
import { pluginSiteUrl } from '../../logic/urls.ts'
|
|
21
22
|
import { SortArrowIcon } from '../ui/icons.tsx'
|
|
22
23
|
|
|
23
24
|
const INSTALLED_SORTS = ['sortName', 'sortInstalledAt', 'sortStars', 'sortForks'] as const
|
|
@@ -101,9 +102,20 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
|
|
|
101
102
|
: item.dshCapable ? t('statusInactive')
|
|
102
103
|
: t('exampleHint'),
|
|
103
104
|
}),
|
|
104
|
-
// 主信息:名称 + 已装版本 +
|
|
105
|
+
// 主信息:名称 + 已装版本 + 有更新徽标(同一行,名称超长时省略,徽标不被挤走)。
|
|
106
|
+
// 名称本身是主站详情页链接:点它直接跳官网,点行内其它位置仍是本地面板。
|
|
107
|
+
// 阻止冒泡,避免一次点击同时打开本地详情弹窗和新标签页
|
|
105
108
|
h('div', { className: styles.rowMain },
|
|
106
|
-
|
|
109
|
+
repo
|
|
110
|
+
? h('a', {
|
|
111
|
+
className: styles.rowTitle,
|
|
112
|
+
href: pluginSiteUrl(repo),
|
|
113
|
+
target: '_blank',
|
|
114
|
+
rel: 'noopener noreferrer',
|
|
115
|
+
title: t('detail'),
|
|
116
|
+
onClick: (e: MouseEvent<HTMLAnchorElement>) => e.stopPropagation(),
|
|
117
|
+
}, name)
|
|
118
|
+
: h('span', { className: styles.rowTitle }, name),
|
|
107
119
|
item.installedVersion
|
|
108
120
|
? h('span', { className: styles.versionBadge, title: t('installedVersionLabel') }, item.installedVersion)
|
|
109
121
|
: null,
|
|
@@ -114,15 +126,51 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
|
|
|
114
126
|
? h('span', { className: styles.updateBadge, title: t('updatePendingHint') }, t('updatePending', { time: clockTime(item.pendingUntil) }))
|
|
115
127
|
: null,
|
|
116
128
|
),
|
|
117
|
-
//
|
|
118
|
-
//
|
|
129
|
+
// 右上角 Star / Fork:与插件市场列表同一口径,装之前看过的热度在已安装里也能对照。
|
|
130
|
+
// 自定义安装没有目录元数据(plugin === null),没有可展示的统计
|
|
131
|
+
item.plugin
|
|
132
|
+
? h('div', { className: styles.rowStats },
|
|
133
|
+
h('span', { className: styles.rowStar }, '\u2605 ', fmtStars(item.plugin.stats?.stargazers_count)),
|
|
134
|
+
h('span', { className: styles.rowFork }, t('fork'), ' ', fmtStars(item.plugin.stats?.forks_count)),
|
|
135
|
+
)
|
|
136
|
+
: null,
|
|
137
|
+
),
|
|
138
|
+
// 示例项目徽标单独一行(非 dsh 插件):标签较长也能完整显示,不挤压名称行
|
|
139
|
+
!item.dshCapable
|
|
140
|
+
? h('div', { className: styles.exampleRow },
|
|
141
|
+
h('span', { className: styles.exampleBadge, title: t('exampleHint') }, t('exampleLabel')),
|
|
142
|
+
)
|
|
143
|
+
: null,
|
|
144
|
+
// 第二行:仓库 · 分类 · 来源(与名称左对齐缩进,独立成行不再挤压名称)。
|
|
145
|
+
// 来源标签恒定存在,故此行恒渲染:仓库与分类缺失时仍要能看到它是从哪装进来的
|
|
146
|
+
h('div', { className: styles.rowMeta },
|
|
147
|
+
// 仓库名同样指向主站详情页;与名称一样阻止冒泡,不连带打开本地详情弹窗
|
|
148
|
+
item.repo
|
|
149
|
+
? h('a', {
|
|
150
|
+
className: styles.rowRepo,
|
|
151
|
+
href: pluginSiteUrl(item.repo),
|
|
152
|
+
target: '_blank',
|
|
153
|
+
rel: 'noopener noreferrer',
|
|
154
|
+
title: t('detail'),
|
|
155
|
+
onClick: (e: MouseEvent<HTMLAnchorElement>) => e.stopPropagation(),
|
|
156
|
+
}, item.repo)
|
|
157
|
+
: null,
|
|
158
|
+
category ? h('span', { className: styles.rowCategory },
|
|
159
|
+
categoryLabel(CATEGORY_LABELS, category, langKey)) : null,
|
|
160
|
+
// 来源标签:目录插件 →「插件市场」(品牌色),命令行安装 →「手动安装」(琥珀色)
|
|
119
161
|
h('span', {
|
|
120
162
|
className: isCustom
|
|
121
163
|
? `${styles.rowSourceTag} ${styles.rowSourceTagManual}`
|
|
122
164
|
: `${styles.rowSourceTag} ${styles.rowSourceTagHub}`,
|
|
123
165
|
title: isCustom ? t('manualInstallHint') : t('hubInstallHint'),
|
|
124
166
|
}, isCustom ? t('manualInstall') : t('hubInstall')),
|
|
125
|
-
|
|
167
|
+
),
|
|
168
|
+
// 第三行(仅目录插件):简介单独一行展示,超长以省略号收尾
|
|
169
|
+
desc ? h('div', { className: styles.rowDesc, title: desc }, desc) : null,
|
|
170
|
+
// 最后一行:操作按钮单独成行、靠右,名称与版本号那条线不再被按钮挤窄
|
|
171
|
+
h('div', { className: styles.rowActionLine },
|
|
172
|
+
// 行操作:重启(dsh 插件待重启时)/ 在 Finder 中显示 / 安装详情 / 更新(有更新时)/ 卸载
|
|
173
|
+
// —— 按钮点击不触发行打开详情
|
|
126
174
|
h('div', { className: styles.rowActions },
|
|
127
175
|
// 装完未挂载、且在待重启列表里的 dsh 插件:给「重启」入口,点了宿主重启后插件即生效;
|
|
128
176
|
// 已重启但仍加载失败(不在待重启列表)不显示「重启」,改为灰点「未激活」
|
|
@@ -134,8 +182,7 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
|
|
|
134
182
|
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRestart() },
|
|
135
183
|
}, t('restart'))
|
|
136
184
|
: null,
|
|
137
|
-
// 支持文件管理器定位的平台(macOS / Linux
|
|
138
|
-
// 行点击本身即打开详情弹窗,故不支持的平台(如 Windows)不显示此按钮
|
|
185
|
+
// 支持文件管理器定位的平台(macOS / Linux)才显示打开安装目录按钮
|
|
139
186
|
canReveal
|
|
140
187
|
? h('button', {
|
|
141
188
|
className: styles.rowDetail,
|
|
@@ -143,6 +190,13 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
|
|
|
143
190
|
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onReveal(item) },
|
|
144
191
|
}, revealLabel)
|
|
145
192
|
: null,
|
|
193
|
+
// 安装详情:显式入口,打开与点行内空白处同一个本地详情弹窗
|
|
194
|
+
h('button', {
|
|
195
|
+
className: styles.rowDetail,
|
|
196
|
+
type: 'button',
|
|
197
|
+
title: t('detail'),
|
|
198
|
+
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onOpenDetail(item) },
|
|
199
|
+
}, t('installDetail')),
|
|
146
200
|
item.hasUpdate && item.plugin
|
|
147
201
|
? h('button', {
|
|
148
202
|
className: styles.rowUpdate,
|
|
@@ -158,22 +212,6 @@ function InstalledRow({ item, t, langKey, canReveal, revealLabel, pendingRestart
|
|
|
158
212
|
}, t('uninstall')),
|
|
159
213
|
),
|
|
160
214
|
),
|
|
161
|
-
// 示例项目徽标单独一行(非 dsh 插件):标签较长也能完整显示,不挤压名称行
|
|
162
|
-
!item.dshCapable
|
|
163
|
-
? h('div', { className: styles.exampleRow },
|
|
164
|
-
h('span', { className: styles.exampleBadge, title: t('exampleHint') }, t('exampleLabel')),
|
|
165
|
-
)
|
|
166
|
-
: null,
|
|
167
|
-
// 第二行:仓库 · 分类(与名称左对齐缩进,独立成行不再挤压名称)
|
|
168
|
-
repo || category
|
|
169
|
-
? h('div', { className: styles.rowMeta },
|
|
170
|
-
item.repo ? h('span', { className: styles.rowRepo }, item.repo) : null,
|
|
171
|
-
category ? h('span', { className: styles.rowCategory },
|
|
172
|
-
categoryLabel(CATEGORY_LABELS, category, langKey)) : null,
|
|
173
|
-
)
|
|
174
|
-
: null,
|
|
175
|
-
// 第三行(仅目录插件):简介单独一行展示,超长以省略号收尾
|
|
176
|
-
desc ? h('div', { className: styles.rowDesc, title: desc }, desc) : null,
|
|
177
215
|
)
|
|
178
216
|
}
|
|
179
217
|
|
package/src/client/data/host.ts
CHANGED
|
@@ -48,6 +48,8 @@ export async function fetchInstalled(): Promise<HostInstalled | null> {
|
|
|
48
48
|
export interface NpmReleaseInfo {
|
|
49
49
|
version: string
|
|
50
50
|
publishedAt: string
|
|
51
|
+
/** 已过 pnpm 供应链门禁的最高稳定版:安装/展示命令钉住它;无可钉版本时为 null。 */
|
|
52
|
+
installable?: NpmReleaseInfo | null
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** 批量查询已安装插件 npm 包的最新版本发布时间(供应链门禁用);失败返回 null(调用方按原逻辑照常提示)。 */
|
|
@@ -562,7 +562,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
562
562
|
desc: p.description,
|
|
563
563
|
version: p.version,
|
|
564
564
|
updatedAt: p.dates?.repoUpdatedAt,
|
|
565
|
-
command: installCommandOf(p, true),
|
|
565
|
+
command: installCommandOf(p, true, opts?.pinVersion),
|
|
566
566
|
source: 'catalog',
|
|
567
567
|
update: opts?.update,
|
|
568
568
|
pinVersion: opts?.pinVersion,
|
package/src/client/locales.ts
CHANGED
|
@@ -404,7 +404,7 @@ export const zh = {
|
|
|
404
404
|
failKind_pluginMissingDep: '插件依赖未发布',
|
|
405
405
|
failReason_pluginMissingDep: '插件声明依赖的包在官方 npm 源上不存在(已删除或从未发布)。',
|
|
406
406
|
failKind_registryVersionStale: '该版本在所用源上不存在',
|
|
407
|
-
failReason_registryVersionStale: '
|
|
407
|
+
failReason_registryVersionStale: '实际安装用的源还没同步到这个版本。可稍后重试,或到「设置 → npm 镜像源」换成官方源;也可先安装上一个可用版本。',
|
|
408
408
|
failKind_registryAuth: 'npm 源拒绝了访问',
|
|
409
409
|
failReason_registryAuth: 'registry 返回 401/403:这个源需要登录凭据,或当前网络不被允许。',
|
|
410
410
|
failKind_profileExclusive: 'profile 由桌面应用独占管理',
|
|
@@ -471,6 +471,9 @@ export const zh = {
|
|
|
471
471
|
installEntryLine: '安装入口:{c}。',
|
|
472
472
|
reportIssue: '去插件仓库提交 Issue',
|
|
473
473
|
detail: '打开详情',
|
|
474
|
+
// 已安装行按钮:打开与点行内空白处相同的本地详情弹窗
|
|
475
|
+
installDetail: '安装详情',
|
|
476
|
+
author: '作者',
|
|
474
477
|
noResult: '没有找到匹配的插件',
|
|
475
478
|
noResultDesc: '换个关键词试试,或前往网站浏览全部插件。',
|
|
476
479
|
today: '今天更新',
|
|
@@ -507,10 +510,10 @@ export const zh = {
|
|
|
507
510
|
close: '关闭',
|
|
508
511
|
// 暂缓提示:远端确有新版本,但发布未满 24 小时(pnpm 供应链安全门槛),与「已是最新」严格区分
|
|
509
512
|
hubPendingDesc: '最新版已发布,但发布尚未满 24 小时:此时更新会被 pnpm 静默回退到旧版本,因此暂不提示。稍后这里会自动出现更新入口。',
|
|
510
|
-
//
|
|
513
|
+
// 头部「关注我们」弹窗:平台介绍(内容由 Worker /about Markdown 推送,非写死)
|
|
511
514
|
followUs: '关注我们',
|
|
512
515
|
aboutTitle: '关注我们',
|
|
513
|
-
aboutDesc: '了解 DSH Plugin Hub
|
|
516
|
+
aboutDesc: '了解 DSH Plugin Hub 是做什么的,访问主站浏览插件,或到 GitHub 提交 Issue。',
|
|
514
517
|
aboutEmpty: '内容暂未发布,敬请期待。',
|
|
515
518
|
aboutUpdated: '更新于',
|
|
516
519
|
empty: '该分类暂无插件',
|
|
@@ -912,7 +915,7 @@ export const en = {
|
|
|
912
915
|
failKind_pluginMissingDep: 'plugin dependency is not published',
|
|
913
916
|
failReason_pluginMissingDep: 'A package the plugin depends on does not exist on the official npm registry.',
|
|
914
917
|
failKind_registryVersionStale: 'this version is not available on the registry in use',
|
|
915
|
-
failReason_registryVersionStale: 'The registry that actually performs the install has not synced this version yet,
|
|
918
|
+
failReason_registryVersionStale: 'The registry that actually performs the install has not synced this version yet. Retry later, switch to the official registry under Settings → npm mirror, or install a previous version for now.',
|
|
916
919
|
failKind_registryAuth: 'the npm registry rejected access',
|
|
917
920
|
failReason_registryAuth: 'The registry returned 401/403: it needs credentials, or the current network is not allowed.',
|
|
918
921
|
failKind_profileExclusive: 'the profile is managed only by the desktop app',
|
|
@@ -979,6 +982,9 @@ export const en = {
|
|
|
979
982
|
installEntryLine: 'Install entry: {c}.',
|
|
980
983
|
reportIssue: 'Report an Issue to the plugin repo',
|
|
981
984
|
detail: 'Open details',
|
|
985
|
+
// Installed-row local details button: opens the same dialog as clicking the row body
|
|
986
|
+
installDetail: 'Install details',
|
|
987
|
+
author: 'Author',
|
|
982
988
|
noResult: 'No plugins match your search',
|
|
983
989
|
noResultDesc: 'Try another keyword, or browse all plugins on the website.',
|
|
984
990
|
today: 'updated today',
|
|
@@ -1013,10 +1019,10 @@ export const en = {
|
|
|
1013
1019
|
close: 'Close',
|
|
1014
1020
|
// 暂缓提示:确有新版本,但仍在 pnpm 24h 供应链门禁窗口内
|
|
1015
1021
|
hubPendingDesc: 'The latest version has been released, but it is still within the 24-hour window where pnpm silently falls back to the older version, so the update is not offered yet. The update entry will appear here shortly.',
|
|
1016
|
-
//
|
|
1022
|
+
// 头部「关注我们」弹窗:平台介绍(内容由 Worker /about 以 Markdown 推送)
|
|
1017
1023
|
followUs: 'Follow us',
|
|
1018
1024
|
aboutTitle: 'Follow us',
|
|
1019
|
-
aboutDesc: 'Learn what DSH Plugin Hub does
|
|
1025
|
+
aboutDesc: 'Learn what DSH Plugin Hub does — browse plugins on the website, or open an Issue on GitHub.',
|
|
1020
1026
|
aboutEmpty: 'Content not published yet — stay tuned.',
|
|
1021
1027
|
aboutUpdated: 'Updated',
|
|
1022
1028
|
empty: 'No plugins in this category yet',
|
|
@@ -29,12 +29,15 @@ export function installTargetOf(p: HubPlugin): string {
|
|
|
29
29
|
|
|
30
30
|
/** 展示用安装命令(复制/弹窗):目录下发的权威命令优先(CLI-only 插件如 dsh-tui
|
|
31
31
|
* 需专属 profile(--profile dsh-tui),无法从 npm 包名推断,必须用官方命令);
|
|
32
|
-
* 常规插件无目录命令时用 npm 包名回退生成。
|
|
33
|
-
|
|
32
|
+
* 常规插件无目录命令时用 npm 包名回退生成。
|
|
33
|
+
* version 为已过供应链门禁的版本:带上它,手抄命令与原位安装的执行结果一致,
|
|
34
|
+
* 不会因 pnpm 门禁静默回退到很旧的版本。 */
|
|
35
|
+
export function installCommandOf(p: HubPlugin, withProfile = false, version?: string): string {
|
|
34
36
|
const cmd = p.install?.command
|
|
35
37
|
if (cmd) return cmd
|
|
36
38
|
const prefix = `dsh plugin${withProfile ? ' --profile web' : ''} add `
|
|
37
|
-
|
|
39
|
+
const target = installTargetOf(p)
|
|
40
|
+
return `${prefix}${version === undefined || version === '' ? target : `${target}@${version}`}`
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/** 把任务/安装目标归一化为 owner/repo 展示身份。 */
|