dsh-everything 0.0.0-stage → 1.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/setup.js ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * dsh-everything auto-provisioning: download and install the search backend
3
+ * (Everything 1.4 + the ES command line tool) on first use, so the tool works
4
+ * on a fresh Windows machine without manual setup.
5
+ *
6
+ * Supply-chain posture: the latest stable version is resolved from the
7
+ * official voidtools.com downloads page at install time. When it matches the
8
+ * known-good release recorded in PINS, the download is SHA256-verified against
9
+ * the recorded digest; otherwise (a newer upstream release, or the page being
10
+ * unreachable and the pin used as fallback) the voidtools Authenticode
11
+ * signature on the downloaded binary is verified instead. Both checks failing
12
+ * aborts the install.
13
+ *
14
+ * Networking: direct download is always tried first (most users have no
15
+ * proxy); a configured or Windows system proxy is only a fallback, and a stale
16
+ * registry proxy can never break a machine that works direct.
17
+ *
18
+ * What lands where:
19
+ * - es.exe is extracted under %LOCALAPPDATA%\dsh-everything\bin (per-user, no admin).
20
+ * - Everything.exe installs machine-wide via winget when available, else via
21
+ * the official silent NSIS setup — either path may show one UAC prompt.
22
+ */
23
+
24
+ import { execFile, spawn } from 'node:child_process';
25
+ import { createHash } from 'node:crypto';
26
+ import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
27
+ import { readFile } from 'node:fs/promises';
28
+ import { win32 as win32path } from 'node:path';
29
+
30
+ export class EverythingError extends Error {
31
+ constructor(message, code) {
32
+ super(message);
33
+ this.name = 'EverythingError';
34
+ this.code = code;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Known-good releases recorded from the official voidtools.com downloads on
40
+ * 2026-10-26 (voidtools does not publish hashes; these digests were computed
41
+ * from the live downloads). Used as the fallback when the downloads page
42
+ * cannot be reached, and as the SHA256 trust anchor when the resolved latest
43
+ * version matches one of them.
44
+ */
45
+ export const PINS = {
46
+ es: {
47
+ version: '1.1.0.37',
48
+ url: 'https://www.voidtools.com/ES-1.1.0.37.x64.zip',
49
+ sha256: '7a57670d9152068d05876c58858c82fe6d3915a9df2c819f4de8801e2929d3a7',
50
+ bytes: 115002,
51
+ },
52
+ everything: {
53
+ version: '1.4.1.1032',
54
+ url: 'https://www.voidtools.com/Everything-1.4.1.1032.x64-Setup.exe',
55
+ sha256: 'c42efad041d4c0bb4d4ac97ae7cbe89f153ec1fe078772392e749c7f5d5282d3',
56
+ bytes: 1998736,
57
+ },
58
+ };
59
+
60
+ const DOWNLOADS_PAGE = 'https://www.voidtools.com/downloads/';
61
+ const VOIDTOOLS_ORIGIN = 'https://www.voidtools.com';
62
+ const SIGNER_MATCH = /CN=voidtools/i;
63
+
64
+ const EVERYTHING_INSTALL_CANDIDATES = [
65
+ 'C:\\Program Files\\Everything\\Everything.exe',
66
+ 'C:\\Program Files (x86)\\Everything\\Everything.exe',
67
+ 'D:\\Everything\\Everything.exe',
68
+ ];
69
+
70
+ const DOWNLOAD_TIMEOUT_MS = 120000;
71
+ const WINGET_TIMEOUT_MS = 300000;
72
+ const SETUP_TIMEOUT_MS = 120000;
73
+
74
+ export function resolveInstallDir(config, env = process.env) {
75
+ if (config && typeof config.installDir === 'string' && config.installDir.trim().length > 0) {
76
+ return config.installDir.trim();
77
+ }
78
+ const local =
79
+ typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA.trim().length > 0
80
+ ? env.LOCALAPPDATA
81
+ : typeof env.USERPROFILE === 'string' && env.USERPROFILE.length > 0
82
+ ? win32path.join(env.USERPROFILE, 'AppData', 'Local')
83
+ : null;
84
+ if (!local) {
85
+ throw new EverythingError(
86
+ 'everything_search setup: could not resolve %LOCALAPPDATA%; set installDir in the plugin config.',
87
+ 'ES_SETUP_FAILED'
88
+ );
89
+ }
90
+ return win32path.join(local, 'dsh-everything');
91
+ }
92
+
93
+ /** Parse `reg query ...\Internet Settings` output into a proxy URL, or null. */
94
+ export function parseProxyFromRegOutput(text) {
95
+ if (typeof text !== 'string' || text.length === 0) return null;
96
+ const enabled = /ProxyEnable\s+REG_DWORD\s+0x1\b/i.test(text);
97
+ if (!enabled) return null;
98
+ const match = /ProxyServer\s+REG_SZ\s+(\S+)/i.exec(text);
99
+ if (!match) return null;
100
+ const server = match[1];
101
+ // Forms: "host:port" or per-protocol "http=host:port;https=host:port;..."
102
+ const httpsPart = /(?:^|;)https=([^;]+)/i.exec(server);
103
+ const target = httpsPart ? httpsPart[1] : server.includes('=') ? null : server;
104
+ if (!target) return null;
105
+ return /^[a-z]+:\/\//i.test(target) ? target : `http://${target}`;
106
+ }
107
+
108
+ export function resolveProxy(env = process.env) {
109
+ for (const key of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) {
110
+ const value = env[key];
111
+ if (typeof value === 'string' && value.trim().length > 0) return value.trim();
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** Read the Windows system proxy (registry). Returns a proxy URL or null. */
117
+ export async function detectSystemProxy() {
118
+ try {
119
+ const out = await run('reg.exe', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'], 10000);
120
+ return parseProxyFromRegOutput(out);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /** Compare dotted numeric versions: compareVersions('1.4.1', '1.10.0') < 0. */
127
+ export function compareVersions(a, b) {
128
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
129
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
130
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
131
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
132
+ if (d !== 0) return d;
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ /**
138
+ * Parse the voidtools.com downloads page into the latest STABLE x64 artifact
139
+ * for each kind. Beta versions (a letter suffix like 1.5.0.1424b) and
140
+ * non-default flavors (en-US, Lite, x86, ARM) are excluded. Returns
141
+ * { es: {version,url}|null, everything: {version,url}|null }.
142
+ */
143
+ export function parseDownloadsPage(html) {
144
+ if (typeof html !== 'string' || html.length === 0) return { es: null, everything: null };
145
+ const pick = (pattern) => {
146
+ let best = null;
147
+ for (const match of html.matchAll(pattern)) {
148
+ const version = match[1];
149
+ if (!best || compareVersions(version, best.version) > 0) {
150
+ best = { version, url: `${VOIDTOOLS_ORIGIN}${match[0].startsWith('/') ? match[0] : `/${match[0]}`}` };
151
+ }
152
+ }
153
+ return best;
154
+ };
155
+ return {
156
+ es: pick(/ES-(\d+(?:\.\d+)+)\.x64\.zip/g),
157
+ everything: pick(/Everything-(\d+(?:\.\d+)+)\.x64-Setup\.exe/g),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Resolve the download artifact for a kind ('es' | 'everything'): scrape the
163
+ * downloads page for the latest stable release, falling back to the recorded
164
+ * known-good pin when the page is unreachable or unparsable. The returned
165
+ * sha256 is set only when the resolved version matches the known-good pin;
166
+ * callers must then verify the Authenticode signature instead.
167
+ */
168
+ export async function resolveArtifact(kind, { proxy, log } = {}) {
169
+ try {
170
+ const html = await fetchText(DOWNLOADS_PAGE, { proxy });
171
+ const parsed = parseDownloadsPage(html);
172
+ const latest = parsed[kind];
173
+ if (latest) {
174
+ const pin = PINS[kind];
175
+ const sha256 = latest.version === pin.version ? pin.sha256 : null;
176
+ if (!sha256) log?.info?.(`everything_search setup: upstream ${kind} is now ${latest.version} (newer than the recorded ${pin.version}); will verify its voidtools signature instead of a hash.`);
177
+ return { ...latest, sha256 };
178
+ }
179
+ log?.warn?.(`everything_search setup: could not find a ${kind} download on the downloads page; using the recorded known-good release ${PINS[kind].version}.`);
180
+ } catch (error) {
181
+ log?.warn?.(`everything_search setup: downloads page unreachable (${error?.message ?? error}); using the recorded known-good release ${PINS[kind].version}.`);
182
+ }
183
+ return { version: PINS[kind].version, url: PINS[kind].url, sha256: PINS[kind].sha256 };
184
+ }
185
+
186
+ export function buildWingetInstallArgs() {
187
+ return [
188
+ 'install',
189
+ '--id',
190
+ 'voidtools.Everything',
191
+ '-e',
192
+ '--silent',
193
+ '--accept-package-agreements',
194
+ '--accept-source-agreements',
195
+ '--disable-interactivity',
196
+ ];
197
+ }
198
+
199
+ export function buildSetupSilentArgs() {
200
+ return ['/S'];
201
+ }
202
+
203
+ function run(command, argv, timeoutMs) {
204
+ return new Promise((resolve, reject) => {
205
+ execFile(command, argv, { timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
206
+ if (error) {
207
+ const tail = typeof stderr === 'string' && stderr.trim() ? stderr.trim().slice(-300) : (error.message ?? String(error));
208
+ reject(new EverythingError(`everything_search setup: "${command}" failed: ${tail}`, 'ES_SETUP_FAILED'));
209
+ return;
210
+ }
211
+ resolve(typeof stdout === 'string' ? stdout : '');
212
+ });
213
+ });
214
+ }
215
+
216
+ export async function sha256File(path) {
217
+ const buf = await readFile(path);
218
+ return createHash('sha256').update(buf).digest('hex');
219
+ }
220
+
221
+ /** Read the FileVersion resource of a PE file through PowerShell. */
222
+ export async function getExeFileVersion(file) {
223
+ const script = `(Get-Item -LiteralPath ${JSON.stringify(file)}).VersionInfo.FileVersion`;
224
+ const stdout = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 15000);
225
+ return stdout.trim();
226
+ }
227
+
228
+ async function verifyHash(file, sha256, label) {
229
+ const digest = await sha256File(file);
230
+ if (digest !== sha256) {
231
+ throw new EverythingError(
232
+ `everything_search setup: ${label} download failed integrity check (expected sha256 ${sha256}, got ${digest}); aborting install.`,
233
+ 'ES_SETUP_FAILED'
234
+ );
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Verify the Authenticode signature of a downloaded binary: must be Valid and
240
+ * signed by voidtools. Used for upstream releases newer than the recorded
241
+ * known-good pin, where no SHA256 anchor exists.
242
+ */
243
+ export async function verifySignature(file, label) {
244
+ // Get-AuthenticodeSignature first; if its module fails to load (polluted
245
+ // PSModulePath can break 5.1 type-data loading), fall back to raw .NET:
246
+ // extract the signing cert and build a chain to a trusted root offline.
247
+ const script = [
248
+ `$f = '${file.replace(/'/g, "''")}'`,
249
+ `$sig = $null`,
250
+ `try { $sig = Get-AuthenticodeSignature -LiteralPath $f -ErrorAction Stop } catch {}`,
251
+ `if ($sig -and $sig.Status -eq 'Valid' -and $sig.SignerCertificate -and $sig.SignerCertificate.Subject -match 'CN=voidtools') { 'OK'; exit 0 }`,
252
+ `try {`,
253
+ ` $raw = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($f)`,
254
+ ` $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(, $raw)`,
255
+ ` $chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain`,
256
+ ` $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Offline`,
257
+ ` $ok = $chain.Build($cert)`,
258
+ ` "CHAIN=$ok|$($cert.Subject)"`,
259
+ `} catch { "CHAIN=False|$($_.Exception.Message)" }`,
260
+ ].join('; ');
261
+ let out;
262
+ try {
263
+ out = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 30000);
264
+ } catch (error) {
265
+ throw new EverythingError(
266
+ `everything_search setup: could not verify the signature of ${label}: ${error?.message ?? error}`,
267
+ 'ES_SETUP_FAILED'
268
+ );
269
+ }
270
+ const line = out.trim().split(/\r?\n/).pop() ?? '';
271
+ if (line === 'OK') return;
272
+ const m = /^CHAIN=(True|False)\|(.*)$/i.exec(line);
273
+ if (m && m[1].toLowerCase() === 'true' && SIGNER_MATCH.test(m[2])) return;
274
+ throw new EverythingError(
275
+ `everything_search setup: ${label} failed signature verification (${line || 'no output'}; expected a valid voidtools signature); aborting install.`,
276
+ 'ES_SETUP_FAILED'
277
+ );
278
+ }
279
+
280
+ async function fetchTextViaFetch(url, timeoutMs) {
281
+ const controller = new AbortController();
282
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
283
+ try {
284
+ const response = await fetch(url, { signal: controller.signal, redirect: 'follow' });
285
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
286
+ return await response.text();
287
+ } finally {
288
+ clearTimeout(timer);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Fetch a text resource (the downloads page) with the same posture as
294
+ * downloadFile: direct first, proxy only as a fallback.
295
+ */
296
+ export async function fetchText(url, { timeoutMs = 60000, proxy } = {}) {
297
+ if (proxy === undefined) proxy = resolveProxy() ?? (await detectSystemProxy());
298
+ const attempts = [
299
+ ['fetch', () => fetchTextViaFetch(url, timeoutMs)],
300
+ ['curl', async () => run('curl.exe', ['-fsSL', '--max-time', String(Math.ceil(timeoutMs / 1000)), url], timeoutMs + 15000)],
301
+ ];
302
+ if (proxy) {
303
+ attempts.push(['curl-proxy', async () => run('curl.exe', ['-fsSL', '--max-time', String(Math.ceil(timeoutMs / 1000)), '--proxy', proxy, url], timeoutMs + 15000)]);
304
+ }
305
+ attempts.push([
306
+ 'powershell',
307
+ async () => run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Invoke-WebRequest -Uri '${url.replace(/'/g, "''")}' -UseBasicParsing -Headers @{ 'User-Agent' = 'Mozilla/5.0' }).Content`], timeoutMs + 15000),
308
+ ]);
309
+ let lastError = null;
310
+ for (const [, fn] of attempts) {
311
+ try {
312
+ const text = await fn();
313
+ if (typeof text === 'string' && text.length > 0) return text;
314
+ } catch (error) {
315
+ lastError = error;
316
+ }
317
+ }
318
+ throw new EverythingError(
319
+ `everything_search setup: could not fetch ${url}: ${lastError?.message ?? lastError}`,
320
+ lastError instanceof EverythingError ? lastError.code : 'ES_SETUP_FAILED'
321
+ );
322
+ }
323
+
324
+ async function downloadViaFetch(url, dest, timeoutMs) {
325
+ const controller = new AbortController();
326
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
327
+ try {
328
+ const response = await fetch(url, { signal: controller.signal, redirect: 'follow' });
329
+ if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
330
+ const chunks = [];
331
+ const reader = response.body.getReader();
332
+ for (;;) {
333
+ const { done, value } = await reader.read();
334
+ if (done) break;
335
+ chunks.push(Buffer.from(value));
336
+ }
337
+ const buf = Buffer.concat(chunks);
338
+ await new Promise((resolveWrite, rejectWrite) => {
339
+ const ws = createWriteStream(dest);
340
+ ws.on('error', rejectWrite);
341
+ ws.on('finish', resolveWrite);
342
+ ws.end(buf);
343
+ });
344
+ } finally {
345
+ clearTimeout(timer);
346
+ }
347
+ }
348
+
349
+ async function downloadViaCurl(url, dest, timeoutMs, proxy) {
350
+ const argv = ['-fSL', '--connect-timeout', '30', '--max-time', String(Math.ceil(timeoutMs / 1000)), '-o', dest];
351
+ if (proxy) argv.push('--proxy', proxy);
352
+ argv.push(url);
353
+ await run('curl.exe', argv, timeoutMs + 15000);
354
+ }
355
+
356
+ async function downloadViaPowerShell(url, dest, timeoutMs) {
357
+ const escapedUrl = url.replace(/'/g, "''");
358
+ const escapedDest = dest.replace(/'/g, "''");
359
+ const script = `Invoke-WebRequest -Uri '${escapedUrl}' -OutFile '${escapedDest}' -UseBasicParsing -Headers @{ 'User-Agent' = 'Mozilla/5.0' }`;
360
+ await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], timeoutMs + 15000);
361
+ }
362
+
363
+ /**
364
+ * Download with layered fallbacks. DIRECT is always tried first (most users
365
+ * have no proxy, and a stale registry proxy must not break them): fetch →
366
+ * curl.exe direct → curl.exe through the detected proxy (only when one is
367
+ * configured) → PowerShell Invoke-WebRequest (honours the system proxy).
368
+ * When sha256 is given the file is hash-verified. When it is null (a newer
369
+ * upstream release) no in-band check is possible here — callers must verify
370
+ * the voidtools Authenticode signature of the resulting binary instead
371
+ * (archives cannot be Authenticode-signed). Returns the strategy that worked.
372
+ */
373
+ export async function downloadFile(url, dest, { timeoutMs = DOWNLOAD_TIMEOUT_MS, proxy, sha256 = null, label = 'file' } = {}) {
374
+ if (proxy === undefined) proxy = resolveProxy() ?? (await detectSystemProxy());
375
+ mkdirSync(win32path.dirname(dest), { recursive: true });
376
+ const attempts = [
377
+ ['fetch', () => downloadViaFetch(url, dest, timeoutMs)],
378
+ ['curl', () => downloadViaCurl(url, dest, timeoutMs, null)],
379
+ ];
380
+ if (proxy) attempts.push(['curl-proxy', () => downloadViaCurl(url, dest, timeoutMs, proxy)]);
381
+ attempts.push(['powershell', () => downloadViaPowerShell(url, dest, timeoutMs)]);
382
+ let lastError = null;
383
+ for (const [name, fn] of attempts) {
384
+ try {
385
+ await fn();
386
+ if (sha256) await verifyHash(dest, sha256, label);
387
+ return name;
388
+ } catch (error) {
389
+ lastError = error;
390
+ }
391
+ }
392
+ throw new EverythingError(
393
+ `everything_search setup: could not download ${label} from ${url}: ${lastError?.message ?? lastError}`,
394
+ lastError instanceof EverythingError ? lastError.code : 'ES_SETUP_FAILED'
395
+ );
396
+ }
397
+
398
+ async function expandZip(zipPath, destDir) {
399
+ const escapedZip = zipPath.replace(/'/g, "''");
400
+ const escapedDir = destDir.replace(/'/g, "''");
401
+ await run(
402
+ 'powershell.exe',
403
+ ['-NoProfile', '-NonInteractive', '-Command', `Expand-Archive -LiteralPath '${escapedZip}' -DestinationPath '${escapedDir}' -Force`],
404
+ 60000
405
+ );
406
+ }
407
+
408
+ export function findEverythingInstall() {
409
+ for (const candidate of EVERYTHING_INSTALL_CANDIDATES) {
410
+ if (existsSync(candidate)) return candidate;
411
+ }
412
+ return null;
413
+ }
414
+
415
+ async function installEverything(installDir, log, proxy) {
416
+ // Strategy 1: winget (present on most Windows 10/11 desktops).
417
+ try {
418
+ log?.info?.('everything_search: installing Everything via winget (a UAC prompt may appear)…');
419
+ await run('winget', buildWingetInstallArgs(), WINGET_TIMEOUT_MS);
420
+ const found = findEverythingInstall();
421
+ if (found) return { everythingPath: found, via: 'winget' };
422
+ } catch (error) {
423
+ log?.warn?.(`everything_search: winget install failed (${error?.message ?? error}); falling back to the official setup.`);
424
+ }
425
+ // Strategy 2: latest official NSIS setup (resolved from the downloads
426
+ // page), silent. One UAC prompt.
427
+ const artifact = await resolveArtifact('everything', { proxy, log });
428
+ const setupExe = win32path.join(installDir, 'dl', `Everything-${artifact.version}.x64-Setup.exe`);
429
+ log?.info?.(`everything_search: downloading the official Everything ${artifact.version} setup…`);
430
+ await downloadFile(artifact.url, setupExe, { proxy, sha256: artifact.sha256, label: 'Everything setup' });
431
+ if (!artifact.sha256) await verifySignature(setupExe, 'Everything setup');
432
+ log?.info?.('everything_search: running the official Everything setup silently (a UAC prompt may appear)…');
433
+ await run(setupExe, buildSetupSilentArgs(), SETUP_TIMEOUT_MS);
434
+ const found = findEverythingInstall();
435
+ if (!found) {
436
+ throw new EverythingError(
437
+ 'everything_search setup: the Everything installer finished but Everything.exe was not found in the standard locations. Install it manually from https://www.voidtools.com/ and set everythingPath in the plugin config.',
438
+ 'ES_SETUP_FAILED'
439
+ );
440
+ }
441
+ return { everythingPath: found, via: 'setup' };
442
+ }
443
+
444
+ async function provisionEs(installDir, log, proxy) {
445
+ const binDir = win32path.join(installDir, 'bin');
446
+ mkdirSync(binDir, { recursive: true });
447
+ const artifact = await resolveArtifact('es', { proxy, log });
448
+ const zipPath = win32path.join(installDir, 'dl', 'es.zip');
449
+ log?.info?.(`everything_search: downloading ES CLI ${artifact.version}…`);
450
+ await downloadFile(artifact.url, zipPath, { proxy, sha256: artifact.sha256, label: 'ES CLI' });
451
+ await expandZip(zipPath, binDir);
452
+ const esPath = win32path.join(binDir, 'es.exe');
453
+ if (!artifact.sha256) await verifySignature(esPath, 'ES CLI');
454
+ if (!existsSync(esPath)) {
455
+ throw new EverythingError('everything_search setup: es.exe missing after extracting the ES archive.', 'ES_SETUP_FAILED');
456
+ }
457
+ return esPath;
458
+ }
459
+
460
+ /**
461
+ * Make sure both halves of the backend exist. Returns the resolved paths and
462
+ * which components were installed this run. The `probe` callback (injected by
463
+ * index.js) runs `es.exe -get-everything-version` to confirm the whole chain.
464
+ */
465
+ export async function ensureBackend({ config = {}, log = null, proxy, probe = null } = {}) {
466
+ const installed = [];
467
+ const installDir = resolveInstallDir(config);
468
+
469
+ let esPath = typeof config.esPath === 'string' && config.esPath.trim().length > 0 ? config.esPath.trim() : null;
470
+ if (esPath && !existsSync(esPath)) esPath = null;
471
+ if (!esPath) {
472
+ const provisioned = win32path.join(installDir, 'bin', 'es.exe');
473
+ if (existsSync(provisioned)) {
474
+ esPath = provisioned;
475
+ // Self-update: our own copy may lag behind the pinned version (e.g. it
476
+ // was installed by an older plugin release). Only the self-installed
477
+ // copy is touched — user-managed installs are never upgraded by us.
478
+ try {
479
+ const installedVersion = await getExeFileVersion(provisioned);
480
+ if (installedVersion.length > 0 && compareVersions(installedVersion, PINS.es.version) < 0) {
481
+ log?.info?.(`everything_search: self-installed ES CLI ${installedVersion} is older than ${PINS.es.version}; upgrading…`);
482
+ esPath = await provisionEs(installDir, log, proxy);
483
+ installed.push('es-cli-upgrade');
484
+ }
485
+ } catch (error) {
486
+ log?.warn?.(`everything_search: could not check the ES CLI version (${error?.message ?? error}); keeping the current copy.`);
487
+ }
488
+ } else {
489
+ esPath = await provisionEs(installDir, log, proxy);
490
+ installed.push('es-cli');
491
+ }
492
+ }
493
+
494
+ let everythingPath =
495
+ typeof config.everythingPath === 'string' && config.everythingPath.trim().length > 0 ? config.everythingPath.trim() : null;
496
+ if (everythingPath && !existsSync(everythingPath)) everythingPath = null;
497
+ if (!everythingPath) everythingPath = findEverythingInstall();
498
+ if (!everythingPath) {
499
+ const result = await installEverything(installDir, log, proxy);
500
+ everythingPath = result.everythingPath;
501
+ installed.push(`everything-${result.via}`);
502
+ }
503
+
504
+ if (probe) {
505
+ try {
506
+ await probe(esPath, everythingPath);
507
+ } catch (error) {
508
+ if (error instanceof EverythingError && error.code === 'ES_NOT_RUNNING') {
509
+ // Installed but not started yet — index.js autoStart handles this path.
510
+ } else {
511
+ throw error;
512
+ }
513
+ }
514
+ }
515
+
516
+ return { esPath, everythingPath, installDir, installed };
517
+ }
518
+
519
+ /** Fire a detached, unref'd process. Used to (re)start Everything.exe. */
520
+ export function launchDetached(binaryPath, argv) {
521
+ spawn(binaryPath, argv, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
522
+ }