dsh-plugin 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +9 -9
- package/README.md +9 -9
- package/client/client.js +311 -299
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +46 -5
- package/lib/services/install/install.js +1 -1
- package/lib/services/install/preflight.js +28 -13
- package/lib/services/profile/profile.js +31 -3
- package/lib/types/http/routes.d.ts +5 -0
- package/lib/types/services/install/install.d.ts +1 -1
- package/lib/types/services/profile/profile.d.ts +5 -2
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +12 -2
- package/src/client/components/modals/modals.tsx +3 -2
- package/src/client/locales.ts +4 -0
- package/src/client/types.ts +1 -1
- package/src/server/http/routes.ts +47 -5
- package/src/server/services/install/install.ts +1 -1
- package/src/server/services/install/preflight.ts +27 -12
- package/src/server/services/profile/profile.ts +29 -3
package/lib/http/routes.js
CHANGED
|
@@ -13,7 +13,7 @@ import { homedir, release } from 'node:os';
|
|
|
13
13
|
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
|
-
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install/install.js';
|
|
16
|
+
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install/install.js';
|
|
17
17
|
import { githubReleaseTarget } from '../services/install/release-target.js';
|
|
18
18
|
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.js';
|
|
19
19
|
import { isDshNpmPackageForRepo, resolveNpmPackage } from '../services/install/npm-resolve.js';
|
|
@@ -26,6 +26,7 @@ import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLo
|
|
|
26
26
|
* 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
27
27
|
* 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
28
28
|
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
29
|
+
* 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
|
|
29
30
|
*/
|
|
30
31
|
const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
|
|
31
32
|
const F = require('node:fs')
|
|
@@ -52,7 +53,16 @@ function findPids() {
|
|
|
52
53
|
return String(out.stdout || '').split(/\s+/).filter(Boolean)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
const targets = findPids()
|
|
57
|
+
// 一个监听进程都没找到 = 端口探测本身就失败了(netstat 输出格式 / 本地化差异),
|
|
58
|
+
// 旧宿主仍占着端口。此时再拉一个 dsh web 只会在日志里以 EADDRINUSE 静默退出,
|
|
59
|
+
// 用户却看到「重启完成」——不制造这种假象,留一行可诊断的记录后退出。
|
|
60
|
+
if (targets.length === 0) {
|
|
61
|
+
logLine('restart aborted: no listener found on port ' + port)
|
|
62
|
+
process.exit(0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const pid of targets) {
|
|
56
66
|
try {
|
|
57
67
|
if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
|
|
58
68
|
else process.kill(Number(pid), 'SIGTERM')
|
|
@@ -65,6 +75,14 @@ let tries = 0
|
|
|
65
75
|
setTimeout(waitForStop, 250)
|
|
66
76
|
})()
|
|
67
77
|
|
|
78
|
+
function logLine(msg) {
|
|
79
|
+
try {
|
|
80
|
+
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
81
|
+
F.mkdirSync(dir, { recursive: true })
|
|
82
|
+
F.appendFileSync(P.join(dir, 'dsh-web-' + port + '.log'), '[' + new Date().toISOString() + '] ' + msg + '\n')
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
function startHost() {
|
|
69
87
|
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
70
88
|
F.mkdirSync(dir, { recursive: true })
|
|
@@ -76,6 +94,13 @@ function startHost() {
|
|
|
76
94
|
})
|
|
77
95
|
child.unref()
|
|
78
96
|
}`;
|
|
97
|
+
/**
|
|
98
|
+
* 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
|
|
99
|
+
* 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
|
|
100
|
+
*/
|
|
101
|
+
export function isDesktopHost() {
|
|
102
|
+
return process.env.ELECTRON_RUN_AS_NODE === '1';
|
|
103
|
+
}
|
|
79
104
|
const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
80
105
|
const BODY_LIMIT_BYTES = 4 * 1024;
|
|
81
106
|
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -808,7 +833,10 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
808
833
|
// githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
|
|
809
834
|
const gitRepo = githubRepoOf(rawRepo);
|
|
810
835
|
const release = githubReleaseTarget(rawRepo);
|
|
811
|
-
|
|
836
|
+
// 显式 pin 的 Git ref(`#tag` / `#commit`)是用户意图:保留它,且不让 npm 反查
|
|
837
|
+
// 把这个精确修订换成 registry 上的默认版本(见 issue #130)。
|
|
838
|
+
const pinnedRef = githubRefOf(rawRepo);
|
|
839
|
+
const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo, pinnedRef) : null;
|
|
812
840
|
let target = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null);
|
|
813
841
|
if (target === null) {
|
|
814
842
|
sendJson(response, 400, { error: 'unsupported install target' });
|
|
@@ -848,7 +876,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
848
876
|
// 通用机制,不针对具体插件。
|
|
849
877
|
// 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
|
|
850
878
|
// 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
|
|
851
|
-
|
|
879
|
+
// 显式 pin 了 Git ref 时跳过 npm 反查:用户要的就是那个精确修订,
|
|
880
|
+
// 不能用 registry 上的默认版本覆盖它(issue #130)。
|
|
881
|
+
if (repoTarget !== null && pinnedRef === null) {
|
|
852
882
|
// 反查/记录统一用归一化后的 owner/repo(gitRepo),保证「输入完整链接」也走同一套 npm 反查
|
|
853
883
|
const repoIdentity = gitRepo ?? rawRepo;
|
|
854
884
|
const npmName = await resolveNpmPackage(repoIdentity, settings.npmRegistry);
|
|
@@ -916,7 +946,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
916
946
|
// (见 issue #71)。握手失败时再用 curl 复核一次主页连通性:只有两者都不通
|
|
917
947
|
// 才判「网络不通」;仅握手失败(仓库不存在、私有仓库、需要凭据)放行,
|
|
918
948
|
// 交给后续预检与真实安装给出准确原因。
|
|
919
|
-
|
|
949
|
+
// 探针只握手仓库本身(去掉 git+ 前缀与 #ref 片段):ls-remote 认的是仓库地址,
|
|
950
|
+
// 带 ref 片段会干扰 URL 解析
|
|
951
|
+
const gitProbeTarget = repoTarget === null ? null : repoTarget.replace(/^git\+/, '').replace(/#.*$/, '');
|
|
920
952
|
let net;
|
|
921
953
|
if (gitProbeTarget !== null) {
|
|
922
954
|
net = await gitLsRemote(gitProbeTarget, effectiveProxy, 6000);
|
|
@@ -1126,6 +1158,15 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
1126
1158
|
handler: (request, response) => {
|
|
1127
1159
|
if (!requireTrustedPost(request, response))
|
|
1128
1160
|
return;
|
|
1161
|
+
// 桌面端:宿主由应用壳托管,插件重启不了它。Windows 上 `taskkill /T` 会把发起
|
|
1162
|
+
// 重启的助手一起结束(助手是宿主的子进程),壳再把这次外部 kill 判成宿主崩溃
|
|
1163
|
+
// 弹「应用无法启动或已意外停止」;退一步说,脱离壳拉起的 `dsh web` 也服务不了
|
|
1164
|
+
// 桌面 UI(缺壳注入的 desktop:boot)。故不 kill、不拉新宿主,由客户端改提示
|
|
1165
|
+
// 用户从菜单/托盘退出后重新打开。
|
|
1166
|
+
if (isDesktopHost()) {
|
|
1167
|
+
sendJson(response, 200, { ok: false, desktop: true });
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1129
1170
|
// 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
|
|
1130
1171
|
const host = request.headers.host ?? '';
|
|
1131
1172
|
const portMatch = host.match(/:(\d+)$/);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { addPendingRestart, clearPendingRestart, listPendingRestarts } from '../profile/pending-restart.js';
|
|
2
2
|
export { dumpLoaderEntries, removeLoadedEntry } from '../loader.js';
|
|
3
|
-
export { addAllowBuildsKey, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.js';
|
|
3
|
+
export { addAllowBuildsKey, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.js';
|
|
4
4
|
export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.js';
|
|
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { pipeline } from 'node:stream/promises';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
|
-
import { githubRepoOf } from '../profile/profile.js';
|
|
20
|
+
import { githubRefOf, githubRepoOf } from '../profile/profile.js';
|
|
21
21
|
import { githubReleaseTarget } from './release-target.js';
|
|
22
22
|
import { resolvePackageEntry } from './package-entry.js';
|
|
23
23
|
const execFileAsync = promisify(execFile);
|
|
@@ -33,18 +33,10 @@ export async function preflightTarget(target) {
|
|
|
33
33
|
if (source === null)
|
|
34
34
|
return { ok: true, missing: null, name: null };
|
|
35
35
|
const [owner, repo] = source.split('/');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
maxBuffer: 1024 * 1024,
|
|
41
|
-
});
|
|
42
|
-
commit = stdout.split(/\s+/)[0] ?? '';
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
return { ok: true, missing: null, name: null };
|
|
46
|
-
}
|
|
47
|
-
if (!commit)
|
|
36
|
+
// 显式 pin 的 Git ref 要在它自己的修订上预检:默认分支常是 monorepo 根,与 pin 的发布包
|
|
37
|
+
// 形态不同,拿 HEAD 校验会把完整产物误判成缺入口文件(或反之)。无 pin 时仍查 HEAD。
|
|
38
|
+
const commit = await remoteCommit(owner, repo, githubRefOf(target));
|
|
39
|
+
if (commit === null)
|
|
48
40
|
return { ok: true, missing: null, name: null };
|
|
49
41
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'));
|
|
50
42
|
try {
|
|
@@ -68,6 +60,29 @@ export async function preflightTarget(target) {
|
|
|
68
60
|
rmSync(dir, { recursive: true, force: true });
|
|
69
61
|
}
|
|
70
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the commit to inspect: the pinned `ref` when given, otherwise the
|
|
65
|
+
* repository default branch (`HEAD`). A full commit SHA passes through as-is;
|
|
66
|
+
* annotated tags are peeled to their commit. Any git/network failure returns
|
|
67
|
+
* null and the caller falls back to "no preflight".
|
|
68
|
+
*/
|
|
69
|
+
async function remoteCommit(owner, repo, ref) {
|
|
70
|
+
if (ref !== null && /^[0-9a-f]{40}$/i.test(ref))
|
|
71
|
+
return ref;
|
|
72
|
+
try {
|
|
73
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, ref ?? 'HEAD'], {
|
|
74
|
+
timeout: PREFLIGHT_TIMEOUT_MS,
|
|
75
|
+
maxBuffer: 1024 * 1024,
|
|
76
|
+
});
|
|
77
|
+
const lines = stdout.split(/\r?\n/).filter((line) => line !== '');
|
|
78
|
+
// Annotated tags list the tag object and the peeled commit (`<ref>^{}`): prefer the peel.
|
|
79
|
+
const chosen = lines.find((line) => line.endsWith('^{}')) ?? lines[lines.length - 1];
|
|
80
|
+
return chosen?.split(/\s+/)[0] ?? null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
71
86
|
function download(url, dest) {
|
|
72
87
|
return new Promise((resolve, reject) => {
|
|
73
88
|
const req = get(url, { timeout: PREFLIGHT_TIMEOUT_MS }, (res) => {
|
|
@@ -50,11 +50,39 @@ export function profileFromArgv(argv, fallback = 'web') {
|
|
|
50
50
|
export function profileDirectory(profile) {
|
|
51
51
|
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile);
|
|
52
52
|
}
|
|
53
|
-
/** Build a safe explicit HTTPS Git target, or null when the repo is unsafe.
|
|
54
|
-
|
|
53
|
+
/** Build a safe explicit HTTPS Git target, or null when the repo is unsafe.
|
|
54
|
+
* When `ref` is given it is appended as a Git ref (`#tag` / `#commit`) so an
|
|
55
|
+
* explicitly pinned revision reaches the installer instead of being dropped. */
|
|
56
|
+
export function githubTarget(repo, ref) {
|
|
55
57
|
if (typeof repo !== 'string' || !REPO_RE.test(repo))
|
|
56
58
|
return null;
|
|
57
|
-
|
|
59
|
+
const url = `git+https://github.com/${repo}.git`;
|
|
60
|
+
return ref === undefined || ref === null || ref === '' ? url : `${url}#${ref}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Extract a validated explicit Git ref (`#<ref>`) from a GitHub spec, or null.
|
|
64
|
+
* Only the fragment of a Git-style address qualifies — a pinned tag/commit is
|
|
65
|
+
* user intent and must survive target normalization, whereas `#…` on a plain
|
|
66
|
+
* web URL (`https://github.com/o/r#readme`) is a page anchor, not a ref.
|
|
67
|
+
* Git-style = `git+…` / `github:` / `git@…` / `….git` / bare `owner/repo`.
|
|
68
|
+
* Grammar mirrors a Git ref: no leading `.`/`-`, no `#`/whitespace.
|
|
69
|
+
*/
|
|
70
|
+
const GIT_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
71
|
+
export function githubRefOf(value) {
|
|
72
|
+
if (typeof value !== 'string')
|
|
73
|
+
return null;
|
|
74
|
+
const input = value.trim();
|
|
75
|
+
const hash = input.indexOf('#');
|
|
76
|
+
if (hash <= 0)
|
|
77
|
+
return null;
|
|
78
|
+
const base = input.slice(0, hash);
|
|
79
|
+
if (githubRepoOf(base) === null)
|
|
80
|
+
return null;
|
|
81
|
+
const gitStyle = /^(?:git\+|github:|git@)/i.test(base) || /\.git$/i.test(base) || !base.includes('://');
|
|
82
|
+
if (!gitStyle)
|
|
83
|
+
return null;
|
|
84
|
+
const ref = input.slice(hash + 1);
|
|
85
|
+
return GIT_REF_RE.test(ref) ? ref : null;
|
|
58
86
|
}
|
|
59
87
|
/**
|
|
60
88
|
* DSH 命令行前缀(官方唯一形式 `dsh plugin --profile <name> <add|update> <target>`)—— 提取其中的安装目标;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { readProfileArg, type LoaderHandle } from '../services/install/install.ts';
|
|
3
|
+
/**
|
|
4
|
+
* 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
|
|
5
|
+
* 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
|
|
6
|
+
*/
|
|
7
|
+
export declare function isDesktopHost(): boolean;
|
|
3
8
|
export interface WebRoute {
|
|
4
9
|
kind: 'exact';
|
|
5
10
|
path: string;
|
|
@@ -18,5 +18,5 @@ export type { InstallResult, InstallTask, Invocation, PendingRestart, QueueItem
|
|
|
18
18
|
export type { LoaderHandle } from '../loader.ts';
|
|
19
19
|
export { addPendingRestart, clearPendingRestart, listPendingRestarts } from '../profile/pending-restart.ts';
|
|
20
20
|
export { dumpLoaderEntries, removeLoadedEntry } from '../loader.ts';
|
|
21
|
-
export { addAllowBuildsKey, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.ts';
|
|
21
|
+
export { addAllowBuildsKey, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.ts';
|
|
22
22
|
export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.ts';
|
|
@@ -12,8 +12,11 @@ export declare function readProfileArg(fallback?: string): string;
|
|
|
12
12
|
export declare function profileFromArgv(argv: readonly string[], fallback?: string): string;
|
|
13
13
|
/** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */
|
|
14
14
|
export declare function profileDirectory(profile: string): string;
|
|
15
|
-
/** Build a safe explicit HTTPS Git target, or null when the repo is unsafe.
|
|
16
|
-
|
|
15
|
+
/** Build a safe explicit HTTPS Git target, or null when the repo is unsafe.
|
|
16
|
+
* When `ref` is given it is appended as a Git ref (`#tag` / `#commit`) so an
|
|
17
|
+
* explicitly pinned revision reaches the installer instead of being dropped. */
|
|
18
|
+
export declare function githubTarget(repo: string, ref?: string | null): string | null;
|
|
19
|
+
export declare function githubRefOf(value: string): string | null;
|
|
17
20
|
export declare function installTargetOf(value: string): string;
|
|
18
21
|
export declare function globalNpmPackagesOf(value: string): string[] | null;
|
|
19
22
|
/** Extract an owner/repo identity from a catalog value or an installed Git spec. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -311,16 +311,26 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
311
311
|
if (!ok) setToast({ id: Date.now(), kind: 'revealFail' })
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
/** 「立即重启」:POST 同源 /restart,宿主进程自杀重启;随后轮询服务恢复后整页刷新。
|
|
314
|
+
/** 「立即重启」:POST 同源 /restart,宿主进程自杀重启;随后轮询服务恢复后整页刷新。
|
|
315
|
+
* 桌面端宿主归应用壳管(服务端回 `desktop: true` 且不重启):不轮询,改提示退出重开。 */
|
|
315
316
|
const requestRestart = async () => {
|
|
316
317
|
if (restarting) return
|
|
317
318
|
setRestarting(true)
|
|
319
|
+
let desktop = false
|
|
318
320
|
try {
|
|
319
321
|
// 响应可能在宿主进程被 kill 前返回,也可能直接断连 —— 两种都属正常
|
|
320
|
-
await fetch('/dsh-plugin-hub/restart', { method: 'POST' })
|
|
322
|
+
const res = await fetch('/dsh-plugin-hub/restart', { method: 'POST' })
|
|
323
|
+
const data = await res.json() as { desktop?: boolean }
|
|
324
|
+
desktop = data.desktop === true
|
|
321
325
|
} catch {
|
|
322
326
|
/* 服务已终止,无需处理 */
|
|
323
327
|
}
|
|
328
|
+
if (desktop) {
|
|
329
|
+
// 宿主不会重启,轮询等不到新服务:直接给出正确口径
|
|
330
|
+
setRestarting(false)
|
|
331
|
+
setToast({ id: Date.now(), kind: 'restartDesktop' })
|
|
332
|
+
return
|
|
333
|
+
}
|
|
324
334
|
// 轮询同源端点,服务恢复后整页 reload(让新挂载的 bundle 完全初始化)
|
|
325
335
|
let attempts = 0
|
|
326
336
|
const poll = async () => {
|
|
@@ -503,8 +503,9 @@ export function Toast({ toast, t }: { toast: ToastState; t: Translate }) {
|
|
|
503
503
|
: toast.kind === 'fail' ? t('installFail')
|
|
504
504
|
: toast.kind === 'removed' ? t('uninstallDone')
|
|
505
505
|
: toast.kind === 'revealFail' ? t('openFolderFail')
|
|
506
|
-
: t('
|
|
507
|
-
|
|
506
|
+
: toast.kind === 'restartDesktop' ? t('toastRestartDesktop')
|
|
507
|
+
: t('uninstallFail')
|
|
508
|
+
const fail = toast.kind === 'fail' || toast.kind === 'removeFail' || toast.kind === 'revealFail' || toast.kind === 'restartDesktop'
|
|
508
509
|
return h('div', {
|
|
509
510
|
key: toast.id,
|
|
510
511
|
className: fail ? `${styles.toast} ${styles.toastFail}` : styles.toast,
|
package/src/client/locales.ts
CHANGED
|
@@ -268,6 +268,8 @@ export const zh = {
|
|
|
268
268
|
restartLater: '稍后重启',
|
|
269
269
|
restarting: '正在重启…',
|
|
270
270
|
restartHint: '重启会中断正在进行的安装/卸载任务,其进度将丢失。部分插件(如插件市场、顶层 bundle)需重启后才会生效。',
|
|
271
|
+
// 桌面端宿主归应用壳管、插件重启不了它:点「立即重启」时改提示用户退出重开
|
|
272
|
+
toastRestartDesktop: '桌面端的宿主由应用管理,请在应用菜单(或托盘)选择退出后重新打开,重启后生效',
|
|
271
273
|
done: '完成',
|
|
272
274
|
// 待重启:装完没重启的插件常驻提醒,直到宿主真正重启
|
|
273
275
|
restartPendingHint: '安装成功,重启后生效',
|
|
@@ -650,6 +652,8 @@ export const en = {
|
|
|
650
652
|
restartLater: 'Later',
|
|
651
653
|
restarting: 'Restarting…',
|
|
652
654
|
restartHint: 'Restarting interrupts any in-progress install / uninstall task — its progress will be lost. Some plugins (such as plugin markets / top-level bundles) only take effect after a restart.',
|
|
655
|
+
// Desktop: the app shell owns the host, so plugins cannot restart it — tell the user to quit and reopen
|
|
656
|
+
toastRestartDesktop: 'In the desktop app the host is managed by the app: quit from the app menu (or tray) and open it again — the change takes effect after that restart',
|
|
653
657
|
done: 'Done',
|
|
654
658
|
restartPendingHint: 'Installed — takes effect after restart',
|
|
655
659
|
restartPendingHintUninstall: 'Removed — cleaned up after restart',
|
package/src/client/types.ts
CHANGED
|
@@ -77,7 +77,7 @@ export interface TaskState {
|
|
|
77
77
|
lines: string[]
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
export type ToastKind = 'copied' | 'errCopied' | 'done' | 'fail' | 'removed' | 'removeFail' | 'revealFail'
|
|
80
|
+
export type ToastKind = 'copied' | 'errCopied' | 'done' | 'fail' | 'removed' | 'removeFail' | 'revealFail' | 'restartDesktop'
|
|
81
81
|
|
|
82
82
|
export interface ToastState {
|
|
83
83
|
id: number
|
|
@@ -14,7 +14,7 @@ import { homedir, release } from 'node:os'
|
|
|
14
14
|
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
|
-
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install/install.ts'
|
|
17
|
+
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install/install.ts'
|
|
18
18
|
import { githubReleaseTarget } from '../services/install/release-target.ts'
|
|
19
19
|
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.ts'
|
|
20
20
|
import { isDshNpmPackageForRepo, resolveNpmPackage } from '../services/install/npm-resolve.ts'
|
|
@@ -28,6 +28,7 @@ import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLo
|
|
|
28
28
|
* 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
29
29
|
* 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
30
30
|
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
31
|
+
* 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
|
|
31
32
|
*/
|
|
32
33
|
const HOST_RESTART_SCRIPT = String.raw`const C = require('node:child_process')
|
|
33
34
|
const F = require('node:fs')
|
|
@@ -54,7 +55,16 @@ function findPids() {
|
|
|
54
55
|
return String(out.stdout || '').split(/\s+/).filter(Boolean)
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
const targets = findPids()
|
|
59
|
+
// 一个监听进程都没找到 = 端口探测本身就失败了(netstat 输出格式 / 本地化差异),
|
|
60
|
+
// 旧宿主仍占着端口。此时再拉一个 dsh web 只会在日志里以 EADDRINUSE 静默退出,
|
|
61
|
+
// 用户却看到「重启完成」——不制造这种假象,留一行可诊断的记录后退出。
|
|
62
|
+
if (targets.length === 0) {
|
|
63
|
+
logLine('restart aborted: no listener found on port ' + port)
|
|
64
|
+
process.exit(0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const pid of targets) {
|
|
58
68
|
try {
|
|
59
69
|
if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
|
|
60
70
|
else process.kill(Number(pid), 'SIGTERM')
|
|
@@ -67,6 +77,14 @@ let tries = 0
|
|
|
67
77
|
setTimeout(waitForStop, 250)
|
|
68
78
|
})()
|
|
69
79
|
|
|
80
|
+
function logLine(msg) {
|
|
81
|
+
try {
|
|
82
|
+
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
83
|
+
F.mkdirSync(dir, { recursive: true })
|
|
84
|
+
F.appendFileSync(P.join(dir, 'dsh-web-' + port + '.log'), '[' + new Date().toISOString() + '] ' + msg + '\n')
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
function startHost() {
|
|
71
89
|
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
72
90
|
F.mkdirSync(dir, { recursive: true })
|
|
@@ -79,6 +97,14 @@ function startHost() {
|
|
|
79
97
|
child.unref()
|
|
80
98
|
}`
|
|
81
99
|
|
|
100
|
+
/**
|
|
101
|
+
* 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
|
|
102
|
+
* 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
|
|
103
|
+
*/
|
|
104
|
+
export function isDesktopHost(): boolean {
|
|
105
|
+
return process.env.ELECTRON_RUN_AS_NODE === '1'
|
|
106
|
+
}
|
|
107
|
+
|
|
82
108
|
export interface WebRoute {
|
|
83
109
|
kind: 'exact'
|
|
84
110
|
path: string
|
|
@@ -798,7 +824,10 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
798
824
|
// githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
|
|
799
825
|
const gitRepo = githubRepoOf(rawRepo)
|
|
800
826
|
const release = githubReleaseTarget(rawRepo)
|
|
801
|
-
|
|
827
|
+
// 显式 pin 的 Git ref(`#tag` / `#commit`)是用户意图:保留它,且不让 npm 反查
|
|
828
|
+
// 把这个精确修订换成 registry 上的默认版本(见 issue #130)。
|
|
829
|
+
const pinnedRef = githubRefOf(rawRepo)
|
|
830
|
+
const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo, pinnedRef) : null
|
|
802
831
|
let target: string | null = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null)
|
|
803
832
|
if (target === null) {
|
|
804
833
|
sendJson(response, 400, { error: 'unsupported install target' })
|
|
@@ -837,7 +866,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
837
866
|
// 通用机制,不针对具体插件。
|
|
838
867
|
// 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
|
|
839
868
|
// 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
|
|
840
|
-
|
|
869
|
+
// 显式 pin 了 Git ref 时跳过 npm 反查:用户要的就是那个精确修订,
|
|
870
|
+
// 不能用 registry 上的默认版本覆盖它(issue #130)。
|
|
871
|
+
if (repoTarget !== null && pinnedRef === null) {
|
|
841
872
|
// 反查/记录统一用归一化后的 owner/repo(gitRepo),保证「输入完整链接」也走同一套 npm 反查
|
|
842
873
|
const repoIdentity = gitRepo ?? rawRepo
|
|
843
874
|
const npmName = await resolveNpmPackage(repoIdentity, settings.npmRegistry)
|
|
@@ -904,7 +935,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
904
935
|
// (见 issue #71)。握手失败时再用 curl 复核一次主页连通性:只有两者都不通
|
|
905
936
|
// 才判「网络不通」;仅握手失败(仓库不存在、私有仓库、需要凭据)放行,
|
|
906
937
|
// 交给后续预检与真实安装给出准确原因。
|
|
907
|
-
|
|
938
|
+
// 探针只握手仓库本身(去掉 git+ 前缀与 #ref 片段):ls-remote 认的是仓库地址,
|
|
939
|
+
// 带 ref 片段会干扰 URL 解析
|
|
940
|
+
const gitProbeTarget = repoTarget === null ? null : repoTarget.replace(/^git\+/, '').replace(/#.*$/, '')
|
|
908
941
|
let net: Awaited<ReturnType<typeof probeUrl>>
|
|
909
942
|
if (gitProbeTarget !== null) {
|
|
910
943
|
net = await gitLsRemote(gitProbeTarget, effectiveProxy, 6000)
|
|
@@ -1102,6 +1135,15 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
1102
1135
|
path: '/dsh-plugin-hub/restart',
|
|
1103
1136
|
handler: (request, response) => {
|
|
1104
1137
|
if (!requireTrustedPost(request, response)) return
|
|
1138
|
+
// 桌面端:宿主由应用壳托管,插件重启不了它。Windows 上 `taskkill /T` 会把发起
|
|
1139
|
+
// 重启的助手一起结束(助手是宿主的子进程),壳再把这次外部 kill 判成宿主崩溃
|
|
1140
|
+
// 弹「应用无法启动或已意外停止」;退一步说,脱离壳拉起的 `dsh web` 也服务不了
|
|
1141
|
+
// 桌面 UI(缺壳注入的 desktop:boot)。故不 kill、不拉新宿主,由客户端改提示
|
|
1142
|
+
// 用户从菜单/托盘退出后重新打开。
|
|
1143
|
+
if (isDesktopHost()) {
|
|
1144
|
+
sendJson(response, 200, { ok: false, desktop: true })
|
|
1145
|
+
return
|
|
1146
|
+
}
|
|
1105
1147
|
// 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
|
|
1106
1148
|
const host = request.headers.host ?? ''
|
|
1107
1149
|
const portMatch = host.match(/:(\d+)$/)
|
|
@@ -18,5 +18,5 @@ export type { InstallResult, InstallTask, Invocation, PendingRestart, QueueItem
|
|
|
18
18
|
export type { LoaderHandle } from '../loader.ts'
|
|
19
19
|
export { addPendingRestart, clearPendingRestart, listPendingRestarts } from '../profile/pending-restart.ts'
|
|
20
20
|
export { dumpLoaderEntries, removeLoadedEntry } from '../loader.ts'
|
|
21
|
-
export { addAllowBuildsKey, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.ts'
|
|
21
|
+
export { addAllowBuildsKey, githubRefOf, githubRepoOf, githubTarget, globalNpmPackagesOf, installTargetOf, parseAllowBuildsKey, readProfileArg, validPackageName } from '../profile/profile.ts'
|
|
22
22
|
export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.ts'
|
|
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os'
|
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { pipeline } from 'node:stream/promises'
|
|
19
19
|
import { promisify } from 'node:util'
|
|
20
|
-
import { githubRepoOf } from '../profile/profile.ts'
|
|
20
|
+
import { githubRefOf, githubRepoOf } from '../profile/profile.ts'
|
|
21
21
|
import { githubReleaseTarget } from './release-target.ts'
|
|
22
22
|
import { resolvePackageEntry } from './package-entry.ts'
|
|
23
23
|
|
|
@@ -44,17 +44,10 @@ export async function preflightTarget(target: string): Promise<PreflightResult>
|
|
|
44
44
|
const source = githubRepoOf(target)
|
|
45
45
|
if (source === null) return { ok: true, missing: null, name: null }
|
|
46
46
|
const [owner, repo] = source.split('/')
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
maxBuffer: 1024 * 1024,
|
|
52
|
-
})
|
|
53
|
-
commit = stdout.split(/\s+/)[0] ?? ''
|
|
54
|
-
} catch {
|
|
55
|
-
return { ok: true, missing: null, name: null }
|
|
56
|
-
}
|
|
57
|
-
if (!commit) return { ok: true, missing: null, name: null }
|
|
47
|
+
// 显式 pin 的 Git ref 要在它自己的修订上预检:默认分支常是 monorepo 根,与 pin 的发布包
|
|
48
|
+
// 形态不同,拿 HEAD 校验会把完整产物误判成缺入口文件(或反之)。无 pin 时仍查 HEAD。
|
|
49
|
+
const commit = await remoteCommit(owner, repo, githubRefOf(target))
|
|
50
|
+
if (commit === null) return { ok: true, missing: null, name: null }
|
|
58
51
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'))
|
|
59
52
|
try {
|
|
60
53
|
const tar = join(dir, 'pkg.tar.gz')
|
|
@@ -74,6 +67,28 @@ export async function preflightTarget(target: string): Promise<PreflightResult>
|
|
|
74
67
|
}
|
|
75
68
|
}
|
|
76
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the commit to inspect: the pinned `ref` when given, otherwise the
|
|
72
|
+
* repository default branch (`HEAD`). A full commit SHA passes through as-is;
|
|
73
|
+
* annotated tags are peeled to their commit. Any git/network failure returns
|
|
74
|
+
* null and the caller falls back to "no preflight".
|
|
75
|
+
*/
|
|
76
|
+
async function remoteCommit(owner: string, repo: string, ref: string | null): Promise<string | null> {
|
|
77
|
+
if (ref !== null && /^[0-9a-f]{40}$/i.test(ref)) return ref
|
|
78
|
+
try {
|
|
79
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, ref ?? 'HEAD'], {
|
|
80
|
+
timeout: PREFLIGHT_TIMEOUT_MS,
|
|
81
|
+
maxBuffer: 1024 * 1024,
|
|
82
|
+
})
|
|
83
|
+
const lines = stdout.split(/\r?\n/).filter((line) => line !== '')
|
|
84
|
+
// Annotated tags list the tag object and the peeled commit (`<ref>^{}`): prefer the peel.
|
|
85
|
+
const chosen = lines.find((line) => line.endsWith('^{}')) ?? lines[lines.length - 1]
|
|
86
|
+
return chosen?.split(/\s+/)[0] ?? null
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
function download(url: string, dest: string): Promise<void> {
|
|
78
93
|
return new Promise((resolve, reject) => {
|
|
79
94
|
const req = get(url, { timeout: PREFLIGHT_TIMEOUT_MS }, (res) => {
|
|
@@ -49,10 +49,36 @@ export function profileDirectory(profile: string): string {
|
|
|
49
49
|
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** Build a safe explicit HTTPS Git target, or null when the repo is unsafe.
|
|
53
|
-
|
|
52
|
+
/** Build a safe explicit HTTPS Git target, or null when the repo is unsafe.
|
|
53
|
+
* When `ref` is given it is appended as a Git ref (`#tag` / `#commit`) so an
|
|
54
|
+
* explicitly pinned revision reaches the installer instead of being dropped. */
|
|
55
|
+
export function githubTarget(repo: string, ref?: string | null): string | null {
|
|
54
56
|
if (typeof repo !== 'string' || !REPO_RE.test(repo)) return null
|
|
55
|
-
|
|
57
|
+
const url = `git+https://github.com/${repo}.git`
|
|
58
|
+
return ref === undefined || ref === null || ref === '' ? url : `${url}#${ref}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract a validated explicit Git ref (`#<ref>`) from a GitHub spec, or null.
|
|
63
|
+
* Only the fragment of a Git-style address qualifies — a pinned tag/commit is
|
|
64
|
+
* user intent and must survive target normalization, whereas `#…` on a plain
|
|
65
|
+
* web URL (`https://github.com/o/r#readme`) is a page anchor, not a ref.
|
|
66
|
+
* Git-style = `git+…` / `github:` / `git@…` / `….git` / bare `owner/repo`.
|
|
67
|
+
* Grammar mirrors a Git ref: no leading `.`/`-`, no `#`/whitespace.
|
|
68
|
+
*/
|
|
69
|
+
const GIT_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
|
|
70
|
+
|
|
71
|
+
export function githubRefOf(value: string): string | null {
|
|
72
|
+
if (typeof value !== 'string') return null
|
|
73
|
+
const input = value.trim()
|
|
74
|
+
const hash = input.indexOf('#')
|
|
75
|
+
if (hash <= 0) return null
|
|
76
|
+
const base = input.slice(0, hash)
|
|
77
|
+
if (githubRepoOf(base) === null) return null
|
|
78
|
+
const gitStyle = /^(?:git\+|github:|git@)/i.test(base) || /\.git$/i.test(base) || !base.includes('://')
|
|
79
|
+
if (!gitStyle) return null
|
|
80
|
+
const ref = input.slice(hash + 1)
|
|
81
|
+
return GIT_REF_RE.test(ref) ? ref : null
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
/**
|