@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5

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 (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-SDK.
4
+ // fangctl — dsh-Fang 离线运维 CLI。
5
+ // node tools/fangctl.mjs status [--profile <name>] [--data-root <dir>]
6
+ // node tools/fangctl.mjs forks
7
+ // node tools/fangctl.mjs verify # 校验审计哈希链
8
+ // node tools/fangctl.mjs tail [n] # 最近 n 条审计
9
+ // node tools/fangctl.mjs doctor # 环境自检(不修改任何东西)
10
+ // node tools/fangctl.mjs restart-status # 只读:重启守卫状态 + 授权清单摘要(--json 可机读;--url 读运行中宿主)
11
+ // node tools/fangctl.mjs approvals [--json] --url <host> [--token <t>]
12
+ // node tools/fangctl.mjs approve <source:id> --url <host> [--token <t>] [--note text]
13
+ // node tools/fangctl.mjs deny <source:id> --url <host> [--token <t>] [--note text]
14
+ //
15
+ // 离线只读:fork/reap/promote 由宿主内的 orchestrator 服务与 /fang 路由完成,
16
+ // 这里不模拟它们,避免"CLI 说成功、宿主没执行"的假象。
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+
20
+ import {
21
+ resolveFangLayout, resolveProfileDir, resolveDshHome, parseWebUrl,
22
+ exists, readJson, readJsonl, isLink, verifyChain, FANG_VERSION,
23
+ } from '../src/kernel/index.mjs'
24
+ import { readRestartStatus, clearCrashLoopSafeMode } from '../src/lib/restart-contract.mjs'
25
+
26
+ function parseArgs(argv) {
27
+ const args = { _: [] }
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i]
30
+ if (a === '--profile' && argv[i + 1]) { args.profile = argv[++i]; continue }
31
+ if (a.startsWith('--profile=')) { args.profile = a.slice(10); continue }
32
+ if (a === '--data-root' && argv[i + 1]) { args.dataRoot = argv[++i]; continue }
33
+ if (a.startsWith('--data-root=')) { args.dataRoot = a.slice(12); continue }
34
+ if (a === '--url' && argv[i + 1]) { args.url = argv[++i]; continue }
35
+ if (a.startsWith('--url=')) { args.url = a.slice(6); continue }
36
+ if (a === '--token' && argv[i + 1]) { args.token = argv[++i]; continue }
37
+ if (a.startsWith('--token=')) { args.token = a.slice(8); continue }
38
+ if (a === '--note' && argv[i + 1]) { args.note = argv[++i]; continue }
39
+ if (a.startsWith('--note=')) { args.note = a.slice(7); continue }
40
+ if (a === '--by' && argv[i + 1]) { args.by = argv[++i]; continue }
41
+ if (a.startsWith('--by=')) { args.by = a.slice(5); continue }
42
+ if (a === '--json') { args.json = true; continue }
43
+ args._.push(a)
44
+ }
45
+ return args
46
+ }
47
+
48
+ function layoutOf(args) {
49
+ const argv = args.profile ? ['node', 'fangctl', '--profile', args.profile] : process.argv
50
+ return resolveFangLayout({ dataRoot: args.dataRoot ?? '', argv, env: process.env })
51
+ }
52
+
53
+ function fmtTime(ms) {
54
+ if (!ms) return '—'
55
+ try { return new Date(ms).toISOString().replace('T', ' ').slice(0, 19) } catch { return String(ms) }
56
+ }
57
+
58
+ /** HTTP base for approval commands; accepts host, host/fang, or host/fang/api. */
59
+ function apiBase(args) {
60
+ const raw = String(args.url ?? process.env.FANG_URL ?? '').trim()
61
+ if (!raw) return ''
62
+ try {
63
+ const url = new URL(raw)
64
+ let pathname = url.pathname.replace(/\/+$/, '')
65
+ if (pathname.endsWith('/fang/api')) pathname = pathname.slice(0, -9)
66
+ else if (pathname.endsWith('/fang')) pathname = pathname.slice(0, -5)
67
+ else pathname = ''
68
+ return `${url.origin}${pathname}`
69
+ } catch { return '' }
70
+ }
71
+
72
+ async function resolveToken(args, base) {
73
+ const direct = String(args.token ?? process.env.FANG_TOKEN ?? '').trim()
74
+ if (direct) return direct
75
+ try {
76
+ const res = await fetch(`${base}/fang/api/auth-token`, { headers: { accept: 'application/json' } })
77
+ const body = await res.json()
78
+ if (res.ok && body?.ok && body.token) return String(body.token)
79
+ } catch { /* fall through */ }
80
+ return ''
81
+ }
82
+
83
+ async function approvalRequest(args, pathname, options = {}) {
84
+ const base = apiBase(args)
85
+ if (!base) throw new Error('--url is required for HTTP commands')
86
+ const token = await resolveToken(args, base)
87
+ if (!token) throw new Error('token unavailable; pass --token or FANG_TOKEN')
88
+ const controller = new AbortController()
89
+ const timer = setTimeout(() => controller.abort(), 10000)
90
+ try {
91
+ const res = await fetch(`${base}${pathname}`, {
92
+ method: options.method ?? 'GET',
93
+ headers: {
94
+ accept: 'application/json',
95
+ 'content-type': 'application/json',
96
+ 'x-fang-auth': token,
97
+ },
98
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
99
+ signal: controller.signal,
100
+ })
101
+ const text = await res.text()
102
+ let body = null
103
+ try { body = text ? JSON.parse(text) : null } catch { body = null }
104
+ return { status: res.status, body, text }
105
+ } finally {
106
+ clearTimeout(timer)
107
+ }
108
+ }
109
+
110
+ function printApprovals(body, json) {
111
+ if (json) {
112
+ console.log(JSON.stringify(body, null, 2))
113
+ return
114
+ }
115
+ const items = Array.isArray(body?.items) ? body.items : []
116
+ if (!items.length) { console.log('(no pending approvals)'); return }
117
+ console.log(`${'SOURCE'.padEnd(7)} ${'APPROVAL'.padEnd(28)} ${'RISK'.padEnd(7)} ${'DEADLINE'.padEnd(12)} TITLE`)
118
+ for (const item of items) {
119
+ console.log([
120
+ String(item.source ?? '').padEnd(7),
121
+ String(item.approvalId ?? '').padEnd(28),
122
+ String(item.risk ?? '').padEnd(7),
123
+ String(fmtTime(Number(item.deadlineAt))).padEnd(12),
124
+ String(item.title ?? ''),
125
+ ].join(' '))
126
+ }
127
+ for (const failed of Array.isArray(body?.failed) ? body.failed : []) {
128
+ console.log(`! unavailable: ${failed.taskId ?? 'mother'} ${failed.error ?? ''}`)
129
+ }
130
+ }
131
+
132
+ function parseApprovalTarget(raw, items) {
133
+ const parts = String(raw ?? '').split(':').filter(Boolean)
134
+ const source = parts[0]
135
+ if (source !== 'mother' && source !== 'fork') return null
136
+ if (source === 'mother') {
137
+ const approvalId = parts.slice(1).join(':')
138
+ return approvalId ? { source, taskId: null, approvalId } : null
139
+ }
140
+ if (parts.length >= 3) return { source, taskId: parts[1], approvalId: parts.slice(2).join(':') }
141
+ const approvalId = parts.slice(1).join(':')
142
+ if (!approvalId) return null
143
+ const matches = (items ?? []).filter((item) => item.source === 'fork' && item.approvalId === approvalId)
144
+ if (matches.length !== 1) return null
145
+ return { source: 'fork', taskId: matches[0].taskId, approvalId }
146
+ }
147
+
148
+ async function cmdApprovals(args) {
149
+ const result = await approvalRequest(args, '/fang/api/approvals')
150
+ if (result.status !== 200 || result.body?.ok === false) {
151
+ console.error(JSON.stringify(result.body ?? { status: result.status, text: result.text }))
152
+ return 1
153
+ }
154
+ printApprovals(result.body, args.json)
155
+ return result.body?.degraded ? 2 : 0
156
+ }
157
+
158
+ async function cmdDecide(args, outcome) {
159
+ const raw = args._[1]
160
+ if (!raw) { console.error('usage: fangctl approve|deny <source:id> [--note ...]'); return 64 }
161
+ const listed = await approvalRequest(args, '/fang/api/approvals')
162
+ const target = parseApprovalTarget(raw, Array.isArray(listed.body?.items) ? listed.body.items : [])
163
+ if (!target) { console.error(`cannot resolve approval target: ${raw}`); return 64 }
164
+ const result = await approvalRequest(args, '/fang/api/approvals/decide', {
165
+ method: 'POST',
166
+ body: {
167
+ ...target,
168
+ outcome,
169
+ note: args.note ?? null,
170
+ by: args.by ?? 'fangctl',
171
+ },
172
+ })
173
+ if (args.json) console.log(JSON.stringify(result.body ?? { status: result.status }, null, 2))
174
+ else if (result.status === 200 && result.body?.ok !== false) console.log(`${outcome}: ${target.source}:${target.approvalId}`)
175
+ else console.error(`${result.status}: ${result.body?.error ?? result.text ?? 'failed'}`)
176
+ if (result.status === 200 && result.body?.ok !== false) return 0
177
+ return result.status === 409 ? 2 : 1
178
+ }
179
+
180
+ function readForks(layout) {
181
+ const raw = readJson(layout.forksFile, [])
182
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.forks) ? raw.forks : []
183
+ return list
184
+ }
185
+
186
+ function cmdStatus(layout) {
187
+ const forks = readForks(layout)
188
+ console.log(`dsh-Fang ${FANG_VERSION}`)
189
+ console.log(` profile : ${layout.profileTag}`)
190
+ console.log(` DSH_HOME : ${layout.dshHome}`)
191
+ console.log(` data dir : ${layout.dataDir}${exists(layout.dataDir) ? '' : ' (不存在:该 profile 未启动过 Fang)'}`)
192
+ console.log(` ledger : ${layout.ledgerFile}${exists(layout.ledgerFile) ? '' : ' (尚未写入)'}`)
193
+ console.log(` buffer : ${layout.bufferDir}`)
194
+ console.log(` forks : ${forks.length}`)
195
+ for (const f of forks) {
196
+ console.log(` - ${f.forkId} ${f.status ?? '?'} port=${f.port ?? '—'} pid=${f.pid ?? '—'} ${f.taskTitle ?? ''}`)
197
+ }
198
+ if (exists(layout.ledgerFile)) {
199
+ const v = verifyChain(layout.ledgerFile)
200
+ console.log(` audit : ${v.ok ? 'OK' : 'CORRUPT'} (${v.count} 条${v.ok ? '' : `, 首个坏行 #${v.badIndex} ${v.reason}`})`)
201
+ }
202
+ return 0
203
+ }
204
+
205
+ function cmdVerify(layout) {
206
+ if (!exists(layout.ledgerFile)) {
207
+ console.log(`no ledger at ${layout.ledgerFile}`)
208
+ return 1
209
+ }
210
+ const v = verifyChain(layout.ledgerFile)
211
+ console.log(JSON.stringify(v, null, 2))
212
+ return v.ok ? 0 : 2
213
+ }
214
+
215
+ function cmdTail(layout, n = 20) {
216
+ const lines = readJsonl(layout.ledgerFile)
217
+ const tail = lines.slice(-n)
218
+ for (const r of tail) {
219
+ const data = r.data && typeof r.data === 'object' ? r.data : {}
220
+ const brief = Object.keys(data).slice(0, 4).map(k => `${k}=${JSON.stringify(data[k])}`).join(' ')
221
+ console.log(`${String(r.seq).padStart(3)} ${fmtTime(r.at)} ${String(r.kind).padEnd(18)} ${brief}`)
222
+ }
223
+ if (!tail.length) console.log('(no audit records)')
224
+ return 0
225
+ }
226
+
227
+ function cmdForks(layout) {
228
+ const forks = readForks(layout)
229
+ if (!forks.length) { console.log('(no forks recorded)'); return 0 }
230
+ for (const f of forks) {
231
+ console.log(`${f.forkId}\t${f.status}\tport=${f.port ?? '—'}\tpid=${f.pid ?? '—'}\t${f.taskTitle ?? ''}`)
232
+ }
233
+ return 0
234
+ }
235
+
236
+ /**
237
+ * 只读:重启守卫状态 + 授权清单摘要。
238
+ * 有 --url:走 GET /fang/api/restart(验证运行中的宿主是否已载入新 bundle)
239
+ * 无 --url:offline 读 <dataDir>/restart(与 HTTP/UI 同一投影)
240
+ * 两种模式都不触发任何执行。
241
+ */
242
+ async function cmdRestartStatus(layout, args) {
243
+ if (args.url) {
244
+ const result = await approvalRequest(args, '/fang/api/restart')
245
+ if (args.json) {
246
+ console.log(JSON.stringify({ status: result.status, ...(result.body ?? {}) }, null, 2))
247
+ return result.status === 200 ? 0 : 1
248
+ }
249
+ if (result.status !== 200) {
250
+ console.error(`HTTP ${result.status}: ${result.body?.error ?? result.text ?? 'restart route unavailable'}`)
251
+ console.error('提示:旧 bundle 没有 /fang/api/restart;需重启 DSH Desktop 载入新代码。')
252
+ return 1
253
+ }
254
+ return printRemoteRestartStatus(result.body)
255
+ }
256
+ const limitArg = Number(args._[1])
257
+ const status = readRestartStatus(layout.dataDir, {
258
+ limit: Number.isFinite(limitArg) && limitArg > 0 ? limitArg : 50,
259
+ })
260
+ if (args.json) {
261
+ console.log(JSON.stringify(status, null, 2))
262
+ return 0
263
+ }
264
+ printLocalRestartStatus(status)
265
+ return 0
266
+ }
267
+
268
+ function printRemoteRestartStatus(body) {
269
+ if (!body || body.ok === false || body.available === false) {
270
+ console.error(`restart-guard unavailable: ${body?.error ?? 'service missing'}`)
271
+ return 1
272
+ }
273
+ const s = body.state ?? {}
274
+ const p = body.policy ?? {}
275
+ const counts = body.approvals?.counts ?? {}
276
+ console.log('restart guard (remote, read-only)')
277
+ const pending = s.pending?.id ? ` (pending ${s.pending.id} risk=${s.pending.risk ?? '?'} phase=${s.pending.phase ?? '?'})` : ''
278
+ console.log(` state : ${s.state ?? 'unknown'}${pending}`)
279
+ console.log(` mode : ${p.mode ?? '—'} authMode=${p.authMode ?? '—'} strictRedLine=${p.strictRedLine === false ? 'false(红灯可自动放行)' : 'true'}`)
280
+ const armedNow = p.mode === 'enforce' && p.allowProcessRestart === true
281
+ console.log(` armed : ${armedNow ? 'yes' : 'no'} (enforce + allowProcessRestart=true)`)
282
+ console.log(` approvals : total=${counts.total ?? 0} authorized=${counts.authorized ?? 0} executed=${counts.executed ?? 0} stale=${counts.stale ?? 0} notArmed=${counts.notArmed ?? 0} invalid=${counts.invalid ?? 0}`)
283
+ console.log(` contract : decision-only; no execution; executed-proof = receipt execution.executed===true`)
284
+ return 0
285
+ }
286
+
287
+ function printLocalRestartStatus(status) {
288
+ const counts = status.approvals.counts
289
+ console.log(`restart guard @ ${status.stateDir}${exists(status.stateDir) ? '' : ' (不存在:守卫尚未启动或未产生状态)'}`)
290
+ const pending = status.pending?.id ? ` (pending ${status.pending.id} risk=${status.pending.risk ?? '?'} phase=${status.pending.phase ?? '?'})` : ''
291
+ console.log(` state : ${status.state}${pending}`)
292
+ console.log(` state file : ${exists(path.join(status.stateDir, 'state.json')) ? 'present' : 'missing'}${status.stateAt ? ` (at ${fmtTime(status.stateAt)})` : ''}`)
293
+ if (status.lastInterrupted) {
294
+ console.log(` interrupted: ${status.lastInterrupted.id ?? '?'} previousState=${status.lastInterrupted.previousState ?? '?'} at=${fmtTime(status.lastInterrupted.at)}`)
295
+ }
296
+ console.log(` approvals : total=${counts.total} authorized=${counts.authorized} executed=${counts.executed} stale=${counts.stale} notArmed=${counts.notArmed} invalid=${counts.invalid}`)
297
+ if (status.approvals.items.length) {
298
+ console.log(`${'ID'.padEnd(22)} ${'RISK'.padEnd(7)} ${'OUTCOME'.padEnd(11)} ${'STATE'.padEnd(14)} ${'APPROVAL'.padEnd(20)} EXPIRES`)
299
+ for (const item of status.approvals.items) {
300
+ console.log([
301
+ String(item.id ?? '').padEnd(22),
302
+ String(item.risk ?? '').padEnd(7),
303
+ String(item.outcome ?? '').padEnd(11),
304
+ String(item.state ?? '').padEnd(14),
305
+ String(item.approvalId ?? '—').padEnd(20),
306
+ item.expiresAt ? fmtTime(item.expiresAt) : '—',
307
+ ].join(' '))
308
+ }
309
+ } else {
310
+ console.log(' (no approval manifests)')
311
+ }
312
+ console.log(' contract : decision-only; no execution; executed-proof = receipt execution.executed===true')
313
+ }
314
+
315
+ function cmdDoctor(layout) {
316
+ const checks = []
317
+ const check = (id, ok, detail) => checks.push({ id, ok, detail })
318
+
319
+ const [major, minor] = process.versions.node.split('.').map(Number)
320
+ check('node', major >= 22, `node ${process.versions.node}(需要 >= 22 才有 node:test 稳定行为)`)
321
+ check('data-dir', exists(layout.dataDir), layout.dataDir)
322
+ check('roster', exists(layout.forksFile) || true, 'forks.json 可缺省(首次启动创建)')
323
+ if (exists(layout.ledgerFile)) {
324
+ const v = verifyChain(layout.ledgerFile)
325
+ check('ledger', v.ok, `${v.count} 条,${v.ok ? '链完整' : `${v.reason} @${v.badIndex}`}`)
326
+ } else {
327
+ check('ledger', true, '尚未写入(未启动或未产生审计)')
328
+ }
329
+
330
+ const profileDir = resolveProfileDir(layout.profileTag)
331
+ check('profile', exists(profileDir), profileDir)
332
+ // Bundle rows do not live in cordis.patch.yml: a bundle is listed in the profile
333
+ // package.json (dsh.profile.bundles) and contributes its own patch layer.
334
+ let pkg = null
335
+ try { pkg = JSON.parse(fs.readFileSync(path.join(profileDir, 'package.json'), 'utf8')) } catch { /* ignore */ }
336
+ const bundles = Array.isArray(pkg?.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles : []
337
+ const installed = bundles.includes('@twinsearth/fang-dsh-plugin') || Boolean(pkg?.dependencies?.['@twinsearth/fang-dsh-plugin'])
338
+ check('bundle-installed', installed, installed ? `profiles/${layout.profileTag} 的 bundles 含 @twinsearth/fang-dsh-plugin` : 'profile package.json 未列出 @twinsearth/fang-dsh-plugin(bundle 未安装)')
339
+
340
+ const template = resolveProfileDir(layout.profileTag)
341
+ check('fork-template', exists(template), `${template}(分身 profile 模板;可用 config.profileTemplate 覆盖)`)
342
+ const nodeModules = path.join(template, 'node_modules')
343
+ check('template-node_modules', exists(nodeModules), `${nodeModules}(分身通过 junction 共享)`)
344
+
345
+ const cliCandidates = ['E:\\DS\\resources\\runtime\\cli\\bin\\dsh.cmd', 'dsh']
346
+ const foundCli = cliCandidates.find(c => c === 'dsh' ? true : exists(c))
347
+ check('dsh-cli', !!foundCli, foundCli === 'dsh' ? '仅在 PATH 中假定可用' : foundCli)
348
+
349
+ for (const c of checks) console.log(`${c.ok ? 'PASS' : 'FAIL'} ${c.id.padEnd(22)} ${c.detail}`)
350
+ const failed = checks.filter(c => !c.ok).length
351
+ console.log(failed === 0 ? '\n全部通过。' : `\n${failed} 项失败(这些项会影响派生/加载,见上)。`)
352
+ return failed === 0 ? 0 : 1
353
+ }
354
+
355
+ const args = parseArgs(process.argv.slice(2))
356
+ const cmd = args._[0] ?? 'status'
357
+ const layout = layoutOf(args)
358
+
359
+ let code = 0
360
+ switch (cmd) {
361
+ case 'status': code = cmdStatus(layout); break
362
+ case 'forks': code = cmdForks(layout); break
363
+ case 'restart-status': {
364
+ try { code = await cmdRestartStatus(layout, args) } catch (error) { console.error(String(error?.message ?? error)); code = 1 }
365
+ break
366
+ }
367
+ case 'restart-safe': {
368
+ const action = args._[1] ?? 'status'
369
+ const safeFile = path.join(layout.dataDir, 'restart', 'safe-mode.json')
370
+ if (action === 'clear') {
371
+ const result = clearCrashLoopSafeMode(layout.dataDir, { by: args.by ?? 'fangctl', note: args.note ?? '' })
372
+ if (args.json) console.log(JSON.stringify(result, null, 2))
373
+ else if (result.ok) console.log(result.cleared ? `safe-mode cleared; crash ledger archived: ${result.archived ?? '(none)'}` : 'safe-mode is not active')
374
+ else console.error(`FAIL ${result.reason}: ${result.error ?? ''}`)
375
+ code = result.ok ? 0 : 1
376
+ break
377
+ }
378
+ let doc = null
379
+ try { doc = JSON.parse(fs.readFileSync(safeFile, 'utf8')) } catch { doc = null }
380
+ if (args.json) console.log(JSON.stringify({ file: safeFile, safeMode: doc }, null, 2))
381
+ else if (!doc || doc.active !== true) console.log(`safe-mode: clear (${safeFile})`)
382
+ else console.log(`safe-mode: ACTIVE reason=${doc.reason ?? '?'} at=${fmtTime(doc.at)} crashCount=${doc.crashCount ?? '?'} file=${safeFile}`)
383
+ code = 0
384
+ break
385
+ }
386
+ case 'verify': code = cmdVerify(layout); break
387
+ case 'tail': code = cmdTail(layout, Number(args._[1] ?? 20)); break
388
+ case 'doctor': code = cmdDoctor(layout); break
389
+ case 'approvals': {
390
+ try {
391
+ if (args._[1] === 'drill') {
392
+ const out = await approvalRequest(args, '/fang/api/approvals/drill', { method: 'POST', body: {} })
393
+ if (args.json) console.log(JSON.stringify(out.body, null, 2))
394
+ else if (out.status === 200 && out.body?.ok) {
395
+ console.log('approval drill: GREEN')
396
+ console.log(` request : ${out.body.drill?.request?.id ?? '-'} ${out.body.drill?.request?.kind ?? '-'}/${out.body.drill?.request?.risk ?? '-'} → ${out.body.drill?.request?.outcome ?? '-'}`)
397
+ console.log(` pending : ${out.body.drill?.pendingBefore ?? '-'} → ${out.body.drill?.pendingAfter ?? '-'}`)
398
+ console.log(` rollback : ${out.body.drill?.rollback?.ok === true ? 'ok' : 'FAIL'} restored=${out.body.drill?.rollback?.restored === true}`)
399
+ console.log(` residue : ${out.body.drill?.cleanup === true ? 'clean' : 'kept'}`)
400
+ } else {
401
+ console.error(`FAIL approval drill: HTTP ${out.status} ${out.text.slice(0, 300)}`)
402
+ }
403
+ code = out.status === 200 && out.body?.ok === true ? 0 : 1
404
+ break
405
+ }
406
+ code = await cmdApprovals(args)
407
+ } catch (error) { console.error(String(error?.message ?? error)); code = 1 }
408
+ break
409
+ }
410
+ case 'approve':
411
+ case 'deny': {
412
+ try { code = await cmdDecide(args, cmd === 'approve' ? 'approved' : 'denied') } catch (error) { console.error(String(error?.message ?? error)); code = 1 }
413
+ break
414
+ }
415
+ case 'parse-url': {
416
+ const parsed = parseWebUrl(args._.slice(1).join(' '))
417
+ console.log(JSON.stringify(parsed, null, 2))
418
+ code = parsed ? 0 : 1
419
+ break
420
+ }
421
+ default:
422
+ console.log('usage: fangctl.mjs <status|forks|verify|tail [n]|doctor|restart-status [limit]|restart-safe [status|clear]|parse-url <line>|approvals [drill]|approve <source:id>|deny <source:id>> [--profile name] [--data-root dir] [--url url] [--token token] [--note text] [--by who] [--json]')
423
+ code = 64
424
+ }
425
+ process.exitCode = code
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
4
+ //
5
+ // 主城门禁 CLI:任何进主城的动作(打包/发布/安装/部署/新建 profile)先过这里。
6
+ // node tools/gate/main-city.mjs --target source
7
+ // node tools/gate/main-city.mjs --target package --package-dir dist/npm/package --tarball dist/dsh-fang-X.tgz
8
+ // node tools/gate/main-city.mjs --target publish --package-dir ... --tarball ... --tag vX --check-registry
9
+ // node tools/gate/main-city.mjs --target install --home <DSH_HOME> --profile fang-x --package-dir <dir>
10
+ // node tools/gate/main-city.mjs --target deploy --home <DSH_HOME> --profile fang-x --repair
11
+ // node tools/gate/main-city.mjs --target profile --home <DSH_HOME> --profile demo --create
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ import {
17
+ DEFAULT_MIN_FREE_BYTES,
18
+ DEFAULT_PROFILE_BUNDLES,
19
+ GATE_TARGETS,
20
+ createGatedProfile,
21
+ formatGateFailure,
22
+ runMainGate,
23
+ } from '../../src/lib/main-gate.mjs'
24
+
25
+ function parseArgs(argv = []) {
26
+ const out = { target: 'package', json: false, repair: false, create: false, force: false, allowExisting: false, checkRegistry: false }
27
+ for (let index = 0; index < argv.length; index += 1) {
28
+ const arg = String(argv[index])
29
+ const next = () => {
30
+ if (index + 1 >= argv.length) throw new Error(`missing value for ${arg}`)
31
+ return String(argv[++index])
32
+ }
33
+ if (arg === '--json') { out.json = true; continue }
34
+ if (arg === '--repair') { out.repair = true; continue }
35
+ if (arg === '--create') { out.create = true; continue }
36
+ if (arg === '--force') { out.force = true; continue }
37
+ if (arg === '--allow-existing') { out.allowExisting = true; continue }
38
+ if (arg === '--require-backup') { out.requireBackup = true; continue }
39
+ if (arg === '--check-registry') { out.checkRegistry = true; continue }
40
+ if (arg === '--supervisor-present') { out.supervisorPresent = true; continue }
41
+ if (arg === '--help' || arg === '-h') { out.help = true; continue }
42
+ if (arg === '--target') { out.target = next(); continue }
43
+ if (arg.startsWith('--target=')) { out.target = arg.slice(9); continue }
44
+ if (arg === '--root') { out.root = next(); continue }
45
+ if (arg.startsWith('--root=')) { out.root = arg.slice(7); continue }
46
+ if (arg === '--version') { out.version = next(); continue }
47
+ if (arg.startsWith('--version=')) { out.version = arg.slice(10); continue }
48
+ if (arg === '--dist') { out.distDir = next(); continue }
49
+ if (arg.startsWith('--dist=')) { out.distDir = arg.slice(7); continue }
50
+ if (arg === '--package-dir') { out.packageDir = next(); continue }
51
+ if (arg.startsWith('--package-dir=')) { out.packageDir = arg.slice(14); continue }
52
+ if (arg === '--tarball') { out.tarball = next(); continue }
53
+ if (arg.startsWith('--tarball=')) { out.tarball = arg.slice(10); continue }
54
+ if (arg === '--tag') { out.tag = next(); continue }
55
+ if (arg.startsWith('--tag=')) { out.tag = arg.slice(6); continue }
56
+ if (arg === '--npm-latest') { out.npmLatest = next(); continue }
57
+ if (arg.startsWith('--npm-latest=')) { out.npmLatest = arg.slice(13); continue }
58
+ if (arg === '--home') { out.homeDir = next(); continue }
59
+ if (arg.startsWith('--home=')) { out.homeDir = arg.slice(7); continue }
60
+ if (arg === '--profile') { out.profile = next(); continue }
61
+ if (arg.startsWith('--profile=')) { out.profile = arg.slice(10); continue }
62
+ if (arg === '--profile-dir') { out.profileDir = next(); continue }
63
+ if (arg.startsWith('--profile-dir=')) { out.profileDir = arg.slice(14); continue }
64
+ if (arg === '--data-dir') { out.dataDir = next(); continue }
65
+ if (arg.startsWith('--data-dir=')) { out.dataDir = arg.slice(11); continue }
66
+ if (arg === '--actor') { out.actor = next(); continue }
67
+ if (arg.startsWith('--actor=')) { out.actor = arg.slice(8); continue }
68
+ if (arg === '--bundles') { out.bundles = next().split(',').map((item) => item.trim()).filter(Boolean); continue }
69
+ if (arg.startsWith('--bundles=')) { out.bundles = arg.slice(10).split(',').map((item) => item.trim()).filter(Boolean); continue }
70
+ if (arg === '--min-free-mb') { out.minFreeBytes = Math.max(0, Number(next()) || 0) * 1024 * 1024; continue }
71
+ if (arg.startsWith('--min-free-mb=')) { out.minFreeBytes = Math.max(0, Number(arg.slice(14)) || 0) * 1024 * 1024; continue }
72
+ throw new Error(`unknown argument: ${arg}`)
73
+ }
74
+ if (!out.minFreeBytes) out.minFreeBytes = DEFAULT_MIN_FREE_BYTES
75
+ return out
76
+ }
77
+
78
+ const USAGE = `usage: node tools/gate/main-city.mjs --target <${GATE_TARGETS.join('|')}> [options]
79
+
80
+ options:
81
+ --root <dir> source root (default: process.cwd())
82
+ --version <semver> expected version (default: package.json)
83
+ --dist <dir> dist directory containing manifest.json/tarball (default: dist)
84
+ --package-dir <dir> staged package directory (dist/npm/package)
85
+ --tarball <file> explicit tarball path
86
+ --tag <vX.Y.Z> release tag to match against version
87
+ --npm-latest <version> current registry latest (offline check)
88
+ --check-registry fetch npm latest before publish check
89
+ --allow-existing allow an already-published version (idempotent republish)
90
+ --home <dir> DSH_HOME that owns the profile
91
+ --profile <name> profile name
92
+ --profile-dir <dir> explicit profile directory
93
+ --data-dir <dir> explicit fang data dir (default <home>/fang/<profile>)
94
+ --create create the profile after a green gate
95
+ --force allow replacing an existing profile (backup taken)
96
+ --repair create a .gate-backups snapshot when a profile has none
97
+ --require-backup block deploy when no profile backup exists
98
+ --bundles a,b,c bundles for a newly created profile
99
+ --min-free-mb <n> minimum free disk space (default ${Math.round(DEFAULT_MIN_FREE_BYTES / 1024 / 1024)})
100
+ --actor <id> recorded in the chained audit record
101
+ --json machine-readable report
102
+ -h, --help
103
+ `
104
+
105
+ async function npmLatest(version) {
106
+ try {
107
+ const res = await fetch('https://registry.npmjs.org/@twinsearth%2Ffang-dsh-plugin', { signal: AbortSignal.timeout(15000) })
108
+ if (!res.ok) return { ok: false, detail: `registry HTTP ${res.status}` }
109
+ const body = await res.json()
110
+ return { ok: true, latest: String(body?.['dist-tags']?.latest ?? '') }
111
+ } catch (error) {
112
+ return { ok: false, detail: String(error?.message ?? error) }
113
+ }
114
+ }
115
+
116
+ async function main() {
117
+ let args
118
+ try { args = parseArgs(process.argv.slice(2)) } catch (error) {
119
+ console.error(`[main-city] ${error?.message ?? error}`)
120
+ console.error(USAGE)
121
+ return 2
122
+ }
123
+ if (args.help) { console.log(USAGE); return 0 }
124
+ if (!GATE_TARGETS.includes(args.target)) {
125
+ console.error(`[main-city] unknown target: ${args.target}`)
126
+ console.error(USAGE)
127
+ return 2
128
+ }
129
+ if (args.checkRegistry && args.target === 'publish') {
130
+ const latest = await npmLatest()
131
+ if (latest.ok) args.npmLatest = latest.latest
132
+ else if (!args.json) console.warn(`[main-city] registry check skipped: ${latest.detail}`)
133
+ }
134
+
135
+ let report = runMainGate({
136
+ target: args.target,
137
+ root: args.root,
138
+ version: args.version,
139
+ distDir: args.distDir,
140
+ packageDir: args.packageDir,
141
+ tarball: args.tarball,
142
+ tag: args.tag,
143
+ npmLatest: args.npmLatest,
144
+ allowExisting: args.allowExisting,
145
+ homeDir: args.homeDir,
146
+ profile: args.profile,
147
+ profileDir: args.profileDir,
148
+ dataDir: args.dataDir,
149
+ repair: args.repair,
150
+ requireBackup: args.requireBackup,
151
+ supervisorPresent: args.supervisorPresent,
152
+ create: args.create,
153
+ force: args.force,
154
+ minFreeBytes: args.minFreeBytes,
155
+ actor: args.actor,
156
+ })
157
+
158
+ if (args.target === 'profile' && args.create === true && report.ok) {
159
+ const created = createGatedProfile({
160
+ homeDir: args.homeDir,
161
+ profile: args.profile,
162
+ bundles: args.bundles ?? DEFAULT_PROFILE_BUNDLES,
163
+ force: args.force,
164
+ minFreeBytes: args.minFreeBytes,
165
+ })
166
+ report = { ...report, create: created }
167
+ if (!created.ok) {
168
+ report.ok = false
169
+ report.blockers = [...(report.blockers ?? []), { id: 'profile-create: failed', detail: `${created.error}${created.detail ? ': ' + created.detail : ''}` }]
170
+ report.summary = { ...report.summary, blockers: report.summary.blockers + 1 }
171
+ }
172
+ }
173
+
174
+ const reportFile = path.join(process.cwd(), 'dist', 'main-city-gate.json')
175
+ try {
176
+ fs.mkdirSync(path.dirname(reportFile), { recursive: true })
177
+ fs.writeFileSync(reportFile, JSON.stringify(report, null, 2) + '\n', 'utf8')
178
+ } catch { /* report is best-effort */ }
179
+
180
+ if (args.json) console.log(JSON.stringify(report, null, 2))
181
+ else {
182
+ for (const item of report.checks ?? []) {
183
+ if (item.ok && item.severity === 'info') continue
184
+ console.log(`${item.ok ? 'PASS' : 'FAIL'} ${item.id.padEnd(48)} ${item.detail}`)
185
+ if (item.repair) console.log(` repair: ${item.repair}`)
186
+ }
187
+ console.log(`\n${report.ok ? 'GREEN' : 'RED'} main-city gate ${report.target} ` +
188
+ `blockers=${report.summary?.blockers ?? 0} warnings=${report.summary?.warnings ?? 0} grade=${report.score?.grade ?? '?'}`)
189
+ if (!report.ok) console.log(formatGateFailure(report))
190
+ console.log(`report: ${reportFile}`)
191
+ }
192
+ return report.ok ? 0 : 1
193
+ }
194
+
195
+ const isMain = (() => {
196
+ try { return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)) } catch { return false }
197
+ })()
198
+
199
+ if (isMain) {
200
+ main().then((code) => { process.exitCode = code }).catch((error) => {
201
+ console.error(`[main-city] ${error?.stack ?? error}`)
202
+ process.exitCode = 1
203
+ })
204
+ }