dsh-plugin 1.0.0 → 1.1.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 +12 -7
- package/README.md +12 -7
- package/client/client.js +326 -193
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +43 -7
- package/lib/services/installed-versions.js +16 -1
- package/lib/services/npm-resolve.js +67 -0
- package/lib/services/task-queue.js +48 -8
- package/lib/types/services/install-types.d.ts +6 -0
- package/lib/types/services/installed-versions.d.ts +10 -0
- package/lib/types/services/npm-resolve.d.ts +6 -0
- package/lib/types/services/task-queue.d.ts +11 -5
- package/package.json +1 -1
- package/src/client/components/NotificationsModal.tsx +71 -58
- package/src/client/components/PluginHubSection.tsx +12 -9
- package/src/client/components/modals.tsx +11 -8
- package/src/client/hooks/useCatalog.ts +14 -4
- package/src/client/hooks/useTaskQueue.ts +139 -42
- package/src/client/lib/catalog.ts +49 -13
- package/src/client/lib/failures.ts +14 -2
- package/src/client/locales.ts +6 -4
- package/src/client/styles/List.module.css +4 -4
- package/src/client/styles/Modal.module.css +46 -5
- package/src/client/styles/tokens.module.css +8 -3
- package/src/client/types.ts +1 -1
- package/src/server/http/routes.ts +42 -7
- package/src/server/services/install-types.ts +6 -0
- package/src/server/services/installed-versions.ts +20 -1
- package/src/server/services/npm-resolve.ts +67 -0
- package/src/server/services/task-queue.ts +50 -8
package/lib/http/routes.js
CHANGED
|
@@ -9,7 +9,8 @@ import { homedir, release } from 'node:os';
|
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, hasQueuedTarget, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install.js';
|
|
12
|
-
import { recordInstalledVersion, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.js';
|
|
12
|
+
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.js';
|
|
13
|
+
import { resolveNpmPackage } from '../services/npm-resolve.js';
|
|
13
14
|
import { preflightTarget } from '../services/preflight.js';
|
|
14
15
|
const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
15
16
|
const BODY_LIMIT_BYTES = 4 * 1024;
|
|
@@ -158,17 +159,45 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
158
159
|
const rawRepo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
|
|
159
160
|
? body.repo.trim()
|
|
160
161
|
: '';
|
|
162
|
+
// 前端决策的展示用仓库名(owner/repo):npm 安装时 body.repo 是包名,display 用于
|
|
163
|
+
// 待重启行 / 任务恢复时展示仓库名,保证用户无感知(安装通道变化不改变所见目标)
|
|
164
|
+
const displayRepo = typeof body === 'object' && body !== null && typeof body.display === 'string'
|
|
165
|
+
? body.display.trim()
|
|
166
|
+
: rawRepo;
|
|
161
167
|
// 目标语法两态:owner/repo → github 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装
|
|
162
168
|
const repoTarget = githubTarget(rawRepo);
|
|
163
|
-
|
|
169
|
+
let target = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null);
|
|
164
170
|
if (target === null) {
|
|
165
171
|
sendJson(response, 400, { error: 'unsupported install target' });
|
|
166
172
|
return;
|
|
167
173
|
}
|
|
174
|
+
// npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
|
|
175
|
+
// git 分发常缺构建产物/子模块导致 prepare 必败,而 npm 包是作者发布的
|
|
176
|
+
// 完整产物,成功率高得多;未命中或查询失败保留 github 直装(不阻塞安装,
|
|
177
|
+
// 预检仍会兜底拦截 git 分发缺入口文件的情况)。通用机制,不针对具体插件。
|
|
178
|
+
// 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
|
|
179
|
+
// 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
|
|
180
|
+
const attempts = [];
|
|
181
|
+
if (repoTarget !== null) {
|
|
182
|
+
const npmName = await resolveNpmPackage(rawRepo);
|
|
183
|
+
if (npmName !== null) {
|
|
184
|
+
target = npmName;
|
|
185
|
+
attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → found \`${npmName}\``);
|
|
186
|
+
// 持久化 repo → npm 包名映射:目录数据未下发 npmPackage(组织 scope 与 GitHub
|
|
187
|
+
// 用户名不一致)时,客户端仍能通过 /installed 的 versions 把依赖 key 匹配回仓库,
|
|
188
|
+
// 列表立即显示「已安装」,避免安装成功后仍显示可安装
|
|
189
|
+
recordResolvedNpmPackage(profile, rawRepo, npmName);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → no matching package found (falling back to git install)`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
168
195
|
// 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
|
|
169
196
|
// (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
|
|
170
|
-
// 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm
|
|
171
|
-
|
|
197
|
+
// 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装。
|
|
198
|
+
// 命中判定双通道:npm 包名按 dependencies 键直接命中,github: 目标按 spec 值匹配。
|
|
199
|
+
const installed = readInstalled(profile);
|
|
200
|
+
const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null);
|
|
172
201
|
if (already !== null && mode !== 'update') {
|
|
173
202
|
sendJson(response, 409, { error: `already installed: ${already}` });
|
|
174
203
|
return;
|
|
@@ -180,13 +209,16 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
180
209
|
}
|
|
181
210
|
// 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
|
|
182
211
|
// 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
|
|
183
|
-
//
|
|
184
|
-
//
|
|
212
|
+
// npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
|
|
213
|
+
// 供客户端归类为「插件分发不完整」并引导去仓库提 Issue
|
|
185
214
|
if (already === null) {
|
|
186
215
|
const preflight = await preflightTarget(target);
|
|
187
216
|
if (!preflight.ok) {
|
|
217
|
+
// 预检拦截(git 分发缺入口文件):同步 400 返回,附上已尝试的安装方式,
|
|
218
|
+
// 客户端失败弹窗提 Issue 时一并贴给作者
|
|
188
219
|
sendJson(response, 400, {
|
|
189
|
-
error: `[packaging] ${target}:
|
|
220
|
+
error: `[packaging] ${target}: plugin distribution is incomplete — the entry file ${preflight.missing ?? '?'} declared in package.json is not present in the git distribution (build output not committed). Report to the author.`,
|
|
221
|
+
attempts,
|
|
190
222
|
});
|
|
191
223
|
return;
|
|
192
224
|
}
|
|
@@ -203,6 +235,10 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
203
235
|
action: 'add',
|
|
204
236
|
profile,
|
|
205
237
|
target,
|
|
238
|
+
// npm 安装时 target 是包名:displayTarget 固定展示仓库名,待重启行/前端恢复不受通道变化影响
|
|
239
|
+
displayTarget: githubTarget(displayRepo) ?? undefined,
|
|
240
|
+
// 已尝试的安装方式(npm 反查):实际执行命令由 spawn 时追加,失败 issue 一并展示
|
|
241
|
+
attempts,
|
|
206
242
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
207
243
|
env: { ...process.env, CI: 'true' },
|
|
208
244
|
});
|
|
@@ -35,7 +35,22 @@ export function readInstalledVersions(profile) {
|
|
|
35
35
|
/** Record the catalog signals (version + repo updated time) present at install time for one repo. */
|
|
36
36
|
export function recordInstalledVersion(profile, repo, version, updatedAt) {
|
|
37
37
|
const table = readInstalledVersions(profile);
|
|
38
|
-
|
|
38
|
+
const key = repo.toLowerCase();
|
|
39
|
+
// 合并写入:保留已记录的 npmPackage(npm 优先通道的 repo → 包名映射),
|
|
40
|
+
// 客户端同步版本信号时不能把它覆盖掉
|
|
41
|
+
table[key] = { ...table[key], version, updatedAt, installedAt: new Date().toISOString() };
|
|
42
|
+
writeVersions(profile, table);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Record the npm package name resolved for a repo (npm-first install channel).
|
|
46
|
+
* Called at `/install` time so the mapping exists even before the task finishes;
|
|
47
|
+
* harmless when the repo is not (yet) installed — the client only treats a repo
|
|
48
|
+
* as installed when its dependency key actually exists.
|
|
49
|
+
*/
|
|
50
|
+
export function recordResolvedNpmPackage(profile, repo, npmPackage) {
|
|
51
|
+
const table = readInstalledVersions(profile);
|
|
52
|
+
const key = repo.toLowerCase();
|
|
53
|
+
table[key] = { ...table[key], npmPackage };
|
|
39
54
|
writeVersions(profile, table);
|
|
40
55
|
}
|
|
41
56
|
/** Forget the recorded version for one repo (called on uninstall). */
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm 包反查:git 分发不完整(缺构建产物/子模块)导致安装失败时,用 npm
|
|
3
|
+
* registry 的搜索接口按 repository 地址反查该仓库对应的官方 npm 包。
|
|
4
|
+
*
|
|
5
|
+
* 这是通用机制,不针对任何具体插件:只要作者发布了 repository 指向该
|
|
6
|
+
* GitHub 仓库的 npm 包(scope 名与 GitHub 用户名是否一致无所谓),就能命中,
|
|
7
|
+
* 从而把「git 直装必败」的插件切换到 npm 通道安装。命中结果带内存缓存,
|
|
8
|
+
* 避免同一会话内重复查询 registry。
|
|
9
|
+
*/
|
|
10
|
+
import { get } from 'node:https';
|
|
11
|
+
/** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包)。 */
|
|
12
|
+
const cache = new Map();
|
|
13
|
+
/** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 8000;
|
|
15
|
+
/**
|
|
16
|
+
* 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
|
|
17
|
+
* 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
|
|
18
|
+
* 原有错误路径,反查只是额外的一次尝试。
|
|
19
|
+
*/
|
|
20
|
+
export function resolveNpmPackage(repo) {
|
|
21
|
+
const key = repo.toLowerCase();
|
|
22
|
+
if (cache.has(key))
|
|
23
|
+
return Promise.resolve(cache.get(key) ?? null);
|
|
24
|
+
const name = searchRepo(repo);
|
|
25
|
+
name.then((found) => cache.set(key, found)).catch(() => cache.set(key, null));
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
/** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 */
|
|
29
|
+
function searchRepo(repo) {
|
|
30
|
+
const [owner, name] = repo.split('/');
|
|
31
|
+
if (owner === undefined || name === undefined || name === '')
|
|
32
|
+
return Promise.resolve(null);
|
|
33
|
+
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10`;
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
36
|
+
if (res.statusCode !== 200) {
|
|
37
|
+
res.resume();
|
|
38
|
+
resolve(null);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let body = '';
|
|
42
|
+
res.on('data', (chunk) => { body += chunk.toString(); });
|
|
43
|
+
res.on('end', () => {
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(body);
|
|
46
|
+
const needle = `github.com/${owner}/${name}`.toLowerCase();
|
|
47
|
+
for (const obj of data.objects ?? []) {
|
|
48
|
+
const pkg = obj.package;
|
|
49
|
+
if (typeof pkg?.name !== 'string' || pkg.name === '')
|
|
50
|
+
continue;
|
|
51
|
+
const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '');
|
|
52
|
+
if (repoUrl.toLowerCase().includes(needle)) {
|
|
53
|
+
resolve(pkg.name);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
resolve(null);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
resolve(null);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
req.on('timeout', () => req.destroy());
|
|
65
|
+
req.on('error', () => resolve(null));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -28,15 +28,18 @@ export function getTask(id) {
|
|
|
28
28
|
/** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
|
|
29
29
|
export function activeTask() {
|
|
30
30
|
const found = [];
|
|
31
|
+
// 展示目标用 displayTarget(owner/repo):安装 target 可能被服务端切成 npm 包名
|
|
32
|
+
// (npm 反查命中),队列恢复/待重启展示对用户保持仓库名无感知
|
|
33
|
+
const show = (task) => task.displayTarget ?? task.target;
|
|
31
34
|
for (const item of queue) {
|
|
32
35
|
const task = item.task;
|
|
33
36
|
if (task.status === 'pending') {
|
|
34
|
-
found.push({ id: task.id, target: task
|
|
37
|
+
found.push({ id: task.id, target: show(task), action: task.action, status: 'pending', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts });
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
tasks.forEach((task) => {
|
|
38
41
|
if (task.status === 'running') {
|
|
39
|
-
found.unshift({ id: task.id, target: task
|
|
42
|
+
found.unshift({ id: task.id, target: show(task), action: task.action, status: 'running', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts });
|
|
40
43
|
}
|
|
41
44
|
});
|
|
42
45
|
return found;
|
|
@@ -139,7 +142,7 @@ function stopChild(child) {
|
|
|
139
142
|
* Progress is visible through `getTask(id)` / `activeTask()` until done.
|
|
140
143
|
*/
|
|
141
144
|
export function startPluginMutation(options) {
|
|
142
|
-
const task = { id: nextTaskId, target: options.target, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [] };
|
|
145
|
+
const task = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [] };
|
|
143
146
|
nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1;
|
|
144
147
|
tasks.set(task.id, task);
|
|
145
148
|
if (tasks.size > MAX_TASKS) {
|
|
@@ -182,6 +185,12 @@ function spawnMutation(options) {
|
|
|
182
185
|
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader } = options;
|
|
183
186
|
const invocation = cliInvocation();
|
|
184
187
|
const args = [...invocation.prefixArgs, 'plugin', '--profile', profile, action, target];
|
|
188
|
+
// 记录实际执行的命令(npm 通道显示包名,git 通道显示 github: 源):失败提 Issue 时
|
|
189
|
+
// 「已尝试的安装方式」如实展示完整命令链,作者一眼看清我们跑过什么
|
|
190
|
+
const executed = `dsh plugin --profile ${profile} ${action} ${target}`;
|
|
191
|
+
if (task && !task.attempts.includes(executed)) {
|
|
192
|
+
task.attempts.push(executed);
|
|
193
|
+
}
|
|
185
194
|
return new Promise((resolvePromise) => {
|
|
186
195
|
let stdout = '';
|
|
187
196
|
let stderr = '';
|
|
@@ -371,17 +380,30 @@ export function verifyInstalledEntry(profile, target) {
|
|
|
371
380
|
return { name, missing: entry };
|
|
372
381
|
}
|
|
373
382
|
}
|
|
383
|
+
/** 从 pnpm `Ignored build scripts:` 提示行解析被拦截的构建脚本 key(name@version,逗号分隔)。 */
|
|
384
|
+
function ignoredBuildKeys(output) {
|
|
385
|
+
const m = /Ignored build scripts:\s*(.+)/i.exec(output);
|
|
386
|
+
if (m === null)
|
|
387
|
+
return [];
|
|
388
|
+
return m[1]
|
|
389
|
+
.split(',')
|
|
390
|
+
.map((s) => s.trim())
|
|
391
|
+
.filter((s) => s !== '');
|
|
392
|
+
}
|
|
374
393
|
/**
|
|
375
|
-
* Run a plugin mutation with one recovery path: when `add` fails because
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
394
|
+
* Run a plugin mutation with one recovery path: when `add` fails because
|
|
395
|
+
* pnpm's allowBuilds gate blocks a build script — either the git-hosted
|
|
396
|
+
* package's own `prepare` (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, key from
|
|
397
|
+
* the printed key@url hint) or a dependency's native build
|
|
398
|
+
* (`ERR_PNPM_IGNORED_BUILDS`, key = the listed name@version) — write the
|
|
399
|
+
* exact keys into the profile's pnpm-workspace.yaml and retry once.
|
|
400
|
+
* Other failures fall through to the single-spawn result.
|
|
380
401
|
*/
|
|
381
402
|
export async function runPluginMutation(options) {
|
|
382
403
|
const result = await spawnMutation(options);
|
|
383
404
|
if (options.action === 'add' && result.exitCode !== 0 && !result.timedOut) {
|
|
384
405
|
const output = `${result.stderr}\n${result.stdout}`;
|
|
406
|
+
// git 源:pnpm 打印的 key@url 提示
|
|
385
407
|
if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
|
|
386
408
|
const key = parseAllowBuildsKey(output);
|
|
387
409
|
if (key !== null) {
|
|
@@ -396,6 +418,24 @@ export async function runPluginMutation(options) {
|
|
|
396
418
|
return spawnMutation(options);
|
|
397
419
|
}
|
|
398
420
|
}
|
|
421
|
+
// 依赖原生模块/构建脚本被 pnpm v11 拦截(ERR_PNPM_IGNORED_BUILDS):git 与 npm 源都会出现。
|
|
422
|
+
// allowBuilds 的 key 是输出里列出的 name@version(如 node-pty@1.1.0),逐个放行后重试一次;
|
|
423
|
+
// 输出解析不到时回退用目标名(插件自身构建脚本被拦的 npm 场景)。
|
|
424
|
+
if (output.includes('ERR_PNPM_IGNORED_BUILDS')) {
|
|
425
|
+
const keys = ignoredBuildKeys(output);
|
|
426
|
+
if (keys.length === 0 && !options.target.startsWith('github:'))
|
|
427
|
+
keys.push(options.target);
|
|
428
|
+
if (keys.length > 0) {
|
|
429
|
+
if (options.task) {
|
|
430
|
+
options.task.status = 'running';
|
|
431
|
+
options.task.exitCode = null;
|
|
432
|
+
pushLine(options.task, `[allow build] ${keys.join(', ')}`);
|
|
433
|
+
}
|
|
434
|
+
for (const key of keys)
|
|
435
|
+
addAllowBuildsKey(options.profile, key);
|
|
436
|
+
return spawnMutation(options);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
399
439
|
}
|
|
400
440
|
// pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
|
|
401
441
|
// 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃
|
|
@@ -11,6 +11,12 @@ export interface InstallTask {
|
|
|
11
11
|
id: number;
|
|
12
12
|
/** 操作目标:`github:<owner>/<repo>`(安装)或 npm 包名(卸载),冲突/恢复时需要展示给用户 */
|
|
13
13
|
target: string;
|
|
14
|
+
/** 展示用目标(owner/repo):npm 安装时 target 是包名,前端恢复用此字段显示仓库名,保持用户无感知 */
|
|
15
|
+
displayTarget?: string;
|
|
16
|
+
/** 尝试过的安装方式(npm registry 反查 + 实际执行的 CLI 命令,按先后顺序):
|
|
17
|
+
* 失败提 Issue 时如实贴给作者,作者据此反推正确的 npm 包名(组织 scope 与 GitHub 用户名不一致时
|
|
18
|
+
* 仅凭仓库名猜不到,作者看到我们查过/试过的命令就能直接指认)。 */
|
|
19
|
+
attempts: string[];
|
|
14
20
|
action: 'add' | 'remove';
|
|
15
21
|
/** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
|
|
16
22
|
status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
|
|
@@ -4,11 +4,21 @@ export interface InstalledVersionRecord {
|
|
|
4
4
|
/** 安装时的仓库最近更新时间(ISO,GitHub pushed_at);无 release 仓库的更新依据 */
|
|
5
5
|
updatedAt: string;
|
|
6
6
|
installedAt: string;
|
|
7
|
+
/** npm 优先通道反查命中的官方 npm 包名(repo → 包名映射):目录数据可能未下发
|
|
8
|
+
* npmPackage(组织 scope 与 GitHub 用户名不一致),客户端据此把依赖 key 匹配回仓库 */
|
|
9
|
+
npmPackage?: string;
|
|
7
10
|
}
|
|
8
11
|
export type InstalledVersionTable = Record<string, InstalledVersionRecord>;
|
|
9
12
|
export declare function versionsFilePath(profile: string): string;
|
|
10
13
|
export declare function readInstalledVersions(profile: string): InstalledVersionTable;
|
|
11
14
|
/** Record the catalog signals (version + repo updated time) present at install time for one repo. */
|
|
12
15
|
export declare function recordInstalledVersion(profile: string, repo: string, version: string, updatedAt: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Record the npm package name resolved for a repo (npm-first install channel).
|
|
18
|
+
* Called at `/install` time so the mapping exists even before the task finishes;
|
|
19
|
+
* harmless when the repo is not (yet) installed — the client only treats a repo
|
|
20
|
+
* as installed when its dependency key actually exists.
|
|
21
|
+
*/
|
|
22
|
+
export declare function recordResolvedNpmPackage(profile: string, repo: string, npmPackage: string): void;
|
|
13
23
|
/** Forget the recorded version for one repo (called on uninstall). */
|
|
14
24
|
export declare function removeInstalledVersion(profile: string, repo: string): void;
|
|
@@ -9,6 +9,8 @@ export interface ActiveTaskInfo {
|
|
|
9
9
|
status: 'pending' | 'running';
|
|
10
10
|
progress: number;
|
|
11
11
|
lines: string[];
|
|
12
|
+
/** 尝试过的安装方式(npm 反查 + 实际执行命令):失败时前端 issue 预填需要 */
|
|
13
|
+
attempts: string[];
|
|
12
14
|
}
|
|
13
15
|
/** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
|
|
14
16
|
export declare function activeTask(): ActiveTaskInfo[];
|
|
@@ -44,6 +46,8 @@ export declare function startPluginMutation(options: {
|
|
|
44
46
|
displayTarget?: string;
|
|
45
47
|
/** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
|
|
46
48
|
uninstallLoader?: LoaderHandle;
|
|
49
|
+
/** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
|
|
50
|
+
attempts?: string[];
|
|
47
51
|
}): InstallTask;
|
|
48
52
|
/**
|
|
49
53
|
* 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
|
|
@@ -56,11 +60,13 @@ export declare function verifyInstalledEntry(profile: string, target: string): {
|
|
|
56
60
|
missing: string | null;
|
|
57
61
|
};
|
|
58
62
|
/**
|
|
59
|
-
* Run a plugin mutation with one recovery path: when `add` fails because
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
63
|
+
* Run a plugin mutation with one recovery path: when `add` fails because
|
|
64
|
+
* pnpm's allowBuilds gate blocks a build script — either the git-hosted
|
|
65
|
+
* package's own `prepare` (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, key from
|
|
66
|
+
* the printed key@url hint) or a dependency's native build
|
|
67
|
+
* (`ERR_PNPM_IGNORED_BUILDS`, key = the listed name@version) — write the
|
|
68
|
+
* exact keys into the profile's pnpm-workspace.yaml and retry once.
|
|
69
|
+
* Other failures fall through to the single-spawn result.
|
|
64
70
|
*/
|
|
65
71
|
export declare function runPluginMutation(options: {
|
|
66
72
|
action: 'add' | 'remove';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -19,12 +19,11 @@ import type { PendingRestart, QueueTask } from '../hooks/useTaskQueue.ts'
|
|
|
19
19
|
import { pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
|
|
20
20
|
import { CloseIcon } from './icons.tsx'
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** 记录时间完整展示:YYYY-MM-DD HH:mm:ss(每条通知都带精确到秒的时间戳)。 */
|
|
23
23
|
function fmtTime(at: number): string {
|
|
24
24
|
const d = new Date(at)
|
|
25
25
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
26
|
-
|
|
27
|
-
return d.getFullYear() === new Date().getFullYear() ? mmdd : `${d.getFullYear()}-${mmdd}`
|
|
26
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
/** 圆形状态徽标内的白色图形:成功为对勾、失败为叉。 */
|
|
@@ -52,7 +51,7 @@ function BadgeGlyph({ ok }: { ok: boolean }) {
|
|
|
52
51
|
}))
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, cancelTask, restarting, onRestart }: {
|
|
54
|
+
export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, onRemove, cancelTask, restarting, onRestart }: {
|
|
56
55
|
records: NotificationRecord[]
|
|
57
56
|
/** 进行中的安装/卸载任务(实时进度,与队列弹窗同一数据源) */
|
|
58
57
|
tasks: QueueTask[]
|
|
@@ -63,6 +62,8 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
63
62
|
onClose: () => void
|
|
64
63
|
onCopy: (text: string) => void
|
|
65
64
|
onClear: () => void
|
|
65
|
+
/** 删除单条通知记录 */
|
|
66
|
+
onRemove: (id: number) => void
|
|
66
67
|
cancelTask: (id: number) => void
|
|
67
68
|
restarting: boolean
|
|
68
69
|
onRestart: () => void
|
|
@@ -156,63 +157,75 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
156
157
|
: h('div', { className: styles.noticeList },
|
|
157
158
|
records.map((r) => {
|
|
158
159
|
return h('div', { key: r.id, className: styles.noticeRow },
|
|
159
|
-
h('div', {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
h('div', { className: styles.
|
|
164
|
-
h('
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
160
|
+
h('div', { className: styles.noticeRowMain },
|
|
161
|
+
h('div', {
|
|
162
|
+
className: r.ok ? styles.noticeBadgeOk : styles.noticeBadgeFail,
|
|
163
|
+
}, h(BadgeGlyph, { ok: r.ok })),
|
|
164
|
+
h('div', { className: styles.noticeMain },
|
|
165
|
+
h('div', { className: styles.noticeHead },
|
|
166
|
+
h('span', {
|
|
167
|
+
className: r.ok ? styles.noticeTextOk : styles.noticeTextFail,
|
|
168
|
+
}, r.ok
|
|
169
|
+
? (r.kind === 'install' ? t('installDone') : t('uninstallDone'))
|
|
170
|
+
: (r.kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall'))),
|
|
171
|
+
r.repo
|
|
172
|
+
? h('a', {
|
|
173
|
+
className: styles.failRepo,
|
|
174
|
+
// 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
|
|
175
|
+
href: pluginSiteUrl(r.repo),
|
|
176
|
+
target: '_blank',
|
|
177
|
+
rel: 'noopener noreferrer',
|
|
178
|
+
title: r.repo,
|
|
179
|
+
}, r.repo)
|
|
180
|
+
: null,
|
|
181
|
+
!r.ok && h('button', {
|
|
182
|
+
className: styles.failCopy,
|
|
183
|
+
onClick: () => onCopy(r.message),
|
|
184
|
+
}, t('failCopy')),
|
|
185
|
+
// 每条通知右侧的删除按钮:单独移除这一条
|
|
186
|
+
h('button', {
|
|
187
|
+
className: styles.noticeRemove,
|
|
188
|
+
'aria-label': t('removeNotification'),
|
|
189
|
+
title: t('removeNotification'),
|
|
190
|
+
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRemove(r.id) },
|
|
191
|
+
}, h(CloseIcon)),
|
|
192
|
+
),
|
|
193
|
+
// 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
|
|
194
|
+
// 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
|
|
195
|
+
!r.ok && (() => {
|
|
196
|
+
const kind = classifyFailure(r.message)
|
|
197
|
+
if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
|
|
198
|
+
// [packaging](预检/装后校验拦截:git 分发缺产物)与
|
|
199
|
+
// prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
|
|
200
|
+
return h('div', null, [
|
|
201
|
+
h('div', { className: styles.failPrepareHint }, kind === 'pnpmIgnoredBuild'
|
|
202
|
+
? t('failIgnoredBuild')
|
|
203
|
+
: /\[packaging\]/i.test(r.message) ? t('failPackagingHint') : t('failPrepareHint')),
|
|
204
|
+
r.repo ? h('a', {
|
|
205
|
+
className: styles.failBigIssue,
|
|
206
|
+
href: pluginIssueUrl(r.repo, r.message, env, r.command, r.attempts),
|
|
207
|
+
target: '_blank',
|
|
208
|
+
rel: 'noopener noreferrer',
|
|
209
|
+
title: t('failIssueHint'),
|
|
210
|
+
}, t('failIssueBig')) : null,
|
|
211
|
+
])
|
|
212
|
+
}
|
|
213
|
+
return r.repo ? h('a', {
|
|
214
|
+
className: styles.failBigIssue,
|
|
215
|
+
href: pluginIssueUrl(r.repo, r.message, env, r.command, r.attempts),
|
|
174
216
|
target: '_blank',
|
|
175
217
|
rel: 'noopener noreferrer',
|
|
176
|
-
title:
|
|
177
|
-
},
|
|
178
|
-
|
|
179
|
-
h('span', {
|
|
180
|
-
className: styles.failTime,
|
|
181
|
-
title: new Date(r.at).toLocaleString(),
|
|
182
|
-
}, fmtTime(r.at)),
|
|
183
|
-
!r.ok && h('button', {
|
|
184
|
-
className: styles.failCopy,
|
|
185
|
-
onClick: () => onCopy(r.message),
|
|
186
|
-
}, t('failCopy')),
|
|
218
|
+
title: t('failIssueHint'),
|
|
219
|
+
}, t('failIssueBig')) : null
|
|
220
|
+
})(),
|
|
187
221
|
),
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return h('div', null, [
|
|
196
|
-
h('div', { className: styles.failPrepareHint }, kind === 'pnpmIgnoredBuild'
|
|
197
|
-
? t('failIgnoredBuild')
|
|
198
|
-
: /\[packaging\]/i.test(r.message) ? t('failPackagingHint') : t('failPrepareHint')),
|
|
199
|
-
r.repo ? h('a', {
|
|
200
|
-
className: styles.failBigIssue,
|
|
201
|
-
href: pluginIssueUrl(r.repo, r.message, env),
|
|
202
|
-
target: '_blank',
|
|
203
|
-
rel: 'noopener noreferrer',
|
|
204
|
-
title: t('failIssueHint'),
|
|
205
|
-
}, t('failIssueBig')) : null,
|
|
206
|
-
])
|
|
207
|
-
}
|
|
208
|
-
return r.repo ? h('a', {
|
|
209
|
-
className: styles.failBigIssue,
|
|
210
|
-
href: pluginIssueUrl(r.repo, r.message, env),
|
|
211
|
-
target: '_blank',
|
|
212
|
-
rel: 'noopener noreferrer',
|
|
213
|
-
title: t('failIssueHint'),
|
|
214
|
-
}, t('failIssueBig')) : null
|
|
215
|
-
})(),
|
|
222
|
+
),
|
|
223
|
+
// 每条通知右下角:完整年月日时分秒时间戳(独立一行,不参与徽标垂直居中)
|
|
224
|
+
h('div', { className: styles.noticeFoot },
|
|
225
|
+
h('span', {
|
|
226
|
+
className: styles.noticeTime,
|
|
227
|
+
title: new Date(r.at).toLocaleString(),
|
|
228
|
+
}, fmtTime(r.at)),
|
|
216
229
|
),
|
|
217
230
|
)
|
|
218
231
|
}),
|
|
@@ -8,13 +8,13 @@ import { createElement as h, useEffect, useState } from 'react'
|
|
|
8
8
|
import styles from '../styles/Header.module.css'
|
|
9
9
|
import { en, zh } from '../locales.ts'
|
|
10
10
|
import type { EnvInfo, HubPlugin, LocaleId, SectionProps, ToastState } from '../types.ts'
|
|
11
|
-
import { langPathOf } from '../lib/catalog.ts'
|
|
11
|
+
import { langPathOf, installCommandOf } from '../lib/catalog.ts'
|
|
12
12
|
import { getEnv } from '../lib/env.ts'
|
|
13
13
|
import { useCatalog } from '../hooks/useCatalog.ts'
|
|
14
14
|
import { useTaskQueue } from '../hooks/useTaskQueue.ts'
|
|
15
15
|
import { ErrorModal, InstallModal, UninstallModal, Toast } from './modals.tsx'
|
|
16
16
|
import { NotificationsModal } from './NotificationsModal.tsx'
|
|
17
|
-
import { addFailure, addSuccess, clearNotifications, loadNotifications } from '../lib/failures.ts'
|
|
17
|
+
import { addFailure, addSuccess, clearNotifications, loadNotifications, removeNotification } from '../lib/failures.ts'
|
|
18
18
|
import type { NotificationRecord } from '../lib/failures.ts'
|
|
19
19
|
import { CatalogHeader } from './CatalogHeader.tsx'
|
|
20
20
|
import { CategoryTabs } from './CategoryTabs.tsx'
|
|
@@ -55,8 +55,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
55
55
|
const [uninstallDone, setUninstallDone] = useState(false)
|
|
56
56
|
/** 结果视图「立即重启」:请求宿主重启后进入等待,服务回来后整页刷新 */
|
|
57
57
|
const [restarting, setRestarting] = useState(false)
|
|
58
|
-
/** 操作失败完整信息 + 所属插件仓库 +
|
|
59
|
-
const [errorMsg, setErrorMsg] = useState<{ message: string; repo: string | null; kind: 'install' | 'uninstall' } | null>(null)
|
|
58
|
+
/** 操作失败完整信息 + 所属插件仓库 + 失败类型(决定弹窗标题「安装失败/卸载失败」)+ 实际执行的安装命令 + 尝试过的安装方式(issue 预填用) */
|
|
59
|
+
const [errorMsg, setErrorMsg] = useState<{ message: string; repo: string | null; kind: 'install' | 'uninstall'; command?: string; attempts?: string[] } | null>(null)
|
|
60
60
|
/** 安装/卸载任务通知记录:localStorage 持久化,成败即落盘,即使错过弹窗也能回来查看 */
|
|
61
61
|
const [notifications, setNotifications] = useState<NotificationRecord[]>(() => loadNotifications())
|
|
62
62
|
const [showNotifications, setShowNotifications] = useState(false)
|
|
@@ -82,10 +82,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
82
82
|
else setToast({ id: Date.now(), kind: 'removed' })
|
|
83
83
|
setNotifications(addSuccess({ kind: 'uninstall', repo: repo ?? '' }))
|
|
84
84
|
},
|
|
85
|
-
onError: (message, repo, kind) => {
|
|
86
|
-
setErrorMsg({ message, repo, kind })
|
|
85
|
+
onError: (message, repo, kind, command, attempts) => {
|
|
86
|
+
setErrorMsg({ message, repo, kind, command, attempts })
|
|
87
87
|
// 失败自动写入通知记录:任务在后台结束时没人盯着也能留痕
|
|
88
|
-
setNotifications(addFailure({ kind, repo: repo ?? '', message }))
|
|
88
|
+
setNotifications(addFailure({ kind, repo: repo ?? '', message, command, attempts }))
|
|
89
89
|
},
|
|
90
90
|
installPlugin: confirmPlugin,
|
|
91
91
|
uninstallPlugin,
|
|
@@ -144,10 +144,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/**
|
|
147
|
+
/** 弹窗动作一:复制安装命令到剪贴板,引导去终端粘贴执行(npm 通道显示包名命令)。 */
|
|
148
148
|
const copyCommand = async (p: HubPlugin) => {
|
|
149
149
|
const repo = p.source?.repo ?? ''
|
|
150
|
-
const ok = await doCopy(
|
|
150
|
+
const ok = await doCopy(installCommandOf(p))
|
|
151
151
|
if (ok) {
|
|
152
152
|
setCopied(repo)
|
|
153
153
|
setToast({ id: Date.now(), kind: 'copied' })
|
|
@@ -309,6 +309,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
309
309
|
message: errorMsg.message,
|
|
310
310
|
repo: errorMsg.repo,
|
|
311
311
|
kind: errorMsg.kind,
|
|
312
|
+
command: errorMsg.command,
|
|
313
|
+
attempts: errorMsg.attempts,
|
|
312
314
|
t,
|
|
313
315
|
env,
|
|
314
316
|
onCopy: (text: string) => {
|
|
@@ -331,6 +333,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
331
333
|
setToast({ id: Date.now(), kind: 'errCopied' })
|
|
332
334
|
},
|
|
333
335
|
onClear: () => setNotifications(clearNotifications()),
|
|
336
|
+
onRemove: (id: number) => setNotifications(removeNotification(id)),
|
|
334
337
|
cancelTask: queue.cancelTask,
|
|
335
338
|
restarting,
|
|
336
339
|
onRestart: () => { void requestRestart() },
|