@mzzsfy/dsh-shell-select 0.3.0 → 0.4.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/src/client.js CHANGED
@@ -8,7 +8,8 @@ window.__ModuleLoader__.load({
8
8
  const React = require('react')
9
9
  const { useState, useEffect } = React
10
10
 
11
- // 官方 primitives 图标/状态点(运行时模块表解析;dsh.client.inject 声明保证在场)
11
+ // 官方 primitives 图标/状态点(运行时模块表解析;dsh.client.inject 声明保证在场)。
12
+ // 0.1.7 起图标名带字重后缀(Regular/Medium),尺寸是 prop 不在名字里
12
13
  const primitives = (() => {
13
14
  try {
14
15
  return require('@deepseek-ai/dsh-client-ui-primitives')
@@ -16,31 +17,34 @@ window.__ModuleLoader__.load({
16
17
  return null
17
18
  }
18
19
  })()
19
- const createElementOf = (name, fallback) => {
20
- const Component = primitives !== null ? primitives[name] : undefined
21
- return typeof Component === 'function' || typeof Component === 'object'
22
- ? (props) => React.createElement(Component, props ?? null)
23
- : fallback
20
+ const createElementOf = (names, fallback) => {
21
+ for (const name of names) {
22
+ const Component = primitives !== null ? primitives[name] : undefined
23
+ if (typeof Component === 'function' || typeof Component === 'object') {
24
+ return (props) => React.createElement(Component, props ?? null)
25
+ }
26
+ }
27
+ return fallback
24
28
  }
25
29
  const DOT_SVG = { done: '#2e9e5b', error: '#d4553f', warning: '#d9a13b', ongoing: '#7a7f8a' }
26
30
  // 降级自绘:模块表缺 primitives 时保持可见性(形态近似官方 StateDot/图标)
27
31
  const TOOLVIEW_ICONS = {
28
- StateDot: createElementOf('StateDot', ({ state }) => h('span', {
32
+ StateDot: createElementOf(['StateDot'], ({ state }) => h('span', {
29
33
  style: {
30
34
  width: 8, height: 8, borderRadius: '50%', background: DOT_SVG[state] ?? DOT_SVG.ongoing,
31
35
  display: 'inline-block', flex: 'none',
32
36
  },
33
37
  })),
34
- IconApi: createElementOf('IconApiOutline14', () => h('span', {
38
+ IconApi: createElementOf(['IconApiOutlineRegular', 'IconApiOutlineMedium', 'IconApiOutline14'], () => h('span', {
35
39
  style: {
36
40
  width: 12, height: 12, borderRadius: 3, border: '1.5px solid currentColor',
37
41
  opacity: .7, display: 'inline-block', flex: 'none',
38
42
  },
39
43
  })),
40
- IconChevron: createElementOf('IconChevronDownOutline14', ({ size }) => h('span', {
44
+ IconChevron: createElementOf(['IconChevronDownOutlineRegular', 'IconChevronDownOutlineMedium', 'IconChevronDownOutline14'], ({ size }) => h('span', {
41
45
  style: { fontSize: (size ?? 14) - 3, lineHeight: 1, userSelect: 'none' },
42
46
  }, '▾')),
43
- IconInspect: createElementOf('IconInspectOutline12', () => h('span', {
47
+ IconInspect: createElementOf(['IconInspectOutlineRegular', 'IconInspectOutlineMedium', 'IconInspectOutline12'], () => h('span', {
44
48
  style: { fontSize: 10, lineHeight: 1, opacity: .8 },
45
49
  }, 'ⓘ')),
46
50
  }
@@ -66,7 +70,7 @@ window.__ModuleLoader__.load({
66
70
  }
67
71
 
68
72
  // 工具卡客户端名册缓存:GET /config 单飞拉取,shell 徽章按 id→用户命名解析。
69
- // 失败置 null(徽章不渲染),不重试——下次页面加载自然重取
73
+ // 失败复位单飞(宿主启动窗口路由未就绪的 404 在下次卡渲染自然重试)
70
74
  let clientCatalog = null
71
75
  let clientCatalogPromise = null
72
76
  // LOGIC-BEGIN ensureClientCatalog
@@ -74,9 +78,11 @@ window.__ModuleLoader__.load({
74
78
  if (clientCatalogPromise === null) {
75
79
  clientCatalogPromise = api(API.config).then((section) => {
76
80
  const byId = {}
77
- for (const entry of section.resolved?.shells ?? []) byId[entry.id] = entry.name
81
+ for (const entry of section.resolved?.shells ?? []) byId[entry.id] = entry.id
78
82
  clientCatalog = { default: section.resolved?.default, byId }
79
- }).catch(() => { })
83
+ }).catch(() => {
84
+ clientCatalogPromise = null
85
+ })
80
86
  }
81
87
  return clientCatalogPromise
82
88
  }
@@ -128,7 +134,16 @@ window.__ModuleLoader__.load({
128
134
  '.sls-tv__row { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:default; }',
129
135
  '.sls-tv__row--exp { cursor:pointer; user-select:none; }',
130
136
  '.sls-tv__lead { display:flex; align-items:center; gap:4px; color:var(--dsw-alias-label-tertiary, inherit); }',
131
- '.sls-tv__chev { opacity:.45; transition:transform .15s ease; }',
137
+ // 官方 DisclosureRow 同构:icon 与 chevron 同 16px 槽,hover 交叉淡化;
138
+ // 错误/进行中状态点绝对定位覆盖 icon(红点掩盖 icon)
139
+ '.sls-tv__leadStack { position:relative; width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; flex:none; }',
140
+ '.sls-tv__iconIdle { display:inline-flex; opacity:1; transition:opacity 100ms ease; }',
141
+ '.sls-tv__stateCover { position:absolute; inset:0; margin:auto; display:inline-flex; align-items:center; justify-content:center; background:transparent; }',
142
+ '.sls-tv__stateCover::before { content:""; position:absolute; inset:-2px; border-radius:50%; background:var(--dsw-alias-bg-primary, #fff); }',
143
+ '.sls-tv__stateCover > span { position:relative; }',
144
+ '.sls-tv__row:hover .sls-tv__iconIdle { opacity:0; }',
145
+ '.sls-tv__chev { opacity:.45; transition:transform .15s ease, opacity .1s ease; }',
146
+ '.sls-tv__row:hover .sls-tv__chev { opacity:1; }',
132
147
  '.sls-tv__row[data-open="1"] .sls-tv__chev { transform:rotate(-90deg); }',
133
148
  '.sls-tv__sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }',
134
149
  '.sls-tv__title { font-weight:400; }',
@@ -141,6 +156,10 @@ window.__ModuleLoader__.load({
141
156
  '.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; }',
142
157
  '.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; }',
143
158
  '.sls-tv__pill--bg { color:var(--dsw-alias-label-tertiary, inherit); }',
159
+ '.sls-tv__duration { font-family:var(--sls-mono, monospace); font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; font-variant-numeric:tabular-nums; }',
160
+ // 运行中脉冲文字:零信息窗口(参数未到)的活体反馈
161
+ '.sls-tv__pulse { font-size:12px; color:var(--dsw-alias-label-tertiary, inherit); flex:none; animation:sls-pulse 1.5s ease-in-out infinite; }',
162
+ '@keyframes sls-pulse { 0%,100% { opacity:.35; } 50% { opacity:1; } }',
144
163
  '.sls-tv__sp { flex:1; }',
145
164
  '.sls-tv__copy { display:flex; align-items:center; gap:2px; flex:none; }',
146
165
  '.sls-tv__copyBtn { padding:2px 8px; border:1px solid rgba(128,128,128,.35); border-radius:6px; background:transparent; color:inherit; cursor:pointer; font-size:12px; }',
@@ -186,7 +205,6 @@ window.__ModuleLoader__.load({
186
205
  return {
187
206
  shells: entries.map((entry) => ({
188
207
  id: entry.id.trim(),
189
- name: entry.name.trim(),
190
208
  kind: entry.kind,
191
209
  path: entry.path.trim(),
192
210
  args: entry.argsText.split(/\s+/).filter((item) => item.length > 0),
@@ -243,7 +261,6 @@ window.__ModuleLoader__.load({
243
261
  function toEntries(section) {
244
262
  return section.shells.map((entry) => ({
245
263
  id: entry.id,
246
- name: entry.name,
247
264
  kind: entry.kind,
248
265
  path: entry.path,
249
266
  argsText: (entry.args ?? []).join(' '),
@@ -290,7 +307,7 @@ window.__ModuleLoader__.load({
290
307
 
291
308
  const addEntry = () => {
292
309
  setDirty(true)
293
- setEntries([...entries, { id: '', name: '', kind: 'bash', path: '', argsText: '', login: false, distro: '', envText: '', available: undefined, resolved: undefined }])
310
+ setEntries([...entries, { id: '', kind: 'bash', path: '', argsText: '', login: false, distro: '', envText: '', available: undefined, resolved: undefined }])
294
311
  }
295
312
 
296
313
  const removeEntry = (index) => {
@@ -422,16 +439,9 @@ window.__ModuleLoader__.load({
422
439
  h('input', {
423
440
  className: 'sls-input',
424
441
  value: entry.id,
425
- placeholder: 'pwsh / git-bash / …(模型看到的 shell 参数值)',
442
+ placeholder: 'pwsh / git-bash / …(模型看到的 shell 参数值,亦作显示名)',
426
443
  onChange: (event) => patchEntry(index, { id: event.target.value }),
427
444
  }),
428
- h('span', { className: 'sls-grid__label' }, '名称'),
429
- h('input', {
430
- className: 'sls-input',
431
- value: entry.name,
432
- placeholder: '显示名,如 Git Bash',
433
- onChange: (event) => patchEntry(index, { name: event.target.value }),
434
- }),
435
445
  h('span', { className: 'sls-grid__label' }, '形态'),
436
446
  h('div', { className: 'sls-row' },
437
447
  h('select', {
@@ -624,12 +634,12 @@ window.__ModuleLoader__.load({
624
634
  const cwdDir = cwdFull !== undefined ? lastSegment(cwdFull) : undefined
625
635
  // shell 名 = 用户在配置页起的条目名;args.shell 缺省时按 default 客户端解析(配置当前读数)
626
636
  const shellName = clientDisplayName(typeof args?.shell === 'string' ? args.shell : undefined)
627
- if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null }
637
+ if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null, running: !settled, alert: undefined }
628
638
 
629
639
  if (!settled) {
630
- // running persistent(进行中无描述):回退行但状态点用进行中灰,非 warning 黄
631
- if (description === undefined) return { kind: 'generic', command, summary: undefined, output: null, running: true }
632
- return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined }
640
+ const startedAt = typeof block.time === 'number' ? block.time : undefined
641
+ // 运行中一律全量卡:阻塞时展开即可见命令与时长,不落无命令的 generic 回退行
642
+ return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined, startedAt }
633
643
  }
634
644
 
635
645
  const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
@@ -638,9 +648,11 @@ window.__ModuleLoader__.load({
638
648
  const jobId = /started background job (\S+)/.exec(contentText)?.[1]
639
649
  return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: contentText, jobId }
640
650
  }
641
- // 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡
651
+ // 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡;
652
+ // isError/error 块带 alert(错误红点语义),其余(空输出/无描述/截断)中性无状态点
642
653
  if (contentText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
643
- return { kind: 'generic', command, summary: description, output: contentText }
654
+ const alert = block.isError === true || block.error !== undefined ? 'error' : undefined
655
+ return { kind: 'generic', command, summary: description, output: contentText, running: false, alert }
644
656
  }
645
657
  const tail = parseExitTail(contentText)
646
658
  const status = tail.signal !== undefined ? 'signaled' : tail.exitCode !== 0 ? 'failed' : 'done'
@@ -648,18 +660,49 @@ window.__ModuleLoader__.load({
648
660
  }
649
661
  // LOGIC-END shellCardModel
650
662
 
651
- // 官方 leadingFor 同构:失败红点,回退行黄点,进行中灰点,其余工具图标
652
- function leadingOf(status, icons) {
653
- if (status === 'failed' || status === 'signaled') return icons.StateDot({ state: 'error' })
654
- if (status === 'generic-warn') return icons.StateDot({ state: 'warning' })
655
- if (status === 'running') return icons.StateDot({ state: 'ongoing' })
663
+ // 官方 DisclosureRow leading 同构:16px 槽内 icon 与 chevron 同位,
664
+ // 收起时 chevron hover 才现(交叉淡化),展开时仅 chevron-up;错误态
665
+ // 红点叠 icon 之上(掩盖 icon,官方状态点语义)
666
+ function leadingStack({ status, icons }) {
667
+ const failed = status === 'failed' || status === 'signaled' || status === 'generic-error'
668
+ const dot = failed ? icons.StateDot({ state: 'error' }) : status === 'generic-warn' ? icons.StateDot({ state: 'warning' }) : status === 'running' ? icons.StateDot({ state: 'ongoing' }) : null
669
+ if (dot !== null) {
670
+ return h('span', { className: 'sls-tv__leadStack' },
671
+ h('span', { className: 'sls-tv__iconIdle' }, icons.IconApi({ size: 14 })),
672
+ h('span', { className: 'sls-tv__stateCover' }, dot),
673
+ )
674
+ }
656
675
  return icons.IconApi({ size: 14 })
657
676
  }
658
677
 
678
+ // 时长格式:秒 → m:ss;超一小时 → h:mm:ss
679
+ // LOGIC-BEGIN formatDuration
680
+ function formatDuration(elapsedMs) {
681
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
682
+ const seconds = totalSeconds % 60
683
+ const minutes = Math.floor(totalSeconds / 60) % 60
684
+ const hours = Math.floor(totalSeconds / 3600)
685
+ const two = (value) => (value < 10 ? '0' + value : String(value))
686
+ return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${minutes}:${two(seconds)}`
687
+ }
688
+ // LOGIC-END formatDuration
689
+
690
+ // 运行中实时时长:每秒 tick;秒级粒度对"阻塞了多久"足够
691
+ function RunningDuration({ startedAt, en }) {
692
+ const [, setTick] = useState(0)
693
+ useEffect(() => {
694
+ const timer = setInterval(() => setTick((value) => value + 1), 1000)
695
+ return () => clearInterval(timer)
696
+ }, [])
697
+ const elapsed = startedAt !== undefined ? Date.now() - startedAt : undefined
698
+ const text = elapsed !== undefined && elapsed >= 0 ? formatDuration(elapsed) : en ? 'running' : '运行中'
699
+ return h('span', { className: 'sls-tv__duration', title: en ? 'Elapsed' : '已运行' }, text)
700
+ }
701
+
659
702
  function statusTextOf(status, en) {
660
703
  switch (status) {
661
704
  case 'running': return en ? 'Running' : '运行中'
662
- case 'background': return en ? 'Background' : '后台运行'
705
+ case 'background': return en ? 'Background' : '后台'
663
706
  case 'failed': case 'signaled': return en ? 'Failed' : '失败'
664
707
  default: return null
665
708
  }
@@ -668,9 +711,10 @@ window.__ModuleLoader__.load({
668
711
  function headMetaOf(model, en) {
669
712
  switch (model.status) {
670
713
  case 'running': return { dot: 'ongoing', label: en ? 'Running' : '运行中', pill: undefined }
714
+ // ack 块静态不反映 job 生命周期,状态点缺省(后台任务由 bg pill 标识)
671
715
  case 'background': return {
672
- dot: 'ongoing',
673
- label: en ? 'Background' : '后台运行',
716
+ dot: undefined,
717
+ label: en ? 'Background' : '后台',
674
718
  pill: model.jobId !== undefined ? (en ? `bg ${model.jobId}` : `后台 ${model.jobId}`) : undefined,
675
719
  pillTone: 'bg',
676
720
  }
@@ -699,13 +743,17 @@ window.__ModuleLoader__.load({
699
743
  }, done ? (detectEnglish() ? 'Copied' : '已复制') : label)
700
744
  }
701
745
 
702
- // 非终端意图回退行(后台 ack / isError / 截断):摘要 + 可展开原文
746
+ // 非终端意图回退行(后台 ack / isError / 截断 / 参数未到):摘要 + 可展开原文。
747
+ // 状态语义对齐官方:运行中灰 spinner,settled 错误红点,其余中性 icon——
748
+ // 黄点(warning)仅真实告警,禁止挪用作"进行中"(用户把黄点当卡死)。
749
+ // 运行中且零信息(参数未到)显示脉冲「运行中…」,给用户活着的感觉。
703
750
  function GenericShellRow({ model, inspect, en }) {
704
751
  const [open, setOpen] = useState(false)
752
+ const status = model.running === true ? 'running' : model.alert === 'error' ? 'generic-error' : undefined
705
753
  const summary = model.summary !== undefined && model.summary !== ''
706
754
  ? model.summary.split('\n')[0]
707
755
  : (model.output !== null && model.output !== '' ? model.output.split('\n')[0] : '')
708
- const expandable = model.output !== null && model.output !== ''
756
+ const expandable = (model.output !== null && model.output !== '') || model.command !== ''
709
757
  return h('div', { className: 'sls-tv' },
710
758
  h('div', {
711
759
  className: 'sls-tv__row' + (expandable ? ' sls-tv__row--exp' : ''),
@@ -721,14 +769,15 @@ window.__ModuleLoader__.load({
721
769
  } : undefined,
722
770
  },
723
771
  h('span', { className: 'sls-tv__lead' },
724
- leadingOf(model.running === true ? 'running' : 'generic-warn', TOOLVIEW_ICONS),
772
+ leadingStack({ status, icons: TOOLVIEW_ICONS }),
725
773
  expandable ? h('span', { className: 'sls-tv__chev', 'data-open': open ? '1' : '0', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })) : null,
726
774
  ),
727
775
  h('span', { className: 'sls-tv__title' }, 'Shell'),
728
776
  summary !== '' ? h('span', { className: 'sls-tv__sep', 'aria-hidden': true }) : null,
729
- summary !== '' ? h('span', { className: 'sls-tv__sum' }, summary) : null,
777
+ summary !== '' ? h('span', { className: 'sls-tv__sum' + (status === 'generic-error' ? ' sls-tv__sum--err' : '') }, summary) : null,
778
+ status === 'running' ? h('span', { className: 'sls-tv__pulse' }, en ? 'running…' : '运行中…') : null,
730
779
  ),
731
- open && expandable ? h('pre', { className: 'sls-tv__out', style: { border: '1px solid rgba(128,128,128,.28)', borderRadius: 8 } }, model.output) : null,
780
+ open && expandable ? h('pre', { className: 'sls-tv__out', style: { border: '1px solid rgba(128,128,128,.28)', borderRadius: 8 } }, model.command + (model.output !== null && model.output !== '' ? `\n${model.output}` : '')) : null,
732
781
  inspect !== undefined ? h('button', { className: 'sls-tv__inspect', onClick: inspect }, TOOLVIEW_ICONS.IconInspect({}), en ? 'Inspect' : '检查') : null,
733
782
  )
734
783
  }
@@ -748,16 +797,20 @@ window.__ModuleLoader__.load({
748
797
  const meta = headMetaOf(model, en)
749
798
  const copyCommandLabel = en ? 'Copy command' : '复制命令'
750
799
  const copyOutputLabel = en ? 'Copy output' : '复制输出'
800
+ // 运行中头部 pill = 实时时长(组件,hook 内每秒自刷新)
801
+ const pill = model.status === 'running'
802
+ ? h(RunningDuration, { startedAt: model.startedAt, en })
803
+ : meta.pill
751
804
  return h('div', { className: 'sls-tv__body', 'data-status': model.status },
752
805
  h('div', { className: 'sls-tv__head' },
753
806
  h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
754
- TOOLVIEW_ICONS.StateDot({ state: meta.dot }),
807
+ meta.dot !== undefined ? TOOLVIEW_ICONS.StateDot({ state: meta.dot }) : null,
755
808
  h('span', { className: 'sls-tv__sr' }, meta.label),
756
809
  ),
757
810
  model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd', title: 'pwd: ' + (model.cwdFull ?? model.cwdDir) }, model.cwdDir) : null,
758
811
  model.shellName !== undefined ? h('span', { className: 'sls-tv__badge', title: en ? 'Shell client' : 'Shell 客户端' }, model.shellName) : null,
759
812
  h('span', { className: 'sls-tv__sp' }),
760
- meta.pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, meta.pill) : null,
813
+ pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, pill) : null,
761
814
  h('span', { className: 'sls-tv__copy' },
762
815
  h(CopyButton, {
763
816
  label: copyCommandLabel,
@@ -783,8 +836,10 @@ window.__ModuleLoader__.load({
783
836
  const { block, cwd, inspect } = props
784
837
  const en = detectEnglish()
785
838
  const model = shellCardModel(block, cwd)
786
- // 后台 ack 卡默认展开:内容即命令全文,收起只剩一行摘要失去可读性
787
- const [open, setOpen] = useState(model.kind === 'background')
839
+ // hooks 恒序:必须在任何提前 return 之前完整执行(真实 React 的 hooks
840
+ // 链表按调用序对位,generic 行与 terminal 行的提前 return 分叉会让
841
+ // 后续 hook 错位,行组件树被整棵卸载——卡片全消失事故根因)
842
+ const [open, setOpen] = useState(false)
788
843
  const [, setCatalogReady] = useState(false)
789
844
  useEffect(() => {
790
845
  let disposed = false
@@ -812,13 +867,17 @@ window.__ModuleLoader__.load({
812
867
  },
813
868
  },
814
869
  h('span', { className: 'sls-tv__lead' },
815
- leadingOf(model.status, TOOLVIEW_ICONS),
870
+ leadingStack({ status: model.status, icons: TOOLVIEW_ICONS }),
816
871
  h('span', { className: 'sls-tv__chev', style: { display: 'inline-flex', transform: open ? 'rotate(-90deg)' : 'none' } }, TOOLVIEW_ICONS.IconChevron({ size: 14 })),
817
872
  ),
818
873
  srStatus !== null ? h('span', { className: 'sls-tv__sr' }, srStatus) : null,
819
874
  h('span', { className: 'sls-tv__title' }, 'Shell'),
820
875
  h('span', { className: 'sls-tv__sep', 'aria-hidden': true }),
821
876
  h('span', { className: 'sls-tv__sum' + (failed ? ' sls-tv__sum--err' : '') }, summary),
877
+ model.status === 'running' ? h(RunningDuration, { startedAt: model.startedAt, en }) : null,
878
+ model.status === 'background' && model.jobId !== undefined
879
+ ? h('span', { className: 'sls-tv__badge', title: en ? 'Background job' : '后台任务' }, en ? `bg ${model.jobId}` : `后台 ${model.jobId}`)
880
+ : null,
822
881
  ),
823
882
  open ? h(ShellCard, { model, inspect, en }) : null,
824
883
  )
package/src/config.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // cmd /d /s /c 与 wsl --exec 为本包定义的保守形态。
4
4
 
5
5
  import z from '@deepseek-ai/schemastery'
6
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
6
7
 
7
8
  // 支持的 shell 形态
8
9
  export const KINDS = ['pwsh', 'bash', 'cmd', 'wsl']
@@ -29,7 +30,6 @@ const EXECUTOR_DEFAULTS = {
29
30
 
30
31
  const shellEntry = z.object({
31
32
  id: z.string().required(),
32
- name: z.string().required(),
33
33
  kind: z.union(KINDS).required(),
34
34
  path: z.string().default(RESOLVED_AUTO),
35
35
  args: z.array(z.string()).default([]),
@@ -39,27 +39,121 @@ const shellEntry = z.object({
39
39
  })
40
40
 
41
41
  export const Config = z.object({
42
+ // 0.1.7 设置页写路径(settings.write)只接受 volatile 字段(免重挂载的活
43
+ // 字段,官方 dsh-bash-local Config 逐字段 volatile 同构);缺声明即
44
+ // "has no volatile fields" 保存失败
42
45
  shells: z.array(shellEntry).default([
43
- { id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: RESOLVED_AUTO, args: [] },
44
- { id: 'git-bash', name: 'Git Bash', kind: 'bash', path: RESOLVED_AUTO, args: [] },
45
- { id: 'cmd', name: 'CMD', kind: 'cmd', path: RESOLVED_AUTO, args: [] },
46
- ]),
47
- default: z.string().default('pwsh'),
46
+ { id: 'pwsh', kind: 'pwsh', path: RESOLVED_AUTO, args: [] },
47
+ { id: 'git-bash', kind: 'bash', path: RESOLVED_AUTO, args: [] },
48
+ { id: 'cmd', kind: 'cmd', path: RESOLVED_AUTO, args: [] },
49
+ ]).volatile(),
50
+ default: z.string().default('pwsh').volatile(),
48
51
  // 命令黑名单(整文本正则,大小写不敏感):命中即拒,deny 绝对无豁免;
49
52
  // 精细放行用正则前瞻在模式内表达。空数组 = 不拦截。
50
53
  // 护栏防误触,非安全边界(真边界是沙箱与访问模式)
51
- deny: z.array(z.string()).default([]),
52
- cwd: z.string(),
53
- timeoutMs: z.number().default(EXECUTOR_DEFAULTS.timeoutMs),
54
- maxTimeoutMs: z.number().default(EXECUTOR_DEFAULTS.maxTimeoutMs),
55
- maxOutputBytes: z.number().default(EXECUTOR_DEFAULTS.maxOutputBytes),
56
- maxSpillBytes: z.number().default(EXECUTOR_DEFAULTS.maxSpillBytes),
57
- graceMs: z.number().default(EXECUTOR_DEFAULTS.graceMs),
54
+ deny: z.array(z.string()).default([]).volatile(),
55
+ cwd: z.string().volatile(),
56
+ timeoutMs: z.number().default(EXECUTOR_DEFAULTS.timeoutMs).volatile(),
57
+ maxTimeoutMs: z.number().default(EXECUTOR_DEFAULTS.maxTimeoutMs).volatile(),
58
+ maxOutputBytes: z.number().default(EXECUTOR_DEFAULTS.maxOutputBytes).volatile(),
59
+ maxSpillBytes: z.number().default(EXECUTOR_DEFAULTS.maxSpillBytes).volatile(),
60
+ graceMs: z.number().default(EXECUTOR_DEFAULTS.graceMs).volatile(),
58
61
  })
59
62
 
63
+ // volatile 字段解箱:schema 声明 .volatile() 后(0.1.7 设置页写路径只接受
64
+ // volatile 字段,缺声明即 "has no volatile fields" 拒写),Config 产物字段为
65
+ // boxed ref(读值须 .get(),官方 dsh-bash-local 同构)。统一在此解箱,消费者
66
+ // (executor/web-routes/测试)一律拿普通对象。
67
+ // structuredClone 深拷贝双向:输入侧(sanitize 产物)切断与调用方对象的耦合——
68
+ // volatile ref .get() 产物是 cosmokit snapshot 深冻结对象,schemastery resolve
69
+ // 原地改写入参,冻结 env 直接炸挂载;输出侧:schemastery resolve 会原地改写入参
70
+ // (填默认/键归一)并把产出对象置为不可扩展,二轮 resolve(快照回灌,web 写路径
71
+ // current+patch 合并常态)对不可扩展对象赋值即炸;clone 切断产物与 schema 的原地
72
+ // 耦合,保证 resolveConfig 幂等且对输入不可变。
73
+ export function unwrapConfig(value) {
74
+ return Object.fromEntries(Object.entries(value).map(([key, field]) => {
75
+ const raw = typeof field?.get === 'function' ? field.get() : field
76
+ return [key, raw !== null && typeof raw === 'object' ? structuredClone(raw) : raw]
77
+ }))
78
+ }
79
+
80
+ /** schema 应用 + 解箱 + 输入深拷:全部消费者经此入口,不直接触 Config 产物。 */
81
+ export function resolveConfig(entry) {
82
+ const sanitized = sanitizeConfigEntry(entry ?? {})
83
+ return unwrapConfig(Config(structuredClone(sanitized)))
84
+ }
85
+
86
+ // 落盘行 config 的类型防御:设置写路径(原生页/mutate)可能把 volatile 字段
87
+ // 以非预期形态(ref 序列化产物 {})持久化,冷启动 Config 直接抛 → 行死 →
88
+ // settings describe 无此节 → 设置页保存 "no longer configurable" 死锁,
89
+ // 只能手工改 patch 解锁。类型不符的键降级为 schema 默认:行活、设置页活、
90
+ // 数据可经保存覆盖修复(官方 schema default 兜底同语义)。
91
+ // 字段类型契约:与 schema 声明一一对应,清洗按此对账
92
+ const FIELD_TYPES = {
93
+ shells: 'array',
94
+ deny: 'array',
95
+ default: 'string',
96
+ cwd: 'string',
97
+ timeoutMs: 'number',
98
+ maxTimeoutMs: 'number',
99
+ maxOutputBytes: 'number',
100
+ maxSpillBytes: 'number',
101
+ graceMs: 'number',
102
+ }
103
+ export function sanitizeConfigEntry(entry) {
104
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return {}
105
+ const out = {}
106
+ for (const [key, kind] of Object.entries(FIELD_TYPES)) {
107
+ const value = entry[key]
108
+ if (value === undefined) continue
109
+ const valid = kind === 'array' ? Array.isArray(value) : typeof value === kind
110
+ if (valid) out[key] = value
111
+ }
112
+ return out
113
+ }
114
+
60
115
  /** 出厂默认配置(供文档与测试比对;schema 内 default 为同构静态值)。 */
61
116
  export function defaultConfig() {
62
- return Config({})
117
+ return resolveConfig({})
118
+ }
119
+
120
+ // yaml 无引号标量会把 `\` 字面落盘,任何一层再转义都让路径翻倍(C:\\ 实测):
121
+ // 入口统一归一,保证进 schema 的 path 就是干净值。
122
+ // 仅 Windows 宿主生效:posix 路径以 / 分隔,归一会把合法路径毁成反斜杠字面(CI linux 实测)
123
+ export function normalizeWin32Path(path) {
124
+ if (process.platform !== 'win32') return path
125
+ if (typeof path !== 'string' || path.length === 0) return path
126
+ return path.replace(/\\{2,}/g, '\\').replace(/\//g, '\\')
127
+ }
128
+
129
+ // 更新负载深归一:shells[].path 与任意层字符串值只处理 path 键,避免误伤 args 模板
130
+ export function normalizeConfigPaths(patch) {
131
+ if (typeof patch !== 'object' || patch === null || !Array.isArray(patch.shells)) return patch
132
+ return {
133
+ ...patch,
134
+ shells: patch.shells.map((entry) => (typeof entry?.path === 'string' ? { ...entry, path: normalizeWin32Path(entry.path) } : entry)),
135
+ }
136
+ }
137
+
138
+ function assertPositiveFinite(name, value) {
139
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`shell-select: ${name} must be a positive finite number`)
140
+ }
141
+
142
+ /** 拒绝无法运行的已解析配置节(schema 之外的正数/时限/清单约束,官方 assertServiceable 同构)。 */
143
+ export function assertServiceableConfig(config) {
144
+ assertPositiveFinite('timeoutMs', config.timeoutMs)
145
+ assertPositiveFinite('maxTimeoutMs', config.maxTimeoutMs)
146
+ assertPositiveFinite('maxOutputBytes', config.maxOutputBytes)
147
+ assertPositiveFinite('maxSpillBytes', config.maxSpillBytes)
148
+ assertPositiveFinite('graceMs', config.graceMs)
149
+ if (config.graceMs > MAX_TIMER_DELAY_MS) throw new Error(`shell-select: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
150
+ if (!Array.isArray(config.shells) || config.shells.length === 0) throw new Error('shell-select: shells must not be empty')
151
+ const ids = new Set()
152
+ for (const entry of config.shells) {
153
+ if (ids.has(entry.id)) throw new Error(`shell-select: duplicate shell id "${entry.id}"`)
154
+ ids.add(entry.id)
155
+ }
156
+ if (!ids.has(config.default)) throw new Error(`shell-select: default "${config.default}" is not a configured shell id`)
63
157
  }
64
158
 
65
159
  /**