@jonathanleelx/meddle 0.5.0-beta.8 → 0.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.
Files changed (208) hide show
  1. package/CONFIG_STRUCTURE.md +9 -2
  2. package/README.md +18 -0
  3. package/bin/commands/mock/add.js +37 -21
  4. package/bin/commands/mock/delete.js +16 -24
  5. package/bin/commands/mock/index.js +8 -4
  6. package/bin/commands/mock/list.js +35 -18
  7. package/bin/commands/mock/toggle.js +17 -27
  8. package/bin/commands/mock/update.js +39 -30
  9. package/bin/commands/route/active.js +20 -3
  10. package/bin/commands/route/add.js +113 -94
  11. package/bin/commands/route/create.js +8 -1
  12. package/bin/commands/route/delete.js +85 -51
  13. package/bin/commands/route/index.js +20 -2
  14. package/bin/commands/route/list.js +2 -2
  15. package/bin/commands/route/show.js +10 -1
  16. package/bin/commands/route/update.js +135 -92
  17. package/bin/commands/update.js +4 -0
  18. package/bin/index.js +7 -3
  19. package/bin/lib/api-client.js +14 -2
  20. package/bin/lib/file-access.js +217 -35
  21. package/bin/lib/output.js +24 -3
  22. package/bin/lib/proxy-detect.js +99 -20
  23. package/bin/lib/proxy-or-file.js +90 -0
  24. package/bin/lib/route-write-args.js +150 -0
  25. package/bin/lib/rule-file-edit.js +263 -0
  26. package/bin/lib/ruleset-readonly.js +61 -0
  27. package/bin/lib/update-check.js +67 -6
  28. package/dist/cert.d.ts.map +1 -1
  29. package/dist/cert.js +43 -0
  30. package/dist/cert.js.map +1 -1
  31. package/dist/core/browser.d.ts +8 -1
  32. package/dist/core/browser.d.ts.map +1 -1
  33. package/dist/core/browser.js +239 -36
  34. package/dist/core/browser.js.map +1 -1
  35. package/dist/core/config-diagnostics.d.ts.map +1 -1
  36. package/dist/core/config-diagnostics.js +6 -1
  37. package/dist/core/config-diagnostics.js.map +1 -1
  38. package/dist/core/custom-plugin-loader.d.ts.map +1 -1
  39. package/dist/core/custom-plugin-loader.js +2 -0
  40. package/dist/core/custom-plugin-loader.js.map +1 -1
  41. package/dist/core/h2-pool.d.ts +1 -0
  42. package/dist/core/h2-pool.d.ts.map +1 -1
  43. package/dist/core/h2-pool.js +17 -1
  44. package/dist/core/h2-pool.js.map +1 -1
  45. package/dist/core/mock-handler.d.ts +59 -0
  46. package/dist/core/mock-handler.d.ts.map +1 -1
  47. package/dist/core/mock-handler.js +627 -29
  48. package/dist/core/mock-handler.js.map +1 -1
  49. package/dist/core/mock-utils.d.ts +58 -0
  50. package/dist/core/mock-utils.d.ts.map +1 -0
  51. package/dist/core/mock-utils.js +167 -0
  52. package/dist/core/mock-utils.js.map +1 -0
  53. package/dist/core/plugin-bootstrap-runner.d.ts +1 -0
  54. package/dist/core/plugin-bootstrap-runner.d.ts.map +1 -1
  55. package/dist/core/plugin-bootstrap-runner.js +37 -1
  56. package/dist/core/plugin-bootstrap-runner.js.map +1 -1
  57. package/dist/core/plugin-source.d.ts +24 -0
  58. package/dist/core/plugin-source.d.ts.map +1 -0
  59. package/dist/core/plugin-source.js +47 -0
  60. package/dist/core/plugin-source.js.map +1 -0
  61. package/dist/core/rule-file-edit.d.ts +81 -0
  62. package/dist/core/rule-file-edit.d.ts.map +1 -0
  63. package/dist/core/rule-file-edit.js +243 -0
  64. package/dist/core/rule-file-edit.js.map +1 -0
  65. package/dist/core/static-server.d.ts.map +1 -1
  66. package/dist/core/static-server.js +15 -13
  67. package/dist/core/static-server.js.map +1 -1
  68. package/dist/core/types.d.ts +13 -1
  69. package/dist/core/types.d.ts.map +1 -1
  70. package/dist/plugins/builtin/mock-plugin.d.ts.map +1 -1
  71. package/dist/plugins/builtin/mock-plugin.js +6 -5
  72. package/dist/plugins/builtin/mock-plugin.js.map +1 -1
  73. package/dist/server/agent/tools.d.ts.map +1 -1
  74. package/dist/server/agent/tools.js +24 -18
  75. package/dist/server/agent/tools.js.map +1 -1
  76. package/dist/server/app-info.d.ts +33 -0
  77. package/dist/server/app-info.d.ts.map +1 -0
  78. package/dist/server/app-info.js +116 -0
  79. package/dist/server/app-info.js.map +1 -0
  80. package/dist/server/config.d.ts +1 -0
  81. package/dist/server/config.d.ts.map +1 -1
  82. package/dist/server/config.js +101 -9
  83. package/dist/server/config.js.map +1 -1
  84. package/dist/server/health.d.ts.map +1 -1
  85. package/dist/server/health.js +8 -0
  86. package/dist/server/health.js.map +1 -1
  87. package/dist/server/index.d.ts +15 -0
  88. package/dist/server/index.d.ts.map +1 -1
  89. package/dist/server/index.js +3 -0
  90. package/dist/server/index.js.map +1 -1
  91. package/dist/server/mocks.d.ts +26 -0
  92. package/dist/server/mocks.d.ts.map +1 -1
  93. package/dist/server/mocks.js +110 -13
  94. package/dist/server/mocks.js.map +1 -1
  95. package/dist/server/plugins.d.ts.map +1 -1
  96. package/dist/server/plugins.js +56 -11
  97. package/dist/server/plugins.js.map +1 -1
  98. package/dist/server/rule-files.d.ts +36 -0
  99. package/dist/server/rule-files.d.ts.map +1 -1
  100. package/dist/server/rule-files.js +395 -107
  101. package/dist/server/rule-files.js.map +1 -1
  102. package/dist/server/ruleset-lock.d.ts +62 -0
  103. package/dist/server/ruleset-lock.d.ts.map +1 -0
  104. package/dist/server/ruleset-lock.js +234 -0
  105. package/dist/server/ruleset-lock.js.map +1 -0
  106. package/index.js +36 -7
  107. package/mcp-server.js +90 -88
  108. package/package.json +1 -1
  109. package/plugins/builtin/mock-plugin.ts +9 -5
  110. package/web/dist/assets/alert-B514Frte.js +1 -0
  111. package/web/dist/assets/arrow-right-BNum_060.js +1 -0
  112. package/web/dist/assets/{body-diff-view-B-4XWmL6.js → body-diff-view-SfLCLzs9.js} +2 -2
  113. package/web/dist/assets/button-BFNlGZ5p.js +1 -0
  114. package/web/dist/assets/check-BkfHQ6re.js +1 -0
  115. package/web/dist/assets/checkbox-CQyFsZzS.js +1 -0
  116. package/web/dist/assets/chevron-down-DHwUtL4O.js +1 -0
  117. package/web/dist/assets/{circle-alert-Q8cZk9Rv.js → circle-alert-zMagrFuI.js} +1 -1
  118. package/web/dist/assets/{circle-x-C9USji_R.js → circle-x-BEkinhez.js} +1 -1
  119. package/web/dist/assets/code-DD-AYxxu.js +1 -0
  120. package/web/dist/assets/{copy-BYjq1hdN.js → copy-BRnscYy1.js} +1 -1
  121. package/web/dist/assets/detail-panel-BtgWWeu3.js +4 -0
  122. package/web/dist/assets/dist-BQN0rzUS.js +1 -0
  123. package/web/dist/assets/{dist-CEd_wnRC.js → dist-C-E2R2jk.js} +1 -1
  124. package/web/dist/assets/{dist-C6eaYCUo.js → dist-CFZ4xMSF.js} +1 -1
  125. package/web/dist/assets/{dist-DWmDaXVN.js → dist-CRmX_NvH.js} +1 -1
  126. package/web/dist/assets/{dist-C8RHq5At.js → dist-DLQKRjuW.js} +1 -1
  127. package/web/dist/assets/{dist-Blyulj4_.js → dist-LiAcI8CK.js} +1 -1
  128. package/web/dist/assets/{dist-Ce4ujolc.js → dist-fzPL-8F9.js} +1 -1
  129. package/web/dist/assets/{dist-DoE7uybN.js → dist-wv9ebJK_.js} +1 -1
  130. package/web/dist/assets/editor-pane-actions-_ckwcCII.js +1 -0
  131. package/web/dist/assets/{es2015-DG46NGN7.js → es2015-DPJTBYa9.js} +1 -1
  132. package/web/dist/assets/{global-panel-shell-5cJneeis.js → global-panel-shell-NaUhzX7J.js} +3 -3
  133. package/web/dist/assets/health-panel-DhXLqcOU.js +1 -0
  134. package/web/dist/assets/index-B-6nUrqW.js +21 -0
  135. package/web/dist/assets/index-EWNlAdFB.css +2 -0
  136. package/web/dist/assets/mobile-proxy-panel-BY8Oi-qC.js +8 -0
  137. package/web/dist/assets/mock-config-TPmfJwAt.js +1 -0
  138. package/web/dist/assets/mock-editor-panel-WQwcHSTD.js +20 -0
  139. package/web/dist/assets/mocks-file-missing-note-BR5oWFLh.js +1 -0
  140. package/web/dist/assets/monaco-editor-BGLPrb0C.js +11 -0
  141. package/web/dist/assets/pane-bar-gFRISZLD.js +1 -0
  142. package/web/dist/assets/pane-split-B6aLmY7G.js +1 -0
  143. package/web/dist/assets/panel-heading-DT_nXayM.js +1 -0
  144. package/web/dist/assets/{pencil-line-Cqe41E4h.js → pencil-line-CEZ7D-de.js} +1 -1
  145. package/web/dist/assets/plugin-code-editor--Gf4ZwGb.js +2 -0
  146. package/web/dist/assets/plugin-config-DsX9xCW_.js +2 -0
  147. package/web/dist/assets/plugin-generator-Bj4f6dxo.js +2 -0
  148. package/web/dist/assets/plugin-test-dialog-DICuykBK.js +7 -0
  149. package/web/dist/assets/plus-ixZQRprm.js +1 -0
  150. package/web/dist/assets/{refresh-cw-CG3HaiQT.js → refresh-cw-BfLoHqg5.js} +1 -1
  151. package/web/dist/assets/rotate-ccw-CRguJya4.js +1 -0
  152. package/web/dist/assets/route-preview-C-LzS1lT.js +1 -0
  153. package/web/dist/assets/{rule-ai-assistant-panel-DMh0F9dr.js → rule-ai-assistant-panel-BEfPNBbx.js} +2 -2
  154. package/web/dist/assets/rule-config-Dw9pcuah.js +15 -0
  155. package/web/dist/assets/rule-overview-panel-C4QQ7U3S.js +1 -0
  156. package/web/dist/assets/{server-Cpj4KNwN.js → server-eYXq3PbE.js} +1 -1
  157. package/web/dist/assets/settings-panel-D8DEH8aH.js +1 -0
  158. package/web/dist/assets/{shield-check-OqUmVLQt.js → shield-check-hQXjdfZ7.js} +1 -1
  159. package/web/dist/assets/smartphone-D4Q7cQfm.js +1 -0
  160. package/web/dist/assets/switch-94vgsMHm.js +1 -0
  161. package/web/dist/assets/{syntax-highlight-Dk0-ewLI.js → syntax-highlight-BFgN_C0Y.js} +1 -1
  162. package/web/dist/assets/{table-2-BR5e3z8m.js → table-2-DvGHhMm4.js} +1 -1
  163. package/web/dist/assets/table-DaXD6jJv.js +1 -0
  164. package/web/dist/assets/textarea-9wB8Hsz-.js +1 -0
  165. package/web/dist/assets/toast-9yeTf7d9.js +1 -0
  166. package/web/dist/assets/use-mocks-file-status-BUIUJ4oQ.js +1 -0
  167. package/web/dist/assets/{wand-sparkles-CJ7fSnom.js → wand-sparkles-Bmt4dDOf.js} +1 -1
  168. package/web/dist/assets/{wifi-CCLKyySQ.js → wifi-Cjj_Br1u.js} +1 -1
  169. package/web/dist/assets/{zap-DR-xiUMe.js → zap-BuOtogAv.js} +1 -1
  170. package/web/dist/index.html +16 -10
  171. package/web/dist/assets/alert-DCvlZAiv.js +0 -1
  172. package/web/dist/assets/arrow-right-B4Mrlj2-.js +0 -1
  173. package/web/dist/assets/button-BTToMaBW.js +0 -1
  174. package/web/dist/assets/checkbox-D1Z9DtjF.js +0 -1
  175. package/web/dist/assets/chevron-down-CPngAx4M.js +0 -1
  176. package/web/dist/assets/chevron-right-V1DGIDbL.js +0 -1
  177. package/web/dist/assets/code-5_eJ_XpJ.js +0 -1
  178. package/web/dist/assets/code-fixer-D48sCDtp.js +0 -20
  179. package/web/dist/assets/detail-panel-BSFz9CUw.js +0 -4
  180. package/web/dist/assets/file-system-access-_og-Tg9o.js +0 -1
  181. package/web/dist/assets/health-panel-BS3L2e5d.js +0 -1
  182. package/web/dist/assets/index-CvTR1IVi.js +0 -18
  183. package/web/dist/assets/index-bfV7sxx5.css +0 -2
  184. package/web/dist/assets/mobile-proxy-panel-tU0NyJ3P.js +0 -8
  185. package/web/dist/assets/mock-config-CULJP6Vt.js +0 -1
  186. package/web/dist/assets/mock-editor-panel-9wlDaySA.js +0 -1
  187. package/web/dist/assets/monaco-editor-BttaUoOz.js +0 -11
  188. package/web/dist/assets/plugin-code-editor-BAIBp4lg.js +0 -2
  189. package/web/dist/assets/plugin-config-ButUV2SZ.js +0 -1
  190. package/web/dist/assets/plugin-generator-uceUPSCv.js +0 -2
  191. package/web/dist/assets/plugin-test-dialog-BrJ4HfRX.js +0 -7
  192. package/web/dist/assets/plus-tVxSydJ8.js +0 -1
  193. package/web/dist/assets/rotate-ccw-CAYM6Vv0.js +0 -1
  194. package/web/dist/assets/route-preview-C4cGrS1-.js +0 -1
  195. package/web/dist/assets/rule-config-BTTGNwMs.js +0 -15
  196. package/web/dist/assets/rule-overview-panel-SFR2OloL.js +0 -1
  197. package/web/dist/assets/select-aBXQ0zNa.js +0 -1
  198. package/web/dist/assets/settings-panel-BMReDG8b.js +0 -1
  199. package/web/dist/assets/smartphone-DKeDuJTU.js +0 -1
  200. package/web/dist/assets/switch-Bes_YiJu.js +0 -1
  201. package/web/dist/assets/table-nW7uQVcl.js +0 -1
  202. package/web/dist/assets/textarea-BSI0rnW6.js +0 -1
  203. /package/web/dist/assets/{babel-DCr4VA1c.js → babel-bwZm0qi3.js} +0 -0
  204. /package/web/dist/assets/{clipboard-VhPXNN5m.js → clipboard-Bt1qY01V.js} +0 -0
  205. /package/web/dist/assets/{estree-qO_m2Qaw.js → estree-MatbZDwq.js} +0 -0
  206. /package/web/dist/assets/{html-VDGahtyk.js → html-CCrsbFYX.js} +0 -0
  207. /package/web/dist/assets/{postcss-BUm8EKMw.js → postcss-BxN7Gr8n.js} +0 -0
  208. /package/web/dist/assets/{standalone-Bj8SSd8Q.js → standalone-DsnFnsqM.js} +0 -0
@@ -1,126 +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
+ }
115
+
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)
36
128
 
37
- const pat = pattern.trim()
38
- const tgt = newTarget.trim()
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
-
85
- if (jsonFlag) {
86
- output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt })
87
- return
88
- }
89
-
90
- output.header('Route Rule Updated')
91
- output.success(`${pat} -> ${tgt}`)
92
- output.kv('File', fileName)
93
- }
94
- } else {
95
- // File mode
96
- const content = getRuleFileContent(fileName)
97
- if (!content) {
98
- output.error(`Route file not found: ${fileName}`)
99
- process.exit(1)
100
- }
101
-
102
- const ruleMap = parseEprc(content)
103
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
104
- output.error(`Pattern not found: ${pat}`)
105
- process.exit(1)
106
- }
107
-
108
- ruleMap[internalKey] = bm ? tgt + bm[0] : tgt
109
- const newContent = ruleMapToEprcText(ruleMap)
110
- saveRuleFileContent(fileName, newContent)
111
-
112
- if (jsonFlag) {
113
- output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt })
152
+ output.warnFileOnlyMode()
153
+ finish(newContent, true)
114
154
  return
115
155
  }
116
-
117
- output.header('Route Rule Updated')
118
- output.success(`${pat} -> ${tgt}`)
119
- output.kv('File', fileName)
120
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)
121
164
  }
122
165
 
123
166
  run().catch(e => {
124
- output.error(e.message)
125
- process.exit(1)
126
- })
167
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
168
+ fail(e.message, jsonFlag)
169
+ })
@@ -77,6 +77,10 @@ meddle update - 检查并升级 meddle
77
77
  meddle update --auto on|off 启用/禁用自动更新 (默认关闭)
78
78
  meddle update status 查看安装方式、当前版本和自动更新开关
79
79
  meddle update --help, -h 显示帮助
80
+
81
+ 私有仓库二进制更新需要设置 GH_TOKEN 或 GITHUB_TOKEN(优先 GH_TOKEN;
82
+ 权限:repo,或 fine-grained contents:read)。也可:
83
+ gh release download -R JonathanLee-LX/meddle -p 'meddle-*'
80
84
  `)
81
85
  }
82
86
 
package/bin/index.js CHANGED
@@ -59,7 +59,7 @@ if (command === 'mock' || command === 'route' || command === 'session') {
59
59
  // No command or only flags - start proxy (default behavior)
60
60
  require('../index.js')
61
61
  } else {
62
- console.log(`Unknown command: ${command}`)
62
+ console.error(`Unknown command: ${command}`)
63
63
  showHelp()
64
64
  process.exit(1)
65
65
  }
@@ -106,8 +106,10 @@ Route 命令:
106
106
  meddle route active 查看当前激活的路由文件
107
107
  meddle route active set <file> 设置激活的路由文件
108
108
  meddle route create <name> 创建路由文件
109
- meddle route add <file> <pattern> <target> 添加路由规则
110
- meddle route update <file> <pattern> <target> 更新路由规则
109
+ meddle route add <file> <pattern> <target> [--exclusions=]
110
+ 添加路由规则;--exclusions= 清空排除项
111
+ meddle route update <file> <pattern> [<target>] [--exclusions=]
112
+ 更新路由规则;--exclusions= 清空排除项
111
113
  meddle route delete <file> <pattern> 删除路由规则
112
114
 
113
115
  全局选项:
@@ -137,6 +139,8 @@ Route 命令:
137
139
  meddle route preview "https://api.example.com/v1/users" # 预览转发目标
138
140
  meddle route preview "https://cdn.com/assets/js/app.js" --file dev-rules --json
139
141
  meddle route add dev-rules "api.test.com" "localhost:3000"
142
+ meddle route add dev-rules "api.test.com" "localhost:3000" --exclusions=
143
+ meddle route update dev-rules "api.test.com" --exclusions=
140
144
  meddle route active set beta-rules
141
145
 
142
146
  更多信息请访问: 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
  /**
@@ -62,36 +90,163 @@ function getMocksPath() {
62
90
  }
63
91
 
64
92
  /**
65
- * Load mock rules from file
93
+ * Offset of the first JSON syntax error. Kept in sync with
94
+ * core/mock-handler.ts findJsonErrorOffset (V8 often omits a position).
66
95
  */
67
- function loadMockRules() {
68
- const mocksPath = getMocksPath()
69
- try {
70
- if (fs.existsSync(mocksPath)) {
71
- const data = JSON.parse(fs.readFileSync(mocksPath, 'utf8'))
72
- return data.rules || []
96
+ function findJsonErrorOffset(t) {
97
+ let i = 0
98
+ const ws = () => { while (i < t.length && ' \t\n\r'.includes(t[i])) i++ }
99
+ const fail = () => { throw i }
100
+ const str = () => {
101
+ i++
102
+ while (i < t.length && t[i] !== '"') {
103
+ if (t[i] === '\\') i++
104
+ else if (t.charCodeAt(i) < 0x20) fail()
105
+ i++
73
106
  }
74
- } catch (_) {}
75
- return []
107
+ if (i >= t.length) fail()
108
+ i++
109
+ }
110
+ const value = () => {
111
+ ws()
112
+ const c = t[i]
113
+ if (c === '{') {
114
+ i++; ws()
115
+ if (t[i] === '}') { i++; return }
116
+ for (;;) {
117
+ ws(); if (t[i] !== '"') fail(); str(); ws()
118
+ if (t[i] !== ':') fail(); i++; value(); ws()
119
+ if (t[i] === ',') { i++; continue }
120
+ if (t[i] === '}') { i++; return }
121
+ fail()
122
+ }
123
+ } else if (c === '[') {
124
+ i++; ws()
125
+ if (t[i] === ']') { i++; return }
126
+ for (;;) {
127
+ value(); ws()
128
+ if (t[i] === ',') { i++; continue }
129
+ if (t[i] === ']') { i++; return }
130
+ fail()
131
+ }
132
+ } else if (c === '"') str()
133
+ else if (/[-0-9]/.test(c || '')) {
134
+ const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(t.slice(i))
135
+ if (!m) fail(); i += m[0].length
136
+ } else {
137
+ for (const w of ['true', 'false', 'null']) if (t.startsWith(w, i)) { i += w.length; return }
138
+ fail()
139
+ }
140
+ }
141
+ try {
142
+ value(); ws()
143
+ return i < t.length ? i : t.length
144
+ } catch (at) {
145
+ return typeof at === 'number' ? Math.min(at, t.length) : t.length
146
+ }
147
+ }
148
+
149
+ /** `第 <line> 行第 <column> 列:<short message>`, same rules as core/mock-handler.ts. */
150
+ function describeMocksParseFailure(text, err) {
151
+ const message = String((err && err.message) || err)
152
+ const body = text.replace(/^\uFEFF/, '')
153
+ let line = null
154
+ let column = null
155
+ const lc = /\(line (\d+) column (\d+)\)/.exec(message)
156
+ const pos = /at position (\d+)/.exec(message)
157
+ const offset = lc ? null : pos ? Number(pos[1]) : findJsonErrorOffset(body)
158
+ if (lc) {
159
+ line = Number(lc[1]); column = Number(lc[2])
160
+ } else {
161
+ const before = body.slice(0, offset == null ? body.length : offset).split('\n')
162
+ line = before.length
163
+ column = before[before.length - 1].length + 1
164
+ }
165
+ const short = message.replace(/\s*\(line \d+ column \d+\)/, '').split(/,\s*(?:\.\.\.)?"/s)[0].replace(/\s+in JSON at position \d+/, '').trim()
166
+ const loc = line != null ? `第 ${line} 行第 ${column} 列:` : ''
167
+ return `${loc}${short}`
76
168
  }
77
169
 
78
170
  /**
79
- * Save mock rules to file
171
+ * Read mocks.json. Missing file → null. Empty / whitespace → {}.
172
+ * Invalid JSON throws MOCKS_FILE_INVALID and must not be treated as [].
173
+ * `message` refuses a write; `listMessage` is what `mock list` prints.
80
174
  */
81
- function saveMockRules(rules) {
82
- const mocksPath = getMocksPath()
83
- const dir = path.dirname(mocksPath)
84
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
175
+ function readMocksDocument(mocksPath) {
176
+ if (!fs.existsSync(mocksPath)) return null
177
+ const text = fs.readFileSync(mocksPath, 'utf8').replace(/^\uFEFF/, '')
178
+ if (text.trim() === '') return {}
179
+ try {
180
+ return JSON.parse(text)
181
+ } catch (err) {
182
+ const detail = describeMocksParseFailure(text, err)
183
+ const error = new Error(`Mock 文件格式错误,已禁止写入以免覆盖: ${mocksPath} ${detail}`)
184
+ error.code = 'MOCKS_FILE_INVALID'
185
+ error.filePath = mocksPath
186
+ error.detail = detail
187
+ error.listMessage = `Mock 文件格式错误: ${mocksPath} ${detail}`
188
+ throw error
189
+ }
190
+ }
191
+
192
+ function rulesFromDocument(data) {
193
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return []
194
+ return Array.isArray(data.rules) ? data.rules : []
195
+ }
85
196
 
86
- // Find max id for sequence
197
+ function metaFromDocument(data) {
198
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return { nextId: 0, lastId: 0 }
199
+ return { nextId: Number(data.nextId) || 0, lastId: Number(data.lastId) || 0 }
200
+ }
201
+
202
+ /**
203
+ * Load mock rules from file. A corrupt file throws; it is not an empty list.
204
+ */
205
+ function loadMockRules() {
206
+ const data = readMocksDocument(getMocksPath())
207
+ if (data == null) return []
208
+ return rulesFromDocument(data)
209
+ }
210
+
211
+ function readMocksFileMeta(mocksPath) {
212
+ const data = readMocksDocument(mocksPath)
213
+ if (data == null) return { nextId: 0, lastId: 0 }
214
+ return metaFromDocument(data)
215
+ }
216
+
217
+ function maxRuleId(rules) {
87
218
  let maxId = 0
88
219
  rules.forEach(r => {
89
- if (r.id > maxId) maxId = r.id
220
+ if (r && Number.isInteger(r.id) && r.id > maxId) maxId = r.id
90
221
  })
222
+ return maxId
223
+ }
91
224
 
225
+ /**
226
+ * Next id to hand out. Shares the server's `nextId` field so ids stay
227
+ * monotonic across CLI file-mode and the running proxy (issue #115); also
228
+ * honours the legacy CLI `lastId` field.
229
+ */
230
+ function nextMockId(rules, meta) {
231
+ return Math.max(1, meta.nextId || 0, (meta.lastId || 0) + 1, maxRuleId(rules) + 1)
232
+ }
233
+
234
+ /**
235
+ * Save mock rules to file
236
+ */
237
+ function saveMockRules(rules, nextId) {
238
+ const mocksPath = getMocksPath()
239
+ const dir = path.dirname(mocksPath)
240
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
241
+
242
+ const meta = readMocksFileMeta(mocksPath)
243
+ const seq = Math.max(nextId || 0, nextMockId(rules, meta))
92
244
  const data = {
245
+ // `nextId` is what the proxy reads; keep it so deleting the newest rule
246
+ // does not make its id reusable (issue #115). `lastId` kept for older CLIs.
247
+ nextId: seq,
93
248
  rules,
94
- lastId: maxId
249
+ lastId: seq - 1
95
250
  }
96
251
  fs.writeFileSync(mocksPath, JSON.stringify(data, null, 2), 'utf8')
97
252
  }
@@ -103,23 +258,14 @@ function addMockRule(rule) {
103
258
  const rules = loadMockRules()
104
259
  const mocksPath = getMocksPath()
105
260
 
106
- // Get next id
107
- let nextId = 1
108
- try {
109
- if (fs.existsSync(mocksPath)) {
110
- const data = JSON.parse(fs.readFileSync(mocksPath, 'utf8'))
111
- nextId = (data.lastId || 0) + 1
112
- }
113
- } catch (_) {}
114
- if (rules.length > 0) {
115
- const maxId = Math.max(...rules.map(r => r.id))
116
- if (maxId >= nextId) nextId = maxId + 1
117
- }
261
+ // Issue #115: never reuse an existing (or previously handed-out) id, and
262
+ // ignore any id the caller put on the rule — add never overwrites.
263
+ const nextId = nextMockId(rules, readMocksFileMeta(mocksPath))
118
264
 
119
- rule.id = nextId
120
- rules.push(rule)
121
- saveMockRules(rules)
122
- return rule
265
+ const created = { ...rule, id: nextId }
266
+ rules.push(created)
267
+ saveMockRules(rules, nextId + 1)
268
+ return created
123
269
  }
124
270
 
125
271
  /**
@@ -167,6 +313,30 @@ function getRuleFilePath(name) {
167
313
  /**
168
314
  * List all rule files
169
315
  */
316
+ function getRuleFileOrderNames() {
317
+ const settings = loadSettings()
318
+ const arr = settings.ruleFileOrder
319
+ return Array.isArray(arr) ? arr.filter((name) => typeof name === 'string') : []
320
+ }
321
+
322
+ function resolveRuleFileOrder(diskNames, storedOrder, activeNames) {
323
+ const diskSet = new Set(diskNames)
324
+ const ordered = []
325
+ const seen = new Set()
326
+ const pushUnique = (name) => {
327
+ if (!diskSet.has(name) || seen.has(name)) return
328
+ ordered.push(name)
329
+ seen.add(name)
330
+ }
331
+ if (storedOrder.length > 0) {
332
+ for (const name of storedOrder) pushUnique(name)
333
+ } else {
334
+ for (const name of activeNames) pushUnique(name)
335
+ }
336
+ for (const name of diskNames) pushUnique(name)
337
+ return ordered
338
+ }
339
+
170
340
  function listRuleFiles() {
171
341
  ensureRouteRulesDir()
172
342
  const activeNames = getActiveRuleFileNames()
@@ -175,7 +345,10 @@ function listRuleFiles() {
175
345
  .filter(f => f.endsWith('.txt'))
176
346
  .map(f => f.replace(/\.txt$/, ''))
177
347
 
178
- return files.map(name => {
348
+ const ordered = resolveRuleFileOrder(files, getRuleFileOrderNames(), activeNames)
349
+ const readonlyNames = new Set(getReadonlyRuleFileNames())
350
+
351
+ return ordered.map(name => {
179
352
  const filePath = getRuleFilePath(name)
180
353
  let ruleCount = 0
181
354
  let excludeCount = 0
@@ -191,6 +364,7 @@ function listRuleFiles() {
191
364
  return {
192
365
  name,
193
366
  enabled: activeNames.includes(name),
367
+ readonly: readonlyNames.has(name),
194
368
  ruleCount,
195
369
  excludeCount
196
370
  }
@@ -215,6 +389,7 @@ function getRuleFileContent(name) {
215
389
  */
216
390
  function saveRuleFileContent(name, content) {
217
391
  ensureRouteRulesDir()
392
+ assertRuleFileWritable(name)
218
393
  const filePath = getRuleFilePath(name)
219
394
  fs.writeFileSync(filePath, content, 'utf8')
220
395
  }
@@ -231,6 +406,8 @@ function createRuleFile(name, content = '', enabled = true) {
231
406
  throw new Error(`规则文件 "${safeName}" 已存在`)
232
407
  }
233
408
 
409
+ // Drop stale lock entries so a new file never inherits an old lock.
410
+ getReadonlyRuleFileNames()
234
411
  fs.writeFileSync(filePath, content, 'utf8')
235
412
 
236
413
  if (enabled) {
@@ -242,7 +419,7 @@ function createRuleFile(name, content = '', enabled = true) {
242
419
  }
243
420
 
244
421
  const ruleCount = content.trim() ? Object.keys(parseEprc(content)).length : 0
245
- return { name: safeName, enabled, ruleCount }
422
+ return { name: safeName, enabled, readonly: false, ruleCount }
246
423
  }
247
424
 
248
425
  /**
@@ -253,6 +430,7 @@ function deleteRuleFile(name) {
253
430
  if (!fs.existsSync(filePath)) {
254
431
  throw new Error('规则文件不存在')
255
432
  }
433
+ assertRuleFileWritable(name)
256
434
  fs.unlinkSync(filePath)
257
435
 
258
436
  const activeNames = getActiveRuleFileNames()
@@ -274,6 +452,7 @@ function setRuleFileEnabled(name, enabled) {
274
452
 
275
453
  const activeNames = getActiveRuleFileNames()
276
454
  const idx = activeNames.indexOf(name)
455
+ if ((enabled && idx === -1) || (!enabled && idx !== -1)) assertRuleFileWritable(name)
277
456
 
278
457
  if (enabled && idx === -1) {
279
458
  activeNames.push(name)
@@ -309,6 +488,9 @@ module.exports = {
309
488
  saveSettings,
310
489
  getActiveRuleFileNames,
311
490
  setActiveRuleFileNames,
491
+ getReadonlyRuleFileNames,
492
+ isRuleFileReadonly,
493
+ assertRuleFileWritable,
312
494
 
313
495
  // Mocks
314
496
  getMocksPath,