dsh-napcat-keeper 2.1.2 → 2.5.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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 「提权交接」自检:在临时目录里造一个假的 NapCat 环境,
3
+ * 走一遍 生成脚本 → 真跑 PowerShell → 读结果 → 撤销 的全流程。
4
+ *
5
+ * 全程不碰真实配置、不碰真实 NapCat 目录,也不需要管理员(这几步本来就不用)。
6
+ * node scripts/selftest-setup.mjs
7
+ */
8
+ import { execFileSync } from 'node:child_process'
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import path from 'node:path'
12
+
13
+ const internals = (await import('../lib/index.js')).__internals
14
+
15
+ const root = mkdtempSync(path.join(tmpdir(), 'npk-selftest-'))
16
+ const fakeShell = path.join(root, 'NapCat.Selftest.Shell')
17
+ const fakeLogDir = path.join(root, 'logs')
18
+ mkdirSync(fakeShell, { recursive: true })
19
+ mkdirSync(fakeLogDir, { recursive: true })
20
+ // 假 NapCat:一个 bat + 一个 boot exe,够了
21
+ writeFileSync(path.join(fakeShell, 'napcat.quick2.bat'), '@echo off\r\necho hi\r\n', 'utf8')
22
+ writeFileSync(path.join(fakeShell, 'NapCatWinBootMain.exe'), '', 'utf8')
23
+
24
+ const cfg = internals.resolveConfig({})
25
+ cfg.shellDir = fakeShell
26
+ cfg.launcherScript = 'napcat.quick2.bat'
27
+ cfg.launcherLog = path.join(fakeLogDir, 'napcat-keeper-launch.log')
28
+ cfg.taskName = 'NapCat-Keeper-Selftest'
29
+ cfg.launchAccount = '10001'
30
+ cfg.launchArgs = ''
31
+ cfg.rememberPassword = true
32
+ cfg.quickPassword = 'selftest-pass-123'
33
+ cfg.hideConsole = true
34
+ cfg.onebotPort = 3000
35
+
36
+ let pass = 0
37
+ let fail = 0
38
+ const ok = (cond, name, extra = '') => {
39
+ if (cond) {
40
+ pass += 1
41
+ console.log(` ✓ ${name}`)
42
+ } else {
43
+ fail += 1
44
+ console.log(` ✗ ${name}${extra ? ' — ' + extra : ''}`)
45
+ }
46
+ }
47
+
48
+ console.log(`临时目录:${root}\n`)
49
+
50
+ /* 1. 需求清单 ------------------------------------------------------------- */
51
+ console.log('1) needCatalog')
52
+ const catalog = internals.needCatalog(cfg)
53
+ ok(Array.isArray(catalog) && catalog.length === 4, '四项需要都在', JSON.stringify(catalog.map((c) => c.id)))
54
+ ok(catalog.every((c) => c.id && c.label), '每项都有 id/label')
55
+ ok(catalog.every((c) => c.ready), '假环境下四项都能做', JSON.stringify(catalog.filter((c) => !c.ready)))
56
+ ok(catalog.find((c) => c.id === 'firewall').needAdmin === true, 'firewall 标了要管理员')
57
+ ok(catalog.find((c) => c.id === 'task').needAdmin === false, 'task 不需要管理员')
58
+
59
+ /* 2. 生成脚本 ------------------------------------------------------------- */
60
+ console.log('\n2) writeSetupBundle')
61
+ const made = internals.writeSetupBundle(cfg, ['launcher', 'envbat', 'task'])
62
+ ok(made.ok === true, '生成成功', made.error || '')
63
+ if (!made.ok) {
64
+ console.log('生成失败,后面不用看了')
65
+ process.exit(1)
66
+ }
67
+ const p = internals.setupPaths()
68
+ ok(existsSync(p.ps1), 'apply.ps1 在')
69
+ ok(existsSync(p.cmd), 'apply-admin.cmd 在')
70
+ ok(existsSync(p.undoCmd), 'undo-admin.cmd 在')
71
+ ok(existsSync(p.plan), 'plan.json 在')
72
+ ok(existsSync(p.readme), 'README.txt 在')
73
+ ok(made.needAdmin === false, '这批步骤不需要管理员', String(made.needAdmin))
74
+ ok(!JSON.stringify(made.steps).includes('selftest-pass-123'), '返回给前端的摘要里没有密码明文')
75
+
76
+ const ps1 = readFileSync(p.ps1)
77
+ ok(ps1[0] === 0xef && ps1[1] === 0xbb && ps1[2] === 0xbf, 'apply.ps1 带 UTF-8 BOM')
78
+ const ps1Text = ps1.toString('utf8')
79
+ ok(ps1Text.includes('Start-Process -FilePath'), '脚本里有自我提权那一句')
80
+ ok(ps1Text.includes('ConvertTo-Json'), '脚本会写回 result.json')
81
+
82
+ const cmdText = readFileSync(p.cmd, 'utf8')
83
+ ok(/^[\x00-\x7f]*$/.test(cmdText), 'cmd 是纯 ASCII(不会乱码)')
84
+ ok(cmdText.includes('apply.ps1'), 'cmd 指向 apply.ps1')
85
+
86
+ const plan = JSON.parse(readFileSync(p.plan, 'utf8'))
87
+ ok(plan.steps.length === 4, '4 步:wrapper + envbat + launcher + task', String(plan.steps.length))
88
+ ok(plan.needAdmin === false, 'plan 里 needAdmin 是 false')
89
+ ok(
90
+ plan.steps.some((s) => s.id === 'task' && Array.isArray(s.requires) && s.requires.includes('envbat')),
91
+ 'task 依赖 envbat(失败就不建任务)',
92
+ )
93
+
94
+ /* 3. 真跑一遍(不提权) --------------------------------------------------- */
95
+ console.log('\n3) 真跑 apply.ps1')
96
+ const runPs = () =>
97
+ execFileSync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', p.ps1], {
98
+ encoding: 'utf8',
99
+ env: { ...process.env, NPK_NO_PAUSE: '1' },
100
+ timeout: 120000,
101
+ windowsHide: true,
102
+ })
103
+
104
+ let ranOut = ''
105
+ try {
106
+ ranOut = runPs()
107
+ ok(true, '脚本跑完了(退出码 0)')
108
+ } catch (error) {
109
+ ok(false, '脚本报错', String((error && error.stdout) || (error && error.message) || error))
110
+ console.log(String(ranOut))
111
+ }
112
+
113
+ const first = internals.readSetupResult()
114
+ const amAdmin = (() => {
115
+ try {
116
+ return (
117
+ execFileSync(
118
+ 'powershell.exe',
119
+ [
120
+ '-NoProfile',
121
+ '-Command',
122
+ '([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)',
123
+ ],
124
+ { encoding: 'utf8', windowsHide: true },
125
+ ).trim() === 'True'
126
+ )
127
+ } catch {
128
+ return false
129
+ }
130
+ })()
131
+ ok(Boolean(first), 'result.json 读得回来')
132
+ if (first) {
133
+ ok(first.mode === 'apply', 'mode = apply')
134
+ ok(first.ok === true, '每步都成了', JSON.stringify(first.results.filter((r) => !r.ok)))
135
+ ok(
136
+ first.elevated === amAdmin,
137
+ `elevated(${first.elevated}) 与当前进程是否管理员(${amAdmin}) 一致`,
138
+ )
139
+ ok(first.results.length === 4, '4 条结果')
140
+ }
141
+ ok(existsSync(path.join(fakeShell, 'napcat.quick.auto.bat')), 'launcher: 启动脚本写出来了')
142
+ ok(existsSync(path.join(fakeLogDir, 'napcat-keeper-env.bat')), 'envbat: 中间层写出来了')
143
+ ok(existsSync(path.join(fakeLogDir, 'napcat-keeper-launch.vbs')), 'task-wrapper: vbs 写出来了')
144
+
145
+ const envBat = readFileSync(path.join(fakeLogDir, 'napcat-keeper-env.bat'), 'utf8')
146
+ ok(envBat.includes('NAPCAT_QUICK_PASSWORD=selftest-pass-123'), 'env bat 里带密码(这是它的用途)')
147
+ ok(envBat.includes('call "'), 'env bat 会 call 真启动脚本')
148
+
149
+ const autoBat = readFileSync(path.join(fakeShell, 'napcat.quick.auto.bat'), 'utf8')
150
+ ok(autoBat.includes('NapCatWinBootMain.exe'), '自动启动脚本调的是 boot main')
151
+ ok(autoBat.includes('10001'), '自动启动脚本带账号')
152
+
153
+ try {
154
+ const q = execFileSync('schtasks.exe', ['/query', '/tn', cfg.taskName], {
155
+ encoding: 'utf8',
156
+ windowsHide: true,
157
+ })
158
+ ok(q.includes(cfg.taskName), '计划任务真的建上了')
159
+ } catch (error) {
160
+ ok(false, '计划任务没查着', String((error && error.message) || error))
161
+ }
162
+
163
+ /* 4. 再跑一次(幂等) ---------------------------------------------------- */
164
+ console.log('\n4) 重复跑一次')
165
+ try {
166
+ runPs()
167
+ const again = internals.readSetupResult()
168
+ ok(again && again.ok === true, '第二次也全成(不炸)')
169
+ } catch (error) {
170
+ ok(false, '第二次跑报错', String((error && error.message) || error))
171
+ }
172
+
173
+ /* 5. 撤销 --------------------------------------------------------------- */
174
+ console.log('\n5) undo')
175
+ try {
176
+ execFileSync(
177
+ 'powershell.exe',
178
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', p.ps1, '-Undo'],
179
+ { encoding: 'utf8', env: { ...process.env, NPK_NO_PAUSE: '1' }, timeout: 120000, windowsHide: true },
180
+ )
181
+ ok(true, 'undo 跑完')
182
+ } catch (error) {
183
+ ok(false, 'undo 报错', String((error && error.message) || error))
184
+ }
185
+ const undone = internals.readSetupResult()
186
+ ok(undone && undone.mode === 'undo', 'result 里 mode = undo')
187
+ ok(undone && undone.ok === true, '撤回每步都成', JSON.stringify(undone && undone.results.filter((r) => !r.ok)))
188
+ ok(!existsSync(path.join(fakeShell, 'napcat.quick.auto.bat')), '启动脚本删掉了')
189
+ ok(!existsSync(path.join(fakeLogDir, 'napcat-keeper-env.bat')), '中间层删掉了(密码不留在磁盘)')
190
+ ok(!existsSync(path.join(fakeLogDir, 'napcat-keeper-launch.vbs')), 'vbs 删掉了')
191
+ try {
192
+ execFileSync('schtasks.exe', ['/query', '/tn', cfg.taskName], { stdio: 'ignore', windowsHide: true })
193
+ ok(false, '计划任务还在(应该删掉)')
194
+ } catch {
195
+ ok(true, '计划任务删掉了')
196
+ }
197
+
198
+ /* 收尾 ---------------------------------------------------------------- */
199
+ console.log('\n6) 再生成一次(留着给人点,也算看下真身)')
200
+ const final = internals.writeSetupBundle(cfg, ['launcher', 'envbat', 'task'])
201
+ ok(final.ok === true, '最后一次生成成功')
202
+ ok(internals.readSetupResult() === null, '旧 result 被清掉(不会误读上一次)')
203
+
204
+ try {
205
+ rmSync(root, { recursive: true, force: true })
206
+ } catch {}
207
+ console.log(`\n临时目录已清:${root}`)
208
+ console.log(`真身留在:${p.dir}`)
209
+ console.log(`\n结果:${pass} 通过 / ${fail} 失败`)
210
+ process.exit(fail === 0 ? 0 : 1)
@@ -0,0 +1,197 @@
1
+ // 一次性 smoke test:把 lib/client.js 塞进假 React 里渲染,确认没崩、盘数按钮跟 drives 走。
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // 相对脚本自身定位:换台机器、换个 clone 目录都跑得起来
7
+ const SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'client.js');
8
+ const code = readFileSync(SRC, 'utf8');
9
+
10
+ let states = [];
11
+ let idx = 0;
12
+ const React = {
13
+ createElement: (type, props, ...children) => ({ type, props: props || {}, children: children.flat(Infinity), __el: true }),
14
+ useState: (init) => {
15
+ const i = idx++;
16
+ if (!(i in states)) states[i] = typeof init === 'function' ? init() : init;
17
+ return [states[i], (v) => { states[i] = typeof v === 'function' ? v(states[i]) : v; }];
18
+ },
19
+ useEffect: () => {},
20
+ useLayoutEffect: () => {},
21
+ useMemo: (fn) => fn(),
22
+ useCallback: (fn) => fn,
23
+ useRef: (v) => ({ current: v }),
24
+ };
25
+
26
+ let factory = null;
27
+ globalThis.window = { __ModuleLoader__: { load: (def) => { factory = def.factory; } } };
28
+ globalThis.fetch = () => Promise.resolve({ json: () => Promise.resolve(null) });
29
+ new Function('window', 'fetch', code)(globalThis.window, globalThis.fetch);
30
+
31
+ const mod = factory((name) => {
32
+ if (name === 'react') return React;
33
+ throw new Error('unexpected require: ' + name);
34
+ });
35
+
36
+ let Section = null;
37
+ mod.apply({ get: (k) => (k === 'slots' ? { inject: (n, fn) => fn(), register: (m, c) => { Section = c; return c; } } : undefined) });
38
+ if (!Section) throw new Error('Section 没拿到');
39
+
40
+ function render() { idx = 0; return Section({}); }
41
+ function collect(tree) {
42
+ const buttons = [];
43
+ const boxes = [];
44
+ const texts = [];
45
+ (function walk(n) {
46
+ if (n === null || n === undefined || n === false) return;
47
+ if (typeof n === 'string' || typeof n === 'number') { texts.push(String(n)); return; }
48
+ if (Array.isArray(n)) { n.forEach(walk); return; }
49
+ if (n.__el) {
50
+ if (n.type === 'button') buttons.push(n.children.filter((c) => typeof c === 'string').join(''));
51
+ if (n.type === 'input' && n.props && n.props.type === 'checkbox') {
52
+ boxes.push({ name: String(n.props.name || ''), checked: Boolean(n.props.checked), disabled: Boolean(n.props.disabled) });
53
+ }
54
+ n.children.forEach(walk);
55
+ }
56
+ })(tree);
57
+ return { buttons, boxes, text: texts.join(' ') };
58
+ }
59
+
60
+ render(); // 第一次:态=null 的 loading 分支
61
+ states[0] = {
62
+ ok: true,
63
+ version: '2.2.2',
64
+ config: {
65
+ configPath: 'C:/x/napcat-keeper-config.json', logPath: 'C:/x/napcat-keeper.log',
66
+ shellDir: 'C:/Users/x/NapCat/OneKey/NapCat.52230.Shell', launcherScript: 'napcat.quick2.bat',
67
+ launchAccount: '1000000000', launchArgs: '', onebotPort: 3000, accessToken: '',
68
+ taskName: 'NapCat-Keeper-Launch', rememberPassword: true, autoCreateTask: true,
69
+ probeIntervalSec: 45, launchGraceSec: 150, scanTimeoutSec: 300, maxScanRetries: 2,
70
+ stuckSec: 420, chainGapSec: 120, procPathMatch: '', bridgeStaleSec: 180, enabled: true,
71
+ },
72
+ runtime: {
73
+ online: true, nickname: 'n', userId: 1, phase: 'online', launchCount: 0, dedupeCount: 0,
74
+ scanRetries: 0, procs: { boot: 1, qq: 4, chains: 1, dup: false, known: true },
75
+ qr: { ageSec: 10 }, bridge: { ageSec: 0 }, procRoot: 'C:/p', launcherFound: 'C:/l.bat',
76
+ taskName: 'T', taskReady: true, error: '',
77
+ },
78
+ scan: { running: false, found: [], drives: ['C:'], visited: 0, elapsedMs: 0, limitHit: false, cancelled: false },
79
+ drives: ['C:', 'D:', 'E:'],
80
+ setup: {
81
+ dir: 'C:/Users/x/.dsh/napcat-keeper-setup',
82
+ cmdPath: 'C:/Users/x/.dsh/napcat-keeper-setup/apply-admin.cmd',
83
+ ps1Path: 'C:/Users/x/.dsh/napcat-keeper-setup/apply.ps1',
84
+ undoCmdPath: 'C:/Users/x/.dsh/napcat-keeper-setup/undo-admin.cmd',
85
+ exists: true,
86
+ needs: [
87
+ { id: 'launcher', label: '自动启动脚本', hint: '补一个 napcat.quick.auto.bat', ready: true, why: '', needAdmin: false },
88
+ { id: 'envbat', label: '中间层(密码)', hint: '写中间层 bat', ready: true, why: '', needAdmin: false },
89
+ { id: 'task', label: '计划任务', hint: '建计划任务', ready: true, why: '', needAdmin: false },
90
+ { id: 'firewall', label: '防火墙放行', hint: '放行 OneBot 端口', ready: false, why: '端口没填', needAdmin: true },
91
+ ],
92
+ plan: {
93
+ createdAt: '2026-10-27T00:12:00.000Z',
94
+ needAdmin: true,
95
+ steps: [{ id: 'a', label: '建计划任务' }, { id: 'b', label: '放行防火墙' }],
96
+ },
97
+ lastRun: {
98
+ mode: 'apply',
99
+ ok: false,
100
+ elevated: true,
101
+ results: [
102
+ { id: 'a', label: '建计划任务', ok: true, skipped: false, message: '好了' },
103
+ { id: 'b', label: '放行防火墙', ok: false, skipped: false, message: '没权限' },
104
+ ],
105
+ },
106
+ },
107
+ log: ['line1'],
108
+ };
109
+
110
+ let bad = 0;
111
+ function check(name, ok, extra) {
112
+ console.log((ok ? 'PASS ' : 'FAIL ') + name + (ok || !extra ? '' : ' → ' + extra));
113
+ if (!ok) bad++;
114
+ }
115
+
116
+ const base = collect(render());
117
+ check('没有「自动探测」', !base.text.includes('自动探测'));
118
+ check('「重新探活」(不是重新探测)', base.buttons.includes('重新探活') && !base.buttons.includes('重新探测'));
119
+ check('有「重新读盘」按钮', base.buttons.includes('重新读盘'));
120
+ check('三块盘三个按钮', ['扫 C:', '扫 D:', '扫 E:'].every((b) => base.buttons.includes(b)));
121
+ check('盘数提示 3 块', /读到 3 块盘:C:、D:、E:/.test(base.text));
122
+ check('shellDir 指向分盘扫描', base.text.includes('不想手填就用上面「分盘扫描」'));
123
+
124
+ // 盘数跟 drives 走:1 块 / 5 块 / 0 块
125
+ for (const drives of [['C:'], ['C:', 'D:', 'E:', 'F:', 'G:'], []]) {
126
+ states[0].drives = drives;
127
+ const out = collect(render());
128
+ const scanBtns = out.buttons.filter((b) => b.startsWith('扫 '));
129
+ const ok = drives.length
130
+ ? scanBtns.length === drives.length && drives.every((d) => out.buttons.includes('扫 ' + d))
131
+ : scanBtns.length === 0 && out.text.includes('没读到盘');
132
+ check(`drives=${JSON.stringify(drives)} → ${scanBtns.length} 个「扫 X:」按钮`, ok, JSON.stringify(scanBtns));
133
+ }
134
+
135
+ // 扫的时候要能停
136
+ states[0].drives = ['C:', 'D:', 'E:'];
137
+ states[0].scan = { running: true, drive: 'D:', found: [{ path: 'D:/a', launcher: 'b.bat', boot: true }], visited: 1234, elapsedMs: 5000, limitHit: false, cancelled: false };
138
+ const running = collect(render());
139
+ check('扫描中显示「停止」', running.buttons.includes('停止'));
140
+ check('扫描中显示进度', /已看 1234 个目录/.test(running.text));
141
+ check('扫描中「重新读盘」被禁用位存在', (running.buttons.includes('重新读盘')));
142
+
143
+ // 扫到的结果要分「真 Shell」和「壳模板」
144
+ states[0].scan = {
145
+ running: false,
146
+ drive: 'C:',
147
+ visited: 9000,
148
+ elapsedMs: 1200,
149
+ limitHit: false,
150
+ cancelled: false,
151
+ found: [
152
+ { path: 'C:/u/NapCat/OneKey/NapCat.52230.Shell', name: 'NapCat.52230.Shell', launcher: 'napcat.quick2.bat', boot: true, shell: true, evidence: ['versions/', 'QQ.exe'], drive: 'C:' },
153
+ { path: 'C:/u/NapCat/OneKey/bootmain', name: 'bootmain', launcher: 'napcat.quick.bat', boot: true, shell: false, evidence: [], drive: 'C:' },
154
+ ],
155
+ };
156
+ const foundOut = collect(render());
157
+ check('真 Shell 那条标 ✓', foundOut.text.includes('✓ 像真 Shell:versions/ · QQ.exe'));
158
+ check('壳模板那条标 ⚠', foundOut.text.includes('多半是 OneKey 装完留下的壳模板'));
159
+ check('真 Shell 按钮是「用这个」', foundOut.buttons.includes('用这个'));
160
+ check('壳模板按钮是「仍要用这个」', foundOut.buttons.includes('仍要用这个'));
161
+
162
+ // 服务端还是旧版(没 shell 字段)时不能把人误导成「壳模板」
163
+ states[0].scan = { running: false, drive: 'E:', visited: 10, elapsedMs: 900, limitHit: false, cancelled: false, found: [{ path: 'E:/old/napcat', launcher: 'a.bat', boot: true }] };
164
+ const legacyOut = collect(render());
165
+ check('老后端不给判定时不误标壳模板', !legacyOut.text.includes('多半是 OneKey 装完留下的壳模板'));
166
+ check('老后端时提示要重启 DSH', legacyOut.text.includes('重启 DSH'));
167
+
168
+ // 提权脚本区
169
+ states[0].scan = { running: false, found: [], drives: ['C:'], visited: 0, elapsedMs: 0, limitHit: false, cancelled: false };
170
+ const setupOut = collect(render());
171
+ check('有「生成脚本并打开文件夹」', setupOut.buttons.includes('生成脚本并打开文件夹'));
172
+ check('有「打开文件夹」「读结果」', setupOut.buttons.includes('打开文件夹') && setupOut.buttons.includes('读结果'));
173
+ const needBoxes = setupOut.boxes.filter((b) => b.name.startsWith('npk-need-'));
174
+ check('4 条 need 就有 4 个勾选框', needBoxes.length === 4, String(needBoxes.length));
175
+ check('做不了的那条禁用', needBoxes.filter((b) => b.disabled).length === 1);
176
+ check('做不了的那条没被勾上', needBoxes.filter((b) => b.checked).length === 3);
177
+ check('显示「先解决:」', setupOut.text.includes('先解决:端口没填'));
178
+ check('显示上次生成 2 步', /上次生成:.*2 步/.test(setupOut.text));
179
+ check('显示上次执行 1/2 步成', /执行:1\/2 步成/.test(setupOut.text));
180
+ check('显示用了管理员', setupOut.text.includes('用了管理员'));
181
+ check('失败项有 ✗', setupOut.text.includes('✗ 放行防火墙:没权限'));
182
+ check('显示脚本目录', setupOut.text.includes('napcat-keeper-setup'));
183
+
184
+ // 没生成过 / 还没跑过
185
+ const keepSetup = states[0].setup;
186
+ states[0].setup = { ...keepSetup, plan: null, lastRun: null };
187
+ const emptyOut = collect(render());
188
+ check('没生成过时提示', emptyOut.text.includes('还没生成过脚本'));
189
+ states[0].setup = keepSetup;
190
+
191
+ // 没有 setup 字段也不炸
192
+ states[0].setup = undefined;
193
+ const noSetup = collect(render());
194
+ check('缺 setup 字段时降级提示', noSetup.text.includes('读不到脚本状态'));
195
+ states[0].setup = keepSetup;
196
+
197
+ process.exit(bad ? 1 : 0);