@gehennawu/dsh-service 0.13.0 → 0.13.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.
Files changed (3) hide show
  1. package/client.js +68 -3
  2. package/index.js +178 -12
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -118,7 +118,24 @@ window.__ModuleLoader__.load({
118
118
  'update.upgrade': '升级插件',
119
119
  'update.upgrading': '升级中…',
120
120
  'update.upgradeError': '插件升级失败',
121
+ 'update.upgradeErrorDetail': '插件升级失败({detail})',
121
122
  'update.upgradeSuccess': '升级成功,服务重启中…',
123
+ 'update.guardActiveWork': '有活动中的会话或后台任务,请处理完毕后再升级',
124
+ 'update.guardLinkInstall': '插件通过 link: 安装(开发模式),不能从 registry 一键升级;请更新源码仓库后重启',
125
+ 'update.guardFileInstall': '插件通过 file: 安装,不能从 registry 一键升级',
126
+ 'update.guardNoNewer': 'registry 最新版不高于当前版本,已拒绝可能回退的升级',
127
+ 'update.guardNoProfile': '没有找到安装了本插件的 profile,无法定位升级目标',
128
+ 'update.guardAmbiguous': '多个 profile 都安装了本插件且无法确定当前加载的副本,已中止',
129
+ 'update.guardStale': '命令报告成功但安装版本没有变化(可能被 pnpm 安全等待期拦下),保持当前版本不重启',
130
+ 'update.guardUnreadable': '命令成功但无法确认安装后的版本,已中止重启',
131
+ 'update.failPnpmMissing': '找不到 pnpm,无法执行升级;请先安装 pnpm 再重试',
132
+ 'update.failNetwork': '拉取依赖时网络临时失败,请稍后重试',
133
+ 'update.failFetchTimeout': '下载超时(网络较慢或安装包较大),请稍后重试',
134
+ 'update.failReleaseAge': '新版本被 pnpm 安全等待期拦截,已自动放行重试仍失败',
135
+ 'update.failHoist': 'profile 的 node_modules 由不同版本的 pnpm 创建,已重建重试仍失败',
136
+ 'update.failAddingToRoot': 'pnpm 拒绝在 workspace 根目录安装(缺少 -w)',
137
+ 'update.failNotWorkspace': 'profile 不是 pnpm workspace 却传入了 -w',
138
+ 'update.failIgnoredBuilds': '依赖构建脚本被 pnpm 默认拦截,无法完成升级',
122
139
  'restart.title': '服务重启',
123
140
  'restart.description': '重启 dsh web 进程。运行中的工作会中断,持久化会话可恢复。也可在对话中输入 /restart。',
124
141
  'restart.button': '重启 dsh web',
@@ -303,7 +320,24 @@ window.__ModuleLoader__.load({
303
320
  'update.upgrade': 'Upgrade plugin',
304
321
  'update.upgrading': 'Upgrading…',
305
322
  'update.upgradeError': 'Plugin upgrade failed',
323
+ 'update.upgradeErrorDetail': 'Plugin upgrade failed ({detail})',
306
324
  'update.upgradeSuccess': 'Upgrade successful, restarting…',
325
+ 'update.guardActiveWork': 'Active sessions or background tasks are running; resolve them before upgrading',
326
+ 'update.guardLinkInstall': 'Installed via link: (development mode) — cannot one-click upgrade from the registry; update the source checkout and restart',
327
+ 'update.guardFileInstall': 'Installed via file: — cannot one-click upgrade from the registry',
328
+ 'update.guardNoNewer': 'The registry latest is not higher than the current version; refused a possible downgrade',
329
+ 'update.guardNoProfile': 'No profile with this plugin installed was found to target the upgrade at',
330
+ 'update.guardAmbiguous': 'Several profiles install this plugin and the loaded copy could not be determined; aborted',
331
+ 'update.guardStale': 'The command reported success but the installed version did not change (pnpm safety wait likely blocked it); keeping the current version without restarting',
332
+ 'update.guardUnreadable': 'The command succeeded but the installed version could not be confirmed; the restart was cancelled',
333
+ 'update.failPnpmMissing': 'pnpm was not found, so the upgrade cannot run; install pnpm first and retry',
334
+ 'update.failNetwork': 'A transient network failure occurred while fetching dependencies; please retry shortly',
335
+ 'update.failFetchTimeout': 'Download timed out (slow network or large package); please retry later',
336
+ 'update.failReleaseAge': 'The new release is blocked by pnpm\'s fresh-release safety wait; one automatic bypass retry also failed',
337
+ 'update.failHoist': 'This profile\'s node_modules was created by a different pnpm major; the rebuild retry also failed',
338
+ 'update.failAddingToRoot': 'pnpm refused to add at the workspace root (missing -w)',
339
+ 'update.failNotWorkspace': '-w was passed but the profile is not a pnpm workspace',
340
+ 'update.failIgnoredBuilds': 'Dependency build scripts are blocked by pnpm by default, so the upgrade could not finish',
307
341
  'restart.title': 'Service restart',
308
342
  '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.',
309
343
  'restart.button': 'Restart dsh web',
@@ -911,6 +945,25 @@ window.__ModuleLoader__.load({
911
945
  reader.readAsArrayBuffer(file)
912
946
  }
913
947
 
948
+ // 宿主返回的稳定失败码 → 词典文案;未知错误详情(如 dsh-failed: …)透出原文。
949
+ const UPGRADE_FAILURES = {
950
+ 'active-work': 'update.guardActiveWork',
951
+ 'link-install': 'update.guardLinkInstall',
952
+ 'file-install': 'update.guardFileInstall',
953
+ 'no-newer-version': 'update.guardNoNewer',
954
+ 'no-profile-found': 'update.guardNoProfile',
955
+ 'ambiguous-profile': 'update.guardAmbiguous',
956
+ 'upgrade-stale': 'update.guardStale',
957
+ 'installed-version-unreadable': 'update.guardUnreadable',
958
+ 'pnpm-missing': 'update.failPnpmMissing',
959
+ 'transient-network': 'update.failNetwork',
960
+ 'fetch-timeout': 'update.failFetchTimeout',
961
+ 'release-age-violation': 'update.failReleaseAge',
962
+ 'hoist-pattern-diff': 'update.failHoist',
963
+ 'adding-to-root': 'update.failAddingToRoot',
964
+ 'not-a-workspace': 'update.failNotWorkspace',
965
+ 'ignored-builds': 'update.failIgnoredBuilds',
966
+ }
914
967
  const upgradePlugin = async () => {
915
968
  setUpgradeBusy(true)
916
969
  setUpgradeError(null)
@@ -918,12 +971,24 @@ window.__ModuleLoader__.load({
918
971
  const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
919
972
  const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
920
973
  const res = await ctx.connection.rpc.call('/dsh-service', 'upgrade', {})
921
- if (!res || res.ok === false) throw new Error('upgrade failed')
974
+ if (!res || res.ok === false) {
975
+ const code = res && typeof res.error === 'string' ? res.error.trim() : ''
976
+ const mapped = typeof code === 'string' && code.length > 0 ? UPGRADE_FAILURES[code] : undefined
977
+ // 已知失败码走双语词典;其余(如 npm-failed: …、dsh-failed: …)随通用文案透出宿主错误详情。
978
+ if (mapped !== undefined) {
979
+ setUpgradeError(translate(mapped))
980
+ } else {
981
+ setUpgradeError(translate('update.upgradeErrorDetail', { detail: code || 'upgrade failed' }))
982
+ }
983
+ return
984
+ }
922
985
  if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
923
986
  startRecovery(previousInstanceId).catch(() => {})
924
987
  }
925
- } catch (_) {
926
- setUpgradeError(translate('update.upgradeError'))
988
+ } catch (err) {
989
+ const detail = err instanceof Error && typeof err.message === 'string' && err.message !== 'upgrade failed' ? err.message.trim() : ''
990
+ console.error('dsh-service: upgrade failed', detail || err)
991
+ setUpgradeError(detail ? translate('update.upgradeErrorDetail', { detail }) : translate('update.upgradeError'))
927
992
  } finally {
928
993
  setUpgradeBusy(false)
929
994
  }
package/index.js CHANGED
@@ -3,10 +3,11 @@
3
3
  import { createHmac, randomBytes, randomUUID } from 'node:crypto'
4
4
  import { Buffer } from 'node:buffer'
5
5
  import { constants as fsConstants } from 'node:fs'
6
- import { access, cp, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
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
- import { basename, join, relative, resolve, sep } from 'node:path'
8
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
9
9
  import { createRequire } from 'node:module'
10
+ import { fileURLToPath } from 'node:url'
10
11
  import https from 'node:https'
11
12
 
12
13
  const require = createRequire(import.meta.url)
@@ -23,6 +24,14 @@ const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
23
24
  const USAGE_INDEX_VERSION = 4
24
25
  const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
25
26
 
27
+ // 升级目标白名单:命令与包名全部来自宿主常量,浏览器不传任何输入。
28
+ // TARGET_RE 与 dsh-market 同源:只放行「包名@版本」这一种形状的字符集。
29
+ const TARGET_RE = /^[A-Za-z0-9@:./_#+~^=-]+$/
30
+ const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
31
+ const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000'
32
+ // 当前正在运行的插件源码目录:定位「本插件由哪个 profile 挂载」时与磁盘副本做 realpath 匹配。
33
+ const loadedPluginDir = dirname(fileURLToPath(import.meta.url))
34
+
26
35
  // 读取当前 dsh 版本。DSH 包由宿主安装,不作为插件依赖打包进来。
27
36
  let dshVersion = 'unknown'
28
37
  let pluginVersion = 'unknown'
@@ -476,6 +485,7 @@ function toolFailureCode(tool, code, message) {
476
485
  if (/ABORT|CANCEL/i.test(value) || /aborted|cancelled|canceled/i.test(message)) return undefined
477
486
  if (value && value !== 'UNKNOWN' && value !== 'Error') return value
478
487
  if (/requires reading\b/i.test(message)) return 'FS_NOT_OBSERVED'
488
+ if (/cannot read[^\n]*as an image/i.test(message)) return 'IMAGE_NOT_SUPPORTED'
479
489
  if (/old_string was not found/i.test(message)) return 'OLD_STRING_NOT_FOUND'
480
490
  if (/no such file or directory|path[^\n]*not found/i.test(message)) return 'PATH_NOT_FOUND'
481
491
  if (/file access denied|permission denied|EACCES/i.test(message)) return 'PERMISSION_DENIED'
@@ -487,6 +497,7 @@ function toolFailureCode(tool, code, message) {
487
497
 
488
498
  function toolFailureMessage(tool, code) {
489
499
  if (code === 'FS_NOT_OBSERVED') return `${tool} requires reading <path> first — read the file, then retry`
500
+ if (code === 'IMAGE_NOT_SUPPORTED') return `${tool} failed: the current model does not support image input; switch to an image-capable model`
490
501
  if (code === 'OLD_STRING_NOT_FOUND') return `${tool}: old_string was not found in <path>`
491
502
  if (code === 'PATH_NOT_FOUND') return `${tool} search failed: <path> not found`
492
503
  if (code === 'PERMISSION_DENIED') return `${tool} failed: permission denied for <path>`
@@ -663,30 +674,185 @@ function modeString(mode) {
663
674
  }
664
675
 
665
676
  async function runFixedCommand(ctx, argv) {
677
+ const result = await runCommandResult(ctx, argv)
678
+ if (result.exitCode !== 0 || result.signal !== null) {
679
+ throw new Error(`${argv[0]}-failed: ${(result.stderr || '').trim() || result.signal || result.exitCode}`)
680
+ }
681
+ }
682
+
683
+ // 非抛出的命令执行:返回退出码/信号/stdout/stderr,供升级流程做失败分类与一次性恢复。
684
+ async function runCommandResult(ctx, argv) {
666
685
  const subprocess = ctx.get('subprocess')
667
686
  if (subprocess === undefined) throw new Error('subprocess-unavailable')
668
687
  const executable = await subprocess.resolveExecutable(argv[0])
688
+ let spawnArgv = [executable, ...argv.slice(1)]
689
+ // Windows 上白名单命令(如 npm/dsh)经 PATHEXT 解析为 .cmd/.bat 脚本;subprocess 服务的
690
+ // spawn 不带 shell,Node 对 .cmd/.bat 一律抛 EINVAL,无法直接执行。固定包一层
691
+ // cmd.exe /d /s /c + 已解析的绝对路径,全部参数仍是宿主白名单常量,无输入拼接。
692
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(executable)) {
693
+ const shell = await subprocess.resolveExecutable('cmd.exe')
694
+ spawnArgv = [shell, '/d', '/s', '/c', executable, ...argv.slice(1)]
695
+ }
669
696
  const handle = subprocess.spawn({
670
- argv: [executable, ...argv.slice(1)],
671
- cwd: '/',
697
+ argv: spawnArgv,
698
+ // '/' 不是合法的 Windows 目录路径;固定改用系统目录,POSIX 保持根目录。
699
+ cwd: process.platform === 'win32' ? (process.env.SystemRoot || 'C:\\Windows') : '/',
672
700
  stdio: {
673
701
  stdin: 'ignore',
674
- stdout: { maxBytes: 16 * 1024 },
675
- stderr: { maxBytes: 64 * 1024 },
702
+ stdout: { maxBytes: 64 * 1024 },
703
+ stderr: { maxBytes: 512 * 1024 },
676
704
  },
677
705
  graceMs: 5000,
678
706
  })
679
707
  const outcome = await handle.done
680
708
  const stderr = handle.collected.stderr?.readFrom(0).text || ''
681
- if (outcome.exitCode !== 0 || outcome.signal !== null) {
682
- throw new Error(`${argv[0]}-failed: ${stderr.trim() || outcome.signal || outcome.exitCode}`)
709
+ const stdout = handle.collected.stdout?.readFrom(0).text || ''
710
+ return { exitCode: outcome.exitCode ?? 1, signal: outcome.signal, stdout, stderr }
711
+ }
712
+
713
+ // 定位「安装了本插件的 profile」。只读扫描 DSH_HOME/profiles/*/package.json,浏览器不传
714
+ // 任何路径/名字;多候选时优先匹配当前正在运行的插件源码目录(realpath),避免同秒竞态。
715
+ async function resolveUpgradeProfile(dshHome) {
716
+ const profilesDir = join(dshHome, 'profiles')
717
+ let entries
718
+ try {
719
+ entries = await readdir(profilesDir, { withFileTypes: true })
720
+ } catch (_) {
721
+ throw new Error('no-profile-found')
722
+ }
723
+ const candidates = []
724
+ for (const entry of entries) {
725
+ if (!entry.isDirectory() || entry.name === 'node_modules') continue
726
+ const dir = join(profilesDir, entry.name)
727
+ let manifest
728
+ try {
729
+ manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'))
730
+ } catch (_) {
731
+ continue
732
+ }
733
+ const spec = manifest?.dependencies?.[PLUGIN_PACKAGE]
734
+ if (typeof spec !== 'string') continue
735
+ candidates.push({ name: entry.name, dir, spec })
736
+ }
737
+ if (candidates.length === 0) throw new Error('no-profile-found')
738
+ let loadedReal = null
739
+ try { loadedReal = await realpath(loadedPluginDir) } catch (_) {}
740
+ const matches = []
741
+ if (loadedReal !== null) {
742
+ for (const candidate of candidates) {
743
+ let installedReal = null
744
+ try { installedReal = await realpath(join(candidate.dir, 'node_modules', PLUGIN_PACKAGE)) } catch (_) {}
745
+ if (installedReal === loadedReal) matches.push(candidate)
746
+ }
747
+ }
748
+ const selected = matches.length === 1 ? matches[0] : (candidates.length === 1 ? candidates[0] : null)
749
+ if (selected === null) throw new Error('ambiguous-profile')
750
+ let workspace = false
751
+ try { workspace = (await stat(join(selected.dir, 'pnpm-workspace.yaml'))).isFile() } catch (_) {}
752
+ return { name: selected.name, dir: selected.dir, spec: selected.spec, workspace }
753
+ }
754
+
755
+ async function readInstalledPluginVersion(profile) {
756
+ try {
757
+ const manifest = JSON.parse(await readFile(join(profile.dir, 'node_modules', PLUGIN_PACKAGE, 'package.json'), 'utf8'))
758
+ const version = typeof manifest?.version === 'string' ? manifest.version : ''
759
+ return version === '' ? null : version
760
+ } catch (_) {
761
+ return null
683
762
  }
684
763
  }
685
764
 
686
- async function upgradePlugin(ctx) {
687
- await runFixedCommand(ctx, ['npm', 'install', '-g', `${PLUGIN_PACKAGE}@latest`])
765
+ // pnpm 失败分类:dsh plugin 转发 pnpm 时只报「pnpm failed in profile directory」,不报原因;
766
+ // 必须按输出特征识别真实失败(踩坑见 KNOWLEDGE.md「pnpm 失败模式识别与自动恢复」)。
767
+ function classifyUpgradeFailure(output) {
768
+ const text = String(output || '')
769
+ if (text.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
770
+ return { code: 'hoist-pattern-diff', recoverable: true, message: 'hoist-pattern-diff' }
771
+ }
772
+ if (text.includes('ERR_PNPM_ADDING_TO_ROOT')) {
773
+ return { code: 'adding-to-root', recoverable: false, message: 'adding-to-root' }
774
+ }
775
+ if (/--workspace-root may only be used inside a workspace/i.test(text)) {
776
+ return { code: 'not-a-workspace', recoverable: false, message: 'not-a-workspace' }
777
+ }
778
+ if (text.includes('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION') || text.includes('ERR_PNPM_NO_MATURE_MATCHING_VERSION')) {
779
+ return { code: 'release-age-violation', recoverable: true, message: 'release-age-violation' }
780
+ }
781
+ if (text.includes('ERR_PNPM_IGNORED_BUILDS')) {
782
+ return { code: 'ignored-builds', recoverable: false, message: 'ignored-builds' }
783
+ }
784
+ if (/ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL|FetchError|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|socket hang up|network timeout/i.test(text)) {
785
+ return { code: 'transient-network', recoverable: true, message: 'transient-network' }
786
+ }
787
+ if (/operation was aborted due to timeout|TimeoutError|error \(23\)/i.test(text)) {
788
+ return { code: 'fetch-timeout', recoverable: true, message: 'fetch-timeout' }
789
+ }
790
+ if (text.includes('pnpm not found on PATH')) {
791
+ return { code: 'pnpm-missing', recoverable: false, message: 'pnpm-missing' }
792
+ }
793
+ return null
794
+ }
795
+
796
+ // 一键升级:走「dsh plugin --profile <p> add <pkg>@<版本>」,落在 DSH 真正读取的 profile
797
+ // node_modules,替代旧的无升级语义的 npm install -g。命令、包名、版本全部宿主白名单常量。
798
+ // 安全教义守卫:活动工作拒用、link/file 拒绝、latest 不高于当前版本拒绝(防降级)一律先于网络。
799
+ async function upgradePlugin(ctx, dshHome) {
800
+ const activity = collectActiveWork(ctx)
801
+ if (activity.hasActive) throw new Error('active-work')
802
+
803
+ const profile = await resolveUpgradeProfile(dshHome)
804
+ if (profile.spec.startsWith('link:')) throw new Error('link-install')
805
+ if (profile.spec.startsWith('file:')) throw new Error('file-install')
806
+
807
+ const published = await fetchPublishedVersions(PLUGIN_PACKAGE)
808
+ const targetVersion = published.latest
809
+ if (parseSemver(targetVersion) !== null && parseSemver(pluginVersion) !== null && compareSemver(targetVersion, pluginVersion) <= 0) {
810
+ throw new Error('no-newer-version')
811
+ }
812
+ const target = `${PLUGIN_PACKAGE}@${targetVersion}`
813
+ if (!TARGET_RE.test(PLUGIN_PACKAGE) || !TARGET_RE.test(target) || !/^[A-Za-z0-9][A-Za-z0-9._+\-]*$/.test(targetVersion)) {
814
+ throw new Error('invalid-upgrade-target')
815
+ }
816
+
817
+ const addArgs = profile.workspace ? ['add', '-w'] : ['add']
818
+ const dshArgs = ['dsh', 'plugin', '--profile', profile.name]
819
+ const run = (extra) => runCommandResult(ctx, [...dshArgs, ...extra])
820
+ const ok = (result) => result.exitCode === 0 && result.signal === null
821
+
822
+ // pnpm 中断(signal 非 null)表示进程被终止,不做自动恢复。
823
+ let result = await run([...addArgs, target])
824
+ if (!ok(result) && result.signal === null) {
825
+ const failure = classifyUpgradeFailure(`${result.stderr}\n${result.stdout}`)
826
+ if (failure !== null) {
827
+ if (failure.code === 'hoist-pattern-diff') {
828
+ const rebuild = await run(['install', '--no-frozen-lockfile'])
829
+ if (ok(rebuild)) result = await run([...addArgs, target])
830
+ } else if (failure.code === 'release-age-violation' || failure.code === 'fetch-timeout') {
831
+ const override = failure.code === 'release-age-violation' ? RELEASE_AGE_OVERRIDE : FETCH_TIMEOUT_OVERRIDE
832
+ if (!addArgs.includes(override)) result = await run([addArgs[0], override, ...addArgs.slice(1), target])
833
+ } else if (failure.code === 'transient-network') {
834
+ result = await run([...addArgs, target])
835
+ } else {
836
+ throw new Error(failure.code)
837
+ }
838
+ }
839
+ }
840
+
841
+ if (!ok(result)) {
842
+ const failure = classifyUpgradeFailure(`${result.stderr}\n${result.stdout}`)
843
+ throw new Error(failure !== null ? failure.code : `dsh-failed: ${(result.stderr || '').trim().slice(-400) || result.signal || result.exitCode}`)
844
+ }
845
+
846
+ // pnpm 干净退出 ≠ 真升级:minimumReleaseAge 会静默保住旧版。重读磁盘安装版本确认变化,只在其进了才重启。
847
+ const installed = await readInstalledPluginVersion(profile)
848
+ if (installed === null) throw new Error('installed-version-unreadable')
849
+ const advanced = parseSemver(installed) !== null && parseSemver(pluginVersion) !== null
850
+ ? compareSemver(installed, pluginVersion) > 0
851
+ : installed !== pluginVersion
852
+ if (!advanced) throw new Error('upgrade-stale')
853
+
688
854
  scheduleRestart(ctx)
689
- return { ok: true }
855
+ return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed }
690
856
  }
691
857
 
692
858
  async function permissionSnapshot(ctx, dshHome, plans) {
@@ -1079,7 +1245,7 @@ function apply(ctx) {
1079
1245
 
1080
1246
  if (endpoint === 'upgrade') {
1081
1247
  try {
1082
- return { ok: true, value: await upgradePlugin(ctx) }
1248
+ return { ok: true, value: await upgradePlugin(ctx, dshHome) }
1083
1249
  } catch (error) {
1084
1250
  return { ok: false, error: error?.message || String(error) }
1085
1251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {