@jonathanleelx/meddle 0.5.0-beta.23 → 0.5.0-beta.25

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.
Files changed (102) hide show
  1. package/CONFIG_STRUCTURE.md +1 -0
  2. package/README.md +16 -0
  3. package/bin/commands/route/active.js +14 -1
  4. package/bin/commands/route/add.js +112 -103
  5. package/bin/commands/route/delete.js +73 -49
  6. package/bin/commands/route/index.js +20 -2
  7. package/bin/commands/route/list.js +2 -2
  8. package/bin/commands/route/show.js +10 -1
  9. package/bin/commands/route/update.js +134 -101
  10. package/bin/index.js +3 -1
  11. package/bin/lib/api-client.js +14 -2
  12. package/bin/lib/file-access.js +39 -1
  13. package/bin/lib/route-write-args.js +150 -0
  14. package/bin/lib/rule-file-edit.js +263 -0
  15. package/bin/lib/ruleset-readonly.js +61 -0
  16. package/dist/core/mock-handler.d.ts.map +1 -1
  17. package/dist/core/mock-handler.js +34 -5
  18. package/dist/core/mock-handler.js.map +1 -1
  19. package/dist/core/rule-file-edit.d.ts +81 -0
  20. package/dist/core/rule-file-edit.d.ts.map +1 -0
  21. package/dist/core/rule-file-edit.js +243 -0
  22. package/dist/core/rule-file-edit.js.map +1 -0
  23. package/dist/core/static-server.d.ts.map +1 -1
  24. package/dist/core/static-server.js +15 -13
  25. package/dist/core/static-server.js.map +1 -1
  26. package/dist/server/agent/tools.d.ts.map +1 -1
  27. package/dist/server/agent/tools.js +20 -18
  28. package/dist/server/agent/tools.js.map +1 -1
  29. package/dist/server/app-info.d.ts +14 -0
  30. package/dist/server/app-info.d.ts.map +1 -1
  31. package/dist/server/app-info.js +33 -1
  32. package/dist/server/app-info.js.map +1 -1
  33. package/dist/server/config.d.ts.map +1 -1
  34. package/dist/server/config.js +51 -9
  35. package/dist/server/config.js.map +1 -1
  36. package/dist/server/index.d.ts.map +1 -1
  37. package/dist/server/index.js +1 -0
  38. package/dist/server/index.js.map +1 -1
  39. package/dist/server/rule-files.d.ts +23 -0
  40. package/dist/server/rule-files.d.ts.map +1 -1
  41. package/dist/server/rule-files.js +169 -81
  42. package/dist/server/rule-files.js.map +1 -1
  43. package/dist/server/ruleset-lock.d.ts +62 -0
  44. package/dist/server/ruleset-lock.d.ts.map +1 -0
  45. package/dist/server/ruleset-lock.js +234 -0
  46. package/dist/server/ruleset-lock.js.map +1 -0
  47. package/mcp-server.js +87 -88
  48. package/package.json +1 -1
  49. package/web/dist/assets/{detail-panel-D4JSaFH3.js → detail-panel-CyOc7gn8.js} +3 -3
  50. package/web/dist/assets/dist-BQN0rzUS.js +1 -0
  51. package/web/dist/assets/{global-panel-shell-DXvcxmbd.js → global-panel-shell-jXQTb9nw.js} +3 -3
  52. package/web/dist/assets/{health-panel-Bo0eD8_R.js → health-panel-CSV7OqBv.js} +1 -1
  53. package/web/dist/assets/index-C6dPxxT3.css +2 -0
  54. package/web/dist/assets/index-CSXxaYkG.js +21 -0
  55. package/web/dist/assets/{mobile-proxy-panel-FQwFQFmS.js → mobile-proxy-panel-a09S-R79.js} +2 -2
  56. package/web/dist/assets/mock-config-D_E5YOE_.js +1 -0
  57. package/web/dist/assets/{mock-editor-panel-kx0bzGv4.js → mock-editor-panel-t4TDXeaR.js} +3 -3
  58. package/web/dist/assets/{mocks-file-missing-note-BvL7-uzS.js → mocks-file-missing-note-Ddy1TdMX.js} +1 -1
  59. package/web/dist/assets/{monaco-editor-BLHN8gCU.js → monaco-editor-GpPLOKA5.js} +2 -2
  60. package/web/dist/assets/pane-bar-zJmE3xI5.js +1 -0
  61. package/web/dist/assets/{pane-split-DfQcgd1v.js → pane-split-CJ1VXJDy.js} +1 -1
  62. package/web/dist/assets/panel-heading-DlFWoYAR.js +1 -0
  63. package/web/dist/assets/plugin-code-editor-CtaamKWJ.js +2 -0
  64. package/web/dist/assets/plugin-config-Dg_FCb-L.js +2 -0
  65. package/web/dist/assets/{plugin-generator-B5FRaaKc.js → plugin-generator-CVREuZs7.js} +2 -2
  66. package/web/dist/assets/plugin-test-dialog-B-iV8ocm.js +7 -0
  67. package/web/dist/assets/route-preview-CLJeg_9v.js +1 -0
  68. package/web/dist/assets/{rule-ai-assistant-panel-BEmXPzli.js → rule-ai-assistant-panel-BvTqm4zV.js} +2 -2
  69. package/web/dist/assets/rule-config-DmNPaKxc.js +15 -0
  70. package/web/dist/assets/{rule-overview-panel-DsNEU8L1.js → rule-overview-panel-CB9b-b0t.js} +1 -1
  71. package/web/dist/assets/settings-panel-DLdeJ8ox.js +1 -0
  72. package/web/dist/assets/toast-9yeTf7d9.js +1 -0
  73. package/web/dist/assets/use-mocks-file-status-BUIUJ4oQ.js +1 -0
  74. package/web/dist/index.html +11 -9
  75. package/web/dist/assets/index-Cbis1y9h.js +0 -18
  76. package/web/dist/assets/index-dKaIrLee.css +0 -2
  77. package/web/dist/assets/mock-config-CL9nXIkx.js +0 -1
  78. package/web/dist/assets/pane-bar-BSzN6D93.js +0 -1
  79. package/web/dist/assets/panel-heading-Dev_soZA.js +0 -1
  80. package/web/dist/assets/plugin-code-editor-uI_-iAXY.js +0 -2
  81. package/web/dist/assets/plugin-config-BpfIX4Uk.js +0 -2
  82. package/web/dist/assets/plugin-test-dialog-Cptd2bs0.js +0 -7
  83. package/web/dist/assets/route-preview-DEc50Y_R.js +0 -1
  84. package/web/dist/assets/rule-config-DQkdF3tr.js +0 -15
  85. package/web/dist/assets/select-CFTzQ8Uy.js +0 -1
  86. package/web/dist/assets/settings-panel-C1v0UmZQ.js +0 -1
  87. /package/web/dist/assets/{alert-DFypnYT1.js → alert-B514Frte.js} +0 -0
  88. /package/web/dist/assets/{babel-DCr4VA1c.js → babel-bwZm0qi3.js} +0 -0
  89. /package/web/dist/assets/{body-diff-view-CCwbvDYx.js → body-diff-view-SfLCLzs9.js} +0 -0
  90. /package/web/dist/assets/{clipboard-VhPXNN5m.js → clipboard-Bt1qY01V.js} +0 -0
  91. /package/web/dist/assets/{dist-Dk9a2sJl.js → dist-DLQKRjuW.js} +0 -0
  92. /package/web/dist/assets/{editor-pane-actions-BGttNlkS.js → editor-pane-actions-_ckwcCII.js} +0 -0
  93. /package/web/dist/assets/{estree-qO_m2Qaw.js → estree-MatbZDwq.js} +0 -0
  94. /package/web/dist/assets/{html-VDGahtyk.js → html-CCrsbFYX.js} +0 -0
  95. /package/web/dist/assets/{postcss-BUm8EKMw.js → postcss-BxN7Gr8n.js} +0 -0
  96. /package/web/dist/assets/{standalone-Bj8SSd8Q.js → standalone-DsnFnsqM.js} +0 -0
  97. /package/web/dist/assets/{syntax-highlight-Dg_CTbXd.js → syntax-highlight-BFgN_C0Y.js} +0 -0
  98. /package/web/dist/assets/{table-B10JkPhg.js → table-DaXD6jJv.js} +0 -0
  99. /package/web/dist/assets/{textarea-BjWF0pk4.js → textarea-9wB8Hsz-.js} +0 -0
  100. /package/web/dist/assets/{wand-sparkles-DDGjzjnX.js → wand-sparkles-Bmt4dDOf.js} +0 -0
  101. /package/web/dist/assets/{wifi-QkTcTrED.js → wifi-Cjj_Br1u.js} +0 -0
  102. /package/web/dist/assets/{zap-CEVoVkAg.js → zap-BuOtogAv.js} +0 -0
@@ -204,6 +204,7 @@ https://localhost:13001/micro/app?a=1
204
204
  | `detailBodySizeKB` | 日志详情保存的请求/响应体截断阈值(KB),默认 `256`;调大可保留更大 body 便于排查,但会增加内存占用(最多 `MAX_DETAIL_SIZE=200` 条同时驻留)。作用范围为单个 session,解析顺序:当前 session 的 `settings.json` → 默认 session(`~/.meddle/settings.json`,仅非默认 session 继承)→ 环境变量 `MEDDLE_MAX_DETAIL_BODY_KB` → 默认 `256`。修改后下次请求即生效(基于 mtime 缓存检测) |
205
205
  | `activeRuleFiles` | 当前启用的路由规则文件名称,顺序即合并顺序 |
206
206
  | `ruleFileOrder` | Web 规则文件 Tab 的完整显示顺序(含禁用文件);启用文件的合并顺序与其在该数组中的相对顺序一致 |
207
+ | `readonlyRuleFiles` | 只读(加锁)的规则文件名称。可选,缺省即全部可读写。只能在 Web 界面中修改;锁住的规则文件拒绝 CLI / MCP / API 的一切写入。见 [只读规则集](./docs/RULESET_READONLY.md) |
207
208
  | `mocksFilePath` | 自定义 Mock JSON 路径;空值使用默认位置 |
208
209
  | `pluginMode` | 插件 Pipeline 模式:`off`、`shadow` 或 `on` |
209
210
  | `clientAliases` | 客户端 IP 到设备名称的映射 |
package/README.md CHANGED
@@ -181,6 +181,22 @@ meddle --remote --no-intercept-https
181
181
 
182
182
  所有 `mock_rule_*`、`route_rule_*`、`route_preview` 工具均支持可选 `session` 参数,指定后可操作对应 session 的规则,不传则操作默认 session。
183
183
 
184
+ **路由规则写入**(`route_rule_add` / `route_rule_update` / `route_rule_delete`)只改包含该 pattern 的那一行,规则文件里的注释、空行、`//` 禁用规则和其他规则(连同排除项)都按原样保留:
185
+
186
+ - **不传 `exclusions`**:保留该规则原有的排除规则(`route_rule_add` 覆盖已有规则时也一样;新规则则没有排除规则)。
187
+ - **传 `exclusions: []`**:清空该规则的排除规则。
188
+ - 传非空数组:用它替换原有的排除规则。
189
+ - 如果一行写了多个 pattern,修改或删除其中一个时,若剩下的 pattern 以 `//` 或 `#` 开头(拆开后会被当成注释),写入会被拒绝并提示「该行剩余的 pattern 以 // 或 # 开头,拆开后会被当成注释,请用文本编辑器手动修改」,规则文件不变。
190
+ - 新增规则的 pattern 不能以 `#` 或 `//` 开头(例如 `#foo.com`、`//foo.com`),否则写进文件后会被当成注释或禁用行。写入会被拒绝并提示「规则不能以 // 或 # 开头,否则会被当成禁用行或注释。要写注释或禁用规则,请用文本编辑器。」,规则文件不变。
191
+ - CLI:`meddle route update <file> <pattern> --exclusions=` 清空该规则的排除项(该行不再有 `!path`,其余行逐字节不变)。`--exclusions=/a,/b` 替换。pattern 里写 `!path`(如 `"api.example.com !/health"`)同样是替换。两者都不写则保留原有排除项。单独的 `--exclusions`(没有 `=`)、空的 `!`、未知参数,或 `!path` 与 `--exclusions` 同时出现,都会非 0 退出且不改文件。`#` 只有出现在一行开头(前导空白之后)才是整行注释;行尾的 `#` 不是注释。
192
+
193
+ **只读规则集**([详细说明](./docs/RULESET_READONLY.md)):规则集(规则文件)可以在 Meddle Web 界面中加只读锁,防止 Agent 误改。
194
+
195
+ - `route_rule_list` 的结果带 `readonly: true|false`。
196
+ - 锁住后 `route_rule_add` / `route_rule_update` / `route_rule_delete` / `route_rule_active_set`(会改变锁住规则集的启用状态时)全部被拒绝,规则文件不变,工具返回 `isError: true` 与 `{"error":{"code":"RULESET_READONLY","message":"该规则集已设为只读,只能在 Meddle 界面中解锁","ruleFile":"…"}}`。CLI 同样拒绝,退出码为 `3`。
197
+ - **CLI 和 MCP 都没有加锁 / 解锁的命令或工具**,只能由人在 Meddle 界面中操作。界面使用页面令牌 + 同源校验;这只是防误改,不是安全隔离(可被 `curl` 取页面令牌并伪造 `Origin` 绕过,有 shell 的 Agent 也能直接改磁盘文件)。
198
+ - 锁住的规则集不能重命名,需先在 Meddle 界面中解锁再改名;在 Meddle 之外直接改文件名视为新规则集(可读写),旧锁记录自动清除。
199
+
184
200
  ### 多 Session 隔离(预览功能)
185
201
 
186
202
  多 Session 允许同时运行多个独立的 `meddle` 代理进程,每个进程拥有独立的配置目录(MEDDLE_HOME)、端口、路由规则和 Mock 规则,适用于多个 Agent 或项目需要隔离代理环境的场景。
@@ -5,7 +5,13 @@
5
5
  const output = require('../../lib/output')
6
6
  const { isProxyRunning } = require('../../lib/proxy-detect')
7
7
  const { apiGet, apiPut } = require('../../lib/api-client')
8
- const { listRuleFiles, getActiveRuleFileNames, setActiveRuleFileNames, setRuleFileEnabled } = require('../../lib/file-access')
8
+ const { listRuleFiles, getActiveRuleFileNames, setRuleFileEnabled } = require('../../lib/file-access')
9
+ const { isRulesetReadonlyError, exitRulesetReadonly, assertListedFilesWritable } = require('../../lib/ruleset-readonly')
10
+
11
+ /** Names whose enabled state `active set <file>` would change. */
12
+ function pendingToggles(files, target) {
13
+ return files.filter((f) => !!f.enabled !== (f.name === target)).map((f) => f.name)
14
+ }
9
15
 
10
16
  // Parse arguments (skip 'route' and 'active' from argv)
11
17
  const args = process.argv.slice(4)
@@ -39,6 +45,9 @@ async function run() {
39
45
  process.exit(1)
40
46
  }
41
47
 
48
+ // Issue #137: refuse up front so a locked ruleset never leaves a half-applied switch.
49
+ assertListedFilesWritable(files, pendingToggles(files, targetFile))
50
+
42
51
  for (const f of files) {
43
52
  const nextEnabled = f.name === targetFile
44
53
  if (!!f.enabled === nextEnabled) continue
@@ -53,6 +62,7 @@ async function run() {
53
62
  output.header('Active Route File Set')
54
63
  output.success(targetFile)
55
64
  } catch (e) {
65
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: targetFile })
56
66
  // Fallback to file mode
57
67
  const files = listRuleFiles()
58
68
  const target = files.find(f => f.name === targetFile)
@@ -61,6 +71,7 @@ async function run() {
61
71
  process.exit(1)
62
72
  }
63
73
 
74
+ assertListedFilesWritable(files, pendingToggles(files, targetFile))
64
75
  // Disable all, then enable target
65
76
  files.forEach(f => setRuleFileEnabled(f.name, f.name === targetFile))
66
77
 
@@ -83,6 +94,7 @@ async function run() {
83
94
  process.exit(1)
84
95
  }
85
96
 
97
+ assertListedFilesWritable(files, pendingToggles(files, targetFile))
86
98
  files.forEach(f => setRuleFileEnabled(f.name, f.name === targetFile))
87
99
 
88
100
  output.warnFileOnlyMode()
@@ -140,6 +152,7 @@ async function run() {
140
152
  }
141
153
 
142
154
  run().catch(e => {
155
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: targetFile })
143
156
  output.error(e.message)
144
157
  process.exit(1)
145
158
  })
@@ -5,133 +5,142 @@
5
5
  const output = require('../../lib/output')
6
6
  const { isProxyRunning } = require('../../lib/proxy-detect')
7
7
  const { apiGet, apiPut } = require('../../lib/api-client')
8
- const { getRuleFileContent, saveRuleFileContent, parseEprc, ruleMapToEprcText } = require('../../lib/file-access')
8
+ const { getRuleFileContent, saveRuleFileContent } = require('../../lib/file-access')
9
+ const { addRouteRule } = require('../../lib/rule-file-edit')
10
+ const { isRulesetReadonlyError, exitRulesetReadonly } = require('../../lib/ruleset-readonly')
11
+ const { parseRouteWriteArgs, parsePatternArg, resolveExclusions } = require('../../lib/route-write-args')
12
+
13
+ const ADD_HELP = `
14
+ meddle route add <file> <pattern> <target> [--exclusions=<list>] [--json]
15
+
16
+ Add a rule. If the pattern already exists, its target is overwritten in place.
17
+ --exclusions= clears that rule's exclusions; --exclusions=/a,/b replaces them.
18
+ Omit --exclusions and any !path in the pattern to keep existing exclusions.
19
+ See: meddle route update --help
20
+ `
9
21
 
10
- // Parse arguments (skip 'route' and 'add' from argv)
11
22
  const args = process.argv.slice(4)
12
- const jsonFlag = args.includes('--json')
23
+
24
+ function fail(message, jsonFlag) {
25
+ if (jsonFlag) {
26
+ console.log(JSON.stringify({ success: false, error: { message } }))
27
+ console.error(message)
28
+ } else {
29
+ output.setJsonMode(false)
30
+ output.error(message)
31
+ }
32
+ process.exit(1)
33
+ }
34
+
35
+ let parsed
36
+ try {
37
+ parsed = parseRouteWriteArgs(args)
38
+ } catch (e) {
39
+ fail(e.message, args.includes('--json'))
40
+ }
41
+
42
+ if (parsed.help) {
43
+ console.log(ADD_HELP.trim())
44
+ process.exit(0)
45
+ }
46
+
47
+ const jsonFlag = parsed.json
13
48
  output.setJsonMode(jsonFlag)
14
49
 
15
- // Get positional arguments: file, pattern, target
16
- const positionalArgs = args.filter(a => !a.startsWith('--'))
17
- const fileName = positionalArgs[0]
18
- const pattern = positionalArgs[1]
19
- const target = positionalArgs[2]
50
+ const fileName = parsed.positional[0]
51
+ const pattern = parsed.positional[1]
52
+ const target = parsed.positional[2]
20
53
 
21
- async function run() {
22
- if (!fileName) {
23
- output.error('Missing required argument: <file>')
24
- process.exit(1)
54
+ let explicitExclusions = false
55
+ let appliedExclusions = []
56
+
57
+ function planAdd() {
58
+ const { patterns, exclusions: patternExclusions } = parsePatternArg(pattern)
59
+ if (patterns.length === 0) fail('Missing required argument: <pattern>', jsonFlag)
60
+ const resolved = resolveExclusions(patternExclusions, parsed.exclusions)
61
+ explicitExclusions = resolved.explicit
62
+ appliedExclusions = resolved.exclusions || []
63
+ return { patterns, exclusions: resolved.explicit ? resolved.exclusions : undefined }
64
+ }
65
+
66
+ /**
67
+ * Issue #145: edit only the line(s) holding this pattern; comments, blank
68
+ * lines and other rules (with their `!exclusions`) keep their exact bytes.
69
+ * Exclusions may be given inside the pattern argument ("app.example.com !/api")
70
+ * or with --exclusions. `--exclusions=` clears them (issue #149).
71
+ */
72
+ function applyAdd(text, plan) {
73
+ let next = text
74
+ for (const p of plan.patterns) {
75
+ next = addRouteRule(next, { pattern: p, target: target.trim(), exclusions: plan.exclusions }).content
25
76
  }
26
- if (!pattern) {
27
- output.error('Missing required argument: <pattern>')
28
- process.exit(1)
77
+ return next
78
+ }
79
+
80
+ function emitAdded({ fileOnly }) {
81
+ const payload = { success: true, file: fileName, pattern: pattern.trim(), target: target.trim() }
82
+ if (explicitExclusions) payload.exclusions = appliedExclusions
83
+ if (fileOnly) {
84
+ payload.fileOnly = true
85
+ payload.warning = output.FILE_ONLY_WARNING
86
+ }
87
+ if (jsonFlag) {
88
+ output.jsonRaw(payload)
89
+ return
29
90
  }
30
- if (!target) {
31
- output.error('Missing required argument: <target>')
32
- process.exit(1)
91
+ output.header('Route Rule Added')
92
+ output.success(`${pattern.trim()} -> ${target.trim()}`)
93
+ if (explicitExclusions) {
94
+ output.kv('Exclusions', appliedExclusions.length ? appliedExclusions.map((e) => `!${e}`).join(' ') : 'cleared')
33
95
  }
96
+ output.kv('File', fileName)
97
+ }
34
98
 
35
- const running = await isProxyRunning()
99
+ async function run() {
100
+ if (!fileName) fail('Missing required argument: <file>', jsonFlag)
101
+ if (!pattern) fail('Missing required argument: <pattern>', jsonFlag)
102
+ if (!target) fail('Missing required argument: <target>', jsonFlag)
103
+ if (parsed.positional.length > 3) fail(`Unexpected argument: ${parsed.positional[3]}`, jsonFlag)
104
+
105
+ let plan
106
+ try {
107
+ plan = planAdd()
108
+ } catch (e) {
109
+ fail(e.message, jsonFlag)
110
+ }
36
111
 
37
- const pat = pattern.trim()
38
- const tgt = target.trim()
112
+ const running = await isProxyRunning()
39
113
 
40
114
  if (running) {
41
115
  try {
42
- // Get current content
43
116
  let content = await apiGet(`/api/rule-files/${encodeURIComponent(fileName)}/content`)
44
117
  const text = typeof content === 'string' ? content : String(content)
45
- const ruleMap = parseEprc(text)
46
-
47
- // Handle [marker] syntax
48
- const bm = pat.match(/\[([^\]]+)\]/)
49
- if (bm) {
50
- ruleMap[pat.replace(bm[0], bm[1])] = tgt + bm[0]
51
- } else {
52
- ruleMap[pat] = tgt
53
- }
54
-
55
- const newContent = ruleMapToEprcText(ruleMap)
118
+ const newContent = applyAdd(text, plan)
56
119
  await apiPut(`/api/rule-files/${encodeURIComponent(fileName)}/content`, { content: newContent })
57
-
58
- if (jsonFlag) {
59
- output.jsonRaw({ success: true, file: fileName, pattern: pat, target: tgt })
60
- return
61
- }
62
-
63
- output.header('Route Rule Added')
64
- output.success(`${pat} -> ${tgt}`)
65
- output.kv('File', fileName)
120
+ emitAdded({ fileOnly: false })
121
+ return
66
122
  } catch (e) {
67
- // Fallback to file mode
123
+ // Issue #137: a locked ruleset is a final answer, never a cue to write the file directly.
124
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
68
125
  const content = getRuleFileContent(fileName)
69
- if (!content) {
70
- output.error(`Route file not found: ${fileName}`)
71
- process.exit(1)
72
- }
73
-
74
- const ruleMap = parseEprc(content)
75
- const bm = pat.match(/\[([^\]]+)\]/)
76
- if (bm) {
77
- ruleMap[pat.replace(bm[0], bm[1])] = tgt + bm[0]
78
- } else {
79
- ruleMap[pat] = tgt
80
- }
81
-
82
- const newContent = ruleMapToEprcText(ruleMap)
126
+ if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
127
+ const newContent = applyAdd(content, plan)
83
128
  saveRuleFileContent(fileName, newContent)
84
129
  output.warnFileOnlyMode()
85
-
86
- if (jsonFlag) {
87
- output.jsonRaw({ success: true, file: fileName, pattern: pat, target: tgt,
88
- fileOnly: true,
89
- warning: output.FILE_ONLY_WARNING
90
- })
91
- return
92
- }
93
-
94
-
95
- output.header('Route Rule Added')
96
- output.success(`${pat} -> ${tgt}`)
97
- output.kv('File', fileName)
98
- }
99
- } else {
100
- // File mode
101
- const content = getRuleFileContent(fileName)
102
- if (!content) {
103
- output.error(`Route file not found: ${fileName}`)
104
- process.exit(1)
105
- }
106
-
107
- const ruleMap = parseEprc(content)
108
- const bm = pat.match(/\[([^\]]+)\]/)
109
- if (bm) {
110
- ruleMap[pat.replace(bm[0], bm[1])] = tgt + bm[0]
111
- } else {
112
- ruleMap[pat] = tgt
113
- }
114
-
115
- const newContent = ruleMapToEprcText(ruleMap)
116
- saveRuleFileContent(fileName, newContent)
117
- output.warnFileOnlyMode()
118
-
119
- if (jsonFlag) {
120
- output.jsonRaw({ success: true, file: fileName, pattern: pat, target: tgt,
121
- fileOnly: true,
122
- warning: output.FILE_ONLY_WARNING
123
- })
130
+ emitAdded({ fileOnly: true })
124
131
  return
125
132
  }
126
-
127
-
128
- output.header('Route Rule Added')
129
- output.success(`${pat} -> ${tgt}`)
130
- output.kv('File', fileName)
131
133
  }
134
+
135
+ const content = getRuleFileContent(fileName)
136
+ if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
137
+ const newContent = applyAdd(content, plan)
138
+ saveRuleFileContent(fileName, newContent)
139
+ output.warnFileOnlyMode()
140
+ emitAdded({ fileOnly: true })
132
141
  }
133
142
 
134
143
  run().catch(e => {
135
- output.error(e.message)
136
- process.exit(1)
144
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
145
+ fail(e.message, jsonFlag)
137
146
  })
@@ -5,49 +5,87 @@
5
5
  const output = require('../../lib/output')
6
6
  const { isProxyRunning } = require('../../lib/proxy-detect')
7
7
  const { apiGet, apiPut } = require('../../lib/api-client')
8
- const { getRuleFileContent, saveRuleFileContent, parseEprc, ruleMapToEprcText } = require('../../lib/file-access')
8
+ const { getRuleFileContent, saveRuleFileContent } = require('../../lib/file-access')
9
+ const { deleteRouteRule } = require('../../lib/rule-file-edit')
10
+ const { isRulesetReadonlyError, exitRulesetReadonly } = require('../../lib/ruleset-readonly')
11
+ const { parseRouteWriteArgs, parsePatternArg } = require('../../lib/route-write-args')
12
+
13
+ const DELETE_HELP = `
14
+ meddle route delete <file> <pattern> [--json]
15
+
16
+ Delete a rule. This command does not take --exclusions.
17
+ `
9
18
 
10
- // Parse arguments (skip 'route' and 'delete' from argv)
11
19
  const args = process.argv.slice(4)
12
- const jsonFlag = args.includes('--json')
20
+
21
+ function fail(message, jsonFlag) {
22
+ if (jsonFlag) {
23
+ console.log(JSON.stringify({ success: false, error: { message } }))
24
+ console.error(message)
25
+ } else {
26
+ output.setJsonMode(false)
27
+ output.error(message)
28
+ }
29
+ process.exit(1)
30
+ }
31
+
32
+ let parsed
33
+ try {
34
+ parsed = parseRouteWriteArgs(args, { allowExclusions: false })
35
+ } catch (e) {
36
+ fail(e.message, args.includes('--json'))
37
+ }
38
+
39
+ if (parsed.help) {
40
+ console.log(DELETE_HELP.trim())
41
+ process.exit(0)
42
+ }
43
+
44
+ const jsonFlag = parsed.json
13
45
  output.setJsonMode(jsonFlag)
14
46
 
15
- // Get positional arguments: file, pattern
16
- const positionalArgs = args.filter(a => !a.startsWith('--'))
17
- const fileName = positionalArgs[0]
18
- const pattern = positionalArgs[1]
47
+ const fileName = parsed.positional[0]
48
+ const pattern = parsed.positional[1]
19
49
 
20
- async function run() {
21
- if (!fileName) {
22
- output.error('Missing required argument: <file>')
23
- process.exit(1)
50
+ /**
51
+ * Issue #145: remove only this pattern; every other line (comments, blank
52
+ * lines, other rules and their `!exclusions`) keeps its exact bytes.
53
+ */
54
+ function applyDelete(text) {
55
+ const { patterns } = parsePatternArg(pattern)
56
+ if (patterns.length === 0) {
57
+ fail('Missing required argument: <pattern>', jsonFlag)
24
58
  }
25
- if (!pattern) {
26
- output.error('Missing required argument: <pattern>')
27
- process.exit(1)
59
+ let next = text
60
+ for (const p of patterns) {
61
+ const result = deleteRouteRule(next, { pattern: p })
62
+ if (!result.found) {
63
+ fail(`Pattern not found: ${pattern.trim()}`, jsonFlag)
64
+ }
65
+ next = result.content
66
+ }
67
+ return next
68
+ }
69
+
70
+ async function run() {
71
+ if (!fileName) fail('Missing required argument: <file>', jsonFlag)
72
+ if (!pattern) fail('Missing required argument: <pattern>', jsonFlag)
73
+ if (parsed.positional.length > 2) fail(`Unexpected argument: ${parsed.positional[2]}`, jsonFlag)
74
+ try {
75
+ parsePatternArg(pattern)
76
+ } catch (e) {
77
+ fail(e.message, jsonFlag)
28
78
  }
29
79
 
30
80
  const running = await isProxyRunning()
31
81
 
32
82
  const pat = pattern.trim()
33
83
 
34
- // Handle [marker] syntax for lookup
35
- const bm = pat.match(/\[([^\]]+)\]/)
36
- const internalKey = bm ? pat.replace(bm[0], bm[1]) : pat
37
-
38
84
  if (running) {
39
85
  try {
40
86
  let content = await apiGet(`/api/rule-files/${encodeURIComponent(fileName)}/content`)
41
87
  const text = typeof content === 'string' ? content : String(content)
42
- const ruleMap = parseEprc(text)
43
-
44
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
45
- output.error(`Pattern not found: ${pat}`)
46
- process.exit(1)
47
- }
48
-
49
- delete ruleMap[internalKey]
50
- const newContent = ruleMapToEprcText(ruleMap)
88
+ const newContent = applyDelete(text)
51
89
  await apiPut(`/api/rule-files/${encodeURIComponent(fileName)}/content`, { content: newContent })
52
90
 
53
91
  if (jsonFlag) {
@@ -59,21 +97,15 @@ async function run() {
59
97
  output.success(pat)
60
98
  output.kv('File', fileName)
61
99
  } catch (e) {
100
+ // Issue #137: a locked ruleset is a final answer, never a cue to write the file directly.
101
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
62
102
  // Fallback to file mode
63
103
  const content = getRuleFileContent(fileName)
64
104
  if (!content) {
65
- output.error(`Route file not found: ${fileName}`)
66
- process.exit(1)
67
- }
68
-
69
- const ruleMap = parseEprc(content)
70
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
71
- output.error(`Pattern not found: ${pat}`)
72
- process.exit(1)
105
+ fail(`Route file not found: ${fileName}`, jsonFlag)
73
106
  }
74
107
 
75
- delete ruleMap[internalKey]
76
- const newContent = ruleMapToEprcText(ruleMap)
108
+ const newContent = applyDelete(content)
77
109
  saveRuleFileContent(fileName, newContent)
78
110
  output.warnFileOnlyMode()
79
111
 
@@ -94,18 +126,10 @@ async function run() {
94
126
  // File mode
95
127
  const content = getRuleFileContent(fileName)
96
128
  if (!content) {
97
- output.error(`Route file not found: ${fileName}`)
98
- process.exit(1)
129
+ fail(`Route file not found: ${fileName}`, jsonFlag)
99
130
  }
100
131
 
101
- const ruleMap = parseEprc(content)
102
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
103
- output.error(`Pattern not found: ${pat}`)
104
- process.exit(1)
105
- }
106
-
107
- delete ruleMap[internalKey]
108
- const newContent = ruleMapToEprcText(ruleMap)
132
+ const newContent = applyDelete(content)
109
133
  saveRuleFileContent(fileName, newContent)
110
134
  output.warnFileOnlyMode()
111
135
 
@@ -125,6 +149,6 @@ async function run() {
125
149
  }
126
150
 
127
151
  run().catch(e => {
128
- output.error(e.message)
129
- process.exit(1)
152
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
153
+ fail(e.message, jsonFlag)
130
154
  })
@@ -39,10 +39,16 @@ Route Commands:
39
39
  meddle route active Show active route files
40
40
  meddle route active set <file> Set active route file
41
41
  meddle route create <name> [--content <text>] Create a route file
42
- meddle route add <file> <pattern> <target> Add a rule to file
43
- meddle route update <file> <pattern> <target> Update a rule in file
42
+ meddle route add <file> <pattern> <target> [--exclusions=<list>]
43
+ Add a rule to file
44
+ meddle route update <file> <pattern> [<target>] [--exclusions=<list>]
45
+ Update a rule in file
44
46
  meddle route delete <file> <pattern> Delete a rule from file
45
47
 
48
+ Read-only route files:
49
+ Files locked in the Meddle web UI reject add/update/delete and active set
50
+ (error RULESET_READONLY, exit code 3). Lock/unlock is only available in the web UI.
51
+
46
52
  Examples:
47
53
  meddle route list
48
54
  meddle route show dev-rules
@@ -53,7 +59,19 @@ Examples:
53
59
  meddle route create staging --content "example.com localhost:3000"
54
60
  meddle route add dev-rules "api.test.com" "localhost:8080"
55
61
  meddle route update dev-rules "api.test.com" "localhost:9000"
62
+ meddle route update dev-rules "api.test.com" --exclusions=
56
63
  meddle route delete dev-rules "api.test.com"
64
+
65
+ Exclusions:
66
+ Omit --exclusions and any !path in the pattern to keep the rule's exclusions.
67
+ --exclusions= Clear exclusions (that line loses every !path).
68
+ --exclusions=/a,/b Replace exclusions. A leading ! is optional.
69
+ "pattern !/a" Replace exclusions. A bare "!" is an error.
70
+ A bare --exclusions, an unknown flag, or both forms at once exits 1
71
+ and does not change the file. meddle route update --help has the details.
72
+
73
+ A "#" comments out a whole line only when it is the first non-space
74
+ character. A "#" later on a rule line is a pattern, not a trailing comment.
57
75
  `)
58
76
  process.exit(1)
59
77
  }
@@ -48,7 +48,7 @@ async function run() {
48
48
  output.section('Active Files')
49
49
  active.forEach(f => {
50
50
  const excludeInfo = f.excludeCount > 0 ? `, ${f.excludeCount} exclusions` : ''
51
- output.success(`${f.name} (${f.ruleCount} rules${excludeInfo})`)
51
+ output.success(`${f.name} (${f.ruleCount} rules${excludeInfo})${f.readonly ? ' [read-only]' : ''}`)
52
52
  })
53
53
  }
54
54
 
@@ -56,7 +56,7 @@ async function run() {
56
56
  output.section('Inactive Files')
57
57
  inactive.forEach(f => {
58
58
  const excludeInfo = f.excludeCount > 0 ? `, ${f.excludeCount} exclusions` : ''
59
- output.bullet(`${f.name} (${f.ruleCount} rules${excludeInfo})`, false)
59
+ output.bullet(`${f.name} (${f.ruleCount} rules${excludeInfo})${f.readonly ? ' [read-only]' : ''}`, false)
60
60
  })
61
61
  }
62
62
  }
@@ -5,7 +5,7 @@
5
5
  const output = require('../../lib/output')
6
6
  const { isProxyRunning } = require('../../lib/proxy-detect')
7
7
  const { apiGet } = require('../../lib/api-client')
8
- const { getRuleFileContent, parseEprcWithExclusions } = require('../../lib/file-access')
8
+ const { getRuleFileContent, parseEprcWithExclusions, isRuleFileReadonly } = require('../../lib/file-access')
9
9
 
10
10
  // Parse arguments (skip 'route' and 'show' from argv)
11
11
  const args = process.argv.slice(4)
@@ -26,10 +26,15 @@ async function run() {
26
26
  let content = null
27
27
  let ruleMap = {}
28
28
  let excludeMap = {}
29
+ // Issue #137: report the lock state (read-only for the CLI either way).
30
+ let readonly = false
29
31
 
30
32
  if (running) {
31
33
  try {
32
34
  content = await apiGet(`/api/rule-files/${encodeURIComponent(fileName)}/content`)
35
+ const files = await apiGet('/api/rule-files')
36
+ const info = Array.isArray(files) ? files.find((f) => f && f.name === fileName) : null
37
+ readonly = Boolean(info && info.readonly)
33
38
  const parsed = parseEprcWithExclusions(typeof content === 'string' ? content : String(content))
34
39
  ruleMap = parsed.ruleMap
35
40
  excludeMap = parsed.excludeMap
@@ -43,6 +48,7 @@ async function run() {
43
48
  const parsed = parseEprcWithExclusions(content)
44
49
  ruleMap = parsed.ruleMap
45
50
  excludeMap = parsed.excludeMap
51
+ readonly = isRuleFileReadonly(fileName)
46
52
  }
47
53
  } else {
48
54
  content = getRuleFileContent(fileName)
@@ -53,6 +59,7 @@ async function run() {
53
59
  const parsed = parseEprcWithExclusions(content)
54
60
  ruleMap = parsed.ruleMap
55
61
  excludeMap = parsed.excludeMap
62
+ readonly = isRuleFileReadonly(fileName)
56
63
  }
57
64
 
58
65
  // Build display rules (handle [marker] syntax)
@@ -76,6 +83,7 @@ async function run() {
76
83
  if (jsonFlag) {
77
84
  output.jsonRaw({
78
85
  file: fileName,
86
+ readonly,
79
87
  rules: displayRules,
80
88
  ruleCount: Object.keys(displayRules).length,
81
89
  exclusionCount: totalExclusions
@@ -84,6 +92,7 @@ async function run() {
84
92
  }
85
93
 
86
94
  output.header(`Route File: ${fileName}`)
95
+ output.kv('Read-only', readonly ? 'Yes (unlock in the Meddle UI)' : 'No')
87
96
  output.kv('Rules', Object.keys(displayRules).length)
88
97
  if (totalExclusions > 0) {
89
98
  output.kv('Exclusions', totalExclusions)