@menghuan1918/dsh-apollo 0.0.0-stage → 0.3.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 (47) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +232 -2
  3. package/cordis.patch.yml +78 -0
  4. package/docs/DEVELOPMENT.md +629 -0
  5. package/package.json +75 -4
  6. package/presets/eng/NOTICE +2 -0
  7. package/presets/eng/plugins/exit-guard/index.js +111 -0
  8. package/presets/eng/plugins/exit-guard/package.json +7 -0
  9. package/presets/eng/plugins/mode-instructions/index.js +48 -0
  10. package/presets/eng/plugins/mode-instructions/package.json +7 -0
  11. package/presets/eng/plugins/wait-subagent/index.js +228 -0
  12. package/presets/eng/plugins/wait-subagent/package.json +7 -0
  13. package/presets/eng-noask/NOTICE +2 -0
  14. package/presets/eng-noask/plugins/exit-guard/index.js +111 -0
  15. package/presets/eng-noask/plugins/exit-guard/package.json +7 -0
  16. package/presets/eng-noask/plugins/mode-instructions/index.js +48 -0
  17. package/presets/eng-noask/plugins/mode-instructions/package.json +7 -0
  18. package/presets/eng-noask/plugins/wait-subagent/index.js +228 -0
  19. package/presets/eng-noask/plugins/wait-subagent/package.json +7 -0
  20. package/presets/eng-noask.patch.yml +341 -0
  21. package/presets/eng.patch.yml +341 -0
  22. package/skills/brainstorming/SKILL.md +90 -0
  23. package/skills/brainstorming/spec-document-reviewer-prompt.md +50 -0
  24. package/skills/code-review/SKILL.md +90 -0
  25. package/skills/dev/SKILL.md +43 -0
  26. package/skills/diagnosing-bugs/SKILL.md +132 -0
  27. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +55 -0
  28. package/skills/exec/SKILL.md +91 -0
  29. package/skills/handoff/SKILL.md +23 -0
  30. package/skills/main-agent-driven-development/SKILL.md +64 -0
  31. package/skills/tdd/SKILL.md +34 -0
  32. package/skills/tdd/mocking.md +59 -0
  33. package/skills/tdd/tests.md +77 -0
  34. package/src/cli/claude.js +166 -0
  35. package/src/cli/codex.js +164 -0
  36. package/src/cli/jsonl.js +216 -0
  37. package/src/cli-adapter.js +198 -0
  38. package/src/cli-sessions.js +69 -0
  39. package/src/client.js +815 -0
  40. package/src/config.js +301 -0
  41. package/src/dsh-home.js +38 -0
  42. package/src/index.js +181 -0
  43. package/src/panel.js +452 -0
  44. package/src/settings.js +191 -0
  45. package/src/subagents.js +199 -0
  46. package/third-party/LICENSE-MIT-dsh-standard-preset +21 -0
  47. package/third-party/NOTICE-mattpocock-skills +21 -0
package/src/panel.js ADDED
@@ -0,0 +1,452 @@
1
+ /**
2
+ * dsh-apollo (npm: @menghuan1918/dsh-apollo) —— 宿主半边只读面板(src/panel.js)。
3
+ *
4
+ * 注册仅限 loopback(或行配置 `trustedHosts` 列出的反向代理权威)+ 同源标志的
5
+ * fenced HTTP 路由 `/apollo/api/capabilities`,供浏览器半边(`exports["./client"]`
6
+ * 的 `plugins.row.config` 页面)读取**能力探针**:两个 CLI 后端配置的可执行文件
7
+ * 在 PATH 上的解析结果与版本、以及 Codex 的 profile 名称。
8
+ *
9
+ * 为什么是「能力」而不是「配置」:设置本身已经是正式 Config(volatile),读写
10
+ * 走 DSH 自己的 configForms/行配置页面(`src/settings.js` 只做一次旧文件导入),
11
+ * 不再需要本包自有的配置路由。这里只暴露**从本机探测出来的、设置表单无法自己
12
+ * 知道**的事实——用户手输的可执行文件名到底解析到了哪里、版本是什么、Codex 有
13
+ * 哪些 profile 可选。
14
+ *
15
+ * 暴露面(写死在契约里,测试守护):
16
+ * `{ executables: { codex: { executable, path, version }, claude: {...} },
17
+ * codexProfiles: string[] }`
18
+ * **绝不返回配置正文,也绝不返回任何凭据**:只回可执行文件名/路径/版本行与
19
+ * profile 名称(profile 文件内容只在本进程内用于抽名称)。只读:GET 之外的
20
+ * 方法一律 405,且从不读取请求体。
21
+ *
22
+ * 安全模型仍是 DNS-rebinding / 跨站防御而非认证(默认只信 loopback;经反向
23
+ * 代理域名访问 GUI 时把该权威加入行配置 `trustedHosts`,后果上限是泄露本机
24
+ * 上某个可执行文件的路径与版本号)。边界判定与 dsh-better-sidebar 的
25
+ * src/trust-fence.ts 同源(其源头为 BSD-3-Clause 的
26
+ * @deepseek-ai/dsh-client-connection api-request-trust + loopback-hostname)。
27
+ *
28
+ * 职责边界:路由的**注册**由 `src/settings.js` 的 `mountSettings` 负责(一处
29
+ * 注册,避免 webServer 的重复 (kind,path) 抛错);本文件提供处理器、探针与
30
+ * `installOwnsHostTap`(`mountSettings` 会调用它,`mountPanel` 是兼容别名,
31
+ * 同一上下文重复调用只装一次)。
32
+ *
33
+ * 零第三方依赖:只用 node 内置模块。
34
+ */
35
+
36
+ import { execFile } from 'node:child_process'
37
+ import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs'
38
+ import { homedir } from 'node:os'
39
+ import { delimiter, isAbsolute, join } from 'node:path'
40
+ import { currentConfig, normalizeConfig } from './config.js'
41
+
42
+ /** 宿主半边模块标识(用于 effect 标签与日志)。 */
43
+ export const name = 'apollo-panel'
44
+
45
+ /** 能力探针的完整路径。 */
46
+ export const CAPABILITIES_PATH = '/apollo/api/capabilities'
47
+ /** 注册在 webServer 上的前缀(精确路径由处理器自己校验)。 */
48
+ export const CAPABILITIES_PREFIX = '/apollo/api'
49
+
50
+ /** Codex 新版「一个 profile 一个文件」的后缀:`<CODEX_HOME>/<name>.config.toml`。 */
51
+ export const CODEX_PROFILE_SUFFIX = '.config.toml'
52
+
53
+ /** 版本探测的兜底超时(毫秒)。 */
54
+ export const VERSION_TIMEOUT_MS = 2000
55
+ /** 探针结果缓存时长(毫秒):避免每次打开设置页都 fork 两个 CLI。 */
56
+ export const CAPABILITY_TTL_MS = 30_000
57
+ /** 版本行最大长度(防止 CLI 打印整段 banner)。 */
58
+ const VERSION_MAX_CHARS = 120
59
+ /** 合法的 profile 名(同时挡住路径分隔符,名称只会被当显示文本用)。 */
60
+ const PROFILE_NAME = /^[A-Za-z0-9._-]+$/
61
+
62
+ // ── 部署标志 tap ────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * 部署标志 tap:在 `<head>` 起始处注入 boot 全局 `__DSH_TRANSPORT__.ownsHost`,
66
+ * 使官方 client 的 `connection.isLoopback` 判定为真。
67
+ *
68
+ * 背景:client 对「浏览器非 loopback 页面」把 settings 镜像/作用域设为 memory
69
+ * (不可用),官方没有受信反代配置口。经反向代理多设备访问时,声明「页面拥有
70
+ * 宿主」让设置读写回到 host 持久化——安全边界随反代信任,仅应在受信代理下
71
+ * 开启(行配置 `declareOwnsHost: true`,默认关闭)。
72
+ * 脚本位于所有 head 注入行之前,先于 boot 执行。
73
+ * @param html - 渲染后的 index HTML。
74
+ * @returns 插入了传输全局的 HTML。
75
+ */
76
+ export function declareOwnsHostTap(html) {
77
+ const script = '<script>globalThis.__DSH_TRANSPORT__={ownsHost:true}</script>'
78
+ return /<head(?:\s[^>]*)?>/i.test(html)
79
+ ? html.replace(/<head(?:\s[^>]*)?>/i, (open) => `${open}${script}`)
80
+ : `${script}${html}`
81
+ }
82
+
83
+ /** 已经装过 tap 的插件上下文(防止 mountSettings 与 mountPanel 同时调用时装两次)。 */
84
+ const tappedContexts = new WeakSet()
85
+
86
+ /**
87
+ * 装配 `declareOwnsHost` tap:行配置 `declareOwnsHost: true` 时给 index 注入
88
+ * boot 全局(一次性;同一插件上下文重复调用只装一次)。
89
+ *
90
+ * 由 `src/settings.js` 的 `mountSettings` 调用——宿主行只调 mountSettings 与
91
+ * mountSubagents 时 tap 也必须装上,否则反代部署下官方 client 会把设置镜像降级
92
+ * 成 memory。
93
+ * @param ctx - 插件上下文。
94
+ * @param config - 行配置(可能含 volatile 引用)。
95
+ */
96
+ export function installOwnsHostTap(ctx, config) {
97
+ if (normalizeConfig(config).declareOwnsHost !== true) return
98
+ if (ctx !== null && typeof ctx === 'object') {
99
+ if (tappedContexts.has(ctx)) return
100
+ tappedContexts.add(ctx)
101
+ }
102
+ ctx.inject(['webServer'], (webCtx) => {
103
+ webCtx.effect(() => webCtx.webServer.tapIndex(declareOwnsHostTap), 'apollo-panel: declareOwnsHost tap')
104
+ })
105
+ }
106
+
107
+ /**
108
+ * 宿主半边装配入口(兼容保留):等价于 {@link installOwnsHostTap}。
109
+ *
110
+ * 不注册 HTTP 路由——`/apollo/api/capabilities` 由 `src/settings.js` 的
111
+ * `mountSettings` 注册(一处注册,headless 组合里没有 webServer 就自然跳过)。
112
+ * 宿主行只调 `mountSettings` 即可;同时调用本函数也不会重复注入 tap。
113
+ * @param ctx - 插件上下文。
114
+ * @param config - 行配置(可能含 volatile 引用)。
115
+ */
116
+ export function mountPanel(ctx, config) {
117
+ installOwnsHostTap(ctx, config)
118
+ }
119
+
120
+ // ── 探针:PATH 解析 / 版本 / Codex profile 名 ───────────────────────────────
121
+
122
+ /** 展开 `~` 前缀(不解析其他 shell 语法)。 */
123
+ function expandHome(value, home) {
124
+ if (value === '~') return home
125
+ if (value.startsWith('~/') || value.startsWith('~\\')) return join(home, value.slice(2))
126
+ return value
127
+ }
128
+
129
+ /** 是否是可执行普通文件(非 win32 要求 X_OK 位)。 */
130
+ function isExecutableFile(path) {
131
+ try {
132
+ if (!statSync(path).isFile()) return false
133
+ if (process.platform !== 'win32') accessSync(path, constants.X_OK)
134
+ return true
135
+ } catch {
136
+ return false
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 解析可执行文件:绝对/相对路径按路径校验,裸名字按 PATH 查找。
142
+ *
143
+ * 默认值只写命令名(`codex`/`claude`),**不写死本机路径**;用户可以在设置里
144
+ * 手输绝对路径或 `~/...`。
145
+ * @param executable - 配置里的可执行文件名或路径。
146
+ * @param options - 注入点:`env`(PATH/CODEX_HOME)、`home`(`~` 展开)、`cwd`(相对路径基准)。
147
+ * @returns 可执行文件的绝对路径,或 null。
148
+ */
149
+ export function resolveExecutable(executable, options = {}) {
150
+ const env = options.env ?? process.env
151
+ const home = options.home ?? homedir()
152
+ if (typeof executable !== 'string') return null
153
+ const expanded = expandHome(executable.trim(), home)
154
+ if (expanded === '') return null
155
+ if (expanded.includes('/') || expanded.includes('\\')) {
156
+ const candidate = isAbsolute(expanded) ? expanded : join(options.cwd ?? process.cwd(), expanded)
157
+ return isExecutableFile(candidate) ? candidate : null
158
+ }
159
+ const extensions = process.platform === 'win32'
160
+ ? String(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((entry) => entry !== '')
161
+ : ['']
162
+ for (const dir of String(env.PATH ?? '').split(delimiter)) {
163
+ if (dir === '') continue
164
+ for (const extension of extensions) {
165
+ const candidate = join(dir, expanded + extension)
166
+ if (isExecutableFile(candidate)) return candidate
167
+ }
168
+ }
169
+ return null
170
+ }
171
+
172
+ /** 取第一行非空文本做版本号:剥掉控制字符并截断。 */
173
+ function parseVersion(text) {
174
+ for (const raw of String(text ?? '').split('\n')) {
175
+ const line = raw.replace(/[\u0000-\u001f\u007f]/g, '').trim()
176
+ if (line === '') continue
177
+ return line.slice(0, VERSION_MAX_CHARS)
178
+ }
179
+ return null
180
+ }
181
+
182
+ /**
183
+ * 运行 `<path> --version` 取版本行(无 shell 展开,超时即杀)。
184
+ *
185
+ * 只有进程正常退出且确有输出时才返回版本;启动失败(EACCES/ENOEXEC/超时)
186
+ * 返回 null——探针失败不该让整个能力接口失败。
187
+ * @param path - 已解析的可执行文件路径。
188
+ * @param options - `timeoutMs` 覆盖。
189
+ * @returns 版本行,或 null。
190
+ */
191
+ export function probeVersion(path, options = {}) {
192
+ const timeoutMs = options.timeoutMs ?? VERSION_TIMEOUT_MS
193
+ return new Promise((resolve) => {
194
+ execFile(path, ['--version'], {
195
+ timeout: timeoutMs,
196
+ killSignal: 'SIGKILL',
197
+ maxBuffer: 64 * 1024,
198
+ windowsHide: true,
199
+ }, (error, stdout, stderr) => {
200
+ if (error !== null) {
201
+ resolve(null)
202
+ return
203
+ }
204
+ resolve(parseVersion(String(stdout ?? '')) ?? parseVersion(String(stderr ?? '')))
205
+ })
206
+ })
207
+ }
208
+
209
+ /** 读取一个文本文件的所有行;缺失或不可读返回空数组。 */
210
+ function readLines(path) {
211
+ try {
212
+ return readFileSync(path, 'utf8').split('\n')
213
+ } catch {
214
+ return []
215
+ }
216
+ }
217
+
218
+ /** 是否是合法的 Codex profile 名(拒绝 `.`/`..`,名称只会当显示文本用)。 */
219
+ function isProfileName(value) {
220
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..' && PROFILE_NAME.test(value)
221
+ }
222
+
223
+ /**
224
+ * 列出 Codex profile **名称**(不读内容、不回内容)。
225
+ *
226
+ * 两种来源都兼容:
227
+ * - 新版 `<CODEX_HOME>/<name>.config.toml`(一个 profile 一个文件);
228
+ * - 旧版 `<CODEX_HOME>/config.toml` 里的 `[profiles.<name>]` 段。
229
+ * `CODEX_HOME` 缺省 `<home>/.codex`。
230
+ * @param options - 注入点:`env`、`home`。
231
+ * @returns 排序去重后的 profile 名。
232
+ */
233
+ export function listCodexProfiles(options = {}) {
234
+ const env = options.env ?? process.env
235
+ const home = options.home ?? homedir()
236
+ const configured = typeof env.CODEX_HOME === 'string' ? env.CODEX_HOME.trim() : ''
237
+ const dir = configured === '' ? join(home, '.codex') : expandHome(configured, home)
238
+ const names = new Set()
239
+ let entries = []
240
+ try {
241
+ entries = readdirSync(dir, { withFileTypes: true })
242
+ } catch {
243
+ entries = []
244
+ }
245
+ for (const entry of entries) {
246
+ if (!entry.isFile() || !entry.name.endsWith(CODEX_PROFILE_SUFFIX)) continue
247
+ const candidate = entry.name.slice(0, -CODEX_PROFILE_SUFFIX.length)
248
+ if (isProfileName(candidate)) names.add(candidate)
249
+ }
250
+ for (const line of readLines(join(dir, 'config.toml'))) {
251
+ const match = /^\s*\[\s*profiles\s*\.\s*(?:"([^"]+)"|'([^']+)'|([^\]\s]+))\s*\]/.exec(line)
252
+ if (match === null) continue
253
+ const candidate = match[1] ?? match[2] ?? match[3] ?? ''
254
+ if (isProfileName(candidate)) names.add(candidate)
255
+ }
256
+ return [...names].sort()
257
+ }
258
+
259
+ /** 同步调用并吞掉异常。 */
260
+ function attempt(run) {
261
+ try {
262
+ return run()
263
+ } catch {
264
+ return undefined
265
+ }
266
+ }
267
+
268
+ /** 异步调用并吞掉异常(拒绝)。 */
269
+ async function attemptAsync(run) {
270
+ try {
271
+ return await run()
272
+ } catch {
273
+ return undefined
274
+ }
275
+ }
276
+
277
+ /**
278
+ * 采集能力探针数据(无缓存;`createCapabilitiesReader` 负责缓存)。
279
+ *
280
+ * 探针的每一步都独立失败:解析不到就是 `path: null`,版本取不到就是
281
+ * `version: null`,profile 目录不存在就是空列表——接口本身不因本机缺一个 CLI
282
+ * 而失败。
283
+ * @param config - 当前配置(含 volatile 引用也可)。
284
+ * @param deps - 注入点:`env`、`home`、`resolveExecutable`、`probeVersion`、
285
+ * `listCodexProfiles`、`versionTimeoutMs`。
286
+ * @returns `{ executables, codexProfiles }`(普通 JSON)。
287
+ */
288
+ export async function collectCapabilities(config, deps = {}) {
289
+ const env = deps.env ?? process.env
290
+ const home = deps.home ?? homedir()
291
+ const resolve = deps.resolveExecutable ?? resolveExecutable
292
+ const probe = deps.probeVersion ?? probeVersion
293
+ const listProfiles = deps.listCodexProfiles ?? listCodexProfiles
294
+ const options = { env, home }
295
+ const backends = normalizeConfig(config).subagents
296
+ const executables = {}
297
+ for (const cli of ['codex', 'claude']) {
298
+ const executable = backends[cli].executable
299
+ const path = attempt(() => resolve(executable, options)) ?? null
300
+ const version = path === null
301
+ ? null
302
+ : await attemptAsync(() => probe(path, { timeoutMs: deps.versionTimeoutMs ?? VERSION_TIMEOUT_MS })) ?? null
303
+ executables[cli] = {
304
+ executable,
305
+ path: typeof path === 'string' && path !== '' ? path : null,
306
+ version: typeof version === 'string' && version !== '' ? version : null,
307
+ }
308
+ }
309
+ const profiles = attempt(() => listProfiles(options))
310
+ return {
311
+ executables,
312
+ codexProfiles: Array.isArray(profiles) ? profiles.filter(isProfileName) : [],
313
+ }
314
+ }
315
+
316
+ /** 探针缓存的键:两个可执行文件名 + PATH/CODEX_HOME/home 的变化都要失效。 */
317
+ function capabilityKey(config, deps) {
318
+ const env = deps.env ?? process.env
319
+ const backends = normalizeConfig(config).subagents
320
+ return JSON.stringify([
321
+ backends.codex.executable,
322
+ backends.claude.executable,
323
+ env.PATH ?? '',
324
+ env.CODEX_HOME ?? '',
325
+ deps.home ?? '',
326
+ ])
327
+ }
328
+
329
+ /**
330
+ * 造一个带 TTL 缓存与并发去重的探针读取器。
331
+ *
332
+ * 设置页每次挂载都会拉一次能力;同键的并发请求共享一次探测,TTL 内的重复
333
+ * 请求直接回缓存,避免反复 fork CLI。
334
+ * @param deps - 同 {@link collectCapabilities},另加 `ttlMs`、`now`(测试注入)。
335
+ * @returns `read(config)` → 探针数据。
336
+ */
337
+ export function createCapabilitiesReader(deps = {}) {
338
+ const ttlMs = deps.ttlMs ?? CAPABILITY_TTL_MS
339
+ const now = deps.now ?? Date.now
340
+ let entry
341
+ let pending
342
+ return async function read(config) {
343
+ const key = capabilityKey(config, deps)
344
+ if (entry !== undefined && entry.key === key && now() - entry.at < ttlMs) return entry.value
345
+ if (pending !== undefined && pending.key === key) return pending.promise
346
+ const promise = collectCapabilities(config, deps).then((value) => {
347
+ entry = { key, at: now(), value }
348
+ return value
349
+ })
350
+ pending = { key, promise }
351
+ try {
352
+ return await promise
353
+ } finally {
354
+ if (pending !== undefined && pending.promise === promise) pending = undefined
355
+ }
356
+ }
357
+ }
358
+
359
+ // ── 安检(loopback Host + 同源标志)─────────────────────────────────────────
360
+ // 改编自 dsh-better-sidebar src/trust-fence.ts(其源头为 BSD-3-Clause 的
361
+ // @deepseek-ai/dsh-client-connection api-request-trust + loopback-hostname)。
362
+
363
+ function headerOf(req, name) {
364
+ const value = req.headers?.[name]
365
+ return typeof value === 'string' ? value : undefined
366
+ }
367
+
368
+ function parseAuthority(authority) {
369
+ try {
370
+ return new URL(`http://${authority}`)
371
+ } catch {
372
+ return undefined
373
+ }
374
+ }
375
+
376
+ function isLoopbackHostname(hostname) {
377
+ if (hostname === 'localhost' || hostname === '[::1]') return true
378
+ const parts = hostname.split('.')
379
+ return parts.length === 4
380
+ && parts[0] === '127'
381
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
382
+ }
383
+
384
+ /** Host 是 loopback(或配置的受信权威)且浏览器标志同源。 */
385
+ function isTrustedApiRequest(req, trustedHosts) {
386
+ const host = headerOf(req, 'host')
387
+ if (host === undefined) return false
388
+ const hostUrl = parseAuthority(host)
389
+ if (hostUrl === undefined) return false
390
+ const trusted = trustedHosts.some((entry) => {
391
+ const entryUrl = parseAuthority(entry)
392
+ return entryUrl !== undefined && entryUrl.host === hostUrl.host
393
+ })
394
+ if (!isLoopbackHostname(hostUrl.hostname) && !trusted) return false
395
+ if (headerOf(req, 'sec-fetch-site') === 'cross-site') return false
396
+ const origin = headerOf(req, 'origin')
397
+ if (origin === undefined) return true
398
+ try {
399
+ return new URL(origin).host === hostUrl.host
400
+ } catch {
401
+ return false
402
+ }
403
+ }
404
+
405
+ // ── 路由 ─────────────────────────────────────────────────────────────────────
406
+
407
+ function writeJson(res, status, body) {
408
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
409
+ res.end(JSON.stringify(body))
410
+ }
411
+
412
+ /**
413
+ * 注册只读 `/apollo/api/capabilities` 路由(由 `src/settings.js` 调用)。
414
+ *
415
+ * 信任边界按请求重读活配置的 `trustedHosts`(反代部署下改完即时生效),读不到
416
+ * 活配置时退回 `configFor` 的规整值。
417
+ * @param ctx - 已注入 webServer 的插件上下文。
418
+ * @param configFor - 返回当前配置的函数(或配置值本身)。
419
+ * @param deps - 探针注入点;`read` 可直接替换整个读取器(测试用)。
420
+ * @returns disposer 由 `ctx.effect` 持有。
421
+ */
422
+ export function registerCapabilitiesRoute(ctx, configFor, deps = {}) {
423
+ const read = deps.read ?? createCapabilitiesReader(deps)
424
+ const configOf = () => (typeof configFor === 'function' ? attempt(configFor) : configFor)
425
+ ctx.effect(() => ctx.webServer.register({
426
+ kind: 'prefix',
427
+ path: CAPABILITIES_PREFIX,
428
+ handler: async (req, res) => {
429
+ try {
430
+ const config = currentConfig(ctx, configOf())
431
+ if (!isTrustedApiRequest(req, config.trustedHosts)) {
432
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
433
+ return
434
+ }
435
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
436
+ if (pathname !== CAPABILITIES_PATH) {
437
+ writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown apollo API path' } })
438
+ return
439
+ }
440
+ if (req.method !== 'GET') {
441
+ writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
442
+ return
443
+ }
444
+ writeJson(res, 200, { ok: true, value: await read(config) })
445
+ } catch (error) {
446
+ // 探针已经各自吞掉失败;这里只剩意外错误——日志留细节,响应只给通用文案。
447
+ ctx.logger?.warn?.(`apollo-panel: capabilities probe failed: ${error instanceof Error ? error.message : String(error)}`)
448
+ writeJson(res, 500, { ok: false, error: { code: 'probe-failed', message: 'capability probe failed' } })
449
+ }
450
+ },
451
+ }), 'apollo-panel: /apollo/api capabilities route')
452
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * dsh-apollo —— 设置装配(src/settings.js)。
3
+ *
4
+ * 本文件负责两件「必须等到服务可用才能做」的事,以及一次性的旧配置迁移:
5
+ *
6
+ * 1. **只读能力路由**:`ctx.inject(['webServer'])` → 注册
7
+ * `/apollo/api/capabilities`(处理器与探针在 `src/panel.js`,一处注册避免
8
+ * webServer 的重复 (kind,path) 抛错)。headless 组合没有 webServer 就自然
9
+ * 跳过,面板装配照常返回。
10
+ *
11
+ * 2. **行配置页面策略**:`ctx.inject(['settings'])` → 在本插件实例上登记
12
+ * `configure({ auto: false }, ctx.fiber)`:本行自带配置页(浏览器半边的
13
+ * `plugins.row.config`)就是它唯一的编辑入口,不需要 DSH 再按 schema 自动
14
+ * 生成一页。策略随本行的 effect 生命周期增删。
15
+ *
16
+ * 3. **旧 eng.json / atlas.json 一次性只读导入**:DSH 0.2 起设置就是正式
17
+ * Config(volatile,见 `src/config.js`),落盘由 configEditor 写进活动
18
+ * profile 的 cordis.patch.yml;`<dshHome>/eng.json` 与旧名 `atlas.json`
19
+ * 不再被写入(**只读**),但第一次装配时会把里面的 `exitGuard` 搬进正式
20
+ * 配置,之后旧的读取路径(预设本地守卫)即使被删也不丢开关。
21
+ *
22
+ * 迁移规则:
23
+ * - 只在 `settings` 服务可用、且 Loader 已把所有 entry 装配完(本行 fiber
24
+ * 进入 active,`describe()` 才认得它)之后跑一次;
25
+ * - **当前 profile 显式给出的值优先**:`exitGuard` 已存在于用户层(哪怕是
26
+ * 显式 `false`)时不覆盖——判断「存在」而不是「等于」,与 dsh-settings
27
+ * 的 override 语义一致;
28
+ * - 值已经与生效值相同(等于 schema 默认)时不写盘,避免制造无意义的
29
+ * profile 覆盖层;
30
+ * - 旧文件永不改写、永不删除;导入失败只告警,不影响插件装载。
31
+ *
32
+ * 持久化与生效:写入经 `settings.update`(= configEditor 校验完整候选后落盘 +
33
+ * Loader 调和)。因为 `exitGuard` 是 volatile 字段,写入不会重挂本行,运行中的
34
+ * 消费方(预设守卫、CLI 路由)下一次读取即拿到新值。
35
+ *
36
+ * 零第三方依赖:只用 node 内置模块 + 本包 `src/config.js`。
37
+ */
38
+
39
+ import { readFileSync } from 'node:fs'
40
+ import { join } from 'node:path'
41
+ import { ENTRY_ID } from './config.js'
42
+ import { resolveDshHome } from './dsh-home.js'
43
+ import { installOwnsHostTap, registerCapabilitiesRoute } from './panel.js'
44
+
45
+ /** 旧配置文件名,按优先级排列(新名优先,旧名只读回退)。 */
46
+ export const LEGACY_FILES = Object.freeze(['eng.json', 'atlas.json'])
47
+
48
+ /** 读取一个 JSON 文件并确保顶层是普通对象;缺失/损坏/形状非法返回 undefined。 */
49
+ function readJsonObject(path) {
50
+ try {
51
+ const raw = JSON.parse(readFileSync(path, 'utf8'))
52
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
53
+ return raw
54
+ } catch {
55
+ return undefined
56
+ }
57
+ }
58
+
59
+ /** 是否是普通数据对象。 */
60
+ function isRecord(value) {
61
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
62
+ }
63
+
64
+ /** 用户层里是否显式写了这个字段(存在即为覆盖,与值无关)。 */
65
+ function hasOwn(value, key) {
66
+ return isRecord(value) && Object.hasOwn(value, key)
67
+ }
68
+
69
+ /**
70
+ * 读取旧运行时配置文档(只读)。
71
+ *
72
+ * `eng.json` 优先,缺失或损坏时回退旧名 `atlas.json`;两者都没有返回 undefined。
73
+ * @param env - 读取 DSH_HOME 的进程环境(测试注入点)。
74
+ * @returns `{ file, path, value }` 或 undefined。
75
+ */
76
+ export function readLegacyDocument(env = process.env) {
77
+ const home = resolveDshHome(env)
78
+ for (const file of LEGACY_FILES) {
79
+ const path = join(home, file)
80
+ const value = readJsonObject(path)
81
+ if (value !== undefined) return { file, path, value }
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ /**
87
+ * 由旧文档算出要写进正式配置的补丁(纯函数,便于测试)。
88
+ *
89
+ * 目前唯一有对应关系的旧字段是 `exitGuard`(布尔)。规则:
90
+ * - 旧值不是布尔 → 不导入;
91
+ * - 用户层已显式给出 `exitGuard`(含显式 false)→ 不覆盖;
92
+ * - 生效值已与旧值相同 → 不写盘(避免无意义的 profile 覆盖层)。
93
+ * @param raw - 旧文档内容。
94
+ * @param user - 当前用户层(profile 覆盖层投影,仅含可编辑字段)。
95
+ * @param value - 当前生效值(仅含可编辑字段)。
96
+ * @returns 补丁对象;无需导入时为空对象。
97
+ */
98
+ export function legacyPatch(raw, user, value) {
99
+ const patch = {}
100
+ if (typeof raw?.exitGuard !== 'boolean') return patch
101
+ if (hasOwn(user, 'exitGuard')) return patch
102
+ if (isRecord(value) && value.exitGuard === raw.exitGuard) return patch
103
+ patch.exitGuard = raw.exitGuard
104
+ return patch
105
+ }
106
+
107
+ /** 取 Loader 服务(用于等待所有 entry 装配完);拿不到返回 undefined。 */
108
+ function loaderOf(ctx) {
109
+ try {
110
+ const direct = ctx.get?.('loader')
111
+ if (typeof direct?.await === 'function') return direct
112
+ } catch {
113
+ // 未提供 loader(测试 fake ctx)→ 退回 root。
114
+ }
115
+ try {
116
+ const root = ctx.root?.loader
117
+ return typeof root?.await === 'function' ? root : undefined
118
+ } catch {
119
+ return undefined
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 把旧文档里的字段一次性导入正式配置(需要 `settings` 服务)。
125
+ *
126
+ * 调用方保证「只调用一次」;本函数自身也幂等(用户层已有该字段就不写)。
127
+ * @param ctx - 已注入 `settings` 的上下文。
128
+ * @param env - 读取 DSH_HOME 的进程环境(测试注入点)。
129
+ * @returns `{ imported, reason, file?, patch? }`(不抛错,失败信息只用于日志)。
130
+ */
131
+ export async function importLegacyConfig(ctx, env = process.env) {
132
+ const legacy = readLegacyDocument(env)
133
+ if (legacy === undefined) return { imported: false, reason: 'absent' }
134
+ const settings = ctx.settings
135
+ if (typeof settings?.describe !== 'function' || typeof settings.update !== 'function') {
136
+ return { imported: false, reason: 'unavailable', file: legacy.file }
137
+ }
138
+ // Loader 装配完成后 describe() 才认得出本行(fiber 必须 active)。
139
+ const loader = loaderOf(ctx)
140
+ if (loader !== undefined) await loader.await()
141
+ const descriptors = await settings.describe()
142
+ const descriptor = Array.isArray(descriptors)
143
+ ? descriptors.find((row) => row?.ns === ENTRY_ID)
144
+ : undefined
145
+ if (descriptor === undefined) return { imported: false, reason: 'not-configurable', file: legacy.file }
146
+ const patch = legacyPatch(legacy.value, descriptor.user, descriptor.value)
147
+ if (Object.keys(patch).length === 0) return { imported: false, reason: 'no-change', file: legacy.file }
148
+ await settings.update(ENTRY_ID, patch, descriptor.revision)
149
+ return { imported: true, file: legacy.file, patch }
150
+ }
151
+
152
+ /**
153
+ * 装配设置侧:延迟注册只读能力路由、登记行配置页面策略、一次性导入旧配置。
154
+ *
155
+ * 由宿主行(`src/index.js` 的 `apply`)调用。所有动作都延迟到服务出现时进行:
156
+ * 没有 webServer / settings 的组合里本函数安静返回,插件其余部分照常工作。
157
+ * @param ctx - 插件上下文。
158
+ * @param config - 行配置(可能含 volatile 引用)。
159
+ * @param options - 注入点(测试用):`env`、`route`(探针依赖,透传
160
+ * {@link registerCapabilitiesRoute})。
161
+ */
162
+ export function mountSettings(ctx, config, options = {}) {
163
+ const env = options.env ?? process.env
164
+
165
+ // 反代部署标志:宿主行只调 mountSettings 时也必须装上(否则设置镜像降级 memory)。
166
+ installOwnsHostTap(ctx, config)
167
+
168
+ ctx.inject(['webServer'], (webCtx) => {
169
+ registerCapabilitiesRoute(webCtx, () => config, options.route)
170
+ })
171
+
172
+ let started = false
173
+ ctx.inject(['settings'], (settingsCtx) => {
174
+ // 行配置页面策略:本行自带配置页,不需要自动生成的一页。
175
+ try {
176
+ const dispose = settingsCtx.settings.configure({ auto: false }, ctx.fiber)
177
+ settingsCtx.effect(() => dispose, 'apollo-settings: row config page policy')
178
+ } catch (error) {
179
+ settingsCtx.logger?.warn?.(`apollo-settings: settings page policy was not registered: ${error instanceof Error ? error.message : String(error)}`)
180
+ }
181
+ if (started) return
182
+ started = true
183
+ void importLegacyConfig(settingsCtx, env).then((result) => {
184
+ if (result.imported) {
185
+ settingsCtx.logger?.info?.(`apollo-settings: imported ${result.file} into the ${ENTRY_ID} config (exitGuard=${String(result.patch.exitGuard)})`)
186
+ }
187
+ }, (error) => {
188
+ settingsCtx.logger?.warn?.(`apollo-settings: legacy config import skipped: ${error instanceof Error ? error.message : String(error)}`)
189
+ })
190
+ })
191
+ }