@fishdivinity/dsh-local-search 0.1.0 → 0.2.1
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/LICENSE +21 -21
- package/README-zh.md +167 -166
- package/README.md +177 -176
- package/cordis.patch.yml +9 -9
- package/icon.svg +35 -7
- package/lib/anytxt.js +272 -252
- package/lib/client.js +623 -0
- package/lib/config.js +101 -0
- package/lib/engines.js +85 -85
- package/lib/errors.js +18 -18
- package/lib/everything.js +277 -219
- package/lib/format.js +101 -92
- package/lib/index.js +77 -71
- package/lib/limits.js +14 -0
- package/lib/probe.js +88 -0
- package/lib/ref.js +38 -38
- package/lib/roots.js +114 -86
- package/lib/route.js +170 -0
- package/lib/tools.js +498 -506
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +86 -65
package/lib/roots.js
CHANGED
|
@@ -1,86 +1,114 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
2
|
-
import { isAbsolute } from 'node:path'
|
|
3
|
-
import { EngineError } from './errors.js'
|
|
4
|
-
|
|
5
|
-
export const SCOPES = ['workspace', 'work', 'personal', 'all']
|
|
6
|
-
|
|
7
|
-
/** Default `all` roots: the system drive plus D: when present. */
|
|
8
|
-
export function defaultAllRoots() {
|
|
9
|
-
const roots = []
|
|
10
|
-
const systemDrive = process.env.SystemDrive || 'C:'
|
|
11
|
-
const candidates = [systemDrive, 'D:']
|
|
12
|
-
for (const drive of candidates) {
|
|
13
|
-
const root = drive.endsWith('\\') ? drive : `${drive}\\`
|
|
14
|
-
if (!roots.includes(root) && existsSync(root)) roots.push(root)
|
|
15
|
-
}
|
|
16
|
-
return roots
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Parse the semicolon-separated `allRoots` config. */
|
|
20
|
-
export function parseAllRoots(value) {
|
|
21
|
-
return String(value || '')
|
|
22
|
-
.split(';')
|
|
23
|
-
.map((root) => root.trim())
|
|
24
|
-
.filter(Boolean)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Validate one existing absolute directory root. */
|
|
28
|
-
export function validateRoot(root, label = 'root') {
|
|
29
|
-
if (!root || typeof root !== 'string' || !isAbsolute(root)) {
|
|
30
|
-
throw new EngineError(`${label} must be an absolute directory: ${root ?? '(empty)'}`, 'ROOT_NOT_ABSOLUTE', {
|
|
31
|
-
hint: `Set ${label} to an existing absolute directory.`,
|
|
32
|
-
})
|
|
33
|
-
}
|
|
34
|
-
if (!existsSync(root)) {
|
|
35
|
-
throw new EngineError(`${label} does not exist: ${root}`, 'ROOT_NOT_FOUND', {
|
|
36
|
-
hint: `Set ${label} to an existing directory.`,
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute } from 'node:path'
|
|
3
|
+
import { EngineError } from './errors.js'
|
|
4
|
+
|
|
5
|
+
export const SCOPES = ['workspace', 'work', 'personal', 'all']
|
|
6
|
+
|
|
7
|
+
/** Default `all` roots: the system drive plus D: when present. */
|
|
8
|
+
export function defaultAllRoots() {
|
|
9
|
+
const roots = []
|
|
10
|
+
const systemDrive = process.env.SystemDrive || 'C:'
|
|
11
|
+
const candidates = [systemDrive, 'D:']
|
|
12
|
+
for (const drive of candidates) {
|
|
13
|
+
const root = drive.endsWith('\\') ? drive : `${drive}\\`
|
|
14
|
+
if (!roots.includes(root) && existsSync(root)) roots.push(root)
|
|
15
|
+
}
|
|
16
|
+
return roots
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parse the semicolon-separated `allRoots` config. */
|
|
20
|
+
export function parseAllRoots(value) {
|
|
21
|
+
return String(value || '')
|
|
22
|
+
.split(';')
|
|
23
|
+
.map((root) => root.trim())
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Validate one existing absolute directory root. */
|
|
28
|
+
export function validateRoot(root, label = 'root') {
|
|
29
|
+
if (!root || typeof root !== 'string' || !isAbsolute(root)) {
|
|
30
|
+
throw new EngineError(`${label} must be an absolute directory: ${root ?? '(empty)'}`, 'ROOT_NOT_ABSOLUTE', {
|
|
31
|
+
hint: `Set ${label} to an existing absolute directory.`,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
if (!existsSync(root)) {
|
|
35
|
+
throw new EngineError(`${label} does not exist: ${root}`, 'ROOT_NOT_FOUND', {
|
|
36
|
+
hint: `Set ${label} to an existing directory.`,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
// A file passes existsSync but makes es.exe -path and Anytxt filterDir return
|
|
40
|
+
// nothing, which reads as "no results" instead of a configuration mistake.
|
|
41
|
+
if (!statSync(root).isDirectory()) {
|
|
42
|
+
throw new EngineError(`${label} is not a directory: ${root}`, 'ROOT_NOT_DIRECTORY', {
|
|
43
|
+
hint: `Set ${label} to a directory, not a file.`,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return root
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Drop roots nested inside another root.
|
|
51
|
+
*
|
|
52
|
+
* An AnyTxt search asks once per root and sums the counts, so overlapping roots
|
|
53
|
+
* count the same file twice: the "x of y" header inflates and `truncated` flips
|
|
54
|
+
* early. One query per distinct tree is also cheaper.
|
|
55
|
+
* @param {string[]} roots - validated absolute directories
|
|
56
|
+
* @returns {string[]} the roots that are not inside another one
|
|
57
|
+
*/
|
|
58
|
+
export function dedupeRoots(roots) {
|
|
59
|
+
// Windows accepts either separator, so compare one form while returning the
|
|
60
|
+
// caller's own spelling.
|
|
61
|
+
const normalized = roots.map((root) => root.replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase())
|
|
62
|
+
return roots.filter((root, index) => !normalized.some((other, otherIndex) => {
|
|
63
|
+
if (otherIndex === index) return false
|
|
64
|
+
// The same tree spelled twice keeps its first occurrence only.
|
|
65
|
+
if (normalized[index] === other) return otherIndex < index
|
|
66
|
+
return normalized[index].startsWith(`${other}\\`)
|
|
67
|
+
}))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the directories a call should search.
|
|
72
|
+
*
|
|
73
|
+
* `workspace` falls back to the session's default directory; `work` and
|
|
74
|
+
* `personal` require explicit config; `all` uses `allRoots` or the C:/D:
|
|
75
|
+
* defaults.
|
|
76
|
+
*/
|
|
77
|
+
export function rootsFor(scope, explicitPath, config, defaultWorkspace) {
|
|
78
|
+
const normalized = scope || 'workspace'
|
|
79
|
+
if (!SCOPES.includes(normalized)) {
|
|
80
|
+
throw new EngineError(`unknown scope: ${scope}`, 'INVALID_SCOPE', {
|
|
81
|
+
hint: `Use one of ${SCOPES.join(', ')}.`,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
if (explicitPath) {
|
|
85
|
+
return [validateRoot(explicitPath, 'path')]
|
|
86
|
+
}
|
|
87
|
+
if (normalized === 'workspace') {
|
|
88
|
+
return [validateRoot(config.workspaceRoot || defaultWorkspace || process.cwd(), 'workspaceRoot')]
|
|
89
|
+
}
|
|
90
|
+
if (normalized === 'work') {
|
|
91
|
+
if (!config.workRoot) {
|
|
92
|
+
throw new EngineError('workRoot is not configured', 'SCOPE_NOT_CONFIGURED', {
|
|
93
|
+
hint: 'Set workRoot in the plugin config, or pass an explicit path.',
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return [validateRoot(config.workRoot, 'workRoot')]
|
|
97
|
+
}
|
|
98
|
+
if (normalized === 'personal') {
|
|
99
|
+
if (!config.personalRoot) {
|
|
100
|
+
throw new EngineError('personalRoot is not configured', 'SCOPE_NOT_CONFIGURED', {
|
|
101
|
+
hint: 'Set personalRoot in the plugin config, or pass an explicit path.',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return [validateRoot(config.personalRoot, 'personalRoot')]
|
|
105
|
+
}
|
|
106
|
+
const configured = parseAllRoots(config.allRoots)
|
|
107
|
+
const roots = (configured.length > 0 ? configured : defaultAllRoots()).filter((root) => existsSync(root))
|
|
108
|
+
if (roots.length === 0) {
|
|
109
|
+
throw new EngineError('no allRoots are available', 'SCOPE_NOT_CONFIGURED', {
|
|
110
|
+
hint: 'Set allRoots to existing directories, or pass an explicit path.',
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
return dedupeRoots(roots)
|
|
114
|
+
}
|
package/lib/route.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The configuration page's Test control needs one thing the browser cannot do
|
|
3
|
+
* itself: call the local Anytxt JSON-RPC and `es.exe` and report whether they
|
|
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.
|
|
6
|
+
*
|
|
7
|
+
* The route is `exact` on the bare `webServer`, the same seam the other
|
|
8
|
+
* installed plugins use (a prefix/exact registration never passes through the
|
|
9
|
+
* shell's `/api` fence). It is read-only and sends no CORS headers, so a foreign
|
|
10
|
+
* page cannot read the answer.
|
|
11
|
+
*
|
|
12
|
+
* A foreign page *can* still fire a plain GET, and a GET here spends an es.exe
|
|
13
|
+
* spawn, so the route refuses any request a browser marks as cross-site
|
|
14
|
+
* (`Sec-Fetch-Site`) or whose `Origin` is not this Host. A non-browser caller
|
|
15
|
+
* sends neither header and is a local process the page's CSRF risk does not
|
|
16
|
+
* cover.
|
|
17
|
+
*
|
|
18
|
+
* `?anytxtUrl=` lets the page test what the user typed before saving. That
|
|
19
|
+
* draft is untrusted input, so it is accepted only as a loopback `http(s)` URL:
|
|
20
|
+
* enough to type `127.0.0.1:9921` and try, never a request-forwarding proxy.
|
|
21
|
+
* Without the parameter the route probes the saved configuration; a saved
|
|
22
|
+
* endpoint that is not local fails in the engine with `ANYTXT_ENDPOINT_REMOTE`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { probeEngines } from './probe.js'
|
|
26
|
+
import { isLoopbackUrl } from './config.js'
|
|
27
|
+
|
|
28
|
+
/** Path the page fetches; the page resolves it against its own base URI. */
|
|
29
|
+
export const STATUS_ROUTE = '/dsh-local-search/api/status'
|
|
30
|
+
|
|
31
|
+
function sendJson(response, status, payload) {
|
|
32
|
+
const body = JSON.stringify(payload)
|
|
33
|
+
response.writeHead(status, {
|
|
34
|
+
'cache-control': 'no-store',
|
|
35
|
+
'content-type': 'application/json; charset=utf-8',
|
|
36
|
+
'content-length': Buffer.byteLength(body),
|
|
37
|
+
})
|
|
38
|
+
response.end(body)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether the request came from this page rather than another site.
|
|
43
|
+
* @param {object} request - the HTTP request (only `headers` is read)
|
|
44
|
+
* @returns true for a same-origin request, or one no browser sent
|
|
45
|
+
*/
|
|
46
|
+
export function isSameOrigin(request) {
|
|
47
|
+
const headers = request.headers ?? {}
|
|
48
|
+
const site = headers['sec-fetch-site']
|
|
49
|
+
if (typeof site === 'string' && site !== '') return site === 'same-origin' || site === 'none'
|
|
50
|
+
const origin = headers.origin
|
|
51
|
+
if (typeof origin !== 'string' || origin === '') return true
|
|
52
|
+
const host = headers.host
|
|
53
|
+
if (typeof host !== 'string' || host === '') return false
|
|
54
|
+
try {
|
|
55
|
+
return new URL(origin).host === host
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the draft endpoint a pre-save test asks about.
|
|
63
|
+
*
|
|
64
|
+
* It runs the same loopback rule the config schema enforces, so a draft can
|
|
65
|
+
* never reach an address the saved configuration could not hold.
|
|
66
|
+
* @param {string} requestUrl - the request target
|
|
67
|
+
* @returns {{ok: true, value: string|undefined} | {ok: false}} the draft, or why it was refused
|
|
68
|
+
*/
|
|
69
|
+
export function draftEndpoint(requestUrl) {
|
|
70
|
+
let parsed
|
|
71
|
+
try {
|
|
72
|
+
parsed = new URL(String(requestUrl ?? '/'), 'http://localhost')
|
|
73
|
+
} catch {
|
|
74
|
+
return { ok: true, value: undefined }
|
|
75
|
+
}
|
|
76
|
+
const raw = parsed.searchParams.get('anytxtUrl')
|
|
77
|
+
if (raw === null || raw.trim() === '') return { ok: true, value: undefined }
|
|
78
|
+
const trimmed = raw.trim()
|
|
79
|
+
return isLoopbackUrl(trimmed) ? { ok: true, value: trimmed } : { ok: false }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Answer one probe request.
|
|
84
|
+
* @param {object} request - the HTTP request (`method` and `url` are read)
|
|
85
|
+
* @param {object} response - the HTTP response
|
|
86
|
+
* @param {(options: object) => Promise<{everything: object, anytxt: object}>} probe - the probe to run
|
|
87
|
+
*/
|
|
88
|
+
export async function statusHandler(request, response, probe) {
|
|
89
|
+
try {
|
|
90
|
+
await answerStatus(request, response, probe)
|
|
91
|
+
} catch {
|
|
92
|
+
// A page that navigated away mid-probe leaves a dead socket; a rejected
|
|
93
|
+
// promise here would surface as an unhandled rejection in the web server's
|
|
94
|
+
// request pipeline.
|
|
95
|
+
try {
|
|
96
|
+
response.destroy()
|
|
97
|
+
} catch {
|
|
98
|
+
// Already gone.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Write the answer for one request.
|
|
105
|
+
* @param {object} request - the HTTP request
|
|
106
|
+
* @param {object} response - the HTTP response
|
|
107
|
+
* @param {(options: object) => Promise<{everything: object, anytxt: object}>} probe - the probe to run
|
|
108
|
+
*/
|
|
109
|
+
async function answerStatus(request, response, probe) {
|
|
110
|
+
const method = request.method ?? 'GET'
|
|
111
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
112
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
113
|
+
response.end()
|
|
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.
|
|
124
|
+
if (method === 'HEAD') {
|
|
125
|
+
response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' })
|
|
126
|
+
response.end()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const draft = draftEndpoint(request.url)
|
|
130
|
+
if (!draft.ok) {
|
|
131
|
+
sendJson(response, 400, {
|
|
132
|
+
ok: false,
|
|
133
|
+
code: 'ANYTXT_URL_REJECTED',
|
|
134
|
+
message: 'a draft endpoint must be a loopback http(s) url',
|
|
135
|
+
})
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
let payload
|
|
139
|
+
let status = 200
|
|
140
|
+
try {
|
|
141
|
+
// This route only feeds the page, so it may carry the actionable cause the
|
|
142
|
+
// model-facing status keeps out of its surface.
|
|
143
|
+
const probed = await probe({ anytxtUrl: draft.value, detail: true })
|
|
144
|
+
payload = {
|
|
145
|
+
ok: Boolean(probed.everything?.ok) && Boolean(probed.anytxt?.ok),
|
|
146
|
+
checkedAt: Date.now(),
|
|
147
|
+
everything: probed.everything ?? { ok: false, code: 'UNKNOWN' },
|
|
148
|
+
anytxt: probed.anytxt ?? { ok: false, code: 'UNKNOWN' },
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
status = 500
|
|
152
|
+
payload = { ok: false, code: 'PROBE_FAILED', message: error instanceof Error ? error.message : String(error) }
|
|
153
|
+
}
|
|
154
|
+
sendJson(response, status, payload)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Register the probe route on the Host's web server.
|
|
159
|
+
* @param {object} server - the resolved `webServer` service
|
|
160
|
+
* @param {object} config - schema output, volatile references included
|
|
161
|
+
* @param {(options: object) => Promise<object>} [probe] - the probe, injectable for tests
|
|
162
|
+
* @returns {() => void} route disposer
|
|
163
|
+
*/
|
|
164
|
+
export function registerStatusRoute(server, config, probe = (options) => probeEngines(config, options)) {
|
|
165
|
+
return server.register({
|
|
166
|
+
kind: 'exact',
|
|
167
|
+
path: STATUS_ROUTE,
|
|
168
|
+
handler: (request, response) => statusHandler(request, response, probe),
|
|
169
|
+
})
|
|
170
|
+
}
|