@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/everything.js CHANGED
@@ -1,219 +1,277 @@
1
- import { spawn, execFileSync } from 'node:child_process'
2
- import { existsSync } from 'node:fs'
3
- import { isAbsolute } from 'node:path'
4
- import { EngineError } from './errors.js'
5
- import { filetimeToIso } from './format.js'
6
-
7
- const ES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
8
- const ES_MAX_STDERR_BYTES = 64 * 1024
9
- const EXCLUDED = [/\\\$RECYCLE\.BIN\\/i, /\\System Volume Information\\/i]
10
-
11
- let cachedEsPath
12
-
13
- /** Decode es.exe stdout using the console code page (GBK on zh-CN). */
14
- export function decodeEs(buffer, encoding = 'gbk') {
15
- try {
16
- return new TextDecoder(encoding).decode(buffer)
17
- } catch {
18
- return new TextDecoder('utf-8').decode(buffer)
19
- }
20
- }
21
-
22
- /** Candidate es.exe locations, in priority order. */
23
- export function esCandidates() {
24
- const localAppData = process.env.LOCALAPPDATA || ''
25
- return [
26
- 'C:\\Program Files\\Everything\\es.exe',
27
- 'C:\\Program Files (x86)\\Everything\\es.exe',
28
- localAppData ? `${localAppData}\\Microsoft\\WindowsApps\\es.exe` : '',
29
- ].filter(Boolean)
30
- }
31
-
32
- /** Resolve es.exe from config, env, PATH, or common install directories. */
33
- export function resolveEsPath(config = {}) {
34
- if (config.esPath) {
35
- if (!existsSync(config.esPath)) {
36
- throw new EngineError(`es.exe not found at the configured esPath: ${config.esPath}`, 'EVERYTHING_ES_NOT_FOUND', {
37
- hint: 'Point esPath at the es.exe download from https://www.voidtools.com/downloads/',
38
- })
39
- }
40
- return config.esPath
41
- }
42
- if (process.env.ES_PATH && existsSync(process.env.ES_PATH)) return process.env.ES_PATH
43
- if (cachedEsPath && existsSync(cachedEsPath)) return cachedEsPath
44
- for (const candidate of esCandidates()) {
45
- if (existsSync(candidate)) {
46
- cachedEsPath = candidate
47
- return candidate
48
- }
49
- }
50
- try {
51
- const output = execFileSync('where.exe', ['es.exe'], {
52
- encoding: 'utf8',
53
- windowsHide: true,
54
- timeout: 2000,
55
- })
56
- const first = output.split(/\r?\n/).map((line) => line.trim()).find(Boolean)
57
- if (first && existsSync(first)) {
58
- cachedEsPath = first
59
- return first
60
- }
61
- } catch {
62
- // where.exe is unavailable or found nothing; fall through.
63
- }
64
- throw new EngineError('es.exe was not found', 'EVERYTHING_ES_NOT_FOUND', {
65
- hint: 'Install es.exe (https://www.voidtools.com/downloads/), add it to PATH, or set esPath in the plugin config.',
66
- })
67
- }
68
-
69
- /** Build the fixed, read-only es.exe argv. Model text is always after `--`. */
70
- export function buildEsArgs({ query, limit = 20, dir } = {}) {
71
- if (typeof query !== 'string' || query.trim().length === 0) {
72
- throw new EngineError('query must be a non-empty string', 'INVALID_ARGUMENT')
73
- }
74
- if (!Number.isInteger(limit) || limit < 1) {
75
- throw new EngineError('limit must be a positive integer', 'INVALID_ARGUMENT')
76
- }
77
- const args = ['-n', String(limit), '-json', '-size', '-date-modified']
78
- if (dir) {
79
- if (!isAbsolute(dir) || !existsSync(dir)) {
80
- throw new EngineError(`dir is not an existing absolute directory: ${dir}`, 'ROOT_NOT_FOUND')
81
- }
82
- args.push('-path', dir)
83
- }
84
- args.push('--', query)
85
- return args
86
- }
87
-
88
- /** Run es.exe with a bounded buffer, timeout, and cancellation. */
89
- export function runEsText(args, { esPath, timeoutMs = 15000, signal, encoding = 'gbk' } = {}) {
90
- return new Promise((resolve, reject) => {
91
- const executable = esPath || resolveEsPath({})
92
- let settled = false
93
- let timer
94
- let child
95
-
96
- const cleanup = () => {
97
- if (timer) clearTimeout(timer)
98
- if (signal) signal.removeEventListener('abort', onAbort)
99
- }
100
- const finish = (error, text) => {
101
- if (settled) return
102
- settled = true
103
- cleanup()
104
- if (error) reject(error)
105
- else resolve(text)
106
- }
107
- const stop = (error) => {
108
- try {
109
- child?.kill()
110
- } catch {
111
- // Process already gone.
112
- }
113
- finish(error)
114
- }
115
- const onAbort = () =>
116
- stop(new EngineError('Everything search was aborted', 'EVERYTHING_ABORTED'))
117
-
118
- if (signal?.aborted) {
119
- onAbort()
120
- return
121
- }
122
-
123
- try {
124
- child = spawn(executable, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
125
- } catch (error) {
126
- finish(
127
- new EngineError(`cannot start es.exe: ${error.message}`, 'EVERYTHING_DOWN', {
128
- cause: error,
129
- hint: 'Check that Everything/es.exe is installed and that esPath is correct.',
130
- }),
131
- )
132
- return
133
- }
134
-
135
- const stdout = []
136
- const stderr = []
137
- let stdoutBytes = 0
138
- let stderrBytes = 0
139
-
140
- timer = setTimeout(() => {
141
- stop(new EngineError('Everything search timed out', 'EVERYTHING_TIMEOUT', { hint: 'Narrow the query or raise timeoutMs.' }))
142
- }, timeoutMs)
143
- signal?.addEventListener('abort', onAbort, { once: true })
144
-
145
- child.stdout.on('data', (chunk) => {
146
- stdoutBytes += chunk.length
147
- if (stdoutBytes > ES_MAX_OUTPUT_BYTES) {
148
- stop(new EngineError('Everything output exceeded 8 MB', 'EVERYTHING_TOO_LARGE', { hint: 'Narrow the query.' }))
149
- return
150
- }
151
- stdout.push(chunk)
152
- })
153
- child.stderr.on('data', (chunk) => {
154
- if (stderrBytes >= ES_MAX_STDERR_BYTES) return
155
- stderrBytes += chunk.length
156
- stderr.push(chunk)
157
- })
158
- child.on('error', (error) => {
159
- const code = error.code === 'ENOENT' ? 'EVERYTHING_ES_NOT_FOUND' : 'EVERYTHING_DOWN'
160
- stop(
161
- new EngineError(`cannot start es.exe: ${error.message}`, code, {
162
- cause: error,
163
- hint: 'Start the Everything executable and verify esPath.',
164
- }),
165
- )
166
- })
167
- child.on('close', (code) => {
168
- if (code !== 0 && code !== 1) {
169
- const detail = decodeEs(Buffer.concat(stderr), encoding).trim()
170
- return finish(
171
- new EngineError(`es.exe exited ${code}${detail ? `: ${detail}` : ''}`, 'EVERYTHING_DOWN', {
172
- hint: 'Start the Everything executable, then retry.',
173
- }),
174
- )
175
- }
176
- finish(undefined, decodeEs(Buffer.concat(stdout), encoding).trim())
177
- })
178
- })
179
- }
180
-
181
- /** Parse es.exe `-json` output into bounded, filtered rows. */
182
- export function parseEsJson(text) {
183
- if (!text || text.trim().length === 0) return []
184
- let rows
185
- try {
186
- rows = JSON.parse(text)
187
- } catch (error) {
188
- throw new EngineError('es.exe returned unparsable JSON', 'EVERYTHING_BAD_OUTPUT', { cause: error })
189
- }
190
- if (!Array.isArray(rows)) {
191
- throw new EngineError('es.exe JSON output was not an array', 'EVERYTHING_BAD_OUTPUT')
192
- }
193
- return rows
194
- .filter((row) => row && typeof row.filename === 'string' && !EXCLUDED.some((re) => re.test(row.filename)))
195
- .map((row) => {
196
- const item = { path: row.filename }
197
- if (typeof row.size === 'number') item.size = row.size
198
- const modified = filetimeToIso(row.date_modified)
199
- if (modified) item.modified = modified
200
- return item
201
- })
202
- }
203
-
204
- /** Search file names/paths through Everything. */
205
- export async function searchFiles({ query, dir, limit = 20, esPath, timeoutMs, signal, encoding } = {}) {
206
- const executable = esPath || resolveEsPath({})
207
- const args = buildEsArgs({ query, dir, limit })
208
- const text = await runEsText(args, { esPath: executable, timeoutMs, signal, encoding })
209
- const items = parseEsJson(text)
210
- return { items, truncated: items.length >= limit }
211
- }
212
-
213
- /** Read the running Everything version. */
214
- export async function getEsVersion({ esPath, timeoutMs, signal, encoding } = {}) {
215
- const executable = esPath || resolveEsPath({})
216
- const text = await runEsText(['-get-everything-version'], { esPath: executable, timeoutMs, signal, encoding })
217
- if (!text) throw new EngineError('Everything version probe returned nothing', 'EVERYTHING_DOWN')
218
- return text
219
- }
1
+ import { spawn, execFileSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { isAbsolute } from 'node:path'
4
+ import { EngineError } from './errors.js'
5
+ import { filetimeToIso } from './format.js'
6
+
7
+ const ES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
8
+ const ES_MAX_STDERR_BYTES = 64 * 1024
9
+ const EXCLUDED = [/\\\$RECYCLE\.BIN\\/i, /\\System Volume Information\\/i]
10
+
11
+ let cachedEsPath
12
+
13
+ /** Decode es.exe stdout using the console code page (GBK on zh-CN). */
14
+ export function decodeEs(buffer, encoding = 'gbk') {
15
+ try {
16
+ return new TextDecoder(encoding).decode(buffer)
17
+ } catch {
18
+ return new TextDecoder('utf-8').decode(buffer)
19
+ }
20
+ }
21
+
22
+ /** Candidate es.exe locations, in priority order. */
23
+ export function esCandidates() {
24
+ const localAppData = process.env.LOCALAPPDATA || ''
25
+ return [
26
+ 'C:\\Program Files\\Everything\\es.exe',
27
+ 'C:\\Program Files (x86)\\Everything\\es.exe',
28
+ localAppData ? `${localAppData}\\Microsoft\\WindowsApps\\es.exe` : '',
29
+ ].filter(Boolean)
30
+ }
31
+
32
+ /** Resolve es.exe from config, env, PATH, or common install directories. */
33
+ export function resolveEsPath(config = {}) {
34
+ if (config.esPath) {
35
+ if (!existsSync(config.esPath)) {
36
+ throw new EngineError(`es.exe not found at the configured esPath: ${config.esPath}`, 'EVERYTHING_ES_NOT_FOUND', {
37
+ hint: 'Point esPath at the es.exe download from https://www.voidtools.com/downloads/',
38
+ })
39
+ }
40
+ return config.esPath
41
+ }
42
+ if (process.env.ES_PATH && existsSync(process.env.ES_PATH)) return process.env.ES_PATH
43
+ if (cachedEsPath && existsSync(cachedEsPath)) return cachedEsPath
44
+ for (const candidate of esCandidates()) {
45
+ if (existsSync(candidate)) {
46
+ cachedEsPath = candidate
47
+ return candidate
48
+ }
49
+ }
50
+ try {
51
+ const output = execFileSync('where.exe', ['es.exe'], {
52
+ encoding: 'utf8',
53
+ windowsHide: true,
54
+ timeout: 2000,
55
+ })
56
+ const first = output.split(/\r?\n/).map((line) => line.trim()).find(Boolean)
57
+ if (first && existsSync(first)) {
58
+ cachedEsPath = first
59
+ return first
60
+ }
61
+ } catch {
62
+ // where.exe is unavailable or found nothing; fall through.
63
+ }
64
+ throw new EngineError('es.exe was not found', 'EVERYTHING_ES_NOT_FOUND', {
65
+ hint: 'Install es.exe (https://www.voidtools.com/downloads/), add it to PATH, or set esPath in the plugin config.',
66
+ })
67
+ }
68
+
69
+ /** Build the fixed, read-only es.exe argv. Model text is always after `--`. */
70
+ export function buildEsArgs({ query, limit = 20, dir } = {}) {
71
+ if (typeof query !== 'string' || query.trim().length === 0) {
72
+ throw new EngineError('query must be a non-empty string', 'INVALID_ARGUMENT')
73
+ }
74
+ if (!Number.isInteger(limit) || limit < 1) {
75
+ throw new EngineError('limit must be a positive integer', 'INVALID_ARGUMENT')
76
+ }
77
+ const args = ['-n', String(limit), '-json', '-size', '-date-modified']
78
+ if (dir) {
79
+ if (!isAbsolute(dir) || !existsSync(dir)) {
80
+ throw new EngineError(`dir is not an existing absolute directory: ${dir}`, 'ROOT_NOT_FOUND')
81
+ }
82
+ args.push('-path', dir)
83
+ }
84
+ args.push('--', query)
85
+ return args
86
+ }
87
+
88
+ /** Run es.exe with a bounded buffer, timeout, and cancellation. */
89
+ export function runEsText(args, { esPath, timeoutMs = 15000, signal, encoding = 'gbk' } = {}) {
90
+ return new Promise((resolve, reject) => {
91
+ const executable = esPath || resolveEsPath({})
92
+ let settled = false
93
+ let timer
94
+ let child
95
+
96
+ const cleanup = () => {
97
+ if (timer) clearTimeout(timer)
98
+ if (signal) signal.removeEventListener('abort', onAbort)
99
+ }
100
+ const finish = (error, text) => {
101
+ if (settled) return
102
+ settled = true
103
+ cleanup()
104
+ if (error) reject(error)
105
+ else resolve(text)
106
+ }
107
+ const stop = (error) => {
108
+ try {
109
+ child?.kill()
110
+ } catch {
111
+ // Process already gone.
112
+ }
113
+ finish(error)
114
+ }
115
+ const onAbort = () =>
116
+ stop(new EngineError('Everything search was aborted', 'EVERYTHING_ABORTED'))
117
+
118
+ if (signal?.aborted) {
119
+ onAbort()
120
+ return
121
+ }
122
+
123
+ try {
124
+ child = spawn(executable, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
125
+ } catch (error) {
126
+ finish(
127
+ new EngineError(`cannot start es.exe: ${error.message}`, 'EVERYTHING_DOWN', {
128
+ cause: error,
129
+ hint: 'Check that Everything/es.exe is installed and that esPath is correct.',
130
+ }),
131
+ )
132
+ return
133
+ }
134
+
135
+ const stdout = []
136
+ const stderr = []
137
+ let stdoutBytes = 0
138
+ let stderrBytes = 0
139
+
140
+ timer = setTimeout(() => {
141
+ stop(new EngineError('Everything search timed out', 'EVERYTHING_TIMEOUT', { hint: 'Narrow the query or raise timeoutMs.' }))
142
+ }, timeoutMs)
143
+ signal?.addEventListener('abort', onAbort, { once: true })
144
+
145
+ child.stdout.on('data', (chunk) => {
146
+ stdoutBytes += chunk.length
147
+ if (stdoutBytes > ES_MAX_OUTPUT_BYTES) {
148
+ stop(new EngineError('Everything output exceeded 8 MB', 'EVERYTHING_TOO_LARGE', { hint: 'Narrow the query.' }))
149
+ return
150
+ }
151
+ stdout.push(chunk)
152
+ })
153
+ child.stderr.on('data', (chunk) => {
154
+ if (stderrBytes >= ES_MAX_STDERR_BYTES) return
155
+ stderrBytes += chunk.length
156
+ stderr.push(chunk)
157
+ })
158
+ child.on('error', (error) => {
159
+ const code = error.code === 'ENOENT' ? 'EVERYTHING_ES_NOT_FOUND' : 'EVERYTHING_DOWN'
160
+ stop(
161
+ new EngineError(`cannot start es.exe: ${error.message}`, code, {
162
+ cause: error,
163
+ hint: 'Start the Everything executable and verify esPath.',
164
+ }),
165
+ )
166
+ })
167
+ child.on('close', (code) => {
168
+ const output = decodeEs(Buffer.concat(stdout), encoding).trim()
169
+ if (isFailedExit(code, output)) {
170
+ const detail = decodeEs(Buffer.concat(stderr), encoding).trim()
171
+ return finish(
172
+ new EngineError(`es.exe exited ${code}${detail ? `: ${detail}` : ''}`, 'EVERYTHING_DOWN', {
173
+ hint: 'Start the Everything executable, then retry.',
174
+ }),
175
+ )
176
+ }
177
+ finish(undefined, output)
178
+ })
179
+ })
180
+ }
181
+
182
+ /**
183
+ * Whether an es.exe exit means "no answer" rather than "no match".
184
+ *
185
+ * es.exe uses exit 1 both for "nothing matched" and for a failed connection, so
186
+ * the code alone cannot decide; an empty stdout next to stderr text is the
187
+ * failure, anything else is a result.
188
+ * @param {number} code - the process exit code
189
+ * @param {string} output - trimmed stdout
190
+ * @returns true when the call produced no usable answer
191
+ */
192
+ export function isFailedExit(code, output) {
193
+ return code !== 0 && (code !== 1 || output === '')
194
+ }
195
+
196
+ /** Parse es.exe `-json` output into rows, before any exclusion filter. */
197
+ export function parseEsJson(text) {
198
+ if (!text || text.trim().length === 0) return []
199
+ let rows
200
+ try {
201
+ rows = JSON.parse(text)
202
+ } catch (error) {
203
+ throw new EngineError('es.exe returned unparsable JSON', 'EVERYTHING_BAD_OUTPUT', { cause: error })
204
+ }
205
+ if (!Array.isArray(rows)) {
206
+ throw new EngineError('es.exe JSON output was not an array', 'EVERYTHING_BAD_OUTPUT')
207
+ }
208
+ return rows
209
+ .filter((row) => row && typeof row.filename === 'string')
210
+ .map((row) => {
211
+ const item = { path: row.filename }
212
+ if (typeof row.size === 'number') item.size = row.size
213
+ const modified = filetimeToIso(row.date_modified)
214
+ if (modified) item.modified = modified
215
+ return item
216
+ })
217
+ }
218
+
219
+ /**
220
+ * Drop rows that live in an index we never surface.
221
+ * @param {Array<{path: string}>} rows - parsed rows
222
+ * @returns {Array<{path: string}>} the rows a tool may return
223
+ */
224
+ export function withoutExcludedPaths(rows) {
225
+ return rows.filter((row) => !EXCLUDED.some((re) => re.test(row.path)))
226
+ }
227
+
228
+ /** Candidate Everything.exe locations, in priority order. */
229
+ export function everythingCandidates() {
230
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files'
231
+ const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
232
+ return [`${programFiles}\\Everything\\Everything.exe`, `${programFilesX86}\\Everything\\Everything.exe`]
233
+ }
234
+
235
+ /**
236
+ * Whether Everything itself is installed, independent of es.exe.
237
+ *
238
+ * `sc query` exits non-zero when the service is absent, which is the one signal
239
+ * that survives a localized Windows console. An install outside Program Files
240
+ * (or a portable one) is only found through that service, so a check that could
241
+ * not run at all reports `known: false` instead of claiming "not installed".
242
+ * @returns {{installed: boolean, via?: 'exe'|'service', known: boolean}} install evidence
243
+ */
244
+ export function everythingInstalled() {
245
+ for (const candidate of everythingCandidates()) {
246
+ if (existsSync(candidate)) return { installed: true, via: 'exe', known: true }
247
+ }
248
+ try {
249
+ execFileSync('sc.exe', ['query', 'Everything'], { encoding: 'utf8', windowsHide: true, timeout: 2000 })
250
+ return { installed: true, via: 'service', known: true }
251
+ } catch (error) {
252
+ // A numeric `status` means sc.exe ran and the service is absent; a spawn
253
+ // failure (EPERM, ENOENT) proves nothing about the machine.
254
+ const ran = typeof error?.status === 'number'
255
+ return { installed: false, known: ran }
256
+ }
257
+ }
258
+
259
+ /** Search file names/paths through Everything. */
260
+ export async function searchFiles({ query, dir, limit = 20, esPath, timeoutMs, signal, encoding } = {}) {
261
+ const executable = esPath || resolveEsPath({})
262
+ const args = buildEsArgs({ query, dir, limit })
263
+ const text = await runEsText(args, { esPath: executable, timeoutMs, signal, encoding })
264
+ const rows = parseEsJson(text)
265
+ // Truncation is decided on what es.exe returned, not on what survived the
266
+ // $RECYCLE.BIN filter: a capped page of excluded hits must not read as a
267
+ // plain "no files found".
268
+ return { items: withoutExcludedPaths(rows), truncated: rows.length >= limit }
269
+ }
270
+
271
+ /** Read the running Everything version. */
272
+ export async function getEsVersion({ esPath, timeoutMs, signal, encoding } = {}) {
273
+ const executable = esPath || resolveEsPath({})
274
+ const text = await runEsText(['-get-everything-version'], { esPath: executable, timeoutMs, signal, encoding })
275
+ if (!text) throw new EngineError('Everything version probe returned nothing', 'EVERYTHING_DOWN')
276
+ return text
277
+ }