@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/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 root
40
- }
41
-
42
- /**
43
- * Resolve the directories a call should search.
44
- *
45
- * `workspace` falls back to the session's default directory; `work` and
46
- * `personal` require explicit config; `all` uses `allRoots` or the C:/D:
47
- * defaults.
48
- */
49
- export function rootsFor(scope, explicitPath, config, defaultWorkspace) {
50
- const normalized = scope || 'workspace'
51
- if (!SCOPES.includes(normalized)) {
52
- throw new EngineError(`unknown scope: ${scope}`, 'INVALID_SCOPE', {
53
- hint: `Use one of ${SCOPES.join(', ')}.`,
54
- })
55
- }
56
- if (explicitPath) {
57
- return [validateRoot(explicitPath, 'path')]
58
- }
59
- if (normalized === 'workspace') {
60
- return [validateRoot(config.workspaceRoot || defaultWorkspace || process.cwd(), 'workspaceRoot')]
61
- }
62
- if (normalized === 'work') {
63
- if (!config.workRoot) {
64
- throw new EngineError('workRoot is not configured', 'SCOPE_NOT_CONFIGURED', {
65
- hint: 'Set workRoot in the plugin config, or pass an explicit path.',
66
- })
67
- }
68
- return [validateRoot(config.workRoot, 'workRoot')]
69
- }
70
- if (normalized === 'personal') {
71
- if (!config.personalRoot) {
72
- throw new EngineError('personalRoot is not configured', 'SCOPE_NOT_CONFIGURED', {
73
- hint: 'Set personalRoot in the plugin config, or pass an explicit path.',
74
- })
75
- }
76
- return [validateRoot(config.personalRoot, 'personalRoot')]
77
- }
78
- const configured = parseAllRoots(config.allRoots)
79
- const roots = (configured.length > 0 ? configured : defaultAllRoots()).filter((root) => existsSync(root))
80
- if (roots.length === 0) {
81
- throw new EngineError('no allRoots are available', 'SCOPE_NOT_CONFIGURED', {
82
- hint: 'Set allRoots to existing directories, or pass an explicit path.',
83
- })
84
- }
85
- return roots
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
+ }