dsh-moli 1.1.13 → 1.1.15
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/README.md +99 -6
- package/README.zh.md +108 -10
- package/client.js +347 -0
- package/index.js +237 -10
- package/lib/install.js +37 -3
- package/lib/moli.js +123 -6
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -210,8 +210,9 @@ An unresolvable bundle is **skipped silently** apart from one stderr line
|
|
|
210
210
|
## Usage
|
|
211
211
|
|
|
212
212
|
`moli_fetch` parameters: `url` (required), `dump`, `writeTo`, `eval`, `scroll`, `wait`,
|
|
213
|
-
`waitSelector`, `
|
|
214
|
-
`
|
|
213
|
+
`waitSelector`, `waitScript`, `waitResponseUrl`, `waitResponseJson`, `delayMs`, `timeoutMs`,
|
|
214
|
+
`maxChars`, `headers`, `userAgent`, `layout`, `scrollbars`, `noJs`, `noCss`, `resources`,
|
|
215
|
+
`withFrames`, `insecureTls`, `failOnHttpError`, `profileDir`, `cookieFile`, `proxy`.
|
|
215
216
|
|
|
216
217
|
```
|
|
217
218
|
moli_fetch url=https://example.com dump=markdown # token-efficient text
|
|
@@ -244,6 +245,43 @@ scrolled `dump=screenshot` shows that part of the page.
|
|
|
244
245
|
It runs as part of the engine's own pre-capture evaluation and therefore composes with `eval`: the
|
|
245
246
|
scroll happens first, and the expression still returns its own value.
|
|
246
247
|
|
|
248
|
+
### `failOnHttpError` — stop reading an error page as content
|
|
249
|
+
|
|
250
|
+
By default a 404 is returned like any other page, so a failed fetch looks successful. Setting
|
|
251
|
+
`failOnHttpError` turns a final status of 400 or above into `ok: false` with the status named — and
|
|
252
|
+
**keeps the body**, because an error page is often the thing you need to read.
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
moli_fetch url=https://example.com/nope dump=markdown failOnHttpError=true
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
| | default | `failOnHttpError: true` |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| a 404 | `ok: true`, the error page as content | `ok: false`, `status: 404`, body still returned |
|
|
261
|
+
| a screenshot of a 404 | written as a normal image | still written, and reported as a failure |
|
|
262
|
+
|
|
263
|
+
It is off by default so existing calls keep their behaviour. A JSON dump already reported the status
|
|
264
|
+
on its own; markdown and HTML dumps did not, and that is the gap this closes.
|
|
265
|
+
|
|
266
|
+
### Waiting for what the page actually fetches
|
|
267
|
+
|
|
268
|
+
`wait: "networkidle"` plus `delayMs` is a guess about how long a list takes to arrive. These wait for
|
|
269
|
+
the request itself:
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
# the response that carries the data
|
|
273
|
+
moli_fetch url=https://example.com/feed dump=markdown waitResponseUrl="/api/feed"
|
|
274
|
+
|
|
275
|
+
# the payload, when the URL is not distinctive
|
|
276
|
+
moli_fetch url=https://example.com/feed dump=markdown waitResponseJson="data.list.0.id=42"
|
|
277
|
+
|
|
278
|
+
# the page's own idea of readiness
|
|
279
|
+
moli_fetch url=https://example.com/app dump=markdown waitScript="window.appReady === true"
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`waitResponseJson` takes `PATH=VALUE`, where `PATH` addresses the field and `VALUE` is compared
|
|
283
|
+
literally. Use these when a fixed delay either wastes time or reads the page too early.
|
|
284
|
+
|
|
247
285
|
## Relationship to Moli's own agent skills
|
|
248
286
|
|
|
249
287
|
Moli ships official agent skills in its repository — [`moli-webfetch`](https://github.com/lexmount/moli/tree/main/skills/moli-webfetch),
|
|
@@ -273,10 +311,13 @@ capabilities always visible to the model.
|
|
|
273
311
|
2. **Binary output is copied byte for byte.** PowerShell's `>` operator re-encodes a native
|
|
274
312
|
program's output as UTF-16, inserting a `00` after every byte, which corrupts a screenshot.
|
|
275
313
|
Bytes here are written verbatim, so the PNG signature stays `89504e470d0a1a0a`.
|
|
276
|
-
3. **
|
|
314
|
+
3. **One optional external dependency.** A profile plugin is loaded from its real path on disk (a `link:`
|
|
277
315
|
dependency stays a symlink target), so a bare specifier such as `@deepseek-ai/schemastery`
|
|
278
|
-
resolves against the plugin directory instead of the profile's `node_modules
|
|
279
|
-
|
|
316
|
+
resolves against the plugin directory instead of the profile's `node_modules`. The import is
|
|
317
|
+
therefore guarded: without schemastery (a `link:` install) the settings form does not appear and
|
|
318
|
+
the plugin degrades to plain patch-layer configuration, while `moli_fetch` and `moli_probe` keep
|
|
319
|
+
registering. With it (a normal npm install, which brings the dependency along) the settings page
|
|
320
|
+
is available.
|
|
280
321
|
|
|
281
322
|
## Platform support
|
|
282
323
|
|
|
@@ -320,6 +361,8 @@ exercised the proxy download route as well as the engine paths.
|
|
|
320
361
|
## Configuration
|
|
321
362
|
|
|
322
363
|
Override any field under the plugin's `config` entry in the profile patch layer. All are optional.
|
|
364
|
+
The same fields are also editable from the **Moli 安装与抓取** settings section on the plugin's page in
|
|
365
|
+
the Plugins manager (a `plugins.bundle.config` form registered by the browser half).
|
|
323
366
|
|
|
324
367
|
```yaml
|
|
325
368
|
- insert:
|
|
@@ -341,6 +384,22 @@ Without `exePath`, the plugin probes `exePath` → `MOLI_BIN` → `%LOCALAPPDATA
|
|
|
341
384
|
`%USERPROFILE%\.local\bin\moli.exe`, then scans inside `%LOCALAPPDATA%\Moli` for a `moli.exe`, so an
|
|
342
385
|
installation under a versioned subdirectory is still found.
|
|
343
386
|
|
|
387
|
+
### Engine lifecycle from the settings page
|
|
388
|
+
|
|
389
|
+
The settings section also carries an **engine** row that detects the installed engine and lets you
|
|
390
|
+
install or uninstall it without touching the profile:
|
|
391
|
+
|
|
392
|
+
- **state** — `engineState` is `installed` (with `engineVersion`), `missing`, or a short error text.
|
|
393
|
+
- **install** — the form writes `engineAction: "install"`; the host downloads and runs the official
|
|
394
|
+
installer asynchronously, then reports the outcome back through `engineState` / `engineVersion`.
|
|
395
|
+
- **uninstall** — `engineAction: "uninstall"` removes the engine. On Windows only the installer-owned
|
|
396
|
+
`%LOCALAPPDATA%\Moli` tree is deleted; without one, nothing is touched. On POSIX the located binary
|
|
397
|
+
is removed and the now-empty install directories are pruned.
|
|
398
|
+
|
|
399
|
+
These three fields (`engineAction`, `engineState`, `engineVersion`) are part of the live Config schema;
|
|
400
|
+
`engineAction` is a `none`/`install`/`uninstall` union. They are meant to be driven by the settings
|
|
401
|
+
form, so prefer editing the four fields above for routine configuration.
|
|
402
|
+
|
|
344
403
|
## Known limitations
|
|
345
404
|
|
|
346
405
|
- **macOS is untested, and Linux was tested by the contributor rather than the maintainer.** The
|
|
@@ -378,8 +437,14 @@ installation under a versioned subdirectory is still found.
|
|
|
378
437
|
|
|
379
438
|
## Verification
|
|
380
439
|
|
|
381
|
-
|
|
440
|
+
Eight suites live in [`verify/`](verify/), and all are runnable against a working tree:
|
|
382
441
|
|
|
442
|
+
- `config-verify.mjs` checks the settings surface and engine lifecycle added by the plugin's browser
|
|
443
|
+
half: the volatile `Config` schema (every declared field, `engineAction` as a `none`/`install`/
|
|
444
|
+
`uninstall` union), `apply` reading both a plain config and a volatile `.get()` reference, the
|
|
445
|
+
`settings/document-updated` wiring, `uninstallMoli` (platform-aware: POSIX removes the located
|
|
446
|
+
binary, Windows removes only the installer-owned `%LOCALAPPDATA%\Moli` tree), and the client.js
|
|
447
|
+
shape (`plugins.bundle.config` registration, zh/en dictionaries, raw `true`/`false` select labels).
|
|
383
448
|
- `schema-verify.mjs` checks the tool definitions with the registry's own validator, including
|
|
384
449
|
controls that assert the two invalid schema shapes still fail.
|
|
385
450
|
- `install-verify.mjs` drives the install pipeline against a loopback HTTP server: route ordering,
|
|
@@ -403,6 +468,13 @@ Four suites live in [`verify/`](verify/), and all are runnable against a working
|
|
|
403
468
|
`bottom`, nonsense rejected, the caller's `eval` value preserved) and then, when an engine is
|
|
404
469
|
present, fetches a page whose `scroll` handler fills a slot and asserts the content appears with
|
|
405
470
|
`scroll` and the placeholder appears without it — a property of the output, not of an internal call.
|
|
471
|
+
- `http-verify.mjs` asserts the parameter-to-flag mapping directly, because a parameter that never
|
|
472
|
+
reaches the engine is indistinguishable from a feature that does not work. It then fetches a 404 and
|
|
473
|
+
checks that `failOnHttpError` reports it while keeping the body, that a healthy page is unaffected,
|
|
474
|
+
and that a screenshot of an error page is still written as a valid PNG rather than decoded as text.
|
|
475
|
+
The end-to-end half needs an HTTP error from a real server: loopback is unreachable from inside this
|
|
476
|
+
sandbox — `curl.exe` times out on `127.0.0.1` while the Node host reaches it — so it uses a path
|
|
477
|
+
under `example.com` and skips when the network is away.
|
|
406
478
|
|
|
407
479
|
`.github/workflows/verify.yml` runs `schema-verify`, `empty-verify` and `scroll-verify` on Linux,
|
|
408
480
|
Windows and macOS (they need no engine and no network) and attempts the engine-dependent suites on
|
|
@@ -414,6 +486,12 @@ through a proxy, that proxy too. Useful where a network is available, but a suit
|
|
|
414
486
|
because a website changed is worse than no suite, so it stays a local check. If you run it and see
|
|
415
487
|
failures in sections 4–6 while sections 1–3 pass, suspect the network before the code.
|
|
416
488
|
|
|
489
|
+
### Prerequisites for the suites
|
|
490
|
+
|
|
491
|
+
The plugin has one optional runtime dependency — [`@deepseek-ai/schemastery`](https://www.npmjs.com/package/@deepseek-ai/schemastery) — used by the live `Config` schema that registers the settings namespace (optional because the import is guarded, so a `link:` install without it still loads and registers the tools). Suites that import the plugin (`config-verify`, `schema-verify`, `empty-verify`, `report-verify`, `http-verify`) need it resolvable from the working tree, so run `npm install` first.
|
|
492
|
+
|
|
493
|
+
`schema-verify` additionally imports the registry's own validator out of the npm-global `@deepseek-ai/dsh` install (`@deepseek-ai/dsh-tools`); `report-verify` and the engine suites need a real Moli engine, and `empty-verify` needs a C compiler to build its stub engines. On Windows, `@deepseek-ai/schemastery` can be resolved through a directory junction into the fork's `node_modules` from the npm-global DSH tree, the same way the suites resolve `dsh-tools`.
|
|
494
|
+
|
|
417
495
|
Beyond those, `lib/moli.js` and the tool layer were exercised end to end against Moli 1.1.16 on
|
|
418
496
|
Windows: registration, probe, Markdown and JSON dumps, `eval` (including a tight-timeout regression
|
|
419
497
|
guard for the `-t` argument position), PNG and PDF artifacts with valid signatures, truncation, every
|
|
@@ -467,6 +545,21 @@ which reads as a missing package and is actually an authorisation failure. Run
|
|
|
467
545
|
`node scripts/preflight-publish.mjs` before any local publish: it reports whether the credential can
|
|
468
546
|
authenticate and names that misleading 404 when it cannot.
|
|
469
547
|
|
|
548
|
+
### Deprecating a version: do it on the website
|
|
549
|
+
|
|
550
|
+
Publishing is scripted; deprecating deliberately is not. Use the package's page on npmjs.com —
|
|
551
|
+
**package → Settings → Deprecate** — and name the versions and the reason.
|
|
552
|
+
|
|
553
|
+
`npm deprecate dsh-moli@1.1.11 "…"` produces the same misleading failure as above when the local
|
|
554
|
+
credential is dead, and there is a second reason not to reach for a token here. npm restricted
|
|
555
|
+
2FA-bypass granular tokens from package-management operations on 31 July 2026, so the kind of token
|
|
556
|
+
that used to make `deprecate` scriptable is being withdrawn, while `publish` has a supported
|
|
557
|
+
token-free path through OIDC. A release process that needs a token only for the rare act of
|
|
558
|
+
deprecating is a process that will be broken the next time it is needed, which is exactly what
|
|
559
|
+
happened with 1.1.11 and 1.1.12.
|
|
560
|
+
|
|
561
|
+
The 1.0.0 and 1.0.1 deprecations were done this way, from the website.
|
|
562
|
+
|
|
470
563
|
## License
|
|
471
564
|
|
|
472
565
|
MIT. See [LICENSE](LICENSE).
|
package/README.zh.md
CHANGED
|
@@ -196,8 +196,9 @@ profile 插件需要两半都齐:依赖边(`dependencies`)+ `dsh.profile.b
|
|
|
196
196
|
## 用法
|
|
197
197
|
|
|
198
198
|
`moli_fetch` 参数:`url`(必填)、`dump`、`writeTo`、`eval`、`scroll`、`wait`、`waitSelector`、
|
|
199
|
-
`delayMs`、`timeoutMs`、`maxChars`、`headers
|
|
200
|
-
`noCss`、`resources`、`withFrames`、`insecureTls
|
|
199
|
+
`waitScript`、`waitResponseUrl`、`waitResponseJson`、`delayMs`、`timeoutMs`、`maxChars`、`headers`、
|
|
200
|
+
`userAgent`、`layout`、`scrollbars`、`noJs`、`noCss`、`resources`、`withFrames`、`insecureTls`、
|
|
201
|
+
`failOnHttpError`、`profileDir`、`cookieFile`、`proxy`。
|
|
201
202
|
|
|
202
203
|
```
|
|
203
204
|
moli_fetch url=https://example.com dump=markdown # 省 token 的正文
|
|
@@ -229,6 +230,41 @@ moli_fetch url=https://example.com/feed dump=markdown scroll=-400 # 往回滚
|
|
|
229
230
|
它作为引擎自身"抓取前求值"的一部分执行,因此与 `eval` 可组合:**先滚动,表达式再读取页面,并且仍然返回
|
|
230
231
|
它自己的值**。
|
|
231
232
|
|
|
233
|
+
### `failOnHttpError` —— 别再把错误页当正文读
|
|
234
|
+
|
|
235
|
+
默认情况下 404 和普通页面一样被返回,于是**抓取失败看起来是成功的**。加上 `failOnHttpError` 后,最终状态码
|
|
236
|
+
400 及以上会变成 `ok: false` 并报出状态码——**而且保留响应体**,因为错误页往往正是你需要读的东西。
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
moli_fetch url=https://example.com/nope dump=markdown failOnHttpError=true
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
| | 默认 | `failOnHttpError: true` |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| 一个 404 | `ok: true`,错误页作为正文 | `ok: false`、`status: 404`,响应体仍然返回 |
|
|
245
|
+
| 404 的截图 | 当普通图片写出 | 仍然写出,但报告为失败 |
|
|
246
|
+
|
|
247
|
+
默认关闭,因此现有调用行为不变。JSON dump 本来就能自己报出状态码;markdown 与 HTML 不能,这个开关补的正是
|
|
248
|
+
这个缺口。
|
|
249
|
+
|
|
250
|
+
### 等待页面真正取回的东西
|
|
251
|
+
|
|
252
|
+
`wait: "networkidle"` 加 `delayMs` 是在**猜**列表要多久才到。下面这几个等的是请求本身:
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
# 等待承载数据的那次响应
|
|
256
|
+
moli_fetch url=https://example.com/feed dump=markdown waitResponseUrl="/api/feed"
|
|
257
|
+
|
|
258
|
+
# URL 不够有特征时,按载荷内容等
|
|
259
|
+
moli_fetch url=https://example.com/feed dump=markdown waitResponseJson="data.list.0.id=42"
|
|
260
|
+
|
|
261
|
+
# 让页面用自己的方式表示"就绪"
|
|
262
|
+
moli_fetch url=https://example.com/app dump=markdown waitScript="window.appReady === true"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`waitResponseJson` 接收 `PATH=VALUE`:`PATH` 定位字段,`VALUE` 按字面比较。当固定延时不是浪费时间就是读得太
|
|
266
|
+
早时,用这几个。
|
|
267
|
+
|
|
232
268
|
## 与 Moli 官方 agent skill 的关系
|
|
233
269
|
|
|
234
270
|
Moli 仓库自带官方 agent skill:[`moli-webfetch`](https://github.com/lexmount/moli/tree/main/skills/moli-webfetch)、
|
|
@@ -253,9 +289,10 @@ Moli 仓库自带官方 agent skill:[`moli-webfetch`](https://github.com/lexmo
|
|
|
253
289
|
重定向到临时文件再读回。
|
|
254
290
|
2. **二进制按原始字节落盘。** PowerShell 的 `>` 会把原生程序输出按 UTF-16 重编码(每字节后插
|
|
255
291
|
`00`),截图因此损坏。这里直接写字节,PNG 签名保持 `89504e470d0a1a0a`。
|
|
256
|
-
3.
|
|
257
|
-
导入(例如 `@deepseek-ai/schemastery`)都会解析到插件目录而非 profile 的 `node_modules
|
|
258
|
-
|
|
292
|
+
3. **一个可选的外部依赖。** profile 插件按磁盘真实路径加载(`link:` 依赖仍是符号链接目标),任何裸包名
|
|
293
|
+
导入(例如 `@deepseek-ai/schemastery`)都会解析到插件目录而非 profile 的 `node_modules`。因此该导入
|
|
294
|
+
是**受保护的**:没有 schemastery(`link:` 安装)时设置界面不出现、插件退化为纯补丁层配置,而
|
|
295
|
+
`moli_fetch` 与 `moli_probe` 照常注册;有它(普通 npm 安装会自动带上)时设置页面可用。
|
|
259
296
|
|
|
260
297
|
## 平台支持
|
|
261
298
|
|
|
@@ -292,7 +329,7 @@ cookie 驱动的查询参数。那次实测同时覆盖了代理下载路由与
|
|
|
292
329
|
|
|
293
330
|
## 配置
|
|
294
331
|
|
|
295
|
-
在 profile 的补丁层里覆盖 `config
|
|
332
|
+
在 profile 的补丁层里覆盖 `config`,全部可选。也可以在图 **Moli 设置** 界面里编辑(见下方「设置页入口」)。
|
|
296
333
|
|
|
297
334
|
```yaml
|
|
298
335
|
- insert:
|
|
@@ -313,6 +350,32 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
|
|
|
313
350
|
→ `%USERPROFILE%\.local\bin\moli.exe`,随后还会在 `%LOCALAPPDATA%\Moli` 内递归查找 `moli.exe`,
|
|
314
351
|
所以装在带版本号的子目录里也能找到。
|
|
315
352
|
|
|
353
|
+
### 设置页入口
|
|
354
|
+
|
|
355
|
+
插件自带一个**设置界面**,挂在插件管理页的对应插件条目上(`plugins.bundle.config` 槽位,由浏览器端
|
|
356
|
+
`client.js` 注册)。两种方式进入:
|
|
357
|
+
|
|
358
|
+
1. **插件管理页**:打开 DSH Web 界面的插件管理页,找到 **dsh-moli** 条目,其配置区即是本设置界面;
|
|
359
|
+
引擎状态、安装/卸载按钮、以及下方所有配置字段都在这里编辑。
|
|
360
|
+
2. **直接改 profile**:按上面的 YAML 改 `cordis.patch.yml` 同样生效,两边是同一份 `config`,保存即同步。
|
|
361
|
+
|
|
362
|
+
设置界面跟随 DSH 当前语言(内置 `zh` / `en`,英语兜底),布尔字段(如 `insecureTls`)以下拉选择
|
|
363
|
+
**原始值**(`true` / `false`)显示,不做本地化翻译。
|
|
364
|
+
|
|
365
|
+
### 引擎生命周期(设置页的「引擎」行)
|
|
366
|
+
|
|
367
|
+
设置界面顶部有一条**引擎**状态行,自动检测已安装的引擎,并可一键安装/卸载,无需动 profile:
|
|
368
|
+
|
|
369
|
+
- **状态** — `engineState` 为 `installed`(同时给出 `engineVersion`)、`missing`,或一段简短错误文本。
|
|
370
|
+
- **安装** — 点「安装引擎」后表单写入 `engineAction: "install"`,主机异步下载并运行官方安装器,
|
|
371
|
+
完成后通过 `engineState` / `engineVersion` 回写结果。
|
|
372
|
+
- **卸载** — 「卸载引擎」写入 `engineAction: "uninstall"`。Windows 上只删除安装器所属的
|
|
373
|
+
`%LOCALAPPDATA%\Moli` 目录树;没有该目录树时**不触碰任何文件**。POSIX 上删除定位到的二进制,
|
|
374
|
+
并清理随之变空的安装目录。
|
|
375
|
+
|
|
376
|
+
`engineAction` / `engineState` / `engineVersion` 三个字段属于动态 Config schema(`engineAction` 是
|
|
377
|
+
`none` / `install` / `uninstall` 三选一),设计上由设置界面驱动;日常改配置请优先用上面的四个字段。
|
|
378
|
+
|
|
316
379
|
## 已知限制
|
|
317
380
|
|
|
318
381
|
- **macOS 未经验证,Linux 是由贡献者而非维护者验证的。** 维护者是一名**没有 macOS / Linux 电脑**的
|
|
@@ -340,8 +403,13 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
|
|
|
340
403
|
|
|
341
404
|
## 验证情况
|
|
342
405
|
|
|
343
|
-
[`verify/`](verify/)
|
|
406
|
+
[`verify/`](verify/) 下有八个可直接对工作区运行的测试:
|
|
344
407
|
|
|
408
|
+
- `config-verify.mjs` 检查设置面与引擎生命周期:动态 `Config` schema(每个已声明字段、`engineAction`
|
|
409
|
+
为 `none`/`install`/`uninstall` 三选一)、`apply` 同时接受普通配置与动态 `.get()` 引用、
|
|
410
|
+
`settings/document-updated` 接线、`uninstallMoli`(平台感知:POSIX 删除定位到的二进制,Windows
|
|
411
|
+
只删除安装器所属的 `%LOCALAPPDATA%\Moli` 目录树)、以及 client.js 的形状(`plugins.bundle.config`
|
|
412
|
+
注册、zh/en 词典、`true`/`false` 下拉显示原始值)。
|
|
345
413
|
- `schema-verify.mjs` 用注册表自己的校验器检查工具定义,并带有断言"两种非法 schema 形状仍会被拒"的对照组。
|
|
346
414
|
- `install-verify.mjs` 用本地回环 HTTP 服务器驱动安装链路:路由顺序、被提供字节的 SHA256、校验不匹配时
|
|
347
415
|
拒绝执行、匹配时真正执行、以及全部路径失败的诊断。
|
|
@@ -358,10 +426,27 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
|
|
|
358
426
|
- `scroll-verify.mjs` 在无引擎时校验表达式构造(像素值、负数、`top`、`bottom`、无效输入被拒、调用方的 `eval`
|
|
359
427
|
值被保留),有引擎时抓取一个"滚动处理器填充槽位"的页面,断言带 `scroll` 时内容出现、不带时只有占位符——
|
|
360
428
|
断言的是**输出**的性质,而不是内部调用。
|
|
429
|
+
- `http-verify.mjs` 直接断言"参数 → 引擎选项"的映射,因为**参数没送到引擎和功能不生效,从外面看是一样的**。
|
|
430
|
+
随后抓一个 404,验证 `failOnHttpError` 会报告失败同时保留响应体、健康页面不受影响、以及错误页的截图仍然被
|
|
431
|
+
写为合法 PNG 而不是被当文本解码。端到端部分需要一个真实服务器的 HTTP 错误:**本沙箱内无法访问 loopback**
|
|
432
|
+
——`curl.exe` 连 `127.0.0.1` 会超时,而 Node 宿主进程可以——所以它用 `example.com` 下的一个路径,网络不可用
|
|
433
|
+
时跳过。
|
|
361
434
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
435
|
+
### 测试前置条件
|
|
436
|
+
|
|
437
|
+
插件有一个可选的运行期依赖——[`@deepseek-ai/schemastery`](https://www.npmjs.com/package/@deepseek-ai/schemastery),
|
|
438
|
+
由动态 `Config` schema(注册设置命名空间的那份)使用。凡是 import 插件本体的套件(`config-verify`、
|
|
439
|
+
`schema-verify`、`empty-verify`、`report-verify`、`http-verify`)都需要它能从工作区解析,所以先跑
|
|
440
|
+
`npm install`。
|
|
441
|
+
|
|
442
|
+
`schema-verify` 额外需要从 npm 全局安装的 `@deepseek-ai/dsh` 里 import 注册表自己的校验器
|
|
443
|
+
(`@deepseek-ai/dsh-tools`);`report-verify` 与引擎类套件需要真实 Moli 引擎,`empty-verify` 需要
|
|
444
|
+
C 编译器来构建它的桩引擎。在 Windows 上,`@deepseek-ai/schemastery` 可以通过目录 junction 指到
|
|
445
|
+
npm 全局 DSH 树来解析(与套件解析 `dsh-tools` 的方式相同)。
|
|
446
|
+
|
|
447
|
+
`.github/workflows/verify.yml` 在 Linux、Windows、macOS 上跑 `schema-verify`、`empty-verify`、
|
|
448
|
+
`scroll-verify` 与 `http-verify`(无需引擎、无需网络),在 Linux 与 Windows 上尝试依赖引擎的几套;
|
|
449
|
+
若 runner 上没有引擎,则报告为跳过而不是失败。
|
|
365
450
|
|
|
366
451
|
`report-verify.mjs` **故意不放进 CI**:其中三项会抓 `example.com`,还有一项抓一个具体的论坛帖子,因此它
|
|
367
452
|
需要能上外网——如果这台机器的流量走代理,还需要那个代理可用。有网络时它是有用的,但**一个因为网站改版就变
|
|
@@ -414,6 +499,19 @@ E404 Not Found - PUT https://registry.npmjs.org/dsh-moli
|
|
|
414
499
|
**这看起来像"包不存在",实际是认证失败。** 任何本地发布之前先跑
|
|
415
500
|
`node scripts/preflight-publish.mjs`:它会告诉你凭据能否认证,不能时明确点出这个误导性的 404。
|
|
416
501
|
|
|
502
|
+
### 废弃某个版本:去网站操作,别用命令行
|
|
503
|
+
|
|
504
|
+
发布是脚本化的,**废弃则刻意不做成脚本**。用 npmjs.com 上的包页面——**package → Settings →
|
|
505
|
+
Deprecate**——选版本、写理由。
|
|
506
|
+
|
|
507
|
+
`npm deprecate dsh-moli@1.1.11 "…"` 在本机凭据失效时会给出上面那个同样误导的失败,而且这里还有第二个
|
|
508
|
+
理由不要依赖 token:npm 在 **2026 年 7 月 31 日**起禁止 2FA-bypass 类型的 granular token 执行**包管理**
|
|
509
|
+
操作,也就是说"以前能让 `deprecate` 脚本化"的那类凭据正在被取消;而 `publish` 有受支持的、免 token 的
|
|
510
|
+
OIDC 路径。**一个只在"偶尔废弃版本"时才需要 token 的流程,注定在下次需要它时是坏的**——1.1.11 与 1.1.12
|
|
511
|
+
遇到的正是这种情况。
|
|
512
|
+
|
|
513
|
+
1.0.0 与 1.0.1 的废弃就是通过网站完成的。
|
|
514
|
+
|
|
417
515
|
## 许可
|
|
418
516
|
|
|
419
517
|
MIT,见 [LICENSE](LICENSE)。
|
package/client.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-moli — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Settings section (Moli install & fetch): the plugin's configuration —
|
|
5
|
+
* engine lifecycle (install / uninstall), forward proxy, per-call timeout,
|
|
6
|
+
* inline truncation budget, the TLS no-verify switch, and the engine path —
|
|
7
|
+
* edited on the plugin's page in the Plugins manager.
|
|
8
|
+
*
|
|
9
|
+
* Hand-written ModuleLoader bundle — no build step required.
|
|
10
|
+
*/
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: "dsh-moli",
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
var module = { exports: {} };
|
|
15
|
+
var exports = module.exports;
|
|
16
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
17
|
+
var react = require("react");
|
|
18
|
+
var h = react.createElement;
|
|
19
|
+
|
|
20
|
+
// ── CSS (theme tokens, same vocabulary as dsh-soul-md) ────────────────
|
|
21
|
+
var CSS = ".__moli_root{max-width:640px;display:flex;flex-direction:column;gap:10px}" +
|
|
22
|
+
".__moli_field{display:flex;flex-direction:column;gap:4px}" +
|
|
23
|
+
".__moli_label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px}" +
|
|
24
|
+
".__moli_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}" +
|
|
25
|
+
".__moli_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px;box-sizing:border-box;width:100%}" +
|
|
26
|
+
".__moli_actions{display:flex;gap:8px;align-items:center;margin-top:4px}" +
|
|
27
|
+
".__moli_btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 14px;font:inherit;font-size:13px;cursor:pointer}" +
|
|
28
|
+
".__moli_btn:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary)}" +
|
|
29
|
+
".__moli_btn:disabled{opacity:.5;cursor:default}" +
|
|
30
|
+
".__moli_btnPrimary{border-color:var(--dsw-alias-state-business-primary, #3964fe);background:var(--dsw-alias-state-business-primary, #3964fe);color:#fff}" +
|
|
31
|
+
".__moli_btnDanger{border-color:var(--dsw-alias-state-error-primary, #f85149);color:var(--dsw-alias-state-error-primary, #f85149)}" +
|
|
32
|
+
".__moli_status{font-size:12px;color:var(--dsw-alias-label-tertiary)}" +
|
|
33
|
+
".__moli_error{font-size:12px;color:var(--dsw-alias-state-error-primary)}" +
|
|
34
|
+
".__moli_unavailable{font-size:13px;color:var(--dsw-alias-label-tertiary)}" +
|
|
35
|
+
".__moli_group{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}" +
|
|
36
|
+
".__moli_engineRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}" +
|
|
37
|
+
".__moli_engineState{font-size:13px;color:var(--dsw-alias-label-primary);font-weight:600}" +
|
|
38
|
+
".__moli_engineDetail{font-size:11px;color:var(--dsw-alias-label-tertiary)}";
|
|
39
|
+
if (typeof document !== "undefined") {
|
|
40
|
+
var style = document.createElement("style");
|
|
41
|
+
style.textContent = CSS;
|
|
42
|
+
style.setAttribute("data-dsh-moli", "");
|
|
43
|
+
if (!document.head.querySelector("style[data-dsh-moli]")) document.head.appendChild(style);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── locale ────────────────────────────────────────────────────────────
|
|
47
|
+
var zh = {
|
|
48
|
+
nav: "Moli 设置",
|
|
49
|
+
sectionTitle: "Moli 安装与抓取",
|
|
50
|
+
unavailable: "配置不可用(可能当前 profile 未启用本插件)。",
|
|
51
|
+
loading: "正在读取配置…",
|
|
52
|
+
saved: "已保存。",
|
|
53
|
+
save: "保存",
|
|
54
|
+
error: "保存失败",
|
|
55
|
+
notApplied: "写入被宿主拒绝,表单已按宿主实际状态刷新。",
|
|
56
|
+
engineSection: "引擎",
|
|
57
|
+
engineInstalled: "已安装",
|
|
58
|
+
engineMissing: "未安装",
|
|
59
|
+
engineUnknown: "检测中…",
|
|
60
|
+
engineWorking: "正在处理…",
|
|
61
|
+
engineActionInstall: "安装引擎",
|
|
62
|
+
engineActionUninstall: "卸载引擎",
|
|
63
|
+
engineVersion: "版本 {version}",
|
|
64
|
+
engineInstallHint: "未检测到 Moli 引擎。点击「安装引擎」下载官方安装器并运行(需联网,可能耗时数分钟)。",
|
|
65
|
+
engineUninstallHint: "点击「卸载引擎」将删除本机安装的 Moli 二进制。",
|
|
66
|
+
fieldProxy: "转发代理",
|
|
67
|
+
hintProxy: '每次调用的代理覆盖项;留空表示直连。例如 "http://127.0.0.1:7890"。回环地址总是绕过代理。',
|
|
68
|
+
fieldDefaultTimeoutMs: "默认超时(毫秒)",
|
|
69
|
+
hintDefaultTimeoutMs: "单次调用默认超时,默认 30000(30 秒)。",
|
|
70
|
+
fieldMaxChars: "内联文本截断(字符)",
|
|
71
|
+
hintMaxChars: "内联文本最多返回的字符数,默认 200000;超出时保留头尾并注明省略量,传 writeTo 可保留全文。",
|
|
72
|
+
fieldInsecureTls: "跳过 TLS 验证(insecureTls)",
|
|
73
|
+
hintInsecureTls: '本网络证书吊销应答不可达时,GitHub 等 HTTPS 主机可能报错;开启后透传 moli 的 no-verify 标志。默认开启。',
|
|
74
|
+
fieldExePath: "引擎路径(exePath)",
|
|
75
|
+
hintExePath: "可选。指定 moli 可执行文件的绝对路径;留空则自动在常见安装位置查找。",
|
|
76
|
+
placeholderProxy: "http://127.0.0.1:7890",
|
|
77
|
+
placeholderExePath: "/home/user/.local/bin/moli",
|
|
78
|
+
};
|
|
79
|
+
var en = {
|
|
80
|
+
nav: "Moli settings",
|
|
81
|
+
sectionTitle: "Moli install & fetch",
|
|
82
|
+
unavailable: "Configuration unavailable (this profile may not enable the plugin).",
|
|
83
|
+
loading: "Loading configuration…",
|
|
84
|
+
saved: "Saved.",
|
|
85
|
+
save: "Save",
|
|
86
|
+
error: "Save failed",
|
|
87
|
+
notApplied: "The write was refused by the host; the form was reloaded to what the host actually holds.",
|
|
88
|
+
engineSection: "Engine",
|
|
89
|
+
engineInstalled: "Installed",
|
|
90
|
+
engineMissing: "Not installed",
|
|
91
|
+
engineUnknown: "Detecting…",
|
|
92
|
+
engineWorking: "Working…",
|
|
93
|
+
engineActionInstall: "Install engine",
|
|
94
|
+
engineActionUninstall: "Uninstall engine",
|
|
95
|
+
engineVersion: "version {version}",
|
|
96
|
+
engineInstallHint: "No Moli engine found. Click “Install engine” to download and run the official installer (needs network; may take a few minutes).",
|
|
97
|
+
engineUninstallHint: "Click “Uninstall engine” to remove the locally installed Moli binary.",
|
|
98
|
+
fieldProxy: "Forward proxy",
|
|
99
|
+
hintProxy: 'Per-call proxy override; leave empty for direct. E.g. "http://127.0.0.1:7890". Loopback URLs always bypass it.',
|
|
100
|
+
fieldDefaultTimeoutMs: "Default timeout (ms)",
|
|
101
|
+
hintDefaultTimeoutMs: "Per-call default timeout; 30000 (30 s) by default.",
|
|
102
|
+
fieldMaxChars: "Inline truncation budget (chars)",
|
|
103
|
+
hintMaxChars: "Max characters returned inline; 200000 by default. Beyond it the head and tail are kept with an omitted-count note; pass writeTo to keep everything.",
|
|
104
|
+
fieldInsecureTls: "Skip TLS verification (insecureTls)",
|
|
105
|
+
hintInsecureTls: "When this network's CRL/OCSP responder is unreachable, HTTPS hosts like GitHub fail; enabling passes moli's no-verify flag. On by default.",
|
|
106
|
+
fieldExePath: "Engine path (exePath)",
|
|
107
|
+
hintExePath: "Optional. Absolute path to the moli executable; leave empty to search the usual install locations.",
|
|
108
|
+
placeholderProxy: "http://127.0.0.1:7890",
|
|
109
|
+
placeholderExePath: "/home/user/.local/bin/moli",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ── write helper (same contract as dsh-soul-md) ───────────────────────
|
|
113
|
+
function settingsOpsApplied(snapshot, ops) {
|
|
114
|
+
var v = snapshot && snapshot.status === "ready" ? snapshot.value : void 0;
|
|
115
|
+
if (v === void 0) return false;
|
|
116
|
+
return ops.every(function (op) {
|
|
117
|
+
if (op.op === "unset") return v[op.path[0]] === void 0;
|
|
118
|
+
return v[op.path[0]] === op.value;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function commitSettingsOps(scope, ops) {
|
|
122
|
+
if (ops.length === 0) return Promise.resolve(true);
|
|
123
|
+
var run;
|
|
124
|
+
if (typeof scope.mutate === "function") {
|
|
125
|
+
run = scope.mutate(ops, scope.getSnapshot().revision);
|
|
126
|
+
} else {
|
|
127
|
+
run = ops.reduce(function (chain, op) {
|
|
128
|
+
return chain.then(function () {
|
|
129
|
+
return op.op === "unset" ? scope.unset(op.path[0]) : scope.set(op.path[0], op.value);
|
|
130
|
+
});
|
|
131
|
+
}, Promise.resolve());
|
|
132
|
+
}
|
|
133
|
+
return Promise.resolve(run).then(function () {
|
|
134
|
+
return settingsOpsApplied(scope.getSnapshot(), ops);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── field descriptors ─────────────────────────────────────────────────
|
|
139
|
+
// `select` fields store booleans (single option pair); the draft holds the
|
|
140
|
+
// string option and the ops map it back to a boolean.
|
|
141
|
+
var FIELDS = [
|
|
142
|
+
{ key: "proxy", label: "fieldProxy", type: "text", hint: "hintProxy", placeholder: "placeholderProxy" },
|
|
143
|
+
{ key: "defaultTimeoutMs", label: "fieldDefaultTimeoutMs", type: "number", min: 1000, hint: "hintDefaultTimeoutMs" },
|
|
144
|
+
{ key: "maxChars", label: "fieldMaxChars", type: "number", min: 100, hint: "hintMaxChars" },
|
|
145
|
+
{ key: "insecureTls", label: "fieldInsecureTls", type: "select", options: [["true", "true"], ["false", "false"]], hint: "hintInsecureTls" },
|
|
146
|
+
{ key: "exePath", label: "fieldExePath", type: "text", hint: "hintExePath", placeholder: "placeholderExePath" },
|
|
147
|
+
];
|
|
148
|
+
function toDraft(value) {
|
|
149
|
+
var out = {};
|
|
150
|
+
for (var i = 0; i < FIELDS.length; i++) {
|
|
151
|
+
var f = FIELDS[i];
|
|
152
|
+
var v = value && value[f.key];
|
|
153
|
+
if (f.type === "number") out[f.key] = typeof v === "number" ? String(v) : "";
|
|
154
|
+
else if (f.type === "select") out[f.key] = v ? "true" : "false";
|
|
155
|
+
else out[f.key] = typeof v === "string" ? v : "";
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function draftOps(draft) {
|
|
160
|
+
var ops = [];
|
|
161
|
+
for (var i = 0; i < FIELDS.length; i++) {
|
|
162
|
+
var f = FIELDS[i];
|
|
163
|
+
if (f.type === "number") {
|
|
164
|
+
var n = Number(draft[f.key]);
|
|
165
|
+
if (draft[f.key] !== "" && Number.isFinite(n)) ops.push({ op: "set", path: [f.key], value: n });
|
|
166
|
+
} else if (f.type === "select") {
|
|
167
|
+
ops.push({ op: "set", path: [f.key], value: draft[f.key] === "true" });
|
|
168
|
+
} else {
|
|
169
|
+
ops.push({ op: "set", path: [f.key], value: typeof draft[f.key] === "string" ? draft[f.key] : "" });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return ops;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── settings section ──────────────────────────────────────────────────
|
|
176
|
+
function MoliConfigSection(props) {
|
|
177
|
+
useLocale(props.locale);
|
|
178
|
+
var t = props.t;
|
|
179
|
+
var scope = props.scope;
|
|
180
|
+
var [snapshot, setSnapshot] = react.useState(function () { return scope.getSnapshot(); });
|
|
181
|
+
var ready = snapshot.status === "ready" && snapshot.value !== void 0;
|
|
182
|
+
var [draft, setDraft] = react.useState(null);
|
|
183
|
+
var [busy, setBusy] = react.useState(false);
|
|
184
|
+
var [notice, setNotice] = react.useState(null);
|
|
185
|
+
var [error, setError] = react.useState(null);
|
|
186
|
+
var writePending = react.useRef(false);
|
|
187
|
+
|
|
188
|
+
react.useEffect(function () {
|
|
189
|
+
var alive = true;
|
|
190
|
+
var sync = function () { if (alive) setSnapshot(scope.getSnapshot()); };
|
|
191
|
+
var un = typeof scope.subscribe === "function" ? scope.subscribe(sync) : null;
|
|
192
|
+
// Bound once per plugin, shared across mounts: unmount only unsubscribes,
|
|
193
|
+
// never disposes, so a later remount does not find a dead write queue.
|
|
194
|
+
return function () { alive = false; if (un) un(); };
|
|
195
|
+
}, [scope]);
|
|
196
|
+
|
|
197
|
+
if (snapshot.status === "unavailable") {
|
|
198
|
+
return h("p", { className: "__moli_unavailable" }, t("unavailable"));
|
|
199
|
+
}
|
|
200
|
+
if (!ready) return h("p", { className: "__moli_status" }, t("loading"));
|
|
201
|
+
|
|
202
|
+
var value = snapshot.value;
|
|
203
|
+
var currentDraft = draft === null ? toDraft(value) : draft;
|
|
204
|
+
var valueKey = JSON.stringify(toDraft(value));
|
|
205
|
+
var draftKey = JSON.stringify(currentDraft);
|
|
206
|
+
var dirty = valueKey !== draftKey;
|
|
207
|
+
var saving = busy;
|
|
208
|
+
|
|
209
|
+
function setField(f, v) { setNotice(null); setError(null); setDraft(function (p) { return Object.assign({}, p, { [f.key]: v }); }); }
|
|
210
|
+
|
|
211
|
+
function onSave() {
|
|
212
|
+
if (writePending.current) return;
|
|
213
|
+
writePending.current = true;
|
|
214
|
+
setBusy(true); setNotice(null); setError(null);
|
|
215
|
+
commitSettingsOps(scope, draftOps(currentDraft)).then(function (ok) {
|
|
216
|
+
writePending.current = false;
|
|
217
|
+
setBusy(false);
|
|
218
|
+
setSnapshot(scope.getSnapshot());
|
|
219
|
+
if (!ok) { setError(t("notApplied")); return; }
|
|
220
|
+
setNotice(t("saved"));
|
|
221
|
+
setDraft(null);
|
|
222
|
+
}).catch(function (e) {
|
|
223
|
+
writePending.current = false;
|
|
224
|
+
setBusy(false);
|
|
225
|
+
setSnapshot(scope.getSnapshot());
|
|
226
|
+
setError(String(e && e.message || e));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Engine lifecycle: the Host reacts to `engineAction` and reports back
|
|
231
|
+
// through `engineState` / `engineVersion`. While the action is pending the
|
|
232
|
+
// form shows "working…"; scope updates (including the reset to `none`)
|
|
233
|
+
// drive the display back to the resulting state.
|
|
234
|
+
var engineState = typeof value.engineState === "string" ? value.engineState : "unknown";
|
|
235
|
+
var engineVersion = typeof value.engineVersion === "string" ? value.engineVersion : "";
|
|
236
|
+
var engineAction = typeof value.engineAction === "string" ? value.engineAction : "none";
|
|
237
|
+
var engineWorking = engineAction !== "none";
|
|
238
|
+
var engineLabel = engineWorking ? t("engineWorking")
|
|
239
|
+
: engineState === "installed" ? t("engineInstalled")
|
|
240
|
+
: engineState === "missing" ? t("engineMissing")
|
|
241
|
+
: t("engineUnknown");
|
|
242
|
+
|
|
243
|
+
function onEngineAction(action) {
|
|
244
|
+
if (engineWorking) return;
|
|
245
|
+
if (action === "uninstall" && !window.confirm(t("engineUninstallHint"))) return;
|
|
246
|
+
setNotice(null); setError(null);
|
|
247
|
+
commitSettingsOps(scope, [{ op: "set", path: ["engineAction"], value: action }]).then(function (ok) {
|
|
248
|
+
setSnapshot(scope.getSnapshot());
|
|
249
|
+
if (!ok) setError(t("notApplied"));
|
|
250
|
+
}).catch(function (e) {
|
|
251
|
+
setSnapshot(scope.getSnapshot());
|
|
252
|
+
setError(String(e && e.message || e));
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return h("div", { className: "__moli_root" },
|
|
257
|
+
// Engine lifecycle group.
|
|
258
|
+
h("div", { className: "__moli_group" },
|
|
259
|
+
h("div", { className: "__moli_label" }, t("engineSection")),
|
|
260
|
+
h("div", { className: "__moli_engineRow" },
|
|
261
|
+
h("span", { className: "__moli_engineState" }, engineLabel),
|
|
262
|
+
engineVersion && engineState === "installed"
|
|
263
|
+
? h("span", { className: "__moli_engineDetail" }, t("engineVersion").replace("{version}", engineVersion))
|
|
264
|
+
: null,
|
|
265
|
+
),
|
|
266
|
+
engineState !== "installed" && !engineWorking
|
|
267
|
+
? h("span", { className: "__moli_engineDetail" }, t("engineInstallHint"))
|
|
268
|
+
: engineState === "installed" && !engineWorking
|
|
269
|
+
? h("span", { className: "__moli_engineDetail" }, t("engineUninstallHint"))
|
|
270
|
+
: null,
|
|
271
|
+
h("div", { className: "__moli_actions" },
|
|
272
|
+
engineState === "installed"
|
|
273
|
+
? h("button", { className: "__moli_btn __moli_btnDanger", disabled: engineWorking, onClick: function () { onEngineAction("uninstall"); } }, t("engineActionUninstall"))
|
|
274
|
+
: h("button", { className: "__moli_btn __moli_btnPrimary", disabled: engineWorking, onClick: function () { onEngineAction("install"); } }, t("engineActionInstall")),
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
// Configuration fields group.
|
|
278
|
+
h("div", { className: "__moli_group" },
|
|
279
|
+
FIELDS.map(function (f) {
|
|
280
|
+
if (f.type === "select") {
|
|
281
|
+
return h("label", { key: f.key, className: "__moli_field" },
|
|
282
|
+
h("span", { className: "__moli_label" }, t(f.label)),
|
|
283
|
+
h("select", {
|
|
284
|
+
className: "__moli_input",
|
|
285
|
+
value: currentDraft[f.key],
|
|
286
|
+
onChange: function (e) { setField(f, e.target.value); },
|
|
287
|
+
}, f.options.map(function (o) {
|
|
288
|
+
// Option labels are the raw stored values ("true"/"false"),
|
|
289
|
+
// never translated; only the field label and hint are locale-bound.
|
|
290
|
+
return h("option", { key: o[0], value: o[0] }, o[1]);
|
|
291
|
+
})),
|
|
292
|
+
f.hint ? h("span", { className: "__moli_hint" }, t(f.hint)) : null,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return h("label", { key: f.key, className: "__moli_field" },
|
|
296
|
+
h("span", { className: "__moli_label" }, t(f.label)),
|
|
297
|
+
h("input", {
|
|
298
|
+
className: "__moli_input",
|
|
299
|
+
type: f.type === "number" ? "number" : "text",
|
|
300
|
+
min: f.min,
|
|
301
|
+
value: currentDraft[f.key],
|
|
302
|
+
placeholder: f.placeholder ? t(f.placeholder) : void 0,
|
|
303
|
+
onChange: function (e) { setField(f, e.target.value); },
|
|
304
|
+
}),
|
|
305
|
+
f.hint ? h("span", { className: "__moli_hint" }, t(f.hint)) : null,
|
|
306
|
+
);
|
|
307
|
+
}),
|
|
308
|
+
h("div", { className: "__moli_actions" },
|
|
309
|
+
h("button", { className: "__moli_btn __moli_btnPrimary", disabled: saving || !dirty, onClick: onSave }, t("save")),
|
|
310
|
+
notice ? h("span", { className: "__moli_status" }, notice) : null,
|
|
311
|
+
error ? h("span", { className: "__moli_error" }, error) : null,
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
var NS = "dsh-moli";
|
|
318
|
+
|
|
319
|
+
// Keep the section re-rendering when the active locale changes.
|
|
320
|
+
function useLocale(locale) {
|
|
321
|
+
var refresh = react.useState(0)[1];
|
|
322
|
+
react.useEffect(function () {
|
|
323
|
+
if (!locale || typeof locale.subscribe !== "function") return;
|
|
324
|
+
return locale.subscribe(function () { refresh(function (revision) { return revision + 1; }); });
|
|
325
|
+
}, [locale]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function apply(ctx) {
|
|
329
|
+
var t = ctx.locale.bind(NS);
|
|
330
|
+
ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-moli: dictionaries");
|
|
331
|
+
var scope = ctx.configForms.get("dsh-moli");
|
|
332
|
+
ctx.slots.inject("plugins.bundle.config", function () {
|
|
333
|
+
return ctx.slots.register({
|
|
334
|
+
name: "plugins.bundle.config",
|
|
335
|
+
key: "dsh-moli",
|
|
336
|
+
locale: NS
|
|
337
|
+
}, function (props) {
|
|
338
|
+
return h(MoliConfigSection, Object.assign({}, props, { scope: scope, t: t, locale: ctx.locale }));
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
exports.apply = apply;
|
|
344
|
+
exports.inject = ["slots", "locale", "configForms"];
|
|
345
|
+
return module.exports;
|
|
346
|
+
}
|
|
347
|
+
});
|
package/index.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
/** Host half of dsh-moli: expose the local `moli` headless browser as callable tools. */
|
|
2
|
-
import { MANUAL_INSTALL, installMoli } from './lib/install.js';
|
|
3
|
-
import { findMoli, moliCandidates, probe, runFetch } from './lib/moli.js';
|
|
2
|
+
import { MANUAL_INSTALL, installMoli, uninstallMoli } from './lib/install.js';
|
|
3
|
+
import { findMoli, moliCandidates, moliVersion, probe, runFetch } from './lib/moli.js';
|
|
4
4
|
|
|
5
5
|
export const name = 'dsh-moli';
|
|
6
6
|
export const inject = ['tools'];
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* dependency stays a symlink target), so a bare specifier
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* `@deepseek-ai/schemastery` powers the live Config schema and therefore the
|
|
10
|
+
* settings namespace. A profile plugin is loaded from its real path on disk (a
|
|
11
|
+
* `link:` dependency stays a symlink target), so a bare specifier must resolve
|
|
12
|
+
* against the plugin's own `node_modules` chain — which does not exist when the
|
|
13
|
+
* plugin is installed as a `link:`/junction (the README's first install route).
|
|
14
|
+
* The import is therefore optional: without schemastery the settings form does
|
|
15
|
+
* not appear and the plugin degrades to plain patch-layer configuration, while
|
|
16
|
+
* `moli_fetch` / `moli_probe` keep registering — those are the plugin's reason
|
|
17
|
+
* to exist, and losing them would be worse than losing the form.
|
|
16
18
|
*/
|
|
19
|
+
let z;
|
|
20
|
+
try {
|
|
21
|
+
z = (await import('@deepseek-ai/schemastery')).default;
|
|
22
|
+
} catch {
|
|
23
|
+
z = undefined;
|
|
24
|
+
}
|
|
17
25
|
|
|
18
26
|
/** Deployment configuration defaults; override via the plugin's `config` entry. */
|
|
19
27
|
const DEFAULTS = {
|
|
@@ -27,6 +35,35 @@ const DEFAULTS = {
|
|
|
27
35
|
insecureTls: true,
|
|
28
36
|
};
|
|
29
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Live Config schema. Declaring it (and exporting it) makes DSH register the
|
|
40
|
+
* `dsh-moli` settings namespace, which the browser half's `plugins.bundle.config`
|
|
41
|
+
* form edits. `.volatile()` marks the whole object live so the namespace appears
|
|
42
|
+
* in the settings describe mirror and the form's writes are applied via the
|
|
43
|
+
* ConfigEditor / volatile HMR path.
|
|
44
|
+
*
|
|
45
|
+
* `undefined` when `@deepseek-ai/schemastery` cannot be resolved (a `link:`
|
|
46
|
+
* install) — then no settings namespace is registered and the engine lifecycle
|
|
47
|
+
* is skipped, but the tools still register.
|
|
48
|
+
*/
|
|
49
|
+
export const Config = z
|
|
50
|
+
? z.object({
|
|
51
|
+
defaultTimeoutMs: z.number().default(DEFAULTS.defaultTimeoutMs),
|
|
52
|
+
maxChars: z.number().default(DEFAULTS.maxChars),
|
|
53
|
+
insecureTls: z.boolean().default(DEFAULTS.insecureTls),
|
|
54
|
+
proxy: z.string().default(''),
|
|
55
|
+
exePath: z.string().default(''),
|
|
56
|
+
// Engine lifecycle, driven by the settings form. The form writes an action
|
|
57
|
+
// (`install` / `uninstall`) and the Host executes it asynchronously, then
|
|
58
|
+
// reports the outcome back through `engineState` / `engineVersion` and resets
|
|
59
|
+
// `engineAction` to `none`. `engineState` is `installed`, `missing`, or a
|
|
60
|
+
// short error text.
|
|
61
|
+
engineAction: z.union([z.const('none'), z.const('install'), z.const('uninstall')]).default('none'),
|
|
62
|
+
engineState: z.string().default('unknown'),
|
|
63
|
+
engineVersion: z.string().default(''),
|
|
64
|
+
}).volatile()
|
|
65
|
+
: undefined;
|
|
66
|
+
|
|
30
67
|
/**
|
|
31
68
|
* Parameter schema of `moli_fetch`.
|
|
32
69
|
*
|
|
@@ -77,6 +114,22 @@ const FETCH_PARAMETERS = {
|
|
|
77
114
|
description: 'Readiness stage to wait for. Defaults to `done`.',
|
|
78
115
|
},
|
|
79
116
|
waitSelector: { type: 'string', description: 'Wait until this CSS selector matches.' },
|
|
117
|
+
waitScript: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description: 'Wait until this JavaScript expression is truthy; a function result is called as the predicate on each poll. More precise than a fixed delay when the page signals readiness in its own terms.',
|
|
120
|
+
},
|
|
121
|
+
waitResponseUrl: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: 'Wait for a response whose original or final URL contains this substring. This is the reliable way to await an AJAX-built list: `wait: "networkidle"` plus a delay is a guess, whereas this waits for the request that actually carries the data.',
|
|
124
|
+
},
|
|
125
|
+
waitResponseJson: {
|
|
126
|
+
type: 'string',
|
|
127
|
+
description: 'Wait for a response whose JSON field equals a value, written as PATH=VALUE (for example "data.list.0.id=42"). The counterpart to waitResponseUrl when the condition is in the payload rather than the URL.',
|
|
128
|
+
},
|
|
129
|
+
failOnHttpError: {
|
|
130
|
+
type: 'boolean',
|
|
131
|
+
description: 'Treat a final main-document HTTP status of 400 or above as a failure. Without it an error page comes back as ordinary content and a 404 reads like a successful fetch; with it the result is ok:false and reports the status, while still carrying the rendered body so the error page can be read. Off by default, so existing calls keep their behaviour.',
|
|
132
|
+
},
|
|
80
133
|
delayMs: { type: 'integer', description: 'Extra milliseconds to wait after readiness.' },
|
|
81
134
|
timeoutMs: { type: 'integer', description: 'Total readiness deadline in milliseconds. Defaults to the plugin configuration.' },
|
|
82
135
|
maxChars: { type: 'integer', description: 'Truncate inline text output to this many characters. Defaults to the plugin configuration.' },
|
|
@@ -125,6 +178,7 @@ const FETCH_OUTPUT_SCHEMA = {
|
|
|
125
178
|
error: { type: 'string' },
|
|
126
179
|
hint: { type: 'string' },
|
|
127
180
|
status: { type: 'integer' },
|
|
181
|
+
httpError: { type: 'boolean' },
|
|
128
182
|
sandboxTlsBlock: { type: 'boolean' },
|
|
129
183
|
unreachable: { type: 'boolean' },
|
|
130
184
|
possiblyUnreachable: { type: 'boolean' },
|
|
@@ -172,6 +226,14 @@ function renderFetch(_args, value) {
|
|
|
172
226
|
if (value.unreachable === true) lines.push('Cause: the host did not answer; a local forward proxy is the usual fix on this network.');
|
|
173
227
|
else if (value.possiblyUnreachable === true) lines.push('Cause: the host did not answer in time; it may be blocked, or the page may simply be slow.');
|
|
174
228
|
if (typeof value.hint === 'string' && value.hint !== '') lines.push(`Next: ${value.hint}`);
|
|
229
|
+
// An HTTP error keeps its body under `failOnHttpError`. Reporting the failure
|
|
230
|
+
// without the body would throw away the error page, which is the part worth
|
|
231
|
+
// reading -- and for a binary dump the body is the file that was written.
|
|
232
|
+
if (value.kind === 'binary' && typeof value.path === 'string') {
|
|
233
|
+
lines.push(`The error page was still written to ${value.path} (${String(value.bytes)} bytes).`);
|
|
234
|
+
} else if (typeof value.content === 'string' && value.content !== '') {
|
|
235
|
+
lines.push('', 'The server\'s response body:', '', value.content);
|
|
236
|
+
}
|
|
175
237
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
176
238
|
}
|
|
177
239
|
if (value.kind === 'binary') {
|
|
@@ -240,7 +302,10 @@ function renderProbe(_args, value) {
|
|
|
240
302
|
* @param config - deployment configuration for paths, timeout, and output size.
|
|
241
303
|
*/
|
|
242
304
|
export function apply(ctx, config) {
|
|
243
|
-
|
|
305
|
+
// The Config schema is declared volatile, so DSH hands it as a live reference
|
|
306
|
+
// whose `.get()` reads the current values; a deployment that mounts an older
|
|
307
|
+
// loader without volatile wiring passes a plain object instead. Read either.
|
|
308
|
+
const options = typeof config?.get === 'function' ? config.get() : (config ?? {});
|
|
244
309
|
// Mutable: a successful install resolves a path this process can use at once,
|
|
245
310
|
// without waiting for a restart to re-run discovery.
|
|
246
311
|
let cachedExe = findMoli(options.exePath);
|
|
@@ -359,5 +424,167 @@ export function apply(ctx, config) {
|
|
|
359
424
|
},
|
|
360
425
|
});
|
|
361
426
|
|
|
427
|
+
// ── engine lifecycle via the settings form ─────────────────────────────
|
|
428
|
+
// The browser half's `plugins.bundle.config` form drives install/uninstall
|
|
429
|
+
// through the `engineAction` field and reads the outcome from
|
|
430
|
+
// `engineState`/`engineVersion`. State is written back only when it changes,
|
|
431
|
+
// and a finished action always resets `engineAction` to `none`, so the
|
|
432
|
+
// resulting `settings/document-updated` re-entry lands in the plain sync
|
|
433
|
+
// branch instead of looping the action.
|
|
434
|
+
//
|
|
435
|
+
// Everything here depends on the settings namespace, which exists only when
|
|
436
|
+
// schemastery resolved (see the `Config` export): without it there is no form
|
|
437
|
+
// to drive the lifecycle, so the whole block is skipped and the plugin is
|
|
438
|
+
// pure tool registration.
|
|
439
|
+
if (Config) {
|
|
440
|
+
const NS = 'dsh-moli';
|
|
441
|
+
let engineBusy = false;
|
|
442
|
+
const currentConfig = () => (typeof config.get === 'function' ? config.get() : (config ?? {}));
|
|
443
|
+
// Write settings outside the current HMR transaction. `settings.update`
|
|
444
|
+
// routes through dsh-hmr's runExclusive, which rejects when it is called
|
|
445
|
+
// from inside an active transaction — the settings/document-updated event
|
|
446
|
+
// handler runs in one, so a direct update throws
|
|
447
|
+
// "HMR transactions cannot be nested" and the write-back is silently lost
|
|
448
|
+
// (the form then stays on "working…" forever). setImmediate defers the
|
|
449
|
+
// update until the event transaction has exited.
|
|
450
|
+
const writeSettings = (settings, patch) =>
|
|
451
|
+
new Promise((resolve, reject) => {
|
|
452
|
+
setImmediate(() => {
|
|
453
|
+
settings.update(NS, patch).then(resolve, reject);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
const syncEngineState = async (settings, current) => {
|
|
457
|
+
const exe = findMoli(current.exePath);
|
|
458
|
+
const state = exe === undefined ? 'missing' : 'installed';
|
|
459
|
+
const version = exe === undefined ? '' : (moliVersion(exe) ?? '');
|
|
460
|
+
if (current.engineState === state && current.engineVersion === version) return;
|
|
461
|
+
try {
|
|
462
|
+
await writeSettings(settings, { engineState: state, engineVersion: version });
|
|
463
|
+
} catch (error) {
|
|
464
|
+
console.error(`[dsh-moli] engine-state sync failed: ${String(error?.message ?? error)}`);
|
|
465
|
+
ctx.logger?.warn?.('dsh-moli: engine-state sync failed', String(error?.message ?? error));
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
const runEngineAction = async (settings, current, action) => {
|
|
469
|
+
if (engineBusy) return;
|
|
470
|
+
engineBusy = true;
|
|
471
|
+
try {
|
|
472
|
+
if (action === 'install') {
|
|
473
|
+
const outcome = await installMoli({
|
|
474
|
+
exePath: current.exePath,
|
|
475
|
+
timeoutMs: 180000,
|
|
476
|
+
proxy: current.proxy,
|
|
477
|
+
locate: findMoli,
|
|
478
|
+
candidates: moliCandidates(current.exePath),
|
|
479
|
+
});
|
|
480
|
+
cachedExe = outcome.ok === true ? (outcome.exe ?? cachedExe) : cachedExe;
|
|
481
|
+
await writeSettings(settings, {
|
|
482
|
+
engineAction: 'none',
|
|
483
|
+
engineState: outcome.ok === true ? 'installed' : `install failed: ${String(outcome.error ?? 'unknown error').slice(0, 200)}`,
|
|
484
|
+
engineVersion: outcome.ok === true ? String(outcome.version ?? '') : '',
|
|
485
|
+
});
|
|
486
|
+
} else if (action === 'uninstall') {
|
|
487
|
+
const removed = uninstallMoli(current.exePath);
|
|
488
|
+
cachedExe = undefined;
|
|
489
|
+
await writeSettings(settings, {
|
|
490
|
+
engineAction: 'none',
|
|
491
|
+
engineState: removed === undefined ? 'uninstall failed: nothing to remove' : 'missing',
|
|
492
|
+
engineVersion: '',
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
// console (not ctx.logger) so the failure reaches the service journal
|
|
497
|
+
console.error(`[dsh-moli] engine action failed: ${String(error?.message ?? error)}`);
|
|
498
|
+
ctx.logger?.error?.('dsh-moli: engine action failed', String(error?.message ?? error));
|
|
499
|
+
try {
|
|
500
|
+
await writeSettings(settings, {
|
|
501
|
+
engineAction: 'none',
|
|
502
|
+
engineState: `action failed: ${String(error?.message ?? error).slice(0, 200)}`,
|
|
503
|
+
});
|
|
504
|
+
} catch (error2) {
|
|
505
|
+
console.error(`[dsh-moli] engine action failure report could not be written back: ${String(error2?.message ?? error2)}`);
|
|
506
|
+
ctx.logger?.error?.('dsh-moli: engine action failure report could not be written back', String(error2?.message ?? error2));
|
|
507
|
+
}
|
|
508
|
+
} finally {
|
|
509
|
+
engineBusy = false;
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
// Dispatch an engineAction if one is pending, otherwise sync state. Shared by
|
|
513
|
+
// the settings update handler and the boot-time initial sync, so an action
|
|
514
|
+
// whose `settings/document-updated` event was lost (a crash between the
|
|
515
|
+
// form's write and the handler) is still picked up on the next boot instead
|
|
516
|
+
// of leaving the form stuck on "processing".
|
|
517
|
+
// The event fires inside a dsh-hmr transaction. That transaction propagates
|
|
518
|
+
// through AsyncLocalStorage into anything the handler spawns — including
|
|
519
|
+
// setTimeout/setImmediate callbacks — so a deferred settings.update still
|
|
520
|
+
// nests and is rejected ("HMR transactions cannot be nested"). dsh-hmr
|
|
521
|
+
// itself escapes its own transaction with `executing.exit(...)` (watchConfig
|
|
522
|
+
// does this); running the dispatch through the same exit clears the ALS
|
|
523
|
+
// store so the write-back opens its own outermost transaction. The poller
|
|
524
|
+
// below stays as a defensive fallback (exit missing, or an event lost
|
|
525
|
+
// between the form's write and the handler).
|
|
526
|
+
const dispatchOutsideTransaction = (settings) => {
|
|
527
|
+
const hmr = ctx.get?.('hmr');
|
|
528
|
+
const run = () => {
|
|
529
|
+
const current = currentConfig();
|
|
530
|
+
const action = current.engineAction;
|
|
531
|
+
if (action === 'install' || action === 'uninstall') void runEngineAction(settings, current, action);
|
|
532
|
+
else void syncEngineState(settings, current);
|
|
533
|
+
};
|
|
534
|
+
if (hmr && typeof hmr.executing?.exit === 'function') {
|
|
535
|
+
hmr.executing.exit(run);
|
|
536
|
+
} else {
|
|
537
|
+
run();
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
// Actions are driven by an independent poller as a fallback, and by the
|
|
541
|
+
// transaction-escaped dispatch on the event path. A setInterval callback
|
|
542
|
+
// runs in the timer phase, outside any transaction, so the action and its
|
|
543
|
+
// write-back are safe there too.
|
|
544
|
+
let pollTimer = null;
|
|
545
|
+
let pendingCheck = false;
|
|
546
|
+
const pollCheck = () => {
|
|
547
|
+
const settings = ctx.get('settings');
|
|
548
|
+
if (!settings) return;
|
|
549
|
+
const current = currentConfig();
|
|
550
|
+
const action = current.engineAction;
|
|
551
|
+
if (action === 'install' || action === 'uninstall') {
|
|
552
|
+
void runEngineAction(settings, current, action);
|
|
553
|
+
} else {
|
|
554
|
+
void syncEngineState(settings, current);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
ctx.on?.('settings/document-updated', (id) => {
|
|
558
|
+
if (id !== NS) return;
|
|
559
|
+
const settings = ctx.get('settings');
|
|
560
|
+
if (!settings) return;
|
|
561
|
+
pendingCheck = true;
|
|
562
|
+
dispatchOutsideTransaction(settings);
|
|
563
|
+
});
|
|
564
|
+
pollTimer = setInterval(() => {
|
|
565
|
+
if (pendingCheck) {
|
|
566
|
+
pendingCheck = false;
|
|
567
|
+
pollCheck();
|
|
568
|
+
}
|
|
569
|
+
}, 500);
|
|
570
|
+
pollTimer.unref?.();
|
|
571
|
+
// Initial state once settings is live. `ctx.get('settings')` may be null in a
|
|
572
|
+
// boot-time effect (the service mounts later), so retry briefly; the poller
|
|
573
|
+
// also picks up any pending engineAction written before the service settled.
|
|
574
|
+
let syncAttempts = 0;
|
|
575
|
+
const tryInitialSync = () => {
|
|
576
|
+
const settings = ctx.get('settings');
|
|
577
|
+
if (!settings) return false;
|
|
578
|
+
pollCheck();
|
|
579
|
+
return true;
|
|
580
|
+
};
|
|
581
|
+
if (!tryInitialSync()) {
|
|
582
|
+
const timer = setInterval(() => {
|
|
583
|
+
if (tryInitialSync() || ++syncAttempts >= 40) clearInterval(timer);
|
|
584
|
+
}, 250);
|
|
585
|
+
timer.unref?.();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
362
589
|
ctx.logger?.info?.(`dsh-moli: registered moli_fetch and moli_probe (${cachedExe ?? 'moli missing'})`);
|
|
363
590
|
}
|
package/lib/install.js
CHANGED
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { spawn, spawnSync } from 'node:child_process';
|
|
38
38
|
import { createHash } from 'node:crypto';
|
|
39
|
-
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
39
|
+
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
40
40
|
import { createConnection } from 'node:net';
|
|
41
41
|
import { tmpdir } from 'node:os';
|
|
42
|
-
import { join } from 'node:path';
|
|
42
|
+
import { dirname, join } from 'node:path';
|
|
43
43
|
import { findMoli } from './moli.js';
|
|
44
44
|
|
|
45
45
|
/** Official installer for the latest release. */
|
|
@@ -673,7 +673,7 @@ export async function installMoli(options = {}) {
|
|
|
673
673
|
return {
|
|
674
674
|
ok: false,
|
|
675
675
|
error: `installer checksum mismatch: expected ${expected}, got ${download.sha256}`,
|
|
676
|
-
|
|
676
|
+
installerSha256: download.sha256,
|
|
677
677
|
expectedSha256: expected,
|
|
678
678
|
method: download.method,
|
|
679
679
|
note: 'Refusing to execute the installer.',
|
|
@@ -759,3 +759,37 @@ export async function installMoli(options = {}) {
|
|
|
759
759
|
rmSync(workDir, { recursive: true, force: true });
|
|
760
760
|
}
|
|
761
761
|
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Remove an installed Moli engine. Windows deletes the installer-owned
|
|
765
|
+
* `%LOCALAPPDATA%\Moli` tree; POSIX removes the located binary and prunes the
|
|
766
|
+
* install directories it sits in when they end up empty.
|
|
767
|
+
*
|
|
768
|
+
* @param override - explicit path from configuration, when set.
|
|
769
|
+
* @returns the removed path, or undefined when nothing was installed.
|
|
770
|
+
*/
|
|
771
|
+
export function uninstallMoli(override) {
|
|
772
|
+
const exe = findMoli(override);
|
|
773
|
+
if (exe === undefined) return undefined;
|
|
774
|
+
if (process.platform === 'win32') {
|
|
775
|
+
const root = process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Moli') : undefined;
|
|
776
|
+
if (root !== undefined && existsSync(root)) {
|
|
777
|
+
rmSync(root, { recursive: true, force: true });
|
|
778
|
+
return root;
|
|
779
|
+
}
|
|
780
|
+
// No installer-owned tree: nothing to remove. Returning here keeps the
|
|
781
|
+
// uninstall from falling through to the POSIX branch and deleting whatever
|
|
782
|
+
// findMoli located — which could be a user-provided path or a test fixture.
|
|
783
|
+
return undefined;
|
|
784
|
+
}
|
|
785
|
+
rmSync(exe, { force: true });
|
|
786
|
+
const dir = dirname(exe);
|
|
787
|
+
for (const parent of [dir, dirname(dir)]) {
|
|
788
|
+
try {
|
|
789
|
+
if (existsSync(parent) && readdirSync(parent).length === 0) rmSync(parent, { force: true });
|
|
790
|
+
} catch {
|
|
791
|
+
// A non-empty or protected parent is fine; the binary is already gone.
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return exe;
|
|
795
|
+
}
|
package/lib/moli.js
CHANGED
|
@@ -31,6 +31,13 @@ const TEXT_DUMPS = new Set([undefined, 'html', 'markdown', 'json', 'semantic_tre
|
|
|
31
31
|
/** Accepted `--wait` lifecycle stages. */
|
|
32
32
|
const WAIT_STAGES = new Set(['domcontentloaded', 'load', 'networkidle', 'domstable', 'done']);
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The exit code the engine uses for "the final main-document status was >= 400".
|
|
36
|
+
* Its help states it for `--fail` and `--fail-with-body`; treating it specially is
|
|
37
|
+
* what lets an HTTP error be reported without discarding the body.
|
|
38
|
+
*/
|
|
39
|
+
const ENGINE_HTTP_ERROR_STATUS = 22;
|
|
40
|
+
|
|
34
41
|
/** `scroll` keywords, mapped to what they mean in the page. */
|
|
35
42
|
const SCROLL_TOKENS = {
|
|
36
43
|
top: '0',
|
|
@@ -300,7 +307,18 @@ export function fetchOnce(options) {
|
|
|
300
307
|
* @param args - validated tool arguments.
|
|
301
308
|
* @returns the argv tail (everything after the executable).
|
|
302
309
|
*/
|
|
303
|
-
|
|
310
|
+
/**
|
|
311
|
+
* Build the `moli fetch` argument vector for one call.
|
|
312
|
+
*
|
|
313
|
+
* Exported so the mapping from tool parameters to engine flags can be asserted
|
|
314
|
+
* directly. That matters more than it sounds: a parameter that silently fails to
|
|
315
|
+
* reach the engine is indistinguishable from a feature that does not work, and the
|
|
316
|
+
* only fast, deterministic way to tell them apart is to check the vector.
|
|
317
|
+
*
|
|
318
|
+
* @param args - tool arguments.
|
|
319
|
+
* @returns the argv tail (without the executable).
|
|
320
|
+
*/
|
|
321
|
+
export function buildArgs(args) {
|
|
304
322
|
if (args.dump !== undefined && !TEXT_DUMPS.has(args.dump) && !BINARY_DUMPS.has(args.dump)) {
|
|
305
323
|
throw new Error(`unsupported dump: ${String(args.dump)}`);
|
|
306
324
|
}
|
|
@@ -320,6 +338,16 @@ function buildArgs(args) {
|
|
|
320
338
|
if (evaluation !== undefined) argv.push('--eval', evaluation);
|
|
321
339
|
if (args.wait !== undefined && args.wait !== '') argv.push('--wait', args.wait);
|
|
322
340
|
if (args.waitSelector !== undefined && args.waitSelector !== '') argv.push('--wait-selector', args.waitSelector);
|
|
341
|
+
if (args.waitScript !== undefined && args.waitScript !== '') argv.push('--wait-script', args.waitScript);
|
|
342
|
+
// Response waits: the right way to await an AJAX list, where a fixed delay is a
|
|
343
|
+
// guess. The engine takes one of each, and a caller supplying several is asking
|
|
344
|
+
// for "any of these", which is the engine's own semantics for multiple waits.
|
|
345
|
+
if (args.waitResponseUrl !== undefined && args.waitResponseUrl !== '') argv.push('--wait-response-url', args.waitResponseUrl);
|
|
346
|
+
if (args.waitResponseJson !== undefined && args.waitResponseJson !== '') argv.push('--wait-response-json', args.waitResponseJson);
|
|
347
|
+
// The engine's own switch, but only when the caller asked for a failure to be
|
|
348
|
+
// surfaced: adding it unconditionally would turn every 404 into `ok: false` and
|
|
349
|
+
// change what existing calls do.
|
|
350
|
+
if (args.failOnHttpError === true) argv.push('--fail-with-body');
|
|
323
351
|
if (args.delayMs !== undefined) argv.push('--delay-ms', String(args.delayMs));
|
|
324
352
|
if (args.noJs === true) argv.push('--disable-js');
|
|
325
353
|
if (args.noCss === true) argv.push('--disable-css');
|
|
@@ -413,6 +441,54 @@ export function runFetch(options) {
|
|
|
413
441
|
};
|
|
414
442
|
}
|
|
415
443
|
|
|
444
|
+
// The engine signals an HTTP error with exit code 22 and, under
|
|
445
|
+
// `--fail-with-body`, keeps whatever the output format produced. That is why the
|
|
446
|
+
// body is still here: the caller is told the fetch failed *and* gets to read what
|
|
447
|
+
// came back, which is what an error page is for.
|
|
448
|
+
//
|
|
449
|
+
// This branch is reached for binary dumps too -- a screenshot of an error page is
|
|
450
|
+
// still a screenshot -- so it must not decode stdout as text.
|
|
451
|
+
if (outcome.status === ENGINE_HTTP_ERROR_STATUS && bytes > 0) {
|
|
452
|
+
// The status is in the stderr reason. A JSON dump also carries it in the body,
|
|
453
|
+
// which is the fallback when the reason wording changes.
|
|
454
|
+
const reported = httpStatusFrom(stderr);
|
|
455
|
+
if (isBinary) {
|
|
456
|
+
const failure = ensureWritable(args.writeTo);
|
|
457
|
+
if (failure !== undefined) {
|
|
458
|
+
return { ok: false, url: args.url, httpError: true, error: failure, hint: 'Pass an absolute path in an existing directory.' };
|
|
459
|
+
}
|
|
460
|
+
writeFileSync(args.writeTo, outcome.stdout);
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
url: args.url,
|
|
464
|
+
kind: 'binary',
|
|
465
|
+
dump: args.dump,
|
|
466
|
+
path: args.writeTo,
|
|
467
|
+
bytes,
|
|
468
|
+
httpError: true,
|
|
469
|
+
...(reported === undefined ? {} : { status: reported }),
|
|
470
|
+
error: stderr.trim() === '' ? 'the server answered with an HTTP error status' : stderr.trim(),
|
|
471
|
+
hint: `The image the server returned was written to ${String(args.writeTo)}. Drop \`failOnHttpError\` to treat this as ordinary output.`,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const text = outcome.stdout.toString('utf8');
|
|
475
|
+
const status = reported
|
|
476
|
+
?? (args.dump === 'json' && text.trimStart().startsWith('{') ? parseStatus(text) : undefined);
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
url: args.url,
|
|
480
|
+
httpError: true,
|
|
481
|
+
...(status === undefined ? {} : { status }),
|
|
482
|
+
error: stderr.trim() === ''
|
|
483
|
+
? `the server answered with an HTTP error status${status === undefined ? '' : ` (${String(status)})`}`
|
|
484
|
+
: stderr.trim(),
|
|
485
|
+
content: text,
|
|
486
|
+
chars: text.length,
|
|
487
|
+
hint: 'The request reached a server and it refused. The content above is the error page as the engine rendered it, '
|
|
488
|
+
+ 'so it is often worth reading. Drop `failOnHttpError` to treat this as ordinary content instead.',
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
416
492
|
if (outcome.status !== 0) {
|
|
417
493
|
const sandboxTls = isSandboxTlsFailure(stderr);
|
|
418
494
|
// A blocked host fails at connect time ("Failed to connect" / RST) or, when
|
|
@@ -426,16 +502,24 @@ export function runFetch(options) {
|
|
|
426
502
|
// through to the generic hint below used to tell the caller to re-check the
|
|
427
503
|
// installation with moli_probe, which cannot possibly help.
|
|
428
504
|
const evalFailure = args.eval !== undefined && /JavaScript evaluation failed/u.test(stderr);
|
|
505
|
+
// A binary dump that failed produced no file at all. Saying only "exited with
|
|
506
|
+
// status 1" leaves the caller to guess whether an image exists; the useful fact
|
|
507
|
+
// is that it does not, and that a text dump would have captured this page.
|
|
508
|
+
const binaryFailure = isBinary && httpStatusFrom(stderr) !== undefined;
|
|
429
509
|
const hint = evalFailure
|
|
430
510
|
? 'The eval expression threw. Check that the page has reached the state you expect (`wait` or `waitSelector`), that the selector matches an element, and that the expression is valid JavaScript. The engine error names the type and position.'
|
|
431
|
-
:
|
|
432
|
-
? 'The
|
|
433
|
-
:
|
|
434
|
-
? 'The
|
|
435
|
-
:
|
|
511
|
+
: binaryFailure
|
|
512
|
+
? 'The engine could not render this page, so no file was written. An HTTP error still produces a body for a text dump (markdown, html or json), so ask for one of those if you need to see what the server returned.'
|
|
513
|
+
: sandboxTls
|
|
514
|
+
? 'The DSH file sandbox blocks Schannel client credentials, so moli cannot do TLS at low integrity. Retry the same call with sandbox_permissions "danger-full-access" (with a justification), use an http:// URL, or pass proxy to reach a local forward proxy.'
|
|
515
|
+
: possiblyUnreachable
|
|
516
|
+
? 'The host did not answer in time. Reachability to some hosts varies on this network, so retrying may succeed; if a local forward proxy is available, setting the plugin config field `proxy` is the other option. A certificate problem would report a schannel error instead of a timeout.'
|
|
517
|
+
: 'Run moli_probe to confirm the install, then retry; use waitSelector or eval for a page that renders late.';
|
|
518
|
+
const failureStatus = httpStatusFrom(stderr);
|
|
436
519
|
return {
|
|
437
520
|
ok: false,
|
|
438
521
|
url: args.url,
|
|
522
|
+
...(failureStatus === undefined ? {} : { status: failureStatus, httpError: true }),
|
|
439
523
|
error: stderr === '' ? `moli exited with status ${String(outcome.status)} and no output` : stderr,
|
|
440
524
|
sandboxTlsBlock: sandboxTls,
|
|
441
525
|
unreachable,
|
|
@@ -450,6 +534,20 @@ export function runFetch(options) {
|
|
|
450
534
|
if (failure !== undefined) {
|
|
451
535
|
return { ok: false, url: args.url, error: failure, sandboxTlsBlock: false, hint: 'Pass an absolute path in an existing directory.' };
|
|
452
536
|
}
|
|
537
|
+
// A binary dump produces nothing for an HTTP error: the engine exits 1 before
|
|
538
|
+
// rendering anything, unlike the text dumps which exit 22 with the body kept.
|
|
539
|
+
// Nothing is written, so say that rather than leaving an empty file behind.
|
|
540
|
+
if (outcome.status !== 0) {
|
|
541
|
+
const status = httpStatusFrom(stderr);
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
url: args.url,
|
|
545
|
+
...(status === undefined ? {} : { status }),
|
|
546
|
+
httpError: outcome.status === ENGINE_HTTP_ERROR_STATUS || status !== undefined,
|
|
547
|
+
error: stderr.trim() === '' ? `moli exited with status ${String(outcome.status)} before producing ${String(args.dump)} output` : stderr.trim(),
|
|
548
|
+
hint: 'The engine could not render this page, so no file was written. A text dump (markdown, html or json) still captures an HTTP error body; binary output does not.',
|
|
549
|
+
};
|
|
550
|
+
}
|
|
453
551
|
writeFileSync(args.writeTo, outcome.stdout);
|
|
454
552
|
return {
|
|
455
553
|
ok: true,
|
|
@@ -532,6 +630,25 @@ export function runFetch(options) {
|
|
|
532
630
|
};
|
|
533
631
|
}
|
|
534
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Read the HTTP status out of the engine's stderr reason.
|
|
635
|
+
*
|
|
636
|
+
* The engine reports it there, in the form
|
|
637
|
+
* `Reason: final main document returned HTTP status 404 at ...`
|
|
638
|
+
* for `--fail` and `--fail-with-body`, whatever the dump format. That makes stderr
|
|
639
|
+
* the reliable source: a JSON dump also carries the status in its body, but a
|
|
640
|
+
* markdown or HTML dump does not, and this branch has to name the status for those
|
|
641
|
+
* too. Matching narrowly keeps an unrelated number in the text from being read as a
|
|
642
|
+
* status.
|
|
643
|
+
*
|
|
644
|
+
* @param stderr - the engine's stderr text.
|
|
645
|
+
* @returns the numeric status, or undefined when it is not reported.
|
|
646
|
+
*/
|
|
647
|
+
function httpStatusFrom(stderr) {
|
|
648
|
+
const match = /HTTP status (\d{3})\b/u.exec(String(stderr));
|
|
649
|
+
return match === null ? undefined : Number(match[1]);
|
|
650
|
+
}
|
|
651
|
+
|
|
535
652
|
/**
|
|
536
653
|
* Read the HTTP status out of a JSON dump without trusting its shape.
|
|
537
654
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-moli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DeepSeek Harness tools for the local Moli headless browser: rendered fetches, markdown, semantic trees, screenshots, and PDFs.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,14 +32,19 @@
|
|
|
32
32
|
"registry": "https://registry.npmjs.org",
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
37
|
+
},
|
|
35
38
|
"exports": {
|
|
36
39
|
".": "./index.js",
|
|
40
|
+
"./client": "./client.js",
|
|
37
41
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
38
42
|
"./package.json": "./package.json"
|
|
39
43
|
},
|
|
40
44
|
"files": [
|
|
41
45
|
"index.js",
|
|
42
46
|
"lib",
|
|
47
|
+
"client.js",
|
|
43
48
|
"cordis.patch.yml",
|
|
44
49
|
"README.md",
|
|
45
50
|
"README.zh.md",
|
|
@@ -48,6 +53,9 @@
|
|
|
48
53
|
"dsh": {
|
|
49
54
|
"bundle": {
|
|
50
55
|
"patch": "./cordis.patch.yml"
|
|
56
|
+
},
|
|
57
|
+
"client": {
|
|
58
|
+
"platform": "web"
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
}
|