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.
@@ -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
- for (const pid of findPids()) {
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
- const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo) : null;
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
- if (repoTarget !== null) {
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
- const gitProbeTarget = repoTarget === null ? null : repoTarget.replace(/^git\+/, '');
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
- let commit = '';
37
- try {
38
- const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, 'HEAD'], {
39
- timeout: PREFLIGHT_TIMEOUT_MS,
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
- export function githubTarget(repo) {
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
- return `git+https://github.com/${repo}.git`;
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
- export declare function githubTarget(repo: string): string | null;
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.0",
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('uninstallFail')
507
- const fail = toast.kind === 'fail' || toast.kind === 'removeFail' || toast.kind === 'revealFail'
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,
@@ -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',
@@ -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
- for (const pid of findPids()) {
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
- const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo) : null
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
- if (repoTarget !== null) {
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
- const gitProbeTarget = repoTarget === null ? null : repoTarget.replace(/^git\+/, '')
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
- let commit = ''
48
- try {
49
- const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, 'HEAD'], {
50
- timeout: PREFLIGHT_TIMEOUT_MS,
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
- export function githubTarget(repo: string): string | null {
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
- return `git+https://github.com/${repo}.git`
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
  /**