@mzzsfy/dsh-shell-select 0.3.1 → 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.
@@ -0,0 +1,167 @@
1
+ // 官方 pwsh 链(tool-pwsh + pwsh-sandbox)entry 生命周期探测与运行时接管。
2
+ // 运行时接管(替代 bundle patch 静态禁行):官方两行的禁用由本包主行 apply
3
+ // 落定后经 entry.update(dsh-market 同款调用)执行——禁用官方的前提严格强于
4
+ // 本包可服务性,本包任意死法(模块加载崩/apply 中途崩/被禁/卸载)下官方行
5
+ // 保持启用,官方 pwsh 链自服务,系统不出现 shell 全灭窗口。
6
+ //
7
+ // 接管空间的判定与 gateway(llm-pi-ai 行无 disabled 声明,undefined=默认态)
8
+ // 不同:官方两行在 dsh-base 带平台表达式 `disabled: !!js process.platform !==
9
+ // 'win32'`,win32 上求值 false——宿主默认启用态与「用户显式启用」在行树上
10
+ // 不可区分。因此行级 false 不作为让位信号:凡非 true(启用中)即接管空间,
11
+ // 用户意图通道改为「禁用本包」(market 禁主行或 user patch 禁行,重启后官方
12
+ // 自然回归)。getter 求值抛错(宿主状态异常)仍让位安全向;非布残值同样保守
13
+ // 让位。链式语义:禁用只更新启用中的行(0.1.7 web 面常态:tool-pwsh 被宿主
14
+ // patch 禁用、pwsh-sandbox 平台默认启用,混合态下仅禁后者)。
15
+
16
+ // 官方受控行 id 与行 id 解析前缀(guard-state 同源共享)
17
+ import { OFFICIAL_ROW_IDS, ROW_ID_PREFIXES } from './guard-state.mjs'
18
+
19
+ // 退场轮询步长与轮数上限,乘积为等待上界(guard 同源常量)
20
+ export const EXIT_POLL_INTERVAL_MS = 20
21
+ export const EXIT_POLL_ROUNDS = 50
22
+
23
+ const ABSENT = Object.freeze({ present: false, disabled: false, running: false, entry: undefined, disabledUnreliable: false, rawDisabled: true })
24
+
25
+ // 宿主 Entry.disabled getter 语义:!!js 表达式求值 + 父链回溯 + 布尔宽化,
26
+ // 裸读 options.disabled 会漏掉表达式与非布尔真值两类禁用形态;求值抛错按
27
+ // 未禁用处理并置 disabledUnreliable(宿主状态异常,自动动作一律让位安全向)
28
+ function effectiveDisabled(entry) {
29
+ try {
30
+ return entry.disabled === true
31
+ } catch {
32
+ return undefined
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 探测单个官方行生命周期状态。loader 缺失或行不存在按缺席处理(官方包未装
38
+ * 时照常接管,与「官方包缺失时本包独占」的既有降级语义一致)。disabled
39
+ * getter 求值抛错时 disabled=false 且 disabledUnreliable=true。rawDisabled
40
+ * 为 options.disabled 原始值(仅观测留档:真机形态是 !!js 表达式包装对象,
41
+ * 决策不消费它,启用中判定走 disabled 求值结果)。
42
+ * @param {object|undefined} loader 宿主 loader 服务(cordis Loader/EntryTree)
43
+ * @param {string} id 行 id
44
+ */
45
+ export function officialRowState(loader, id) {
46
+ if (loader?.resolve === undefined) return ABSENT
47
+ let entry
48
+ for (const prefix of ROW_ID_PREFIXES) {
49
+ try {
50
+ const candidate = loader.resolve(prefix + id)
51
+ if (candidate) {
52
+ entry = candidate
53
+ break
54
+ }
55
+ } catch {
56
+ // 该命名空间无此行,试下一前缀
57
+ }
58
+ }
59
+ if (!entry) return ABSENT
60
+ const disabled = effectiveDisabled(entry)
61
+ let rawDisabled
62
+ try {
63
+ rawDisabled = entry.options?.disabled
64
+ } catch {
65
+ rawDisabled = undefined
66
+ }
67
+ return {
68
+ present: true,
69
+ disabled: disabled === true,
70
+ running: entry.fiber?.uid != null,
71
+ entry,
72
+ disabledUnreliable: disabled === undefined,
73
+ rawDisabled,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 官方双行状态快照(id → 行状态,键序与 OFFICIAL_ROW_IDS 一致)。
79
+ * @param {object|undefined} loader
80
+ */
81
+ export function officialChainState(loader) {
82
+ return Object.fromEntries(OFFICIAL_ROW_IDS.map((id) => [id, officialRowState(loader, id)]))
83
+ }
84
+
85
+ /**
86
+ * 接管决策(链为单位):任一行 getter 求值不可信 → 让位安全向;全部已禁停稳
87
+ * → 接管;禁用但在退场 → 等待;存在启用中的行(getter 求值 false:平台默认
88
+ * 与无声明同形,同为接管空间)→ runtime-disable,仅禁启用中的行。注意
89
+ * options.disabled 原始值可能是 !!js 表达式包装对象(真机实证,非求值布尔),
90
+ * 启用中判定必须走 entry.disabled getter 求值结果,不读原始值。行缺席视为
91
+ * 已禁(不参与 update),全缺席 = 官方包未装 = 直接接管。
92
+ * @param {Record<string, object>} states officialChainState 快照
93
+ * @returns {{ decision: 'takeover'|'await-exit'|'yield'|'runtime-disable', targets: object[] }}
94
+ * targets = runtime-disable 时需 update 的 entry 清单,其余态为空
95
+ */
96
+ export function takeoverDecision(states) {
97
+ const rows = OFFICIAL_ROW_IDS.map((id) => states[id] ?? ABSENT)
98
+ if (rows.some((row) => row.present && row.disabledUnreliable)) return { decision: 'yield', targets: [] }
99
+ const targets = rows.filter((row) => row.present && row.disabled !== true).map((row) => row.entry)
100
+ if (targets.length > 0) return { decision: 'runtime-disable', targets }
101
+ const exiting = rows.some((row) => row.present && row.running)
102
+ return { decision: exiting ? 'await-exit' : 'takeover', targets: [] }
103
+ }
104
+
105
+ /**
106
+ * 运行时禁用一批官方行:dsh-market toggle 同款调用(force update,穿透
107
+ * 「init 在途时 options 翻转但收尾 init 仍拉起 fiber」的空 diff 形态)。
108
+ * 逐行独立执行,单行失败不中止其余行;调用方据失败清单决定回滚。
109
+ * 纯内存态不持久:重启后官方行回归行树声明态,由下一轮 apply 收敛。
110
+ * @param {object[]} entries 官方 loader entry 清单
111
+ * @param {{quiet?: boolean, force?: boolean}} [options]
112
+ * @returns {Promise<{disabled: object[], failed: object[]}>}
113
+ */
114
+ export async function runtimeDisableRows(entries, { quiet = false, force = true } = {}) {
115
+ const disabled = []
116
+ const failed = []
117
+ for (const entry of entries) {
118
+ try {
119
+ await entry.update({ disabled: true }, quiet, force)
120
+ disabled.push(entry)
121
+ } catch {
122
+ failed.push(entry)
123
+ }
124
+ }
125
+ return { disabled, failed }
126
+ }
127
+
128
+ /**
129
+ * 回滚:重新启用已禁的官方行(update {disabled:false}),官方链复活。
130
+ * 仅在接管序列失败路径调用;quiet 减噪(失败路径已另行告警),逐行独立,
131
+ * 失败不抛(重启后行树声明态自然归位)。
132
+ * @param {object[]} entries
133
+ */
134
+ export async function rollbackDisabledRows(entries) {
135
+ for (const entry of entries) {
136
+ try {
137
+ await entry.update({ disabled: false }, true, true)
138
+ } catch {
139
+ // 回滚失败不抛:重启收敛
140
+ }
141
+ }
142
+ }
143
+
144
+ const defaultDelay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
145
+
146
+ /**
147
+ * 有界等待官方行退场(fiber 清)。dispose 为纯内存操作毫秒级完成,轮询上界
148
+ * 仅防御异常宿主状态。fiber 清空后官方注册的撤销还在卸载收尾(微任务批)中
149
+ * 完成,返回前补一轮间隔让收尾落定,避免挂载执行器时与官方未撤完的 ctx.shell
150
+ * 注册交错(cordis 重复 provide 抛错)。
151
+ * @param {object[]} entries 官方 loader entry 清单
152
+ * @param {{intervalMs?: number, rounds?: number, delay?: (ms: number) => Promise<void>}} [options]
153
+ * @returns {Promise<boolean>} true = 已全部退场;false = 轮询耗尽仍有在场
154
+ */
155
+ export async function awaitOfficialExit(entries, {
156
+ intervalMs = EXIT_POLL_INTERVAL_MS,
157
+ rounds = EXIT_POLL_ROUNDS,
158
+ delay = defaultDelay,
159
+ } = {}) {
160
+ const stillRunning = () => entries.some((entry) => entry.fiber?.uid != null)
161
+ for (let round = 0; round < rounds && stillRunning(); round += 1) {
162
+ await delay(intervalMs)
163
+ }
164
+ if (stillRunning()) return false
165
+ await delay(intervalMs)
166
+ return true
167
+ }
package/src/tool.mjs CHANGED
@@ -3,6 +3,10 @@
3
3
  // 2. 描述动态含可用客户端清单与方言提示(settings onChange 重注册);
4
4
  // 3. 执行经 executor.entryFor/runFor/startFor 按条目 argv 运行。
5
5
  // 输出 schema、后台任务语义、升权流程、terminal 卡呈现与官方逐字同构。
6
+ // 另注册 pwsh 名字工具(镜像官方定义,钉死 pwsh 客户端):旧会话 preset
7
+ // 快照里的 pwsh 工具语义是"PowerShell 专用",缺省落 config.default 会随
8
+ // 用户改默认客户端而炸方言;本注册给新会话提供同名工具保语义,模型点名
9
+ // pwsh 时永远 PowerShell。
6
10
 
7
11
  import { TOOL_ABORTED, defineTool } from '@deepseek-ai/dsh-tools'
8
12
  import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
@@ -135,8 +139,23 @@ function shellDescription({ executor, backgroundEnabled, escalationModes }) {
135
139
  return base + ' Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Never escalate speculatively: ground the request in a real denial. If the session states approval prompts are disabled, a denial is final — do not set `sandbox_permissions`.'
136
140
  }
137
141
 
142
+ /** pwsh 工具描述:兼容保留工具,开头一句禁止调用。 */
143
+ function pwshDescription({ backgroundEnabled, escalationModes }) {
144
+ const base = 'Do not call this tool. It is kept only for backward compatibility with old sessions — call the `shell` tool instead. '
145
+ + 'It executes a PowerShell command and returns its stdout/stderr. '
146
+ + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. '
147
+ + 'Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed environment variables (`DSH_*`); inspect them when needed. '
148
+ + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
149
+ + (backgroundEnabled
150
+ ? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
151
+ : 'Background execution is not available; long-running commands must finish within the timeout.')
152
+ if (escalationModes.length === 0) return base
153
+ return base + ' Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Never escalate speculatively: ground the request in a real denial. If the session states approval prompts are disabled, a denial is final — do not set `sandbox_permissions`.'
154
+ }
155
+
138
156
  /**
139
- * 注册 shell 工具,返回卸载 disposer(重注册 = 先卸后挂)。
157
+ * 注册 shell + pwsh 双工具,返回卸载 disposer(重注册 = 先卸后挂)。
158
+ * pwsh 工具钉死 pwsh 客户端(config 删 pwsh 条目时 requireEntry 明确拒绝)。
140
159
  * @param {object} ctx cordis context(需 tools/shellEnv 服务在场)
141
160
  * @param {{executor: object}} faces 执行器实例
142
161
  */
@@ -166,24 +185,28 @@ export function registerShellTool(ctx, { executor }) {
166
185
  validateEscalationArgs(args.sandbox_permissions, args.justification)
167
186
  }
168
187
 
169
- const definition = (faces) => defineTool({
170
- name: 'shell',
171
- description: shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes }),
188
+ const makeDefinition = (toolName, pinned) => (faces) => defineTool({
189
+ name: toolName,
190
+ description: pinned === undefined
191
+ ? shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes })
192
+ : pwshDescription({ backgroundEnabled, escalationModes }),
172
193
  parameters: {
173
194
  command: {
174
195
  type: 'string',
175
196
  required: true,
176
- description: 'The command to execute, in the dialect of the selected shell client (default client when `shell` is omitted).',
197
+ description: pinned === undefined
198
+ ? 'The command to execute, in the dialect of the selected shell client (default client when `shell` is omitted).'
199
+ : 'The PowerShell command to execute.',
177
200
  },
178
201
  description: {
179
202
  type: 'string',
180
203
  required: true,
181
204
  description: 'Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI).',
182
205
  },
183
- shell: {
206
+ ...pinned === undefined ? { shell: {
184
207
  type: 'string',
185
208
  description: 'Shell client id from the tool description list (e.g. pwsh, git-bash, cmd). Omit to use the configured default client.',
186
- },
209
+ } } : {},
187
210
  timeoutMs: {
188
211
  type: 'number',
189
212
  description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.',
@@ -227,11 +250,11 @@ export function registerShellTool(ctx, { executor }) {
227
250
  validateArgs(args)
228
251
  const standingPolicy = resolveStandingPolicy(exec)
229
252
  const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
230
- ? await approveShellEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
253
+ ? await approveShellEscalation(toolName, args.sandbox_permissions, args.justification, exec, standingPolicy)
231
254
  : undefined
232
255
  const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode }
233
256
  const workdir = resolveWorkdir(args.workdir, exec)
234
- const entry = faces.executor.entryFor(args.shell)
257
+ const entry = faces.executor.entryFor(pinned ?? args.shell)
235
258
  const request = {
236
259
  command: args.command,
237
260
  ...workdir !== undefined ? { workdir } : {},
@@ -323,7 +346,7 @@ export function registerShellTool(ctx, { executor }) {
323
346
  }
324
347
 
325
348
  /** 升权审批:先于执行,共享 fail-closed 序列交 approveEscalation(官方同构)。 */
326
- function approveShellEscalation(mode, justification, exec, standingPolicy) {
349
+ function approveShellEscalation(toolName, mode, justification, exec, standingPolicy) {
327
350
  if (escalationModes.length === 0) throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
328
351
  const approver = ctx.get('approval')
329
352
  return approveEscalation({
@@ -335,16 +358,19 @@ export function registerShellTool(ctx, { executor }) {
335
358
  approver,
336
359
  agent: exec.agent,
337
360
  callId: exec.callId,
338
- toolName: 'shell',
361
+ toolName,
339
362
  signal: exec.signal,
340
363
  })
341
364
  }
342
365
 
343
366
  const faces = { executor }
344
- const disposer = ctx.tools.register(definition(faces))
367
+ const disposers = [
368
+ ctx.tools.register(makeDefinition('shell', undefined)(faces)),
369
+ ctx.tools.register(makeDefinition('pwsh', 'pwsh')(faces)),
370
+ ]
345
371
  return () => {
346
372
  if (disposed) return
347
373
  disposed = true
348
- disposer()
374
+ for (const disposer of disposers) disposer()
349
375
  }
350
376
  }
@@ -0,0 +1,91 @@
1
+ // shell-select web 路由行:设置页数据通道(inject 声明式门控,webServer/settings
2
+ // 就绪才激活——主行一次性同步探测的时序竞态即 0.1.7 设置页 404 事故根因)。
3
+ // faces 不依赖执行器实例:配置事实源 = 行 Config(0.1.7 settings 面以 profile
4
+ // 条目 id 为 ns,行 Config 即设置存储,变更经 cordis 行重载活生效),读经
5
+ // settings.describe,写经 settings.replace;探测/扫描用 resolve 纯函数。
6
+ // HTTP 形态(具名 exact 路由 + 跨源守卫 + 业务异常归一 400)照 dsh-maintain。
7
+
8
+ import { KINDS, resolveConfig, assertServiceableConfig, normalizeConfigPaths, normalizeWin32Path } from './config.mjs'
9
+ import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
10
+ import { requestShellRefresh } from './executor.mjs'
11
+ import { mountRoutes } from './api.mjs'
12
+
13
+ export const name = 'shell-select/web'
14
+
15
+ export const inject = ['webServer', 'settings']
16
+
17
+ // 设置节命名空间 = 本包主行在组合树中的条目 id(cordis.patch.yml insert id)
18
+ export const SECTION_NS = 'shell-select'
19
+
20
+ /** 会话可见清单:条目 + 解析后的真实路径与可用性(与主行 listShells 同构)。 */
21
+ function listShellsOf(section) {
22
+ return {
23
+ default: section.default,
24
+ shells: section.shells.map((entry) => {
25
+ const resolved = normalizeWin32Path(resolveEntryPath(entry, candidateExists))
26
+ return {
27
+ id: entry.id,
28
+ kind: entry.kind,
29
+ args: entry.args,
30
+ path: resolved,
31
+ available: resolved !== undefined,
32
+ login: entry.login === true,
33
+ distro: entry.distro ?? '',
34
+ env: entry.env ?? {},
35
+ }
36
+ }),
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 从 settings describe 投影当前节:目标 ns 缺失(主行未挂/旧宿主形态)时
42
+ * 回落出厂默认并告警,读路径不崩。
43
+ */
44
+ function readSection(ctx, settings) {
45
+ const found = (typeof settings.describe === 'function' ? settings.describe() : [])
46
+ .find((descriptor) => descriptor.ns === SECTION_NS)
47
+ if (found === undefined) {
48
+ ctx.logger?.warn?.(`shell-select: settings 面无 "${SECTION_NS}" 节,设置页回落出厂默认`)
49
+ return resolveConfig({})
50
+ }
51
+ return resolveConfig(found.value ?? {})
52
+ }
53
+
54
+ /**
55
+ * 设置页 faces:HTTP 层(api.mjs)与配置事实源(settings 行条目)的适配。
56
+ * @param {{logger?: {warn?: Function}}} ctx 行上下文(告警通道)
57
+ * @param settings 宿主 settings 服务(describe/replace)
58
+ */
59
+ export function buildFaces(ctx, settings) {
60
+ const readCurrent = () => readSection(ctx, settings)
61
+ return {
62
+ readConfig: () => readCurrent(),
63
+ listShells: () => listShellsOf(readCurrent()),
64
+ // replace 落盘后经模块级桥刷新执行面:执行器 provide 的 ctx.shell 作用域
65
+ // 在主行子树,本行是兄弟行够不着(cordis 服务不横向查找,getter 直接
66
+ // throw);cordis 对 volatile-only diff 换 ref 不重挂行,执行器手里的
67
+ // 挂载快照就此陈旧——replace 成功即权威信号,桥调活跃实例换源并重建
68
+ // 工具注册(原生设置页对本行被 auto:false 抑制,本 API 是唯一写口)
69
+ async updateConfig(patch) {
70
+ const current = readCurrent()
71
+ const section = normalizeConfigPaths({
72
+ ...current,
73
+ shells: patch.shells ?? current.shells,
74
+ default: patch.default ?? current.default,
75
+ deny: Array.isArray(patch.deny) ? patch.deny : current.deny,
76
+ })
77
+ const validated = resolveConfig(section)
78
+ assertServiceableConfig(validated)
79
+ await settings.replace(SECTION_NS, section)
80
+ requestShellRefresh(validated)
81
+ return listShellsOf(validated)
82
+ },
83
+ detect: (kinds) => detectCandidates(kinds ?? [...KINDS], process.env, candidateExists),
84
+ probe: (candidatePath) => candidateExists(candidatePath),
85
+ }
86
+ }
87
+
88
+ /** 行入口:webServer/settings 已由 inject 门控就绪。 */
89
+ export function apply(ctx) {
90
+ mountRoutes(ctx, buildFaces(ctx, ctx.get('settings')))
91
+ }
@@ -34,6 +34,16 @@ function parseEnvText() {
34
34
  return extractLogic('parseEnvText')
35
35
  }
36
36
 
37
+ test('运行时长格式:秒→m:ss,一小时以上→h:mm:ss,负值钳 0', () => {
38
+ const formatDuration = extractLogic('formatDuration')
39
+ assert.equal(formatDuration(0), '0:00')
40
+ assert.equal(formatDuration(42_000), '0:42')
41
+ assert.equal(formatDuration(61_000), '1:01')
42
+ assert.equal(formatDuration(3600_000), '1:00:00')
43
+ assert.equal(formatDuration(3661_000), '1:01:01')
44
+ assert.equal(formatDuration(-5), '0:00')
45
+ })
46
+
37
47
  function invalidEnvLines() {
38
48
  return extractLogic('invalidEnvLines')
39
49
  }
@@ -57,10 +67,11 @@ function settledBlock(argsRaw, text, options = {}) {
57
67
  const ARGS = JSON.stringify({ command: 'git status', description: 'Show working tree status', workdir: 'sub' })
58
68
  const SESSION_CWD = 'C:\\repo'
59
69
 
60
- test('S11 running 且无 description(persistent 形)回退 generic', () => {
70
+ test('S11 running 且无 description(persistent 形):全量卡不回退(阻塞时展开必有命令)', () => {
61
71
  const model = cardModel()(runningBlock(JSON.stringify({ command: 'interactive session' })), SESSION_CWD)
62
- assert.equal(model.kind, 'generic')
63
- assert.equal(model.running, true)
72
+ assert.equal(model.kind, 'terminal')
73
+ assert.equal(model.status, 'running')
74
+ assert.equal(model.description, undefined)
64
75
  })
65
76
 
66
77
  test('S11b settled 无 description(persistent 结束)回退 generic', () => {
@@ -0,0 +1,170 @@
1
+ // 渲染守卫:全量执行 client.js 工厂(React 桩),实际调用注册出的 toolview
2
+ // 组件——纯文本/LOGIC 提取守卫抓不住的渲染期未定义引用(model 丢失事故)在此暴露。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url))
11
+ const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
12
+
13
+ function reactStub() {
14
+ // 函数组件即时执行(模拟渲染语义):组件体内的未定义引用/坏访问当场抛出
15
+ const createElement = (type, props, ...children) => {
16
+ if (typeof type === 'function') return type(props ?? {})
17
+ return { type, props, children }
18
+ }
19
+ return {
20
+ createElement,
21
+ useState: (init) => [typeof init === 'function' ? init() : init, () => {}],
22
+ useEffect: () => {},
23
+ }
24
+ }
25
+
26
+ // 加载 client.js 并执行 apply,返回 slots 注册面
27
+ function loadClient() {
28
+ const React = reactStub()
29
+ const registrations = []
30
+ const slots = {
31
+ inject: (_name, factory) => factory(),
32
+ register: (meta, component) => registrations.push({ meta, component }),
33
+ }
34
+ const document = {
35
+ documentElement: { lang: 'zh-CN' },
36
+ createElement: () => ({ setAttribute: () => {}, remove: () => {}, style: {} }),
37
+ head: { appendChild: () => {} },
38
+ }
39
+ let loaded
40
+ const window = { __ModuleLoader__: { load: (definition) => { loaded = definition } } }
41
+ new Function('window', 'document', 'navigator', source)(window, document, {}, undefined)
42
+ assert.notEqual(loaded, undefined, 'client.js 未调用 __ModuleLoader__.load')
43
+ const ctx = {
44
+ effect(fn) {
45
+ return fn()
46
+ },
47
+ slots,
48
+ }
49
+ loaded.factory((name) => {
50
+ if (name === 'react') return React
51
+ // 其余模块(primitives 等)按缺席处理:抛错触发 client 内 try/catch 降级
52
+ throw new Error('module not available: ' + name)
53
+ }).apply(ctx)
54
+ return registrations
55
+ }
56
+
57
+ function toolviewOf(registrations, key) {
58
+ const view = registrations.find((item) => item.meta.name === 'tool.call.toolview' && item.meta.key === key)
59
+ assert.notEqual(view, undefined, `缺少 key=${key} 的 toolview 注册`)
60
+ return view.component
61
+ }
62
+
63
+ // 已定调用块(官方形态:kind 字段在)
64
+ function settledBlock(argsRaw, text) {
65
+ return {
66
+ kind: 'tool',
67
+ callId: 'c1',
68
+ call: { callId: 'c1', name: 'shell', argsRaw },
69
+ content: [{ type: 'text', text }],
70
+ }
71
+ }
72
+
73
+ test('渲染守卫:settled terminal 调用组件执行不抛(引用完整)', () => {
74
+ const component = toolviewOf(loadClient(), 'shell')
75
+ const element = component({
76
+ block: settledBlock(JSON.stringify({ command: 'git status', description: 'status' }), 'On branch main\n[exit code: 0]'),
77
+ cwd: 'C:\\repo',
78
+ })
79
+ assert.notEqual(element, undefined)
80
+ })
81
+
82
+ test('渲染守卫:running 块组件执行不抛', () => {
83
+ const component = toolviewOf(loadClient(), 'pwsh')
84
+ const element = component({
85
+ block: { callId: 'c2', name: 'pwsh', argsRaw: JSON.stringify({ command: 'Start-Sleep 1', description: 'wait' }), time: Date.now() - 65_000 },
86
+ cwd: 'C:\\repo',
87
+ })
88
+ assert.notEqual(element, undefined)
89
+ })
90
+
91
+ test('渲染守卫:运行中卡展开后可见命令全文与实时时长', () => {
92
+ const component = toolviewOf(loadClient(), 'shell')
93
+ // 默认收起:运行中折叠行 = 黄点 + 描述 + 时长,不含卡体
94
+ const element = component({
95
+ block: { callId: 'c3', name: 'shell', argsRaw: JSON.stringify({ command: 'npm run build', description: 'build' }), time: Date.now() - 65_000 },
96
+ cwd: 'C:\\repo',
97
+ })
98
+ assert.doesNotMatch(JSON.stringify(element), /sls-tv__body/)
99
+ assert.match(JSON.stringify(element), /1:05/)
100
+ })
101
+
102
+ test('渲染守卫:后台 ack 块(默认收起行 + 任务号徽标)组件执行不抛', () => {
103
+ const component = toolviewOf(loadClient(), 'bash')
104
+ const element = component({
105
+ block: settledBlock(JSON.stringify({ command: 'npm test', description: 'tests', run_in_background: true }), 'started background job bg-1'),
106
+ cwd: 'C:\\repo',
107
+ })
108
+ assert.notEqual(element, undefined)
109
+ })
110
+
111
+ test('设置卡注册:settings.section 单卡 id shell-select', () => {
112
+ const registrations = loadClient()
113
+ const card = registrations.find((item) => item.meta.name === 'settings.section')
114
+ assert.notEqual(card, undefined)
115
+ assert.equal(card.meta.id, 'shell-select')
116
+ })
117
+
118
+ test('图标名守卫:0.1.7 primitives 字重后缀名在场,尺寸后缀旧名不在首选位', () => {
119
+ // 0.1.7 图标改名:尺寸从名字移除(IconApiOutline14 → IconApiOutlineRegular/Medium),
120
+ // 旧名 miss 使 IconApi 落淡灰 fallback 被当成"空白 icon"(实测事故)
121
+ assert.match(source, /'IconApiOutlineRegular'/)
122
+ assert.match(source, /'IconChevronDownOutlineRegular'/)
123
+ assert.match(source, /'IconInspectOutlineRegular'/)
124
+ assert.doesNotMatch(source, /createElementOf\('IconApiOutline14'/)
125
+ })
126
+
127
+ test('名册单飞守卫:失败复位 promise,下次渲染重试(宿主启动窗口 404 不永久缺徽章)', () => {
128
+ // 旧实现 catch 静默吞错且 promise 不复位:boot 窗口期 404 后所有卡永久缺客户端徽章
129
+ assert.doesNotMatch(source, /catch\(\(\) => \{ \}\)/)
130
+ assert.match(source, /clientCatalogPromise = null/)
131
+ })
132
+
133
+ test('后台卡头部守卫:状态点缺省渲染(bg pill 即标识),非法 StateDot state 不再传入', () => {
134
+ // StateDot 合法 state 仅 done/warning/ongoing/error/idle;'none' 是编造值
135
+ assert.match(source, /dot: undefined/)
136
+ assert.match(source, /meta\.dot !== undefined \? TOOLVIEW_ICONS\.StateDot/)
137
+ assert.doesNotMatch(source, /dot: 'none'/)
138
+ })
139
+
140
+ test('hooks 恒序守卫:组件内 hook 调用必须全部位于第一个提前 return 之前', () => {
141
+ // 真实 React 的 hooks 链表按调用序对位:提前 return 之后的 hook 使不同
142
+ // 块形态(generic/terminal)的 hook 数量分叉 → "Rendered fewer hooks
143
+ // than expected" → 行组件树整棵卸载(shell 卡全消失事故)。React 桩无
144
+ // hooks 链表,渲染守卫抓不住,必须源码结构断言。
145
+ const extract = (name) => {
146
+ const begin = source.indexOf(`function ${name}(`)
147
+ assert.ok(begin >= 0, `缺函数 ${name}`)
148
+ const beginLine = source.slice(0, begin).split('\n').length
149
+ const body = source.slice(begin)
150
+ const end = body.indexOf('\n function ', 1)
151
+ const scope = end >= 0 ? body.slice(0, end) : body
152
+ const hookLines = [...scope.matchAll(/\b(?:useState|useEffect|useRef|useMemo|useCallback)\(/g)]
153
+ .map((m) => beginLine + scope.slice(0, m.index).split('\n').length - 1)
154
+ return { hookLines, scopeLines: beginLine + scope.split('\n').length }
155
+ }
156
+ const earlyReturnLine = (scope, beginLine) => {
157
+ const m = /return h\(/.exec(scope)
158
+ return m === null ? Number.POSITIVE_INFINITY : beginLine + scope.slice(0, m.index).split('\n').length - 1
159
+ }
160
+ for (const name of ['ShellToolRow', 'GenericShellRow', 'CopyButton', 'RunningDuration', 'ShellSelectApp']) {
161
+ const { hookLines } = extract(name)
162
+ const scopeBody = source.slice(source.indexOf(`function ${name}(`))
163
+ const end = scopeBody.indexOf('\n function ', 1)
164
+ const scope = end >= 0 ? scopeBody.slice(0, end) : scopeBody
165
+ const limit = earlyReturnLine(scope, source.slice(0, source.indexOf(`function ${name}(`)).split('\n').length)
166
+ for (const line of hookLines) {
167
+ assert.ok(line < limit, `${name} 的 hook 在第 ${line} 行,位于提前 return(第 ${limit} 行)之后:hooks 恒序被破坏`)
168
+ }
169
+ }
170
+ })
@@ -1,12 +1,12 @@
1
- // 条目级 env 与 wsl distro:schema 默认、argv 形、entryFor 透传。BDD 场景见 docs/feat-shell-select-optim/plan.md S5-S10。
1
+ // 条目级 env 与 wsl distro:schema 默认、argv 形、entryFor 透传。BDD 场景见 docs/feat-shell-select-optim/plan.md S5-S10。
2
2
 
3
3
  import { test } from 'node:test'
4
4
  import assert from 'node:assert/strict'
5
5
  import ShellSelectExecutor from '../src/executor.mjs'
6
- import { Config, buildArgv, defaultConfig } from '../src/config.mjs'
6
+ import { resolveConfig, buildArgv, defaultConfig, entryById, requireEntry } from '../src/config.mjs'
7
7
 
8
8
  test('S5 schema 默认:条目 env 空对象、distro 空串,旧配置反序列化补默认', () => {
9
- const config = Config({ shells: [{ id: 'w', name: 'WSL', kind: 'wsl' }], default: 'w' })
9
+ const config = resolveConfig({ shells: [{ id: 'w', name: 'WSL', kind: 'wsl' }], default: 'w' })
10
10
  const entry = config.shells[0]
11
11
  assert.deepEqual(entry.env, {})
12
12
  assert.equal(entry.distro, '')
@@ -32,7 +32,7 @@ test('S10 args 模板条目接管 argv,distro 不出现', () => {
32
32
 
33
33
  test('S6 entryFor 透传 env 与 distro;spawnSpec env 含条目键', () => {
34
34
  const cmdPath = process.execPath
35
- const config = Config({
35
+ const config = resolveConfig({
36
36
  shells: [{ id: 'c', name: 'CMD', kind: 'cmd', path: cmdPath, env: { MSYSTEM: 'MINGW64' } }, { id: 'p', name: 'pwsh', kind: 'pwsh' }],
37
37
  default: 'c',
38
38
  })
@@ -45,19 +45,24 @@ test('S6 entryFor 透传 env 与 distro;spawnSpec env 含条目键', () => {
45
45
  assert.equal(spawned.env.NO_COLOR, '1')
46
46
  })
47
47
 
48
- test('S7 updateConfig 接受 env/distro 字段并落盘回读', async () => {
49
- const section = Config({
48
+ test('S7 web 行写路径接受 env/distro 字段并落盘', async () => {
49
+ const { SECTION_NS, buildFaces } = await import('../src/web-routes.mjs')
50
+ const section = resolveConfig({
50
51
  shells: [
51
52
  { id: 'p', name: 'pwsh', kind: 'pwsh' },
52
53
  { id: 'w', name: 'WSL', kind: 'wsl', distro: 'Debian', env: { LANG: 'C.UTF-8' } },
53
54
  ],
54
55
  default: 'p',
55
56
  })
56
- const executor = new ShellSelectExecutor(stubCtxFor(section), section)
57
- const next = await executor.updateConfig({})
57
+ const replaced = []
58
+ const ctx = { logger: { warn: () => {} } }
59
+ const settings = { describe: () => [{ ns: SECTION_NS, value: section }], replace: async (ns, next) => { replaced.push({ ns, next }) } }
60
+ const faces = buildFaces(ctx, settings)
61
+ const next = await faces.updateConfig({})
58
62
  const wsl = next.shells.find((entry) => entry.id === 'w')
59
63
  assert.equal(wsl.distro, 'Debian')
60
64
  assert.deepEqual(wsl.env, { LANG: 'C.UTF-8' })
65
+ assert.equal(replaced[0].ns, SECTION_NS)
61
66
  })
62
67
 
63
68
  function stubCtxFor(config) {