@jonathanleelx/meddle 0.5.0-beta.9 → 0.5.1

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 (217) hide show
  1. package/CONFIG_STRUCTURE.md +9 -2
  2. package/README.md +18 -0
  3. package/bin/commands/mock/add.js +59 -21
  4. package/bin/commands/mock/delete.js +16 -24
  5. package/bin/commands/mock/index.js +59 -25
  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 +87 -53
  13. package/bin/commands/route/index.js +74 -26
  14. package/bin/commands/route/list.js +2 -2
  15. package/bin/commands/route/preview.js +2 -2
  16. package/bin/commands/route/show.js +12 -3
  17. package/bin/commands/route/update.js +135 -92
  18. package/bin/commands/status.js +27 -11
  19. package/bin/commands/update.js +4 -0
  20. package/bin/doctor.js +8 -4
  21. package/bin/index.js +11 -7
  22. package/bin/lib/api-client.js +14 -2
  23. package/bin/lib/file-access.js +234 -35
  24. package/bin/lib/meddle-home.js +14 -1
  25. package/bin/lib/output.js +30 -4
  26. package/bin/lib/proxy-detect.js +99 -20
  27. package/bin/lib/proxy-or-file.js +93 -0
  28. package/bin/lib/route-write-args.js +150 -0
  29. package/bin/lib/rule-file-edit.js +263 -0
  30. package/bin/lib/ruleset-readonly.js +61 -0
  31. package/bin/lib/update-check.js +67 -6
  32. package/dist/cert.d.ts +2 -0
  33. package/dist/cert.d.ts.map +1 -1
  34. package/dist/cert.js +53 -1
  35. package/dist/cert.js.map +1 -1
  36. package/dist/core/browser.d.ts +10 -1
  37. package/dist/core/browser.d.ts.map +1 -1
  38. package/dist/core/browser.js +252 -36
  39. package/dist/core/browser.js.map +1 -1
  40. package/dist/core/config-diagnostics.d.ts.map +1 -1
  41. package/dist/core/config-diagnostics.js +6 -1
  42. package/dist/core/config-diagnostics.js.map +1 -1
  43. package/dist/core/custom-plugin-loader.d.ts.map +1 -1
  44. package/dist/core/custom-plugin-loader.js +2 -0
  45. package/dist/core/custom-plugin-loader.js.map +1 -1
  46. package/dist/core/mock-handler.d.ts +63 -0
  47. package/dist/core/mock-handler.d.ts.map +1 -1
  48. package/dist/core/mock-handler.js +641 -29
  49. package/dist/core/mock-handler.js.map +1 -1
  50. package/dist/core/mock-utils.d.ts +58 -0
  51. package/dist/core/mock-utils.d.ts.map +1 -0
  52. package/dist/core/mock-utils.js +167 -0
  53. package/dist/core/mock-utils.js.map +1 -0
  54. package/dist/core/plugin-bootstrap-runner.d.ts +1 -0
  55. package/dist/core/plugin-bootstrap-runner.d.ts.map +1 -1
  56. package/dist/core/plugin-bootstrap-runner.js +37 -1
  57. package/dist/core/plugin-bootstrap-runner.js.map +1 -1
  58. package/dist/core/plugin-source.d.ts +24 -0
  59. package/dist/core/plugin-source.d.ts.map +1 -0
  60. package/dist/core/plugin-source.js +47 -0
  61. package/dist/core/plugin-source.js.map +1 -0
  62. package/dist/core/proxy-record.d.ts +14 -0
  63. package/dist/core/proxy-record.d.ts.map +1 -1
  64. package/dist/core/proxy-record.js +39 -0
  65. package/dist/core/proxy-record.js.map +1 -1
  66. package/dist/core/rule-file-edit.d.ts +81 -0
  67. package/dist/core/rule-file-edit.d.ts.map +1 -0
  68. package/dist/core/rule-file-edit.js +243 -0
  69. package/dist/core/rule-file-edit.js.map +1 -0
  70. package/dist/core/static-server.d.ts.map +1 -1
  71. package/dist/core/static-server.js +15 -13
  72. package/dist/core/static-server.js.map +1 -1
  73. package/dist/core/types.d.ts +15 -1
  74. package/dist/core/types.d.ts.map +1 -1
  75. package/dist/plugins/builtin/mock-plugin.d.ts.map +1 -1
  76. package/dist/plugins/builtin/mock-plugin.js +6 -5
  77. package/dist/plugins/builtin/mock-plugin.js.map +1 -1
  78. package/dist/server/agent/tools.d.ts.map +1 -1
  79. package/dist/server/agent/tools.js +24 -18
  80. package/dist/server/agent/tools.js.map +1 -1
  81. package/dist/server/app-info.d.ts +33 -0
  82. package/dist/server/app-info.d.ts.map +1 -0
  83. package/dist/server/app-info.js +116 -0
  84. package/dist/server/app-info.js.map +1 -0
  85. package/dist/server/config.d.ts +1 -0
  86. package/dist/server/config.d.ts.map +1 -1
  87. package/dist/server/config.js +114 -16
  88. package/dist/server/config.js.map +1 -1
  89. package/dist/server/health.d.ts.map +1 -1
  90. package/dist/server/health.js +8 -0
  91. package/dist/server/health.js.map +1 -1
  92. package/dist/server/index.d.ts +16 -0
  93. package/dist/server/index.d.ts.map +1 -1
  94. package/dist/server/index.js +10 -0
  95. package/dist/server/index.js.map +1 -1
  96. package/dist/server/json-parse-error.d.ts +7 -0
  97. package/dist/server/json-parse-error.d.ts.map +1 -0
  98. package/dist/server/json-parse-error.js +28 -0
  99. package/dist/server/json-parse-error.js.map +1 -0
  100. package/dist/server/mocks.d.ts +26 -0
  101. package/dist/server/mocks.d.ts.map +1 -1
  102. package/dist/server/mocks.js +110 -13
  103. package/dist/server/mocks.js.map +1 -1
  104. package/dist/server/plugins.d.ts.map +1 -1
  105. package/dist/server/plugins.js +56 -11
  106. package/dist/server/plugins.js.map +1 -1
  107. package/dist/server/rule-files.d.ts +36 -0
  108. package/dist/server/rule-files.d.ts.map +1 -1
  109. package/dist/server/rule-files.js +388 -107
  110. package/dist/server/rule-files.js.map +1 -1
  111. package/dist/server/ruleset-lock.d.ts +62 -0
  112. package/dist/server/ruleset-lock.d.ts.map +1 -0
  113. package/dist/server/ruleset-lock.js +234 -0
  114. package/dist/server/ruleset-lock.js.map +1 -0
  115. package/index.js +128 -16
  116. package/mcp-server.js +108 -93
  117. package/package.json +1 -1
  118. package/plugins/builtin/mock-plugin.ts +9 -5
  119. package/web/dist/assets/alert-B514Frte.js +1 -0
  120. package/web/dist/assets/arrow-right-BNum_060.js +1 -0
  121. package/web/dist/assets/{body-diff-view-B-4XWmL6.js → body-diff-view-SfLCLzs9.js} +2 -2
  122. package/web/dist/assets/button-BFNlGZ5p.js +1 -0
  123. package/web/dist/assets/check-BkfHQ6re.js +1 -0
  124. package/web/dist/assets/checkbox-CQyFsZzS.js +1 -0
  125. package/web/dist/assets/chevron-down-DHwUtL4O.js +1 -0
  126. package/web/dist/assets/{circle-alert-Q8cZk9Rv.js → circle-alert-zMagrFuI.js} +1 -1
  127. package/web/dist/assets/{circle-x-C9USji_R.js → circle-x-BEkinhez.js} +1 -1
  128. package/web/dist/assets/code-DD-AYxxu.js +1 -0
  129. package/web/dist/assets/{copy-BYjq1hdN.js → copy-BRnscYy1.js} +1 -1
  130. package/web/dist/assets/detail-panel-DZRKJG2W.js +4 -0
  131. package/web/dist/assets/dist-BQN0rzUS.js +1 -0
  132. package/web/dist/assets/{dist-CEd_wnRC.js → dist-C-E2R2jk.js} +1 -1
  133. package/web/dist/assets/{dist-C6eaYCUo.js → dist-CFZ4xMSF.js} +1 -1
  134. package/web/dist/assets/{dist-DWmDaXVN.js → dist-CRmX_NvH.js} +1 -1
  135. package/web/dist/assets/{dist-C8RHq5At.js → dist-DLQKRjuW.js} +1 -1
  136. package/web/dist/assets/{dist-Blyulj4_.js → dist-LiAcI8CK.js} +1 -1
  137. package/web/dist/assets/{dist-Ce4ujolc.js → dist-fzPL-8F9.js} +1 -1
  138. package/web/dist/assets/{dist-DoE7uybN.js → dist-wv9ebJK_.js} +1 -1
  139. package/web/dist/assets/editor-pane-actions-_ckwcCII.js +1 -0
  140. package/web/dist/assets/{es2015-DG46NGN7.js → es2015-DPJTBYa9.js} +1 -1
  141. package/web/dist/assets/global-panel-shell-Iir-c_pQ.js +6 -0
  142. package/web/dist/assets/health-panel-Dmf-EhJz.js +1 -0
  143. package/web/dist/assets/index-C4dIj1nT.js +21 -0
  144. package/web/dist/assets/index-CdYMl1M7.css +2 -0
  145. package/web/dist/assets/{mobile-proxy-panel-tU0NyJ3P.js → mobile-proxy-panel-CFcwa11A.js} +6 -6
  146. package/web/dist/assets/mock-config-CGl50MIr.js +1 -0
  147. package/web/dist/assets/mock-editor-panel-xcCk23oH.js +20 -0
  148. package/web/dist/assets/mocks-file-missing-note-CW_ONVLA.js +1 -0
  149. package/web/dist/assets/monaco-editor-BkP7envQ.js +11 -0
  150. package/web/dist/assets/pane-bar-5j9Nf7W_.js +1 -0
  151. package/web/dist/assets/pane-split-C0OSe365.js +1 -0
  152. package/web/dist/assets/panel-heading-izT5q0ZN.js +1 -0
  153. package/web/dist/assets/{pencil-line-Cqe41E4h.js → pencil-line-CEZ7D-de.js} +1 -1
  154. package/web/dist/assets/plugin-code-editor-DKE9dFEE.js +2 -0
  155. package/web/dist/assets/plugin-config-Cg09TsuV.js +2 -0
  156. package/web/dist/assets/plugin-generator-CT1f1tu0.js +2 -0
  157. package/web/dist/assets/plugin-test-dialog-BbLIMnkJ.js +7 -0
  158. package/web/dist/assets/plus-ixZQRprm.js +1 -0
  159. package/web/dist/assets/{refresh-cw-CG3HaiQT.js → refresh-cw-BfLoHqg5.js} +1 -1
  160. package/web/dist/assets/rotate-ccw-CRguJya4.js +1 -0
  161. package/web/dist/assets/route-preview-CKO7nF4W.js +1 -0
  162. package/web/dist/assets/{rule-ai-assistant-panel-DMh0F9dr.js → rule-ai-assistant-panel-CtfUqdYG.js} +2 -2
  163. package/web/dist/assets/rule-config-Cb4ejIXI.js +15 -0
  164. package/web/dist/assets/rule-overview-panel-xtHTS27O.js +1 -0
  165. package/web/dist/assets/{server-Cpj4KNwN.js → server-eYXq3PbE.js} +1 -1
  166. package/web/dist/assets/settings-panel-CpQuE6nr.js +1 -0
  167. package/web/dist/assets/{shield-check-OqUmVLQt.js → shield-check-hQXjdfZ7.js} +1 -1
  168. package/web/dist/assets/smartphone-D4Q7cQfm.js +1 -0
  169. package/web/dist/assets/switch-94vgsMHm.js +1 -0
  170. package/web/dist/assets/{syntax-highlight-Dk0-ewLI.js → syntax-highlight-BFgN_C0Y.js} +1 -1
  171. package/web/dist/assets/{table-2-BR5e3z8m.js → table-2-DvGHhMm4.js} +1 -1
  172. package/web/dist/assets/table-DaXD6jJv.js +1 -0
  173. package/web/dist/assets/textarea-9wB8Hsz-.js +1 -0
  174. package/web/dist/assets/toast-9yeTf7d9.js +1 -0
  175. package/web/dist/assets/use-mocks-file-status-BUIUJ4oQ.js +1 -0
  176. package/web/dist/assets/{wand-sparkles-CJ7fSnom.js → wand-sparkles-Bmt4dDOf.js} +1 -1
  177. package/web/dist/assets/{wifi-CCLKyySQ.js → wifi-Cjj_Br1u.js} +1 -1
  178. package/web/dist/assets/{zap-DR-xiUMe.js → zap-BuOtogAv.js} +1 -1
  179. package/web/dist/index.html +16 -10
  180. package/web/dist/assets/alert-DCvlZAiv.js +0 -1
  181. package/web/dist/assets/arrow-right-B4Mrlj2-.js +0 -1
  182. package/web/dist/assets/button-BTToMaBW.js +0 -1
  183. package/web/dist/assets/checkbox-D1Z9DtjF.js +0 -1
  184. package/web/dist/assets/chevron-down-CPngAx4M.js +0 -1
  185. package/web/dist/assets/chevron-right-V1DGIDbL.js +0 -1
  186. package/web/dist/assets/code-5_eJ_XpJ.js +0 -1
  187. package/web/dist/assets/code-fixer-D48sCDtp.js +0 -20
  188. package/web/dist/assets/detail-panel-BSFz9CUw.js +0 -4
  189. package/web/dist/assets/file-system-access-_og-Tg9o.js +0 -1
  190. package/web/dist/assets/global-panel-shell-5cJneeis.js +0 -6
  191. package/web/dist/assets/health-panel-BS3L2e5d.js +0 -1
  192. package/web/dist/assets/index-CvTR1IVi.js +0 -18
  193. package/web/dist/assets/index-bfV7sxx5.css +0 -2
  194. package/web/dist/assets/mock-config-CULJP6Vt.js +0 -1
  195. package/web/dist/assets/mock-editor-panel-9wlDaySA.js +0 -1
  196. package/web/dist/assets/monaco-editor-BttaUoOz.js +0 -11
  197. package/web/dist/assets/plugin-code-editor-BAIBp4lg.js +0 -2
  198. package/web/dist/assets/plugin-config-ButUV2SZ.js +0 -1
  199. package/web/dist/assets/plugin-generator-uceUPSCv.js +0 -2
  200. package/web/dist/assets/plugin-test-dialog-BrJ4HfRX.js +0 -7
  201. package/web/dist/assets/plus-tVxSydJ8.js +0 -1
  202. package/web/dist/assets/rotate-ccw-CAYM6Vv0.js +0 -1
  203. package/web/dist/assets/route-preview-C4cGrS1-.js +0 -1
  204. package/web/dist/assets/rule-config-BTTGNwMs.js +0 -15
  205. package/web/dist/assets/rule-overview-panel-SFR2OloL.js +0 -1
  206. package/web/dist/assets/select-aBXQ0zNa.js +0 -1
  207. package/web/dist/assets/settings-panel-BMReDG8b.js +0 -1
  208. package/web/dist/assets/smartphone-DKeDuJTU.js +0 -1
  209. package/web/dist/assets/switch-Bes_YiJu.js +0 -1
  210. package/web/dist/assets/table-nW7uQVcl.js +0 -1
  211. package/web/dist/assets/textarea-BSI0rnW6.js +0 -1
  212. /package/web/dist/assets/{babel-DCr4VA1c.js → babel-bwZm0qi3.js} +0 -0
  213. /package/web/dist/assets/{clipboard-VhPXNN5m.js → clipboard-Bt1qY01V.js} +0 -0
  214. /package/web/dist/assets/{estree-qO_m2Qaw.js → estree-MatbZDwq.js} +0 -0
  215. /package/web/dist/assets/{html-VDGahtyk.js → html-CCrsbFYX.js} +0 -0
  216. /package/web/dist/assets/{postcss-BUm8EKMw.js → postcss-BxN7Gr8n.js} +0 -0
  217. /package/web/dist/assets/{standalone-Bj8SSd8Q.js → standalone-DsnFnsqM.js} +0 -0
@@ -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,11 +71,14 @@ 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
 
78
+ output.warnFileOnlyMode()
79
+
67
80
  if (jsonFlag) {
68
- output.jsonRaw({ success: true, activeFile: targetFile })
81
+ output.jsonRaw({ success: true, activeFile: targetFile, fileOnly: true, warning: output.FILE_ONLY_WARNING })
69
82
  return
70
83
  }
71
84
 
@@ -81,10 +94,13 @@ async function run() {
81
94
  process.exit(1)
82
95
  }
83
96
 
97
+ assertListedFilesWritable(files, pendingToggles(files, targetFile))
84
98
  files.forEach(f => setRuleFileEnabled(f.name, f.name === targetFile))
85
99
 
100
+ output.warnFileOnlyMode()
101
+
86
102
  if (jsonFlag) {
87
- output.jsonRaw({ success: true, activeFile: targetFile })
103
+ output.jsonRaw({ success: true, activeFile: targetFile, fileOnly: true, warning: output.FILE_ONLY_WARNING })
88
104
  return
89
105
  }
90
106
 
@@ -136,6 +152,7 @@ async function run() {
136
152
  }
137
153
 
138
154
  run().catch(e => {
155
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: targetFile })
139
156
  output.error(e.message)
140
157
  process.exit(1)
141
158
  })
@@ -5,123 +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
29
86
  }
30
- if (!target) {
31
- output.error('Missing required argument: <target>')
32
- process.exit(1)
87
+ if (jsonFlag) {
88
+ output.jsonRaw(payload)
89
+ return
33
90
  }
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')
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 == null) fail(`Route file not found: ${fileName}`, jsonFlag)
127
+ const newContent = applyAdd(content, plan)
83
128
  saveRuleFileContent(fileName, newContent)
84
-
85
- if (jsonFlag) {
86
- output.jsonRaw({ success: true, file: fileName, pattern: pat, target: tgt })
87
- return
88
- }
89
-
90
- output.header('Route Rule Added')
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
- const bm = pat.match(/\[([^\]]+)\]/)
104
- if (bm) {
105
- ruleMap[pat.replace(bm[0], bm[1])] = tgt + bm[0]
106
- } else {
107
- ruleMap[pat] = tgt
108
- }
109
-
110
- const newContent = ruleMapToEprcText(ruleMap)
111
- saveRuleFileContent(fileName, newContent)
112
-
113
- if (jsonFlag) {
114
- output.jsonRaw({ success: true, file: fileName, pattern: pat, target: tgt })
129
+ output.warnFileOnlyMode()
130
+ emitAdded({ fileOnly: true })
115
131
  return
116
132
  }
117
-
118
- output.header('Route Rule Added')
119
- output.success(`${pat} -> ${tgt}`)
120
- output.kv('File', fileName)
121
133
  }
134
+
135
+ const content = getRuleFileContent(fileName)
136
+ if (content == null) fail(`Route file not found: ${fileName}`, jsonFlag)
137
+ const newContent = applyAdd(content, plan)
138
+ saveRuleFileContent(fileName, newContent)
139
+ output.warnFileOnlyMode()
140
+ emitAdded({ fileOnly: true })
122
141
  }
123
142
 
124
143
  run().catch(e => {
125
- output.error(e.message)
126
- process.exit(1)
144
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
145
+ fail(e.message, jsonFlag)
127
146
  })
@@ -34,6 +34,7 @@ async function run() {
34
34
  const running = await isProxyRunning()
35
35
 
36
36
  let result
37
+ let fileOnly = false
37
38
  if (running) {
38
39
  try {
39
40
  result = await apiPost('/api/rule-files', {
@@ -46,6 +47,7 @@ async function run() {
46
47
  // Fallback to file mode
47
48
  try {
48
49
  result = createRuleFile(fileName, content, true)
50
+ fileOnly = true
49
51
  } catch (err) {
50
52
  output.error(err.message)
51
53
  process.exit(1)
@@ -54,14 +56,19 @@ async function run() {
54
56
  } else {
55
57
  try {
56
58
  result = createRuleFile(fileName, content, true)
59
+ fileOnly = true
57
60
  } catch (err) {
58
61
  output.error(err.message)
59
62
  process.exit(1)
60
63
  }
61
64
  }
62
65
 
66
+ if (fileOnly) output.warnFileOnlyMode()
67
+
63
68
  if (jsonFlag) {
64
- output.jsonRaw(result)
69
+ output.jsonRaw(fileOnly
70
+ ? { ...result, fileOnly: true, warning: output.FILE_ONLY_WARNING }
71
+ : result)
65
72
  return
66
73
  }
67
74
 
@@ -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)
58
+ }
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
24
66
  }
25
- if (!pattern) {
26
- output.error('Missing required argument: <pattern>')
27
- process.exit(1)
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,28 +97,27 @@ 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
- if (!content) {
65
- output.error(`Route file not found: ${fileName}`)
66
- process.exit(1)
104
+ if (content == null) {
105
+ fail(`Route file not found: ${fileName}`, jsonFlag)
67
106
  }
68
107
 
69
- const ruleMap = parseEprc(content)
70
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
71
- output.error(`Pattern not found: ${pat}`)
72
- process.exit(1)
73
- }
74
-
75
- delete ruleMap[internalKey]
76
- const newContent = ruleMapToEprcText(ruleMap)
108
+ const newContent = applyDelete(content)
77
109
  saveRuleFileContent(fileName, newContent)
110
+ output.warnFileOnlyMode()
78
111
 
79
112
  if (jsonFlag) {
80
- output.jsonRaw({ success: true, file: fileName, pattern: pat })
113
+ output.jsonRaw({ success: true, file: fileName, pattern: pat,
114
+ fileOnly: true,
115
+ warning: output.FILE_ONLY_WARNING
116
+ })
81
117
  return
82
118
  }
83
119
 
120
+
84
121
  output.header('Route Rule Deleted')
85
122
  output.success(pat)
86
123
  output.kv('File', fileName)
@@ -88,26 +125,23 @@ async function run() {
88
125
  } else {
89
126
  // File mode
90
127
  const content = getRuleFileContent(fileName)
91
- if (!content) {
92
- output.error(`Route file not found: ${fileName}`)
93
- process.exit(1)
128
+ if (content == null) {
129
+ fail(`Route file not found: ${fileName}`, jsonFlag)
94
130
  }
95
131
 
96
- const ruleMap = parseEprc(content)
97
- if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
98
- output.error(`Pattern not found: ${pat}`)
99
- process.exit(1)
100
- }
101
-
102
- delete ruleMap[internalKey]
103
- const newContent = ruleMapToEprcText(ruleMap)
132
+ const newContent = applyDelete(content)
104
133
  saveRuleFileContent(fileName, newContent)
134
+ output.warnFileOnlyMode()
105
135
 
106
136
  if (jsonFlag) {
107
- output.jsonRaw({ success: true, file: fileName, pattern: pat })
137
+ output.jsonRaw({ success: true, file: fileName, pattern: pat,
138
+ fileOnly: true,
139
+ warning: output.FILE_ONLY_WARNING
140
+ })
108
141
  return
109
142
  }
110
143
 
144
+
111
145
  output.header('Route Rule Deleted')
112
146
  output.success(pat)
113
147
  output.kv('File', fileName)
@@ -115,6 +149,6 @@ async function run() {
115
149
  }
116
150
 
117
151
  run().catch(e => {
118
- output.error(e.message)
119
- process.exit(1)
152
+ if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
153
+ fail(e.message, jsonFlag)
120
154
  })
@@ -2,8 +2,70 @@
2
2
  * meddle route - Route rule subcommand router
3
3
  */
4
4
 
5
+ const output = require('../../lib/output')
6
+
5
7
  const args = process.argv.slice(3)
6
8
  const subcommand = args[0]
9
+ const jsonFlag = args.includes('--json')
10
+
11
+ const ROUTE_HELP = `
12
+ Route Commands:
13
+ meddle route list [--json] List all route files
14
+ meddle route show <file> [--json] Show rules in a file
15
+ meddle route preview <url> [--file <name>] [--json] Preview route target for a URL
16
+ meddle route active Show active route files
17
+ meddle route active set <file> Set active route file
18
+ meddle route create <name> [--content <text>] Create a route file
19
+ meddle route add <file> <pattern> <target> [--exclusions=<list>]
20
+ Add a rule to file
21
+ meddle route update <file> <pattern> [<target>] [--exclusions=<list>]
22
+ Update a rule in file
23
+ meddle route delete <file> <pattern> Delete a rule from file
24
+
25
+ Read-only route files:
26
+ Files locked in the Meddle web UI reject add/update/delete and active set
27
+ (error RULESET_READONLY, exit code 3). Lock/unlock is only available in the web UI.
28
+
29
+ Examples:
30
+ meddle route list
31
+ meddle route show dev-rules
32
+ meddle route preview "https://api.example.com/v1/users"
33
+ meddle route preview "https://cdn.com/assets/js/app.js" --file dev-rules
34
+ meddle route preview "https://api.test.com/data" --json
35
+ meddle route active set beta-rules
36
+ meddle route create staging --content "example.com localhost:3000"
37
+ meddle route add dev-rules "api.test.com" "localhost:8080"
38
+ meddle route update dev-rules "api.test.com" "localhost:9000"
39
+ meddle route update dev-rules "api.test.com" --exclusions=
40
+ meddle route delete dev-rules "api.test.com"
41
+
42
+ Exclusions:
43
+ Omit --exclusions and any !path in the pattern to keep the rule's exclusions.
44
+ --exclusions= Clear exclusions (that line loses every !path).
45
+ --exclusions=/a,/b Replace exclusions. A leading ! is optional.
46
+ "pattern !/a" Replace exclusions. A bare "!" is an error.
47
+ A bare --exclusions, an unknown flag, or both forms at once exits 1
48
+ and does not change the file. meddle route update --help has the details.
49
+
50
+ A "#" comments out a whole line only when it is the first non-space
51
+ character. A "#" later on a rule line is a pattern, not a trailing comment.
52
+ `
53
+
54
+ function printHelp(stream = process.stdout) {
55
+ stream.write(ROUTE_HELP.trimStart())
56
+ }
57
+
58
+ function failUnknown(name) {
59
+ const message = `Unknown command: route ${name}`
60
+ if (jsonFlag) {
61
+ output.setJsonMode(true)
62
+ output.error(message)
63
+ } else {
64
+ console.error(message)
65
+ printHelp(process.stderr)
66
+ }
67
+ process.exit(1)
68
+ }
7
69
 
8
70
  switch (subcommand) {
9
71
  case 'list':
@@ -30,30 +92,16 @@ switch (subcommand) {
30
92
  case 'delete':
31
93
  require('./delete.js')
32
94
  break
95
+ case '--help':
96
+ case '-h':
97
+ case 'help':
98
+ printHelp()
99
+ process.exit(0)
100
+ break
33
101
  default:
34
- console.log(`
35
- Route Commands:
36
- meddle route list [--json] List all route files
37
- meddle route show <file> [--json] Show rules in a file
38
- meddle route preview <url> [--file <name>] [--json] Preview route target for a URL
39
- meddle route active Show active route files
40
- meddle route active set <file> Set active route file
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
44
- meddle route delete <file> <pattern> Delete a rule from file
45
-
46
- Examples:
47
- meddle route list
48
- meddle route show dev-rules
49
- meddle route preview "https://api.example.com/v1/users"
50
- meddle route preview "https://cdn.com/assets/js/app.js" --file dev-rules
51
- meddle route preview "https://api.test.com/data" --json
52
- meddle route active set beta-rules
53
- meddle route create staging --content "example.com localhost:3000"
54
- meddle route add dev-rules "api.test.com" "localhost:8080"
55
- meddle route update dev-rules "api.test.com" "localhost:9000"
56
- meddle route delete dev-rules "api.test.com"
57
- `)
58
- process.exit(1)
59
- }
102
+ if (!subcommand) {
103
+ printHelp()
104
+ process.exit(1)
105
+ }
106
+ failUnknown(subcommand)
107
+ }
@@ -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
  }