@weibaohui/dsh-git-server 0.1.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.
Files changed (248) hide show
  1. package/README.md +43 -0
  2. package/client/bundle.js +2356 -0
  3. package/client/index.js +2342 -0
  4. package/cordis.patch.yml +11 -0
  5. package/package.json +70 -0
  6. package/server/dist/api/v1.js +2029 -0
  7. package/server/dist/authx/password.js +43 -0
  8. package/server/dist/authx/um.js +157 -0
  9. package/server/dist/conf.js +323 -0
  10. package/server/dist/context.js +701 -0
  11. package/server/dist/db/actions.js +202 -0
  12. package/server/dist/db/db.js +838 -0
  13. package/server/dist/dshapi.js +1416 -0
  14. package/server/dist/editorconfig.js +104 -0
  15. package/server/dist/gitx/git.js +580 -0
  16. package/server/dist/gitx/http.js +232 -0
  17. package/server/dist/gitx/paths.js +28 -0
  18. package/server/dist/gitx/service.js +251 -0
  19. package/server/dist/gosprintf.js +167 -0
  20. package/server/dist/hook.js +53 -0
  21. package/server/dist/index.js +112 -0
  22. package/server/dist/lfsx.js +265 -0
  23. package/server/dist/mailer.js +127 -0
  24. package/server/dist/markup.js +317 -0
  25. package/server/dist/metrics.js +18 -0
  26. package/server/dist/mirror.js +203 -0
  27. package/server/dist/multipart.js +33 -0
  28. package/server/dist/repox.js +99 -0
  29. package/server/dist/router.js +185 -0
  30. package/server/dist/serv.js +43 -0
  31. package/server/dist/server.js +382 -0
  32. package/server/dist/sshx/dispatch.js +160 -0
  33. package/server/dist/sshx/lfs-transfer.js +289 -0
  34. package/server/dist/sshx/server.js +107 -0
  35. package/server/dist/toolx.js +108 -0
  36. package/server/dist/twofactor.js +160 -0
  37. package/server/dist/webapi.js +377 -0
  38. package/server/dist/webhook.js +128 -0
  39. package/server/package.json +34 -0
  40. package/server/public/img/avatar_default.png +0 -0
  41. package/server/tsconfig.json +19 -0
  42. package/server/vendored-conf/app.ini +538 -0
  43. package/server/vendored-conf/gitignore/Actionscript +11 -0
  44. package/server/vendored-conf/gitignore/Ada +5 -0
  45. package/server/vendored-conf/gitignore/Agda +1 -0
  46. package/server/vendored-conf/gitignore/Android +32 -0
  47. package/server/vendored-conf/gitignore/Anjuta +3 -0
  48. package/server/vendored-conf/gitignore/AppEngine +2 -0
  49. package/server/vendored-conf/gitignore/AppceleratorTitanium +3 -0
  50. package/server/vendored-conf/gitignore/ArchLinuxPackages +13 -0
  51. package/server/vendored-conf/gitignore/Archives +27 -0
  52. package/server/vendored-conf/gitignore/Autotools +14 -0
  53. package/server/vendored-conf/gitignore/BricxCC +4 -0
  54. package/server/vendored-conf/gitignore/C +32 -0
  55. package/server/vendored-conf/gitignore/C Sharp +108 -0
  56. package/server/vendored-conf/gitignore/C++ +28 -0
  57. package/server/vendored-conf/gitignore/CFWheels +12 -0
  58. package/server/vendored-conf/gitignore/CMake +6 -0
  59. package/server/vendored-conf/gitignore/CUDA +6 -0
  60. package/server/vendored-conf/gitignore/CakePHP +13 -0
  61. package/server/vendored-conf/gitignore/ChefCookbook +9 -0
  62. package/server/vendored-conf/gitignore/Cloud9 +3 -0
  63. package/server/vendored-conf/gitignore/CodeIgniter +6 -0
  64. package/server/vendored-conf/gitignore/CodeKit +3 -0
  65. package/server/vendored-conf/gitignore/CommonLisp +3 -0
  66. package/server/vendored-conf/gitignore/Composer +6 -0
  67. package/server/vendored-conf/gitignore/Concrete5 +3 -0
  68. package/server/vendored-conf/gitignore/Coq +3 -0
  69. package/server/vendored-conf/gitignore/CraftCMS +3 -0
  70. package/server/vendored-conf/gitignore/DM +5 -0
  71. package/server/vendored-conf/gitignore/Dart +15 -0
  72. package/server/vendored-conf/gitignore/DartEditor +2 -0
  73. package/server/vendored-conf/gitignore/Delphi +57 -0
  74. package/server/vendored-conf/gitignore/Dreamweaver +3 -0
  75. package/server/vendored-conf/gitignore/Drupal +36 -0
  76. package/server/vendored-conf/gitignore/EPiServer +4 -0
  77. package/server/vendored-conf/gitignore/Eagle +36 -0
  78. package/server/vendored-conf/gitignore/Eclipse +39 -0
  79. package/server/vendored-conf/gitignore/EiffelStudio +2 -0
  80. package/server/vendored-conf/gitignore/Elisp +5 -0
  81. package/server/vendored-conf/gitignore/Elixir +4 -0
  82. package/server/vendored-conf/gitignore/Emacs +32 -0
  83. package/server/vendored-conf/gitignore/Ensime +4 -0
  84. package/server/vendored-conf/gitignore/Erlang +10 -0
  85. package/server/vendored-conf/gitignore/Espresso +1 -0
  86. package/server/vendored-conf/gitignore/ExpressionEngine +19 -0
  87. package/server/vendored-conf/gitignore/ExtJs +4 -0
  88. package/server/vendored-conf/gitignore/Fancy +2 -0
  89. package/server/vendored-conf/gitignore/Finale +13 -0
  90. package/server/vendored-conf/gitignore/FlexBuilder +3 -0
  91. package/server/vendored-conf/gitignore/ForceDotCom +4 -0
  92. package/server/vendored-conf/gitignore/FuelPHP +2 -0
  93. package/server/vendored-conf/gitignore/GWT +28 -0
  94. package/server/vendored-conf/gitignore/Gcov +5 -0
  95. package/server/vendored-conf/gitignore/GitBook +16 -0
  96. package/server/vendored-conf/gitignore/Go +24 -0
  97. package/server/vendored-conf/gitignore/Gradle +8 -0
  98. package/server/vendored-conf/gitignore/Grails +33 -0
  99. package/server/vendored-conf/gitignore/Haskell +16 -0
  100. package/server/vendored-conf/gitignore/IGORPro +5 -0
  101. package/server/vendored-conf/gitignore/IPythonNotebook +2 -0
  102. package/server/vendored-conf/gitignore/Idris +2 -0
  103. package/server/vendored-conf/gitignore/JDeveloper +13 -0
  104. package/server/vendored-conf/gitignore/Java +12 -0
  105. package/server/vendored-conf/gitignore/Jboss +19 -0
  106. package/server/vendored-conf/gitignore/Jekyll +3 -0
  107. package/server/vendored-conf/gitignore/JetBrains +46 -0
  108. package/server/vendored-conf/gitignore/Joomla +525 -0
  109. package/server/vendored-conf/gitignore/KDevelop4 +2 -0
  110. package/server/vendored-conf/gitignore/Kate +3 -0
  111. package/server/vendored-conf/gitignore/KiCAD +13 -0
  112. package/server/vendored-conf/gitignore/Kohana +2 -0
  113. package/server/vendored-conf/gitignore/LabVIEW +16 -0
  114. package/server/vendored-conf/gitignore/Laravel +4 -0
  115. package/server/vendored-conf/gitignore/Lazarus +30 -0
  116. package/server/vendored-conf/gitignore/Leiningen +12 -0
  117. package/server/vendored-conf/gitignore/LemonStand +21 -0
  118. package/server/vendored-conf/gitignore/LibreOffice +2 -0
  119. package/server/vendored-conf/gitignore/Lilypond +6 -0
  120. package/server/vendored-conf/gitignore/Linux +7 -0
  121. package/server/vendored-conf/gitignore/Lithium +2 -0
  122. package/server/vendored-conf/gitignore/Lua +41 -0
  123. package/server/vendored-conf/gitignore/LyX +4 -0
  124. package/server/vendored-conf/gitignore/Magento +126 -0
  125. package/server/vendored-conf/gitignore/Matlab +16 -0
  126. package/server/vendored-conf/gitignore/Maven +9 -0
  127. package/server/vendored-conf/gitignore/Mercurial +6 -0
  128. package/server/vendored-conf/gitignore/Mercury +13 -0
  129. package/server/vendored-conf/gitignore/MetaProgrammingSystem +16 -0
  130. package/server/vendored-conf/gitignore/MicrosoftOffice +10 -0
  131. package/server/vendored-conf/gitignore/ModelSim +23 -0
  132. package/server/vendored-conf/gitignore/Momentics +8 -0
  133. package/server/vendored-conf/gitignore/MonoDevelop +8 -0
  134. package/server/vendored-conf/gitignore/Nanoc +10 -0
  135. package/server/vendored-conf/gitignore/NetBeans +8 -0
  136. package/server/vendored-conf/gitignore/Nim +1 -0
  137. package/server/vendored-conf/gitignore/Ninja +2 -0
  138. package/server/vendored-conf/gitignore/Node +28 -0
  139. package/server/vendored-conf/gitignore/NotepadPP +2 -0
  140. package/server/vendored-conf/gitignore/OCaml +20 -0
  141. package/server/vendored-conf/gitignore/Objective-C +43 -0
  142. package/server/vendored-conf/gitignore/Opa +13 -0
  143. package/server/vendored-conf/gitignore/OpenCart +11 -0
  144. package/server/vendored-conf/gitignore/OracleForms +8 -0
  145. package/server/vendored-conf/gitignore/Packer +5 -0
  146. package/server/vendored-conf/gitignore/Perl +20 -0
  147. package/server/vendored-conf/gitignore/Phalcon +2 -0
  148. package/server/vendored-conf/gitignore/PhpStorm +63 -0
  149. package/server/vendored-conf/gitignore/PlayFramework +17 -0
  150. package/server/vendored-conf/gitignore/Plone +18 -0
  151. package/server/vendored-conf/gitignore/Prestashop +25 -0
  152. package/server/vendored-conf/gitignore/Processing +7 -0
  153. package/server/vendored-conf/gitignore/Python +58 -0
  154. package/server/vendored-conf/gitignore/Qooxdoo +5 -0
  155. package/server/vendored-conf/gitignore/Qt +34 -0
  156. package/server/vendored-conf/gitignore/R +16 -0
  157. package/server/vendored-conf/gitignore/ROS +47 -0
  158. package/server/vendored-conf/gitignore/Rails +35 -0
  159. package/server/vendored-conf/gitignore/Redcar +1 -0
  160. package/server/vendored-conf/gitignore/Redis +3 -0
  161. package/server/vendored-conf/gitignore/RhodesRhomobile +9 -0
  162. package/server/vendored-conf/gitignore/Ruby +36 -0
  163. package/server/vendored-conf/gitignore/Rust +11 -0
  164. package/server/vendored-conf/gitignore/SBT +9 -0
  165. package/server/vendored-conf/gitignore/SCons +2 -0
  166. package/server/vendored-conf/gitignore/SVN +1 -0
  167. package/server/vendored-conf/gitignore/Sass +2 -0
  168. package/server/vendored-conf/gitignore/Scala +17 -0
  169. package/server/vendored-conf/gitignore/Scrivener +7 -0
  170. package/server/vendored-conf/gitignore/Sdcc +8 -0
  171. package/server/vendored-conf/gitignore/SeamGen +26 -0
  172. package/server/vendored-conf/gitignore/SketchUp +1 -0
  173. package/server/vendored-conf/gitignore/SlickEdit +11 -0
  174. package/server/vendored-conf/gitignore/Stella +12 -0
  175. package/server/vendored-conf/gitignore/SublimeText +14 -0
  176. package/server/vendored-conf/gitignore/SugarCRM +25 -0
  177. package/server/vendored-conf/gitignore/Swift +43 -0
  178. package/server/vendored-conf/gitignore/Symfony +37 -0
  179. package/server/vendored-conf/gitignore/SymphonyCMS +6 -0
  180. package/server/vendored-conf/gitignore/SynopsysVCS +36 -0
  181. package/server/vendored-conf/gitignore/Tags +14 -0
  182. package/server/vendored-conf/gitignore/TeX +136 -0
  183. package/server/vendored-conf/gitignore/TextMate +3 -0
  184. package/server/vendored-conf/gitignore/Textpattern +11 -0
  185. package/server/vendored-conf/gitignore/TortoiseGit +2 -0
  186. package/server/vendored-conf/gitignore/TurboGears2 +20 -0
  187. package/server/vendored-conf/gitignore/Typo3 +20 -0
  188. package/server/vendored-conf/gitignore/Umbraco +17 -0
  189. package/server/vendored-conf/gitignore/Unity +21 -0
  190. package/server/vendored-conf/gitignore/UnrealEngine +76 -0
  191. package/server/vendored-conf/gitignore/VVVV +6 -0
  192. package/server/vendored-conf/gitignore/Vagrant +1 -0
  193. package/server/vendored-conf/gitignore/Vim +6 -0
  194. package/server/vendored-conf/gitignore/VirtualEnv +10 -0
  195. package/server/vendored-conf/gitignore/VisualStudio +217 -0
  196. package/server/vendored-conf/gitignore/VisualStudioCode +2 -0
  197. package/server/vendored-conf/gitignore/Waf +3 -0
  198. package/server/vendored-conf/gitignore/WebMethods +14 -0
  199. package/server/vendored-conf/gitignore/WebStorm +63 -0
  200. package/server/vendored-conf/gitignore/Windows +18 -0
  201. package/server/vendored-conf/gitignore/WordPress +18 -0
  202. package/server/vendored-conf/gitignore/Xcode +23 -0
  203. package/server/vendored-conf/gitignore/XilinxISE +57 -0
  204. package/server/vendored-conf/gitignore/Xojo +11 -0
  205. package/server/vendored-conf/gitignore/Yeoman +6 -0
  206. package/server/vendored-conf/gitignore/Yii +6 -0
  207. package/server/vendored-conf/gitignore/ZendFramework +20 -0
  208. package/server/vendored-conf/gitignore/Zephir +26 -0
  209. package/server/vendored-conf/gitignore/macOS +25 -0
  210. package/server/vendored-conf/label/Default +7 -0
  211. package/server/vendored-conf/license/Abstyles License +10 -0
  212. package/server/vendored-conf/license/Academic Free License v1.1 +22 -0
  213. package/server/vendored-conf/license/Academic Free License v1.2 +27 -0
  214. package/server/vendored-conf/license/Academic Free License v2.0 +43 -0
  215. package/server/vendored-conf/license/Academic Free License v2.1 +44 -0
  216. package/server/vendored-conf/license/Academic Free License v3.0 +42 -0
  217. package/server/vendored-conf/license/Affero General Public License v1.0 +84 -0
  218. package/server/vendored-conf/license/Apache License 1.0 +19 -0
  219. package/server/vendored-conf/license/Apache License 1.1 +20 -0
  220. package/server/vendored-conf/license/Apache License 2.0 +72 -0
  221. package/server/vendored-conf/license/Artistic License 1.0 +48 -0
  222. package/server/vendored-conf/license/Artistic License 2.0 +84 -0
  223. package/server/vendored-conf/license/BSD 2-clause License +8 -0
  224. package/server/vendored-conf/license/BSD 3-clause License +10 -0
  225. package/server/vendored-conf/license/BSD 4-clause License +13 -0
  226. package/server/vendored-conf/license/Creative Commons CC0 1.0 Universal +39 -0
  227. package/server/vendored-conf/license/Eclipse Public License 1.0 +72 -0
  228. package/server/vendored-conf/license/Educational Community License v1.0 +22 -0
  229. package/server/vendored-conf/license/Educational Community License v2.0 +97 -0
  230. package/server/vendored-conf/license/GNU Affero General Public License v3.0 +241 -0
  231. package/server/vendored-conf/license/GNU Free Documentation License v1.1 +118 -0
  232. package/server/vendored-conf/license/GNU Free Documentation License v1.2 +129 -0
  233. package/server/vendored-conf/license/GNU Free Documentation License v1.3 +148 -0
  234. package/server/vendored-conf/license/GNU General Public License v1.0 +99 -0
  235. package/server/vendored-conf/license/GNU General Public License v2.0 +116 -0
  236. package/server/vendored-conf/license/GNU General Public License v3.0 +231 -0
  237. package/server/vendored-conf/license/GNU Lesser General Public License v2.1 +174 -0
  238. package/server/vendored-conf/license/GNU Lesser General Public License v3.0 +70 -0
  239. package/server/vendored-conf/license/GNU Library General Public License v2.0 +173 -0
  240. package/server/vendored-conf/license/ISC license +6 -0
  241. package/server/vendored-conf/license/MIT License +8 -0
  242. package/server/vendored-conf/license/Mozilla Public License 1.0 +122 -0
  243. package/server/vendored-conf/license/Mozilla Public License 1.1 +142 -0
  244. package/server/vendored-conf/license/Mozilla Public License 2.0 +143 -0
  245. package/server/vendored-conf/readme/Default +3 -0
  246. package/src/dsh-host.js +168 -0
  247. package/src/index.js +682 -0
  248. package/src/runner.js +364 -0
package/src/index.js ADDED
@@ -0,0 +1,682 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * @weibaohui/dsh-git-server — Host half(cordis 宿主接线)。
5
+ *
6
+ * 四件事:
7
+ * 1. settings 服务注册 schema(enabled/host/port/dataDir/authMode,3s 内
8
+ * 热生效——reconciler 指纹比对,变化才重启子进程);
9
+ * 2. 内嵌 ts-gogs 子进程生命周期(src/runner.js:写 app.ini → spawn →
10
+ * 就绪探测 → 崩溃自动拉起);
11
+ * 3. 同源路由 /dsh-git-server/api/*(status/settings)给宿主设置页用——
12
+ * 认证由宿主 web 门禁负责,此处不重复做;
13
+ * 4. 管理员密码管理:首次启动自动生成并持久化(settings 优先,token 文件
14
+ * 兜底),设置页可见可轮换。
15
+ *
16
+ * authMode:
17
+ * - gogs(默认):ts-gogs 自己的账号/令牌体系(管理员密码在状态里展示);
18
+ * - user-management:git HTTP Basic 与网页登录接受 user-management 的
19
+ * 用户名密码(映射到本库管理员),用户库缺失自动回退 gogs 模式。
20
+ */
21
+
22
+ const fs = require('node:fs')
23
+ const path = require('node:path')
24
+
25
+ const engine = require('./runner')
26
+
27
+ const name = engine.PLUGIN_ID
28
+ const inject = ['webServer', 'settings']
29
+ const API_PREFIX = '/' + engine.PLUGIN_ID + '/api'
30
+ const SETTINGS_NS = engine.PLUGIN_ID
31
+
32
+ // ── schemastery 加载(settings 服务同源;缺席时仅 loader config 生效)──────
33
+
34
+ const { pathToFileURL } = require('node:url')
35
+ const os = require('node:os')
36
+
37
+ let schemaPromise = null
38
+
39
+ function hostCandidatePaths(pkgName, rel) {
40
+ const prefixes = [process.env.DSH_GLOBAL_PREFIX, os.homedir() + '/.local'].filter(Boolean)
41
+ return prefixes.map((prefix) =>
42
+ path.join(prefix, 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', pkgName, rel),
43
+ )
44
+ }
45
+
46
+ function startSchemaLoader() {
47
+ if (!schemaPromise) {
48
+ schemaPromise = (async () => {
49
+ const candidates = [
50
+ ...hostCandidatePaths('schemastery', 'lib/index.cjs'),
51
+ '@deepseek-ai/schemastery',
52
+ ]
53
+ const errors = []
54
+ for (const target of candidates) {
55
+ try {
56
+ const specifier = target.includes('/') && !target.startsWith('@') && target.includes('node_modules')
57
+ ? pathToFileURL(target).href
58
+ : target
59
+ const mod = await import(specifier)
60
+ return mod
61
+ } catch (e) {
62
+ errors.push(`${target}: ${String((e && e.message) || e).slice(0, 160)}`)
63
+ }
64
+ }
65
+ throw new Error(errors.join(' | '))
66
+ })()
67
+ }
68
+ return schemaPromise
69
+ }
70
+
71
+ async function resolveSchema() {
72
+ try {
73
+ const mod = await startSchemaLoader()
74
+ if (!mod) return null
75
+ const Schema = mod.default || mod.Schema || (typeof mod === 'function' ? mod : null)
76
+ return Schema && typeof Schema.object === 'function' ? Schema : null
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ function __seedSchema(Schema) {
83
+ schemaPromise = Promise.resolve(Schema ? { default: Schema } : null)
84
+ }
85
+
86
+ function settingsSchema(Schema) {
87
+ if (!Schema || typeof Schema.object !== 'function') return null
88
+ return Schema.object({
89
+ enabled: Schema.boolean().default(engine.DEFAULTS.enabled),
90
+ port: Schema.number().step(1).min(engine.NUM_RANGES.port[0]).max(engine.NUM_RANGES.port[1]).default(engine.DEFAULTS.port),
91
+ dataDir: Schema.string().default(engine.DEFAULTS.dataDir),
92
+ adminPassword: Schema.string().default(engine.DEFAULTS.adminPassword),
93
+ impersonateSecret: Schema.string().default(engine.DEFAULTS.impersonateSecret),
94
+ })
95
+ }
96
+
97
+ // ── 插件 ─────────────────────────────────────────────────────────────────
98
+
99
+ module.exports = {
100
+ name,
101
+ inject,
102
+ version: '0.1.0',
103
+ __internals: { settingsSchema, resolveSchema, __seedSchema, API_PREFIX, SETTINGS_NS },
104
+
105
+ apply(ctx, config) {
106
+ // 宿主可能过滤插件 logger 输出;console.error 走 stderr 保底可见(launchd 下进 err.log)
107
+ const hostLogger = ctx.logger && typeof ctx.logger.warn === 'function' ? ctx.logger : null
108
+ const logger = {
109
+ info(m) { try { hostLogger && hostLogger.info && hostLogger.info(m) } catch {} console.error(m) },
110
+ warn(m) { try { hostLogger && hostLogger.warn && hostLogger.warn(m) } catch {} console.error(m) },
111
+ error(m) { try { hostLogger && hostLogger.error && hostLogger.error(m) } catch {} console.error(m) },
112
+ }
113
+ const webServer = ctx.webServer
114
+
115
+ const base = engine.normalizeConfig({ ...engine.DEFAULTS, ...(config || {}) })
116
+ let settingsScope = null
117
+ let memoryPatch = {}
118
+ let pwFileCache = null
119
+
120
+ const state = { handle: null, fingerprint: '', error: null, restarting: 0, deps: 'ok' }
121
+
122
+ const warnThrottledAt = new Map()
123
+ function warnThrottled(key, line, ttlMs = 60000) {
124
+ const t = Date.now()
125
+ if (t - (warnThrottledAt.get(key) || 0) < ttlMs) return
126
+ warnThrottledAt.set(key, t)
127
+ logger.warn(`dsh-git-server: ${line}`)
128
+ }
129
+
130
+ function passwordFile() {
131
+ return path.join(engine.resolveDataDir(base.dataDir || ''), 'custom', 'admin-password.txt')
132
+ }
133
+ function readPasswordFile() {
134
+ if (pwFileCache !== null) return pwFileCache
135
+ try { pwFileCache = fs.readFileSync(passwordFile(), 'utf8').trim() } catch { pwFileCache = '' }
136
+ return pwFileCache
137
+ }
138
+ function writePasswordFile(pw) {
139
+ try {
140
+ fs.mkdirSync(path.dirname(passwordFile()), { recursive: true })
141
+ fs.writeFileSync(passwordFile(), pw + '\n', { mode: 0o600 })
142
+ pwFileCache = pw
143
+ } catch (e) {
144
+ logger.warn(`dsh-git-server: 管理员密码文件写入失败: ${(e && e.message) || e}`)
145
+ }
146
+ }
147
+
148
+ /** 确保管理员密码存在:settings → 密码文件(已有的先采纳)→ 自动生成。 */
149
+ async function ensureAdminPassword(cfg) {
150
+ if (cfg.adminPassword) return
151
+ const fromFile = readPasswordFile()
152
+ if (fromFile) {
153
+ memoryPatch.adminPassword = fromFile
154
+ cfg.adminPassword = fromFile
155
+ return
156
+ }
157
+ const pw = engine.generateSecret().slice(0, 20)
158
+ if (settingsScope && typeof settingsScope.update === 'function') {
159
+ try {
160
+ await settingsScope.update({ adminPassword: pw })
161
+ } catch (e) {
162
+ logger.warn(`dsh-git-server: 管理员密码写入 settings 失败,退回文件: ${(e && e.message) || e}`)
163
+ memoryPatch.adminPassword = pw
164
+ writePasswordFile(pw)
165
+ }
166
+ } else {
167
+ memoryPatch.adminPassword = pw
168
+ writePasswordFile(pw)
169
+ }
170
+ cfg.adminPassword = pw
171
+ }
172
+
173
+ // ── 用户桥(宿主侧全程无内核 HTTP):dsh 会话用户名 → 影子建号(宿主读
174
+ // UM users.json + 写共享 gogs.db)→ 铸个人令牌(直插 access_token 表)。
175
+ // 内核不再为此服务 HTTP;只保留 git HTTP/LFS 的 basic 认证。
176
+ const userTokens = new Map() // username → { pw, token }(子进程重启由 reconcile 清空)
177
+ async function kernelTokenFor(cfg, username) {
178
+ const hit = userTokens.get(username)
179
+ if (hit && hit.pw === cfg.adminPassword) return hit.token
180
+ const { ensureShadowHost, mintTokenHost } = require('./dsh-host')
181
+ await ensureShadowHost(cfg, username)
182
+ const token = await mintTokenHost(cfg, username)
183
+ userTokens.set(username, { pw: cfg.adminPassword, token })
184
+ return token
185
+ }
186
+ async function kernelApi(cfg, username, method, path, body) {
187
+ const token = await kernelTokenFor(cfg, username)
188
+ const headers = { Authorization: `token ${token}` }
189
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
190
+ const res = await fetch(`http://127.0.0.1:${cfg.port}/api/v1${path}`, {
191
+ method, headers, body: body !== undefined ? JSON.stringify(body) : undefined,
192
+ })
193
+ const text = await res.text()
194
+ let json = null
195
+ try { json = JSON.parse(text) } catch {}
196
+ return { status: res.status, json }
197
+ }
198
+ function umUsernameOf(req) {
199
+ // 网关链路:um_session 由网关代理以 x-um-session 头转发(cookie 被其
200
+ // 替换为 dsh-auth);直连链路:cookie 里带 um_session。两者都查。
201
+ const hdr = req.headers['x-um-session']
202
+ const m = hdr ? [null, String(hdr)] : String(req.headers.cookie || '').match(/um_session=([^;]+)/)
203
+ if (!m) return null
204
+ return umUsernameForSession(decodeURIComponent(m[1]))
205
+ }
206
+
207
+ // ── ts-gogs 管理桥:用 root:adminPassword 铸 token,代理仓库管理 ──────
208
+ let adminTokenCache = null
209
+ async function adminToken(cfg) {
210
+ if (adminTokenCache && adminTokenCache.pw === cfg.adminPassword) return adminTokenCache.token
211
+ const auth = 'Basic ' + Buffer.from(`root:${cfg.adminPassword}`).toString('base64')
212
+ const res = await fetch(`http://127.0.0.1:${cfg.port}/api/v1/users/root/tokens`, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json', Authorization: auth },
215
+ body: JSON.stringify({ name: 'dsh-settings-' + Date.now().toString(36) }),
216
+ })
217
+ if (res.status !== 200 && res.status !== 201) throw new Error(`铸管理令牌失败: ${res.status}`)
218
+ const token = ((await res.json()) || {}).sha1
219
+ adminTokenCache = { pw: cfg.adminPassword, token }
220
+ return token
221
+ }
222
+ async function gogsApi(cfg, method, path, body) {
223
+ const token = await adminToken(cfg)
224
+ const headers = { Authorization: `token ${token}` }
225
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
226
+ const res = await fetch(`http://127.0.0.1:${cfg.port}/api/v1${path}`, {
227
+ method, headers, body: body !== undefined ? JSON.stringify(body) : undefined,
228
+ })
229
+ const text = await res.text()
230
+ let json = null
231
+ try { json = JSON.parse(text) } catch {}
232
+ return { status: res.status, json, text }
233
+ }
234
+
235
+ function impersonateFile() {
236
+ return path.join(engine.resolveDataDir(base.dataDir || ''), 'custom', 'impersonate-secret.txt')
237
+ }
238
+ function readImpersonateFile() {
239
+ try { return fs.readFileSync(impersonateFile(), 'utf8').trim() } catch { return '' }
240
+ }
241
+ function writeImpersonateFile(v) {
242
+ try {
243
+ fs.mkdirSync(path.dirname(impersonateFile()), { recursive: true })
244
+ fs.writeFileSync(impersonateFile(), v + '\n', { mode: 0o600 })
245
+ } catch (e) {
246
+ logger.warn(`dsh-git-server: impersonate secret 文件写入失败: ${(e && e.message) || e}`)
247
+ }
248
+ }
249
+ async function ensureImpersonateSecret(cfg) {
250
+ if (cfg.impersonateSecret) return
251
+ const fromFile = readImpersonateFile()
252
+ const pw = fromFile || engine.generateSecret()
253
+ memoryPatch.impersonateSecret = pw
254
+ cfg.impersonateSecret = pw
255
+ if (fromFile) return
256
+ if (settingsScope && typeof settingsScope.update === 'function') {
257
+ try { await settingsScope.update({ impersonateSecret: pw }) } catch (e) {
258
+ logger.warn(`dsh-git-server: impersonate secret 写入 settings 失败,退回文件: ${(e && e.message) || e}`)
259
+ writeImpersonateFile(pw)
260
+ }
261
+ } else writeImpersonateFile(pw)
262
+ }
263
+
264
+ // dsh 会话(um_session cookie)→ user-management 用户名
265
+ let umSessionsCache = null
266
+ let umSessionsStamp = null
267
+ function umUsernameForSession(token) {
268
+ const file = path.join(engine.dshHome(), 'user-management', 'sessions.json')
269
+ let stamp = null
270
+ try { const st = fs.statSync(file); stamp = `${st.mtimeMs}:${st.size}` } catch { return null }
271
+ if (stamp !== umSessionsStamp || !umSessionsCache) {
272
+ try {
273
+ umSessionsCache = JSON.parse(fs.readFileSync(file, 'utf8')).tokens || {}
274
+ umSessionsStamp = stamp
275
+ } catch { umSessionsCache = {} }
276
+ }
277
+ const rec = umSessionsCache[token]
278
+ if (!rec || (rec.expiresAt && rec.expiresAt < Date.now())) return null
279
+ return rec.username || null
280
+ }
281
+
282
+ function umAvailability(cfg) {
283
+ const mod = engine.locateUmStore()
284
+ if (!mod) return 'missing'
285
+ try { fs.statSync(mod); return 'ok' } catch { return 'missing' }
286
+ }
287
+
288
+ function status() {
289
+ const cfg = engine.normalizeConfig(effective())
290
+ const lan = require('node:os').networkInterfaces()
291
+ let lanIp = null
292
+ for (const list of Object.values(lan)) {
293
+ for (const ni of list || []) {
294
+ if (ni.family === 'IPv4' && !ni.internal) { lanIp = ni.address; break }
295
+ }
296
+ if (lanIp) break
297
+ }
298
+ const openToLan = cfg.host === '0.0.0.0' || cfg.host === '::'
299
+ const urlHost = openToLan ? (lanIp || '127.0.0.1') : cfg.host
300
+ return {
301
+ ok: true,
302
+ running: Boolean(state.handle),
303
+ pid: state.handle ? state.handle.pid : null,
304
+ enabled: cfg.enabled,
305
+ error: state.error,
306
+ host: cfg.host,
307
+ port: cfg.port,
308
+ urlLocal: `http://127.0.0.1:${cfg.port}/`,
309
+ urlLan: openToLan && lanIp ? `http://${lanIp}:${cfg.port}/` : null,
310
+ urlDisplay: `http://${urlHost}:${cfg.port}/`,
311
+ dataDir: cfg.dataDir,
312
+ umAvailable: umAvailability(cfg),
313
+ adminUser: 'root',
314
+ adminPassword: cfg.adminPassword || readPasswordFile(),
315
+ crashes: state.restarting,
316
+ deps: state.deps,
317
+ }
318
+ }
319
+
320
+ function effective() {
321
+ const fromSettings = settingsScope && typeof settingsScope.get === 'function'
322
+ ? settingsScope.get()
323
+ : null
324
+ return engine.normalizeConfig({ ...base, ...(fromSettings || {}), ...memoryPatch })
325
+ }
326
+
327
+ let restartTimer = null
328
+ /** 配置协调器:指纹变化才重启子进程;enabled=false 则停机。 */
329
+ async function reconcile() {
330
+ const cfg = engine.normalizeConfig(effective())
331
+ if (state.retryAt && Date.now() < state.retryAt) return status()
332
+ await ensureAdminPassword(cfg)
333
+ await ensureImpersonateSecret(cfg)
334
+ const fp = engine.serverFingerprint(cfg)
335
+ if (fp === state.fingerprint) return status()
336
+
337
+ if (!cfg.enabled) {
338
+ if (state.handle) { await state.handle.stop(); state.handle = null }
339
+ state.fingerprint = fp
340
+ state.error = null
341
+ logger.info('dsh-git-server: 已停用,git 服务器子进程关闭')
342
+ return status()
343
+ }
344
+
345
+ state.fingerprint = fp // 先记账防并发重建;失败时错误落在 state.error
346
+ userTokens.clear()
347
+ if (state.handle) { await state.handle.stop(); state.handle = null }
348
+ try {
349
+ // 依赖自检/自动安装的日志始终可见;子进程自身输出仅在 verbose 模式转发
350
+ await engine.ensureDeps({ logger: (line) => logger.info(`dsh-git-server: ${line}`) })
351
+ state.deps = 'ok'
352
+ const handle = await engine.start(cfg, {
353
+ logger: (line) => { if (process.env.DSH_GIT_SERVER_VERBOSE) logger.info(`dsh-git-server: ${line}`) },
354
+ })
355
+ handle.onExit(({ code, stderrTail }) => {
356
+ // 意外退出:清指纹让 reconciler 的下一跳(3s)自动拉起
357
+ if (state.handle === handle) state.handle = null
358
+ state.fingerprint = ''
359
+ state.restarting++
360
+ logger.warn(`dsh-git-server: ts-gogs 意外退出 code=${code},3s 后自动拉起\n${(stderrTail || '').slice(-400)}`)
361
+ })
362
+ state.handle = handle
363
+ state.error = null
364
+ logger.info(
365
+ `dsh-git-server: Git 服务器 http://${cfg.host}:${cfg.port}/ ` +
366
+ `auth=${cfg.authMode === 'user-management' ? 'user-management' : 'gogs'} dataDir=${cfg.dataDir}`,
367
+ )
368
+ } catch (e) {
369
+ const msg = String((e && e.message) || e)
370
+ // 失败后清指纹 + 30s 退避,让心跳自动重试(in-flight 去重防并发装)
371
+ state.handle = null
372
+ state.fingerprint = ''
373
+ state.retryAt = Date.now() + 30000
374
+ if (/依赖自动安装/.test(msg)) {
375
+ state.deps = 'failed'
376
+ state.error = msg
377
+ logger.warn('dsh-git-server: ' + msg + '(30s 后自动重试)')
378
+ } else {
379
+ state.deps = 'ok'
380
+ state.error = msg
381
+ logger.error('dsh-git-server: ' + msg)
382
+ }
383
+ }
384
+ return status()
385
+ }
386
+
387
+ // ── settings 注册(异步;缺席不阻塞宿主半其余功能)───────────────────
388
+ ;(async () => {
389
+ const Schema = await resolveSchema()
390
+ if (Schema && ctx.settings && typeof ctx.settings.register === 'function') {
391
+ try {
392
+ settingsScope = ctx.settings.register(SETTINGS_NS, settingsSchema(Schema), { base })
393
+ logger.info('dsh-git-server: settings-registered')
394
+ } catch (e) {
395
+ logger.warn(`dsh-git-server: settings register 失败(仅 loader config 生效): ${(e && e.message) || e}`)
396
+ }
397
+ } else {
398
+ logger.warn(
399
+ `dsh-git-server: settings 不可用(Schema=${Boolean(Schema)}, ctx.settings=${Boolean(ctx.settings)})——配置退回进程内兜底`,
400
+ )
401
+ }
402
+ await reconcile()
403
+ })().catch((e) => logger.error(`dsh-git-server: init: ${(e && e.message) || e}`))
404
+
405
+ // ── 同源 API:status / settings ─────────────────────────────────────
406
+ function repoCard(x, actor) {
407
+ return {
408
+ id: x.id, name: x.name, owner: (x.owner && (x.owner.login || x.owner.username)) || '',
409
+ fullName: x.full_name, private: !!x.private,
410
+ stars: x.stars_count || 0, issues: x.open_issues_count || 0, forks: x.forks_count || 0,
411
+ description: x.description || '',
412
+ }
413
+ }
414
+ function errText(r) {
415
+ const j = r.json
416
+ if (j && j.message) return String(j.message)
417
+ if (Array.isArray(j) && j[0] && j[0].message) return j[0].message
418
+ return 'kernel ' + r.status
419
+ }
420
+
421
+ function sendJson(res, statusCode, payloadOut) {
422
+ const body = JSON.stringify(payloadOut)
423
+ res.writeHead(statusCode, {
424
+ 'content-type': 'application/json; charset=utf-8',
425
+ 'content-length': Buffer.byteLength(body),
426
+ 'cache-control': 'no-store',
427
+ })
428
+ res.end(body)
429
+ }
430
+
431
+ function readBody(req) {
432
+ return new Promise((resolve, reject) => {
433
+ const chunks = []
434
+ let size = 0
435
+ req.on('data', (c) => {
436
+ size += c.length
437
+ if (size > 64 * 1024) {
438
+ reject(new Error('body too large'))
439
+ req.destroy()
440
+ return
441
+ }
442
+ chunks.push(c)
443
+ })
444
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
445
+ req.on('error', reject)
446
+ })
447
+ }
448
+
449
+ if (webServer && typeof webServer.register === 'function') {
450
+ ctx.effect(() => {
451
+ webServer.register({
452
+ kind: 'prefix',
453
+ path: API_PREFIX,
454
+ handler: async (req, res) => {
455
+ const url = new URL(req.url || '/', 'http://dsh.local')
456
+ const rest = url.pathname.slice(API_PREFIX.length)
457
+ try {
458
+ if (req.method === 'GET' && (rest === '' || rest === '/' || rest === '/status')) {
459
+ sendJson(res, 200, status())
460
+ return
461
+ }
462
+ const cfg = engine.normalizeConfig(effective())
463
+ const actor = umUsernameOf(req) // dsh 会话 → 操作者本人
464
+ if (!actor) { sendJson(res, 401, { ok: false, error: '需要 dsh 登录' }); return }
465
+ const parts = rest.split('/').filter(Boolean) // dsh|repos,o,r,what...
466
+
467
+ // 未启用/未运行时,数据面路由返回清晰提示(而不是去 fetch 内核撞
468
+ // ECONNREFUSED 变成一句莫名其妙的 "fetch failed")。PUT /settings 例外
469
+ // 放行——那是启用入口,必须可用;GET /status 在上面 459 行已先行返回。
470
+ const isSettingsWrite = req.method === 'PUT' && rest === '/settings'
471
+ if (!isSettingsWrite) {
472
+ if (!cfg.enabled) {
473
+ sendJson(res, 503, { ok: false, disabled: true, enabled: false, error: 'git 服务器未启用:请到「设置 → Git 服务器」勾选启用后重试' })
474
+ return
475
+ }
476
+ if (!state.handle) {
477
+ sendJson(res, 503, { ok: false, starting: true, enabled: true, running: false, error: 'git 服务器正在启动或异常重启中,请稍候重试' })
478
+ return
479
+ }
480
+ }
481
+
482
+ // GET /me — 本人 + 仓库列表
483
+ if (req.method === 'GET' && (rest === '/me' || rest === '' || rest === '/')) {
484
+ const me = await kernelApi(cfg, actor, 'GET', '/user')
485
+ const repos = await kernelApi(cfg, actor, 'GET', '/user/repos')
486
+ sendJson(res, 200, {
487
+ ok: true,
488
+ user: { name: actor, isAdmin: !!(me.json && me.json.is_admin) },
489
+ repos: Array.isArray(repos.json) ? repos.json.map((x) => repoCard(x)) : [],
490
+ })
491
+ return
492
+ }
493
+ // POST /repos — 建仓(本人身份)
494
+ if (req.method === 'POST' && rest === '/repos') {
495
+ const body = JSON.parse((await readBody(req)) || '{}')
496
+ const name = String(body.name || '').trim()
497
+ if (!name) { sendJson(res, 400, { ok: false, error: '仓库名不能为空' }); return }
498
+ const r = await kernelApi(cfg, actor, 'POST', '/user/repos',
499
+ { name, private: !!body.private, auto_init: body.autoInit !== false, readme: 'Default' })
500
+ sendJson(res, r.status === 201 ? 200 : r.status,
501
+ r.status === 201 ? { ok: true, repo: repoCard(r.json, actor) } : { ok: false, error: errText(r) })
502
+ return
503
+ }
504
+ // DELETE /repos/:owner/:name
505
+ const delM = req.method === 'DELETE' && rest.startsWith('/repos/') && rest.split('/').length === 4
506
+ if (delM) {
507
+ const [, , owner, name] = rest.split('/')
508
+ const r = await kernelApi(cfg, actor, 'DELETE', `/repos/${owner}/${name}`)
509
+ sendJson(res, [200, 204].includes(r.status) ? 200 : r.status,
510
+ [200, 204].includes(r.status) ? { ok: true } : { ok: false, error: '删除失败(无权限或不存在)' })
511
+ return
512
+ }
513
+ // GET /repos/:o/:r/tree|raw|commits|branches|issues...
514
+ if (req.method === 'GET' && parts[0] === 'repos' && parts.length >= 4) {
515
+ const [, owner, repo, ...tail] = parts
516
+ const what = tail.join('/')
517
+ const q = (n) => url.searchParams.get(n) || ''
518
+ if (what === 'tree') {
519
+ const pth = q('path') ? '?ref=' + encodeURIComponent(q('ref') || '') + '&path=' + encodeURIComponent(q('path'))
520
+ : '?ref=' + encodeURIComponent(q('ref') || '')
521
+ const r = await kernelApi(cfg, actor, 'GET', `/repos/${owner}/${repo}/contents${pth}`)
522
+ sendJson(res, r.status, Array.isArray(r.json)
523
+ ? { ok: true, entries: r.json.map((e) => ({ name: e.name, type: e.type, path: e.path, size: e.size })) }
524
+ : { ok: false, error: errText(r) })
525
+ return
526
+ }
527
+ if (what === 'raw') {
528
+ const r = await kernelApi(cfg, actor, 'GET',
529
+ `/repos/${owner}/${repo}/raw/${encodeURIComponent(q('ref'))}/${q('path').split('/').map(encodeURIComponent).join('/')}`)
530
+ // raw 端点返回文本(非 JSON)
531
+ const res2 = await fetch(`http://127.0.0.1:${cfg.port}/api/v1/repos/${owner}/${repo}/raw/${encodeURIComponent(q('ref'))}/${q('path').split('/').map(encodeURIComponent).join('/')}`,
532
+ { headers: { Authorization: 'token ' + await kernelTokenFor(cfg, actor) } })
533
+ const text = await res2.text()
534
+ sendJson(res, 200, { ok: res2.status === 200, text: res2.status === 200 ? text.slice(0, 512 * 1024) : '', error: res2.status === 200 ? null : 'not found' })
535
+ return
536
+ }
537
+ if (what === 'commits') {
538
+ const r = await kernelApi(cfg, actor, 'GET', `/repos/${owner}/${repo}/commits${q('ref') ? '?sha=' + encodeURIComponent(q('ref')) : ''}`)
539
+ const list = Array.isArray(r.json) ? r.json : (r.json && r.json.data) || []
540
+ sendJson(res, r.status, { ok: Array.isArray(list), commits: (Array.isArray(list) ? list : []).slice(0, 30).map((c) => ({
541
+ sha: (c.sha || '').slice(0, 10), message: (c.commit && c.commit.message) || c.message || '',
542
+ author: (c.author && (c.author.login || c.author.name)) || (c.commit && c.commit.author && c.commit.author.name) || '',
543
+ date: (c.commit && c.commit.author && c.commit.author.date) || c.created_at || '',
544
+ })) })
545
+ return
546
+ }
547
+ if (what === 'branches') {
548
+ const r = await kernelApi(cfg, actor, 'GET', `/repos/${owner}/${repo}/branches`)
549
+ sendJson(res, r.status, { ok: Array.isArray(r.json), branches: (Array.isArray(r.json) ? r.json : []).map((b) => ({ name: b.name, sha: (b.commit && b.commit.id) || b.sha })) })
550
+ return
551
+ }
552
+ if (what === 'issues') {
553
+ const state = q('state') || 'open'
554
+ const r = await kernelApi(cfg, actor, 'GET', `/repos/${owner}/${repo}/issues?state=${state}&type=issues`)
555
+ sendJson(res, r.status, { ok: Array.isArray(r.json), issues: (Array.isArray(r.json) ? r.json : []).map((i) => ({
556
+ number: i.number, title: i.title, state: i.state, user: i.user && i.user.login,
557
+ comments: i.comments, updatedAt: i.updated_at,
558
+ })) })
559
+ return
560
+ }
561
+ if (what.startsWith('issues/') && tail.length === 3) {
562
+ // issues/:idx/comments
563
+ const idx = tail[1]
564
+ if (tail[2] === 'comments') {
565
+ const r = await kernelApi(cfg, actor, 'GET', `/repos/${owner}/${repo}/issues/${idx}/comments`)
566
+ sendJson(res, r.status, { ok: Array.isArray(r.json), comments: (Array.isArray(r.json) ? r.json : []).map((c) => ({
567
+ id: c.id, body: c.body, user: c.user && c.user.login, created: c.created_at,
568
+ })) })
569
+ return
570
+ }
571
+ }
572
+ }
573
+ // POST /repos/:o/:r/issues — 建工单
574
+ if (req.method === 'POST' && parts[0] === 'repos' && parts[3] === 'issues' && parts.length === 4) {
575
+ const [, owner, repo] = parts
576
+ const body = JSON.parse((await readBody(req)) || '{}')
577
+ const r = await kernelApi(cfg, actor, 'POST', `/repos/${owner}/${repo}/issues`, {
578
+ title: String(body.title || ''), body: String(body.body || ''),
579
+ ...(Array.isArray(body.labels) ? { labels: body.labels } : {}),
580
+ ...(body.milestone ? { milestone: body.milestone } : {}),
581
+ ...(body.assignee ? { assignee: body.assignee } : {}),
582
+ })
583
+ sendJson(res, r.status === 201 ? 200 : r.status, r.status === 201 ? { ok: true } : { ok: false, error: errText(r) })
584
+ return
585
+ }
586
+ // POST /repos/:o/:r/issues/:idx/comments
587
+ if (req.method === 'POST' && parts[0] === 'repos' && parts[3] === 'issues' && parts[5] === 'comments') {
588
+ const [, owner, repo, , idx] = parts
589
+ const body = JSON.parse((await readBody(req)) || '{}')
590
+ const r = await kernelApi(cfg, actor, 'POST', `/repos/${owner}/${repo}/issues/${idx}/comments`, { body: String(body.body || '') })
591
+ sendJson(res, r.status === 201 ? 200 : r.status, r.status === 201 ? { ok: true } : { ok: false, error: errText(r) })
592
+ return
593
+ }
594
+ // /dsh/*:宿主进程内直接执行内核 dshapi(省铸令牌+HTTP 跳转)。
595
+ // 异常/未匹配路由回退到原来的 HTTP 转发,保证任何内核端点仍可到达。
596
+ if (parts && parts[0] === 'dsh') {
597
+ try {
598
+ const token = await kernelTokenFor(cfg, actor)
599
+ const { dispatch } = require('./dsh-host')
600
+ const handled = await dispatch(cfg, actor, req.method, rest, req, res, token)
601
+ if (handled) return
602
+ } catch (e) {
603
+ sendJson(res, 502, { ok: false, error: 'in-process dispatch failed: ' + String((e && e.message) || e) })
604
+ return
605
+ }
606
+ // 未匹配(理论上不会发生——dshapi 的路由全注册了)则回退透传
607
+ const kernelPath = '/api' + (rest.startsWith('/') ? rest : '/' + rest) + (url.search || '')
608
+ try {
609
+ const token = await kernelTokenFor(cfg, actor)
610
+ const headers = { Authorization: 'token ' + token }
611
+ let bodyStr
612
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
613
+ bodyStr = await readBody(req)
614
+ headers['Content-Type'] = 'application/json'
615
+ }
616
+ const upstream = await fetch(`http://127.0.0.1:${cfg.port}${kernelPath}`, {
617
+ method: req.method, headers, body: bodyStr,
618
+ })
619
+ const text = await upstream.text()
620
+ let json = null
621
+ try { json = JSON.parse(text) } catch { json = { ok: false, error: text.slice(0, 200) } }
622
+ sendJson(res, upstream.status, json)
623
+ } catch (e) {
624
+ sendJson(res, 502, { ok: false, error: String((e && e.message) || e) })
625
+ }
626
+ return
627
+ }
628
+
629
+ // PATCH /repos/:o/:r/issues/:idx — 开/关工单
630
+ if (req.method === 'PATCH' && parts[0] === 'repos' && parts[3] === 'issues' && parts.length === 5) {
631
+ const [, owner, repo, , idx] = parts
632
+ const body = JSON.parse((await readBody(req)) || '{}')
633
+ const r = await kernelApi(cfg, actor, 'PATCH', `/repos/${owner}/${repo}/issues/${idx}`, { state: body.state === 'closed' ? 'closed' : 'open' })
634
+ sendJson(res, r.status === 201 || r.status === 200 ? 200 : r.status, { ok: [200, 201].includes(r.status) })
635
+ return
636
+ }
637
+
638
+ if (req.method === 'PUT' && rest === '/settings') {
639
+ let patchBody = null
640
+ try {
641
+ patchBody = JSON.parse((await readBody(req)) || '{}')
642
+ } catch {
643
+ sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
644
+ return
645
+ }
646
+ const patch = engine.sanitizePatch(patchBody, effective())
647
+ memoryPatch = { ...memoryPatch, ...patch }
648
+ if (typeof patch.adminPassword === 'string' && patch.adminPassword) writePasswordFile(patch.adminPassword)
649
+ if (settingsScope && typeof settingsScope.update === 'function') {
650
+ try {
651
+ await settingsScope.update(patch)
652
+ } catch (e) {
653
+ logger.warn(`dsh-git-server: settings update 失败(仅本次进程生效): ${(e && e.message) || e}`)
654
+ }
655
+ }
656
+ await reconcile()
657
+ sendJson(res, 200, status())
658
+ return
659
+ }
660
+ sendJson(res, 404, { ok: false, error: 'not found' })
661
+ } catch (e) {
662
+ try { sendJson(res, 500, { ok: false, error: String((e && e.message) || e) }) } catch {}
663
+ }
664
+ },
665
+ })
666
+ }, 'dsh-git-server: api route')
667
+ } else {
668
+ logger.warn('dsh-git-server: webServer 不可用,设置 API 未注册')
669
+ }
670
+
671
+ // ── 协调器心跳:兜住手改 settings.yaml / 意外退出的自动拉起 ───────────
672
+ ctx.effect(() => {
673
+ restartTimer = setInterval(() => {
674
+ reconcile().catch((e) => logger.error(`dsh-git-server: reconcile: ${(e && e.message) || e}`))
675
+ }, 3000)
676
+ return () => {
677
+ clearInterval(restartTimer)
678
+ if (state.handle) state.handle.stop().catch(() => {})
679
+ }
680
+ }, 'dsh-git-server: reconciler + child process')
681
+ },
682
+ }