dsh-plugin 1.3.2 → 1.3.4

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.
@@ -16,21 +16,22 @@ const PROXY_BASE = '/dsh-plugin-hub/catalog'
16
16
  const PLUGINS_URL = (lang: LocaleId) => `${PROXY_BASE}?lang=${lang}`
17
17
  const STATS_URL = `${PROXY_BASE}?stats=1`
18
18
 
19
- async function fetchJson(url: string): Promise<unknown> {
20
- const res = await fetch(url, { cache: 'no-store' })
19
+ async function fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
20
+ const res = await fetch(url, { cache: 'no-store', signal })
21
21
  if (!res.ok) throw new Error(String(res.status))
22
22
  return res.json()
23
23
  }
24
24
 
25
- /** 目录插件列表(在线 API 已只返回 verified;过滤与排序由上层负责)。 */
26
- export async function fetchCatalog(lang: LocaleId): Promise<HubPlugin[]> {
27
- const data = await fetchJson(PLUGINS_URL(lang))
25
+ /** 目录插件列表(在线 API 已只返回 verified;过滤与排序由上层负责)。
26
+ * signal 可选:宿主重启/代理抖动导致请求挂起时,上层可中止旧请求释放连接。 */
27
+ export async function fetchCatalog(lang: LocaleId, signal?: AbortSignal): Promise<HubPlugin[]> {
28
+ const data = await fetchJson(PLUGINS_URL(lang), signal)
28
29
  return (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
29
30
  }
30
31
 
31
32
  /** 收录/精选统计(/api/stats.json);字段不完整返回 null。 */
32
- export async function fetchStats(): Promise<{ total: number; verified: number } | null> {
33
- const s = await fetchJson(STATS_URL) as { total?: number; verified?: number }
33
+ export async function fetchStats(signal?: AbortSignal): Promise<{ total: number; verified: number } | null> {
34
+ const s = await fetchJson(STATS_URL, signal) as { total?: number; verified?: number }
34
35
  if (s && typeof s.total === 'number' && typeof s.verified === 'number') {
35
36
  return { total: s.total, verified: s.verified }
36
37
  }
@@ -9,7 +9,7 @@
9
9
  * installed-plugin table, and the filter/search/sort/install-status view
10
10
  * state, exposing the derived visible list and per-category counts.
11
11
  */
12
- import { useEffect, useMemo, useState } from 'react'
12
+ import { useEffect, useMemo, useRef, useState } from 'react'
13
13
  import type { HubPlugin, LocaleId } from '../types.ts'
14
14
  import { HUB_REPO, PLUGIN_VERSION } from '../logic/constants.ts'
15
15
  import type { HubAboutInfo, HubUpdateInfo } from '../types.ts'
@@ -45,6 +45,14 @@ export function useCatalog(lang: LocaleId) {
45
45
  const [sort, setSort] = useState<SortKey>('sortStars')
46
46
  /** 当前排序方向:点同一个排序按钮切换 正序/倒序;点新排序用该排序的默认方向(与已安装视图一致) */
47
47
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
48
+ /** 目录加载「超时自动强制刷新」重试计数:宿主重启/代理未就绪时请求可能长时间挂起,
49
+ * 界面停在「正在加载插件数据…」假死 —— 超时自动重拉,最多自动重试 2 次后转失败态;
50
+ * 用户手动刷新(reload)时清零重新计数。 */
51
+ const loadRetriesRef = useRef(0)
52
+ /** 目录加载超时阈值:超过即判定挂起,强制刷新一次(服务端代理 curl 上限 20s,客户端 12s 先兜底) */
53
+ const LOAD_TIMEOUT_MS = 12000
54
+ /** 挂起后最多自动重试次数:避免代理持续不通时无限循环刷新(每次重试仍 12s,2 次后转失败界面可手动重试) */
55
+ const MAX_LOAD_RETRIES = 2
48
56
 
49
57
  /** 点击排序按钮:同一按钮切换正/倒序,新按钮用默认方向 */
50
58
  function toggleSort(key: SortKey) {
@@ -83,15 +91,21 @@ export function useCatalog(lang: LocaleId) {
83
91
  // 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
84
92
  useEffect(() => {
85
93
  let cancelled = false
94
+ // 本次加载是否已收尾(成功或失败):超时定时器只对「还在加载中」生效
95
+ let settled = false
96
+ // 中止控制器:请求挂起时(宿主重启/代理抖动)超时自动重试会换新请求,
97
+ // 旧请求在此中止释放连接,避免挂起堆积
98
+ const controller = new AbortController()
86
99
  setPlugins(null)
87
100
  setHubPlugin(null)
88
101
  setStats(null)
89
102
  setFailed(false)
90
103
  // 目录数据直拉:hub 自身版本由独立 Worker 版本控制中心管理,不再依赖主站版本号接口。
91
- const load = () => Promise.all([fetchCatalog(lang), fetchStats()])
104
+ const load = () => Promise.all([fetchCatalog(lang, controller.signal), fetchStats(controller.signal)])
92
105
  load()
93
106
  .then(([list, stats]) => {
94
107
  if (cancelled) return
108
+ settled = true
95
109
  // 插件市场不显示自己:DSH Plugin Hub 从目录里排除自身条目,
96
110
  // 防止「自己出现在自己的插件列表里、还能自己安装自己」;统计计数同步减 1 保持与列表一致。
97
111
  // 目录数据已排除自身,此处再兜底过滤一次,防旧快照仍含自身条目。
@@ -106,9 +120,27 @@ export function useCatalog(lang: LocaleId) {
106
120
  }
107
121
  })
108
122
  .catch(() => {
109
- if (!cancelled) setFailed(true)
123
+ if (cancelled) return
124
+ settled = true
125
+ setFailed(true)
110
126
  })
111
- return () => { cancelled = true }
127
+ // 加载超时兜底:宿主刚重启/代理未就绪时目录请求可能长时间挂起,界面停在「正在加载插件数据…」
128
+ // 像假死。超过阈值未收尾 → 强制刷新列表(重拉一次,最多 MAX_LOAD_RETRIES 次自动重试),
129
+ // 重试仍失败 → 转失败界面(显示「重试」按钮,手动刷新时计数清零重新开始)。
130
+ const timer = window.setTimeout(() => {
131
+ if (cancelled || settled) return
132
+ if (loadRetriesRef.current < MAX_LOAD_RETRIES) {
133
+ loadRetriesRef.current += 1
134
+ setReloadKey((k) => k + 1)
135
+ } else {
136
+ setFailed(true)
137
+ }
138
+ }, LOAD_TIMEOUT_MS)
139
+ return () => {
140
+ cancelled = true
141
+ window.clearTimeout(timer)
142
+ controller.abort()
143
+ }
112
144
  // lang 必须进依赖:切换界面语言要重新拉对应语言的数据文件,
113
145
  // 否则英文模式仍停留在 plugins.zh.json,描述/名称全是中文
114
146
  }, [reloadKey, lang])
@@ -218,7 +250,7 @@ export function useCatalog(lang: LocaleId) {
218
250
  hubPlugin,
219
251
  stats,
220
252
  failed,
221
- reload: () => setReloadKey((k) => k + 1),
253
+ reload: () => { loadRetriesRef.current = 0; setReloadKey((k) => k + 1) },
222
254
  installed,
223
255
  installedItems,
224
256
  installedName,
@@ -141,6 +141,12 @@ export const zh = {
141
141
  logOpenFile: '打开文件',
142
142
  logOpenFileTip: '在系统文件管理器中定位日志文件',
143
143
  logOpenFileFail: '打开失败,请在文件管理器中手动定位',
144
+ logClearAll: '清空所有日志',
145
+ logClearConfirmTitle: '清空所有日志?',
146
+ logClearConfirmDesc: '将删除全部日志记录,且不可恢复。',
147
+ logClearing: '正在清空…',
148
+ logClearAllTip: '清空全部日志记录',
149
+ logClearFailed: '清空失败,请重试',
144
150
  logPathSettingTitle: '日志存放位置',
145
151
  logPathSettingDesc: '默认存于宿主配置目录,可填目录或 .log 文件路径改存别处',
146
152
  logPathPlaceholder: '留空恢复默认',
@@ -241,7 +247,7 @@ export const zh = {
241
247
  restartNow: '立即重启',
242
248
  restartLater: '稍后重启',
243
249
  restarting: '正在重启…',
244
- restartHint: '部分插件(如插件市场、顶层 bundle)需重启后才会生效。',
250
+ restartHint: '重启会中断正在进行的安装/卸载任务,其进度将丢失。部分插件(如插件市场、顶层 bundle)需重启后才会生效。',
245
251
  done: '完成',
246
252
  // 待重启:装完没重启的插件常驻提醒,直到宿主真正重启
247
253
  restartPendingHint: '安装成功,重启后生效',
@@ -254,7 +260,11 @@ export const zh = {
254
260
  notificationsDesc: '安装、卸载任务与更新提醒都会记录在这里,即使错过提示也能随时回来查看。',
255
261
  notificationsEmpty: '暂无通知记录',
256
262
  notificationsClear: '清空通知',
263
+ notificationsClearConfirmTitle: '清空所有通知?',
264
+ notificationsClearConfirmDesc: '将删除全部通知记录,且不可恢复。',
257
265
  removeNotification: '删除这条通知',
266
+ removeNotificationConfirmTitle: '删除这条通知?',
267
+ removeNotificationConfirmDesc: '该通知将被移除,且不可恢复。',
258
268
  // 串行队列:安装/卸载弹窗内可后台关闭,排队任务自动串行执行
259
269
  queuedTitle: '已加入安装排队',
260
270
  queuedUninstallTitle: '已加入卸载排队',
@@ -458,6 +468,12 @@ export const en = {
458
468
  logOpenFile: 'Reveal',
459
469
  logOpenFileTip: 'Reveal the log file in your system file manager',
460
470
  logOpenFileFail: 'Could not open; locate the file manually',
471
+ logClearAll: 'Clear all logs',
472
+ logClearConfirmTitle: 'Clear all logs?',
473
+ logClearConfirmDesc: 'All log entries will be deleted. This cannot be undone.',
474
+ logClearing: 'Clearing…',
475
+ logClearAllTip: 'Delete all log entries',
476
+ logClearFailed: 'Failed to clear; try again',
461
477
  logPathSettingTitle: 'Log Location',
462
478
  logPathSettingDesc: 'Stored in the host config directory by default — set a directory or a .log file path to store elsewhere',
463
479
  logPathPlaceholder: 'Leave empty for default',
@@ -558,7 +574,7 @@ export const en = {
558
574
  restartNow: 'Restart now',
559
575
  restartLater: 'Later',
560
576
  restarting: 'Restarting…',
561
- restartHint: 'Some plugins (such as plugin markets / top-level bundles) only take effect after a restart.',
577
+ 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.',
562
578
  done: 'Done',
563
579
  restartPendingHint: 'Installed — takes effect after restart',
564
580
  restartPendingHintUninstall: 'Removed — cleaned up after restart',
@@ -570,7 +586,11 @@ export const en = {
570
586
  notificationsDesc: 'Every install, remove and update notice is logged here — even if you missed the prompt, you can always check back.',
571
587
  notificationsEmpty: 'No notifications yet',
572
588
  notificationsClear: 'Clear notifications',
589
+ notificationsClearConfirmTitle: 'Clear all notifications?',
590
+ notificationsClearConfirmDesc: 'All notification records will be deleted. This cannot be undone.',
573
591
  removeNotification: 'Remove this notification',
592
+ removeNotificationConfirmTitle: 'Remove this notification?',
593
+ removeNotificationConfirmDesc: 'This notification will be removed. This cannot be undone.',
574
594
  queuedTitle: 'Queued…',
575
595
  queuedUninstallTitle: 'Queued for removal…',
576
596
  queuedHint: 'Queued — starts automatically after earlier tasks finish. You may close this window.',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Self-heal missing plugin CSS tags.
3
+ *
4
+ * CSS injection is a one-shot side effect of the bundle factory (guarded by
5
+ * "tag already present"). When the host restarts the page survives, but the
6
+ * host HMR chain may remove the <style data-plugin> tags while the in-memory
7
+ * module cache still holds the old factory — so the factory never re-runs and
8
+ * the styles never come back until some interaction triggers a reload.
9
+ *
10
+ * The tsdown `dsh-css-modules-inline` build plugin registers every module's
11
+ * { tagId, css } into the global list `__DSH_PLUGIN_CSS__` at factory time.
12
+ * This helper compares that list against the live DOM and re-injects any
13
+ * missing tags, turning the "click around to fix it" self-heal into an
14
+ * automatic one on mount / view switch.
15
+ */
16
+
17
+ interface PluginCssEntry {
18
+ tagId: string
19
+ css: string
20
+ }
21
+
22
+ declare global {
23
+ interface Window {
24
+ __DSH_PLUGIN_CSS__?: PluginCssEntry[]
25
+ }
26
+ }
27
+
28
+ /** Re-inject any registered stylesheet that is missing from the DOM. Idempotent. */
29
+ export function ensurePluginCss(): void {
30
+ if (typeof document === 'undefined') return
31
+ const registry = window.__DSH_PLUGIN_CSS__
32
+ if (!registry || registry.length === 0) return
33
+ for (const { tagId, css } of registry) {
34
+ const selector = 'style[data-plugin-css=' + JSON.stringify(tagId) + ']'
35
+ if (document.querySelector(selector) !== null) continue
36
+ const tag = document.createElement('style')
37
+ tag.dataset.plugin = 'dsh-plugin'
38
+ tag.dataset.pluginCss = tagId
39
+ tag.textContent = css
40
+ document.head.appendChild(tag)
41
+ }
42
+ }
@@ -62,16 +62,37 @@
62
62
  cursor: default;
63
63
  }
64
64
 
65
- /* 主操作:导出日志,品牌色实底 */
66
- .btnPrimary {
67
- color: #ffffff;
68
- background: var(--hub-brand);
69
- border-color: var(--hub-brand);
65
+ /* 弹窗头部动作:清空所有日志(危险操作,二次确认时转红) */
66
+ .headActions {
67
+ flex: 1;
68
+ display: flex;
69
+ justify-content: flex-end;
70
+ margin-right: 4px;
71
+ }
72
+
73
+ .clearBtn {
74
+ flex: none;
75
+ height: 22px;
76
+ padding: 0 10px;
77
+ font-size: 11px;
78
+ font-weight: 500;
79
+ color: var(--hub-text-secondary);
80
+ background: transparent;
81
+ border: 1px solid var(--hub-border-2);
82
+ border-radius: 6px;
83
+ cursor: pointer;
84
+ user-select: none;
85
+ transition: color 0.12s ease, background-color 0.12s ease, border-color 0.12s ease;
70
86
  }
71
87
 
72
- .btnPrimary:hover:not(:disabled) {
73
- background: var(--hub-brand-hover);
74
- border-color: var(--hub-brand-hover);
88
+ .clearBtn:hover:not(:disabled) {
89
+ color: var(--hub-text-primary);
90
+ background: var(--hub-bg-2);
91
+ }
92
+
93
+ .clearBtn:disabled {
94
+ opacity: 0.55;
95
+ cursor: default;
75
96
  }
76
97
 
77
98
  /* 日志列表:一行一条,悬浮高亮;超出限高内部滚动 */
@@ -122,6 +122,83 @@
122
122
  flex-shrink: 0;
123
123
  }
124
124
 
125
+ /* 确认弹窗图标区:图标居中占位,位于标题下方、描述上方 —— 所有询问框都要有对应图标 */
126
+ .confirmIconWrap {
127
+ display: flex;
128
+ justify-content: center;
129
+ padding: 2px 0 6px;
130
+ }
131
+
132
+ /* 确认弹窗图标:圆形徽标(48px),普通确认 = 品牌蓝底白图形 */
133
+ .confirmIcon {
134
+ width: 48px;
135
+ height: 48px;
136
+ flex: none;
137
+ display: inline-flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ border-radius: 50%;
141
+ background: var(--hub-brand);
142
+ box-shadow: 0 4px 12px rgba(79, 110, 247, 0.28);
143
+ }
144
+
145
+ /* 危险操作确认图标:红色底白图形 —— 清空/删除/卸载/重置,醒目区分于普通确认 */
146
+ .confirmIconDanger {
147
+ background: var(--hub-danger);
148
+ box-shadow: 0 4px 12px rgba(209, 36, 47, 0.3);
149
+ }
150
+
151
+ /* 危险操作确认按钮:红色实底白字 —— 清空/删除/卸载/重置的最终确认,显著醒目 */
152
+ .dangerConfirm {
153
+ flex: none;
154
+ height: 28px;
155
+ padding: 0 14px;
156
+ font-size: 12px;
157
+ font-weight: 600;
158
+ color: #ffffff;
159
+ background: var(--hub-danger);
160
+ border: 1px solid var(--hub-danger);
161
+ border-radius: 6px;
162
+ cursor: pointer;
163
+ user-select: none;
164
+ transition: background-color 0.12s ease, border-color 0.12s ease;
165
+ }
166
+
167
+ .dangerConfirm:hover:not(:disabled) {
168
+ background: var(--hub-danger-hover);
169
+ border-color: var(--hub-danger-hover);
170
+ }
171
+
172
+ .dangerConfirm:disabled {
173
+ opacity: 0.55;
174
+ cursor: default;
175
+ }
176
+
177
+ /* 普通确认按钮:品牌蓝实底白字 —— 安装/更新/重启/忽略等常规确认 */
178
+ .confirmPrimary {
179
+ flex: none;
180
+ height: 28px;
181
+ padding: 0 14px;
182
+ font-size: 12px;
183
+ font-weight: 600;
184
+ color: #ffffff;
185
+ background: var(--hub-brand);
186
+ border: 1px solid var(--hub-brand);
187
+ border-radius: 6px;
188
+ cursor: pointer;
189
+ user-select: none;
190
+ transition: filter 0.12s ease;
191
+ }
192
+
193
+ .confirmPrimary:hover:not(:disabled) {
194
+ filter: brightness(1.08);
195
+ }
196
+
197
+ .confirmPrimary:disabled {
198
+ opacity: 0.55;
199
+ cursor: default;
200
+ }
201
+
125
202
  .modalDesc {
126
203
  font-size: 12px;
127
204
  line-height: 18px;
@@ -20,7 +20,63 @@ import { resolveNpmPackage } from '../services/install/npm-resolve.ts'
20
20
  import { preflightTarget } from '../services/install/preflight.ts'
21
21
  import { isDshPlugin, isEntryLoaded } from '../services/loader.ts'
22
22
  import { loadSettings, saveSettings, resetSettings, type HubSettings } from '../services/settings.ts'
23
- import { appendLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.ts'
23
+ import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.ts'
24
+
25
+ /**
26
+ * 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
27
+ * 取代原先的 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
28
+ * 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
29
+ * 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
30
+ */
31
+ const HOST_RESTART_SCRIPT = String.raw`const C = require('node:child_process')
32
+ const F = require('node:fs')
33
+ const P = require('node:path')
34
+ const O = require('node:os')
35
+ const win = process.platform === 'win32'
36
+ const port = Number(process.argv[1])
37
+
38
+ function findPids() {
39
+ if (win) {
40
+ const out = C.spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
41
+ const found = []
42
+ for (const line of String(out.stdout || '').split(/\r?\n/)) {
43
+ if (!/LISTENING/i.test(line)) continue
44
+ const parts = line.trim().split(/\s+/)
45
+ const addr = parts[1] || ''
46
+ if (!addr.endsWith(':' + port)) continue
47
+ const pid = parts[parts.length - 1]
48
+ if (pid && /\d+/.test(pid)) found.push(pid)
49
+ }
50
+ return found
51
+ }
52
+ const out = C.spawnSync('lsof', ['-ti', 'tcp:' + port, '-sTCP:LISTEN'], { encoding: 'utf8' })
53
+ return String(out.stdout || '').split(/\s+/).filter(Boolean)
54
+ }
55
+
56
+ for (const pid of findPids()) {
57
+ try {
58
+ if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
59
+ else process.kill(Number(pid), 'SIGTERM')
60
+ } catch {}
61
+ }
62
+
63
+ let tries = 0
64
+ ;(function waitForStop() {
65
+ if (findPids().length === 0 || ++tries > 20) return startHost()
66
+ setTimeout(waitForStop, 250)
67
+ })()
68
+
69
+ function startHost() {
70
+ const dir = P.join(O.homedir(), '.dsh', 'logs')
71
+ F.mkdirSync(dir, { recursive: true })
72
+ const fd = F.openSync(P.join(dir, 'dsh-web-' + port + '.log'), 'a')
73
+ const child = C.spawn('dsh', ['web', '--port', String(port)], {
74
+ detached: true,
75
+ shell: win,
76
+ stdio: ['ignore', fd, fd],
77
+ })
78
+ child.unref()
79
+ }`
24
80
 
25
81
  export interface WebRoute {
26
82
  kind: 'exact'
@@ -325,6 +381,25 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
325
381
  }
326
382
  },
327
383
  }),
384
+ webServer.register({
385
+ kind: 'exact',
386
+ path: '/dsh-plugin-hub/clear-log',
387
+ handler: (request, response) => {
388
+ if (!requireTrustedPost(request, response)) return
389
+ // 清空所有日志:截断当前日志文件为空
390
+ try {
391
+ const ok = clearLog(profile)
392
+ if (!ok) {
393
+ sendJson(response, 500, { error: 'failed to clear log' })
394
+ return
395
+ }
396
+ appendLog(profile, { at: Date.now(), level: 'info', category: 'system', event: 'system.clear', message: '日志已由用户清空' })
397
+ sendJson(response, 200, { ok: true })
398
+ } catch {
399
+ sendJson(response, 500, { error: 'failed to clear log' })
400
+ }
401
+ },
402
+ }),
328
403
  webServer.register({
329
404
  kind: 'exact',
330
405
  path: '/dsh-plugin-hub/open-log',
@@ -773,16 +848,12 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
773
848
  const host = request.headers.host ?? ''
774
849
  const portMatch = host.match(/:(\d+)$/)
775
850
  const port = portMatch ? Number(portMatch[1]) : 7923
776
- // detached 子 shell 完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
777
- // 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行
778
- const script = [
779
- `pids=$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)`,
780
- '[ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true',
781
- `i=0; while [ -n "$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)" ] && [ $i -lt 20 ]; do sleep 0.25; i=$((i + 1)); done`,
782
- 'mkdir -p "$HOME/.dsh/logs"',
783
- `nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
784
- ].join('\n')
785
- spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref()
851
+ // detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
852
+ // 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
853
+ // 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
854
+ // 故孵化 node(process.execPath) 跑跨平台脚本 —— Windows 用
855
+ // netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
856
+ spawn(process.execPath, ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref()
786
857
  sendJson(response, 200, { ok: true, port })
787
858
  },
788
859
  }),
@@ -23,7 +23,8 @@ export const NPM_MIN_VERSION = [11, 6, 0]
23
23
  /** 读本机 npm 版本(major.minor.patch);npm 不在 PATH 或执行失败/超时时返回 null(不妄下结论)。 */
24
24
  export function npmVersionOf(env: NodeJS.ProcessEnv | undefined): [number, number, number] | null {
25
25
  try {
26
- const res = spawnSync('npm', ['-v'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
26
+ // Windows 下 npm 是 npm.cmd:spawn 直接 exec 会拒绝,须 shell:true 解析(与 task-queue 的 useShell 一致)
27
+ const res = spawnSync('npm', ['-v'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' })
27
28
  if (res.error !== undefined || res.status !== 0) return null
28
29
  const m = /(\d+)\.(\d+)\.(\d+)/.exec(res.stdout ?? '')
29
30
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
@@ -105,6 +105,18 @@ export function appendLog(profile: string, entry: LogEntry): void {
105
105
  }
106
106
  }
107
107
 
108
+ /** 清空系统日志:把当前日志文件截断为空。失败返回 false(不阻断调用方)。 */
109
+ export function clearLog(profile: string): boolean {
110
+ try {
111
+ const file = logFilePath(profile)
112
+ mkdirSync(dirname(file), { recursive: true })
113
+ writeFileSync(file, '')
114
+ return true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
108
120
  /** 兼容旧格式:早期无 category(level 用 info/ok/fail/warn),归一化到新版。 */
109
121
  function normalize(raw: unknown): LogEntry | null {
110
122
  if (typeof raw !== 'object' || raw === null) return null