dsh-mesh-node 0.0.0-stage → 0.0.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +341 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. package/src/tools/tasks.js +117 -0
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * Delegation safety profile (2026-10-25).
6
+ *
7
+ * A peer must never inject a task into a session that owns a scheduled job:
8
+ * that mixes two contexts and pollutes the scheduled conversation (real
9
+ * incident: a `channel_wake` landed inside a company `定时-*` session).
10
+ *
11
+ * The profile joins two local sources of truth:
12
+ * - `<dshHome>/storages/schedule.json` schedules -> sessionId
13
+ * - `<fsRoot>/.mesh/tasks/<taskId>/request.json` delegations -> sessionId
14
+ *
15
+ * A session is *protected* when it owns at least one schedule record.
16
+ * Delegations are the sessions this node created for a peer, so they are
17
+ * identified explicitly instead of being guessed from titles.
18
+ */
19
+
20
+ const TITLE_SCHEDULE_HINT = /定时|班次|schedule|nightly/i
21
+ const EMPTY = Object.freeze([])
22
+
23
+ function readJson(file, fallback = null) {
24
+ try {
25
+ return JSON.parse(readFileSync(file, 'utf8'))
26
+ } catch {
27
+ return fallback
28
+ }
29
+ }
30
+
31
+ /** `<home>/profiles/web` -> `<home>`; empty/unknown profileDir -> ''. */
32
+ export function dshHomeOf(hostEntry) {
33
+ const dir = String(hostEntry?.profileDir || '')
34
+ if (!dir) return ''
35
+ return dirname(dirname(dir))
36
+ }
37
+
38
+ /** sessionId -> [{id,title,kind,status,instance}] for one configured instance. */
39
+ export function readScheduleIndex(hostEntry) {
40
+ const home = dshHomeOf(hostEntry)
41
+ const file = home ? join(home, 'storages', 'schedule.json') : ''
42
+ const index = new Map()
43
+ const raw = file && existsSync(file) ? readJson(file) : null
44
+ const tasks = raw?.tables?.tasks || {}
45
+ for (const [id, entry] of Object.entries(tasks)) {
46
+ const sessionId = entry?.sessionId
47
+ if (!sessionId) continue
48
+ const record = entry.record || {}
49
+ const rows = index.get(sessionId) || []
50
+ rows.push({
51
+ id,
52
+ title: record.title || '',
53
+ kind: record.kind || '',
54
+ status: entry.status || '',
55
+ instance: hostEntry?.id || ''
56
+ })
57
+ index.set(sessionId, rows)
58
+ }
59
+ return index
60
+ }
61
+
62
+ /** sessionId -> delegation record from this node's `.mesh/tasks` mailbox. */
63
+ export function readDelegationIndex(root) {
64
+ const dir = root ? join(root, '.mesh', 'tasks') : ''
65
+ const index = new Map()
66
+ if (!dir || !existsSync(dir)) return index
67
+ let names = []
68
+ try {
69
+ names = readdirSync(dir)
70
+ } catch {
71
+ return index
72
+ }
73
+ for (const name of names) {
74
+ const request = readJson(join(dir, name, 'request.json'))
75
+ if (!request?.sessionId) continue
76
+ index.set(request.sessionId, {
77
+ taskId: request.taskId || name,
78
+ conversationId: request.conversationId || '',
79
+ from: request.from || '',
80
+ policy: request.sessionPolicy || '',
81
+ createdAt: request.requestedAt || ''
82
+ })
83
+ }
84
+ return index
85
+ }
86
+
87
+ /** Join every configured instance's schedules plus this node's delegations. */
88
+ export function buildSessionProfile(config) {
89
+ const schedules = new Map()
90
+ for (const entry of config?.hosts || []) {
91
+ if (entry?.enabled === false) continue
92
+ for (const [sessionId, rows] of readScheduleIndex(entry)) {
93
+ const merged = schedules.get(sessionId) || []
94
+ merged.push(...rows.map((row) => ({ ...row, instance: entry.id })))
95
+ schedules.set(sessionId, merged)
96
+ }
97
+ }
98
+ return { schedules, delegations: readDelegationIndex(config?.acl?.fsRoots?.[0] || '') }
99
+ }
100
+
101
+ /** Classify one host_sessions / delegate_candidates / listAgents row. */
102
+ export function classifySession(profile, session) {
103
+ const sessionId = session?.sessionId || session?.id || ''
104
+ const schedules = profile?.schedules?.get(sessionId) || EMPTY
105
+ const delegation = profile?.delegations?.get(sessionId) || null
106
+ const title = String(session?.title || '')
107
+ const alive = session?.agentAvailable === true && session?.state !== 'ended'
108
+ let kind = 'unknown'
109
+ let reason = 'no schedule record and no delegation record'
110
+ if (schedules.length > 0) {
111
+ kind = 'business-scheduled'
112
+ reason = `owns ${schedules.length} scheduled task(s) — injecting here would pollute the scheduled conversation`
113
+ } else if (delegation) {
114
+ kind = 'delegation'
115
+ reason = `created by delegation ${delegation.taskId}`
116
+ } else if (TITLE_SCHEDULE_HINT.test(title)) {
117
+ kind = 'business'
118
+ reason = 'title looks like a scheduled/business session (no schedule record found — still inject with care)'
119
+ }
120
+ return {
121
+ kind,
122
+ protected: schedules.length > 0,
123
+ delegatable: alive && schedules.length === 0,
124
+ recommended: alive && String(session?.state) === 'idle' && schedules.length === 0,
125
+ reason,
126
+ schedules,
127
+ delegation
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Guard for write-capable tools (host_session_prompt / channel_wake /
133
+ * host_call / delegate continuation). Returns null when the target is safe.
134
+ */
135
+ export function sessionBlock(profile, sessionId) {
136
+ const schedules = profile?.schedules?.get(String(sessionId || '')) || EMPTY
137
+ if (schedules.length === 0) return null
138
+ return {
139
+ kind: 'business-scheduled',
140
+ reason: `session ${sessionId} owns ${schedules.length} scheduled task(s): ${schedules
141
+ .map((row) => row.title || row.id)
142
+ .join(' / ')}`,
143
+ schedules,
144
+ hint: 'delegate to a FRESH conversation instead (omit sessionId), or pass force:true if you really mean to inject into this scheduled session'
145
+ }
146
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * TLS:自签证书生成与校验信息读取。
3
+ *
4
+ * 为什么需要:樱花穿透的 TCP 隧道默认不加密(只有「访问认证」,没有传输加密),
5
+ * Bearer token 与所有 MCP 报文都会明文过公网。跨网阶段必须自带 TLS。
6
+ *
7
+ * 设计取舍:
8
+ * - 自签「CA-and-leaf」证书(basicConstraints CA:true + serverAuth EKU),这样对端
9
+ * 把 cert.pem 当作信任锚(NODE_EXTRA_CA_CERTS)就能通过校验,不需要额外 CA 基础设施。
10
+ * - SAN 里默认包含 localhost / 127.0.0.1 / 本机 hostname;樱花隧道建好后用
11
+ * `--san <节点域名>` 重新生成(或首次生成时一次给全)。
12
+ * - 私钥 0600 落 `~/.dsh-mesh/tls/key.pem`,不进 git、不进任何快照。
13
+ */
14
+
15
+ import { X509Certificate } from 'node:crypto'
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { hostname as osHostname } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import selfsigned from 'selfsigned'
20
+
21
+ /** TLS 文件目录。 */
22
+ export function tlsDir(home) {
23
+ return join(home, 'tls')
24
+ }
25
+
26
+ /** 证书路径(可在 config.tls.cert 覆盖)。 */
27
+ export function certPath(home) {
28
+ return join(tlsDir(home), 'cert.pem')
29
+ }
30
+
31
+ /** 私钥路径(可在 config.tls.key 覆盖)。 */
32
+ export function keyPath(home) {
33
+ return join(tlsDir(home), 'key.pem')
34
+ }
35
+
36
+ /** 是否已生成证书。 */
37
+ export function hasCert(home) {
38
+ return existsSync(certPath(home)) && existsSync(keyPath(home))
39
+ }
40
+
41
+ /** 判断字符串是不是 IPv4/IPv6 字面量。 */
42
+ function isIp(value) {
43
+ return /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || /^[0-9a-f:]+$/i.test(value) && value.includes(':')
44
+ }
45
+
46
+ /** 构造 SAN 列表:总是包含本机可用的默认项,再合并调用方给的。 */
47
+ export function buildAltNames(extra = []) {
48
+ const names = new Set(['localhost', '127.0.0.1', osHostname()])
49
+ for (const item of extra) {
50
+ const value = String(item).trim()
51
+ if (value.length > 0) names.add(value)
52
+ }
53
+ return [...names].map(value => (isIp(value) ? { type: 7, ip: value } : { type: 2, value }))
54
+ }
55
+
56
+ /**
57
+ * 生成自签证书。
58
+ * @returns {{certPath: string, keyPath: string, info: object}}
59
+ */
60
+ export async function generateCert({ home, sans = [], days = 825, keySize = 2048, commonName, force = false }) {
61
+ const dir = tlsDir(home)
62
+ mkdirSync(dir, { recursive: true })
63
+ if (!force && hasCert(home)) {
64
+ return { certPath: certPath(home), keyPath: keyPath(home), info: describeCert(home), reused: true }
65
+ }
66
+ const altNames = buildAltNames(sans)
67
+ const attrs = [{ name: 'commonName', value: commonName ?? `dsh-mesh-${osHostname()}` }]
68
+ const pems = await selfsigned.generate(attrs, {
69
+ days,
70
+ keySize,
71
+ algorithm: 'sha256',
72
+ extensions: [
73
+ { name: 'basicConstraints', cA: true },
74
+ { name: 'keyUsage', digitalSignature: true, keyEncipherment: true, keyCertSign: true },
75
+ { name: 'extKeyUsage', serverAuth: true, clientAuth: true },
76
+ { name: 'subjectAltName', altNames },
77
+ ],
78
+ })
79
+ writeFileSync(certPath(home), pems.cert, { mode: 0o644 })
80
+ writeFileSync(keyPath(home), pems.private, { mode: 0o600 })
81
+ return { certPath: certPath(home), keyPath: keyPath(home), info: describeCert(home), reused: false, sans: altNames }
82
+ }
83
+
84
+ /** 读取证书的人类可读信息(也用于 `tls info`)。 */
85
+ export function describeCert(home) {
86
+ if (!hasCert(home)) return { present: false }
87
+ const cert = new X509Certificate(readFileSync(certPath(home)))
88
+ return {
89
+ present: true,
90
+ certPath: certPath(home),
91
+ keyPath: keyPath(home),
92
+ subject: cert.subject.replace(/\n/g, ', '),
93
+ issuer: cert.issuer.replace(/\n/g, ', '),
94
+ validFrom: cert.validFrom,
95
+ validTo: cert.validTo,
96
+ validToDate: new Date(cert.validTo).toISOString(),
97
+ fingerprint256: cert.fingerprint256,
98
+ serialNumber: cert.serialNumber,
99
+ subjectAltName: cert.subjectAltName,
100
+ selfSigned: cert.subject === cert.issuer,
101
+ }
102
+ }
103
+
104
+ /** 供 https.createServer 使用的证书对。路径缺失时抛结构化错误。 */
105
+ export function loadTlsMaterial({ home, cert, key }) {
106
+ const certFile = cert || certPath(home)
107
+ const keyFile = key || keyPath(home)
108
+ if (!existsSync(certFile) || !existsSync(keyFile)) {
109
+ const error = new Error(`TLS 已启用但缺少证书:${certFile} / ${keyFile},先跑 \`node src/cli.js tls init\``)
110
+ error.code = 'E_TLS_MISSING'
111
+ throw error
112
+ }
113
+ return { cert: readFileSync(certFile), key: readFileSync(keyFile), certFile, keyFile }
114
+ }
115
+
116
+ /**
117
+ * 证书指纹规范化:大小写无关、去掉冒号/空格,统一成 64 位大写 hex。
118
+ *
119
+ * 为什么需要它:`mesh_self` / `tls info` / `pair --code` 打印的是
120
+ * `AA:BB:...` 形式(X509Certificate.fingerprint256),而 socket 拿到的也是这种,
121
+ * 但人手工粘贴时常带上小写或漏冒号 —— 直接字符串比较会假失败。
122
+ */
123
+ export function normalizeFingerprint(value) {
124
+ const compact = String(value || '')
125
+ .replace(/[^0-9a-fA-F]/g, '')
126
+ .toUpperCase()
127
+ if (compact.length !== 64) {
128
+ const error = new Error(`证书指纹应为 64 位 hex(SHA-256),收到 ${compact.length} 位`)
129
+ error.code = 'E_BAD_FINGERPRINT'
130
+ throw error
131
+ }
132
+ return compact
133
+ }
134
+
135
+ /** 从 TLS socket 上取对端证书指纹,返回规范化的 64 位 hex。 */
136
+ export function fingerprintOfSocket(socket) {
137
+ const cert = socket?.getPeerCertificate?.()
138
+ if (!cert || !cert.fingerprint256) {
139
+ const error = new Error('对端没有提供证书,无法核对指纹')
140
+ error.code = 'E_NO_PEER_CERT'
141
+ throw error
142
+ }
143
+ return normalizeFingerprint(cert.fingerprint256)
144
+ }
145
+
146
+ /** 固定指纹校验:expected / actual 都规范化后比较,失败抛 E_PIN_MISMATCH。 */
147
+ export function assertFingerprint(expected, actual) {
148
+ const want = normalizeFingerprint(expected)
149
+ const got = normalizeFingerprint(actual)
150
+ if (want !== got) {
151
+ const error = new Error(
152
+ `证书指纹不匹配(可能被中间人替换或证书已轮换)\n 期望 ${want}\n 实际 ${got}`
153
+ )
154
+ error.code = 'E_PIN_MISMATCH'
155
+ error.expected = want
156
+ error.actual = got
157
+ throw error
158
+ }
159
+ return got
160
+ }
@@ -0,0 +1,212 @@
1
+ // dsh-mesh 隧道采集(只读):自建 frps(.runtime/frpc/frpc.toml)+ Sakura Launcher 日志/进程 + 端口探测
2
+ // 用途:/health 上报给其它节点、mesh-health 聚合、面板显示、可独立 CLI 调用
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import net from 'node:net'
6
+ import { execFileSync } from 'node:child_process'
7
+ import { collectSakuraAccount } from './sakura-api.js'
8
+
9
+ const SAKURA_LOG_DIR_WIN = 'C:\\ProgramData\\SakuraFrpService\\Logs'
10
+ const SAKURA_LOG_DIR_LINUX = '/var/log/sakurafrp'
11
+
12
+ function readText(p) {
13
+ try {
14
+ return readFileSync(p, 'utf8')
15
+ } catch {
16
+ return ''
17
+ }
18
+ }
19
+
20
+ function frpcToml(repo) {
21
+ const file = join(repo || '', '.runtime', 'frpc', 'frpc.toml')
22
+ const text = readText(file)
23
+ if (!text) return null
24
+ const grab = (key) => {
25
+ const m = new RegExp('^\\s*' + key + '\\s*=\\s*"?([^"\\r\\n]+)"?', 'm').exec(text)
26
+ return m ? m[1].trim() : null
27
+ }
28
+ const host = grab('serverAddr')
29
+ const port = grab('remotePort')
30
+ if (!host || !port) return null
31
+ return { host, port, serverPort: grab('serverPort') || null, localPort: grab('localPort') || null, name: grab('name') || null, configPath: file }
32
+ }
33
+
34
+ function sakuraTunnels() {
35
+ const dir = process.platform === 'win32' ? SAKURA_LOG_DIR_WIN : SAKURA_LOG_DIR_LINUX
36
+ try {
37
+ if (!existsSync(dir)) return []
38
+ const files = readdirSync(dir)
39
+ .filter((n) => n.endsWith('.log'))
40
+ .sort()
41
+ if (!files.length) return []
42
+ const text = readText(join(dir, files[files.length - 1]))
43
+ const tail = text.slice(-400000)
44
+ const found = new Map()
45
+ const re = /frpc\[([^\]|]+)[^\]]*\][^\n]*?使用 >>([^\s:]+):(\d+)<</g
46
+ let m
47
+ while ((m = re.exec(tail))) found.set(m[1].trim(), { name: m[1].trim(), host: m[2], port: m[3] })
48
+ return [...found.values()]
49
+ } catch {
50
+ return []
51
+ }
52
+ }
53
+
54
+ function processes() {
55
+ const out = { frpc: [], sakura: [], error: null }
56
+ try {
57
+ if (process.platform === 'win32') {
58
+ const ps = execFileSync(
59
+ 'powershell.exe',
60
+ [
61
+ '-NoProfile',
62
+ '-Command',
63
+ "Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(frpc|SakuraLauncher|SakuraFrpService)\\.exe$' } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress"
64
+ ],
65
+ { encoding: 'utf8', timeout: 8000, windowsHide: true }
66
+ ).trim()
67
+ const parsed = ps ? JSON.parse(ps) : []
68
+ const list = Array.isArray(parsed) ? parsed : [parsed]
69
+ for (const p of list) {
70
+ const name = String(p.Name || '')
71
+ const cmd = String(p.CommandLine || '')
72
+ if (name.toLowerCase() === 'frpc.exe') {
73
+ out.frpc.push({ pid: p.ProcessId || null, kind: /SakuraFrpLauncher/i.test(cmd) ? 'sakura' : 'self-hosted', cmd: cmd.slice(0, 200) })
74
+ } else {
75
+ out.sakura.push({ pid: p.ProcessId || null, name })
76
+ }
77
+ }
78
+ } else {
79
+ const txt = execFileSync('ps', ['-eo', 'pid,comm,args'], { encoding: 'utf8', timeout: 5000 }).toString()
80
+ for (const line of txt.split('\n')) {
81
+ if (!/frpc/i.test(line)) continue
82
+ const m = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line)
83
+ if (!m) continue
84
+ out.frpc.push({ pid: Number(m[1]), kind: /sakura/i.test(m[3]) ? 'sakura' : 'self-hosted', cmd: m[3].slice(0, 200) })
85
+ }
86
+ }
87
+ } catch (error) {
88
+ out.error = String(error?.message || error)
89
+ }
90
+ return out
91
+ }
92
+
93
+ /** 已知公网隧道端点(~/.dsh-mesh/tunnels-known.json):不依赖对端上报即可探测樱花/公网入口。 */
94
+ function knownEndpoints(home) {
95
+ if (!home) return []
96
+ try {
97
+ const raw = JSON.parse(readFileSync(join(home, 'tunnels-known.json'), 'utf8'))
98
+ const list = Array.isArray(raw?.endpoints) ? raw.endpoints : []
99
+ return list.filter((x) => x && x.host && x.port).map((x) => ({
100
+ node: String(x.node || ""),
101
+ kind: String(x.kind || 'sakura'),
102
+ host: String(x.host),
103
+ port: Number(x.port),
104
+ label: String(x.label || "")
105
+ }))
106
+ } catch {
107
+ return []
108
+ }
109
+ }
110
+
111
+ function probe(host, port, timeoutMs) {
112
+ return new Promise((resolve) => {
113
+ const startedAt = Date.now()
114
+ let settled = false
115
+ const sock = net.connect({ host, port: Number(port) })
116
+ sock.setTimeout(timeoutMs, () => finish(false, 'timeout'))
117
+ const finish = (up, error) => {
118
+ if (settled) return
119
+ settled = true
120
+ try {
121
+ sock.destroy()
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ resolve({ up, latencyMs: Date.now() - startedAt, error: error || null })
126
+ }
127
+ sock.once('connect', () => finish(true))
128
+ sock.once('error', (error) => finish(false, String(error?.code || error?.message || error)))
129
+ })
130
+ }
131
+
132
+ /**
133
+ * 采集本机隧道状态(只读,会做少量端口探测,默认 ~2.5s 超时)。
134
+ * 返回:{ generatedAt, processes, selfHosted, sakura } —— 供 /health / mesh-health / 面板使用。
135
+ */
136
+ export async function collectTunnels({ repo, home, timeoutMs = 2500 } = {}) {
137
+ const procs = processes()
138
+ const selfHosted = frpcToml(repo)
139
+ const sakura = sakuraTunnels()
140
+ const selfProbe = selfHosted ? await probe(selfHosted.host, selfHosted.port, timeoutMs) : null
141
+ const sakuraProbes = await Promise.all(
142
+ sakura.slice(0, 4).map(async (t) => ({ ...t, probe: await probe(t.host, t.port, timeoutMs) }))
143
+ )
144
+ const sakuraAlive = procs.sakura.length > 0 || procs.frpc.some((p) => p.kind === 'sakura')
145
+ const sakuraAccount = await collectSakuraAccount({ ttlMs: 60_000 })
146
+ const known = await Promise.all(
147
+ knownEndpoints(home).map(async (x) => ({ ...x, probe: await probe(x.host, x.port, timeoutMs) }))
148
+ )
149
+ return {
150
+ generatedAt: new Date().toISOString(),
151
+ processes: procs,
152
+ selfHosted: selfHosted ? { ...selfHosted, probe: selfProbe } : null,
153
+ sakura: { launcherRunning: sakuraAlive, tunnels: sakuraProbes },
154
+ known,
155
+ sakuraAccount,
156
+ summary: {
157
+ selfHostedUp: Boolean(selfProbe && selfProbe.up),
158
+ sakuraRunning: sakuraAlive,
159
+ sakuraTunnels: sakuraProbes.length,
160
+ sakuraUp: sakuraProbes.filter((t) => t.probe && t.probe.up).length,
161
+ knownUp: known.filter((x) => x.probe && x.probe.up).length,
162
+ knownTotal: known.length,
163
+ accountTotal: sakuraAccount.total || 0,
164
+ accountOnline: sakuraAccount.online || 0,
165
+ accountError: sakuraAccount.available ? null : (sakuraAccount.error || "unavailable")
166
+ }
167
+ }
168
+ }
169
+
170
+ /** 后台缓存:/health 每次请求都探测会拖慢 peer 探测,这里 60s 刷新一次。 */
171
+ export function createTunnelCache({ repo, home, refreshMs = 60_000, log } = {}) {
172
+ let value = null
173
+ let timer = null
174
+ let running = false
175
+ const file = home ? join(home, 'TUNNEL.json') : null
176
+ const refresh = async () => {
177
+ if (running) return value
178
+ running = true
179
+ try {
180
+ value = await collectTunnels({ repo, home })
181
+ if (file) {
182
+ try {
183
+ const { writeFileSync } = await import('node:fs')
184
+ writeFileSync(file, JSON.stringify(value, null, 2))
185
+ } catch {
186
+ /* ignore */
187
+ }
188
+ }
189
+ } catch (error) {
190
+ if (log) log('tunnel cache: ' + String(error?.message || error))
191
+ } finally {
192
+ running = false
193
+ }
194
+ return value
195
+ }
196
+ return {
197
+ start() {
198
+ refresh().catch(() => {})
199
+ timer = setInterval(() => refresh().catch(() => {}), refreshMs)
200
+ if (timer.unref) timer.unref()
201
+ return this
202
+ },
203
+ stop() {
204
+ if (timer) clearInterval(timer)
205
+ timer = null
206
+ },
207
+ get() {
208
+ return value
209
+ },
210
+ refresh
211
+ }
212
+ }
@@ -0,0 +1,68 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { readFileSync } from 'node:fs'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ /** Run a command without a shell; resolves (never rejects) with stdout/stderr/error. */
6
+ export function run(cmd, args, { timeout = 20000, cwd, signal, maxBuffer = 8 * 1024 * 1024 } = {}) {
7
+ return new Promise((resolvePromise) => {
8
+ execFile(
9
+ cmd,
10
+ args,
11
+ { cwd, timeout, signal, encoding: 'utf8', windowsHide: true, maxBuffer },
12
+ (error, stdout, stderr) => {
13
+ resolvePromise({
14
+ error,
15
+ stdout: stdout || '',
16
+ stderr: stderr || '',
17
+ code: typeof error?.code === 'number' ? error.code : 0,
18
+ killed: Boolean(error?.killed),
19
+ timedOut: Boolean(error?.killed && error?.signal === 'SIGTERM')
20
+ })
21
+ }
22
+ )
23
+ })
24
+ }
25
+
26
+ /** Trim a string to a byte budget without splitting a UTF-8 code point badly. */
27
+ export function clip(text, maxBytes) {
28
+ const buf = Buffer.from(text, 'utf8')
29
+ if (buf.length <= maxBytes) return { text, truncated: false, bytes: buf.length }
30
+ return { text: buf.subarray(0, maxBytes).toString('utf8'), truncated: true, bytes: buf.length }
31
+ }
32
+
33
+ export function sha256(text) {
34
+ return createHash('sha256').update(text, 'utf8').digest('hex')
35
+ }
36
+
37
+ export function short(text, n = 500) {
38
+ const s = String(text ?? '')
39
+ return s.length <= n ? s : s.slice(0, n) + '…'
40
+ }
41
+
42
+ /** ISO timestamp helper. */
43
+ export function nowIso() {
44
+ return new Date().toISOString()
45
+ }
46
+
47
+ /**
48
+ * Strip a UTF-8 BOM. Windows editors (and PowerShell's Set-Content) love adding one,
49
+ * and `JSON.parse` chokes on it — a BOM'd config.json used to kill the node at boot.
50
+ */
51
+ export function stripBom(text) {
52
+ return String(text).replace(/^\uFEFF/, '')
53
+ }
54
+
55
+ /** Read a JSON file, tolerating a BOM. */
56
+ export function readJsonFile(path) {
57
+ return JSON.parse(stripBom(readFileSync(path, 'utf8')))
58
+ }
59
+
60
+ /** Wrap a promise with a hard timeout so a stuck tool cannot hang a request forever. */
61
+ export function withTimeout(promise, ms, onTimeout) {
62
+ let timer
63
+ const timeout = new Promise((_resolve, reject) => {
64
+ timer = setTimeout(() => reject(onTimeout || new Error(`timed out after ${ms}ms`)), ms)
65
+ timer.unref?.()
66
+ })
67
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
68
+ }
@@ -0,0 +1,58 @@
1
+ // e2e: autostart helper (Windows only). Read-only: never registers/removes a task.
2
+ // Proves (a) status is safe on a clean machine, (b) both mutating paths refuse without --yes,
3
+ // (c) the generated logon command points at this checkout's cli.js.
4
+ import assert from 'node:assert/strict'
5
+ import {
6
+ TASK_NAME,
7
+ autostartCommand,
8
+ autostartInstall,
9
+ autostartRemove,
10
+ autostartStatus
11
+ } from './core/autostart.js'
12
+
13
+ const checks = []
14
+ function check(name, fn) {
15
+ checks.push({ name, fn })
16
+ }
17
+
18
+ check('Windows guard / status shape', async () => {
19
+ if (process.platform !== 'win32') return 'skipped: not win32'
20
+ const st = await autostartStatus()
21
+ assert.equal(typeof st.installed, 'boolean')
22
+ assert.equal(st.name, TASK_NAME)
23
+ if (st.installed) {
24
+ assert.ok(st.taskToRun, 'installed task must report taskToRun')
25
+ } else {
26
+ assert.ok(typeof st.detail === 'string')
27
+ }
28
+ return `installed=${st.installed}`
29
+ })
30
+
31
+ check('command quotes node + cli.js + port', () => {
32
+ const cmd = autostartCommand(TASK_NAME, 8124)
33
+ assert.match(cmd, /^".*node(\.exe)?"/)
34
+ assert.ok(cmd.includes('cli.js'), 'command must point at the CLI')
35
+ assert.match(cmd, /serve --port 8124$/)
36
+ return cmd
37
+ })
38
+
39
+ check('install refuses without --yes', async () => {
40
+ await assert.rejects(() => autostartInstall({ yes: false }), /--yes/)
41
+ })
42
+
43
+ check('remove refuses without --yes', async () => {
44
+ await assert.rejects(() => autostartRemove({ yes: false }), /--yes/)
45
+ })
46
+
47
+ let failed = 0
48
+ for (const c of checks) {
49
+ try {
50
+ const detail = await c.fn()
51
+ console.log(`PASS ${c.name}${detail ? ` (${detail})` : ''}`)
52
+ } catch (err) {
53
+ failed += 1
54
+ console.error(`FAIL ${c.name}\n ${err.message}`)
55
+ }
56
+ }
57
+ console.log(`\n${checks.length - failed}/${checks.length} passed`)
58
+ process.exit(failed === 0 ? 0 : 1)