dsh-everything 1.3.0 → 1.4.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/CHANGELOG.md +51 -0
- package/README.md +22 -7
- package/README.zh-CN.md +22 -9
- package/cordis.patch.yml +4 -0
- package/index.js +482 -29
- package/package.json +1 -1
- package/setup.js +153 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,57 @@
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [1.4.0] - 2026-10-27
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **HTTP fast channel**: searches are now served by Everything's built-in
|
|
10
|
+
HTTP JSON server on `127.0.0.1` when it is available — no per-call
|
|
11
|
+
`es.exe` spawn, native UTF-8, and exact totals for free. The plugin
|
|
12
|
+
auto-enables the server itself (Everything ships it disabled AND
|
|
13
|
+
policy-locked: `allow_http_server=0` in the ini next to
|
|
14
|
+
`Everything.exe`; the GUI cannot unlock it) by patching both
|
|
15
|
+
`Everything.ini` files, binding loopback only, and restarting the GUI
|
|
16
|
+
once per session (`httpAutoEnable`, default on; `httpPort` default 5151,
|
|
17
|
+
`0` disables; optional Basic auth via `httpUsername`/`httpPassword`).
|
|
18
|
+
The `es.exe` CLI remains the always-available fallback and covers
|
|
19
|
+
`dateCreated`, the extra sort keys, and `regex` + `path`/type-filter
|
|
20
|
+
combinations the HTTP API cannot express.
|
|
21
|
+
- `exactTotal` (default `true`): set `false` to skip the extra
|
|
22
|
+
match-count round-trip on full pages; the total then reports as a
|
|
23
|
+
page-based lower bound (`≥N`, `totalExact: false`).
|
|
24
|
+
- Results now carry `channel` (`"http"`/`"es"`), `totalExact`, and `notes`
|
|
25
|
+
(non-fatal query-interpretation remarks) so behavior is observable.
|
|
26
|
+
- A whole-query quoted phrase containing spaces (an exact multi-word
|
|
27
|
+
match) is automatically converted to an equivalent regex — previously
|
|
28
|
+
it degraded to ANDed words silently.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- `readExportText` no longer blocks the event loop while waiting out the
|
|
32
|
+
es.exe export-file flush race (async retry instead of `Atomics.wait`),
|
|
33
|
+
and a genuinely empty/missing export file now falls back to decoded
|
|
34
|
+
stdout instead of reporting a fake empty result page.
|
|
35
|
+
- A `path` that does not exist (or points at a file) is rejected up front
|
|
36
|
+
with `ES_INVALID_ARGS` instead of silently returning "no matches"
|
|
37
|
+
(UNC paths are checked after a zero-result page).
|
|
38
|
+
- `truncated` is `true` whenever a full page is returned with an
|
|
39
|
+
inexact total, not only when the exact count exceeds the page.
|
|
40
|
+
|
|
41
|
+
## [1.3.1] - 2026-10-27
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- Multi-word queries (`*.docx dm:thisweek`) silently returned 0 results: the
|
|
45
|
+
whole query string was handed to es.exe as ONE argument, which Everything
|
|
46
|
+
then matched literally. Queries are now tokenized on whitespace (outside
|
|
47
|
+
double quotes) and each term is passed as its own argument, restoring AND
|
|
48
|
+
semantics. Quoted phrases without spaces keep their phrase meaning; quoted
|
|
49
|
+
phrases containing spaces degrade to ANDed words (es.exe cannot express
|
|
50
|
+
them positionally — use `regex=true` for exact multi-word matching).
|
|
51
|
+
- Non-ASCII file names came back as mojibake (`?????`): es.exe pipes results
|
|
52
|
+
in the console/ANSI codepage, not UTF-8. Results are now read from es.exe's
|
|
53
|
+
UTF-8 `-export-json` file instead, so Chinese/Japanese/emoji names survive
|
|
54
|
+
the round trip on every codepage (with a decoded-stdout fallback for es.exe
|
|
55
|
+
builds without `-export-json`).
|
|
56
|
+
|
|
6
57
|
## [1.3.0] - 2026-10-26
|
|
7
58
|
|
|
8
59
|
### Added
|
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[中文文档](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that gives the agent an **`everything_search`** tool: whole-disk file and folder search in **milliseconds**, powered by the local [Everything](https://www.voidtools.com/) (voidtools) index
|
|
5
|
+
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that gives the agent an **`everything_search`** tool: whole-disk file and folder search in **milliseconds**, powered by the local [Everything](https://www.voidtools.com/) (voidtools) index.
|
|
6
6
|
|
|
7
|
-
Instead of recursively scanning directories (glob) when the model needs to locate a file, the agent asks Everything's pre-built index — instant even on drives with millions of files.
|
|
7
|
+
Instead of recursively scanning directories (glob) when the model needs to locate a file, the agent asks Everything's pre-built index — instant even on drives with millions of files. Two channels are used: Everything's built-in HTTP JSON server (loopback only, auto-enabled — no process spawn per call, native UTF-8, exact totals) with the official command line `es.exe` as an always-available fallback for everything the HTTP server cannot express.
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
@@ -37,16 +37,18 @@ Start-Process "C:\Program Files\Everything\Everything.exe"
|
|
|
37
37
|
In a DSH session, ask the agent or call the plugin manager:
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
plugin_manager install_bundle with target =
|
|
40
|
+
plugin_manager install_bundle with target = dsh-everything
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
The plugin activates as `dsh-everything` and registers the `everything_search` tool for every agent in the profile.
|
|
44
44
|
|
|
45
|
+
Published on npm as [`dsh-everything`](https://www.npmjs.com/package/dsh-everything); installing from the package directory works too.
|
|
46
|
+
|
|
45
47
|
## Tool: `everything_search`
|
|
46
48
|
|
|
47
49
|
| Parameter | Type | Description |
|
|
48
50
|
|---|---|---|
|
|
49
|
-
| `query` *(required)* | string | Everything search expression: `report ext:docx`, `*.sln dm:thisweek`, `path:\projects\ ext:py`. Space = AND, `\|` = OR, `!` = NOT
|
|
51
|
+
| `query` *(required)* | string | Everything search expression: `report ext:docx`, `*.sln dm:thisweek`, `path:\projects\ ext:py`. Space = AND, `\|` = OR, `!` = NOT. Multi-word queries are split into es.exe terms automatically. |
|
|
50
52
|
| `path` | string | Restrict to a directory and its subtree (absolute path). |
|
|
51
53
|
| `maxResults` | integer | Result cap (default 100, ceiling 1000). |
|
|
52
54
|
| `offset` | integer | Skip leading results (pagination). |
|
|
@@ -57,11 +59,15 @@ The plugin activates as `dsh-everything` and registers the `everything_search` t
|
|
|
57
59
|
| `foldersOnly` / `filesOnly` | boolean | Restrict result type. |
|
|
58
60
|
| `dateCreated` | boolean | Also return each result's creation date. |
|
|
59
61
|
| `countOnly` | boolean | Return only the total match count — the fastest way to check existence or estimate volume. |
|
|
62
|
+
| `exactTotal` | boolean | Default `true`. `false` skips the extra match-count round-trip on full pages and reports a `≥N` total instead. |
|
|
60
63
|
|
|
61
|
-
Returns `{ query, total, truncated, results: [{ path, size, dateModified, dateCreated? }] }` and renders in the chat as a search-paths card.
|
|
64
|
+
Returns `{ query, total, totalExact, truncated, results: [{ path, size, dateModified, dateCreated? }], channel, notes }` (`channel` is `"http"` or `"es"`; `notes` carries query-interpretation remarks) and renders in the chat as a search-paths card.
|
|
62
65
|
|
|
63
66
|
Typical queries: `report ext:docx` · `*.sln dm:thisweek` · `size:>500mb` · `notes !archive` · recently changed files with `sort: date-modified-descending` + `dm:thisweek`.
|
|
64
67
|
|
|
68
|
+
> **Phrase quirks:** a quoted phrase only matches the *name* part, so `"report.docx"` matches nothing — pass it unquoted. A quoted phrase **containing spaces that is the whole query** (an exact multi-word match) is automatically converted to an equivalent regex; one embedded alongside other terms degrades to ANDed words and is reported in `notes` (use `regex=true` for those).
|
|
69
|
+
> **Encoding:** the HTTP channel is UTF-8 natively; the es.exe fallback reads its UTF-8 `-export-json` output — non-ASCII file names (Chinese, Japanese, emoji, …) survive the round trip intact either way.
|
|
70
|
+
|
|
65
71
|
> **Scope note:** searches names and paths only, never file contents — content search stays with `grep`.
|
|
66
72
|
> **Coverage note:** results follow the local Everything index — NTFS/reFS drives and folders explicitly added to the index. FAT/exFAT removable drives and network shares are typically not covered.
|
|
67
73
|
|
|
@@ -77,11 +83,20 @@ Edit the plugin's `config` in `cordis.patch.yml` (or your profile's patch layer)
|
|
|
77
83
|
| `autoInstall` | `true` | Download & install Everything + ES CLI when missing (see above). |
|
|
78
84
|
| `installDir` | `%LOCALAPPDATA%\dsh-everything` | Where the auto-installed ES CLI lives. |
|
|
79
85
|
| `maxResults` | `100` | Default per-call result cap. |
|
|
80
|
-
| `timeoutMs` | `8000` | Per-
|
|
86
|
+
| `timeoutMs` | `8000` | Per-call timeout (ms). |
|
|
87
|
+
| `httpPort` | `5151` | Everything HTTP server port for the fast channel; `0` disables the HTTP channel entirely. |
|
|
88
|
+
| `httpAutoEnable` | `true` | When the HTTP server is down, once per session: patch Everything's own config (unlock + enable + loopback bind) and restart its GUI. Set `false` to never touch Everything's config. |
|
|
89
|
+
| `httpUsername` / `httpPassword` | `''` | Optional Basic-auth credentials for the HTTP server. |
|
|
90
|
+
|
|
91
|
+
### Fast path: the built-in HTTP server
|
|
92
|
+
|
|
93
|
+
Everything 1.4 ships with an HTTP JSON server, but it is **disabled and policy-locked by default** — installed builds write `allow_http_server=0` next to `Everything.exe`, and the GUI cannot unlock it. With `httpAutoEnable: true` the plugin flips that switch itself (one-time, once per session): it patches both `Everything.ini` files, binds the server to **127.0.0.1 only**, restarts the Everything GUI, and routes searches through it. The background index service keeps running untouched.
|
|
94
|
+
|
|
95
|
+
The HTTP channel serves plain searches, `path`/`foldersOnly`/`filesOnly` scope, `countOnly`, pagination, and the `name`/`path`/`size`/`date-modified` sort keys. Everything else — `dateCreated`, other sort keys, and `regex` combined with a `path` or type filter — automatically uses the es.exe channel instead. If the HTTP server cannot be started (port busy, Everything not installed yet), the plugin silently stays on es.exe and retries later; a `path` that does not exist is reported as an error rather than an empty result.
|
|
81
96
|
|
|
82
97
|
## Privacy
|
|
83
98
|
|
|
84
|
-
The search scope is **every indexed drive** on the machine, and result paths are sent to the model as tool output. Keep that in mind on machines with sensitive directory layouts; use `path` to scope searches.
|
|
99
|
+
The search scope is **every indexed drive** on the machine, and result paths are sent to the model as tool output. Keep that in mind on machines with sensitive directory layouts; use `path` to scope searches. The HTTP fast path listens on `127.0.0.1` only — nothing is exposed to the network.
|
|
85
100
|
|
|
86
101
|
## Development
|
|
87
102
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md)
|
|
4
4
|
|
|
5
|
-
一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)插件,为模型注册 **`everything_search`** 工具:借助本机 [Everything](https://www.voidtools.com/)(voidtools
|
|
5
|
+
一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)插件,为模型注册 **`everything_search`** 工具:借助本机 [Everything](https://www.voidtools.com/)(voidtools)索引,实现**毫秒级**全盘文件/文件夹搜索。
|
|
6
6
|
|
|
7
|
-
当模型需要找文件时,不再递归遍历目录(glob),而是直接查询 Everything
|
|
7
|
+
当模型需要找文件时,不再递归遍历目录(glob),而是直接查询 Everything 预先建好的索引——即使磁盘上有数百万文件也是秒回。内部走两条通道:优先用 Everything 内置的 HTTP JSON 服务(仅绑定 127.0.0.1,插件会自动启用——每次调用无需再拉起进程、原生 UTF-8、精确总数免费自带),官方命令行 `es.exe` 作为兜底,覆盖 HTTP 服务表达不了的一切查询。
|
|
8
8
|
|
|
9
9
|
## 环境要求
|
|
10
10
|
|
|
@@ -34,19 +34,19 @@ Start-Process "C:\Program Files\Everything\Everything.exe"
|
|
|
34
34
|
|
|
35
35
|
## 安装
|
|
36
36
|
|
|
37
|
-
在 DSH
|
|
37
|
+
在 DSH 会话中让插件管理器安装 npm 包名:
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
plugin_manager install_bundle,target =
|
|
40
|
+
plugin_manager install_bundle,target = dsh-everything
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
插件以 `dsh-everything` 激活,并为该 profile 下的所有 Agent 注册 `everything_search`
|
|
43
|
+
插件以 `dsh-everything` 激活,并为该 profile 下的所有 Agent 注册 `everything_search` 工具。已发布到 npm([`dsh-everything`](https://www.npmjs.com/package/dsh-everything)),直接用本包目录安装也可以。
|
|
44
44
|
|
|
45
45
|
## 工具:everything_search
|
|
46
46
|
|
|
47
47
|
| 参数 | 类型 | 说明 |
|
|
48
48
|
|---|---|---|
|
|
49
|
-
| `query` *(必填)* | string | Everything 搜索表达式:`report ext:docx`、`*.sln dm:thisweek`、`path:\projects\ ext:py`。空格 = 与,`\|` = 或,`!` =
|
|
49
|
+
| `query` *(必填)* | string | Everything 搜索表达式:`report ext:docx`、`*.sln dm:thisweek`、`path:\projects\ ext:py`。空格 = 与,`\|` = 或,`!` = 非。多词查询会自动拆分为 es.exe 的独立搜索词。 |
|
|
50
50
|
| `path` | string | 限定在某目录及其子目录内(绝对路径)。 |
|
|
51
51
|
| `maxResults` | integer | 返回条数上限(默认 100,封顶 1000)。 |
|
|
52
52
|
| `offset` | integer | 跳过前 N 条(分页)。 |
|
|
@@ -57,11 +57,15 @@ plugin_manager install_bundle,target = 本包目录的绝对路径
|
|
|
57
57
|
| `foldersOnly` / `filesOnly` | boolean | 只看文件夹 / 只看文件。 |
|
|
58
58
|
| `dateCreated` | boolean | 一并返回每个结果的创建时间。 |
|
|
59
59
|
| `countOnly` | boolean | 只返回匹配总数——判断存在性、估量级的最快方式。 |
|
|
60
|
+
| `exactTotal` | boolean | 默认 `true`。设 `false` 时整页结果不再额外做一次计数往返,总数显示为 `≥N` 下界。 |
|
|
60
61
|
|
|
61
|
-
返回 `{ query, total, truncated, results: [{ path, size, dateModified, dateCreated? }] }
|
|
62
|
+
返回 `{ query, total, totalExact, truncated, results: [{ path, size, dateModified, dateCreated? }], channel, notes }`(`channel` 为 `"http"` 或 `"es"`;`notes` 携带查询解释说明),在会话里以搜索路径卡片展示。
|
|
62
63
|
|
|
63
64
|
典型查询:`report ext:docx` · `*.sln dm:thisweek` · `size:>500mb` · `notes !archive` · 最近改动文件用 `sort: date-modified-descending` + `dm:thisweek`。
|
|
64
65
|
|
|
66
|
+
> **短语怪癖**:带引号的短语只匹配文件名的“名字部分”,所以 `"report.docx"` 永远搜不到——直接写不带引号的 `report.docx`。**整条查询**就是一个含空格的引号短语(精确多词匹配)时,插件会自动转换成等价正则;与其他词混用的引号短语会退化为逐词 AND 并在 `notes` 里说明(这类场景请显式用 `regex=true`)。
|
|
67
|
+
> **编码**:HTTP 通道原生 UTF-8;es.exe 兜底通道从其 UTF-8 `-export-json` 输出读取——无论哪条通道,非 ASCII 文件名(中文、日文、emoji 等)都完整回传、不再乱码。
|
|
68
|
+
|
|
65
69
|
> **范围说明**:只搜文件名和路径,不搜文件内容——内容搜索请继续用 `grep`。
|
|
66
70
|
> **覆盖说明**:结果取决于本机 Everything 索引——NTFS/reFS 磁盘与显式加入索引的目录;FAT/exFAT 可移动盘与网络共享通常不在覆盖范围。
|
|
67
71
|
|
|
@@ -77,11 +81,20 @@ plugin_manager install_bundle,target = 本包目录的绝对路径
|
|
|
77
81
|
| `autoInstall` | `true` | 后端缺失时自动下载并安装 Everything + ES CLI(见上文)。 |
|
|
78
82
|
| `installDir` | `%LOCALAPPDATA%\dsh-everything` | 自动安装的 ES CLI 存放目录。 |
|
|
79
83
|
| `maxResults` | `100` | 单次返回条数默认上限。 |
|
|
80
|
-
| `timeoutMs` | `8000` |
|
|
84
|
+
| `timeoutMs` | `8000` | 单次调用超时(毫秒)。 |
|
|
85
|
+
| `httpPort` | `5151` | 快速通道的 Everything HTTP 服务端口;设 `0` 完全关闭 HTTP 通道。 |
|
|
86
|
+
| `httpAutoEnable` | `true` | HTTP 服务未开启时,每会话一次:自动改写 Everything 自身配置(解锁+启用+仅回环绑定)并重启其 GUI。设 `false` 则永不触碰 Everything 的配置。 |
|
|
87
|
+
| `httpUsername` / `httpPassword` | `''` | HTTP 服务的可选 Basic 认证凭据。 |
|
|
88
|
+
|
|
89
|
+
### 快速通道:内置 HTTP 服务
|
|
90
|
+
|
|
91
|
+
Everything 1.4 自带 HTTP JSON 服务,但**默认是关闭且被策略锁死的**——安装版会在 `Everything.exe` 旁写入 `allow_http_server=0`,GUI 里也解不了锁。`httpAutoEnable: true`(默认)时插件会亲自翻开这个开关(每会话一次):同时补丁两份 `Everything.ini`、把服务**只绑定到 127.0.0.1**、重启 Everything GUI,之后搜索都走 HTTP。后台索引服务全程不受影响。
|
|
92
|
+
|
|
93
|
+
HTTP 通道覆盖:普通搜索、`path`/`foldersOnly`/`filesOnly` 范围限定、`countOnly`、分页,以及 `name`/`path`/`size`/`date-modified` 排序键。其余——`dateCreated`、其他排序键、`regex` 搭配 `path` 或类型过滤——自动改走 es.exe 通道。HTTP 服务起不来(端口被占、Everything 未安装)时插件静默留在 es.exe 通道、稍后重试;`path` 指向不存在的路径会直接报错,而不是返回空结果。
|
|
81
94
|
|
|
82
95
|
## 隐私
|
|
83
96
|
|
|
84
|
-
搜索范围是本机**所有已索引磁盘**,结果路径会作为工具输出发给模型。在目录结构敏感的机器上请注意,可用 `path` 参数收窄范围。
|
|
97
|
+
搜索范围是本机**所有已索引磁盘**,结果路径会作为工具输出发给模型。在目录结构敏感的机器上请注意,可用 `path` 参数收窄范围。HTTP 快速通道只监听 `127.0.0.1`——不向网络暴露任何东西。
|
|
85
98
|
|
|
86
99
|
## 开发
|
|
87
100
|
|
package/cordis.patch.yml
CHANGED
package/index.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-everything: model-facing `everything_search` tool backed by the local
|
|
3
|
-
* Everything (voidtools) index
|
|
3
|
+
* Everything (voidtools) index.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Two channels, tried in order:
|
|
6
|
+
* 1. Everything's built-in HTTP JSON server (127.0.0.1 only) — no process
|
|
7
|
+
* spawn per call, native UTF-8, exact totals for free. Auto-enabled from
|
|
8
|
+
* the plugin when httpAutoEnable is on (one Everything GUI restart).
|
|
9
|
+
* 2. The official ES command line (es.exe) — always available as fallback
|
|
10
|
+
* (and used for dateCreated, unsupported sort keys, and regex+scope
|
|
11
|
+
* combos). Results are read from a UTF-8 `-export-json` file, immune to
|
|
12
|
+
* console codepages.
|
|
13
|
+
* Results project onto the built-in search-paths card. No external npm
|
|
14
|
+
* dependencies. Windows only: Everything itself is a Windows application, so
|
|
15
|
+
* on other platforms a stub tool is registered that fails with a clear
|
|
16
|
+
* platform message instead of a confusing spawn error.
|
|
10
17
|
*
|
|
11
18
|
* Config (set in cordis.patch.yml):
|
|
12
19
|
* esPath absolute path to es.exe (default: well-known locations, then auto-install)
|
|
@@ -15,12 +22,18 @@
|
|
|
15
22
|
* autoInstall download & install Everything + ES CLI when missing (default: true)
|
|
16
23
|
* installDir where the auto-installed ES CLI lives (default: %LOCALAPPDATA%\dsh-everything)
|
|
17
24
|
* maxResults default per-call result cap (default: 100, ceiling: 1000)
|
|
18
|
-
* timeoutMs per-
|
|
25
|
+
* timeoutMs per-call timeout in milliseconds (default: 8000)
|
|
26
|
+
* httpPort Everything HTTP server port; 0 disables the HTTP channel (default: 5151)
|
|
27
|
+
* httpAutoEnable flip Everything's own config to start its HTTP server (default: true)
|
|
28
|
+
* httpUsername optional Basic-auth user for the Everything HTTP server (default: '')
|
|
29
|
+
* httpPassword optional Basic-auth password (default: '')
|
|
19
30
|
*/
|
|
20
31
|
import { execFile, spawn } from 'node:child_process';
|
|
21
|
-
import { existsSync } from 'node:fs';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
32
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
33
|
+
import { unlink } from 'node:fs/promises';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { join as joinPath, win32 as win32path } from 'node:path';
|
|
36
|
+
import { ensureBackend, ensureHttpServer, probeHttpPort, EverythingError } from './setup.js';
|
|
24
37
|
|
|
25
38
|
const DEFAULT_MAX_RESULTS = 100;
|
|
26
39
|
const DEFAULT_TIMEOUT_MS = 8000;
|
|
@@ -28,6 +41,11 @@ const MAX_RESULTS_CEILING = 1000;
|
|
|
28
41
|
const MAX_BUFFER = 32 * 1024 * 1024;
|
|
29
42
|
const AUTOSTART_WAIT_MS = 15000;
|
|
30
43
|
const SETUP_BUDGET_MS = 240000;
|
|
44
|
+
const DEFAULT_HTTP_PORT = 5151;
|
|
45
|
+
/** After an HTTP failure, stay on the es.exe channel for this long before probing again. */
|
|
46
|
+
const HTTP_RETRY_DOWN_MS = 30000;
|
|
47
|
+
/** Per-request cap for HTTP calls (hot latency is ~2ms; 5s covers worst cases). */
|
|
48
|
+
const HTTP_REQUEST_CAP_MS = 5000;
|
|
31
49
|
|
|
32
50
|
/** Fallback candidates when the patch config does not pin `esPath`. */
|
|
33
51
|
const ES_CANDIDATES = [
|
|
@@ -38,6 +56,9 @@ const ES_CANDIDATES = [
|
|
|
38
56
|
|
|
39
57
|
const SORT_NAME_RE = /^[a-z][a-z0-9-]*(-(ascending|descending))?$/i;
|
|
40
58
|
|
|
59
|
+
/** Sort keys the Everything HTTP server understands (es.exe accepts more). */
|
|
60
|
+
const HTTP_SORT_KEYS = { name: 'name', path: 'path', size: 'size', 'date-modified': 'date_modified' };
|
|
61
|
+
|
|
41
62
|
export { EverythingError };
|
|
42
63
|
|
|
43
64
|
function clampInt(value, min, max, fallback) {
|
|
@@ -64,7 +85,7 @@ function resolveEverythingPath(config, esPath) {
|
|
|
64
85
|
return 'Everything.exe';
|
|
65
86
|
}
|
|
66
87
|
|
|
67
|
-
/** Run es.exe once and resolve with its raw stdout. Errors are classified into actionable codes. */
|
|
88
|
+
/** Run es.exe once and resolve with its raw stdout Buffer. Errors are classified into actionable codes. */
|
|
68
89
|
function runEs(esPath, argv, timeoutMs, signal) {
|
|
69
90
|
return new Promise((resolve, reject) => {
|
|
70
91
|
let child;
|
|
@@ -72,7 +93,7 @@ function runEs(esPath, argv, timeoutMs, signal) {
|
|
|
72
93
|
child = execFile(
|
|
73
94
|
esPath,
|
|
74
95
|
argv,
|
|
75
|
-
{ timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true, signal },
|
|
96
|
+
{ timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true, signal, encoding: 'buffer' },
|
|
76
97
|
(error, stdout, stderr) => {
|
|
77
98
|
const stderrText = typeof stderr === 'string' ? stderr.trim() : '';
|
|
78
99
|
if (error) {
|
|
@@ -104,7 +125,7 @@ function runEs(esPath, argv, timeoutMs, signal) {
|
|
|
104
125
|
reject(new EverythingError('everything_search: Everything is not running.', 'ES_NOT_RUNNING'));
|
|
105
126
|
return;
|
|
106
127
|
}
|
|
107
|
-
resolve(
|
|
128
|
+
resolve(stdout); // Buffer (execFile encoding: 'buffer')
|
|
108
129
|
}
|
|
109
130
|
);
|
|
110
131
|
} catch (error) {
|
|
@@ -146,6 +167,119 @@ async function autoStartEverything(everythingPath, esPath, signal) {
|
|
|
146
167
|
throw new EverythingError('everything_search: Everything was started but did not answer within 15s; retry the search.', 'ES_NOT_RUNNING');
|
|
147
168
|
}
|
|
148
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Split an Everything search expression into es.exe search terms.
|
|
172
|
+
*
|
|
173
|
+
* es.exe takes each command-line argument as ONE search term; an argument
|
|
174
|
+
* that itself contains a space is matched literally and never matches, so
|
|
175
|
+
* `*.docx dm:thisweek` MUST be passed as two arguments. Quoted phrases
|
|
176
|
+
* without spaces keep their quotes (phrase semantics); a quoted phrase that
|
|
177
|
+
* contains a space cannot be expressed positionally, so it degrades to its
|
|
178
|
+
* component words ANDed together (report it so callers can switch to regex).
|
|
179
|
+
*
|
|
180
|
+
* Returns { terms, degraded } — `degraded` lists the quoted phrases that had
|
|
181
|
+
* to be split (only used for the user-facing note).
|
|
182
|
+
*/
|
|
183
|
+
function tokenizeQuery(query) {
|
|
184
|
+
const terms = [];
|
|
185
|
+
const degraded = [];
|
|
186
|
+
let current = '';
|
|
187
|
+
let inQuotes = false;
|
|
188
|
+
const flush = () => {
|
|
189
|
+
if (current.length === 0) return;
|
|
190
|
+
if (/\s/.test(current)) {
|
|
191
|
+
// Only reachable for a quoted phrase with internal spaces: es.exe cannot
|
|
192
|
+
// take it as one term, so degrade to its component words (quotes stripped).
|
|
193
|
+
const bare = current.replace(/"/g, '').trim();
|
|
194
|
+
if (bare.length > 0) degraded.push(bare);
|
|
195
|
+
for (const word of current.split(/\s+/)) {
|
|
196
|
+
const w = word.replace(/"/g, '');
|
|
197
|
+
if (w.length > 0) terms.push(w);
|
|
198
|
+
}
|
|
199
|
+
} else if (current.replace(/"/g, '').length > 0) {
|
|
200
|
+
terms.push(current);
|
|
201
|
+
}
|
|
202
|
+
current = '';
|
|
203
|
+
};
|
|
204
|
+
for (const ch of query) {
|
|
205
|
+
if (ch === '"') {
|
|
206
|
+
inQuotes = !inQuotes;
|
|
207
|
+
current += ch;
|
|
208
|
+
} else if (!inQuotes && /\s/.test(ch)) {
|
|
209
|
+
flush();
|
|
210
|
+
} else {
|
|
211
|
+
current += ch;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
flush();
|
|
215
|
+
return { terms, degraded };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pre-process a raw query. A quoted phrase WITH spaces that is the whole
|
|
220
|
+
* query (the only exact-phrase intent es.exe cannot express) is converted to
|
|
221
|
+
* an equivalent regex (`word\s+word`), because both channels accept regex.
|
|
222
|
+
*/
|
|
223
|
+
function prepareQuery(query) {
|
|
224
|
+
const trimmed = query.trim();
|
|
225
|
+
const m = /^"([^"]*\s[^"]*)"$/.exec(trimmed);
|
|
226
|
+
if (m) {
|
|
227
|
+
return {
|
|
228
|
+
query: phraseToRegex(m[1]),
|
|
229
|
+
regex: true,
|
|
230
|
+
note: `exact phrase "${m[1]}" was converted to a regular expression (quoted phrases with spaces are not expressible otherwise)`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { query: trimmed, regex: false, note: null };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Escape each word and join with `\s+` — a regex equivalent of a phrase. */
|
|
237
|
+
function phraseToRegex(phrase) {
|
|
238
|
+
const escapeRe = /[.*+?^${}()|[\]\\]/g;
|
|
239
|
+
return phrase
|
|
240
|
+
.split(/\s+/)
|
|
241
|
+
.filter((w) => w.length > 0)
|
|
242
|
+
.map((w) => w.replace(escapeRe, '\\$&'))
|
|
243
|
+
.join('\\s+');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Decode es.exe stdout: it emits the console/ANSI codepage, not UTF-8. */
|
|
247
|
+
function decodeEsStdout(buf) {
|
|
248
|
+
if (!buf || buf.length === 0) return '';
|
|
249
|
+
const utf8 = buf.toString('utf8');
|
|
250
|
+
if (!utf8.includes('\uFFFD')) return utf8;
|
|
251
|
+
try {
|
|
252
|
+
return new TextDecoder('gbk').decode(buf);
|
|
253
|
+
} catch {
|
|
254
|
+
return utf8;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let exportFileCounter = 0;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Read the UTF-8 file written by `es.exe -export-json <file>`.
|
|
262
|
+
* The file occasionally exists but is still empty right after the process
|
|
263
|
+
* exits (flush race), so an empty read is retried briefly (async, never
|
|
264
|
+
* blocking the event loop).
|
|
265
|
+
* Returns { exists, text } — `exists: false` (or empty text) lets the caller
|
|
266
|
+
* fall back to decoding stdout (e.g. an es.exe build that ignores
|
|
267
|
+
* -export-json).
|
|
268
|
+
*/
|
|
269
|
+
async function readExportText(filePath) {
|
|
270
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
271
|
+
try {
|
|
272
|
+
const text = readFileSync(filePath, 'utf8');
|
|
273
|
+
if (text.length > 0) return { exists: true, text: text.replace(/^\uFEFF/, '') };
|
|
274
|
+
} catch {
|
|
275
|
+
return { exists: false, text: '' };
|
|
276
|
+
}
|
|
277
|
+
// exists but empty: brief wait for the flush race
|
|
278
|
+
await sleep(100);
|
|
279
|
+
}
|
|
280
|
+
return { exists: true, text: '' };
|
|
281
|
+
}
|
|
282
|
+
|
|
149
283
|
/**
|
|
150
284
|
* Shared search flag vector (everything except output formatting and result
|
|
151
285
|
* window). NOTE: `-regex` consumes the search text as its own value, so the
|
|
@@ -166,7 +300,13 @@ function buildSearchFlags(args, query) {
|
|
|
166
300
|
argv.push('-sort', sort);
|
|
167
301
|
}
|
|
168
302
|
if (args.regex === true) argv.push('-regex', query);
|
|
169
|
-
else
|
|
303
|
+
else {
|
|
304
|
+
const { terms } = tokenizeQuery(query);
|
|
305
|
+
if (terms.length === 0) {
|
|
306
|
+
throw new EverythingError('everything_search: "query" must contain at least one search term (not only quotes or whitespace).', 'ES_INVALID_ARGS');
|
|
307
|
+
}
|
|
308
|
+
argv.push(...terms);
|
|
309
|
+
}
|
|
170
310
|
return argv;
|
|
171
311
|
}
|
|
172
312
|
|
|
@@ -192,6 +332,98 @@ function parseResults(stdout) {
|
|
|
192
332
|
return results;
|
|
193
333
|
}
|
|
194
334
|
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// HTTP channel (Everything's built-in JSON server on 127.0.0.1)
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/** Map our sort names onto HTTP params. null = this sort must use es.exe. */
|
|
340
|
+
function httpSortParams(sort) {
|
|
341
|
+
if (typeof sort !== 'string' || sort.trim().length === 0) return {};
|
|
342
|
+
const s = sort.trim();
|
|
343
|
+
const key = s.replace(/-(ascending|descending)$/i, '');
|
|
344
|
+
const httpKey = HTTP_SORT_KEYS[key];
|
|
345
|
+
if (!httpKey) return null;
|
|
346
|
+
return { sort: httpKey, ascending: /-descending$/i.test(s) ? 0 : 1 };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* URL parameters for the Everything HTTP server (pure, exported for tests).
|
|
351
|
+
* Scope (`path:`) and type (`folder:`/`file:`) filters are folded into the
|
|
352
|
+
* search text — the HTTP API has no dedicated parameters for them. NOTE: in
|
|
353
|
+
* regex mode Everything does not interpret those prefixes, so the caller
|
|
354
|
+
* routes regex+scope combos to es.exe instead.
|
|
355
|
+
*/
|
|
356
|
+
function httpSearchParams(input, query, { limit, offset }) {
|
|
357
|
+
let search = query;
|
|
358
|
+
if (input.foldersOnly === true) search = `folder:${search}`;
|
|
359
|
+
else if (input.filesOnly === true) search = `file:${search}`;
|
|
360
|
+
if (typeof input.path === 'string' && input.path.trim().length > 0) search += ` path:"${input.path.trim()}"`;
|
|
361
|
+
const params = {
|
|
362
|
+
search,
|
|
363
|
+
json: '1',
|
|
364
|
+
count: String(input.countOnly === true ? 1 : limit),
|
|
365
|
+
offset: String(offset),
|
|
366
|
+
// JSON mode defaults count to 4294967295 — always pass it explicitly. The
|
|
367
|
+
// *_column switches make results carry the fields we project.
|
|
368
|
+
path_column: '1',
|
|
369
|
+
size_column: '1',
|
|
370
|
+
date_modified_column: '1',
|
|
371
|
+
};
|
|
372
|
+
if (input.regex === true) params.regex = '1';
|
|
373
|
+
if (input.matchCase === true) params.case = '1';
|
|
374
|
+
if (input.wholeWord === true) params.wholeword = '1';
|
|
375
|
+
const sort = httpSortParams(input.sort);
|
|
376
|
+
if (sort && sort.sort) {
|
|
377
|
+
params.sort = sort.sort;
|
|
378
|
+
params.ascending = String(sort.ascending);
|
|
379
|
+
}
|
|
380
|
+
return params;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Convert a Windows FILETIME string (> 2^53, needs BigInt) into the same
|
|
385
|
+
* local "YYYY-MM-DDTHH:MM:SS" rendering es.exe -date-format 1 produces.
|
|
386
|
+
* Returns undefined on garbage input.
|
|
387
|
+
*/
|
|
388
|
+
function fileTimeToLocalIso(fileTime) {
|
|
389
|
+
try {
|
|
390
|
+
const n = typeof fileTime === 'bigint' ? fileTime : BigInt(fileTime);
|
|
391
|
+
const ms = Number((n - 116444736000000000n) / 10000n);
|
|
392
|
+
const d = new Date(ms);
|
|
393
|
+
if (Number.isNaN(d.getTime())) return undefined;
|
|
394
|
+
const p = (x) => String(x).padStart(2, '0');
|
|
395
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
396
|
+
} catch {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** fetch with a timeout, honoring the caller's abort signal. */
|
|
402
|
+
async function fetchHttp(url, { headers, signal, timeoutMs }) {
|
|
403
|
+
const ctrl = new AbortController();
|
|
404
|
+
const onAbort = () => ctrl.abort();
|
|
405
|
+
let timer = null;
|
|
406
|
+
try {
|
|
407
|
+
if (signal) {
|
|
408
|
+
if (signal.aborted) throw new EverythingError('everything_search: the call was aborted (tool timeout or caller cancellation).', 'ES_ABORTED');
|
|
409
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
410
|
+
}
|
|
411
|
+
timer = setTimeout(() => ctrl.abort(new Error('timeout')), timeoutMs);
|
|
412
|
+
return await fetch(url, { headers, signal: ctrl.signal });
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (signal?.aborted) throw new EverythingError('everything_search: the call was aborted (tool timeout or caller cancellation).', 'ES_ABORTED');
|
|
415
|
+
const cause = error?.cause?.code ?? error?.message ?? String(error);
|
|
416
|
+
throw new EverythingError(`everything_search: the Everything HTTP server on 127.0.0.1 is unreachable (${cause}).`, 'ES_HTTP_UNREACHABLE');
|
|
417
|
+
} finally {
|
|
418
|
+
if (timer) clearTimeout(timer);
|
|
419
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// Rendering / presentation
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
195
427
|
function formatSize(bytes) {
|
|
196
428
|
if (bytes < 1024) return `${bytes} B`;
|
|
197
429
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -201,7 +433,7 @@ function formatSize(bytes) {
|
|
|
201
433
|
|
|
202
434
|
function renderResults(value) {
|
|
203
435
|
const lines = [];
|
|
204
|
-
const { query, total, truncated, results, countOnly } = value;
|
|
436
|
+
const { query, total, truncated, results, countOnly, totalExact, notes } = value;
|
|
205
437
|
if (countOnly) {
|
|
206
438
|
lines.push(total === 0 ? `Everything "${query}": no matches.` : `Everything "${query}": ${total} match(es) (count only).`);
|
|
207
439
|
return lines.join('\n');
|
|
@@ -210,7 +442,9 @@ function renderResults(value) {
|
|
|
210
442
|
lines.push(`Everything "${query}": no matches.`);
|
|
211
443
|
return lines.join('\n');
|
|
212
444
|
}
|
|
213
|
-
|
|
445
|
+
const totalText = totalExact === false ? `≥${total}` : String(total);
|
|
446
|
+
lines.push(`Everything "${query}": ${results.length} of ${totalText} result(s)${truncated ? ' (truncated, refine the query, raise maxResults, or page with offset)' : ''}:`);
|
|
447
|
+
for (const note of Array.isArray(notes) ? notes : []) lines.push(` note: ${note}`);
|
|
214
448
|
for (const entry of results) {
|
|
215
449
|
const meta = [entry.size !== undefined ? formatSize(entry.size) : null, entry.dateModified ?? null, entry.dateCreated ? `created ${entry.dateCreated}` : null]
|
|
216
450
|
.filter(Boolean)
|
|
@@ -252,7 +486,7 @@ const PARAMETERS_SCHEMA = {
|
|
|
252
486
|
},
|
|
253
487
|
path: {
|
|
254
488
|
type: 'string',
|
|
255
|
-
description: 'Restrict the search to this directory and everything below it (absolute path).',
|
|
489
|
+
description: 'Restrict the search to this directory and everything below it (absolute path; must exist).',
|
|
256
490
|
},
|
|
257
491
|
maxResults: {
|
|
258
492
|
type: 'integer',
|
|
@@ -280,13 +514,18 @@ const PARAMETERS_SCHEMA = {
|
|
|
280
514
|
type: 'boolean',
|
|
281
515
|
description: 'Return only the total number of matches, without listing paths (fastest way to check existence or estimate volume).',
|
|
282
516
|
},
|
|
517
|
+
exactTotal: {
|
|
518
|
+
type: 'boolean',
|
|
519
|
+
description:
|
|
520
|
+
'When a full page is returned, fetch the exact total match count (default true). Set false to skip that extra round-trip and get a fast "≥N" total.',
|
|
521
|
+
},
|
|
283
522
|
},
|
|
284
523
|
required: ['query'],
|
|
285
524
|
additionalProperties: false,
|
|
286
525
|
};
|
|
287
526
|
|
|
288
527
|
const TOOL_DESCRIPTION =
|
|
289
|
-
'Locate files and folders across every indexed drive on this machine through the local Everything (voidtools) index — answers in milliseconds, far faster than glob or a recursive directory scan. Use this FIRST whenever you need to find a file or folder by name, extension, or path, especially outside the workspace. The query uses Everything search syntax, not a glob: plain words match names, `*`/`?` wildcards, `ext:js;ts` filters extensions, `dm:today` / `dm:thisweek` filter date modified, `size:>10mb` filters size, space means AND, `|` means OR, `!` means NOT
|
|
528
|
+
'Locate files and folders across every indexed drive on this machine through the local Everything (voidtools) index — answers in milliseconds, far faster than glob or a recursive directory scan. Use this FIRST whenever you need to find a file or folder by name, extension, or path, especially outside the workspace. The query uses Everything search syntax, not a glob: plain words match names, `*`/`?` wildcards, `ext:js;ts` filters extensions, `dm:today` / `dm:thisweek` filter date modified, `size:>10mb` filters size, space means AND, `|` means OR, `!` means NOT. Two quoting quirks: do NOT quote a filename-with-extension like "report.docx" — quoted phrases only match the name part, so pass it unquoted; a whole-query quoted phrase with spaces (exact multi-word match) is auto-converted to a regex. Combine sort="date-modified-descending" with dm:thisweek for recently changed files, or sort="size-descending" to find the biggest ones first. Restrict scope with the dedicated path parameter rather than a path: prefix inside query. Set regex=true for a regular expression over the whole path, countOnly=true for a fast match count without paths, dateCreated=true to also return creation dates, exactTotal=false to trade the exact total for speed on full pages. Returns absolute paths with size and date modified; non-ASCII names come back intact. Names and paths only, never file contents — use grep for content. Coverage follows the local Everything index: NTFS/reFS drives and explicitly added folders; FAT/exFAT removable drives and network shares are typically not indexed.';
|
|
290
529
|
|
|
291
530
|
const OUTPUT_SCHEMA = {
|
|
292
531
|
type: 'object',
|
|
@@ -295,8 +534,11 @@ const OUTPUT_SCHEMA = {
|
|
|
295
534
|
properties: {
|
|
296
535
|
query: { type: 'string' },
|
|
297
536
|
total: { type: 'integer' },
|
|
537
|
+
totalExact: { type: 'boolean', description: 'false means total is a page-based lower bound ("≥N").' },
|
|
298
538
|
truncated: { type: 'boolean' },
|
|
299
539
|
countOnly: { type: 'boolean' },
|
|
540
|
+
channel: { type: 'string', description: 'Which Everything channel served the call: "http" or "es".' },
|
|
541
|
+
notes: { type: 'array', items: { type: 'string' }, description: 'Non-fatal remarks about how the query was interpreted.' },
|
|
300
542
|
results: {
|
|
301
543
|
type: 'array',
|
|
302
544
|
items: {
|
|
@@ -338,11 +580,20 @@ export function apply(ctx, config = {}) {
|
|
|
338
580
|
const autoInstall = config.autoInstall !== false;
|
|
339
581
|
const maxResults = clampInt(config.maxResults, 1, MAX_RESULTS_CEILING, DEFAULT_MAX_RESULTS);
|
|
340
582
|
const timeoutMs = clampInt(config.timeoutMs, 1000, 60000, DEFAULT_TIMEOUT_MS);
|
|
583
|
+
const httpPort = clampInt(config.httpPort, 0, 65535, DEFAULT_HTTP_PORT);
|
|
584
|
+
const httpAutoEnable = config.httpAutoEnable !== false;
|
|
585
|
+
const httpUsername = typeof config.httpUsername === 'string' ? config.httpUsername.trim() : '';
|
|
586
|
+
const httpPassword = typeof config.httpPassword === 'string' ? config.httpPassword : '';
|
|
341
587
|
|
|
342
588
|
// Mutable: auto-install may re-resolve both paths after provisioning.
|
|
343
589
|
let esPath = resolveEsPath(config);
|
|
344
590
|
let everythingPath = resolveEverythingPath(config, esPath ?? 'es.exe');
|
|
345
591
|
|
|
592
|
+
// HTTP channel state: cached readiness, a 30s breaker after failures, and
|
|
593
|
+
// a one-shot flag so an auth failure is logged once, not per call.
|
|
594
|
+
const httpState = { ready: false, downUntil: 0, authWarned: false };
|
|
595
|
+
let httpSetupDone = false;
|
|
596
|
+
|
|
346
597
|
/**
|
|
347
598
|
* One shared provisioning run. Rejects clear the cache so the next call can
|
|
348
599
|
* retry (e.g. after the user approves a UAC prompt on a second attempt).
|
|
@@ -365,10 +616,27 @@ export function apply(ctx, config = {}) {
|
|
|
365
616
|
}
|
|
366
617
|
},
|
|
367
618
|
})
|
|
368
|
-
.then((result) => {
|
|
619
|
+
.then(async (result) => {
|
|
369
620
|
esPath = result.esPath;
|
|
370
621
|
everythingPath = result.everythingPath;
|
|
371
622
|
if (result.installed.length > 0) ctx.logger?.info?.(`everything_search: auto-installed ${result.installed.join(', ')} under ${result.installDir}`);
|
|
623
|
+
// One-time attempt to switch Everything's own config on so its
|
|
624
|
+
// HTTP JSON server starts (the fast channel). Best effort only.
|
|
625
|
+
if (httpPort > 0 && httpAutoEnable && !httpSetupDone) {
|
|
626
|
+
httpSetupDone = true;
|
|
627
|
+
try {
|
|
628
|
+
const r = await ensureHttpServer({ port: httpPort, everythingPath, log: ctx.logger, username: httpUsername, password: httpPassword });
|
|
629
|
+
if (r.enabled || r.alreadyRunning) {
|
|
630
|
+
httpState.ready = true;
|
|
631
|
+
httpState.downUntil = 0; // clear the breaker so the next call uses HTTP
|
|
632
|
+
}
|
|
633
|
+
if (r.enabled) {
|
|
634
|
+
ctx.logger?.info?.(`everything_search: HTTP channel enabled (127.0.0.1:${httpPort}); searches will skip per-call es.exe launches.`);
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
ctx.logger?.warn?.(`everything_search: HTTP channel setup failed (${error?.message ?? error}); staying on the es.exe channel.`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
372
640
|
return result;
|
|
373
641
|
})
|
|
374
642
|
.catch((error) => {
|
|
@@ -416,8 +684,124 @@ export function apply(ctx, config = {}) {
|
|
|
416
684
|
if (input.dateCreated === true) searchArgv.push('-dc');
|
|
417
685
|
if (offset > 0) searchArgv.push('-viewport-offset', String(offset));
|
|
418
686
|
searchArgv.push(...flags);
|
|
419
|
-
|
|
420
|
-
|
|
687
|
+
|
|
688
|
+
// Primary path: es.exe writes results to a UTF-8 file. Piping stdout
|
|
689
|
+
// instead emits the ANSI codepage and mangles non-ASCII names (GBK on a
|
|
690
|
+
// zh-CN machine, question marks elsewhere).
|
|
691
|
+
const exportPath = joinPath(tmpdir(), `dsh-everything-${process.pid}-${Date.now()}-${++exportFileCounter}.json`);
|
|
692
|
+
try {
|
|
693
|
+
let stdoutBuf;
|
|
694
|
+
try {
|
|
695
|
+
stdoutBuf = await runEsWithRetry([...searchArgv, '-export-json', exportPath], signal);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
// es.exe builds that reject the option: retry once in stdout mode.
|
|
698
|
+
if (!(error instanceof EverythingError) || error.code !== 'ES_FAILED' || !/export|unknown|invalid|option/i.test(error.message ?? '')) throw error;
|
|
699
|
+
stdoutBuf = await runEsWithRetry(searchArgv, signal);
|
|
700
|
+
return { flags, results: parseResults(decodeEsStdout(stdoutBuf)) };
|
|
701
|
+
}
|
|
702
|
+
const exported = await readExportText(exportPath);
|
|
703
|
+
if (!exported.exists || exported.text.length === 0) {
|
|
704
|
+
// No file written (or an es.exe that ignores the option): fall back
|
|
705
|
+
// to decoding stdout instead of reporting a fake empty result.
|
|
706
|
+
return { flags, results: parseResults(decodeEsStdout(stdoutBuf)) };
|
|
707
|
+
}
|
|
708
|
+
return { flags, results: parseResults(exported.text) };
|
|
709
|
+
} finally {
|
|
710
|
+
try {
|
|
711
|
+
await unlink(exportPath);
|
|
712
|
+
} catch {
|
|
713
|
+
/* best effort */
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Is the HTTP channel up? Probes lazily; 30s breaker after failures. */
|
|
719
|
+
async function httpChannelReady() {
|
|
720
|
+
if (httpPort <= 0) return false;
|
|
721
|
+
if (httpState.ready) return true;
|
|
722
|
+
if (Date.now() < httpState.downUntil) return false;
|
|
723
|
+
try {
|
|
724
|
+
await probeHttpPort(httpPort, { timeoutMs: 1500, username: httpUsername, password: httpPassword });
|
|
725
|
+
httpState.ready = true;
|
|
726
|
+
return true;
|
|
727
|
+
} catch {
|
|
728
|
+
httpState.downUntil = Date.now() + HTTP_RETRY_DOWN_MS;
|
|
729
|
+
// The server is down: one shot per session at switching Everything's
|
|
730
|
+
// own config on (config patch + GUI restart, never blocks this call).
|
|
731
|
+
if (httpAutoEnable && !httpSetupDone && (esPath || existsSync(everythingPath))) {
|
|
732
|
+
void ensureReady().catch(() => {
|
|
733
|
+
/* logged inside ensureReady */
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** One HTTP search (or count). Throws ES_HTTP_* to let the caller fall back to es.exe. */
|
|
741
|
+
async function httpSearch(input, query, limit, offset, signal) {
|
|
742
|
+
const params = httpSearchParams(input, query, { limit, offset });
|
|
743
|
+
const url = `http://127.0.0.1:${httpPort}/?${new URLSearchParams(params).toString()}`;
|
|
744
|
+
const headers = httpUsername ? { authorization: `Basic ${Buffer.from(`${httpUsername}:${httpPassword}`).toString('base64')}` } : undefined;
|
|
745
|
+
const res = await fetchHttp(url, { headers, signal, timeoutMs: Math.min(timeoutMs, HTTP_REQUEST_CAP_MS) });
|
|
746
|
+
if (res.status === 401 || res.status === 403) {
|
|
747
|
+
throw new EverythingError(`everything_search: the Everything HTTP server rejected the credentials (HTTP ${res.status}); check httpUsername/httpPassword in the plugin config.`, 'ES_HTTP_AUTH');
|
|
748
|
+
}
|
|
749
|
+
if (!res.ok) {
|
|
750
|
+
throw new EverythingError(`everything_search: the Everything HTTP server returned HTTP ${res.status}.`, 'ES_HTTP_UNREACHABLE');
|
|
751
|
+
}
|
|
752
|
+
let json;
|
|
753
|
+
try {
|
|
754
|
+
json = await res.json();
|
|
755
|
+
} catch {
|
|
756
|
+
throw new EverythingError('everything_search: the Everything HTTP server returned a malformed response.', 'ES_HTTP_UNREACHABLE');
|
|
757
|
+
}
|
|
758
|
+
if (typeof json?.totalResults !== 'number' || !Array.isArray(json.results)) {
|
|
759
|
+
throw new EverythingError('everything_search: unexpected JSON from the Everything HTTP server (is another service on that port?).', 'ES_HTTP_UNREACHABLE');
|
|
760
|
+
}
|
|
761
|
+
const total = json.totalResults;
|
|
762
|
+
if (input.countOnly === true) return { total, results: [], truncated: false, totalExact: true, countOnly: true };
|
|
763
|
+
const results = [];
|
|
764
|
+
for (const item of json.results) {
|
|
765
|
+
if (typeof item !== 'object' || item === null) continue;
|
|
766
|
+
const dir = typeof item.path === 'string' ? item.path : '';
|
|
767
|
+
const name = typeof item.name === 'string' ? item.name : '';
|
|
768
|
+
if (name.length === 0 && dir.length === 0) continue;
|
|
769
|
+
const entry = { path: dir ? (dir.endsWith('\\') ? dir + name : `${dir}\\${name}`) : name };
|
|
770
|
+
if (typeof item.size === 'string' && item.size.length > 0) {
|
|
771
|
+
const n = Number(item.size);
|
|
772
|
+
if (Number.isFinite(n)) entry.size = n;
|
|
773
|
+
}
|
|
774
|
+
if (typeof item.date_modified === 'string' && item.date_modified.length > 0) {
|
|
775
|
+
const iso = fileTimeToLocalIso(item.date_modified);
|
|
776
|
+
if (iso !== undefined) entry.dateModified = iso;
|
|
777
|
+
}
|
|
778
|
+
results.push(entry);
|
|
779
|
+
}
|
|
780
|
+
return { total, results, truncated: total > offset + results.length, totalExact: true };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Reject a local-drive path that does not exist (or is a file) up front. */
|
|
784
|
+
function validatePathExists(pathArg) {
|
|
785
|
+
if (/^[a-z]:[\\/]/i.test(pathArg)) {
|
|
786
|
+
if (!existsSync(pathArg)) {
|
|
787
|
+
throw new EverythingError(`everything_search: path does not exist: ${pathArg}`, 'ES_INVALID_ARGS');
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
if (statSync(pathArg).isFile()) {
|
|
791
|
+
throw new EverythingError(`everything_search: path must be a directory, not a file: ${pathArg} (drop the path parameter, or pass its parent directory).`, 'ES_INVALID_ARGS');
|
|
792
|
+
}
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (error instanceof EverythingError) throw error;
|
|
795
|
+
/* statSync raced — treat as existing */
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** UNC paths cannot be stat-checked cheaply up front: check after a miss. */
|
|
801
|
+
function noteUncMissing(pathArg, results) {
|
|
802
|
+
if (results.length === 0 && typeof pathArg === 'string' && pathArg.startsWith('\\\\') && !existsSync(pathArg)) {
|
|
803
|
+
throw new EverythingError(`everything_search: path does not exist: ${pathArg}`, 'ES_INVALID_ARGS');
|
|
804
|
+
}
|
|
421
805
|
}
|
|
422
806
|
|
|
423
807
|
async function execute(args, exec) {
|
|
@@ -427,34 +811,96 @@ export function apply(ctx, config = {}) {
|
|
|
427
811
|
if (query.length === 0) throw new EverythingError('everything_search: "query" must be a non-empty Everything search expression.', 'ES_INVALID_ARGS');
|
|
428
812
|
const limit = clampInt(input.maxResults, 1, MAX_RESULTS_CEILING, maxResults);
|
|
429
813
|
const offset = clampInt(input.offset, 0, Number.MAX_SAFE_INTEGER, 0);
|
|
814
|
+
if (typeof input.sort === 'string' && input.sort.trim().length > 0 && !SORT_NAME_RE.test(input.sort.trim())) {
|
|
815
|
+
throw new EverythingError(`everything_search: invalid sort "${input.sort.trim()}"; use e.g. "date-modified-descending", "size", "name-ascending".`, 'ES_INVALID_ARGS');
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Exact-phrase upgrade (a whole-query quoted phrase with spaces becomes
|
|
819
|
+
// an equivalent regex — the only way to express it on either channel).
|
|
820
|
+
const prep = prepareQuery(query);
|
|
821
|
+
const effectiveQuery = prep.query;
|
|
822
|
+
const regexMode = input.regex === true || prep.regex;
|
|
823
|
+
const notes = prep.note ? [prep.note] : [];
|
|
824
|
+
|
|
825
|
+
const pathArg = typeof input.path === 'string' ? input.path.trim() : '';
|
|
826
|
+
if (pathArg.length > 0) validatePathExists(pathArg);
|
|
827
|
+
const flagsInput = { ...input, regex: regexMode };
|
|
828
|
+
|
|
829
|
+
// Channel choice. The HTTP server has no date_created column, supports
|
|
830
|
+
// only a subset of sort keys, and its regex mode does not interpret
|
|
831
|
+
// path:/folder:/file: prefixes — those combinations go to es.exe.
|
|
832
|
+
const httpSortOk = httpSortParams(input.sort) !== null;
|
|
833
|
+
const httpEligible =
|
|
834
|
+
httpSortOk &&
|
|
835
|
+
(input.countOnly === true || input.dateCreated !== true) &&
|
|
836
|
+
!(regexMode && (pathArg.length > 0 || input.foldersOnly === true || input.filesOnly === true));
|
|
837
|
+
|
|
838
|
+
if (httpEligible && (await httpChannelReady())) {
|
|
839
|
+
try {
|
|
840
|
+
const res = await httpSearch(flagsInput, effectiveQuery, limit, offset, signal);
|
|
841
|
+
noteUncMissing(pathArg, res.results);
|
|
842
|
+
return { query, ...res, channel: 'http', ...(notes.length > 0 ? { notes } : {}) };
|
|
843
|
+
} catch (error) {
|
|
844
|
+
if (error instanceof EverythingError && error.code === 'ES_ABORTED') throw error;
|
|
845
|
+
if (error instanceof EverythingError && (error.code === 'ES_HTTP_UNREACHABLE' || error.code === 'ES_HTTP_AUTH')) {
|
|
846
|
+
// Circuit-break the HTTP channel for 30s and fall back to es.exe.
|
|
847
|
+
httpState.ready = false;
|
|
848
|
+
httpState.downUntil = Date.now() + HTTP_RETRY_DOWN_MS;
|
|
849
|
+
if (error.code === 'ES_HTTP_AUTH' && !httpState.authWarned) {
|
|
850
|
+
httpState.authWarned = true;
|
|
851
|
+
ctx.logger?.warn?.(error.message);
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// es.exe channel. Report phrases that had to be degraded to ANDed words.
|
|
860
|
+
if (!regexMode) {
|
|
861
|
+
const { degraded } = tokenizeQuery(effectiveQuery);
|
|
862
|
+
for (const phrase of degraded) {
|
|
863
|
+
notes.push(`quoted phrase "${phrase}" contains spaces; matched as ANDed words instead (use regex=true, or a whole-query quoted phrase, for exact multi-word matching)`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
430
866
|
|
|
431
867
|
// Count-only mode: one IPC round-trip, no paths listed.
|
|
432
868
|
if (input.countOnly === true) {
|
|
433
|
-
const flags = buildSearchFlags(
|
|
869
|
+
const flags = buildSearchFlags(flagsInput, effectiveQuery);
|
|
434
870
|
const countOut = await runEsWithRetry(['-get-result-count', ...flags], signal);
|
|
435
871
|
const total = Number.parseInt(String(countOut).trim(), 10);
|
|
436
872
|
if (!Number.isFinite(total) || total < 0) {
|
|
437
873
|
throw new EverythingError(`everything_search: could not parse the result count ("${countOut}").`, 'ES_PARSE_FAILED');
|
|
438
874
|
}
|
|
439
|
-
return { query, total, truncated: false, results: [], countOnly: true };
|
|
875
|
+
return { query, total, truncated: false, results: [], countOnly: true, totalExact: true, channel: 'es', ...(notes.length > 0 ? { notes } : {}) };
|
|
440
876
|
}
|
|
441
877
|
|
|
442
|
-
const { flags, results } = await searchOnce(
|
|
878
|
+
const { flags, results } = await searchOnce(flagsInput, effectiveQuery, limit, offset, signal);
|
|
879
|
+
noteUncMissing(pathArg, results);
|
|
443
880
|
|
|
444
881
|
// The exact total costs a second IPC round-trip; only fetch it when the
|
|
445
|
-
// page is full and
|
|
882
|
+
// page is full and the caller has not opted out (exactTotal=false).
|
|
446
883
|
let total = offset + results.length;
|
|
447
|
-
|
|
884
|
+
let totalExact = results.length < limit;
|
|
885
|
+
if (results.length === limit && input.exactTotal !== false) {
|
|
448
886
|
try {
|
|
449
887
|
const countOut = await runEs(esPath, ['-get-result-count', ...flags], timeoutMs, signal);
|
|
450
|
-
const parsed = Number.parseInt(countOut.trim(), 10);
|
|
451
|
-
if (Number.isFinite(parsed) && parsed >= total)
|
|
888
|
+
const parsed = Number.parseInt(String(countOut).trim(), 10);
|
|
889
|
+
if (Number.isFinite(parsed) && parsed >= total) {
|
|
890
|
+
total = parsed;
|
|
891
|
+
totalExact = true;
|
|
892
|
+
}
|
|
452
893
|
} catch {
|
|
453
894
|
/* count is cosmetic; keep the page-based estimate */
|
|
454
895
|
}
|
|
455
896
|
}
|
|
456
897
|
|
|
457
|
-
|
|
898
|
+
// With an exact total, "truncated" means more results exist past this
|
|
899
|
+
// page; with a page-based estimate (exactTotal=false) a full page always
|
|
900
|
+
// reports as truncated because more results may exist.
|
|
901
|
+
const truncated = totalExact ? total > offset + results.length : results.length === limit;
|
|
902
|
+
|
|
903
|
+
return { query, total, truncated, totalExact, results, channel: 'es', ...(notes.length > 0 ? { notes } : {}) };
|
|
458
904
|
}
|
|
459
905
|
|
|
460
906
|
ctx.tools.register({
|
|
@@ -482,10 +928,17 @@ export function apply(ctx, config = {}) {
|
|
|
482
928
|
export {
|
|
483
929
|
buildSearchFlags,
|
|
484
930
|
clampInt,
|
|
931
|
+
decodeEsStdout,
|
|
932
|
+
fileTimeToLocalIso,
|
|
485
933
|
formatSize,
|
|
934
|
+
httpSearchParams,
|
|
935
|
+
httpSortParams,
|
|
486
936
|
parseResults,
|
|
937
|
+
phraseToRegex,
|
|
938
|
+
prepareQuery,
|
|
487
939
|
presentCall,
|
|
488
940
|
presentResult,
|
|
489
941
|
renderResults,
|
|
490
942
|
resolveEsPath,
|
|
943
|
+
tokenizeQuery,
|
|
491
944
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-everything",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "DeepSeek Harness plugin: millisecond whole-disk file search for agents via the local Everything (voidtools) index",
|
|
5
5
|
"keywords": ["dsh", "deepseek-harness", "everything", "voidtools", "file-search", "agent-tool"],
|
|
6
6
|
"license": "MIT",
|
package/setup.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { execFile, spawn } from 'node:child_process';
|
|
25
25
|
import { createHash } from 'node:crypto';
|
|
26
|
-
import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
|
26
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { readFile } from 'node:fs/promises';
|
|
28
28
|
import { win32 as win32path } from 'node:path';
|
|
29
29
|
|
|
@@ -412,13 +412,30 @@ export function findEverythingInstall() {
|
|
|
412
412
|
return null;
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Fresh installs ship with the HTTP server feature locked off
|
|
417
|
+
* (allow_http_server=0 in the ini next to Everything.exe). Open it once at
|
|
418
|
+
* install time so the fast channel can come up later.
|
|
419
|
+
*/
|
|
420
|
+
function allowHttpInLocalIni(everythingPath, log) {
|
|
421
|
+
try {
|
|
422
|
+
const localIni = win32path.join(win32path.dirname(everythingPath), 'Everything.ini');
|
|
423
|
+
patchIniValue(localIni, 'allow_http_server', 1, { create: true });
|
|
424
|
+
} catch (error) {
|
|
425
|
+
log?.warn?.(`everything_search: could not unlock the HTTP server in the Everything.ini next to ${everythingPath} (${error?.message ?? error}).`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
415
429
|
async function installEverything(installDir, log, proxy) {
|
|
416
430
|
// Strategy 1: winget (present on most Windows 10/11 desktops).
|
|
417
431
|
try {
|
|
418
432
|
log?.info?.('everything_search: installing Everything via winget (a UAC prompt may appear)…');
|
|
419
433
|
await run('winget', buildWingetInstallArgs(), WINGET_TIMEOUT_MS);
|
|
420
434
|
const found = findEverythingInstall();
|
|
421
|
-
if (found)
|
|
435
|
+
if (found) {
|
|
436
|
+
allowHttpInLocalIni(found, log);
|
|
437
|
+
return { everythingPath: found, via: 'winget' };
|
|
438
|
+
}
|
|
422
439
|
} catch (error) {
|
|
423
440
|
log?.warn?.(`everything_search: winget install failed (${error?.message ?? error}); falling back to the official setup.`);
|
|
424
441
|
}
|
|
@@ -438,6 +455,7 @@ async function installEverything(installDir, log, proxy) {
|
|
|
438
455
|
'ES_SETUP_FAILED'
|
|
439
456
|
);
|
|
440
457
|
}
|
|
458
|
+
allowHttpInLocalIni(found, log);
|
|
441
459
|
return { everythingPath: found, via: 'setup' };
|
|
442
460
|
}
|
|
443
461
|
|
|
@@ -520,3 +538,136 @@ export async function ensureBackend({ config = {}, log = null, proxy, probe = nu
|
|
|
520
538
|
export function launchDetached(binaryPath, argv) {
|
|
521
539
|
spawn(binaryPath, argv, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
522
540
|
}
|
|
541
|
+
|
|
542
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Replace (or append) one `key=value` line in an Everything .ini file.
|
|
546
|
+
* `onlyIfEmpty` keeps an existing non-empty value (used for bindings so a
|
|
547
|
+
* user's explicit choice is never overridden). Returns true when the file
|
|
548
|
+
* was written.
|
|
549
|
+
*/
|
|
550
|
+
export function patchIniValue(filePath, key, value, { create = false, onlyIfEmpty = false } = {}) {
|
|
551
|
+
let text = '';
|
|
552
|
+
if (existsSync(filePath)) text = readFileSync(filePath, 'utf8');
|
|
553
|
+
else if (!create) return false;
|
|
554
|
+
const line = `${key}=${value}`;
|
|
555
|
+
const re = new RegExp(`^${key}=.*$`, 'm');
|
|
556
|
+
if (re.test(text)) {
|
|
557
|
+
const current = text.match(re)[0];
|
|
558
|
+
if (onlyIfEmpty && current !== `${key}=`) return false;
|
|
559
|
+
writeFileSync(filePath, text.replace(re, line), 'utf8');
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
const out = text.length === 0 ? `[Everything]\n${line}\n` : `${text.endsWith('\n') ? text : `${text}\n`}${line}\n`;
|
|
563
|
+
writeFileSync(filePath, out, 'utf8');
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Probe the Everything HTTP JSON server on 127.0.0.1:<port>. Validates the
|
|
569
|
+
* `totalResults` field so a foreign service on the same port is detected,
|
|
570
|
+
* not silently used. Resolves true, or throws ES_HTTP_UNREACHABLE.
|
|
571
|
+
*/
|
|
572
|
+
export async function probeHttpPort(port, { timeoutMs = 1500, username = '', password = '' } = {}) {
|
|
573
|
+
const url = `http://127.0.0.1:${port}/?${new URLSearchParams({ json: '1', search: '_dsh_probe_', count: '1' }).toString()}`;
|
|
574
|
+
const headers = username ? { authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` } : undefined;
|
|
575
|
+
let res;
|
|
576
|
+
try {
|
|
577
|
+
res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
578
|
+
} catch (error) {
|
|
579
|
+
throw new EverythingError(`everything_search setup: no Everything HTTP server on 127.0.0.1:${port} (${error?.cause?.code ?? error?.message ?? error}).`, 'ES_HTTP_UNREACHABLE');
|
|
580
|
+
}
|
|
581
|
+
if (!res.ok) {
|
|
582
|
+
throw new EverythingError(`everything_search setup: HTTP probe on 127.0.0.1:${port} returned ${res.status}.`, 'ES_HTTP_UNREACHABLE');
|
|
583
|
+
}
|
|
584
|
+
let json;
|
|
585
|
+
try {
|
|
586
|
+
json = await res.json();
|
|
587
|
+
} catch {
|
|
588
|
+
throw new EverythingError(`everything_search setup: the service on 127.0.0.1:${port} is not the Everything HTTP server.`, 'ES_HTTP_UNREACHABLE');
|
|
589
|
+
}
|
|
590
|
+
if (typeof json?.totalResults !== 'number') {
|
|
591
|
+
throw new EverythingError(`everything_search setup: the service on 127.0.0.1:${port} is not the Everything HTTP server.`, 'ES_HTTP_UNREACHABLE');
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Kill the Everything GUI (session > 0); the background service is left alone. */
|
|
597
|
+
async function stopEverythingGui() {
|
|
598
|
+
const script = 'Get-Process Everything -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -ne 0 } | Stop-Process -Force';
|
|
599
|
+
try {
|
|
600
|
+
await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 20000);
|
|
601
|
+
await sleep(500);
|
|
602
|
+
} catch {
|
|
603
|
+
/* best effort — the config patch still applies at next Everything start */
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Turn on Everything's built-in HTTP JSON server.
|
|
609
|
+
*
|
|
610
|
+
* Everything 1.4 ships it disabled AND locked: the per-machine policy switch
|
|
611
|
+
* `allow_http_server` in the Everything.ini next to Everything.exe defaults
|
|
612
|
+
* to 0 (installed builds), and the per-user %APPDATA% ini only honors
|
|
613
|
+
* http_server_* when the local lock is open. The server cannot be enabled
|
|
614
|
+
* through the GUI on locked installs, so we patch both files directly and
|
|
615
|
+
* restart the GUI (the background index service keeps running untouched).
|
|
616
|
+
*
|
|
617
|
+
* Idempotent: returns {enabled: false, alreadyRunning: true} when the server
|
|
618
|
+
* already answers. All failures are returned, never thrown — the caller
|
|
619
|
+
* stays on the es.exe channel.
|
|
620
|
+
*/
|
|
621
|
+
export async function ensureHttpServer({ port = 5151, everythingPath, log = null, username = '', password = '' } = {}) {
|
|
622
|
+
try {
|
|
623
|
+
await probeHttpPort(port, { username, password });
|
|
624
|
+
return { enabled: false, alreadyRunning: true };
|
|
625
|
+
} catch {
|
|
626
|
+
/* not up yet — continue below */
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (!everythingPath || !existsSync(everythingPath)) {
|
|
630
|
+
return { enabled: false, reason: 'Everything.exe path unknown' };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
log?.info?.(`everything_search: enabling the Everything HTTP server on 127.0.0.1:${port} (the Everything GUI restarts once)…`);
|
|
634
|
+
await stopEverythingGui();
|
|
635
|
+
|
|
636
|
+
// 1. Unlock the feature in the ini next to Everything.exe (machine policy).
|
|
637
|
+
const localIni = win32path.join(win32path.dirname(everythingPath), 'Everything.ini');
|
|
638
|
+
try {
|
|
639
|
+
patchIniValue(localIni, 'allow_http_server', 1, { create: true });
|
|
640
|
+
} catch (error) {
|
|
641
|
+
log?.warn?.(`everything_search: could not patch ${localIni} (${error?.message ?? error}).`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// 2. Enable + configure it in the per-user ini. Bind loopback only when the
|
|
645
|
+
// user has not chosen something else.
|
|
646
|
+
const appDataDir = process.env.APPDATA ?? win32path.join(process.env.USERPROFILE ?? '.', 'AppData', 'Roaming');
|
|
647
|
+
const appDataIni = win32path.join(appDataDir, 'Everything', 'Everything.ini');
|
|
648
|
+
try {
|
|
649
|
+
mkdirSync(win32path.dirname(appDataIni), { recursive: true });
|
|
650
|
+
patchIniValue(appDataIni, 'http_server_enabled', 1, { create: true });
|
|
651
|
+
patchIniValue(appDataIni, 'http_server_port', port, { create: true });
|
|
652
|
+
patchIniValue(appDataIni, 'http_server_username', username, { create: true });
|
|
653
|
+
patchIniValue(appDataIni, 'http_server_password', password, { create: true });
|
|
654
|
+
patchIniValue(appDataIni, 'http_server_bindings', '127.0.0.1', { create: true, onlyIfEmpty: true });
|
|
655
|
+
} catch (error) {
|
|
656
|
+
log?.warn?.(`everything_search: could not patch ${appDataIni} (${error?.message ?? error}).`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// 3. Restart the GUI detached and wait for the server to answer.
|
|
660
|
+
launchDetached(everythingPath, ['-startup']);
|
|
661
|
+
const deadline = Date.now() + 15000;
|
|
662
|
+
while (Date.now() < deadline) {
|
|
663
|
+
await sleep(250);
|
|
664
|
+
try {
|
|
665
|
+
await probeHttpPort(port, { username, password });
|
|
666
|
+
return { enabled: true };
|
|
667
|
+
} catch {
|
|
668
|
+
/* retry until the deadline */
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
log?.warn?.(`everything_search: the Everything HTTP server did not come up on 127.0.0.1:${port} within 15s; staying on the es.exe channel.`);
|
|
672
|
+
return { enabled: false, reason: 'timeout' };
|
|
673
|
+
}
|