@gehennawu/dsh-service 0.15.0 → 0.17.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 CHANGED
@@ -14,19 +14,22 @@ The Settings panel "Service Control" page has six top-level tabs: **Overview, No
14
14
 
15
15
  - Displays current DSH and plugin versions with links to GitHub Releases
16
16
  - Automatically checks npm registry for stable and preview releases; when a new version exists, the right-side status text (chevron + `New version`) is clickable to expand/collapse, showing the current/latest versions and both dist-tag versions inline, each with npmjs and npmmirror links (npmjs.com is blocked on some networks; npmmirror serves as the mirror entry)
17
- - One-click plugin upgrade with automatic restart after completion
17
+ - One-click plugin upgrade with automatic restart after completion; when no process manager is detected (for example, a manual launch from a Windows terminal), the upgrade first confirms the consequences, then keeps the process running and shows manual-restart instructions
18
18
 
19
19
  ### Safe restart
20
20
 
21
21
  - Detects active agents, background jobs, and terminals before restart; lists them and requires explicit confirmation
22
22
  - `/restart` command also available in conversations; automatically refuses when active work is detected
23
23
  - Automatically probes for the new process after restart and reloads the page; manual reload available after 60 seconds
24
+ - Overview shows the runtime environment; when a manual terminal launch is suspected, the confirmation flow warns that nothing will bring the process back
24
25
  - Optional `Restart` entry at the bottom of the settings left navigation, enabled by a switch in the Restart tab (off by default), sharing the exact same confirmation flow as the Restart tab
25
26
 
26
27
  ### Health diagnostics
27
28
 
28
29
  - Shows uptime, memory, session count, active agents, and background jobs
29
- - Full diagnostics check session storage, workspace registry, backup storage, tar availability, and file permissions
30
+ - The "Process and runtime" card shows platform, architecture, and Node version
31
+ - Full diagnostics check session storage, workspace registry, backup storage, tar availability, file permissions, runtime environment, and Node runtime version; a manual launch is flagged as a warning (no restart assurance), while an unrecognized environment is informational only (not a warning) — both can be declared explicitly via `DSH_SERVICE_RUNTIME_ENV`
32
+ - Having no backups is an informational note, not a warning, and does not light the Health tab ⚠
30
33
  - File-permission deep scan and repair: checks whether the Agent can read/write DSH_HOME and workspaces; repair requires two-step confirmation
31
34
 
32
35
  ### Model statistics
@@ -90,6 +93,8 @@ dsh plugin --profile web add link:/path/to/dsh-service
90
93
 
91
94
  The plugin only sends an exit signal; it does not start the process again. Without a process manager, selecting restart stops DSH Web.
92
95
 
96
+ The plugin passively detects whether a process manager is present (environment variables, `/.dockerenv`, `/proc/1/cgroup`, terminal TTY): with Docker/systemd/pm2/supervisord/Kubernetes detected it restarts as usual; when nothing is detected and stdin/stdout are an interactive terminal, it treats the environment as a likely manual launch, labels the runtime environment in the overview, and switches one-click upgrade to keep the process running with manual-restart instructions. Heuristics cannot cover redirected output or wrappers like NSSM/WinSW; set `DSH_SERVICE_RUNTIME_ENV=managed|manual` to declare it explicitly.
97
+
93
98
  ### Docker Compose
94
99
 
95
100
  ```yaml
@@ -122,6 +127,8 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
122
127
  | macOS / Windows + pm2 or similar | Not blocked by the code | Managed externally | Not tested |
123
128
  | Direct `dsh web` execution | Supported | Not supported | Expected behavior |
124
129
 
130
+ When launched directly from a terminal (PowerShell/CMD/bash), the panel labels the environment as a likely manual launch and the one-click upgrade no longer exits the process automatically.
131
+
125
132
  Requirements: Node.js `>=22`, and a DSH Web installation capable of loading both Host and Client plugin halves. Update checks require access to `registry.npmjs.org`; network failures do not affect other features.
126
133
 
127
134
  ## Security design
package/README.md CHANGED
@@ -14,19 +14,22 @@
14
14
 
15
15
  - 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
16
16
  - 自动检查 npm registry 的正式版和预览版;有新版本时右侧「小三角 + 有新版本」状态文本可点击展开/收起,行内展示当前/最新版本与正式版/预览版双 tag,版本号分别带 npmjs 与 npmmirror 链接(部分网络会被 npmjs.com 拦截,npmmirror 作为镜像入口)
17
- - 一键升级插件,升级后自动重启
17
+ - 一键升级插件,升级后自动重启;未检测到进程管理器(如在 Windows 终端手动启动)时,升级前先确认后果,安装完成后保持运行并提示手动重启
18
18
 
19
19
  ### 安全重启
20
20
 
21
21
  - 重启前检测活跃 Agent、后台任务和终端,展示清单并要求显式确认
22
22
  - 对话中输入 `/restart` 也可触发,检测到运行中工作时自动拒绝
23
23
  - 重启后自动探测新进程并刷新页面,60 秒未恢复时提供手动刷新
24
+ - 概览显示「运行环境」;疑似终端手动启动时,确认流程会提示「退出后不会自动拉起」
24
25
  - 可在「重启」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「重启」快捷入口,与「重启」标签共用同一套确认流程
25
26
 
26
27
  ### 健康诊断
27
28
 
28
29
  - 显示运行时间、内存、会话数、活跃 Agent 和后台任务
29
- - 完整诊断检查会话存储、工作区注册表、备份目录、tar 可用性和文件权限
30
+ - 「进程与运行环境」卡显示平台、架构和 Node 版本
31
+ - 完整诊断检查会话存储、工作区注册表、备份目录、tar 可用性、文件权限、运行环境和 Node 运行时版本;手动启动环境按警告提示重启无保障,未识别环境为信息级提示(不算警告),均可通过 `DSH_SERVICE_RUNTIME_ENV` 显式声明
32
+ - 没有备份属于信息级提示,不算警告,也不点亮健康诊断标签的 ⚠
30
33
  - 文件权限深检与修复:检查 Agent 是否能读写 DSH_HOME 和工作区,修复需两段式确认
31
34
 
32
35
  ### 模型统计
@@ -90,6 +93,8 @@ dsh plugin --profile web add link:/path/to/dsh-service
90
93
 
91
94
  插件只发送退出信号,不负责重新启动进程。没有进程管理器时,点击重启会直接停止 DSH Web。
92
95
 
96
+ 插件会用被动信号(环境变量、`/.dockerenv`、`/proc/1/cgroup`、终端 TTY)判断当前是否由进程管理器拉起:检测到 Docker/systemd/pm2/supervisord/Kubernetes 时照常自动重启;都没有且 stdin/stdout 是交互终端时视为「疑似手动启动」,概览会标注运行环境,一键升级改为「不自动退出 + 提示手动重启」。启发式无法覆盖输出重定向、NSSM/WinSW 等场景,可用环境变量 `DSH_SERVICE_RUNTIME_ENV=managed|manual` 显式声明。
97
+
93
98
  ### Docker Compose
94
99
 
95
100
  ```yaml
@@ -122,6 +127,8 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
122
127
  | macOS / Windows + pm2 等 | 代码未限制 | 由进程管理器负责 | 未验证 |
123
128
  | 直接运行 `dsh web` | 支持 | 不支持 | 预期行为 |
124
129
 
130
+ 直接在终端运行(PowerShell/CMD/bash)时,面板会标注「疑似终端手动启动」,一键升级不再自动退出进程。
131
+
125
132
  运行要求:Node.js `>=22`,DSH Web 能加载 Host 和 Client 两半插件。检查更新需要访问 `registry.npmjs.org`;网络失败不影响其他功能。
126
133
 
127
134
  ## 安全设计
package/client.js CHANGED
@@ -20,6 +20,8 @@ window.__ModuleLoader__.load({
20
20
  'health.title': '运行状况',
21
21
  'health.uptime': '运行时间',
22
22
  'health.rss': '内存 RSS',
23
+ 'health.platform': '平台',
24
+ 'health.nodeVersion': 'Node 版本',
23
25
  'health.liveSessions': '存活会话',
24
26
  'health.persistedSessions': '持久化会话',
25
27
  'health.activeAgents': '活跃 Agent',
@@ -43,6 +45,14 @@ window.__ModuleLoader__.load({
43
45
  'health.check.backup-storage': '备份存储',
44
46
  'health.check.tar': 'tar',
45
47
  'health.check.permissions': '文件权限',
48
+ 'health.check.runtime-env': '运行环境',
49
+ 'health.check.node-version': 'Node 运行时',
50
+ 'health.detail.runtime-env.managed': '由{kind}管理,重启后会自动拉起',
51
+ 'health.detail.runtime-env.declared': '已通过 DSH_SERVICE_RUNTIME_ENV 声明由外部进程管理器管理',
52
+ 'health.detail.runtime-env.manual': '疑似终端手动启动,重启后不会自动拉起;一键升级已改为不自动退出',
53
+ 'health.detail.runtime-env.unknown': '未检测到进程管理器,无法确认重启后是否自动拉起;如实际有,可设置 DSH_SERVICE_RUNTIME_ENV=managed 声明',
54
+ 'health.detail.node-version.ok': '{version},满足 ≥{required} 要求',
55
+ 'health.detail.node-version.warning': '{version} 低于插件要求的 {required}.x,建议升级 Node',
46
56
  'health.detail.session-storage.ok': '可用,共 {count} 个会话快照',
47
57
  'health.detail.workspace-registry.ok': '可用,共 {count} 个工作区',
48
58
  'health.detail.dsh-home.ok': '目录可访问,权限模式 {mode}',
@@ -56,7 +66,7 @@ window.__ModuleLoader__.load({
56
66
  'tabs.notify': '通知',
57
67
  'tabs.health': '健康诊断',
58
68
  'tabs.usage': '模型统计',
59
- 'overview.container': '容器信息',
69
+ 'overview.container': '进程与运行环境',
60
70
  'overview.errors': '报错信息',
61
71
  'tabs.backup': '备份维护',
62
72
  'tabs.restart': '重启',
@@ -141,6 +151,22 @@ window.__ModuleLoader__.load({
141
151
  'update.failAddingToRoot': 'pnpm 拒绝在 workspace 根目录安装(缺少 -w)',
142
152
  'update.failNotWorkspace': 'profile 不是 pnpm workspace 却传入了 -w',
143
153
  'update.failIgnoredBuilds': '依赖构建脚本被 pnpm 默认拦截,无法完成升级',
154
+ 'env.line': '运行环境:{value}',
155
+ 'env.kind.docker': 'Docker 容器',
156
+ 'env.kind.container': '容器(containerd/Kubernetes)',
157
+ 'env.kind.systemd': 'systemd 服务',
158
+ 'env.kind.pm2': 'pm2',
159
+ 'env.kind.supervisord': 'supervisord',
160
+ 'env.kind.kubernetes': 'Kubernetes',
161
+ 'env.kind.declared': '外部进程管理器(手动指定)',
162
+ 'env.kind.unrecognized': '检测到未识别的进程管理器',
163
+ 'env.likelyManual': '疑似终端手动启动',
164
+ 'env.unknown': '未检测到进程管理器',
165
+ 'update.manualConfirmTitle': '升级前请确认:当前疑似手动启动环境',
166
+ 'update.manualConfirm': '未检测到 Docker/systemd/pm2 等进程管理器。升级完成后 DSH 不会自动重启——新版本写入磁盘,但当前进程继续运行旧版本;你需要手动关闭并重新运行 dsh 才能启用新版本。',
167
+ 'update.manualProceed': '仍要升级',
168
+ 'update.manualRestartTitle': '升级完成,需要手动重启',
169
+ 'update.manualRestartBody': '新版本已安装,但当前进程仍在运行旧版本。请在运行 dsh 的终端窗口按 Ctrl+C(或直接关闭窗口),然后重新启动 dsh。',
144
170
  'restart.title': '服务重启',
145
171
  'restart.description': '重启 dsh web 进程。运行中的工作会中断,持久化会话可恢复。也可在对话中输入 /restart。',
146
172
  'restart.button': '重启 dsh web',
@@ -151,6 +177,8 @@ window.__ModuleLoader__.load({
151
177
  'restart.sent': '重启指令已发出。',
152
178
  'restart.sentHint': '页面连接即将断开,服务恢复后将自动刷新。',
153
179
  'restart.idleHint': '当前没有检测到运行中的工作。确认后将断开连接,等待服务自动重启。',
180
+ 'restart.manualWarn': '当前疑似终端手动启动环境:确认后进程将退出且不会自动拉起,需要你手动重新运行 dsh。',
181
+ 'restart.sentManualHint': '当前为手动启动环境,服务不会自动拉起。请在原终端重新运行 dsh;服务起来后刷新此页面即可恢复。',
154
182
  'restart.navToggle': '设置页左列显示「重启」入口',
155
183
  'restart.navToggleHint': '默认关闭;开启后在设置页左侧标签列底部显示快捷重启入口',
156
184
  'activity.agent': 'Agent',
@@ -228,6 +256,8 @@ window.__ModuleLoader__.load({
228
256
  'health.title': 'Health',
229
257
  'health.uptime': 'Uptime',
230
258
  'health.rss': 'Memory RSS',
259
+ 'health.platform': 'Platform',
260
+ 'health.nodeVersion': 'Node version',
231
261
  'health.liveSessions': 'Live sessions',
232
262
  'health.persistedSessions': 'Persisted sessions',
233
263
  'health.activeAgents': 'Active agents',
@@ -251,6 +281,14 @@ window.__ModuleLoader__.load({
251
281
  'health.check.backup-storage': 'Backup storage',
252
282
  'health.check.tar': 'tar',
253
283
  'health.check.permissions': 'File permissions',
284
+ 'health.check.runtime-env': 'Runtime environment',
285
+ 'health.check.node-version': 'Node runtime',
286
+ 'health.detail.runtime-env.managed': 'Managed by {kind}; the process restarts automatically after exit',
287
+ 'health.detail.runtime-env.declared': 'Declared externally managed via DSH_SERVICE_RUNTIME_ENV',
288
+ 'health.detail.runtime-env.manual': 'Likely a manual terminal launch — nothing restarts the process after exit; one-click upgrade already keeps it running',
289
+ 'health.detail.runtime-env.unknown': 'No process manager detected, so automatic restart cannot be confirmed; set DSH_SERVICE_RUNTIME_ENV=managed if one exists',
290
+ 'health.detail.node-version.ok': '{version}, meets the >={required} requirement',
291
+ 'health.detail.node-version.warning': '{version} is below the required {required}.x; upgrading Node is recommended',
254
292
  'health.detail.session-storage.ok': 'Available, with {count} session snapshots',
255
293
  'health.detail.workspace-registry.ok': 'Available, with {count} workspaces',
256
294
  'health.detail.dsh-home.ok': 'Directory is accessible with mode {mode}',
@@ -264,7 +302,7 @@ window.__ModuleLoader__.load({
264
302
  'tabs.notify': 'Notifications',
265
303
  'tabs.health': 'Health',
266
304
  'tabs.usage': 'Models',
267
- 'overview.container': 'Container info',
305
+ 'overview.container': 'Process and runtime',
268
306
  'overview.errors': 'Errors',
269
307
  'tabs.backup': 'Backup',
270
308
  'tabs.restart': 'Restart',
@@ -349,6 +387,22 @@ window.__ModuleLoader__.load({
349
387
  'update.failAddingToRoot': 'pnpm refused to add at the workspace root (missing -w)',
350
388
  'update.failNotWorkspace': '-w was passed but the profile is not a pnpm workspace',
351
389
  'update.failIgnoredBuilds': 'Dependency build scripts are blocked by pnpm by default, so the upgrade could not finish',
390
+ 'env.line': 'Runtime environment: {value}',
391
+ 'env.kind.docker': 'Docker container',
392
+ 'env.kind.container': 'Container (containerd/Kubernetes)',
393
+ 'env.kind.systemd': 'systemd service',
394
+ 'env.kind.pm2': 'pm2',
395
+ 'env.kind.supervisord': 'supervisord',
396
+ 'env.kind.kubernetes': 'Kubernetes',
397
+ 'env.kind.declared': 'External process manager (declared manually)',
398
+ 'env.kind.unrecognized': 'Unrecognized process manager detected',
399
+ 'env.likelyManual': 'Likely launched manually from a terminal',
400
+ 'env.unknown': 'No process manager detected',
401
+ 'update.manualConfirmTitle': 'Confirm before upgrading: manual launch suspected',
402
+ 'update.manualConfirm': 'No process manager such as Docker/systemd/pm2 was detected. After the upgrade DSH will NOT restart automatically — the new version is written to disk while the current process keeps running the old one; close and rerun dsh manually to activate it.',
403
+ 'update.manualProceed': 'Upgrade anyway',
404
+ 'update.manualRestartTitle': 'Upgrade finished — manual restart required',
405
+ 'update.manualRestartBody': 'The new version is installed, but the current process still runs the old one. Press Ctrl+C in the terminal running dsh (or simply close the window), then start dsh again.',
352
406
  'restart.title': 'Service restart',
353
407
  'restart.description': 'Restart the dsh web process. Active work will be interrupted; persisted sessions can be resumed. You can also type /restart in a conversation.',
354
408
  'restart.button': 'Restart dsh web',
@@ -359,6 +413,8 @@ window.__ModuleLoader__.load({
359
413
  'restart.sent': 'Restart request sent.',
360
414
  'restart.sentHint': 'The connection will close shortly. The page will reload automatically after recovery.',
361
415
  'restart.idleHint': 'No active work was detected. Confirm to disconnect and wait for the service to restart.',
416
+ 'restart.manualWarn': 'This looks like a manual terminal launch: once confirmed the process exits and nothing restarts it — you must run dsh again yourself.',
417
+ 'restart.sentManualHint': 'This is a manual-launch environment, so the service will not come back on its own. Run dsh again in the original terminal; refresh this page once the service is up.',
362
418
  'restart.navToggle': 'Show "Restart" entry in settings left nav',
363
419
  'restart.navToggleHint': 'Off by default; when enabled, a quick-restart entry appears at the bottom of the settings left navigation',
364
420
  'activity.agent': 'Agent',
@@ -607,6 +663,53 @@ window.__ModuleLoader__.load({
607
663
  }, [])
608
664
  return snapshot
609
665
  }
666
+
667
+ // 运行环境(version RPC 随 instanceId 返回):概览展示、升级前置确认与重启警告共用。
668
+ // null = 宿主未返回该字段(旧版本)或尚未拉取,一切行为静默退回现状。
669
+ const runtimeEnvListeners = new Set()
670
+ let runtimeEnvState = null
671
+ const setRuntimeEnvState = (next) => {
672
+ runtimeEnvState = next
673
+ for (const listener of runtimeEnvListeners) listener(next)
674
+ }
675
+ const useRuntimeEnv = () => {
676
+ const [snapshot, setSnapshot] = useState(runtimeEnvState)
677
+ useEffect(() => {
678
+ runtimeEnvListeners.add(setSnapshot)
679
+ setSnapshot(runtimeEnvState)
680
+ return () => runtimeEnvListeners.delete(setSnapshot)
681
+ }, [])
682
+ return snapshot
683
+ }
684
+ // 宿主字段按形状校验后才入库:manualStartLikely 必须 boolean、supervisorKind 缺省或 string,
685
+ // 形状不对按「旧宿主无该字段」降级,绝不让坏值悄悄关掉确认门。
686
+ const applyVersionRuntimeEnv = (value) => {
687
+ const env = value ? value.runtimeEnv : undefined
688
+ if (env === null || typeof env !== 'object') return
689
+ if (typeof env.manualStartLikely !== 'boolean') return
690
+ if (env.supervisorKind !== undefined && env.supervisorKind !== null && typeof env.supervisorKind !== 'string') return
691
+ setRuntimeEnvState({ platform: typeof env.platform === 'string' ? env.platform : '', supervisorKind: env.supervisorKind === undefined || env.supervisorKind === null ? null : env.supervisorKind, manualStartLikely: env.manualStartLikely })
692
+ }
693
+ // version 快照全插件只取一次:ServicePanel 与左列入口无论谁先挂载都共享同一请求,
694
+ // 升级前取 instanceId/运行环境也复用它。失败清缓存,下一个消费者重试。
695
+ let versionSnapshotPromise = null
696
+ const fetchVersionSnapshot = () => {
697
+ if (versionSnapshotPromise === null) {
698
+ versionSnapshotPromise = ctx.connection.rpc.call('/dsh-service', 'version', {})
699
+ .then((res) => {
700
+ if (res && res.ok) applyVersionRuntimeEnv(res.value)
701
+ return res
702
+ })
703
+ .catch(() => {
704
+ versionSnapshotPromise = null
705
+ return null
706
+ })
707
+ }
708
+ return versionSnapshotPromise
709
+ }
710
+ // 升级执行中标志放在 factory 作用域:闭包状态挡不住同一 tick 的重入,跨渲染的新闭包
711
+ // 也各自持有独立的 false,只有插件级可变标志能同时覆盖两种情况。
712
+ let upgradeInFlight = false
610
713
  const checkRestart = async () => {
611
714
  setRestartFlow({ ...restartFlow, busy: true, error: null })
612
715
  try {
@@ -692,6 +795,7 @@ window.__ModuleLoader__.load({
692
795
  recoveryGeneration += 1
693
796
  recoveryListeners.clear()
694
797
  restartFlowListeners.clear()
798
+ runtimeEnvListeners.clear()
695
799
  updateListeners.clear()
696
800
  }, 'dsh-service recovery')
697
801
 
@@ -797,6 +901,11 @@ window.__ModuleLoader__.load({
797
901
  function RestartSection({ showNavToggle }) {
798
902
  const translate = useTranslation()
799
903
  const flow = useRestartFlow()
904
+ const runtimeEnv = useRuntimeEnv()
905
+ // 左列专属入口可能先于「服务控制」面板挂载:走共享 version 快照(与面板同一次请求)。
906
+ useEffect(() => {
907
+ fetchVersionSnapshot()
908
+ }, [])
800
909
  const [navEnabled, setNavEnabled] = useRestartNavEnabled()
801
910
  const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
802
911
  const danger = { ...btn, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', borderColor: 'var(--dsw-alias-state-error-primary)' }
@@ -807,12 +916,12 @@ window.__ModuleLoader__.load({
807
916
  const displaySurface = { background: 'var(--dsh-svc-surface-bg)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', padding: '10px' }
808
917
  const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
809
918
 
810
- // 重启后提示
919
+ // 重启后提示:手动启动环境不会自动拉起,等待文案换成手动指引。
811
920
  if (flow.stage === 2) {
812
921
  return React.createElement('div', { 'data-testid': 'restart-card', style: card },
813
922
  React.createElement('div', { style: sectionTitle }, translate('restart.title')),
814
923
  React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
815
- React.createElement('p', { style: hint }, translate('restart.sentHint')))
924
+ React.createElement('p', { style: hint }, translate(runtimeEnv !== null && runtimeEnv.manualStartLikely === true ? 'restart.sentManualHint' : 'restart.sentHint')))
816
925
  }
817
926
 
818
927
  const activityLabels = {
@@ -821,6 +930,10 @@ window.__ModuleLoader__.load({
821
930
  terminal: translate('activity.terminal'),
822
931
  }
823
932
  const activityItems = flow.activity && Array.isArray(flow.activity.items) ? flow.activity.items : []
933
+ // 疑似终端手动启动:确认前就把「退出后无人拉起」讲清楚,两段式确认的后果清单。
934
+ const manualWarn = runtimeEnv !== null && runtimeEnv.manualStartLikely === true
935
+ ? React.createElement('p', { 'data-testid': 'restart-manual-warn', style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-warn-primary)', margin: '6px 0 0' }) }, translate('restart.manualWarn'))
936
+ : null
824
937
  const activityWarning = flow.stage === 3
825
938
  ? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
826
939
  React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
@@ -840,6 +953,7 @@ window.__ModuleLoader__.load({
840
953
  React.createElement('div', { style: sectionTitle }, translate('restart.title')),
841
954
  React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
842
955
  React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
956
+ manualWarn,
843
957
  activityWarning,
844
958
  React.createElement('div', { style: row },
845
959
  flow.stage === 0
@@ -906,6 +1020,9 @@ window.__ModuleLoader__.load({
906
1020
  const [usageError, setUsageError] = useState(null)
907
1021
  const [upgradeBusy, setUpgradeBusy] = useState(false)
908
1022
  const [upgradeError, setUpgradeError] = useState(null)
1023
+ // 疑似手动启动环境的升级两段式:确认后果 → 仍要升级;成功后不自动退出,改示指引。
1024
+ const [upgradeManualConfirm, setUpgradeManualConfirm] = useState(false)
1025
+ const [upgradeManualPending, setUpgradeManualPending] = useState(false)
909
1026
  const [hoveredUsageSegment, setHoveredUsageSegment] = useState(null)
910
1027
  const [usageProject, setUsageProject] = useState('all')
911
1028
  const [modelErrorsOpen, setModelErrorsOpen] = useState(false)
@@ -916,16 +1033,18 @@ window.__ModuleLoader__.load({
916
1033
  const [channelOpen, setChannelOpen] = useState(false)
917
1034
  // 重启流程状态来自共享流(与设置页左列底部的专属入口同源)
918
1035
  const restartFlowState = useRestartFlow()
1036
+ const runtimeEnv = useRuntimeEnv()
919
1037
  const usageRequestPayload = { timezoneOffsetMinutes: new Date().getTimezoneOffset() }
920
1038
 
921
1039
  // 进入面板时拉取当前版本和健康快照;健康数据每 5 秒刷新,卸载即停止。
1040
+ // version 走全插件共享的缓存快照:无论哪个挂载点先到,都只有一次请求。
922
1041
  useEffect(() => {
923
- ctx.connection.rpc.call('/dsh-service', 'version', {}).then((res) => {
1042
+ fetchVersionSnapshot().then((res) => {
924
1043
  if (res && res.ok) {
925
1044
  setVersion(res.value.current)
926
1045
  setPluginVersion(res.value.pluginVersion || null)
927
1046
  }
928
- }).catch(() => {})
1047
+ })
929
1048
  }, [])
930
1049
  useEffect(() => {
931
1050
  let active = true
@@ -1173,10 +1292,25 @@ window.__ModuleLoader__.load({
1173
1292
  'ignored-builds': 'update.failIgnoredBuilds',
1174
1293
  }
1175
1294
  const upgradePlugin = async () => {
1176
- setUpgradeBusy(true)
1177
- setUpgradeError(null)
1295
+ if (upgradeInFlight) return
1296
+ upgradeInFlight = true
1178
1297
  try {
1179
- const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
1298
+ // check-update 先于 version 返回时按钮可能先出现:env 未知就等共享快照落地再判。
1299
+ let env = runtimeEnv
1300
+ if (env === null) {
1301
+ await fetchVersionSnapshot()
1302
+ env = runtimeEnvState
1303
+ }
1304
+ // 疑似终端手动启动:升级成功不会自动重启,先两段式确认后果再动手(安全教义)。
1305
+ if (!upgradeManualConfirm && env !== null && env.manualStartLikely === true) {
1306
+ setUpgradeError(null)
1307
+ setUpgradeManualConfirm(true)
1308
+ return
1309
+ }
1310
+ setUpgradeManualConfirm(false)
1311
+ setUpgradeBusy(true)
1312
+ setUpgradeError(null)
1313
+ const versionRes = await fetchVersionSnapshot()
1180
1314
  const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
1181
1315
  const res = await ctx.connection.rpc.call('/dsh-service', 'upgrade', {})
1182
1316
  if (!res || res.ok === false) {
@@ -1190,6 +1324,11 @@ window.__ModuleLoader__.load({
1190
1324
  }
1191
1325
  return
1192
1326
  }
1327
+ if (res.value && res.value.requiresManualRestart === true) {
1328
+ // 宿主保持运行(没有 exit,就不会有新实例):不启动恢复轮询,改示手动重启指引。
1329
+ setUpgradeManualPending(true)
1330
+ return
1331
+ }
1193
1332
  if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
1194
1333
  startRecovery(previousInstanceId).catch(() => {})
1195
1334
  }
@@ -1198,6 +1337,7 @@ window.__ModuleLoader__.load({
1198
1337
  console.error('dsh-service: upgrade failed', detail || err)
1199
1338
  setUpgradeError(detail ? translate('update.upgradeErrorDetail', { detail }) : translate('update.upgradeError'))
1200
1339
  } finally {
1340
+ upgradeInFlight = false
1201
1341
  setUpgradeBusy(false)
1202
1342
  }
1203
1343
  }
@@ -1338,6 +1478,17 @@ window.__ModuleLoader__.load({
1338
1478
  }
1339
1479
  if (check.id === 'tar' && check.status === 'ok') return translate('health.detail.tar.ok')
1340
1480
  if (check.id === 'permissions') return translate(check.status === 'ok' ? 'health.detail.permissions.ok' : 'health.detail.permissions.warning', { count: detail || '0' })
1481
+ if (check.id === 'runtime-env') {
1482
+ if (detail === 'manual') return translate('health.detail.runtime-env.manual')
1483
+ if (detail === 'declared') return translate('health.detail.runtime-env.declared')
1484
+ if (detail === 'unknown') return translate('health.detail.runtime-env.unknown')
1485
+ // 已识别的管理器 kind:复用概览运行环境的标签词典。
1486
+ return translate('health.detail.runtime-env.managed', { kind: translate(`env.kind.${detail}`) })
1487
+ }
1488
+ if (check.id === 'node-version') {
1489
+ const [version = '', required = ''] = detail.split(':')
1490
+ return translate(check.status === 'ok' ? 'health.detail.node-version.ok' : 'health.detail.node-version.warning', { version, required })
1491
+ }
1341
1492
  return translate('health.detail.generic', { status: translate(`health.status.${check.status}`) })
1342
1493
  }
1343
1494
  const summaryItems = (totals) => [
@@ -1440,10 +1591,17 @@ window.__ModuleLoader__.load({
1440
1591
  : React.createElement('p', { style: hint }, usageError || translate('usage.empty')),
1441
1592
  React.createElement('div', { style: row }, React.createElement('button', { style: neutral, 'data-variant': 'neutral', onClick: refreshUsage, disabled: usageBusy }, translate(usageBusy ? 'usage.refreshing' : 'usage.refresh'))))
1442
1593
 
1594
+ // 平台标签:process.platform 映射为常见系统名,arch 跟在后面(均为专有名词,不走词典)。
1595
+ const platformNames = { win32: 'Windows', darwin: 'macOS', linux: 'Linux', freebsd: 'FreeBSD', openbsd: 'OpenBSD' }
1596
+ const platformLabel = (health && typeof health.platform === 'string' && health.platform)
1597
+ ? `${platformNames[health.platform] || health.platform}${typeof health.arch === 'string' && health.arch ? ` · ${health.arch}` : ''}`
1598
+ : '—'
1443
1599
  const containerInfoBlock = React.createElement('div', { key: 'container-info', style: { marginTop: '18px' } },
1444
1600
  React.createElement('div', { style: sectionTitle }, translate('overview.container')),
1445
1601
  health
1446
1602
  ? React.createElement('div', { 'data-testid': 'health-display', style: Object.assign({}, displaySurface, { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '8px' }) },
1603
+ metric('health.platform', platformLabel),
1604
+ metric('health.nodeVersion', typeof health.nodeVersion === 'string' && health.nodeVersion ? health.nodeVersion : '—'),
1447
1605
  metric('health.uptime', formatUptime(health.uptimeSeconds)),
1448
1606
  metric('health.rss', formatBytes(health.rssBytes)),
1449
1607
  metric('health.liveSessions', String(health.liveSessions)),
@@ -1473,7 +1631,7 @@ window.__ModuleLoader__.load({
1473
1631
  ? React.createElement('div', { 'data-testid': 'health-check-list', style: Object.assign({}, displaySurface, { marginTop: '10px', padding: '8px 10px' }) },
1474
1632
  diagnostics.checks.map((check, index) => React.createElement('div', { key: check.id, style: { display: 'flex', justifyContent: 'space-between', gap: '14px', fontSize: '12px', padding: '9px 2px', borderTop: index === 0 ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1475
1633
  React.createElement('span', null, translate(`health.check.${check.id}`)),
1476
- React.createElement('span', { style: { color: check.status === 'ok' ? 'var(--dsw-alias-state-success-primary)' : check.status === 'warning' ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)', textAlign: 'right' } }, diagnosticDetail(check)))))
1634
+ React.createElement('span', { style: { color: check.status === 'ok' ? 'var(--dsw-alias-state-success-primary)' : check.status === 'warning' ? 'var(--dsw-alias-state-warn-primary)' : check.status === 'info' ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-state-error-primary)', textAlign: 'right' } }, diagnosticDetail(check)))))
1477
1635
  : null)
1478
1636
 
1479
1637
  const permissionAbnormal = permissions && permissions.supported === true
@@ -1602,9 +1760,22 @@ window.__ModuleLoader__.load({
1602
1760
  const dshUpdate = updateInfo?.dsh
1603
1761
  const dshExpandable = dshUpdate && dshUpdate.status !== 'unpublished' && dshUpdate.status !== 'unavailable' && !dshUpdate.upToDate
1604
1762
  const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
1605
- const pluginAction = pluginUpdate
1763
+ // 确认后果或已装好待手动重启期间收起升级按钮,避免重复触发或撞 no-newer-version 守卫。
1764
+ const pluginAction = pluginUpdate && !upgradeManualConfirm && !upgradeManualPending
1606
1765
  ? React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '24px', padding: '2px 8px', fontSize: '11px' }), disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade'))
1607
1766
  : null
1767
+ // 运行环境一行:managed 显示管理器标签;疑似手动启动用警示色;未知非空 kind 不得
1768
+ // 显示成「未检测到」(那是 supervisorKind 为 null 时的语义)。
1769
+ const runtimeEnvKinds = ['docker', 'container', 'systemd', 'pm2', 'supervisord', 'kubernetes', 'declared']
1770
+ const runtimeEnvLabelKey = runtimeEnv === null
1771
+ ? null
1772
+ : runtimeEnv.manualStartLikely === true
1773
+ ? 'env.likelyManual'
1774
+ : typeof runtimeEnv.supervisorKind === 'string' && runtimeEnvKinds.includes(runtimeEnv.supervisorKind)
1775
+ ? `env.kind.${runtimeEnv.supervisorKind}`
1776
+ : typeof runtimeEnv.supervisorKind === 'string' && runtimeEnv.supervisorKind.length > 0
1777
+ ? 'env.kind.unrecognized'
1778
+ : 'env.unknown'
1608
1779
  const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
1609
1780
  React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
1610
1781
  React.createElement('div', { style: displaySurface },
@@ -1616,6 +1787,23 @@ window.__ModuleLoader__.load({
1616
1787
  React.createElement('div', null, translate('update.details.latest', { version: dshUpdate?.latest || '—' })),
1617
1788
  channelLines(translate, dshUpdate?.tags))
1618
1789
  : null,
1790
+ runtimeEnvLabelKey !== null
1791
+ ? React.createElement('div', { 'data-testid': 'runtime-env-line', style: { marginTop: '8px', paddingTop: '8px', borderTop: '1px solid var(--dsw-alias-border-l1)', fontSize: '12px', color: runtimeEnv.manualStartLikely === true ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-secondary)' } },
1792
+ translate('env.line', { value: translate(runtimeEnvLabelKey) }))
1793
+ : null,
1794
+ upgradeManualConfirm
1795
+ ? React.createElement('div', { 'data-testid': 'upgrade-manual-confirm', style: { marginTop: '10px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.08)', border: '1px solid rgba(211,51,51,0.3)' } },
1796
+ React.createElement('p', { style: { margin: '0 0 6px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } }, translate('update.manualConfirmTitle')),
1797
+ React.createElement('p', { style: Object.assign({}, hint, { margin: '0 0 8px' }) }, translate('update.manualConfirm')),
1798
+ React.createElement('div', { style: row },
1799
+ React.createElement('button', { style: dangerGhost, 'data-variant': 'danger', disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.manualProceed')),
1800
+ React.createElement('button', { style: ghost, disabled: upgradeBusy, onClick: () => setUpgradeManualConfirm(false) }, translate('restart.cancel'))))
1801
+ : null,
1802
+ upgradeManualPending
1803
+ ? React.createElement('div', { 'data-testid': 'upgrade-manual-pending', style: { marginTop: '10px', padding: '10px 12px', borderRadius: '6px', border: '1px solid var(--dsw-alias-state-warn-primary)', background: 'var(--dsw-alias-bg-layer-2)' } },
1804
+ React.createElement('p', { style: { margin: '0 0 4px', color: 'var(--dsw-alias-state-warn-primary)', fontSize: '13px', fontWeight: 650 } }, translate('update.manualRestartTitle')),
1805
+ React.createElement('p', { style: Object.assign({}, hint, { margin: 0 }) }, translate('update.manualRestartBody')))
1806
+ : null,
1619
1807
  upgradeError ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)', margin: '4px 0 0' }) }, upgradeError) : null))
1620
1808
 
1621
1809
  // 重启区块复用共享组件(「重启」标签还承载设置页左列入口的显示开关;左侧入口默认关闭)
@@ -1659,7 +1847,7 @@ window.__ModuleLoader__.load({
1659
1847
  // 任务通知独立成顶部标签(v0.14 起不再混在概览里)
1660
1848
  const notifyBlock = React.createElement('div', null, notificationBlock)
1661
1849
  const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
1662
- const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
1850
+ const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || check.status === 'warning') === true
1663
1851
  const tabWarnings = {
1664
1852
  overview: false,
1665
1853
  notify: false,
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 「服务控制」:版本信息 + 检查更新 + 一键重启 dsh web
3
3
  import { createHmac, randomBytes, randomUUID } from 'node:crypto'
4
4
  import { Buffer } from 'node:buffer'
5
- import { constants as fsConstants } from 'node:fs'
5
+ import { constants as fsConstants, existsSync, readFileSync } from 'node:fs'
6
6
  import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
7
7
  import { homedir } from 'node:os'
8
8
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
@@ -44,6 +44,13 @@ try {
44
44
  } catch (__) {}
45
45
  }
46
46
 
47
+ // Node 最低要求只读 package.json engines 一处(与版本号同源),启动时解析一次。
48
+ let requiredNodeMajor = 22
49
+ try {
50
+ const enginesNode = require('./package.json').engines?.node || ''
51
+ requiredNodeMajor = parseInt(enginesNode.replace(/[^0-9].*$/, ''), 10) || 22
52
+ } catch (_) {}
53
+
47
54
  function isSemverIdentifier(value) {
48
55
  if (value.length === 0) return false
49
56
  return [...value].every((char) => (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '-')
@@ -794,7 +801,8 @@ function classifyUpgradeFailure(output) {
794
801
  // 一键升级:走「dsh plugin --profile <p> add <pkg>@<版本>」,落在 DSH 真正读取的 profile
795
802
  // node_modules,替代旧的无升级语义的 npm install -g。命令、包名、版本全部宿主白名单常量。
796
803
  // 安全教义守卫:活动工作拒用、link/file 拒绝、latest 不高于当前版本拒绝(防降级)一律先于网络。
797
- async function upgradePlugin(ctx, dshHome) {
804
+ // runtimeEnv 来自 apply() 的 detectRuntimeEnv():疑似终端手动启动时安装成功不调度 exit(42)。
805
+ async function upgradePlugin(ctx, dshHome, runtimeEnv) {
798
806
  const activity = collectActiveWork(ctx)
799
807
  if (activity.hasActive) throw new Error('active-work')
800
808
 
@@ -849,6 +857,12 @@ async function upgradePlugin(ctx, dshHome) {
849
857
  : installed !== pluginVersion
850
858
  if (!advanced) throw new Error('upgrade-stale')
851
859
 
860
+ // 手动终端启动环境没有进程管理器拉起 exit(42):保持旧版本继续服务,把重启时机交给用户。
861
+ // requiresManualRestart 让客户端改走「手动重启指引」而不是恢复轮询(不会有新实例)。
862
+ if (runtimeEnv !== undefined && runtimeEnv !== null && runtimeEnv.manualStartLikely === true) {
863
+ return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed, requiresManualRestart: true }
864
+ }
865
+
852
866
  scheduleRestart(ctx)
853
867
  return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed }
854
868
  }
@@ -999,7 +1013,20 @@ async function repairPermissions(ctx, dshHome, plans, planId) {
999
1013
  return permissionSnapshot(ctx, dshHome, plans)
1000
1014
  }
1001
1015
 
1002
- async function collectDiagnostics(ctx, dshHome) {
1016
+ // 运行环境检查(v0.17):managed/declared ok;疑似手动启动 → warning(重启无保障);
1017
+ // unknown → info(未检测到管理器也无终端特征:输出重定向、NSSM/WinSW 等包装器——维持现状
1018
+ // 行为本就是默认路径,不算异常,只提示可显式声明)。detail 是客户端映射词典的令牌。
1019
+ function runtimeEnvCheck(runtimeEnv) {
1020
+ if (runtimeEnv === undefined || runtimeEnv === null) return null
1021
+ if (runtimeEnv.manualStartLikely === true) return { id: 'runtime-env', status: 'warning', detail: 'manual' }
1022
+ if (runtimeEnv.supervisorKind === 'declared') return { id: 'runtime-env', status: 'ok', detail: 'declared' }
1023
+ if (typeof runtimeEnv.supervisorKind === 'string' && runtimeEnv.supervisorKind.length > 0) {
1024
+ return { id: 'runtime-env', status: 'ok', detail: runtimeEnv.supervisorKind }
1025
+ }
1026
+ return { id: 'runtime-env', status: 'info', detail: 'unknown' }
1027
+ }
1028
+
1029
+ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
1003
1030
  const checks = []
1004
1031
  const add = (id, status, detail) => checks.push({ id, status, ...(detail === undefined ? {} : { detail: String(detail) }) })
1005
1032
 
@@ -1027,7 +1054,8 @@ async function collectDiagnostics(ctx, dshHome) {
1027
1054
 
1028
1055
  try {
1029
1056
  const backups = await listBackups(dshHome)
1030
- add('backup-storage', backups.items.length === 0 ? 'warning' : 'ok', `${backups.items.length}:${backups.totalBytes}`)
1057
+ // 没有备份不算警告(v0.14 口径的落地):空列表是信息级提示,不点亮标签 ⚠、不影响 overall。
1058
+ add('backup-storage', backups.items.length === 0 ? 'info' : 'ok', `${backups.items.length}:${backups.totalBytes}`)
1031
1059
  } catch (error) { add('backup-storage', 'error', error?.code || error?.message || error) }
1032
1060
 
1033
1061
  const subprocess = ctx.get('subprocess')
@@ -1044,6 +1072,14 @@ async function collectDiagnostics(ctx, dshHome) {
1044
1072
  } catch (error) { add('permissions', 'warning', error?.message || error) }
1045
1073
  }
1046
1074
 
1075
+ const runtimeCheck = runtimeEnvCheck(runtimeEnv)
1076
+ if (runtimeCheck !== null) checks.push(runtimeCheck)
1077
+
1078
+ // Node 运行时版本检查:低于 package.json engines 的最低 major 时告警。
1079
+ // detail 形如 "v22.14.0:22"(当前版本:要求 major),客户端按 backup-storage 同款分隔符解析。
1080
+ const nodeMajor = parseInt(process.versions.node, 10)
1081
+ add('node-version', Number.isFinite(nodeMajor) && nodeMajor >= requiredNodeMajor ? 'ok' : 'warning', `${process.version}:${requiredNodeMajor}`)
1082
+
1047
1083
  let status = 'ok'
1048
1084
  if (checks.some((check) => check.status === 'error')) status = 'error'
1049
1085
  else if (checks.some((check) => check.status === 'warning')) status = 'warning'
@@ -1060,6 +1096,9 @@ async function collectHealth(ctx) {
1060
1096
  return {
1061
1097
  uptimeSeconds: process.uptime(),
1062
1098
  rssBytes: memory.rss,
1099
+ platform: process.platform,
1100
+ arch: process.arch,
1101
+ nodeVersion: process.version,
1063
1102
  liveSessions: sessionsService === undefined ? 0 : sessionsService.list().length,
1064
1103
  persistedSessions: sessionRecords.filter((record) => record.persisted === true).length,
1065
1104
  activeAgents: activity.items.filter((item) => item.type === 'agent').length,
@@ -1127,6 +1166,50 @@ function collectActiveWork(ctx) {
1127
1166
  return { hasActive: items.length > 0, items }
1128
1167
  }
1129
1168
 
1169
+ // ---- 运行环境探测(v0.16:Windows 终端手动启动场景,用户点名)----
1170
+ // 重启 = process.exit(42),依赖外层进程管理器拉起(见 AGENTS.md);PowerShell/CMD 手动
1171
+ // 启动时没有任何东西会把它拉起来。这里只用跨平台被动信号(env + 固定路径 fs + Node 自带
1172
+ // isTTY,零 spawn、浏览器零输入)把环境分成三态:
1173
+ // supervisorKind 非 null —— 检测到管理器,exit 后会被自动拉起,维持自动重启;
1174
+ // manualStartLikely=true —— 无管理器且 stdin/stdout 双 TTY(终端直启特征),升级后宿主
1175
+ // 不自动退出,客户端改示手动重启指引;
1176
+ // 其余 —— unknown(输出重定向、NSSM/WinSW 等包装器),维持现状不折腾。
1177
+ // 探测不到的包装器可用 DSH_SERVICE_RUNTIME_ENV=managed|manual 由用户显式声明。
1178
+ const RUNTIME_SUPERVISOR_ENV = [
1179
+ ['pm2', ['pm_id', 'PM2_HOME', 'pm_uptime']],
1180
+ ['systemd', ['INVOCATION_ID', 'JOURNAL_STREAM', 'NOTIFY_SOCKET']],
1181
+ ['supervisord', ['SUPERVISOR_ENABLED', 'SUPERVISOR_PROCESS_NAME']],
1182
+ ['kubernetes', ['KUBERNETES_SERVICE_HOST']],
1183
+ ]
1184
+
1185
+ function detectRuntimeEnv(options = {}) {
1186
+ const env = options.env ?? process.env
1187
+ const platform = options.platform ?? process.platform
1188
+ const stdinIsTTY = options.stdinIsTTY ?? (process.stdin ? process.stdin.isTTY === true : false)
1189
+ const stdoutIsTTY = options.stdoutIsTTY ?? (process.stdout ? process.stdout.isTTY === true : false)
1190
+ // 默认探测器只在未被注入时执行;win32 直接短路(/.dockerenv 与 /proc 不存在),单测全部显式传原语。
1191
+ const dockerEnvExists = options.dockerEnvExists ?? (platform === 'win32' ? false : (() => { try { return existsSync('/.dockerenv') } catch (_) { return false } })())
1192
+ const cgroupText = options.cgroupText ?? (platform === 'win32' ? '' : (() => { try { return readFileSync('/proc/1/cgroup', 'utf8') } catch (_) { return '' } })())
1193
+
1194
+ const forced = typeof env.DSH_SERVICE_RUNTIME_ENV === 'string' ? env.DSH_SERVICE_RUNTIME_ENV.trim().toLowerCase() : ''
1195
+ if (forced === 'manual') return { platform, supervisorKind: null, manualStartLikely: true }
1196
+ if (forced === 'managed') return { platform, supervisorKind: 'declared', manualStartLikely: false }
1197
+
1198
+ for (const [kind, keys] of RUNTIME_SUPERVISOR_ENV) {
1199
+ if (keys.some((key) => env[key] !== undefined && env[key] !== '')) {
1200
+ return { platform, supervisorKind: kind, manualStartLikely: false }
1201
+ }
1202
+ }
1203
+ // /.dockerenv 与 /proc 只在 POSIX 内核上存在;win32 原生进程不做这两个探测。
1204
+ if (platform !== 'win32') {
1205
+ if (dockerEnvExists === true) return { platform, supervisorKind: 'docker', manualStartLikely: false }
1206
+ if (/docker/i.test(cgroupText)) return { platform, supervisorKind: 'docker', manualStartLikely: false }
1207
+ if (/containerd|kubepods|lxc|podman/i.test(cgroupText)) return { platform, supervisorKind: 'container', manualStartLikely: false }
1208
+ }
1209
+ if (stdinIsTTY === true && stdoutIsTTY === true) return { platform, supervisorKind: null, manualStartLikely: true }
1210
+ return { platform, supervisorKind: null, manualStartLikely: false }
1211
+ }
1212
+
1130
1213
  function scheduleRestart(ctx) {
1131
1214
  const doExit = () => {
1132
1215
  try {
@@ -1143,6 +1226,8 @@ function scheduleRestart(ctx) {
1143
1226
 
1144
1227
  function apply(ctx) {
1145
1228
  const dshHome = resolveDshHome()
1229
+ // 进程运行环境在生命周期内不变:挂载时探测一次,version RPC 与升级分支共用。
1230
+ const runtimeEnv = detectRuntimeEnv()
1146
1231
  const permissionPlans = new Map()
1147
1232
  const downloadTokens = new Map()
1148
1233
  let usageIndexPromise = loadUsageIndex(dshHome)
@@ -1205,7 +1290,9 @@ function apply(ctx) {
1205
1290
  // 合法示例:channel=/dsh-service,endpoint=version/check-update/activity/web。
1206
1291
  ctx.connection.rpc.handle('/dsh-service', async (endpoint, payload) => {
1207
1292
  if (endpoint === 'version') {
1208
- return { ok: true, value: { current: dshVersion, pluginVersion, instanceId } }
1293
+ // runtimeEnv 随进程身份(instanceId)一起返回:概览展示、升级前置确认与重启警告共用,
1294
+ // 客户端对缺字段的老宿主静默降级。
1295
+ return { ok: true, value: { current: dshVersion, pluginVersion, instanceId, runtimeEnv } }
1209
1296
  }
1210
1297
 
1211
1298
  if (endpoint === 'check-update') {
@@ -1243,7 +1330,7 @@ function apply(ctx) {
1243
1330
 
1244
1331
  if (endpoint === 'upgrade') {
1245
1332
  try {
1246
- return { ok: true, value: await upgradePlugin(ctx, dshHome) }
1333
+ return { ok: true, value: await upgradePlugin(ctx, dshHome, runtimeEnv) }
1247
1334
  } catch (error) {
1248
1335
  return { ok: false, error: error?.message || String(error) }
1249
1336
  }
@@ -1263,7 +1350,7 @@ function apply(ctx) {
1263
1350
 
1264
1351
  if (endpoint === 'diagnostics') {
1265
1352
  try {
1266
- return { ok: true, value: await collectDiagnostics(ctx, dshHome) }
1353
+ return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv) }
1267
1354
  } catch (error) {
1268
1355
  return { ok: false, error: error?.message || String(error) }
1269
1356
  }
@@ -1393,5 +1480,5 @@ function apply(ctx) {
1393
1480
  }, { authority: 'loopback' })
1394
1481
  }
1395
1482
 
1396
- export { apply, inject, name }
1397
- export default { apply, inject, name }
1483
+ export { apply, detectRuntimeEnv, inject, name, runtimeEnvCheck }
1484
+ export default { apply, detectRuntimeEnv, inject, name, runtimeEnvCheck }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {