@sidady/dsh-steward 0.9.12 → 0.9.14
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 +79 -1
- package/lib/client.js +21 -3
- package/lib/index.js +57 -10
- package/package.json +1 -1
- package/smoke.mjs +51 -0
package/README.md
CHANGED
|
@@ -1,7 +1,85 @@
|
|
|
1
1
|
# dsh-steward — 智能管家(体检 + 授权执行)插件
|
|
2
2
|
|
|
3
|
-
**状态:v0.9.
|
|
3
|
+
**状态:v0.9.14 已安装并热挂载(体检 + 授权执行 + 对话入口 + 设置项)**
|
|
4
4
|
|
|
5
|
+
## v0.9.14(冷启动竞态:路由与工具都注册了,只是没在最合适的那一刻)
|
|
6
|
+
|
|
7
|
+
### 症状和真正的原因不是同一件事
|
|
8
|
+
|
|
9
|
+
重启之后面板照旧报 `读取失败: 接口没有挂上(HTTP 404)`。v0.9.13 已经能把这句
|
|
10
|
+
话翻译成人话,但它把原因归给了「热重载」——而这一次是冷启动,热重载的解释
|
|
11
|
+
根本对不上。真正的原因更安静:
|
|
12
|
+
|
|
13
|
+
`apply()` 里原本是这样拿服务的:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const webServer = getService(ctx, 'webServer');
|
|
17
|
+
if (webServer && typeof webServer.register === 'function') { ... }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`getService` 只做 `ctx.get(key)` 或 `ctx[key]`,而 **`ctx.get` 在 Cordis 里只能拿到
|
|
21
|
+
那一刻已经 apply 完的服务**。冷启动时 `webServer` 与 `tools` 的 apply 都排在本插件
|
|
22
|
+
之后,于是那一次探测拿到 `null`,整个 `if` 被跳过:前缀路由没注册、两个
|
|
23
|
+
`dsh_steward_*` 工具也没注册。外面的 `try { ... } catch { /* ignore */ }` 是刻意的
|
|
24
|
+
fail-soft(注册失败不该拖垮 boot),代价是**失败完全无声**——面板照常从 manifest
|
|
25
|
+
画出来,然后每个请求都撞在「没有命名路由匹配 → 404」上。
|
|
26
|
+
|
|
27
|
+
所以这不是「路由写错了」,是「注册发生在错误的时刻」。热重载会是同一个症状,
|
|
28
|
+
但它们是两个不同的病因,v0.9.13 的诊断文案只认识其中一种。
|
|
29
|
+
|
|
30
|
+
### 改法:等它就绪,再注册
|
|
31
|
+
|
|
32
|
+
新增 `whenService(ctx, keys, fn)`:优先走 `ctx.inject(keys, cb)`,回调在服务真正
|
|
33
|
+
存在之后才执行;`:root` 上没有 `inject` 时退回旧的一次性探测(行为与从前一致,不会
|
|
34
|
+
更差)。三处注册(`systemPrompt` 段、前缀路由、tools)全部改走它。
|
|
35
|
+
|
|
36
|
+
- 用 `ctx.inject` 而不是 `export const inject = [...]`,是因为硬 inject 会让本插件
|
|
37
|
+
成为 **boot 依赖**——它的 apply 一旦抛错,整个 Web UI 起不来。这个插件的第一原则
|
|
38
|
+
是「不许让面板成为新的崩溃源」,所以宁可延迟注册,也不升级成硬依赖。
|
|
39
|
+
- `ctx.effect` 仍然包在回调里,所以热重载时 dispose 依旧会被正确回收。
|
|
40
|
+
|
|
41
|
+
### 失败必须能看见
|
|
42
|
+
|
|
43
|
+
注册结果记在 `wiring.route` 里,`ping` 的返回体多一个字段:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
{"ok":true,...,"wiring":{"route":"registered"}}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
下次再出现「面板画出来了但每个请求都 404」,只需要看一眼 ping:`registered` 说明
|
|
50
|
+
路由在,问题在别处;`pending` 或 `failed: ...` 直接指到病因。**静默的 fail-soft 是
|
|
51
|
+
这次真正的坑**——比注册失败本身更难查,因为它连一条可搜的日志都不留。
|
|
52
|
+
|
|
53
|
+
### 这一版钉住的判据
|
|
54
|
+
|
|
55
|
+
`smoke.mjs` 新增五条静态判据(291 条断言):必须用 `ctx.inject` 延迟注册;三处注册
|
|
56
|
+
全都走 `whenService`;**旧的裸 `try { const webServer = getService(...)` 写法必须彻底
|
|
57
|
+
消失**;`wiring.route` 要记录成功与失败两种结果;`ping` 要把它带出来。反向验证过:
|
|
58
|
+
把路由那段改回一次性探测,立刻 `289 passed, 2 failed`。
|
|
59
|
+
|
|
60
|
+
## v0.9.13(路由没挂上时,别只说 HTTP 404)
|
|
61
|
+
|
|
62
|
+
改插件源码会让宿主热重载:旧 fiber 被销毁,新 fiber 重新跑一遍 `apply()`。
|
|
63
|
+
真机踩到的形态是——重载过后 `apply()` 里那段 `webServer.register({ kind: 'prefix', path: ROUTE_PREFIX })`
|
|
64
|
+
没有接上,面板打开就只剩一行红字 `读取失败: HTTP 404`:bundle 是 enabled + installed,
|
|
65
|
+
源码 `node --check` 全过,`import()` 也正常,只有路由不见了。
|
|
66
|
+
|
|
67
|
+
`HTTP 404` 这句话的问题不是不准,是**没法行动**:它和「服务端主动回了个 404」长得一模一样,
|
|
68
|
+
而这两种情况的处置完全不同。所以这一版:
|
|
69
|
+
|
|
70
|
+
1. **认得出来** —— 自己这条路由上的 404 单独成句:说清「面板加载了,但 /api/dsh-steward/ 没注册」,
|
|
71
|
+
并点名最常见的原因(热重载后旧路由被销毁、新的没接上)。
|
|
72
|
+
2. **说得出怎么办** —— 提示「设置里停用再启用一次」或「直接重启 DSH Web」,两者都真的能修。
|
|
73
|
+
3. **重试一次就够** —— 热重载有一个几百毫秒的窗口,这时路由确实不在;重试一次不会把
|
|
74
|
+
真正的故障藏起来,也不会让用户对着一个会自动好的状态点按钮。
|
|
75
|
+
4. **别的错误不许挪用这套话** —— 非 404 的失败仍然原样报服务端给的原因,
|
|
76
|
+
否则「热重载」就成了一个什么都往里装的筐。
|
|
77
|
+
|
|
78
|
+
五条断言全部是**静态判据**(读 `lib/client.js` 自己的源码),并且反向验证过:把
|
|
79
|
+
`response.status === 404 && tries === 0` 改成 `if (false)`,`285 passed / 1 failed`。
|
|
80
|
+
|
|
81
|
+
> 顺带记一条运维经验:这个状态下**只有重启/重挂**能修,改代码不会修好它。
|
|
82
|
+
> 而 `plugin_manager set_bundle enabled:false` → `enabled:true` 就是重挂。
|
|
5
83
|
## v0.9.12(配色:从「自造一套蓝」改成 baseline 的六个 accent)
|
|
6
84
|
|
|
7
85
|
面板里原本散着 12 处私有颜色:`rgba(90,159,212,.14)` 这种自造的蓝、`#e05555` 这种自造的红、
|
package/lib/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-steward · client entry — v0.9.
|
|
2
|
+
* dsh-steward · client entry — v0.9.14 (model-mode conversational entry + L1 briefing;
|
|
3
3
|
* ChatBar rebuilt as a mature chat composer: one rounded input bar, mode chip on the
|
|
4
4
|
* left, round send button on the right, settings panel below the bar;
|
|
5
5
|
* v0.7.5 collapse: a collapsed group lists EVERY peer as a two-line summary row
|
|
@@ -119,7 +119,8 @@ window.__ModuleLoader__.load({
|
|
|
119
119
|
function timeoutFor(action) {
|
|
120
120
|
return SLOW_ACTIONS[action] || DEFAULT_TIMEOUT_MS;
|
|
121
121
|
}
|
|
122
|
-
async function call(action, extra) {
|
|
122
|
+
async function call(action, extra, attempt) {
|
|
123
|
+
const tries = typeof attempt === 'number' ? attempt : 0;
|
|
123
124
|
const name = action || 'report';
|
|
124
125
|
const budget = timeoutFor(name);
|
|
125
126
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
@@ -145,7 +146,24 @@ window.__ModuleLoader__.load({
|
|
|
145
146
|
if (timer) clearTimeout(timer);
|
|
146
147
|
}
|
|
147
148
|
let data;
|
|
148
|
-
try { data = await response.json(); } catch {
|
|
149
|
+
try { data = await response.json(); } catch { data = null; }
|
|
150
|
+
// v0.9.13: a 404 on our OWN route is a different failure from a 404 the
|
|
151
|
+
// server sent on purpose. It means this plugin's fiber is loaded (the
|
|
152
|
+
// panel rendered) but its route is not mounted. v0.9.14: there are TWO
|
|
153
|
+
// causes and the earlier wording only named the first — a host hot-reload
|
|
154
|
+
// disposes the old fiber, AND a cold boot can have webServer apply after
|
|
155
|
+
// this plugin, which is what the deferred registration now handles.
|
|
156
|
+
if (response.status === 404 && tries === 0) {
|
|
157
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
158
|
+
return call(action, extra, tries + 1);
|
|
159
|
+
}
|
|
160
|
+
if (response.status === 404) {
|
|
161
|
+
throw new Error('接口没有挂上(HTTP 404)——面板加载了,但 /api/dsh-steward/ 这段路由没注册。'
|
|
162
|
+
+ '两种常见原因:宿主热重载(改了插件文件,旧路由被销毁、新的没接上),'
|
|
163
|
+
+ '或冷启动时 webServer 比插件晚就绪。'
|
|
164
|
+
+ '在设置里把这个插件停用再启用一次通常就能恢复;也可以重启 DSH Web。'
|
|
165
|
+
+ '恢复后 ping 会带 wiring.route,值是 registered 就说明路由真的挂上了。');
|
|
166
|
+
}
|
|
149
167
|
if (!response.ok || !data || data.ok !== true) {
|
|
150
168
|
throw new Error((data && data.error) || 'HTTP ' + response.status);
|
|
151
169
|
}
|
package/lib/index.js
CHANGED
|
@@ -65,6 +65,48 @@ function getService(ctx, key) {
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// What the deferred wiring actually achieved. Reported by ping so "the panel
|
|
69
|
+
// draws but every request 404s" stops being indistinguishable from "fine".
|
|
70
|
+
const wiring = { route: 'pending', via: {} };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* v0.9.14: run `fn` once the named services are live.
|
|
74
|
+
*
|
|
75
|
+
* getService() only sees services that are ALREADY active at the moment of the
|
|
76
|
+
* call. On a cold boot `webServer` and `tools` activate after this plugin, so a
|
|
77
|
+
* one-shot lookup returns null and every registration is skipped — silently,
|
|
78
|
+
* because each block is wrapped in a fail-soft try/catch. The symptom is exactly
|
|
79
|
+
* what was reported: the panel draws itself (its client bundle comes from the
|
|
80
|
+
* manifest) while the route and both tools were never registered, so each
|
|
81
|
+
* request answered 404 and every dsh_steward_* tool was absent.
|
|
82
|
+
*
|
|
83
|
+
* ctx.inject() is the documented optional-access path: it defers the callback
|
|
84
|
+
* until the services exist. Unlike a hard `export const inject` it does not make
|
|
85
|
+
* this bundle a boot dependency.
|
|
86
|
+
*/
|
|
87
|
+
function whenService(ctx, keys, fn) {
|
|
88
|
+
let injected = false;
|
|
89
|
+
try {
|
|
90
|
+
if (typeof ctx.inject === 'function') {
|
|
91
|
+
ctx.inject(keys, (scoped) => {
|
|
92
|
+
wiring.via[keys.join(',')] = 'inject';
|
|
93
|
+
try { fn(scoped || ctx); } catch (e) {
|
|
94
|
+
try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] deferred setup failed (' + keys.join(',') + '): ' + String(e && e.message || e)); } catch { /* noop */ }
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
injected = true;
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] ctx.inject unavailable: ' + String(e && e.message || e)); } catch { /* noop */ }
|
|
101
|
+
}
|
|
102
|
+
if (injected) return true;
|
|
103
|
+
// Fallback for a ctx without inject(): the old best-effort one-shot lookup.
|
|
104
|
+
try {
|
|
105
|
+
if (getService(ctx, keys[0])) { wiring.via[keys.join(',')] = 'one-shot'; fn(ctx); return true; }
|
|
106
|
+
} catch { /* nothing live yet — stays inert, as before */ }
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
68
110
|
function sendJson(res, status, value) {
|
|
69
111
|
try {
|
|
70
112
|
const body = Buffer.from(JSON.stringify(value));
|
|
@@ -451,7 +493,7 @@ export function apply(ctx) {
|
|
|
451
493
|
}
|
|
452
494
|
|
|
453
495
|
async function run(action, payload) {
|
|
454
|
-
if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true };
|
|
496
|
+
if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true, wiring: { ...wiring } };
|
|
455
497
|
// v0.8.0 settings: 只读设置页需要的一切(值 + 范围 + 为什么),以及一次写入。
|
|
456
498
|
if (action === 'settings') {
|
|
457
499
|
await loadSettings();
|
|
@@ -643,7 +685,7 @@ export function apply(ctx) {
|
|
|
643
685
|
// @changfenhuang/dsh-genui/lib/index.js:7772 ({name, order, text}, order via
|
|
644
686
|
// getSectionOrder). Guarded: if the service or getSectionOrder is missing we
|
|
645
687
|
// skip entirely (探针先行 red line: never guess, never endanger boot).
|
|
646
|
-
|
|
688
|
+
whenService(ctx, ['systemPrompt'], () => {
|
|
647
689
|
const sp = getService(ctx, 'systemPrompt');
|
|
648
690
|
if (sp && typeof sp.section === 'function' && typeof sp.getSectionOrder === 'function') {
|
|
649
691
|
ctx.effect(() => {
|
|
@@ -659,15 +701,17 @@ export function apply(ctx) {
|
|
|
659
701
|
}
|
|
660
702
|
}, 'dsh-steward: prompt section');
|
|
661
703
|
}
|
|
662
|
-
}
|
|
704
|
+
});
|
|
663
705
|
|
|
664
|
-
// 2) Host prefix route (panel + same-origin callers).
|
|
665
|
-
|
|
706
|
+
// 2) Host prefix route (panel + same-origin callers). Deferred until the
|
|
707
|
+
// service exists: on a cold boot webServer activates after this plugin, and a
|
|
708
|
+
// one-shot lookup silently registered nothing (the reported 404).
|
|
709
|
+
whenService(ctx, ['webServer'], () => {
|
|
666
710
|
const webServer = getService(ctx, 'webServer');
|
|
667
711
|
if (webServer && typeof webServer.register === 'function') {
|
|
668
712
|
ctx.effect(() => {
|
|
669
713
|
try {
|
|
670
|
-
|
|
714
|
+
const dispose = webServer.register({
|
|
671
715
|
kind: 'prefix',
|
|
672
716
|
path: ROUTE_PREFIX,
|
|
673
717
|
handler: async (req, res) => {
|
|
@@ -688,17 +732,20 @@ export function apply(ctx) {
|
|
|
688
732
|
}
|
|
689
733
|
},
|
|
690
734
|
});
|
|
735
|
+
wiring.route = 'registered';
|
|
736
|
+
return dispose;
|
|
691
737
|
} catch (e) {
|
|
738
|
+
wiring.route = 'failed: ' + String(e && e.message || e);
|
|
692
739
|
// Contained: a route registration failure must NOT fail the boot fiber.
|
|
693
740
|
try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] route registration skipped: ' + String(e && e.message || e)); } catch { /* noop */ }
|
|
694
741
|
return undefined;
|
|
695
742
|
}
|
|
696
743
|
}, 'dsh-steward: routes');
|
|
697
744
|
}
|
|
698
|
-
}
|
|
745
|
+
});
|
|
699
746
|
|
|
700
|
-
// 3) Read-only health tool (kept from v0.1).
|
|
701
|
-
|
|
747
|
+
// 3) Read-only health tool (kept from v0.1). Same cold-boot race, same fix.
|
|
748
|
+
whenService(ctx, ['tools'], () => {
|
|
702
749
|
const tools = getService(ctx, 'tools');
|
|
703
750
|
if (tools && typeof tools.register === 'function') {
|
|
704
751
|
ctx.effect(() => {
|
|
@@ -774,7 +821,7 @@ export function apply(ctx) {
|
|
|
774
821
|
}
|
|
775
822
|
}, 'dsh-steward: act tool');
|
|
776
823
|
}
|
|
777
|
-
}
|
|
824
|
+
});
|
|
778
825
|
}
|
|
779
826
|
|
|
780
827
|
export default apply;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sidady/dsh-steward",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Guardian for DeepSeek Harness: a read-only health check (boot crashes, backup freshness, config drift, workspace growth, plugin updates, host-version compat, model availability) plus an explicitly authorized action layer (plan → single-use fingerprint → confirm → audit) covering cache cleaning, encrypted volatile-data backups, plugin enable/disable, knowledge capture, finding mute, and switching the default model.",
|
|
6
6
|
"keywords": [
|
package/smoke.mjs
CHANGED
|
@@ -1376,6 +1376,57 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
|
|
|
1376
1376
|
check("washes and rings derive from an accent", mixUses >= 6, String(mixUses));
|
|
1377
1377
|
check("the only saturated tokens are the six accents", /accentStability/.test(src) && /accentInfo/.test(src));
|
|
1378
1378
|
}
|
|
1379
|
+
|
|
1380
|
+
// — v0.9.13: 面板自己的路由没挂上时,不许只说「HTTP 404」 —
|
|
1381
|
+
{
|
|
1382
|
+
// 真机形态:改了插件源码触发宿主热重载,旧 fiber 被销毁、新 fiber 的路由
|
|
1383
|
+
// 没接上,面板只剩一行「读取失败: HTTP 404」。用户无法判断这是接口没挂、
|
|
1384
|
+
// 还是请求本身出错。这一组钉住三件事:认得出来、说得出怎么办、只重试一次。
|
|
1385
|
+
const clientSrc = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
|
|
1386
|
+
const callBody = clientSrc.slice(clientSrc.indexOf('async function call('), clientSrc.indexOf('function PanelIcon'));
|
|
1387
|
+
|
|
1388
|
+
check('a 404 on our own route is distinguished from a plain HTTP failure',
|
|
1389
|
+
/response\.status === 404/.test(callBody) && /接口没有挂上/.test(callBody));
|
|
1390
|
+
check('the 404 message names the hot-reload cause and the way out',
|
|
1391
|
+
/热重载/.test(callBody) && /停用再启用|重启 DSH Web/.test(callBody));
|
|
1392
|
+
check('the retry is bounded — one attempt, then it reports',
|
|
1393
|
+
/tries === 0/.test(callBody) && /call\(action, extra, tries \+ 1\)/.test(callBody));
|
|
1394
|
+
check('a generic failure still reports the server error verbatim',
|
|
1395
|
+
/\(data && data\.error\) \|\| 'HTTP ' \+ response\.status/.test(callBody));
|
|
1396
|
+
// 反向判据:不许把任何非 2xx 都说成「热重载」,那会把真正的服务端错误藏掉。
|
|
1397
|
+
check('the hot-reload wording is gated on 404, not on every non-2xx',
|
|
1398
|
+
!/接口没有挂上/.test(callBody.split('response.status === 404')[0]));
|
|
1399
|
+
}
|
|
1400
|
+
// — v0.9.14: 冷启动竞态 —— 服务没就绪时不许把注册整段跳过 —
|
|
1401
|
+
{
|
|
1402
|
+
// 真机形态:重启(不是热重载)后面板照样 404。原因不是路由写错了,而是
|
|
1403
|
+
// apply() 里用 ctx.get('webServer') 一次性探测:冷启动时 webServer 与 tools
|
|
1404
|
+
// 都比本插件晚 apply,那一次探测拿到 null,于是 if (webServer && ...) 整段
|
|
1405
|
+
// 跳过,路由与两个工具都没注册 —— 而每段外面都包了 fail-soft try/catch,
|
|
1406
|
+
// 所以只 warn 一行,面板与工具都静默消失。
|
|
1407
|
+
const idxSrc = fs.readFileSync(path.join(here, 'lib', 'index.js'), 'utf8');
|
|
1408
|
+
|
|
1409
|
+
check('the plugin defers to ctx.inject instead of a one-shot lookup',
|
|
1410
|
+
/ctx\.inject\(keys,/.test(idxSrc) && /function whenService\(/.test(idxSrc));
|
|
1411
|
+
check('every service-backed registration goes through whenService',
|
|
1412
|
+
(idxSrc.match(/whenService\(ctx, \['/g) || []).length >= 3);
|
|
1413
|
+
// 反向判据:旧的裸探测写法必须彻底消失,否则冷启动仍会静默跳过。
|
|
1414
|
+
check('no registration is left behind a bare try/getService probe',
|
|
1415
|
+
!/try \{\n\s*const webServer = getService\(/.test(idxSrc)
|
|
1416
|
+
&& !/try \{\n\s*const tools = getService\(/.test(idxSrc)
|
|
1417
|
+
&& !/try \{\n\s*const sp = getService\(/.test(idxSrc));
|
|
1418
|
+
// 静默失败是这次真正的病因:说得出「挂上了没有」比「应该挂上」有用。
|
|
1419
|
+
check('the wiring outcome is recorded, not just logged',
|
|
1420
|
+
/wiring\.route = 'registered'/.test(idxSrc) && /wiring\.route = 'failed: '/.test(idxSrc));
|
|
1421
|
+
check('ping reports the wiring outcome so a 404 is diagnosable',
|
|
1422
|
+
/wiring: \{ \.\.\.wiring \}/.test(idxSrc));
|
|
1423
|
+
// 走的是哪条路也要记:inject 与一次性探测在冷启动下行为完全不同,
|
|
1424
|
+
// 只看 route=registered 无法区分「真的等了」和「碰巧那次就绪了」。
|
|
1425
|
+
check('the wiring path records whether inject was used',
|
|
1426
|
+
/wiring\.via\[keys\.join\(','\)\] = 'inject'/.test(idxSrc)
|
|
1427
|
+
&& /wiring\.via\[keys\.join\(','\)\] = 'one-shot'/.test(idxSrc));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1379
1430
|
console.log('');
|
|
1380
1431
|
console.log(`RESULT: ${pass} passed, ${fail} failed`);
|
|
1381
1432
|
process.exit(fail === 0 ? 0 : 1);
|