@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
@@ -1,136 +1,169 @@
1
1
  /**
2
2
  * meddle route update - Update a rule in a route file
3
+ *
4
+ * Issue #149: `--exclusions=` clears a rule's exclusions. Omitting the flag
5
+ * (and any !path in the pattern) still keeps them.
3
6
  */
4
7
 
5
8
  const output = require('../../lib/output')
6
9
  const { isProxyRunning } = require('../../lib/proxy-detect')
7
10
  const { apiGet, apiPut } = require('../../lib/api-client')
8
- const { getRuleFileContent, saveRuleFileContent, parseEprc, ruleMapToEprcText } = require('../../lib/file-access')
11
+ const { getRuleFileContent, saveRuleFileContent } = require('../../lib/file-access')
12
+ const { updateRouteRule, patternKey, parseRuleLine } = require('../../lib/rule-file-edit')
13
+ const { isRulesetReadonlyError, exitRulesetReadonly } = require('../../lib/ruleset-readonly')
14
+ const {
15
+ parseRouteWriteArgs,
16
+ parsePatternArg,
17
+ resolveExclusions,
18
+ UPDATE_HELP,
19
+ } = require('../../lib/route-write-args')
9
20
 
10
- // Parse arguments (skip 'route' and 'update' from argv)
11
21
  const args = process.argv.slice(4)
12
- const jsonFlag = args.includes('--json')
22
+
23
+ function fail(message, jsonFlag) {
24
+ if (jsonFlag) {
25
+ console.log(JSON.stringify({ success: false, error: { message } }))
26
+ console.error(message)
27
+ } else {
28
+ output.setJsonMode(false)
29
+ output.error(message)
30
+ }
31
+ process.exit(1)
32
+ }
33
+
34
+ let parsed
35
+ try {
36
+ parsed = parseRouteWriteArgs(args)
37
+ } catch (e) {
38
+ fail(e.message, args.includes('--json'))
39
+ }
40
+
41
+ if (parsed.help) {
42
+ console.log(UPDATE_HELP.trim())
43
+ process.exit(0)
44
+ }
45
+
46
+ const jsonFlag = parsed.json
13
47
  output.setJsonMode(jsonFlag)
14
48
 
15
- // Get positional arguments: file, pattern, newTarget
16
- const positionalArgs = args.filter(a => !a.startsWith('--'))
17
- const fileName = positionalArgs[0]
18
- const pattern = positionalArgs[1]
19
- const newTarget = positionalArgs[2]
49
+ const fileName = parsed.positional[0]
50
+ const pattern = parsed.positional[1]
51
+ const newTarget = parsed.positional[2]
20
52
 
21
- async function run() {
22
- if (!fileName) {
23
- output.error('Missing required argument: <file>')
24
- process.exit(1)
53
+ /** Set once arguments are valid. `explicit` means the user asked to set exclusions, including clear. */
54
+ let explicitExclusions = false
55
+ let appliedExclusions = []
56
+
57
+ function planUpdate() {
58
+ const { patterns, exclusions: patternExclusions } = parsePatternArg(pattern)
59
+ if (patterns.length === 0) {
60
+ fail('Missing required argument: <pattern>', jsonFlag)
25
61
  }
26
- if (!pattern) {
27
- output.error('Missing required argument: <pattern>')
28
- process.exit(1)
62
+ const resolved = resolveExclusions(patternExclusions, parsed.exclusions)
63
+ explicitExclusions = resolved.explicit
64
+ appliedExclusions = resolved.exclusions || []
65
+ return { patterns, exclusions: resolved.explicit ? resolved.exclusions : undefined }
66
+ }
67
+
68
+ /**
69
+ * Issue #145: rewrite only the line(s) holding this pattern. The rule keeps its
70
+ * own `!exclusions` unless new ones are given. Issue #149: `--exclusions=` passes
71
+ * `[]`, which removes `!path` from that line. Every other line keeps its bytes.
72
+ */
73
+ function applyUpdate(text, plan) {
74
+ const target = newTarget ? newTarget.trim() : undefined
75
+ let next = text
76
+ for (const p of plan.patterns) {
77
+ const result = updateRouteRule(next, { pattern: p, target, exclusions: plan.exclusions })
78
+ if (!result.found) {
79
+ fail(`Pattern not found: ${pattern.trim()}`, jsonFlag)
80
+ }
81
+ next = result.content
29
82
  }
30
- if (!newTarget) {
31
- output.error('Missing required argument: <target>')
32
- process.exit(1)
83
+ return next
84
+ }
85
+
86
+ /** Target token actually written, for feedback when the user did not pass one. */
87
+ function targetOnDisk(content, patternToken) {
88
+ const key = patternKey(patternToken)
89
+ const lines = content.split(/\r?\n/)
90
+ for (const line of lines) {
91
+ const parsedLine = parseRuleLine(line)
92
+ if (parsedLine && parsedLine.patterns.some((p) => patternKey(p) === key)) return parsedLine.target
33
93
  }
94
+ return ''
95
+ }
34
96
 
35
- const running = await isProxyRunning()
97
+ function emitUpdated({ fileOnly, target }) {
98
+ const payload = { success: true, file: fileName, pattern: pattern.trim(), newTarget: target }
99
+ if (explicitExclusions) payload.exclusions = appliedExclusions
100
+ if (fileOnly) {
101
+ payload.fileOnly = true
102
+ payload.warning = output.FILE_ONLY_WARNING
103
+ }
104
+ if (jsonFlag) {
105
+ output.jsonRaw(payload)
106
+ return
107
+ }
108
+ output.header('Route Rule Updated')
109
+ output.success(`${pattern.trim()} -> ${target}`)
110
+ if (explicitExclusions) {
111
+ output.kv('Exclusions', appliedExclusions.length ? appliedExclusions.map((e) => `!${e}`).join(' ') : 'cleared')
112
+ }
113
+ output.kv('File', fileName)
114
+ }
36
115
 
37
- const pat = pattern.trim()
38
- const tgt = newTarget.trim()
116
+ async function run() {
117
+ if (!fileName) fail('Missing required argument: <file>', jsonFlag)
118
+ if (!pattern) fail('Missing required argument: <pattern>', jsonFlag)
119
+ if (parsed.positional.length > 3) fail(`Unexpected argument: ${parsed.positional[3]}`, jsonFlag)
120
+
121
+ let plan
122
+ try {
123
+ plan = planUpdate()
124
+ } catch (e) {
125
+ fail(e.message, jsonFlag)
126
+ }
127
+ if (!newTarget && !explicitExclusions) fail('Missing required argument: <target>', jsonFlag)
128
+
129
+ const running = await isProxyRunning()
39
130
 
40
- // Handle [marker] syntax for lookup
41
- const bm = pat.match(/\[([^\]]+)\]/)
42
- const internalKey = bm ? pat.replace(bm[0], bm[1]) : pat
131
+ const finish = (content, fileOnly) => {
132
+ const target = newTarget ? newTarget.trim() : targetOnDisk(content, plan.patterns[0])
133
+ emitUpdated({ fileOnly, target })
134
+ }
43
135
 
44
136
  if (running) {
45
137
  try {
46
138
  let content = await apiGet(`/api/rule-files/${encodeURIComponent(fileName)}/content`)
47
139
  const text = typeof content === 'string' ? content : String(content)
48
- const ruleMap = parseEprc(text)
49
-
50
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
51
- output.error(`Pattern not found: ${pat}`)
52
- process.exit(1)
53
- }
54
-
55
- ruleMap[internalKey] = bm ? tgt + bm[0] : tgt
56
- const newContent = ruleMapToEprcText(ruleMap)
140
+ const newContent = applyUpdate(text, plan)
57
141
  await apiPut(`/api/rule-files/${encodeURIComponent(fileName)}/content`, { content: newContent })
58
-
59
- if (jsonFlag) {
60
- output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt })
61
- return
62
- }
63
-
64
- output.header('Route Rule Updated')
65
- output.success(`${pat} -> ${tgt}`)
66
- output.kv('File', fileName)
142
+ finish(newContent, false)
143
+ return
67
144
  } catch (e) {
145
+ // Issue #137: a locked ruleset is a final answer, never a cue to write the file directly.
146
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
68
147
  // Fallback to file mode
69
148
  const content = getRuleFileContent(fileName)
70
- if (!content) {
71
- output.error(`Route file not found: ${fileName}`)
72
- process.exit(1)
73
- }
74
-
75
- const ruleMap = parseEprc(content)
76
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
77
- output.error(`Pattern not found: ${pat}`)
78
- process.exit(1)
79
- }
80
-
81
- ruleMap[internalKey] = bm ? tgt + bm[0] : tgt
82
- const newContent = ruleMapToEprcText(ruleMap)
149
+ if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
150
+ const newContent = applyUpdate(content, plan)
83
151
  saveRuleFileContent(fileName, newContent)
84
152
  output.warnFileOnlyMode()
85
-
86
- if (jsonFlag) {
87
- output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt,
88
- fileOnly: true,
89
- warning: output.FILE_ONLY_WARNING
90
- })
91
- return
92
- }
93
-
94
-
95
- output.header('Route Rule Updated')
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
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
109
- output.error(`Pattern not found: ${pat}`)
110
- process.exit(1)
111
- }
112
-
113
- ruleMap[internalKey] = bm ? tgt + bm[0] : tgt
114
- const newContent = ruleMapToEprcText(ruleMap)
115
- saveRuleFileContent(fileName, newContent)
116
- output.warnFileOnlyMode()
117
-
118
- if (jsonFlag) {
119
- output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt,
120
- fileOnly: true,
121
- warning: output.FILE_ONLY_WARNING
122
- })
153
+ finish(newContent, true)
123
154
  return
124
155
  }
125
-
126
-
127
- output.header('Route Rule Updated')
128
- output.success(`${pat} -> ${tgt}`)
129
- output.kv('File', fileName)
130
156
  }
157
+
158
+ const content = getRuleFileContent(fileName)
159
+ if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
160
+ const newContent = applyUpdate(content, plan)
161
+ saveRuleFileContent(fileName, newContent)
162
+ output.warnFileOnlyMode()
163
+ finish(newContent, true)
131
164
  }
132
165
 
133
166
  run().catch(e => {
134
- output.error(e.message)
135
- process.exit(1)
136
- })
167
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
168
+ fail(e.message, jsonFlag)
169
+ })
package/bin/index.js CHANGED
@@ -107,7 +107,8 @@ Route 命令:
107
107
  meddle route active set <file> 设置激活的路由文件
108
108
  meddle route create <name> 创建路由文件
109
109
  meddle route add <file> <pattern> <target> 添加路由规则
110
- meddle route update <file> <pattern> <target> 更新路由规则
110
+ meddle route update <file> <pattern> [<target>] [--exclusions=]
111
+ 更新路由规则;--exclusions= 清空排除项
111
112
  meddle route delete <file> <pattern> 删除路由规则
112
113
 
113
114
  全局选项:
@@ -137,6 +138,7 @@ Route 命令:
137
138
  meddle route preview "https://api.example.com/v1/users" # 预览转发目标
138
139
  meddle route preview "https://cdn.com/assets/js/app.js" --file dev-rules --json
139
140
  meddle route add dev-rules "api.test.com" "localhost:3000"
141
+ meddle route update dev-rules "api.test.com" --exclusions=
140
142
  meddle route active set beta-rules
141
143
 
142
144
  更多信息请访问: https://github.com/JonathanLee-LX/meddle
@@ -25,14 +25,26 @@ async function apiRequest(method, pathname, body, timeoutMs = DEFAULT_TIMEOUT) {
25
25
 
26
26
  if (!response.ok) {
27
27
  const text = await response.text()
28
- throw new Error(text || response.statusText)
28
+ const err = new Error(text || response.statusText)
29
+ err.status = response.status
30
+ // Structured errors (e.g. issue #137 RULESET_READONLY) keep their code.
31
+ try {
32
+ const data = JSON.parse(text)
33
+ if (data && typeof data === 'object') {
34
+ if (typeof data.code === 'string') err.code = data.code
35
+ if (typeof data.ruleFile === 'string') err.ruleFile = data.ruleFile
36
+ }
37
+ } catch (_) {}
38
+ throw err
29
39
  }
30
40
 
31
41
  const contentType = response.headers.get('content-type')
32
42
  if (contentType && contentType.includes('application/json')) {
33
43
  return response.json()
34
44
  }
35
- return response.text()
45
+ // Issue #145: `Response.text()` strips a leading UTF-8 BOM, so a rule file
46
+ // read and written back through the API would silently lose it.
47
+ return new TextDecoder('utf-8', { ignoreBOM: true }).decode(await response.arrayBuffer())
36
48
  }
37
49
 
38
50
  /**
@@ -5,6 +5,7 @@
5
5
  const fs = require('fs')
6
6
  const path = require('path')
7
7
  const { meddleDir } = require('./proxy-detect')
8
+ const { RulesetReadonlyError } = require('./ruleset-readonly')
8
9
 
9
10
  const routeRulesDir = path.join(meddleDir, 'route-rules')
10
11
  const settingsPath = path.join(meddleDir, 'settings.json')
@@ -51,6 +52,33 @@ function setActiveRuleFileNames(names) {
51
52
  saveSettings(settings)
52
53
  }
53
54
 
55
+ /**
56
+ * Issue #137: locked rule files (settings.json `readonlyRuleFiles`). Entries
57
+ * whose file no longer exists are dropped silently. A missing field = none locked.
58
+ */
59
+ function getReadonlyRuleFileNames() {
60
+ const settings = loadSettings()
61
+ if (!Object.prototype.hasOwnProperty.call(settings, 'readonlyRuleFiles')) return []
62
+ const raw = settings.readonlyRuleFiles
63
+ const names = Array.isArray(raw) ? raw.filter((n, i, arr) => typeof n === 'string' && n && arr.indexOf(n) === i) : []
64
+ const live = names.filter((name) => fs.existsSync(path.join(routeRulesDir, `${name}.txt`)))
65
+ if (!Array.isArray(raw) || live.length !== raw.length) {
66
+ settings.readonlyRuleFiles = live
67
+ saveSettings(settings)
68
+ }
69
+ return live
70
+ }
71
+
72
+ function isRuleFileReadonly(name) {
73
+ const fold = (n) => (process.platform === 'darwin' || process.platform === 'win32' ? n.toLowerCase() : n)
74
+ return getReadonlyRuleFileNames().some((item) => fold(item) === fold(name))
75
+ }
76
+
77
+ /** Throws RulesetReadonlyError (code RULESET_READONLY) for a locked rule file. */
78
+ function assertRuleFileWritable(name) {
79
+ if (isRuleFileReadonly(name)) throw new RulesetReadonlyError(name)
80
+ }
81
+
54
82
  // ========== Mocks ==========
55
83
 
56
84
  /**
@@ -217,6 +245,7 @@ function listRuleFiles() {
217
245
  .map(f => f.replace(/\.txt$/, ''))
218
246
 
219
247
  const ordered = resolveRuleFileOrder(files, getRuleFileOrderNames(), activeNames)
248
+ const readonlyNames = new Set(getReadonlyRuleFileNames())
220
249
 
221
250
  return ordered.map(name => {
222
251
  const filePath = getRuleFilePath(name)
@@ -234,6 +263,7 @@ function listRuleFiles() {
234
263
  return {
235
264
  name,
236
265
  enabled: activeNames.includes(name),
266
+ readonly: readonlyNames.has(name),
237
267
  ruleCount,
238
268
  excludeCount
239
269
  }
@@ -258,6 +288,7 @@ function getRuleFileContent(name) {
258
288
  */
259
289
  function saveRuleFileContent(name, content) {
260
290
  ensureRouteRulesDir()
291
+ assertRuleFileWritable(name)
261
292
  const filePath = getRuleFilePath(name)
262
293
  fs.writeFileSync(filePath, content, 'utf8')
263
294
  }
@@ -274,6 +305,8 @@ function createRuleFile(name, content = '', enabled = true) {
274
305
  throw new Error(`规则文件 "${safeName}" 已存在`)
275
306
  }
276
307
 
308
+ // Drop stale lock entries so a new file never inherits an old lock.
309
+ getReadonlyRuleFileNames()
277
310
  fs.writeFileSync(filePath, content, 'utf8')
278
311
 
279
312
  if (enabled) {
@@ -285,7 +318,7 @@ function createRuleFile(name, content = '', enabled = true) {
285
318
  }
286
319
 
287
320
  const ruleCount = content.trim() ? Object.keys(parseEprc(content)).length : 0
288
- return { name: safeName, enabled, ruleCount }
321
+ return { name: safeName, enabled, readonly: false, ruleCount }
289
322
  }
290
323
 
291
324
  /**
@@ -296,6 +329,7 @@ function deleteRuleFile(name) {
296
329
  if (!fs.existsSync(filePath)) {
297
330
  throw new Error('规则文件不存在')
298
331
  }
332
+ assertRuleFileWritable(name)
299
333
  fs.unlinkSync(filePath)
300
334
 
301
335
  const activeNames = getActiveRuleFileNames()
@@ -317,6 +351,7 @@ function setRuleFileEnabled(name, enabled) {
317
351
 
318
352
  const activeNames = getActiveRuleFileNames()
319
353
  const idx = activeNames.indexOf(name)
354
+ if ((enabled && idx === -1) || (!enabled && idx !== -1)) assertRuleFileWritable(name)
320
355
 
321
356
  if (enabled && idx === -1) {
322
357
  activeNames.push(name)
@@ -352,6 +387,9 @@ module.exports = {
352
387
  saveSettings,
353
388
  getActiveRuleFileNames,
354
389
  setActiveRuleFileNames,
390
+ getReadonlyRuleFileNames,
391
+ isRuleFileReadonly,
392
+ assertRuleFileWritable,
355
393
 
356
394
  // Mocks
357
395
  getMocksPath,
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Argument parsing for `meddle route add|update|delete` (issue #149).
3
+ *
4
+ * `--exclusions=` clears a rule's exclusions. `--exclusions=/a,/b` replaces
5
+ * them. A bare `--exclusions`, an unknown flag, or an empty `!` token used to
6
+ * be ignored and the command still exited 0 without changing exclusions.
7
+ */
8
+
9
+ const { splitPatternArg } = require('./rule-file-edit')
10
+
11
+ class RouteArgError extends Error {
12
+ constructor(message) {
13
+ super(message)
14
+ this.name = 'RouteArgError'
15
+ }
16
+ }
17
+
18
+ const CLEAR_HINT = 'To clear exclusions, pass --exclusions= (empty). To replace them, pass --exclusions=/a,/b.'
19
+
20
+ /**
21
+ * @param {string} raw value after `=`
22
+ * @returns {string[]}
23
+ */
24
+ function parseExclusionList(raw) {
25
+ const value = String(raw).trim()
26
+ if (value === '') return []
27
+ const pieces = value.includes(',') ? value.split(',') : value.split(/\s+/)
28
+ const out = []
29
+ for (const piece of pieces) {
30
+ const token = piece.trim()
31
+ if (!token) {
32
+ throw new RouteArgError(`Invalid --exclusions: empty entry. ${CLEAR_HINT}`)
33
+ }
34
+ const body = token.startsWith('!') ? token.slice(1).trim() : token
35
+ if (!body) {
36
+ throw new RouteArgError(`Invalid --exclusions: "!" needs a path, for example --exclusions=/health. ${CLEAR_HINT}`)
37
+ }
38
+ out.push(body)
39
+ }
40
+ return out
41
+ }
42
+
43
+ /**
44
+ * @param {string[]} args argv after the subcommand
45
+ * @param {{ allowExclusions?: boolean }} [options]
46
+ * @returns {{ help: boolean, json: boolean, positional: string[], exclusions: string[] | undefined }}
47
+ * `exclusions` is undefined when the flag was not passed. `[]` means clear.
48
+ */
49
+ function parseRouteWriteArgs(args, { allowExclusions = true } = {}) {
50
+ let help = false
51
+ let json = false
52
+ /** @type {string[] | undefined} */
53
+ let exclusions
54
+ let sawExclusions = false
55
+ const positional = []
56
+
57
+ for (let i = 0; i < args.length; i++) {
58
+ const arg = args[i]
59
+ if (arg === '--help' || arg === '-h') {
60
+ help = true
61
+ continue
62
+ }
63
+ if (arg === '--json') {
64
+ json = true
65
+ continue
66
+ }
67
+ if (arg === '--exclusions' || arg.startsWith('--exclusions=')) {
68
+ if (!allowExclusions) {
69
+ throw new RouteArgError(`Unsupported option: ${arg === '--exclusions' ? '--exclusions' : '--exclusions='}. route delete does not change exclusions.`)
70
+ }
71
+ if (arg === '--exclusions') {
72
+ throw new RouteArgError(`Unsupported option: --exclusions. ${CLEAR_HINT}`)
73
+ }
74
+ if (sawExclusions) throw new RouteArgError('--exclusions was given more than once.')
75
+ sawExclusions = true
76
+ exclusions = parseExclusionList(arg.slice('--exclusions='.length))
77
+ continue
78
+ }
79
+ if (arg.startsWith('-')) {
80
+ throw new RouteArgError(`Unsupported option: ${arg}.`)
81
+ }
82
+ positional.push(arg)
83
+ }
84
+
85
+ return { help, json, positional, exclusions: sawExclusions ? exclusions : undefined }
86
+ }
87
+
88
+ /**
89
+ * Split a pattern argument the way the CLI always has (`"host !/api"`), but
90
+ * refuse a bare `!` — that used to be dropped and looked like a successful clear.
91
+ * @returns {{ patterns: string[], exclusions: string[] }}
92
+ */
93
+ function parsePatternArg(raw) {
94
+ const parts = String(raw).trim().split(/\s+/).filter(Boolean)
95
+ for (const part of parts) {
96
+ if (part.startsWith('!') && part.slice(1).trim() === '') {
97
+ throw new RouteArgError('Invalid exclusion "!": a "!" token needs a path such as "!/api". To clear exclusions, pass --exclusions=.')
98
+ }
99
+ }
100
+ return splitPatternArg(raw)
101
+ }
102
+
103
+ /**
104
+ * @param {string[]} patternExclusions exclusions parsed from !path tokens
105
+ * @param {string[] | undefined} flagExclusions undefined when --exclusions was omitted
106
+ * @returns {{ explicit: boolean, exclusions: string[] | undefined }}
107
+ */
108
+ function resolveExclusions(patternExclusions, flagExclusions) {
109
+ if (flagExclusions !== undefined && patternExclusions.length > 0) {
110
+ throw new RouteArgError('Pass exclusions either as !path tokens in the pattern or with --exclusions, not both.')
111
+ }
112
+ if (flagExclusions !== undefined) return { explicit: true, exclusions: flagExclusions }
113
+ if (patternExclusions.length > 0) return { explicit: true, exclusions: patternExclusions }
114
+ return { explicit: false, exclusions: undefined }
115
+ }
116
+
117
+ const UPDATE_HELP = `
118
+ meddle route update <file> <pattern> [<target>] [--exclusions=<list>] [--json]
119
+
120
+ Update a rule. Edits only the line(s) that hold the pattern; every other line
121
+ stays byte-for-byte, including comments, blank lines and other rules' !path.
122
+
123
+ --exclusions= Clear this rule's exclusions (the line loses !path).
124
+ --exclusions=/a,/b Replace exclusions. Spaces work inside quotes
125
+ (--exclusions="/a /b"). A leading ! is optional.
126
+ "pattern !/a !/b" Replace exclusions (same as before). Omit both
127
+ --exclusions and !path to keep existing exclusions.
128
+
129
+ The <target> may be omitted when --exclusions is set; the current target stays.
130
+ A bare --exclusions, an empty "!" token, an unknown flag, or both exclusion
131
+ forms at once exits 1 and does not change the file.
132
+
133
+ A "#" comments out a whole line only when it is the first non-space character.
134
+ A "#" later on a rule line is a pattern token, not a trailing comment.
135
+
136
+ Examples:
137
+ meddle route update dev "api.example.com" "localhost:4000"
138
+ meddle route update dev "api.example.com" --exclusions=
139
+ meddle route update dev "api.example.com" "localhost:4000" --exclusions=/health
140
+ `
141
+
142
+ module.exports = {
143
+ CLEAR_HINT,
144
+ RouteArgError,
145
+ UPDATE_HELP,
146
+ parseExclusionList,
147
+ parsePatternArg,
148
+ parseRouteWriteArgs,
149
+ resolveExclusions,
150
+ }