dsh-plugin 1.4.13 → 1.5.0
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 +345 -301
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +17 -10
- package/lib/services/install/npm-resolve.js +29 -13
- package/lib/services/install/preflight.js +5 -0
- package/lib/services/install/release-target.js +38 -0
- package/lib/services/profile/profile.js +6 -0
- package/lib/types/services/install/npm-resolve.d.ts +22 -0
- package/lib/types/services/install/release-target.d.ts +28 -0
- package/package.json +1 -1
- package/src/client/components/modals/AboutModal.tsx +18 -2
- package/src/client/components/modals/NotificationsModal.tsx +1 -2
- package/src/client/components/modals/modals.tsx +1 -2
- package/src/client/logic/failures.ts +10 -12
- package/src/client/logic/install-command.ts +22 -8
- package/src/server/http/routes.ts +17 -10
- package/src/server/services/install/npm-resolve.ts +41 -13
- package/src/server/services/install/preflight.ts +4 -0
- package/src/server/services/install/release-target.ts +46 -0
- package/src/server/services/profile/profile.ts +5 -0
package/lib/http/routes.js
CHANGED
|
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
14
14
|
import { spawn, spawnSync } from 'node:child_process';
|
|
15
15
|
import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/probe.js';
|
|
16
16
|
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install/install.js';
|
|
17
|
+
import { githubReleaseTarget } from '../services/install/release-target.js';
|
|
17
18
|
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.js';
|
|
18
19
|
import { resolveNpmPackage } from '../services/install/npm-resolve.js';
|
|
19
20
|
import { preflightTarget } from '../services/install/preflight.js';
|
|
@@ -801,12 +802,14 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
801
802
|
sendJson(response, 200, { ok: true, task: task.id });
|
|
802
803
|
return;
|
|
803
804
|
}
|
|
804
|
-
//
|
|
805
|
-
//
|
|
805
|
+
// 目标语法三态:固定 GitHub release .tgz(保留其包根目录,不转成仓库 HEAD);
|
|
806
|
+
// GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式 HTTPS Git 源
|
|
807
|
+
// (走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
|
|
806
808
|
// githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
|
|
807
809
|
const gitRepo = githubRepoOf(rawRepo);
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
+
const release = githubReleaseTarget(rawRepo);
|
|
811
|
+
const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo) : null;
|
|
812
|
+
let target = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null);
|
|
810
813
|
if (target === null) {
|
|
811
814
|
sendJson(response, 400, { error: 'unsupported install target' });
|
|
812
815
|
return;
|
|
@@ -836,9 +839,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
836
839
|
}
|
|
837
840
|
// 命令行安装(目录外)按通道门禁:GitHub 源码通道受「启用 GitHub 源码安装」控制,
|
|
838
841
|
// npm 通道受「启用 NPM 安装」控制 —— 目录插件安装走目录白名单,不受开关影响。
|
|
839
|
-
// 通道判定看反查后的最终 target
|
|
842
|
+
// 通道判定看反查后的最终 target:release 包与 git 目标同属 GitHub 通道,否则走 npm。
|
|
840
843
|
if (source !== 'catalog') {
|
|
841
|
-
const isGitChannel = repoTarget !== null && target === repoTarget;
|
|
844
|
+
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget);
|
|
842
845
|
if (isGitChannel && !settings.enableGitInstall) {
|
|
843
846
|
sendJson(response, 403, { error: 'git installs are disabled by the security settings' });
|
|
844
847
|
return;
|
|
@@ -874,8 +877,12 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
874
877
|
const effectiveProxy = settings.proxy !== ''
|
|
875
878
|
? settings.proxy
|
|
876
879
|
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
877
|
-
const isGitChannel = repoTarget !== null && target === repoTarget;
|
|
878
|
-
|
|
880
|
+
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget);
|
|
881
|
+
// release 包是 https://github.com/ 直链下载(非 git 克隆),探针按 HTTP 打 github.com 主页;
|
|
882
|
+
// npm 通道探 registry 根。两者都是通道的稳定入口,不探具体包/仓库(404 会被误判成网络故障)。
|
|
883
|
+
const httpProbeTarget = release !== null
|
|
884
|
+
? 'https://github.com/'
|
|
885
|
+
: `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`;
|
|
879
886
|
// git 通道的探针换成克隆握手本身:git ls-remote 走的就是 pnpm 克隆前的同一套
|
|
880
887
|
// https 传输。curl 打 github.com 主页会被按协议/端口区分的防火墙与 TLS 复检
|
|
881
888
|
// 拦截(能 git 克隆、打不开网页),把可装的仓库误判成 [network] 而中止
|
|
@@ -893,9 +900,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
893
900
|
}
|
|
894
901
|
}
|
|
895
902
|
else {
|
|
896
|
-
net = await probeUrl(
|
|
903
|
+
net = await probeUrl(httpProbeTarget, effectiveProxy, 6000);
|
|
897
904
|
}
|
|
898
|
-
const probeTarget = gitProbeTarget ??
|
|
905
|
+
const probeTarget = gitProbeTarget ?? httpProbeTarget;
|
|
899
906
|
if (!net.ok) {
|
|
900
907
|
// 错误消息按客户端界面语言提示:中文界面给中文、英文界面给英文,
|
|
901
908
|
// 让用户一眼看懂是网络问题而非插件问题
|
|
@@ -16,6 +16,8 @@ import { get } from 'node:https';
|
|
|
16
16
|
const cache = new Map();
|
|
17
17
|
/** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
|
|
18
18
|
const REQUEST_TIMEOUT_MS = 8000;
|
|
19
|
+
/** npm search 是相关性搜索而非 repository 精确索引;拉满单页结果后再本地做严格 repo 过滤。 */
|
|
20
|
+
const SEARCH_RESULT_LIMIT = 250;
|
|
19
21
|
/**
|
|
20
22
|
* 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
|
|
21
23
|
* 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
|
|
@@ -37,6 +39,31 @@ export function resolveNpmPackage(repo, registry = '') {
|
|
|
37
39
|
});
|
|
38
40
|
return name.then((found) => found ?? null);
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Pick the installable npm package for one GitHub repository. npm search is a
|
|
44
|
+
* relevance-ranked text search, not an exact repository index, so first filter
|
|
45
|
+
* by repository URL. If a monorepo publishes both a CLI/helper package and a
|
|
46
|
+
* DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
|
|
47
|
+
* keyword instead of whichever result npm happened to rank first.
|
|
48
|
+
*/
|
|
49
|
+
export function selectNpmPackageForRepo(objects, repo) {
|
|
50
|
+
const needle = `github.com/${repo}`.toLowerCase();
|
|
51
|
+
const matches = [];
|
|
52
|
+
for (const obj of objects) {
|
|
53
|
+
const pkg = obj.package;
|
|
54
|
+
if (typeof pkg?.name !== 'string' || pkg.name === '')
|
|
55
|
+
continue;
|
|
56
|
+
const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '');
|
|
57
|
+
if (!repoUrl.toLowerCase().includes(needle))
|
|
58
|
+
continue;
|
|
59
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : [];
|
|
60
|
+
matches.push({
|
|
61
|
+
name: pkg.name,
|
|
62
|
+
isDshPlugin: keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin'),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null;
|
|
66
|
+
}
|
|
40
67
|
/** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
|
|
41
68
|
* 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */
|
|
42
69
|
function searchRepo(repo, registry) {
|
|
@@ -44,7 +71,7 @@ function searchRepo(repo, registry) {
|
|
|
44
71
|
if (owner === undefined || name === undefined || name === '')
|
|
45
72
|
return Promise.resolve(null);
|
|
46
73
|
const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '');
|
|
47
|
-
const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size
|
|
74
|
+
const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=${SEARCH_RESULT_LIMIT}`;
|
|
48
75
|
return new Promise((resolve) => {
|
|
49
76
|
const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
50
77
|
if (res.statusCode !== 200) {
|
|
@@ -57,18 +84,7 @@ function searchRepo(repo, registry) {
|
|
|
57
84
|
res.on('end', () => {
|
|
58
85
|
try {
|
|
59
86
|
const data = JSON.parse(body);
|
|
60
|
-
|
|
61
|
-
for (const obj of data.objects ?? []) {
|
|
62
|
-
const pkg = obj.package;
|
|
63
|
-
if (typeof pkg?.name !== 'string' || pkg.name === '')
|
|
64
|
-
continue;
|
|
65
|
-
const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '');
|
|
66
|
-
if (repoUrl.toLowerCase().includes(needle)) {
|
|
67
|
-
resolve(pkg.name);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
resolve(null);
|
|
87
|
+
resolve(selectNpmPackageForRepo(data.objects ?? [], `${owner}/${name}`));
|
|
72
88
|
}
|
|
73
89
|
catch {
|
|
74
90
|
resolve(undefined);
|
|
@@ -18,12 +18,17 @@ 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 { githubReleaseTarget } from './release-target.js';
|
|
21
22
|
import { resolvePackageEntry } from './package-entry.js';
|
|
22
23
|
const execFileAsync = promisify(execFile);
|
|
23
24
|
/** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
|
|
24
25
|
* 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
|
|
25
26
|
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
|
26
27
|
export async function preflightTarget(target) {
|
|
28
|
+
// release 包自带包根:拿 monorepo HEAD 的源码分发去校验它没有意义,会把完整产物
|
|
29
|
+
// 误判成缺入口文件而拦下。该包的真实性交给安装本身与装后 verifyInstalledEntry 兜底。
|
|
30
|
+
if (githubReleaseTarget(target) !== null)
|
|
31
|
+
return { ok: true, missing: null, name: null };
|
|
27
32
|
const source = githubRepoOf(target);
|
|
28
33
|
if (source === null)
|
|
29
34
|
return { ok: true, missing: null, name: null };
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
* 预构建 GitHub release 安装目标(目录权威命令 → 可安装直链)。
|
|
7
|
+
* 部分插件(如 loopx)把构建产物发布成 GitHub release 的 .tgz,仓库 HEAD 反而是
|
|
8
|
+
* monorepo 根、走 git 直装会因缺入口文件失败;目录下发的权威安装命令直接指向该
|
|
9
|
+
* release 包。这里把「目录命令」解析成安全的 release 目标,供客户端选路与服务端
|
|
10
|
+
* 校验共用(单一事实来源,避免两端口径漂移)。
|
|
11
|
+
*
|
|
12
|
+
* 只接受固定形态的 HTTPS `github.com/<owner>/<repo>/releases/download/<tag>/<asset>.tgz`:
|
|
13
|
+
* 目录命令是展示数据、绝非 shell 代码,绝不执行命令文本;跨仓跳转、外部主机、凭据、
|
|
14
|
+
* 移动的 latest 链接、query/fragment、多余 CLI 参数与 shell 片段一律拒绝。
|
|
15
|
+
*/
|
|
16
|
+
/** 目录权威命令:`dsh plugin --profile <p> add|update <target>`(与 profile.ts installTargetOf 同口径)。 */
|
|
17
|
+
const DSH_PLUGIN_CMD_RE = /^dsh[ \t]+plugin[ \t]+--profile(?:[ \t]+|=)[A-Za-z0-9_-]+[ \t]+(?:add|update)[ \t]+(.+)$/i;
|
|
18
|
+
/** 固定形态的 release .tgz 直链:主机、路径段与扩展名全部锁死。 */
|
|
19
|
+
const RELEASE_URL_RE = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/releases\/download\/([A-Za-z0-9._+-]+)\/([A-Za-z0-9._+-]+\.tgz)$/;
|
|
20
|
+
/**
|
|
21
|
+
* 解析一个 release 安装目标。
|
|
22
|
+
* @param value - 目录命令(`dsh plugin --profile web add <url>`)或裸直链。
|
|
23
|
+
* @returns 命中返回 `{ target, repo }`,否则 null。
|
|
24
|
+
*/
|
|
25
|
+
export function githubReleaseTarget(value) {
|
|
26
|
+
const input = typeof value === 'string' ? value.trim() : '';
|
|
27
|
+
if (input === '')
|
|
28
|
+
return null;
|
|
29
|
+
const command = DSH_PLUGIN_CMD_RE.exec(input);
|
|
30
|
+
const raw = command !== null ? command[1].trim() : input;
|
|
31
|
+
const match = RELEASE_URL_RE.exec(raw);
|
|
32
|
+
if (match === null)
|
|
33
|
+
return null;
|
|
34
|
+
// 拒绝 `.` / `..` 路径段,杜绝借路径穿越跳出 release 目录
|
|
35
|
+
if (match.slice(1).some((part) => part === '.' || part === '..'))
|
|
36
|
+
return null;
|
|
37
|
+
return { target: raw, repo: `${match[1]}/${match[2]}` };
|
|
38
|
+
}
|
|
@@ -10,6 +10,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { basename, dirname, join } from 'node:path';
|
|
12
12
|
import { PACKAGE_RE, REPO_RE } from '../install/install-types.js';
|
|
13
|
+
import { githubReleaseTarget } from '../install/release-target.js';
|
|
13
14
|
/** Resolve the active profile from the booted CLI args, falling back to `web`. */
|
|
14
15
|
export function readProfileArg(fallback = 'web') {
|
|
15
16
|
return profileFromArgv(process.argv, fallback);
|
|
@@ -90,6 +91,11 @@ export function githubRepoOf(value) {
|
|
|
90
91
|
if (typeof value !== 'string')
|
|
91
92
|
return null;
|
|
92
93
|
const input = value.trim();
|
|
94
|
+
// release 直链先归一到仓库身份:目录命令里的固定 HTTPS release 包不是 git 地址,
|
|
95
|
+
// 但它的所属仓库就是 owner/repo —— 已装态读回、去重、展示都要拿到这层身份。
|
|
96
|
+
const release = githubReleaseTarget(input);
|
|
97
|
+
if (release !== null)
|
|
98
|
+
return release.repo;
|
|
93
99
|
if (REPO_RE.test(input))
|
|
94
100
|
return input;
|
|
95
101
|
const patterns = [
|
|
@@ -9,3 +9,25 @@
|
|
|
9
9
|
* 慢网络下失败不阻塞安装。
|
|
10
10
|
*/
|
|
11
11
|
export declare function resolveNpmPackage(repo: string, registry?: string): Promise<string | null>;
|
|
12
|
+
type NpmSearchPackage = {
|
|
13
|
+
name?: unknown;
|
|
14
|
+
keywords?: unknown;
|
|
15
|
+
links?: {
|
|
16
|
+
repository?: unknown;
|
|
17
|
+
};
|
|
18
|
+
repository?: {
|
|
19
|
+
url?: unknown;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
type NpmSearchObject = {
|
|
23
|
+
package?: NpmSearchPackage;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Pick the installable npm package for one GitHub repository. npm search is a
|
|
27
|
+
* relevance-ranked text search, not an exact repository index, so first filter
|
|
28
|
+
* by repository URL. If a monorepo publishes both a CLI/helper package and a
|
|
29
|
+
* DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
|
|
30
|
+
* keyword instead of whichever result npm happened to rank first.
|
|
31
|
+
*/
|
|
32
|
+
export declare function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null;
|
|
33
|
+
export {};
|
|
@@ -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
|
+
* 预构建 GitHub release 安装目标(目录权威命令 → 可安装直链)。
|
|
7
|
+
* 部分插件(如 loopx)把构建产物发布成 GitHub release 的 .tgz,仓库 HEAD 反而是
|
|
8
|
+
* monorepo 根、走 git 直装会因缺入口文件失败;目录下发的权威安装命令直接指向该
|
|
9
|
+
* release 包。这里把「目录命令」解析成安全的 release 目标,供客户端选路与服务端
|
|
10
|
+
* 校验共用(单一事实来源,避免两端口径漂移)。
|
|
11
|
+
*
|
|
12
|
+
* 只接受固定形态的 HTTPS `github.com/<owner>/<repo>/releases/download/<tag>/<asset>.tgz`:
|
|
13
|
+
* 目录命令是展示数据、绝非 shell 代码,绝不执行命令文本;跨仓跳转、外部主机、凭据、
|
|
14
|
+
* 移动的 latest 链接、query/fragment、多余 CLI 参数与 shell 片段一律拒绝。
|
|
15
|
+
*/
|
|
16
|
+
/** release 包目标及其所属仓库身份(owner/repo);非法返回 null。 */
|
|
17
|
+
export interface GithubReleaseTarget {
|
|
18
|
+
/** 规范化后的安装目标(HTTPS 直链) */
|
|
19
|
+
target: string;
|
|
20
|
+
/** 该直链所属仓库(owner/repo):供已装态读回、队列去重与展示身份复用 */
|
|
21
|
+
repo: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 解析一个 release 安装目标。
|
|
25
|
+
* @param value - 目录命令(`dsh plugin --profile web add <url>`)或裸直链。
|
|
26
|
+
* @returns 命中返回 `{ target, repo }`,否则 null。
|
|
27
|
+
*/
|
|
28
|
+
export declare function githubReleaseTarget(value: string): GithubReleaseTarget | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
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",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 客户端按接口返回渲染,不内置固定文案。
|
|
9
9
|
* 图片(反馈群二维码)经 renderMarkdown 的  语法嵌入,居中、最大高度受控。
|
|
10
10
|
*/
|
|
11
|
-
import { createElement as h } from 'react'
|
|
11
|
+
import { createElement as h, useMemo } from 'react'
|
|
12
12
|
import type { MouseEvent } from 'react'
|
|
13
13
|
import styles from '../../styles/Modal.module.css'
|
|
14
14
|
import type { HubAboutInfo } from '../../types.ts'
|
|
@@ -16,6 +16,19 @@ import type { LocaleId, Translate } from '../../types.ts'
|
|
|
16
16
|
import { renderMarkdown } from '../../logic/renderMarkdown.ts'
|
|
17
17
|
import { CloseIcon } from '../ui/icons.tsx'
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 反馈群二维码 URL 恒定(每次换码都是同名覆盖,文件名不变),浏览器 / CDN 会按
|
|
21
|
+
* URL 缓存旧图。这里在渲染前给二维码 URL 实时拼一个新的时间戳,强制每次打开
|
|
22
|
+
* 「关注我们」都回源拉取最新图片,无需重新构建 / 部署接口中心。
|
|
23
|
+
* 匹配规则与 renderMarkdown 的二维码识别保持一致(dsh-plugin-user-group-qr 前缀)。
|
|
24
|
+
*/
|
|
25
|
+
function bustQrCache(md: string, ts: number): string {
|
|
26
|
+
return md.replace(
|
|
27
|
+
/(!\[[^\]]*\]\()([^)\s]*dsh-plugin-user-group-qr[^)\s]*)/g,
|
|
28
|
+
(_m, pre, url) => `${pre}${String(url).split('?')[0]}?v=${ts}`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
export function AboutModal({ info, lang, t, onClose }: {
|
|
20
33
|
info: HubAboutInfo | null
|
|
21
34
|
lang: LocaleId
|
|
@@ -33,7 +46,10 @@ export function AboutModal({ info, lang, t, onClose }: {
|
|
|
33
46
|
: (info.content.zh ?? info.content.en ?? '')
|
|
34
47
|
: ''
|
|
35
48
|
: ''
|
|
36
|
-
|
|
49
|
+
// 每次打开弹窗(组件重新挂载)生成一个时间戳,用于二维码破缓存;
|
|
50
|
+
// 同一次打开内保持稳定,避免父组件重渲染导致图片被反复重载。
|
|
51
|
+
const qrTs = useMemo(() => Date.now(), [])
|
|
52
|
+
const contentHtml = contentRaw.trim() ? renderMarkdown(bustQrCache(contentRaw, qrTs)) : null
|
|
37
53
|
// 更新时间:ISO 字符串转本地可读格式;非法值静默隐藏
|
|
38
54
|
let updated: string | null = null
|
|
39
55
|
if (info?.updatedAt) {
|
|
@@ -310,8 +310,7 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
310
310
|
if (kind === 'pnpmMissingDep') {
|
|
311
311
|
// profile 里留着指向 registry 上不存在(404)的包的依赖:pnpm 连得上 registry,
|
|
312
312
|
// 是被明确告知包不存在 —— 该 profile 的任何安装都会先卡在这条依赖上,与本次要装的
|
|
313
|
-
//
|
|
314
|
-
// 删除条目指引,不引导提 Issue
|
|
313
|
+
// 插件无关 → 点名缺失的包 + 删除条目指引,不引导提 Issue
|
|
315
314
|
const pkg = missingRegistryPackageOf(r.message)
|
|
316
315
|
return h('div', null, [
|
|
317
316
|
pkg ? h('div', { className: styles.failNetworkTarget }, t('failPnpmMissingDepTarget', { pkg })) : null,
|
|
@@ -424,8 +424,7 @@ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onC
|
|
|
424
424
|
: failureKind === 'pnpmMissingDep'
|
|
425
425
|
// profile 里留着指向 registry 上不存在(404)的包的依赖:pnpm 连得上 registry,
|
|
426
426
|
// 是被明确告知包不存在 —— 该 profile 的任何安装都会先卡在这条依赖上,与本次要装的
|
|
427
|
-
//
|
|
428
|
-
// 删除条目指引,不引导提 Issue
|
|
427
|
+
// 插件无关 → 点名缺失的包 + 删除条目指引,不引导提 Issue
|
|
429
428
|
? h('div', null, [
|
|
430
429
|
(() => {
|
|
431
430
|
const pkg = missingRegistryPackageOf(message)
|
|
@@ -315,17 +315,16 @@ export function classifyFailure(message: string): FailureKind {
|
|
|
315
315
|
// 与 pluginPrepare 同类(插件打包/分发问题),引导去仓库提 Issue
|
|
316
316
|
if (/\[packaging\]|entry file missing/i.test(message)) return 'pluginPrepare'
|
|
317
317
|
// 依赖在 registry 上不存在(`ERR_PNPM_FETCH_404` / `Not Found - 404`):pnpm 连得上 registry,
|
|
318
|
-
// 是被明确告知「这个包不存在」——
|
|
319
|
-
//
|
|
320
|
-
// 必须在 pluginPrepare
|
|
321
|
-
//
|
|
318
|
+
// 是被明确告知「这个包不存在」—— 属于 profile 的本地依赖问题,不是插件问题。profile 里留着
|
|
319
|
+
// 一条指向未发布包的依赖条目时,该 profile 的**任何**安装都会先卡在这条依赖上。
|
|
320
|
+
// 必须在 pluginPrepare 之前判定:此时宿主尾部只有通用的 `dsh: plugin command failed`,
|
|
321
|
+
// 落到下面会被当成构建失败。
|
|
322
322
|
// (两个形态:pnpm 的 `[ERR_PNPM_FETCH_404] GET <url>: Not Found - 404` 与 npm 的
|
|
323
323
|
// `npm error 404 Not Found - GET <url> - Not found`)
|
|
324
324
|
if (/ERR_PNPM_FETCH_404|404 Not Found|Not Found - 404/i.test(message)) return 'pnpmMissingDep'
|
|
325
|
-
// 再判 prepare
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
// 「插件分发不完整」,把 Issue 误挂到正在安装的插件上,见 #73)
|
|
325
|
+
// 再判 prepare 实际执行失败:只有构建脚本真的跑挂了才是插件问题。
|
|
326
|
+
// `Command failed` 区分大小写 —— 只匹配 pnpm 包装出的报错,不匹配宿主那句小写的
|
|
327
|
+
// `dsh: plugin command failed`(那只是「这条命令失败了」的通用说明)。
|
|
329
328
|
if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|prepare-guard/i.test(message) || /Command failed/.test(message)) return 'pluginPrepare'
|
|
330
329
|
// 其余失败(含 git prepare 被 pnpm 白名单拦截):当前通道装不上 = 插件分发/依赖的问题,一律提 Issue
|
|
331
330
|
return 'repo'
|
|
@@ -386,16 +385,15 @@ export function summarizeError(message: string, maxChars: number = MAX_CORE_CHAR
|
|
|
386
385
|
}
|
|
387
386
|
|
|
388
387
|
/** 提取首个错误代码(如 ERR_PNPM_PREPARE_PACKAGE),无则 null。
|
|
389
|
-
* 代码里可能含数字(ERR_PNPM_FETCH_404 / ERR_PNPM_EBADPLATFORM_
|
|
390
|
-
*
|
|
388
|
+
* 代码里可能含数字(ERR_PNPM_FETCH_404 / ERR_PNPM_EBADPLATFORM_...),字符类需带上 0-9,
|
|
389
|
+
* 否则会被截断成 `ERR_PNPM_FETCH_`。 */
|
|
391
390
|
export function coreErrorCode(message: string): string | null {
|
|
392
391
|
const m = message.match(/\[?ERR_[A-Z0-9_]+\]?/)
|
|
393
392
|
return m ? m[0].replace(/^\[|\]$/g, '') : null
|
|
394
393
|
}
|
|
395
394
|
|
|
396
395
|
/** 提取「registry 上不存在的包名」:从 404 行(`ERR_PNPM_FETCH_404 GET <registry>/<pkg>: Not Found - 404`)
|
|
397
|
-
* 里取请求路径中的包名(含 scope,如 `@scope/name`);非 404 或提取不到返回 null。
|
|
398
|
-
* 调用方据此在提示里点名具体是哪个依赖缺失 —— 光说「有个包 404 了」用户无从下手。 */
|
|
396
|
+
* 里取请求路径中的包名(含 scope,如 `@scope/name`);非 404 或提取不到返回 null。 */
|
|
399
397
|
export function missingRegistryPackageOf(message: string): string | null {
|
|
400
398
|
for (const line of message.split(/\r?\n/)) {
|
|
401
399
|
if (!/ERR_PNPM_FETCH_404|404/.test(line)) continue
|
|
@@ -4,20 +4,29 @@
|
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
6
|
* Install target/command helpers: decide the install channel (npm package
|
|
7
|
-
* vs explicit-HTTPS GitHub), build the display
|
|
8
|
-
* install spec back to its owner/repo identity.
|
|
7
|
+
* vs prebuilt GitHub release vs explicit-HTTPS GitHub), build the display
|
|
8
|
+
* command and normalize a raw install spec back to its owner/repo identity.
|
|
9
9
|
*/
|
|
10
10
|
import type { HubPlugin } from '../types.ts'
|
|
11
|
+
import { githubReleaseTarget } from '../../server/services/install/release-target.ts'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* 安装通道决策(用户无感知):目录探测到 npm 包名 → 用 npm 包名安装
|
|
14
|
-
* (走 npm registry tarball,更快、与 GitHub
|
|
15
|
-
*
|
|
15
|
+
* (走 npm registry tarball,更快、与 GitHub 网络无关);其次用「同仓库」的权威
|
|
16
|
+
* release 包直链(仓库 HEAD 往往是 monorepo 根、装不上,release .tgz 才是完整产物);
|
|
17
|
+
* 都没有 → git 直装。
|
|
18
|
+
* 返回值 target 即传给后端 /install 的安装目标(npm 包名 / release URL / owner/repo)。
|
|
16
19
|
*/
|
|
17
|
-
export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'github' } {
|
|
20
|
+
export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'release' | 'github' } {
|
|
18
21
|
const pkg = (p.source?.npmPackage ?? '').trim()
|
|
19
22
|
const repo = (p.source?.repo ?? '').trim()
|
|
20
23
|
if (pkg && repo) return { target: pkg, via: 'npm' }
|
|
24
|
+
// 目录下发的权威命令若是「同仓库」的固定 release 直链,改走该预构建包;
|
|
25
|
+
// 跨仓 / 非法 release 目标不采信,退回 git 直装。
|
|
26
|
+
const release = githubReleaseTarget(p.install?.githubCommand ?? '')
|
|
27
|
+
if (release !== null && repo !== '' && release.repo.toLowerCase() === repo.toLowerCase()) {
|
|
28
|
+
return { target: release.target, via: 'release' }
|
|
29
|
+
}
|
|
21
30
|
return { target: repo, via: 'github' }
|
|
22
31
|
}
|
|
23
32
|
|
|
@@ -30,17 +39,22 @@ export function installCommandOf(p: HubPlugin, withProfile = false): string {
|
|
|
30
39
|
const cmd = p.install?.command
|
|
31
40
|
if (cmd) return cmd
|
|
32
41
|
} else {
|
|
42
|
+
// release 与 git 通道共用目录下发的 githubCommand(git+ 显式 HTTPS 或 release 直链)
|
|
33
43
|
const cmd = p.install?.githubCommand
|
|
34
44
|
if (cmd) return cmd
|
|
35
45
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const prefix = `dsh plugin${withProfile ? ' --profile web' : ''} add `
|
|
47
|
+
if (via === 'npm' || via === 'release') return `${prefix}${target}`
|
|
48
|
+
return `${prefix}git+https://github.com/${target}.git`
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
/** Normalize a task/install target to its owner/repo display identity. */
|
|
42
52
|
export function repoFromInstallTarget(value: string): string {
|
|
43
53
|
const input = value.trim()
|
|
54
|
+
// release 直链(预构建 .tgz)走同一条身份归一化:解析出所属仓库,已装态/更新信号才能
|
|
55
|
+
// 认回目录条目;仅接受固定形态直链,跨仓/非法目标不接受,继续走下面的常规解析。
|
|
56
|
+
const release = githubReleaseTarget(input)
|
|
57
|
+
if (release !== null) return release.repo
|
|
44
58
|
if (/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input)) return input
|
|
45
59
|
const patterns = [
|
|
46
60
|
/^github:([^/]+)\/([^/]+)$/i,
|
|
@@ -15,6 +15,7 @@ import { dirname, join, resolve } from 'node:path'
|
|
|
15
15
|
import { spawn, spawnSync } from 'node:child_process'
|
|
16
16
|
import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/probe.ts'
|
|
17
17
|
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install/install.ts'
|
|
18
|
+
import { githubReleaseTarget } from '../services/install/release-target.ts'
|
|
18
19
|
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.ts'
|
|
19
20
|
import { resolveNpmPackage } from '../services/install/npm-resolve.ts'
|
|
20
21
|
import { preflightTarget } from '../services/install/preflight.ts'
|
|
@@ -791,12 +792,14 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
791
792
|
sendJson(response, 200, { ok: true, task: task.id })
|
|
792
793
|
return
|
|
793
794
|
}
|
|
794
|
-
//
|
|
795
|
-
//
|
|
795
|
+
// 目标语法三态:固定 GitHub release .tgz(保留其包根目录,不转成仓库 HEAD);
|
|
796
|
+
// GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式 HTTPS Git 源
|
|
797
|
+
// (走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
|
|
796
798
|
// githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
|
|
797
799
|
const gitRepo = githubRepoOf(rawRepo)
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
+
const release = githubReleaseTarget(rawRepo)
|
|
801
|
+
const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo) : null
|
|
802
|
+
let target: string | null = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null)
|
|
800
803
|
if (target === null) {
|
|
801
804
|
sendJson(response, 400, { error: 'unsupported install target' })
|
|
802
805
|
return
|
|
@@ -825,9 +828,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
825
828
|
}
|
|
826
829
|
// 命令行安装(目录外)按通道门禁:GitHub 源码通道受「启用 GitHub 源码安装」控制,
|
|
827
830
|
// npm 通道受「启用 NPM 安装」控制 —— 目录插件安装走目录白名单,不受开关影响。
|
|
828
|
-
// 通道判定看反查后的最终 target
|
|
831
|
+
// 通道判定看反查后的最终 target:release 包与 git 目标同属 GitHub 通道,否则走 npm。
|
|
829
832
|
if (source !== 'catalog') {
|
|
830
|
-
const isGitChannel = repoTarget !== null && target === repoTarget
|
|
833
|
+
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget)
|
|
831
834
|
if (isGitChannel && !settings.enableGitInstall) {
|
|
832
835
|
sendJson(response, 403, { error: 'git installs are disabled by the security settings' })
|
|
833
836
|
return
|
|
@@ -863,8 +866,12 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
863
866
|
const effectiveProxy = settings.proxy !== ''
|
|
864
867
|
? settings.proxy
|
|
865
868
|
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
|
|
866
|
-
const isGitChannel = repoTarget !== null && target === repoTarget
|
|
867
|
-
|
|
869
|
+
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget)
|
|
870
|
+
// release 包是 https://github.com/ 直链下载(非 git 克隆),探针按 HTTP 打 github.com 主页;
|
|
871
|
+
// npm 通道探 registry 根。两者都是通道的稳定入口,不探具体包/仓库(404 会被误判成网络故障)。
|
|
872
|
+
const httpProbeTarget = release !== null
|
|
873
|
+
? 'https://github.com/'
|
|
874
|
+
: `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`
|
|
868
875
|
// git 通道的探针换成克隆握手本身:git ls-remote 走的就是 pnpm 克隆前的同一套
|
|
869
876
|
// https 传输。curl 打 github.com 主页会被按协议/端口区分的防火墙与 TLS 复检
|
|
870
877
|
// 拦截(能 git 克隆、打不开网页),把可装的仓库误判成 [network] 而中止
|
|
@@ -880,9 +887,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
880
887
|
if (reachable.ok) net = reachable
|
|
881
888
|
}
|
|
882
889
|
} else {
|
|
883
|
-
net = await probeUrl(
|
|
890
|
+
net = await probeUrl(httpProbeTarget, effectiveProxy, 6000)
|
|
884
891
|
}
|
|
885
|
-
const probeTarget = gitProbeTarget ??
|
|
892
|
+
const probeTarget = gitProbeTarget ?? httpProbeTarget
|
|
886
893
|
if (!net.ok) {
|
|
887
894
|
// 错误消息按客户端界面语言提示:中文界面给中文、英文界面给英文,
|
|
888
895
|
// 让用户一眼看懂是网络问题而非插件问题
|
|
@@ -18,6 +18,8 @@ const cache = new Map<string, string | null>()
|
|
|
18
18
|
|
|
19
19
|
/** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
|
|
20
20
|
const REQUEST_TIMEOUT_MS = 8000
|
|
21
|
+
/** npm search 是相关性搜索而非 repository 精确索引;拉满单页结果后再本地做严格 repo 过滤。 */
|
|
22
|
+
const SEARCH_RESULT_LIMIT = 250
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
|
|
@@ -39,13 +41,49 @@ export function resolveNpmPackage(repo: string, registry = ''): Promise<string |
|
|
|
39
41
|
return name.then((found) => found ?? null)
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
type NpmSearchPackage = {
|
|
45
|
+
name?: unknown
|
|
46
|
+
keywords?: unknown
|
|
47
|
+
links?: { repository?: unknown }
|
|
48
|
+
repository?: { url?: unknown }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type NpmSearchObject = { package?: NpmSearchPackage }
|
|
52
|
+
type NpmSearchResponse = { objects?: NpmSearchObject[] }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pick the installable npm package for one GitHub repository. npm search is a
|
|
56
|
+
* relevance-ranked text search, not an exact repository index, so first filter
|
|
57
|
+
* by repository URL. If a monorepo publishes both a CLI/helper package and a
|
|
58
|
+
* DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
|
|
59
|
+
* keyword instead of whichever result npm happened to rank first.
|
|
60
|
+
*/
|
|
61
|
+
export function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null {
|
|
62
|
+
const needle = `github.com/${repo}`.toLowerCase()
|
|
63
|
+
const matches: Array<{ name: string; isDshPlugin: boolean }> = []
|
|
64
|
+
|
|
65
|
+
for (const obj of objects) {
|
|
66
|
+
const pkg = obj.package
|
|
67
|
+
if (typeof pkg?.name !== 'string' || pkg.name === '') continue
|
|
68
|
+
const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '')
|
|
69
|
+
if (!repoUrl.toLowerCase().includes(needle)) continue
|
|
70
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []
|
|
71
|
+
matches.push({
|
|
72
|
+
name: pkg.name,
|
|
73
|
+
isDshPlugin: keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin'),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null
|
|
78
|
+
}
|
|
79
|
+
|
|
42
80
|
/** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
|
|
43
81
|
* 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */
|
|
44
82
|
function searchRepo(repo: string, registry: string): Promise<string | null | undefined> {
|
|
45
83
|
const [owner, name] = repo.split('/')
|
|
46
84
|
if (owner === undefined || name === undefined || name === '') return Promise.resolve(null)
|
|
47
85
|
const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '')
|
|
48
|
-
const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size
|
|
86
|
+
const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=${SEARCH_RESULT_LIMIT}`
|
|
49
87
|
return new Promise((resolve) => {
|
|
50
88
|
const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
51
89
|
if (res.statusCode !== 200) {
|
|
@@ -57,18 +95,8 @@ function searchRepo(repo: string, registry: string): Promise<string | null | und
|
|
|
57
95
|
res.on('data', (chunk: Buffer) => { body += chunk.toString() })
|
|
58
96
|
res.on('end', () => {
|
|
59
97
|
try {
|
|
60
|
-
const data = JSON.parse(body) as
|
|
61
|
-
|
|
62
|
-
for (const obj of data.objects ?? []) {
|
|
63
|
-
const pkg = obj.package
|
|
64
|
-
if (typeof pkg?.name !== 'string' || pkg.name === '') continue
|
|
65
|
-
const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '')
|
|
66
|
-
if (repoUrl.toLowerCase().includes(needle)) {
|
|
67
|
-
resolve(pkg.name)
|
|
68
|
-
return
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
resolve(null)
|
|
98
|
+
const data = JSON.parse(body) as NpmSearchResponse
|
|
99
|
+
resolve(selectNpmPackageForRepo(data.objects ?? [], `${owner}/${name}`))
|
|
72
100
|
} catch {
|
|
73
101
|
resolve(undefined)
|
|
74
102
|
}
|
|
@@ -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 { githubReleaseTarget } from './release-target.ts'
|
|
21
22
|
import { resolvePackageEntry } from './package-entry.ts'
|
|
22
23
|
|
|
23
24
|
const execFileAsync = promisify(execFile)
|
|
@@ -37,6 +38,9 @@ export interface PreflightResult {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export async function preflightTarget(target: string): Promise<PreflightResult> {
|
|
41
|
+
// release 包自带包根:拿 monorepo HEAD 的源码分发去校验它没有意义,会把完整产物
|
|
42
|
+
// 误判成缺入口文件而拦下。该包的真实性交给安装本身与装后 verifyInstalledEntry 兜底。
|
|
43
|
+
if (githubReleaseTarget(target) !== null) return { ok: true, missing: null, name: null }
|
|
40
44
|
const source = githubRepoOf(target)
|
|
41
45
|
if (source === null) return { ok: true, missing: null, name: null }
|
|
42
46
|
const [owner, repo] = source.split('/')
|