@marsken/dsh-studio 0.1.2 → 0.1.3

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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  | 项 | 值 |
8
8
  | --- | --- |
9
- | dsh-studio | `package.json` `version: 0.1.2` |
9
+ | dsh-studio | `package.json` `version: 0.1.3` |
10
10
  | DeepSeek Harness | `0.2.2-rc.1`(`dsh --version`) |
11
11
  | Node | `v24.22.2`(Linux 目标机);本机 Windows `v24.19.0` |
12
12
  | 目标系统 | Ubuntu 24.04,`dsh web` 监听 `18080`,nginx `:80` 反代并注入 `dsh-auth-*` cookie |
package/docs/publish.md CHANGED
@@ -127,6 +127,7 @@ token 只写进临时 npmrc(放在仓库外或 `tmp/` 且不提交),发完
127
127
  | --- | --- | --- |
128
128
  | `0.1.0` | 2026-11-13 | 首个公开版:看板 / 需求 / 用例回归 / 效能 / 设计评审四期 |
129
129
  | `0.1.1` | 2026-11-13 | 补三张 README 截图(npm 页可看图);默认值与文档去掉公司内网信息 |
130
+ | `0.1.3` | 2026-11-13 | shell 用例(命令 + 退出码/输出断言,默认关闭) |
130
131
  | `0.1.2` | 2026-11-13 | 修 5 个缺陷:卡片挂不上 MR、换列清空负责人、三处静默截断、还原度无历史、规范体检不定位元素 |
131
132
 
132
133
  两条硬规矩,每次发版都过一遍:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marsken/dsh-studio",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "DSH Studio: role-based multi-agent workspace (product / architect / developer / QA) with a live work board, GitLab + Feishu connection settings, all configurable from the web GUI.",
6
6
  "main": "index.js",
@@ -8,6 +8,7 @@
8
8
  * @module dsh-studio/src/host/domain
9
9
  */
10
10
  import { createHash, randomBytes } from 'node:crypto'
11
+ import { sanitizeCaseShell } from './shell-case.js'
11
12
  import { homedir } from 'node:os'
12
13
  import { dirname, join } from 'node:path'
13
14
 
@@ -1257,6 +1258,7 @@ export function sanitizeCaseHttp(raw) {
1257
1258
  }
1258
1259
 
1259
1260
  /** 一条测试用例:做什么、期望什么、实际结果,以及最近一次执行的样子。 */
1261
+
1260
1262
  export function sanitizeCase(raw, index) {
1261
1263
  if (typeof raw === 'string') {
1262
1264
  const title = raw.trim().slice(0, 200)
@@ -1275,6 +1277,7 @@ export function sanitizeCase(raw, index) {
1275
1277
  by: '',
1276
1278
  at: 0,
1277
1279
  http: null,
1280
+ shell: null,
1278
1281
  lastRun: null,
1279
1282
  }
1280
1283
  }
@@ -1287,6 +1290,7 @@ export function sanitizeCase(raw, index) {
1287
1290
  return {
1288
1291
  id: String(raw.id ?? '').trim() || `c${index + 1}-${randomBytes(2).toString('hex')}`,
1289
1292
  title,
1293
+ shell: sanitizeCaseShell(raw.shell),
1290
1294
  expected: String(raw.expected ?? '').trim().slice(0, 300),
1291
1295
  pointId: String(raw.pointId ?? '').trim().slice(0, 24),
1292
1296
  result,
@@ -1445,7 +1449,9 @@ export function sanitizeAllowHosts(input) {
1445
1449
 
1446
1450
  /** 测试执行配置:环境、默认环境、超时、允许的域名。 */
1447
1451
  export function sanitizeTesting(raw) {
1448
- const base = { envs: [], defaultEnv: '', timeoutMs: 15000, allowHosts: ['127.0.0.1', 'localhost'], concurrency: 4 }
1452
+ // allowShell 默认关:开了等于把「在宿主机执行任意命令」的能力交给看板。
1453
+
1454
+ const base = { envs: [], defaultEnv: '', timeoutMs: 15000, allowHosts: ['127.0.0.1', 'localhost'], concurrency: 4, allowShell: false, shellCwd: '' }
1449
1455
  if (raw === null || typeof raw !== 'object') return base
1450
1456
  const envs = sanitizeEnvs(raw.envs)
1451
1457
  const defaultEnv = String(raw.defaultEnv ?? '').trim()
@@ -1454,6 +1460,8 @@ export function sanitizeTesting(raw) {
1454
1460
  defaultEnv: envs.some(env => env.id === defaultEnv) ? defaultEnv : (envs[0]?.id ?? ''),
1455
1461
  timeoutMs: Math.max(1000, Math.min(120000, Number(raw.timeoutMs) || base.timeoutMs)),
1456
1462
  allowHosts: sanitizeAllowHosts(raw.allowHosts ?? base.allowHosts),
1463
+ allowShell: raw.allowShell === true,
1464
+ shellCwd: String(raw.shellCwd ?? '').slice(0, 300),
1457
1465
  concurrency: Math.max(1, Math.min(8, Number(raw.concurrency) || base.concurrency)),
1458
1466
  }
1459
1467
  }
@@ -8,6 +8,7 @@ import { casesOfTasks, impactSummary } from './impact.js'
8
8
  import { designGate } from './design.js'
9
9
  import { RUN_LOG, parseMentions } from './dispatcher.js'
10
10
  import { appendTeamMemory } from './team-memory.js'
11
+ import { runShellCase } from './shell-runner.js'
11
12
  import { homedir } from 'node:os'
12
13
  import { join, resolve } from 'node:path'
13
14
  import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
@@ -183,6 +184,7 @@ export async function probeEnv(env, config, timeoutMs = 5000) {
183
184
 
184
185
  export async function runOneCase(item, env, config, seedVars) {
185
186
  const started = Date.now()
187
+ if (item.shell !== null && item.shell !== undefined) return runShellCase(item, config)
186
188
  if (item.http == null) {
187
189
  return { ok: false, status: 0, ms: 0, error: '这条用例还没有可执行的请求定义(method/url)', preview: '' }
188
190
  }
@@ -297,7 +299,7 @@ export async function runTaskCases(store, taskId, options = {}) {
297
299
  ? new Set(options.caseIds.map(id => String(id)))
298
300
  : null
299
301
  const targets = (Array.isArray(task.cases) ? task.cases : [])
300
- .filter(item => (wanted === null ? item.http != null : wanted.has(item.id)))
302
+ .filter(item => (wanted === null ? item.http != null || item.shell != null : wanted.has(item.id)))
301
303
  if (targets.length === 0) throw new Error('没有可执行的用例:用例上要填 method + url(相对地址会拼环境 baseUrl)')
302
304
 
303
305
  const probe = await probeEnv(env, config)
@@ -940,7 +942,7 @@ export async function pullFeishuDoc(url) {
940
942
  }
941
943
  text = String(parsed.data?.document?.content ?? '')
942
944
  if (text.trim() === '') return { ok: false, tool: true, text: '', error: '拉到了空正文:这个文档可能是空的,或者应用没有阅读权限' }
943
- const title = /<title>([\s\S]*?)<\/title>/.exec(text)?.[1]?.trim() ?? ''
945
+ const title = text.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? ''
944
946
  const body = text.replace(/<title>[\s\S]*?<\/title>\s*/, '')
945
947
  return { ok: true, tool: true, title, text: body.slice(0, 60000), error: '' }
946
948
  } catch {
@@ -0,0 +1,16 @@
1
+ // shell 用例的数据形状。纯函数,放在这里是为了不把 domain.js 撑过 2000 行的闸门。
2
+
3
+ /** shell 用例:跑一条命令,看退出码与输出。默认**关**,要在设置里显式打开(见 config.testing.allowShell)。 */
4
+ export function sanitizeCaseShell(raw) {
5
+ if (raw === null || typeof raw !== 'object') return null
6
+ const command = String(raw.command ?? '').trim().slice(0, 2000)
7
+ if (command === '') return null
8
+ return {
9
+ command,
10
+ cwd: String(raw.cwd ?? '').trim().slice(0, 300),
11
+ timeoutMs: Math.max(1000, Math.min(120000, Number(raw.timeoutMs) || 30000)),
12
+ expectExit: Number.isInteger(Number(raw.expectExit)) ? Number(raw.expectExit) : 0,
13
+ expectContains: String(raw.expectContains ?? '').slice(0, 500),
14
+ expectNotContains: String(raw.expectNotContains ?? '').slice(0, 500),
15
+ }
16
+ }
@@ -0,0 +1,42 @@
1
+ // shell 用例执行器:跑一条命令,比对退出码与输出。
2
+ // 默认关闭(config.testing.allowShell)——打开它等于把「在宿主机执行任意命令」的能力交给看板。
3
+ import { exec } from 'node:child_process'
4
+
5
+ /** shell 用例:跑一条命令,比对退出码与输出。默认关闭,见 config.testing.allowShell。 */
6
+ export async function runShellCase(item, config) {
7
+ const started = Date.now()
8
+ const shell = item.shell
9
+ if (config?.testing?.allowShell !== true) {
10
+ return { ok: false, status: 0, ms: 0, error: '这台机器没开 shell 用例:设置 → 测试执行 里打开「允许 shell 用例」', preview: '' }
11
+ }
12
+ const cwd = shell.cwd !== '' ? shell.cwd : String(config?.testing?.shellCwd ?? '')
13
+ const timeout = shell.timeoutMs > 0 ? shell.timeoutMs : 30000
14
+ let stdout = ''
15
+ let stderr = ''
16
+ let code = 0
17
+ try {
18
+ const out = await new Promise(finish => {
19
+ exec(shell.command, { cwd: cwd === '' ? undefined : cwd, timeout, maxBuffer: 1024 * 1024 }, (error, outText, errText) => {
20
+ finish({ error, out: String(outText ?? ''), err: String(errText ?? '') })
21
+ })
22
+ })
23
+ stdout = out.out
24
+ stderr = out.err
25
+ code = out.error === null || out.error === undefined ? 0 : (Number(out.error.code) || 1)
26
+ } catch (error) {
27
+ stderr = String(error?.message ?? error)
28
+ code = 1
29
+ }
30
+ const combined = stdout + stderr
31
+ const problems = []
32
+ if (code !== shell.expectExit) problems.push('退出码 ' + code + '(期望 ' + shell.expectExit + ')')
33
+ if (shell.expectContains !== '' && !combined.includes(shell.expectContains)) problems.push('输出里没有「' + shell.expectContains + '」')
34
+ if (shell.expectNotContains !== '' && combined.includes(shell.expectNotContains)) problems.push('输出里有不该出现的「' + shell.expectNotContains + '」')
35
+ return {
36
+ ok: problems.length === 0,
37
+ status: code,
38
+ ms: Date.now() - started,
39
+ error: problems.join(';').slice(0, 300),
40
+ preview: combined.trim().slice(-600),
41
+ }
42
+ }