whistle.easy-proxy 1.0.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,230 @@
1
+ const http = require('node:http');
2
+ const {
3
+ listRulesets: listOfflineRulesetsRaw,
4
+ getRuleset,
5
+ saveRuleset,
6
+ deleteRuleset,
7
+ } = require('../config');
8
+
9
+ const SYSTEM_GROUP = '\rEasyProxy 系统规则';
10
+ const USER_GROUP = '\rEasyProxy 自定义规则';
11
+
12
+ function loadOfflineRulesets() {
13
+ return listOfflineRulesetsRaw().filter((r) => r.name !== 'default');
14
+ }
15
+
16
+ function getOfflineDefaultRules() {
17
+ const r = getRuleset('default');
18
+ return r ? r.content : '';
19
+ }
20
+
21
+ function saveOfflineDefaultRules(content) {
22
+ const _r = getRuleset('default');
23
+ saveRuleset('default', content, true, '');
24
+ }
25
+
26
+ /**
27
+ * 从环境变量构建 whistle 连接选项
28
+ */
29
+ function getOptions() {
30
+ const port = parseInt(process.env.W2_PORT || '8899', 10);
31
+ const host = process.env.W2_HOST || '127.0.0.1';
32
+ const username = process.env.W2_USERNAME;
33
+ const password = process.env.W2_PASSWORD;
34
+ const auth =
35
+ username && password
36
+ ? `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
37
+ : null;
38
+ return { host, port, auth };
39
+ }
40
+
41
+ /**
42
+ * 发送 HTTP 请求到 whistle
43
+ */
44
+ function request(method, path, body, options) {
45
+ return new Promise((resolve, reject) => {
46
+ const headers = {};
47
+ if (options.auth) headers.Authorization = options.auth;
48
+ if (body) headers['Content-Type'] = 'application/x-www-form-urlencoded';
49
+
50
+ const req = http.request(
51
+ {
52
+ hostname: options.host,
53
+ port: options.port,
54
+ path: path,
55
+ method: method,
56
+ headers: headers,
57
+ },
58
+ (res) => {
59
+ var data = '';
60
+ res.on('data', (chunk) => {
61
+ data += chunk;
62
+ });
63
+ res.on('end', () => {
64
+ if (res.statusCode !== 200) {
65
+ reject(new Error(`whistle 返回 HTTP ${res.statusCode}`));
66
+ return;
67
+ }
68
+ resolve(data);
69
+ });
70
+ }
71
+ );
72
+ req.on('error', () => {
73
+ reject(
74
+ new Error(
75
+ '无法连接 whistle (' +
76
+ options.host +
77
+ ':' +
78
+ options.port +
79
+ ') — 请先执行 easy-proxy start --init'
80
+ )
81
+ );
82
+ });
83
+ if (body) req.write(body);
84
+ req.end();
85
+ });
86
+ }
87
+
88
+ /**
89
+ * 列出所有规则集
90
+ * 返回: { ec, enabledCount, defaultRulesIsDisabled, defaultRules, list: [{name, data, selected}] }
91
+ */
92
+ async function listRulesets() {
93
+ const opts = getOptions();
94
+ const raw = await request('GET', '/cgi-bin/rules/list', null, opts);
95
+ try {
96
+ var data = JSON.parse(raw);
97
+ } catch (_e) {
98
+ throw new Error(`whistle API 返回无效 JSON: ${raw.substring(0, 100)}`);
99
+ }
100
+ if (data.ec !== 0) throw new Error('whistle API 返回错误');
101
+ return data;
102
+ }
103
+
104
+ /**
105
+ * 创建分组(whistle 分组以 \r 开头)
106
+ */
107
+ async function createGroup(name) {
108
+ const opts = getOptions();
109
+ await request('POST', '/cgi-bin/rules/add', `name=${encodeURIComponent(name)}`, opts);
110
+ }
111
+
112
+ /**
113
+ * 添加或更新规则集(非 Default)
114
+ * 可选 groupName 参数将规则集放入指定分组
115
+ */
116
+ async function addRuleset(name, content, groupName) {
117
+ const opts = getOptions();
118
+ let body = `name=${encodeURIComponent(name)}&value=${encodeURIComponent(content)}`;
119
+ if (groupName) body += `&groupName=${encodeURIComponent(groupName)}`;
120
+ const raw = await request('POST', '/cgi-bin/rules/add', body, opts);
121
+ return JSON.parse(raw);
122
+ }
123
+
124
+ /**
125
+ * 在 whistle 中启用多选模式,使启用新规则集时不会自动取消其他规则集的选中状态
126
+ */
127
+ async function enableMultipleChoice() {
128
+ const opts = getOptions();
129
+ await request('POST', '/cgi-bin/rules/allow-multiple-choice', 'allowMultipleChoice=1', opts);
130
+ }
131
+
132
+ /**
133
+ * 启用规则集(非 Default)
134
+ * 启用前先确保多选模式已开启,避免覆盖已有选中状态
135
+ */
136
+ async function selectRuleset(name, content) {
137
+ const opts = getOptions();
138
+ await enableMultipleChoice();
139
+ const body = `name=${encodeURIComponent(name)}&value=${encodeURIComponent(content || '')}`;
140
+ const raw = await request('POST', '/cgi-bin/rules/select', body, opts);
141
+ return JSON.parse(raw);
142
+ }
143
+
144
+ /**
145
+ * 禁用规则集(非 Default)
146
+ * unselect 会先 add(确保存在)再 unselect
147
+ */
148
+ async function unselectRuleset(name, content) {
149
+ const opts = getOptions();
150
+ const body = `name=${encodeURIComponent(name)}&value=${encodeURIComponent(content || '')}`;
151
+ const raw = await request('POST', '/cgi-bin/rules/unselect', body, opts);
152
+ return JSON.parse(raw);
153
+ }
154
+
155
+ /**
156
+ * 启用 Default 规则集
157
+ */
158
+ async function enableDefault() {
159
+ const opts = getOptions();
160
+ const raw = await request('POST', '/cgi-bin/rules/enable-default', '', opts);
161
+ return JSON.parse(raw);
162
+ }
163
+
164
+ /**
165
+ * 禁用 Default 规则集
166
+ */
167
+ async function disableDefault() {
168
+ const opts = getOptions();
169
+ const raw = await request('POST', '/cgi-bin/rules/disable-default', '', opts);
170
+ return JSON.parse(raw);
171
+ }
172
+
173
+ /**
174
+ * 删除规则集(非 Default,Default 不可删除)
175
+ */
176
+ async function removeRuleset(name) {
177
+ const opts = getOptions();
178
+ const body = `name=${encodeURIComponent(name)}`;
179
+ const raw = await request('POST', '/cgi-bin/rules/remove', body, opts);
180
+ return JSON.parse(raw);
181
+ }
182
+
183
+ async function syncOfflineRulesToWhistle() {
184
+ const offlineRules = loadOfflineRulesets();
185
+ const defaultRules = getOfflineDefaultRules();
186
+ if (offlineRules.length === 0 && !defaultRules) {
187
+ return 0;
188
+ }
189
+
190
+ let synced = 0;
191
+ await createGroup(USER_GROUP);
192
+
193
+ if (defaultRules) {
194
+ await addRuleset('Default', defaultRules);
195
+ synced++;
196
+ }
197
+
198
+ for (const rule of offlineRules) {
199
+ if (rule.name === 'Default') continue;
200
+ await addRuleset(rule.name, rule.content, rule.group || USER_GROUP);
201
+ if (rule.enabled) {
202
+ await selectRuleset(rule.name, rule.content);
203
+ } else {
204
+ await unselectRuleset(rule.name, rule.content);
205
+ }
206
+ synced++;
207
+ }
208
+
209
+ return synced;
210
+ }
211
+
212
+ module.exports = {
213
+ getOptions,
214
+ listRulesets,
215
+ addRuleset,
216
+ selectRuleset,
217
+ unselectRuleset,
218
+ enableDefault,
219
+ disableDefault,
220
+ removeRuleset,
221
+ createGroup,
222
+
223
+ loadOfflineRulesets,
224
+ getOfflineDefaultRules,
225
+ saveOfflineDefaultRules,
226
+ syncOfflineRulesToWhistle,
227
+
228
+ SYSTEM_GROUP,
229
+ USER_GROUP,
230
+ };
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Whistle 规则编辑器解析模块
3
+ * 纯函数:parse / validate / buildEditContent
4
+ *
5
+ * 规则格式约定(whistle 原生):
6
+ * - 每行一条规则:pattern operator://value
7
+ * - 空行和以 # 开头的注释行会被忽略
8
+ * - 支持 \r\n 和 \n 换行
9
+ * - operator 不区分大小写
10
+ */
11
+
12
+ // whistle 已知 operator 列表(用于语法校验,不区分大小写)
13
+ // 参考官方文档:https://wproxy.org/whistle/rules/
14
+ const KNOWN_OPERATORS = new Set([
15
+ // 特殊规则
16
+ '@',
17
+ '%',
18
+ // Map Local
19
+ 'file',
20
+ 'xfile',
21
+ 'tpl',
22
+ 'xtpl',
23
+ 'rawfile',
24
+ 'xrawfile',
25
+ // Map Remote
26
+ 'https',
27
+ 'http',
28
+ 'wss',
29
+ 'ws',
30
+ 'tunnel',
31
+ // DNS Spoofing
32
+ 'host',
33
+ 'xhost',
34
+ 'proxy',
35
+ 'http-proxy',
36
+ 'xproxy',
37
+ 'xhttp-proxy',
38
+ 'https-proxy',
39
+ 'xhttps-proxy',
40
+ 'socks',
41
+ 'xsocks',
42
+ 'pac',
43
+ 'sni',
44
+ 'snicallback',
45
+ 'tlscallback',
46
+ 'tls_options',
47
+ 'tlsoptions',
48
+ // Rewrite Request
49
+ 'urlparams',
50
+ 'pathreplace',
51
+ 'method',
52
+ 'tlsoptions',
53
+ 'reqheaders',
54
+ 'forwardedfor',
55
+ 'ua',
56
+ 'auth',
57
+ 'cache',
58
+ 'referer',
59
+ 'reqtype',
60
+ 'reqcharset',
61
+ 'reqcookies',
62
+ 'reqcors',
63
+ 'reqbody',
64
+ 'reqmerge',
65
+ 'reqprepend',
66
+ 'reqappend',
67
+ 'reqreplace',
68
+ 'reqwrite',
69
+ 'reqwriteraw',
70
+ 'reqrules',
71
+ 'reqscript',
72
+ // Rewrite Response
73
+ 'statuscode',
74
+ 'replacestatus',
75
+ 'redirect',
76
+ 'locationhref',
77
+ 'resheaders',
78
+ 'responsefor',
79
+ 'restype',
80
+ 'rescharset',
81
+ 'rescookies',
82
+ 'attachment',
83
+ 'rescors',
84
+ 'resbody',
85
+ 'resmerge',
86
+ 'resprepend',
87
+ 'resappend',
88
+ 'resreplace',
89
+ 'htmlprepend',
90
+ 'htmlbody',
91
+ 'htmlappend',
92
+ 'cssprepend',
93
+ 'cssbody',
94
+ 'cssappend',
95
+ 'jsprepend',
96
+ 'jsbody',
97
+ 'jsappend',
98
+ 'trailers',
99
+ 'reswrite',
100
+ 'reswriteraw',
101
+ 'resrules',
102
+ 'resscript',
103
+ 'framescript',
104
+ // General
105
+ 'pipe',
106
+ 'delete',
107
+ 'headerreplace',
108
+ // Throttle
109
+ 'reqdelay',
110
+ 'resdelay',
111
+ 'reqspeed',
112
+ 'resspeed',
113
+ // Tools
114
+ 'weinre',
115
+ 'log',
116
+ 'filter',
117
+ // Settings
118
+ 'style',
119
+ 'enable',
120
+ 'disable',
121
+ 'lineprops',
122
+ 'abort',
123
+ // Filters
124
+ 'excludefilter',
125
+ 'includefilter',
126
+ 'ignore',
127
+ 'skip',
128
+ // 插件协议
129
+ 'easy-proxy',
130
+ 'whistle.easy-proxy',
131
+ ]);
132
+
133
+ /**
134
+ * 提取行中的 operator(小写形式),不存在则返回 null
135
+ * 格式:pattern operator://value 或 operator://value
136
+ * @param {string} line
137
+ * @returns {string|null}
138
+ */
139
+ function extractOperator(line) {
140
+ const m = line.match(/(\S+):\/\//);
141
+ return m ? m[1].toLowerCase() : null;
142
+ }
143
+
144
+ /**
145
+ * 将文本分割为「原始行号 + 内容」的数组,同时处理 \r\n 和注释过滤
146
+ * 保留原始行号用于错误提示
147
+ * @param {string} text
148
+ * @returns {Array<{lineNo: number, content: string, raw: string}>}
149
+ */
150
+ function splitLinesWithNumbers(text) {
151
+ const rawLines = text.replace(/\r/g, '').split('\n');
152
+ const result = [];
153
+ for (let i = 0; i < rawLines.length; i++) {
154
+ const line = rawLines[i];
155
+ const trimmed = line.trim();
156
+ // 过滤整行注释和空行
157
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
158
+ result.push({ lineNo: i + 1, content: trimmed, raw: line });
159
+ }
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * 解析单条规则,返回 { pattern, operator, value, raw }
165
+ * 解析失败抛出带行号的错误
166
+ * @param {{lineNo: number, content: string, raw: string}} lineInfo
167
+ * @returns {{pattern: string, operator: string, value: string, raw: string}}
168
+ */
169
+ function parseLine(lineInfo) {
170
+ const { lineNo, content } = lineInfo;
171
+
172
+ // 检查是否有 operator:// 结构
173
+ const op = extractOperator(content);
174
+ if (!op) {
175
+ throw new Error(`第 ${lineNo} 行: 缺少操作符(operator://),如 host://、sni://、proxy://`);
176
+ }
177
+
178
+ const opIndex = content.indexOf('://');
179
+ const beforeOp = content.substring(0, opIndex).trim();
180
+ const afterOp = content.substring(opIndex + 3).trim();
181
+
182
+ // beforeOp 可能是 pattern + operator 或只有 operator
183
+ // 规则:如果 beforeOp 包含空格,则最后一个词是 operator,前面是 pattern
184
+ // 否则 beforeOp 就是 operator(没有 pattern 的全局规则,如 enable://)
185
+ let pattern = '';
186
+ let operator = '';
187
+
188
+ const parts = beforeOp.split(/\s+/);
189
+ if (parts.length === 1) {
190
+ // 没有 pattern,全局规则
191
+ operator = parts[0];
192
+ } else {
193
+ operator = parts[parts.length - 1];
194
+ pattern = parts.slice(0, -1).join(' ');
195
+ }
196
+
197
+ operator = operator.toLowerCase();
198
+
199
+ // 校验 operator 是否是已知的(警告级别,不强制拒绝 —— 允许用户用插件或新特性)
200
+ // 这里只做基本格式校验
201
+ if (!/^[a-z][a-z0-9-]*$/i.test(operator)) {
202
+ throw new Error(`第 ${lineNo} 行: 操作符格式无效 "${operator}"`);
203
+ }
204
+
205
+ return {
206
+ pattern,
207
+ operator,
208
+ value: afterOp,
209
+ raw: lineInfo.raw,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * 解析 whistle 规则文本
215
+ * @param {string} text - 编辑器中的文本内容
216
+ * @returns {Array<{pattern: string, operator: string, value: string, raw: string}>}
217
+ * @throws {Error} 格式错误时抛出,错误信息包含具体位置和原因
218
+ */
219
+ function parse(text) {
220
+ if (!text || !text.trim()) return [];
221
+
222
+ const lines = splitLinesWithNumbers(text);
223
+ const rules = [];
224
+
225
+ for (const lineInfo of lines) {
226
+ const rule = parseLine(lineInfo);
227
+ rules.push(rule);
228
+ }
229
+
230
+ return rules;
231
+ }
232
+
233
+ /**
234
+ * 将规则数组格式化为文本(不含注释头部)
235
+ * @param {Array<{pattern: string, operator: string, value: string}>} rules
236
+ * @returns {string}
237
+ */
238
+ function format(rules) {
239
+ if (!rules || rules.length === 0) return '';
240
+
241
+ return rules
242
+ .map((r) => {
243
+ const pattern = r.pattern ? `${r.pattern} ` : '';
244
+ return `${pattern}${r.operator}://${r.value}`;
245
+ })
246
+ .join('\n');
247
+ }
248
+
249
+ /**
250
+ * 校验规则合法性
251
+ * @param {Array<{pattern: string, operator: string, value: string}>} rules
252
+ * @returns {Array} 返回校验后的 rules
253
+ * @throws {Error} 校验失败时抛出
254
+ */
255
+ function validate(rules) {
256
+ if (!Array.isArray(rules)) {
257
+ throw new Error('规则必须是数组');
258
+ }
259
+
260
+ return rules.map((rule, idx) => {
261
+ const position = `第 ${idx + 1} 条规则`;
262
+
263
+ const operator = (rule.operator || '').trim().toLowerCase();
264
+ if (!operator) {
265
+ throw new Error(`${position}: 操作符不能为空`);
266
+ }
267
+ if (!/^[a-z][a-z0-9-]*$/i.test(operator)) {
268
+ throw new Error(`${position}: 操作符格式无效 "${operator}"`);
269
+ }
270
+
271
+ const value = rule.value !== undefined ? String(rule.value) : '';
272
+ // value 可以为空(如 abort:// 无值也合法)
273
+
274
+ const pattern = (rule.pattern || '').trim();
275
+
276
+ return { pattern, operator, value };
277
+ });
278
+ }
279
+
280
+ /**
281
+ * 检查规则中是否包含未知 operator,返回警告信息数组
282
+ * (不阻塞保存,仅用于提示)
283
+ * @param {Array<{pattern: string, operator: string, value: string}>} rules
284
+ * @returns {Array<{lineNo: number, operator: string}>}
285
+ */
286
+ function findUnknownOperators(rules) {
287
+ const warnings = [];
288
+ for (let i = 0; i < rules.length; i++) {
289
+ const op = rules[i].operator.toLowerCase();
290
+ if (!KNOWN_OPERATORS.has(op)) {
291
+ warnings.push({ lineNo: i + 1, operator: rules[i].operator });
292
+ }
293
+ }
294
+ return warnings;
295
+ }
296
+
297
+ /**
298
+ * 构建编辑器内容(带注释头部 + 当前规则)
299
+ * @param {string} currentContent - 当前规则文本
300
+ * @returns {string}
301
+ */
302
+ function buildEditContent(currentContent) {
303
+ const topHint = [
304
+ '# Whistle 规则 — EasyProxy 编辑器',
305
+ '# 格式: pattern operator://value 完整文档: https://wproxy.org/whistle/rules/',
306
+ '# 空行和 # 开头的行是注释,会被忽略。下方有语法参考和常用样例。',
307
+ ].join('\n');
308
+
309
+ const bottomRef = [
310
+ '# ─── 语法参考(不会被保存,编辑时可忽略) ───',
311
+ '#',
312
+ '# Pattern 匹配模式:',
313
+ '# example.com 域名匹配(所有端口/协议)',
314
+ '# example.com:8080 指定端口',
315
+ '# example.com/api 路径前缀匹配',
316
+ '# $example.com/api 路径精确匹配',
317
+ '# ^*.example.com 通配符匹配(^ 前缀开启)',
318
+ '# /\\.example\\./ 正则匹配',
319
+ '# 完整语法: https://wproxy.org/docs/rules/pattern.html',
320
+ '#',
321
+ '# 常用 Operator(完整列表: https://wproxy.org/docs/rules/protocols.html)',
322
+ '# host://1.2.3.4 DNS/Host 改写',
323
+ '# sni://example.com SNI 改写',
324
+ '# proxy://1.2.3.4:8080 HTTP 代理转发',
325
+ '# https-proxy://host:8443 HTTPS 代理',
326
+ '# socks://host:1080 SOCKS 代理',
327
+ '# redirect://https://x.com 302 重定向',
328
+ '# statusCode://404 修改状态码',
329
+ '# reqHeaders://{...} 修改请求头(JSON)',
330
+ '# resHeaders://{...} 修改响应头(JSON)',
331
+ '# reqBody://... / resBody://... 替换请求/响应体',
332
+ '# file:///path/to/file 本地文件替换响应',
333
+ '# xfile:///path/to/dir 目录替换',
334
+ '# resDelay://1000 响应延迟(毫秒)',
335
+ '# resSpeed://100k 响应限速(k/m/g)',
336
+ '# abort:// 中断请求',
337
+ '# enable://capture / disable://gzip',
338
+ '#',
339
+ '# 常用样例:',
340
+ '# GitHub 加速: github.com host://20.205.243.166',
341
+ '# SNI 改写: api.example.com sni://origin.example.com',
342
+ '# 上游代理: *.google.com proxy://127.0.0.1:7890',
343
+ '# 本地 Mock: /api/v1/user file:///Users/xxx/mock/user.json',
344
+ '# 允许跨域: /api/* resHeaders://{"access-control-allow-origin":"*"}',
345
+ '# 延迟测试: /slow/* resDelay://3000',
346
+ '# 正则匹配: /\\.example\\./ host://127.0.0.1',
347
+ '# ─────────────────────────────────────────────────',
348
+ ].join('\n');
349
+
350
+ const body = (currentContent || '').trim();
351
+ if (!body) return topHint + '\n\n' + bottomRef + '\n';
352
+ return topHint + '\n\n' + body + '\n\n' + bottomRef + '\n';
353
+ }
354
+
355
+ module.exports = {
356
+ parse,
357
+ format,
358
+ validate,
359
+ findUnknownOperators,
360
+ buildEditContent,
361
+ KNOWN_OPERATORS,
362
+ };
@@ -0,0 +1,121 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const os = require('node:os');
4
+
5
+ const { debug } = require('../debug');
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.easy-proxy');
8
+ const RULESETS_DIR = path.join(CONFIG_DIR, 'rulesets');
9
+ const META_FILE = 'meta.json';
10
+ const META_PATH = path.join(RULESETS_DIR, META_FILE);
11
+
12
+ function ensureDir(dir) {
13
+ try {
14
+ fs.mkdirSync(dir || CONFIG_DIR, { recursive: true });
15
+ } catch (e) {
16
+ debug('rules-file ensureDir failed:', e.message);
17
+ }
18
+ }
19
+
20
+ function loadMeta() {
21
+ ensureDir(RULESETS_DIR);
22
+ try {
23
+ if (fs.existsSync(META_PATH)) {
24
+ return JSON.parse(fs.readFileSync(META_PATH, 'utf8'));
25
+ }
26
+ } catch (e) {
27
+ debug('loadMeta failed:', e.message);
28
+ }
29
+ return {};
30
+ }
31
+
32
+ function saveMeta(meta) {
33
+ ensureDir(RULESETS_DIR);
34
+ try {
35
+ fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2));
36
+ } catch (e) {
37
+ debug('saveMeta failed:', e.message);
38
+ }
39
+ }
40
+
41
+ function listRulesets() {
42
+ ensureDir(RULESETS_DIR);
43
+ const meta = loadMeta();
44
+ const results = [];
45
+
46
+ try {
47
+ const files = fs.readdirSync(RULESETS_DIR).filter((f) => f.endsWith('.txt'));
48
+ for (const file of files) {
49
+ const name = path.basename(file, '.txt');
50
+ const content = fs.readFileSync(path.join(RULESETS_DIR, file), 'utf8');
51
+ results.push({
52
+ name,
53
+ content,
54
+ enabled: meta[name]?.enabled !== false,
55
+ group: meta[name]?.group || '',
56
+ });
57
+ }
58
+ } catch (e) {
59
+ debug('listRulesets failed:', e.message);
60
+ }
61
+
62
+ return results;
63
+ }
64
+
65
+ function getRuleset(name) {
66
+ ensureDir(RULESETS_DIR);
67
+ const file = path.join(RULESETS_DIR, `${name}.txt`);
68
+ try {
69
+ if (fs.existsSync(file)) {
70
+ const meta = loadMeta();
71
+ return {
72
+ name,
73
+ content: fs.readFileSync(file, 'utf8'),
74
+ enabled: meta[name]?.enabled !== false,
75
+ group: meta[name]?.group || '',
76
+ };
77
+ }
78
+ } catch (e) {
79
+ debug('getRuleset failed for', `${name}:`, e.message);
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function saveRuleset(name, content, enabled, group) {
85
+ ensureDir(RULESETS_DIR);
86
+ const file = path.join(RULESETS_DIR, `${name}.txt`);
87
+ try {
88
+ fs.writeFileSync(file, content, 'utf8');
89
+ const meta = loadMeta();
90
+ meta[name] = { enabled, group };
91
+ saveMeta(meta);
92
+ return true;
93
+ } catch (e) {
94
+ debug('saveRuleset failed for', `${name}:`, e.message);
95
+ }
96
+ return false;
97
+ }
98
+
99
+ function deleteRuleset(name) {
100
+ const file = path.join(RULESETS_DIR, `${name}.txt`);
101
+ try {
102
+ if (fs.existsSync(file)) {
103
+ fs.unlinkSync(file);
104
+ const meta = loadMeta();
105
+ delete meta[name];
106
+ saveMeta(meta);
107
+ return true;
108
+ }
109
+ } catch (e) {
110
+ debug('deleteRuleset failed for', `${name}:`, e.message);
111
+ }
112
+ return false;
113
+ }
114
+
115
+ module.exports = {
116
+ RULESETS_DIR,
117
+ listRulesets,
118
+ getRuleset,
119
+ saveRuleset,
120
+ deleteRuleset,
121
+ };