dsh-mesh-node 0.0.7 → 0.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mesh-node",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Mesh server for dsh-mesh: expose a machine to peer DSH nodes over MCP/HTTPS, with delegation and optional desktop-control tools.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,77 @@
1
+ // @generated 共享的「git 可执行文件解析 + 执行」,供 src/tools/git.js 与 src/tools/cluster.js 共用
2
+ // [why] 2026-11-10:mesh server 由计划任务/启动脚本拉起,PATH 里可能没有 git(peer-node 实测:git 在
3
+ // C:\Users\<user>\tools\git\cmd,只有 DSH 的 start-dsh0.cmd 把它写进 PATH)。git.js 已有
4
+ // 「DSH_MESH_GIT → PATH → 常见安装目录」回退;cluster.js 的 gitInfo() 却仍直接 execFileSync('git'),
5
+ // 于是 mesh_overview 的本机 repo 摘要在干净 PATH 下失效且只报空。抽成共用模块,两处行为一致。
6
+ import { join } from 'node:path'
7
+ import { run } from './util.js'
8
+
9
+ /** 找不到 git 时给用户的可诊断提示。 */
10
+ const SPAWN_HINT =
11
+ 'the mesh server process cannot find git on its PATH. Add the git directory to PATH in the mesh server start script, or set DSH_MESH_GIT to the git executable, then restart mesh (no DSH restart needed).'
12
+
13
+ /**
14
+ * git 可执行文件候选,按优先级:DSH_MESH_GIT 覆盖 → PATH 里的 git → 平台常见安装位置。
15
+ * @returns {string[]} 去重后的候选列表
16
+ */
17
+ export function gitCandidates() {
18
+ const env = process.env
19
+ const list = []
20
+ if (env.DSH_MESH_GIT) list.push(env.DSH_MESH_GIT)
21
+ list.push('git')
22
+ if (process.platform === 'win32') {
23
+ for (const root of [env.ProgramFiles, env.ProgramW6432, env['ProgramFiles(x86)']].filter(Boolean)) {
24
+ list.push(join(root, 'Git', 'cmd', 'git.exe'))
25
+ }
26
+ if (env.LOCALAPPDATA) list.push(join(env.LOCALAPPDATA, 'Programs', 'Git', 'cmd', 'git.exe'))
27
+ if (env.USERPROFILE) list.push(join(env.USERPROFILE, 'tools', 'git', 'cmd', 'git.exe'))
28
+ } else {
29
+ list.push('/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git')
30
+ }
31
+ return [...new Set(list)]
32
+ }
33
+
34
+ /** 解析一次并缓存(`git --version` 成功才算命中)。 */
35
+ let gitProbe = null
36
+ /**
37
+ * @returns {Promise<{bin: string, tried: string[]} | {error: {code: string, message: string}, tried: string[]}>}
38
+ */
39
+ export function gitBinary() {
40
+ if (!gitProbe) {
41
+ gitProbe = (async () => {
42
+ const tried = []
43
+ for (const candidate of gitCandidates()) {
44
+ tried.push(candidate)
45
+ const out = await run(candidate, ['--version'], { timeout: 5000 })
46
+ if (!out.error && /\bgit version\b/i.test(out.stdout)) return { bin: candidate, tried }
47
+ }
48
+ return { error: { code: 'ENOENT', message: `git not found; probed: ${tried.join(', ')}` }, tried }
49
+ })()
50
+ }
51
+ return gitProbe
52
+ }
53
+
54
+ /**
55
+ * 用解析到的 git 跑命令;解析失败时返回与 run() 同形的 error 结果,调用方不必各自处理。
56
+ * @param {string[]} args git 参数
57
+ * @param {{timeout?: number, cwd?: string, signal?: AbortSignal, maxBuffer?: number}} [options] 透传给 run()
58
+ * @returns {Promise<{error: unknown, stdout: string, stderr: string, code: number, killed: boolean, timedOut: boolean}>}
59
+ */
60
+ export async function runGit(args, options) {
61
+ const found = await gitBinary()
62
+ if (found.error) return { error: found.error, stdout: '', stderr: '', code: 0, killed: false, timedOut: false }
63
+ return run(found.bin, args, options)
64
+ }
65
+
66
+ /**
67
+ * 诊断信息:spawn 失败时附加到工具错误里,避免把「找不到 git」误报成「不是仓库」。
68
+ * @returns {{hint: string, gitCandidates: string[], pathEnv: string, nodePid: number}}
69
+ */
70
+ export function gitSpawnDiagnostics() {
71
+ return {
72
+ hint: SPAWN_HINT,
73
+ gitCandidates: gitCandidates(),
74
+ pathEnv: String(process.env.PATH || process.env.Path || ''),
75
+ nodePid: process.pid
76
+ }
77
+ }
@@ -0,0 +1,53 @@
1
+ // @generated 平台无关的「对端路径」拼接 / 取父目录(src 与 scripts 共用)
2
+ // [why] 2026-11-10:peer-inspect.mjs 用本机 `node:path.dirname` 推导对端用户目录。调用方可能是 Linux
3
+ // (手机 proot 里的 node),对端路径却是 Windows(`C:\Users\<user>\.dsh-mesh`)——
4
+ // posix dirname 把整串当成一个文件名,返回 `.`,于是拼出相对路径 `.dsh0/storages/workspace.json`,
5
+ // 被对端按 mesh server 的 cwd 解析成 `...\Code\dsh-mesh\.dsh0\storages\workspace.json` → E_NOT_FOUND。
6
+ // 对端路径的分隔符由**对端平台**决定,不能交给本机 path 模块;这里只用字符串处理,`/` 和 `\` 都支持。
7
+ // 2026-11-10 batch2(B5):从 scripts/lib/remote-path.mjs 提升到 src/core,供 src/tools/cluster.js 等共用;
8
+ // scripts/lib/remote-path.mjs 只做 re-export,保持既有 import 不变。
9
+
10
+ /** 探测一串路径使用的分隔符:出现反斜杠按 Windows 处理,否则按 POSIX。 */
11
+ function sepOf(p) {
12
+ return String(p ?? '').includes('\\') ? '\\' : '/'
13
+ }
14
+
15
+ /**
16
+ * 平台无关的取父目录。
17
+ * `C:\Users\<user>\.dsh-mesh` → `C:\Users\<user>`
18
+ * `/root/.dsh-mesh` → `/root`;`C:\` → `C:\`;`/` → `/`
19
+ * 没有分隔符的单段(相对名)原样返回——调用方只应对绝对路径使用它。
20
+ * @param {string} p 路径(对端风格,`/` 或 `\` 均可)
21
+ * @returns {string} 父目录
22
+ */
23
+ export function parentDir(p) {
24
+ const s = String(p ?? '')
25
+ if (!s) return ''
26
+ const trimmed = s.replace(/[\\/]+$/, '')
27
+ if (!trimmed) return s[0]
28
+ const m = /^(.*)[\\/][^\\/]*$/.exec(trimmed)
29
+ if (!m) {
30
+ // 单段:盘符(C:)要补回分隔符,相对名原样返回
31
+ return /^[A-Za-z]:$/.test(trimmed) ? trimmed + sepOf(s) : trimmed
32
+ }
33
+ if (/^[A-Za-z]:$/.test(m[1])) return m[1] + sepOf(s)
34
+ return m[1] || sepOf(s)
35
+ }
36
+
37
+ /**
38
+ * 平台无关拼接:分隔符跟随 base(base 含反斜杠就用 `\`),避免在本机用错分隔符拼出相对路径。
39
+ * `joinRemote('C:\\Users\\Administrator', '.dsh0', 'storages')` → `C:\Users\<user>\.dsh0\storages`
40
+ * @param {string} base 基准目录
41
+ * @param {...string} parts 追加的路径段
42
+ * @returns {string} 拼接结果
43
+ */
44
+ export function joinRemote(base, ...parts) {
45
+ const sep = sepOf(base)
46
+ let out = String(base ?? '').replace(/[\\/]+$/, '')
47
+ for (const part of parts) {
48
+ const p = String(part ?? '').replace(/^[\\/]+|[\\/]+$/g, '')
49
+ if (!p) continue
50
+ out = out ? `${out}${sep}${p}` : p
51
+ }
52
+ return out || sep
53
+ }
@@ -1,25 +1,42 @@
1
1
  import { z } from 'zod'
2
2
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
3
- import { execFileSync } from 'node:child_process'
4
3
  import { join } from 'node:path'
5
4
  import { loadPeers } from '../core/pairing.js'
6
5
  import { clusterReport, probePeer } from '../core/cluster.js'
7
6
  import { callPeerTool } from '../core/peer-client.js'
7
+ import { gitSpawnDiagnostics, runGit } from '../core/git-exec.js'
8
+ import { joinRemote } from '../core/remote-path.js'
8
9
  import { fail, ok } from '../core/result.js'
9
10
 
10
11
  const TERMINAL = new Set(['done', 'failed', 'cancelled'])
11
12
 
12
- function gitInfo(repo) {
13
+ /**
14
+ * 本机仓库摘要。走共享的 git 解析(DSH_MESH_GIT → PATH → 常见安装目录),
15
+ * 否则计划任务式干净 PATH 下这里会把「找不到 git」报成空摘要。
16
+ * @param {string} repo 仓库目录
17
+ * @returns {Promise<object>} { path, head, branch, dirty, ahead, behind } 或 { path, error, ...诊断 }
18
+ */
19
+ async function gitInfo(repo) {
13
20
  if (!repo) return { path: '', error: 'no repo path' }
14
- const run = (args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim()
21
+ const run = async (args) => {
22
+ const out = await runGit(['-C', repo, ...args], { timeout: 5000 })
23
+ if (out.error) {
24
+ const code = typeof out.error.code === 'string' ? out.error.code : `exit-${out.code}`
25
+ const error = new Error(out.error.message || out.stderr.trim() || `git ${args[0]} failed`)
26
+ error.spawnCode = typeof out.error.code === 'string' ? out.error.code : null
27
+ error.code = code
28
+ throw error
29
+ }
30
+ return out.stdout.trim()
31
+ }
15
32
  try {
16
- const head = run(['rev-parse', '--short', 'HEAD'])
17
- const branch = run(['rev-parse', '--abbrev-ref', 'HEAD'])
18
- const dirty = run(['status', '--porcelain']) !== ''
33
+ const head = await run(['rev-parse', '--short', 'HEAD'])
34
+ const branch = await run(['rev-parse', '--abbrev-ref', 'HEAD'])
35
+ const dirty = (await run(['status', '--porcelain'])) !== ''
19
36
  let ahead = null
20
37
  let behind = null
21
38
  try {
22
- const counts = run(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}']).split(/\s+/).map(Number)
39
+ const counts = (await run(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'])).split(/\s+/).map(Number)
23
40
  ahead = counts[0] || 0
24
41
  behind = counts[1] || 0
25
42
  } catch {
@@ -27,6 +44,9 @@ function gitInfo(repo) {
27
44
  }
28
45
  return { path: repo, head, branch, dirty, ahead, behind }
29
46
  } catch (error) {
47
+ if (error?.spawnCode) {
48
+ return { path: repo, error: `E_GIT_SPAWN: ${error.message}`, ...gitSpawnDiagnostics() }
49
+ }
30
50
  return { path: repo, error: String(error?.message || error) }
31
51
  }
32
52
  }
@@ -140,7 +160,8 @@ export function buildCluster(ctx) {
140
160
  const selfOut = await callPeerTool(peer, { tool: 'mesh_self', args: {}, timeoutMs: Math.min(timeoutMs, 8000) })
141
161
  const selfText = selfOut.result?.content?.[0]?.text || '{}'
142
162
  const self = JSON.parse(selfText)
143
- const repoPath = self.mesh?.repo || join(self.acl?.fsRoots?.[0]?.path || '', 'dsh-mesh')
163
+ // 对端仓库路径由**对端**决定:用平台无关拼接(Linux 节点拼 Windows 对端路径不能走本机 path.join)
164
+ const repoPath = self.mesh?.repo || joinRemote(self.acl?.fsRoots?.[0]?.path || '', 'dsh-mesh')
144
165
  const statusOut = await callPeerTool(peer, { tool: 'git_status', args: { path: repoPath }, timeoutMs: Math.min(timeoutMs, 8000) })
145
166
  const statusText = statusOut.result?.content?.[0]?.text || '{}'
146
167
  const status = JSON.parse(statusText)
@@ -157,12 +178,13 @@ export function buildCluster(ctx) {
157
178
  peerRepos.push({ peer: peer.name, error: String(error?.message || error) })
158
179
  }
159
180
  }
181
+ const localRepo = await gitInfo(process.cwd())
160
182
  return ok({
161
183
  node: report.node,
162
184
  summary: report.summary,
163
185
  verdict: report.verdict,
164
186
  issues: report.issues,
165
- repos: { local: gitInfo(process.cwd()), peers: peerRepos },
187
+ repos: { local: localRepo, peers: peerRepos },
166
188
  instances: (report.instances || []).map((instance) => ({
167
189
  id: instance.id,
168
190
  label: instance.label,