dsh-plugin 1.6.12 → 2.0.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 +1 -1
- package/README.md +1 -1
- package/client/client.js +615 -741
- package/client/client.js.map +1 -1
- package/lib/http/catalog.js +2 -3
- package/lib/http/diagnostics.js +6 -7
- package/lib/http/install.js +47 -213
- package/lib/http/logs.js +1 -2
- package/lib/http/npm-release-age.js +42 -0
- package/lib/http/routes.js +8 -7
- package/lib/http/settings.js +2 -2
- package/lib/http/shared.js +2 -2
- package/lib/http/task.js +1 -2
- package/lib/index.js +5 -6
- package/lib/services/install/install-types.js +4 -4
- package/lib/services/install/install.js +1 -1
- package/lib/services/install/npm-check.js +3 -2
- package/lib/services/install/npm-resolve.js +9 -96
- package/lib/services/install/package-entry.js +6 -11
- package/lib/services/install/registry-check.js +2 -2
- package/lib/services/install/release-target.js +8 -9
- package/lib/services/install/task-queue.js +40 -48
- package/lib/services/npm-release-age.js +119 -0
- package/lib/services/probe.js +4 -4
- package/lib/services/profile/installed-versions.js +15 -21
- package/lib/services/profile/profile.js +16 -44
- package/lib/services/profile/progress.js +8 -11
- package/lib/services/settings.js +2 -5
- package/lib/types/http/catalog.d.ts +1 -1
- package/lib/types/http/install.d.ts +2 -2
- package/lib/types/http/npm-release-age.d.ts +2 -0
- package/lib/types/http/routes.d.ts +10 -12
- package/lib/types/http/shared.d.ts +5 -6
- package/lib/types/index.d.ts +7 -8
- package/lib/types/services/install/install-types.d.ts +12 -13
- package/lib/types/services/install/install.d.ts +1 -1
- package/lib/types/services/install/npm-resolve.d.ts +4 -16
- package/lib/types/services/install/package-entry.d.ts +6 -11
- package/lib/types/services/install/release-target.d.ts +7 -8
- package/lib/types/services/install/task-queue.d.ts +25 -30
- package/lib/types/services/npm-release-age.d.ts +15 -0
- package/lib/types/services/probe.d.ts +4 -4
- package/lib/types/services/profile/installed-versions.d.ts +5 -7
- package/lib/types/services/profile/profile.d.ts +14 -18
- package/lib/types/services/profile/progress.d.ts +7 -10
- package/lib/types/services/settings.d.ts +0 -4
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +17 -22
- package/src/client/components/catalog/CatalogList.tsx +2 -4
- package/src/client/components/catalog/PluginCard.tsx +2 -3
- package/src/client/components/layout/CatalogControls.tsx +5 -6
- package/src/client/components/layout/CatalogHeader.tsx +1 -2
- package/src/client/components/layout/CategoryTabs.tsx +2 -3
- package/src/client/components/layout/SectionTabs.tsx +3 -5
- package/src/client/components/modals/ConfirmDialog.tsx +4 -6
- package/src/client/components/modals/InstalledDetailModal.tsx +3 -6
- package/src/client/components/modals/LogsModal.tsx +7 -12
- package/src/client/components/modals/NotificationsModal.tsx +35 -16
- package/src/client/components/modals/ProgressView.tsx +3 -4
- package/src/client/components/modals/modals.tsx +35 -15
- package/src/client/components/ui/Dropdown.tsx +4 -6
- package/src/client/components/ui/Toggle.tsx +2 -3
- package/src/client/components/ui/icons.tsx +1 -2
- package/src/client/components/views/CustomInstallView.tsx +20 -178
- package/src/client/components/views/DiagnosticsView.tsx +3 -6
- package/src/client/components/views/InstalledView.tsx +6 -11
- package/src/client/components/views/MarketView.tsx +2 -4
- package/src/client/components/views/SettingsView.tsx +9 -32
- package/src/client/css-modules.d.ts +1 -1
- package/src/client/data/catalog.ts +2 -3
- package/src/client/data/host.ts +26 -3
- package/src/client/data/hub.ts +2 -3
- package/src/client/hooks/useCatalog.ts +79 -24
- package/src/client/hooks/useIncrementalList.ts +3 -5
- package/src/client/hooks/useLanguage.ts +4 -6
- package/src/client/hooks/useSettings.ts +4 -11
- package/src/client/hooks/useTaskQueue.ts +36 -42
- package/src/client/index.tsx +2 -3
- package/src/client/locales.ts +75 -114
- package/src/client/logic/constants.ts +1 -2
- package/src/client/logic/ensure-plugin-css.ts +8 -12
- package/src/client/logic/failures.ts +16 -18
- package/src/client/logic/format.ts +1 -1
- package/src/client/logic/install-command.ts +14 -35
- package/src/client/logic/installed.ts +15 -10
- package/src/client/logic/normalize.ts +3 -3
- package/src/client/logic/release-age.ts +19 -0
- package/src/client/logic/urls.ts +20 -20
- package/src/client/styles/CustomInstallView.module.css +3 -30
- package/src/client/styles/DiagnosticsView.module.css +3 -5
- package/src/client/styles/Dropdown.module.css +2 -4
- package/src/client/styles/Header.module.css +6 -7
- package/src/client/styles/InstalledView.module.css +4 -7
- package/src/client/styles/List.module.css +4 -5
- package/src/client/styles/LogsView.module.css +2 -3
- package/src/client/styles/Modal.module.css +4 -5
- package/src/client/styles/SectionTabs.module.css +2 -4
- package/src/client/styles/SettingsView.module.css +3 -5
- package/src/client/styles/Toggle.module.css +2 -4
- package/src/client/styles/tokens.module.css +14 -18
- package/src/client/types.ts +11 -11
- package/src/server/http/catalog.ts +2 -3
- package/src/server/http/diagnostics.ts +6 -7
- package/src/server/http/install.ts +47 -208
- package/src/server/http/logs.ts +1 -2
- package/src/server/http/npm-release-age.ts +42 -0
- package/src/server/http/routes.ts +12 -12
- package/src/server/http/settings.ts +2 -2
- package/src/server/http/shared.ts +5 -6
- package/src/server/http/task.ts +1 -2
- package/src/server/index.ts +7 -8
- package/src/server/services/install/install-types.ts +12 -13
- package/src/server/services/install/install.ts +0 -1
- package/src/server/services/install/npm-check.ts +3 -2
- package/src/server/services/install/npm-resolve.ts +9 -99
- package/src/server/services/install/package-entry.ts +6 -11
- package/src/server/services/install/registry-check.ts +2 -2
- package/src/server/services/install/release-target.ts +8 -9
- package/src/server/services/install/task-queue.ts +47 -54
- package/src/server/services/npm-release-age.ts +107 -0
- package/src/server/services/probe.ts +4 -4
- package/src/server/services/profile/installed-versions.ts +16 -22
- package/src/server/services/profile/profile.ts +16 -42
- package/src/server/services/profile/progress.ts +8 -11
- package/src/server/services/settings.ts +2 -9
- package/lib/services/install/preflight.js +0 -178
- package/lib/types/services/install/preflight.d.ts +0 -23
- package/src/server/services/install/preflight.ts +0 -184
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* Website: https://dsh-plugin.org
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* back to DEFAULT_SETTINGS so old files stay readable as new options land.
|
|
6
|
+
* 插件中心设置持久化:每个活跃 profile 一个 `hub-settings.json`(与 pnpm-workspace.yaml 同目录)。
|
|
7
|
+
* 所有字段可选;缺失的 key 回退到 DEFAULT_SETTINGS,新选项上线后旧文件仍可读。
|
|
9
8
|
*/
|
|
10
9
|
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
11
10
|
import { dirname, join } from 'node:path'
|
|
@@ -20,10 +19,6 @@ export interface HubSettings {
|
|
|
20
19
|
npmRegistry: string
|
|
21
20
|
/** 启用命令行安装 NPM 包(默认开;关掉后输入 npm 包名安装被拦截) */
|
|
22
21
|
enableNpmInstall: boolean
|
|
23
|
-
/** 启用命令行安装 GitHub 源码(默认开;关掉后输入 GitHub 地址安装被拦截) */
|
|
24
|
-
enableGitInstall: boolean
|
|
25
|
-
/** 启用命令行粘贴 dsh plugin 命令安装(默认开;关掉后命令输入被禁用) */
|
|
26
|
-
enableDshInstall: boolean
|
|
27
22
|
/** 日志存放位置覆盖(空串 = 默认 ~/.dsh/profiles/<profile>/hub.log;可填目录或 .log 文件路径) */
|
|
28
23
|
logPath: string
|
|
29
24
|
}
|
|
@@ -33,8 +28,6 @@ export const DEFAULT_SETTINGS: HubSettings = {
|
|
|
33
28
|
proxy: '',
|
|
34
29
|
npmRegistry: '',
|
|
35
30
|
enableNpmInstall: true,
|
|
36
|
-
enableGitInstall: true,
|
|
37
|
-
enableDshInstall: true,
|
|
38
31
|
logPath: '',
|
|
39
32
|
}
|
|
40
33
|
|
|
@@ -1,178 +0,0 @@
|
|
|
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 源的插件,检查其分发(codeload tarball)里是否真的包含
|
|
7
|
-
* package.json 声明的文件(main / exports["."] / dsh.bundle.patch)。git 分发常不提交
|
|
8
|
-
* 构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 ERR_MODULE_NOT_FOUND
|
|
9
|
-
* 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
|
|
10
|
-
* npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
|
|
11
|
-
* 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
|
|
12
|
-
*/
|
|
13
|
-
import { execFile } from 'node:child_process';
|
|
14
|
-
import { createWriteStream, mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
-
import { get } from 'node:https';
|
|
16
|
-
import { tmpdir } from 'node:os';
|
|
17
|
-
import { join } from 'node:path';
|
|
18
|
-
import { pipeline } from 'node:stream/promises';
|
|
19
|
-
import { promisify } from 'node:util';
|
|
20
|
-
import { githubRefOf, githubRepoOf } from '../profile/profile.js';
|
|
21
|
-
import { githubReleaseTarget } from './release-target.js';
|
|
22
|
-
import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.js';
|
|
23
|
-
const execFileAsync = promisify(execFile);
|
|
24
|
-
/** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
|
|
25
|
-
* 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
|
|
26
|
-
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
|
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 };
|
|
32
|
-
const source = githubRepoOf(target);
|
|
33
|
-
if (source === null)
|
|
34
|
-
return { ok: true, missing: null, name: null };
|
|
35
|
-
const [owner, repo] = source.split('/');
|
|
36
|
-
// 显式 pin 的 Git ref 要在它自己的修订上预检:默认分支常是 monorepo 根,与 pin 的发布包
|
|
37
|
-
// 形态不同,拿 HEAD 校验会把完整产物误判成缺入口文件(或反之)。无 pin 时仍查 HEAD。
|
|
38
|
-
const commit = await remoteCommit(owner, repo, githubRefOf(target));
|
|
39
|
-
if (commit === null)
|
|
40
|
-
return { ok: true, missing: null, name: null };
|
|
41
|
-
const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'));
|
|
42
|
-
try {
|
|
43
|
-
const tar = join(dir, 'pkg.tar.gz');
|
|
44
|
-
await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar);
|
|
45
|
-
const rootPackage = await rootPackageEntry(tar);
|
|
46
|
-
if (rootPackage === null)
|
|
47
|
-
return { ok: true, missing: null, name: null };
|
|
48
|
-
const rootPrefix = rootPackage.slice(0, -'package.json'.length);
|
|
49
|
-
const meta = await readTarJson(tar, rootPackage);
|
|
50
|
-
if (meta === null)
|
|
51
|
-
return { ok: true, missing: null, name: null };
|
|
52
|
-
// 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口,
|
|
53
|
-
// 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己,
|
|
54
|
-
// 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。
|
|
55
|
-
const patchTexts = [];
|
|
56
|
-
for (const file of resolveBundlePatchFiles(meta)) {
|
|
57
|
-
const text = await readTarText(tar, `${rootPrefix}${file}`);
|
|
58
|
-
if (text !== null)
|
|
59
|
-
patchTexts.push(text);
|
|
60
|
-
}
|
|
61
|
-
const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`);
|
|
62
|
-
const missing = missingDistributionFileOfTarList(await tarListing(tar), required);
|
|
63
|
-
return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null };
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return { ok: true, missing: null, name: null };
|
|
67
|
-
}
|
|
68
|
-
finally {
|
|
69
|
-
rmSync(dir, { recursive: true, force: true });
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Resolve the commit to inspect: the pinned `ref` when given, otherwise the
|
|
74
|
-
* repository default branch (`HEAD`). A full commit SHA passes through as-is;
|
|
75
|
-
* annotated tags are peeled to their commit. Any git/network failure returns
|
|
76
|
-
* null and the caller falls back to "no preflight".
|
|
77
|
-
*/
|
|
78
|
-
async function remoteCommit(owner, repo, ref) {
|
|
79
|
-
if (ref !== null && /^[0-9a-f]{40}$/i.test(ref))
|
|
80
|
-
return ref;
|
|
81
|
-
try {
|
|
82
|
-
const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, ref ?? 'HEAD'], {
|
|
83
|
-
timeout: PREFLIGHT_TIMEOUT_MS,
|
|
84
|
-
maxBuffer: 1024 * 1024,
|
|
85
|
-
});
|
|
86
|
-
const lines = stdout.split(/\r?\n/).filter((line) => line !== '');
|
|
87
|
-
// Annotated tags list the tag object and the peeled commit (`<ref>^{}`): prefer the peel.
|
|
88
|
-
const chosen = lines.find((line) => line.endsWith('^{}')) ?? lines[lines.length - 1];
|
|
89
|
-
return chosen?.split(/\s+/)[0] ?? null;
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
function download(url, dest) {
|
|
96
|
-
return new Promise((resolve, reject) => {
|
|
97
|
-
const req = get(url, { timeout: PREFLIGHT_TIMEOUT_MS }, (res) => {
|
|
98
|
-
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
|
|
99
|
-
res.resume();
|
|
100
|
-
if (res.headers.location) {
|
|
101
|
-
void download(res.headers.location, dest).then(resolve, reject);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
reject(new Error('download failed: redirect without location'));
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
if (res.statusCode !== 200) {
|
|
108
|
-
res.resume();
|
|
109
|
-
reject(new Error(`download failed: HTTP ${res.statusCode ?? '?'}`));
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
void pipeline(res, createWriteStream(dest)).then(resolve, reject);
|
|
113
|
-
});
|
|
114
|
-
// 网络不可达时 connect/响应可能无限挂起:超时销毁,避免预检卡死(放行交给装后校验)
|
|
115
|
-
req.on('timeout', () => req.destroy(new Error(`download timeout after ${PREFLIGHT_TIMEOUT_MS}ms`)));
|
|
116
|
-
req.on('error', reject);
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
/** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
|
|
120
|
-
* not npm's `package/` prefix. Pick only a package.json directly under that
|
|
121
|
-
* single archive root; nested monorepo package manifests are intentionally
|
|
122
|
-
* ignored. Exported for a small parser regression test. */
|
|
123
|
-
export function rootPackageEntryOfTarList(listing) {
|
|
124
|
-
const candidates = listing
|
|
125
|
-
.split(/\r?\n/)
|
|
126
|
-
.filter((line) => /^[^/]+\/package\.json$/u.test(line));
|
|
127
|
-
return candidates.length === 1 ? candidates[0] : null;
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
|
|
131
|
-
* 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
|
|
132
|
-
* 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
|
|
133
|
-
* Exported for a small parser regression test.
|
|
134
|
-
*/
|
|
135
|
-
export function missingDistributionFileOfTarList(listing, required) {
|
|
136
|
-
if (required.length === 0)
|
|
137
|
-
return null;
|
|
138
|
-
if (listing === null)
|
|
139
|
-
return required[0];
|
|
140
|
-
const present = new Set(listing.split(/\r?\n/));
|
|
141
|
-
return required.find((file) => !present.has(file)) ?? null;
|
|
142
|
-
}
|
|
143
|
-
async function rootPackageEntry(tar) {
|
|
144
|
-
const listing = await tarListing(tar);
|
|
145
|
-
return listing === null ? null : rootPackageEntryOfTarList(listing);
|
|
146
|
-
}
|
|
147
|
-
/** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */
|
|
148
|
-
async function tarListing(tar) {
|
|
149
|
-
try {
|
|
150
|
-
const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 });
|
|
151
|
-
return stdout;
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
/** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */
|
|
158
|
-
async function readTarText(tar, entry) {
|
|
159
|
-
try {
|
|
160
|
-
const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 });
|
|
161
|
-
return stdout;
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
/** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
|
|
168
|
-
async function readTarJson(tar, entry) {
|
|
169
|
-
const text = await readTarText(tar, entry);
|
|
170
|
-
if (text === null)
|
|
171
|
-
return null;
|
|
172
|
-
try {
|
|
173
|
-
return JSON.parse(text);
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/** 预检结果:ok=false 表示分发改 package.json 声明的文件(入口或 bundle 补丁),
|
|
2
|
-
* missing 为该文件在包内的相对路径;
|
|
3
|
-
* name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
|
|
4
|
-
* 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
|
|
5
|
-
* 需要把晦涩的 CLI 报错转成明确的拦截。 */
|
|
6
|
-
export interface PreflightResult {
|
|
7
|
-
ok: boolean;
|
|
8
|
-
missing: string | null;
|
|
9
|
-
name: string | null;
|
|
10
|
-
}
|
|
11
|
-
export declare function preflightTarget(target: string): Promise<PreflightResult>;
|
|
12
|
-
/** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
|
|
13
|
-
* not npm's `package/` prefix. Pick only a package.json directly under that
|
|
14
|
-
* single archive root; nested monorepo package manifests are intentionally
|
|
15
|
-
* ignored. Exported for a small parser regression test. */
|
|
16
|
-
export declare function rootPackageEntryOfTarList(listing: string): string | null;
|
|
17
|
-
/**
|
|
18
|
-
* 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
|
|
19
|
-
* 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
|
|
20
|
-
* 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
|
|
21
|
-
* Exported for a small parser regression test.
|
|
22
|
-
*/
|
|
23
|
-
export declare function missingDistributionFileOfTarList(listing: string | null, required: string[]): string | null;
|
|
@@ -1,184 +0,0 @@
|
|
|
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 源的插件,检查其分发(codeload tarball)里是否真的包含
|
|
7
|
-
* package.json 声明的文件(main / exports["."] / dsh.bundle.patch)。git 分发常不提交
|
|
8
|
-
* 构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 ERR_MODULE_NOT_FOUND
|
|
9
|
-
* 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
|
|
10
|
-
* npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
|
|
11
|
-
* 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
|
|
12
|
-
*/
|
|
13
|
-
import { execFile } from 'node:child_process'
|
|
14
|
-
import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
15
|
-
import { get } from 'node:https'
|
|
16
|
-
import { tmpdir } from 'node:os'
|
|
17
|
-
import { join } from 'node:path'
|
|
18
|
-
import { pipeline } from 'node:stream/promises'
|
|
19
|
-
import { promisify } from 'node:util'
|
|
20
|
-
import { githubRefOf, githubRepoOf } from '../profile/profile.ts'
|
|
21
|
-
import { githubReleaseTarget } from './release-target.ts'
|
|
22
|
-
import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.ts'
|
|
23
|
-
|
|
24
|
-
const execFileAsync = promisify(execFile)
|
|
25
|
-
|
|
26
|
-
/** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
|
|
27
|
-
* 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
|
|
28
|
-
const PREFLIGHT_TIMEOUT_MS = 15_000
|
|
29
|
-
|
|
30
|
-
/** 预检结果:ok=false 表示分发改 package.json 声明的文件(入口或 bundle 补丁),
|
|
31
|
-
* missing 为该文件在包内的相对路径;
|
|
32
|
-
* name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
|
|
33
|
-
* 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
|
|
34
|
-
* 需要把晦涩的 CLI 报错转成明确的拦截。 */
|
|
35
|
-
export interface PreflightResult {
|
|
36
|
-
ok: boolean
|
|
37
|
-
missing: string | null
|
|
38
|
-
name: string | null
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function preflightTarget(target: string): Promise<PreflightResult> {
|
|
42
|
-
// release 包自带包根:拿 monorepo HEAD 的源码分发去校验它没有意义,会把完整产物
|
|
43
|
-
// 误判成缺入口文件而拦下。该包的真实性交给安装本身与装后 verifyInstalledEntry 兜底。
|
|
44
|
-
if (githubReleaseTarget(target) !== null) return { ok: true, missing: null, name: null }
|
|
45
|
-
const source = githubRepoOf(target)
|
|
46
|
-
if (source === null) return { ok: true, missing: null, name: null }
|
|
47
|
-
const [owner, repo] = source.split('/')
|
|
48
|
-
// 显式 pin 的 Git ref 要在它自己的修订上预检:默认分支常是 monorepo 根,与 pin 的发布包
|
|
49
|
-
// 形态不同,拿 HEAD 校验会把完整产物误判成缺入口文件(或反之)。无 pin 时仍查 HEAD。
|
|
50
|
-
const commit = await remoteCommit(owner, repo, githubRefOf(target))
|
|
51
|
-
if (commit === null) return { ok: true, missing: null, name: null }
|
|
52
|
-
const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'))
|
|
53
|
-
try {
|
|
54
|
-
const tar = join(dir, 'pkg.tar.gz')
|
|
55
|
-
await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar)
|
|
56
|
-
const rootPackage = await rootPackageEntry(tar)
|
|
57
|
-
if (rootPackage === null) return { ok: true, missing: null, name: null }
|
|
58
|
-
const rootPrefix = rootPackage.slice(0, -'package.json'.length)
|
|
59
|
-
const meta = await readTarJson(tar, rootPackage)
|
|
60
|
-
if (meta === null) return { ok: true, missing: null, name: null }
|
|
61
|
-
// 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口,
|
|
62
|
-
// 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己,
|
|
63
|
-
// 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。
|
|
64
|
-
const patchTexts: string[] = []
|
|
65
|
-
for (const file of resolveBundlePatchFiles(meta)) {
|
|
66
|
-
const text = await readTarText(tar, `${rootPrefix}${file}`)
|
|
67
|
-
if (text !== null) patchTexts.push(text)
|
|
68
|
-
}
|
|
69
|
-
const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`)
|
|
70
|
-
const missing = missingDistributionFileOfTarList(await tarListing(tar), required)
|
|
71
|
-
return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null }
|
|
72
|
-
} catch {
|
|
73
|
-
return { ok: true, missing: null, name: null }
|
|
74
|
-
} finally {
|
|
75
|
-
rmSync(dir, { recursive: true, force: true })
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Resolve the commit to inspect: the pinned `ref` when given, otherwise the
|
|
81
|
-
* repository default branch (`HEAD`). A full commit SHA passes through as-is;
|
|
82
|
-
* annotated tags are peeled to their commit. Any git/network failure returns
|
|
83
|
-
* null and the caller falls back to "no preflight".
|
|
84
|
-
*/
|
|
85
|
-
async function remoteCommit(owner: string, repo: string, ref: string | null): Promise<string | null> {
|
|
86
|
-
if (ref !== null && /^[0-9a-f]{40}$/i.test(ref)) return ref
|
|
87
|
-
try {
|
|
88
|
-
const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, ref ?? 'HEAD'], {
|
|
89
|
-
timeout: PREFLIGHT_TIMEOUT_MS,
|
|
90
|
-
maxBuffer: 1024 * 1024,
|
|
91
|
-
})
|
|
92
|
-
const lines = stdout.split(/\r?\n/).filter((line) => line !== '')
|
|
93
|
-
// Annotated tags list the tag object and the peeled commit (`<ref>^{}`): prefer the peel.
|
|
94
|
-
const chosen = lines.find((line) => line.endsWith('^{}')) ?? lines[lines.length - 1]
|
|
95
|
-
return chosen?.split(/\s+/)[0] ?? null
|
|
96
|
-
} catch {
|
|
97
|
-
return null
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function download(url: string, dest: string): Promise<void> {
|
|
102
|
-
return new Promise((resolve, reject) => {
|
|
103
|
-
const req = get(url, { timeout: PREFLIGHT_TIMEOUT_MS }, (res) => {
|
|
104
|
-
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
|
|
105
|
-
res.resume()
|
|
106
|
-
if (res.headers.location) {
|
|
107
|
-
void download(res.headers.location, dest).then(resolve, reject)
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
reject(new Error('download failed: redirect without location'))
|
|
111
|
-
return
|
|
112
|
-
}
|
|
113
|
-
if (res.statusCode !== 200) {
|
|
114
|
-
res.resume()
|
|
115
|
-
reject(new Error(`download failed: HTTP ${res.statusCode ?? '?'}`))
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
|
-
void pipeline(res, createWriteStream(dest)).then(resolve, reject)
|
|
119
|
-
})
|
|
120
|
-
// 网络不可达时 connect/响应可能无限挂起:超时销毁,避免预检卡死(放行交给装后校验)
|
|
121
|
-
req.on('timeout', () => req.destroy(new Error(`download timeout after ${PREFLIGHT_TIMEOUT_MS}ms`)))
|
|
122
|
-
req.on('error', reject)
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
|
|
127
|
-
* not npm's `package/` prefix. Pick only a package.json directly under that
|
|
128
|
-
* single archive root; nested monorepo package manifests are intentionally
|
|
129
|
-
* ignored. Exported for a small parser regression test. */
|
|
130
|
-
export function rootPackageEntryOfTarList(listing: string): string | null {
|
|
131
|
-
const candidates = listing
|
|
132
|
-
.split(/\r?\n/)
|
|
133
|
-
.filter((line) => /^[^/]+\/package\.json$/u.test(line))
|
|
134
|
-
return candidates.length === 1 ? candidates[0] : null
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
|
|
139
|
-
* 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
|
|
140
|
-
* 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
|
|
141
|
-
* Exported for a small parser regression test.
|
|
142
|
-
*/
|
|
143
|
-
export function missingDistributionFileOfTarList(listing: string | null, required: string[]): string | null {
|
|
144
|
-
if (required.length === 0) return null
|
|
145
|
-
if (listing === null) return required[0]
|
|
146
|
-
const present = new Set(listing.split(/\r?\n/))
|
|
147
|
-
return required.find((file) => !present.has(file)) ?? null
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function rootPackageEntry(tar: string): Promise<string | null> {
|
|
151
|
-
const listing = await tarListing(tar)
|
|
152
|
-
return listing === null ? null : rootPackageEntryOfTarList(listing)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */
|
|
156
|
-
async function tarListing(tar: string): Promise<string | null> {
|
|
157
|
-
try {
|
|
158
|
-
const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 })
|
|
159
|
-
return stdout
|
|
160
|
-
} catch {
|
|
161
|
-
return null
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */
|
|
166
|
-
async function readTarText(tar: string, entry: string): Promise<string | null> {
|
|
167
|
-
try {
|
|
168
|
-
const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 })
|
|
169
|
-
return stdout
|
|
170
|
-
} catch {
|
|
171
|
-
return null
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
|
|
176
|
-
async function readTarJson(tar: string, entry: string): Promise<Record<string, unknown> | null> {
|
|
177
|
-
const text = await readTarText(tar, entry)
|
|
178
|
-
if (text === null) return null
|
|
179
|
-
try {
|
|
180
|
-
return JSON.parse(text) as Record<string, unknown>
|
|
181
|
-
} catch {
|
|
182
|
-
return null
|
|
183
|
-
}
|
|
184
|
-
}
|