@fishdivinity/dsh-local-search 0.2.0 → 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.
- package/README-zh.md +36 -22
- package/README.md +35 -21
- package/cordis.patch.yml +2 -1
- package/lib/anytxt.js +49 -35
- package/lib/bootstrap.js +201 -0
- package/lib/client.js +1196 -290
- package/lib/config.js +193 -49
- package/lib/describe.js +157 -0
- package/lib/engines.js +6 -6
- package/lib/errors.js +2 -3
- package/lib/everything.js +70 -15
- package/lib/format.js +38 -33
- package/lib/index.js +75 -38
- package/lib/limits.js +13 -0
- package/lib/probe.js +34 -21
- package/lib/ref.js +5 -5
- package/lib/roots.js +34 -16
- package/lib/route.js +253 -70
- package/lib/schema.js +130 -0
- package/lib/tools.js +46 -36
- package/package.json +3 -2
package/lib/route.js
CHANGED
|
@@ -1,33 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* answered. A cross-origin POST from the page to `127.0.0.1:9920` is refused by
|
|
5
|
-
* both sides, so the page asks the Host instead.
|
|
2
|
+
* 配置页的测试按钮有一件事浏览器自己做不到:调本机的 Anytxt JSON-RPC 与 `es.exe`,并报告
|
|
3
|
+
* 它们有没有应答。页面往 `127.0.0.1:9920` 发的跨源 POST 两边都会拒,所以页面改问 Host。
|
|
6
4
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* shell's `/api` fence). It is read-only and sends no CORS headers, so a foreign
|
|
10
|
-
* page cannot read the answer.
|
|
5
|
+
* 路由注册在裸 `webServer` 上、用 `exact`——与其他已安装插件用的是同一个接缝(prefix/exact
|
|
6
|
+
* 注册从不经过 shell 的 `/api` 围栏)。它是只读的,也不发 CORS 头,所以外站页面读不到答案。
|
|
11
7
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* sends neither header and is a local process the page's CSRF risk does not
|
|
16
|
-
* cover.
|
|
8
|
+
* 外站页面仍然**能**发一个普通 GET,而这里的一次 GET 要花一次 es.exe 启动,所以路由拒掉任何
|
|
9
|
+
* 浏览器标成跨站的请求(`Sec-Fetch-Site`),以及 `Origin` 不是本 Host 的请求。非浏览器的调用方
|
|
10
|
+
* 两个头都不发,它是本地进程,不在页面那个 CSRF 风险的覆盖范围里。
|
|
17
11
|
*
|
|
18
|
-
* `?anytxtUrl=`
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
12
|
+
* `?anytxtUrl=` 让页面测试用户保存前敲下的地址。那份草稿是不可信输入,所以只接受 loopback 的
|
|
13
|
+
* `http(s)` URL:够敲 `127.0.0.1:9921` 试一下,永远不是一个请求转发代理。不带这个参数时路由
|
|
14
|
+
* 探测已保存的配置;一份不是本机的已保存地址会在引擎里以 `ANYTXT_ENDPOINT_REMOTE` 失败。
|
|
15
|
+
*
|
|
16
|
+
* `?esPath=` 让页面在保存前确认「这个路径在不在」。这里**只查文件存在**,绝不启动它:让一条
|
|
17
|
+
* HTTP 路由按请求里的路径去 spawn,等于把任意执行开成了接口。`?dir=`(可重复)同理,只回答
|
|
18
|
+
* 「这个目录在不在」,给检索范围那几个字段用。
|
|
23
19
|
*/
|
|
24
20
|
|
|
21
|
+
import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
22
|
+
import { isAbsolute } from 'node:path'
|
|
25
23
|
import { probeEngines } from './probe.js'
|
|
26
24
|
import { isLoopbackUrl } from './config.js'
|
|
25
|
+
import { describeConfig } from './describe.js'
|
|
27
26
|
|
|
28
|
-
/**
|
|
27
|
+
/** 页面取引擎状态用的路径;页面按自己的 base URI 解析它。 */
|
|
29
28
|
export const STATUS_ROUTE = '/dsh-local-search/api/status'
|
|
30
29
|
|
|
30
|
+
/** 页面取后端与字段描述用的路径。 */
|
|
31
|
+
export const DESCRIBE_ROUTE = '/dsh-local-search/api/describe'
|
|
32
|
+
|
|
33
|
+
/** 携带待探测的未保存地址的查询参数。 */
|
|
34
|
+
export const DRAFT_PARAM = 'anytxtUrl'
|
|
35
|
+
|
|
36
|
+
/** 携带待校验的未保存 `es.exe` 路径的查询参数。 */
|
|
37
|
+
export const DRAFT_PATH_PARAM = 'esPath'
|
|
38
|
+
|
|
39
|
+
/** 携带待校验的未保存目录的查询参数;可重复,`allRoots` 会拆成多条。 */
|
|
40
|
+
export const DRAFT_DIR_PARAM = 'dir'
|
|
41
|
+
|
|
42
|
+
/** 只要校验草稿、不跑引擎探测的查询参数。失焦校验用它,免得一次按键花一次 es.exe 启动。 */
|
|
43
|
+
export const CHECK_ONLY_PARAM = 'checkOnly'
|
|
44
|
+
|
|
31
45
|
function sendJson(response, status, payload) {
|
|
32
46
|
const body = JSON.stringify(payload)
|
|
33
47
|
response.writeHead(status, {
|
|
@@ -39,9 +53,47 @@ function sendJson(response, status, payload) {
|
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
56
|
+
* 一个已存在的路径在磁盘上的写法:真实大小写、真实分隔符。
|
|
57
|
+
*
|
|
58
|
+
* 只是锦上添花——页面把 `d:/alpha` 交给这里,回填 `D:\Alpha`;取不到就原样返回,校验结论不受影响。
|
|
59
|
+
* `realpath` 会解析 junction 与符号链接,所以回填的是目标路径本身,两者指向同一棵树。
|
|
60
|
+
* @param value - 已确认存在的绝对路径
|
|
61
|
+
* @returns 规范写法
|
|
62
|
+
*/
|
|
63
|
+
function canonicalPath(value) {
|
|
64
|
+
try {
|
|
65
|
+
return realpathSync.native(value)
|
|
66
|
+
} catch {
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 请求目标里的查询参数;解析不了的 URL 当成没有参数。
|
|
73
|
+
* @param {string} requestUrl - 请求目标
|
|
74
|
+
* @returns {URLSearchParams}
|
|
75
|
+
*/
|
|
76
|
+
function queryOf(requestUrl) {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(String(requestUrl ?? '/'), 'http://localhost').searchParams
|
|
79
|
+
} catch {
|
|
80
|
+
return new URLSearchParams()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 这次请求只要校验草稿,不要引擎状态吗。
|
|
86
|
+
* @param {string} requestUrl - 请求目标
|
|
87
|
+
* @returns 带 `checkOnly` 时为 true
|
|
88
|
+
*/
|
|
89
|
+
function checkOnly(requestUrl) {
|
|
90
|
+
return queryOf(requestUrl).has(CHECK_ONLY_PARAM)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 请求是来自本页面而不是别的站点吗。
|
|
95
|
+
* @param {object} request - HTTP 请求(只读 `headers`)
|
|
96
|
+
* @returns 同源请求、或不是浏览器发来的请求时为 true
|
|
45
97
|
*/
|
|
46
98
|
export function isSameOrigin(request) {
|
|
47
99
|
const headers = request.headers ?? {}
|
|
@@ -59,68 +111,134 @@ export function isSameOrigin(request) {
|
|
|
59
111
|
}
|
|
60
112
|
|
|
61
113
|
/**
|
|
62
|
-
*
|
|
114
|
+
* 拒绝一个不该处理的请求:非 GET/HEAD,或浏览器标记为跨站。
|
|
63
115
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* @param {
|
|
67
|
-
* @
|
|
116
|
+
* 两条路由共用同一道门——一次探测要花一次 es.exe 启动,describe 也要避免变成任意站点可读的
|
|
117
|
+
* 本机信息口。
|
|
118
|
+
* @param {object} request - HTTP 请求
|
|
119
|
+
* @param {object} response - HTTP 响应
|
|
120
|
+
* @returns {boolean} 已经作答、调用方应直接返回时为 true
|
|
68
121
|
*/
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return
|
|
122
|
+
function refused(request, response) {
|
|
123
|
+
const method = request.method ?? 'GET'
|
|
124
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
125
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
126
|
+
response.end()
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
if (!isSameOrigin(request)) {
|
|
130
|
+
sendJson(response, 403, { ok: false, code: 'CROSS_SITE_REFUSED', message: 'this route answers this page only' })
|
|
131
|
+
return true
|
|
75
132
|
}
|
|
76
|
-
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 读出保存前测试要问的那个草稿地址。
|
|
138
|
+
*
|
|
139
|
+
* 它跑的是 config schema 强制的同一条 loopback 规则,所以草稿永远到不了已保存配置都放不下的
|
|
140
|
+
* 地址。
|
|
141
|
+
* @param {string} requestUrl - 请求目标
|
|
142
|
+
* @returns {{ok: true, value: string|undefined} | {ok: false}} 草稿,或它被拒的原因
|
|
143
|
+
*/
|
|
144
|
+
export function draftEndpoint(requestUrl) {
|
|
145
|
+
const raw = queryOf(requestUrl).get(DRAFT_PARAM)
|
|
77
146
|
if (raw === null || raw.trim() === '') return { ok: true, value: undefined }
|
|
78
147
|
const trimmed = raw.trim()
|
|
79
148
|
return isLoopbackUrl(trimmed) ? { ok: true, value: trimmed } : { ok: false }
|
|
80
149
|
}
|
|
81
150
|
|
|
82
151
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
152
|
+
* 读出保存前要确认的 `es.exe` 草稿路径。
|
|
153
|
+
*
|
|
154
|
+
* 只回答「在不在」,不启动它:路径是调用方给的,按它 spawn 就等于把任意执行开成接口。
|
|
155
|
+
* `resolved` 是同一个文件的规范写法,页面拿它回填输入框。
|
|
156
|
+
* @param {string} requestUrl - 请求目标
|
|
157
|
+
* @returns {{ok: true, value: string|undefined, resolved: string|undefined} | {ok: false, reason: 'missing'|'not-file'}}
|
|
158
|
+
*/
|
|
159
|
+
export function draftEsPath(requestUrl) {
|
|
160
|
+
const raw = queryOf(requestUrl).get(DRAFT_PATH_PARAM)
|
|
161
|
+
if (raw === null || raw.trim() === '') return { ok: true, value: undefined, resolved: undefined }
|
|
162
|
+
const trimmed = raw.trim()
|
|
163
|
+
if (!isAbsolute(trimmed) || !existsSync(trimmed)) return { ok: false, reason: 'missing' }
|
|
164
|
+
let file = false
|
|
165
|
+
try {
|
|
166
|
+
file = statSync(trimmed).isFile()
|
|
167
|
+
} catch {
|
|
168
|
+
file = false
|
|
169
|
+
}
|
|
170
|
+
// 目录不是文件:这一条对读数的人才说得清楚(「这里要填文件」比「不存在」有用)。
|
|
171
|
+
if (!file) return { ok: false, reason: 'not-file' }
|
|
172
|
+
return { ok: true, value: trimmed, resolved: canonicalPath(trimmed) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 读出保存前要确认的目录草稿(可多值,`allRoots` 会拆成多条)。
|
|
177
|
+
*
|
|
178
|
+
* 同样只查存在与「是个目录」:Windows 接受 `/` 与 `\` 两种分隔符,所以 `D:/Alpha` 与
|
|
179
|
+
* `D:\Alpha` 都是合法路径,这里按同一规则判定。
|
|
180
|
+
* @param {string} requestUrl - 请求目标
|
|
181
|
+
* @returns {{ok: true, value: string[], resolved: string[]} | {ok: false, reason: 'missing'|'not-directory'}}
|
|
182
|
+
*/
|
|
183
|
+
export function draftDirs(requestUrl) {
|
|
184
|
+
const dirs = queryOf(requestUrl).getAll(DRAFT_DIR_PARAM)
|
|
185
|
+
.map((value) => value.trim())
|
|
186
|
+
.filter((value) => value !== '')
|
|
187
|
+
for (const dir of dirs) {
|
|
188
|
+
if (!isAbsolute(dir) || !existsSync(dir)) return { ok: false, reason: 'missing' }
|
|
189
|
+
let directory = false
|
|
190
|
+
try {
|
|
191
|
+
directory = statSync(dir).isDirectory()
|
|
192
|
+
} catch {
|
|
193
|
+
directory = false
|
|
194
|
+
}
|
|
195
|
+
// 填了具体文件是最常见的错法(粘了一条文件路径进来),单独给一个原因。
|
|
196
|
+
if (!directory) return { ok: false, reason: 'not-directory' }
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, value: dirs, resolved: dirs.map((dir) => canonicalPath(dir)) }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 回答一次探测请求。
|
|
203
|
+
* @param {object} request - HTTP 请求(读 `method` 与 `url`)
|
|
204
|
+
* @param {object} response - HTTP 响应
|
|
205
|
+
* @param {(options: object) => Promise<{everything: object, anytxt: object}>} probe - 要跑的探测
|
|
87
206
|
*/
|
|
88
207
|
export async function statusHandler(request, response, probe) {
|
|
89
208
|
try {
|
|
90
209
|
await answerStatus(request, response, probe)
|
|
91
210
|
} catch {
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// request pipeline.
|
|
211
|
+
// 探测到一半页面就走了,socket 已经是死的;这里让 promise 拒掉的话,会在 web server 的
|
|
212
|
+
// 请求管线里冒成一个未处理的 rejection。
|
|
95
213
|
try {
|
|
96
214
|
response.destroy()
|
|
97
215
|
} catch {
|
|
98
|
-
//
|
|
216
|
+
// 已经没了。
|
|
99
217
|
}
|
|
100
218
|
}
|
|
101
219
|
}
|
|
102
220
|
|
|
103
221
|
/**
|
|
104
|
-
*
|
|
105
|
-
* @param {object}
|
|
106
|
-
* @param {
|
|
107
|
-
* @param {
|
|
222
|
+
* 草稿被拒:400 加一个稳定的 code,页面据此决定说哪一句。
|
|
223
|
+
* @param {object} response - HTTP 响应
|
|
224
|
+
* @param {string} code - 机器可读的原因
|
|
225
|
+
* @param {string} message - 英文说明(面向日志与调试,不面向页面)
|
|
226
|
+
*/
|
|
227
|
+
function refuseDraft(response, code, message) {
|
|
228
|
+
sendJson(response, 400, { ok: false, code, message })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 写出一次请求的答案。
|
|
233
|
+
* @param {object} request - HTTP 请求
|
|
234
|
+
* @param {object} response - HTTP 响应
|
|
235
|
+
* @param {(options: object) => Promise<{everything: object, anytxt: object}>} probe - 要跑的探测
|
|
108
236
|
*/
|
|
109
237
|
async function answerStatus(request, response, probe) {
|
|
110
238
|
const method = request.method ?? 'GET'
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
// A probe costs an es.exe spawn, so another site's page must not be able to
|
|
117
|
-
// trigger one with an ordinary GET.
|
|
118
|
-
if (!isSameOrigin(request)) {
|
|
119
|
-
sendJson(response, 403, { ok: false, code: 'CROSS_SITE_REFUSED', message: 'this route answers this page only' })
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
// HEAD claims reachability without spending an es.exe spawn: a probe is the
|
|
123
|
-
// whole cost of this route, and the body carries the only useful answer.
|
|
239
|
+
if (refused(request, response)) return
|
|
240
|
+
// HEAD 只声明可达,不花一次 es.exe 启动:探测就是这条路由的全部成本,而唯一有用的答案在
|
|
241
|
+
// body 里。
|
|
124
242
|
if (method === 'HEAD') {
|
|
125
243
|
response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' })
|
|
126
244
|
response.end()
|
|
@@ -128,22 +246,48 @@ async function answerStatus(request, response, probe) {
|
|
|
128
246
|
}
|
|
129
247
|
const draft = draftEndpoint(request.url)
|
|
130
248
|
if (!draft.ok) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
249
|
+
refuseDraft(response, 'ANYTXT_URL_REJECTED', 'a draft endpoint must be a loopback http(s) url')
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
const path = draftEsPath(request.url)
|
|
253
|
+
if (!path.ok) {
|
|
254
|
+
refuseDraft(
|
|
255
|
+
response,
|
|
256
|
+
path.reason === 'not-file' ? 'ES_PATH_NOT_FILE' : 'ES_PATH_REJECTED',
|
|
257
|
+
path.reason === 'not-file'
|
|
258
|
+
? 'a draft es.exe path must be a file, not a directory'
|
|
259
|
+
: 'a draft es.exe path must be an absolute existing file',
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const dirs = draftDirs(request.url)
|
|
264
|
+
if (!dirs.ok) {
|
|
265
|
+
refuseDraft(
|
|
266
|
+
response,
|
|
267
|
+
dirs.reason === 'not-directory' ? 'DIR_NOT_DIRECTORY' : 'DIR_REJECTED',
|
|
268
|
+
dirs.reason === 'not-directory'
|
|
269
|
+
? 'a draft directory must be a directory, not a file'
|
|
270
|
+
: 'a draft directory must be an absolute existing directory',
|
|
271
|
+
)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
// 草稿的规范写法,页面用它回填输入框(`d:/alpha` 落成 `D:\Alpha`);只要校验时直接作答。
|
|
275
|
+
const drafts = { esPath: path.resolved, dirs: dirs.resolved ?? [] }
|
|
276
|
+
if (checkOnly(request.url)) {
|
|
277
|
+
sendJson(response, 200, { ok: true, drafts })
|
|
136
278
|
return
|
|
137
279
|
}
|
|
138
280
|
let payload
|
|
139
281
|
let status = 200
|
|
140
282
|
try {
|
|
141
|
-
|
|
283
|
+
// 这条路由只喂页面,所以可以带上 `detail` 那个原因——面向模型的状态刻意把它留在表面之外。
|
|
284
|
+
const probed = await probe({ anytxtUrl: draft.value, detail: true })
|
|
142
285
|
payload = {
|
|
143
286
|
ok: Boolean(probed.everything?.ok) && Boolean(probed.anytxt?.ok),
|
|
144
287
|
checkedAt: Date.now(),
|
|
145
288
|
everything: probed.everything ?? { ok: false, code: 'UNKNOWN' },
|
|
146
289
|
anytxt: probed.anytxt ?? { ok: false, code: 'UNKNOWN' },
|
|
290
|
+
drafts,
|
|
147
291
|
}
|
|
148
292
|
} catch (error) {
|
|
149
293
|
status = 500
|
|
@@ -153,11 +297,11 @@ async function answerStatus(request, response, probe) {
|
|
|
153
297
|
}
|
|
154
298
|
|
|
155
299
|
/**
|
|
156
|
-
*
|
|
157
|
-
* @param {object} server -
|
|
158
|
-
* @param {object} config - schema
|
|
159
|
-
* @param {(options: object) => Promise<object>} [probe] -
|
|
160
|
-
* @returns {() => void}
|
|
300
|
+
* 把探测路由注册到 Host 的 web server 上。
|
|
301
|
+
* @param {object} server - 解析出的 `webServer` 服务
|
|
302
|
+
* @param {object} config - schema 输出,含 volatile 引用
|
|
303
|
+
* @param {(options: object) => Promise<object>} [probe] - 探测函数,测试可注入
|
|
304
|
+
* @returns {() => void} 路由的 disposer
|
|
161
305
|
*/
|
|
162
306
|
export function registerStatusRoute(server, config, probe = (options) => probeEngines(config, options)) {
|
|
163
307
|
return server.register({
|
|
@@ -166,3 +310,42 @@ export function registerStatusRoute(server, config, probe = (options) => probeEn
|
|
|
166
310
|
handler: (request, response) => statusHandler(request, response, probe),
|
|
167
311
|
})
|
|
168
312
|
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 回答一次 describe 请求。
|
|
316
|
+
* @param {object} request - HTTP 请求
|
|
317
|
+
* @param {object} response - HTTP 响应
|
|
318
|
+
* @param {() => object} describe - 载荷构造函数
|
|
319
|
+
*/
|
|
320
|
+
export async function describeHandler(request, response, describe = () => describeConfig()) {
|
|
321
|
+
try {
|
|
322
|
+
if (refused(request, response)) return
|
|
323
|
+
if ((request.method ?? 'GET') === 'HEAD') {
|
|
324
|
+
response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' })
|
|
325
|
+
response.end()
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
sendJson(response, 200, describe())
|
|
329
|
+
} catch {
|
|
330
|
+
// 与 status 同理:页面已经走开时不要再抛进 web server 的请求管线。
|
|
331
|
+
try {
|
|
332
|
+
response.destroy()
|
|
333
|
+
} catch {
|
|
334
|
+
// 已经没了。
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 把 describe 路由注册到 Host 的 web server 上。
|
|
341
|
+
* @param {object} server - 解析出的 `webServer` 服务
|
|
342
|
+
* @param {() => object} [describe] - 载荷构造函数,测试可注入
|
|
343
|
+
* @returns {() => void} 路由的 disposer
|
|
344
|
+
*/
|
|
345
|
+
export function registerDescribeRoute(server, describe = () => describeConfig()) {
|
|
346
|
+
return server.register({
|
|
347
|
+
kind: 'exact',
|
|
348
|
+
path: DESCRIBE_ROUTE,
|
|
349
|
+
handler: (request, response) => describeHandler(request, response, describe),
|
|
350
|
+
})
|
|
351
|
+
}
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config schema(schemastery)的构造。
|
|
3
|
+
*
|
|
4
|
+
* 字段表是 schema、路径与默认值三者唯一的汇合点:路径来自 `lib/config.js` 的 {@link PATHS},
|
|
5
|
+
* 默认值来自 {@link DEFAULTS},描述留在这里(配置编辑器读它,用户界面不读)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么要从字段表拼装而不是手写嵌套字面量:嵌套字面量会把同一条路径写两遍(schema 一遍、
|
|
8
|
+
* describe 一遍、迁移一遍),改一处忘一处就出现「页面能改、工具没读」的静默错位。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import z from '@deepseek-ai/schemastery'
|
|
12
|
+
import { DEFAULTS, PATHS, RETIRED_PATHS } from './config.js'
|
|
13
|
+
import { MAX_LIMIT, MAX_READ } from './limits.js'
|
|
14
|
+
|
|
15
|
+
/** 逗号分隔的后端名提示,来自注册表的说明留在 `lib/engines.js`。 */
|
|
16
|
+
const BACKEND_HINT = 'Use this unless a tool call names another backend; local_search_status lists the registered names.'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 可编辑字段表。
|
|
20
|
+
*
|
|
21
|
+
* `kind: 'count'` 的字段带 `max`,对应 Host 的硬上限;页面从 describe 拿到同一个数,所以
|
|
22
|
+
* 保存不会被 schema 拒绝。
|
|
23
|
+
*/
|
|
24
|
+
const FIELDS = [
|
|
25
|
+
{ path: PATHS.filesBackend, default: DEFAULTS.files.backend, description: `Default filename backend. ${BACKEND_HINT}` },
|
|
26
|
+
{ path: PATHS.esPath, default: DEFAULTS.files.everything.esPath, description: 'Absolute path to es.exe; empty auto-detects PATH and common install folders.' },
|
|
27
|
+
{
|
|
28
|
+
path: PATHS.esEncoding,
|
|
29
|
+
default: DEFAULTS.files.everything.esEncoding,
|
|
30
|
+
// 故意保持自由字符串:0.1 接受任意标签并在 decodeEs 里回退 UTF-8,收成枚举会让昨天还能用的 profile 激活失败。
|
|
31
|
+
description: 'Code page used to decode es.exe output: gbk for a zh-CN console, utf-8 otherwise. Any TextDecoder label is accepted; the page offers these two.',
|
|
32
|
+
},
|
|
33
|
+
{ path: PATHS.contentBackend, default: DEFAULTS.content.backend, description: `Default document-content backend. ${BACKEND_HINT}` },
|
|
34
|
+
{ path: PATHS.anytxtUrl, default: DEFAULTS.content.anytxt.url, description: 'Local Anytxt JSON-RPC endpoint on this machine (127.0.0.1, localhost or [::1]); :9921 is tried as a fallback.' },
|
|
35
|
+
{ path: PATHS.workspaceRoot, default: DEFAULTS.scopes.workspaceRoot, description: 'Root for scope=workspace; empty uses the session working directory.' },
|
|
36
|
+
{ path: PATHS.workRoot, default: DEFAULTS.scopes.workRoot, description: 'Root for scope=work; required before using that scope.' },
|
|
37
|
+
{ path: PATHS.personalRoot, default: DEFAULTS.scopes.personalRoot, description: 'Root for scope=personal; required before using that scope.' },
|
|
38
|
+
{ path: PATHS.allRoots, default: DEFAULTS.scopes.allRoots, description: 'Semicolon-separated roots for Anytxt scope=all; empty uses C: and D: when present.' },
|
|
39
|
+
{ path: PATHS.maxResults, default: DEFAULTS.limits.maxResults, kind: 'count', max: MAX_LIMIT, description: `Default result cap; the hard cap is ${MAX_LIMIT}.` },
|
|
40
|
+
{ path: PATHS.readMaxChars, default: DEFAULTS.limits.readMaxChars, kind: 'count', max: MAX_READ, description: `Default local_search_read window; the hard cap is ${MAX_READ} characters.` },
|
|
41
|
+
{
|
|
42
|
+
path: PATHS.autoConfigured,
|
|
43
|
+
default: DEFAULTS.autoConfigured,
|
|
44
|
+
kind: 'flag',
|
|
45
|
+
hidden: true,
|
|
46
|
+
// 隐藏而不是不声明:页面不渲染它,但 settings 只允许写 volatile 路径,自动配置要靠它落盘。
|
|
47
|
+
description: 'Set once the first-run configuration pass has decided what to fill; it keeps a field you cleared from being filled again.',
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/** 叶子标记;用 Symbol 是为了让路径键和它永不冲突。 */
|
|
52
|
+
const LEAF = Symbol('schema-leaf')
|
|
53
|
+
|
|
54
|
+
function fieldSchema(field) {
|
|
55
|
+
const base = field.kind === 'count'
|
|
56
|
+
? z.number().min(1).max(field.max)
|
|
57
|
+
: field.kind === 'flag' ? z.boolean() : z.string()
|
|
58
|
+
const leaf = base.default(field.default).volatile().description(field.description)
|
|
59
|
+
return field.hidden === true ? leaf.hidden() : leaf
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 旧位置的 schema:与目标字段同类型、同默认值,只是位置不同且隐藏。
|
|
64
|
+
*
|
|
65
|
+
* 必须留在 schema 里而不是删掉:settings 只允许写/撤 schema 里有的路径,迁移要靠它把旧键清掉;
|
|
66
|
+
* 隐藏则让配置编辑器不显示它们(页面本来就只按 describe 渲染)。
|
|
67
|
+
* @param field - 目标位置现在的字段
|
|
68
|
+
* @param to - 目标路径,只用于描述
|
|
69
|
+
*/
|
|
70
|
+
function legacySchema(field, to) {
|
|
71
|
+
const base = field.kind === 'count' ? z.number().min(1).max(field.max) : z.string()
|
|
72
|
+
return base
|
|
73
|
+
.default(field.default)
|
|
74
|
+
.volatile()
|
|
75
|
+
.hidden()
|
|
76
|
+
.description(`Retired path; moved to ${to.join('.')} at startup.`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function insert(tree, path, schema) {
|
|
80
|
+
let node = tree
|
|
81
|
+
for (const key of path.slice(0, -1)) {
|
|
82
|
+
node[key] ??= {}
|
|
83
|
+
node = node[key]
|
|
84
|
+
}
|
|
85
|
+
node[path.at(-1)] = { [LEAF]: schema }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 这条旧路径还能不能挂进 schema:它经过的每一层都得是对象(或还没建)。
|
|
90
|
+
* @param tree - 正在拼的 schema 树
|
|
91
|
+
* @param path - 旧路径
|
|
92
|
+
* @returns 可以插入时为 true
|
|
93
|
+
*/
|
|
94
|
+
function canInsert(tree, path) {
|
|
95
|
+
let node = tree
|
|
96
|
+
for (const key of path.slice(0, -1)) {
|
|
97
|
+
if (node[key] === undefined) return true
|
|
98
|
+
if (node[key][LEAF] !== undefined) return false
|
|
99
|
+
node = node[key]
|
|
100
|
+
}
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function wrap(tree) {
|
|
105
|
+
const dict = {}
|
|
106
|
+
for (const [key, child] of Object.entries(tree)) {
|
|
107
|
+
dict[key] = child[LEAF] ?? wrap(child)
|
|
108
|
+
}
|
|
109
|
+
return z.object(dict)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function build() {
|
|
113
|
+
const tree = {}
|
|
114
|
+
for (const field of FIELDS) insert(tree, field.path, fieldSchema(field))
|
|
115
|
+
const byPath = new Map(FIELDS.map((field) => [field.path.join('.'), field]))
|
|
116
|
+
// 退役路径仍要声明:settings 只允许写/撤 schema 里有的路径,迁移靠它把旧键清掉;隐藏则让
|
|
117
|
+
// 配置编辑器不显示它们(页面本来就只按 describe 渲染)。旧路径的父级可能已经是普通值,
|
|
118
|
+
// 那时 insert 会在它下面建对象把值挤掉——所以只处理父级仍是对象(或不存在)的那些。
|
|
119
|
+
for (const { from, to } of RETIRED_PATHS) {
|
|
120
|
+
const field = byPath.get(to.join('.'))
|
|
121
|
+
if (field === undefined) continue
|
|
122
|
+
if (!canInsert(tree, from)) continue
|
|
123
|
+
insert(tree, from, legacySchema(field, to))
|
|
124
|
+
}
|
|
125
|
+
tree.timeoutMs = { [LEAF]: z.number().default(DEFAULTS.timeoutMs).description('Per-call timeout budget in milliseconds; profile YAML only, because each tool fixes its deadline at registration.') }
|
|
126
|
+
return wrap(tree)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 插件的 Config schema。 */
|
|
130
|
+
export const Config = build()
|