@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,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,180 @@ 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}`
168
+ }
169
+
170
+ const MOCKS_MISSING_RULES_MESSAGE = '缺少 rules 数组'
171
+
172
+ function mocksDocumentShapeError(data) {
173
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.rules)) {
174
+ return MOCKS_MISSING_RULES_MESSAGE
175
+ }
176
+ return null
177
+ }
178
+
179
+ function throwMocksFileInvalid(mocksPath, detail) {
180
+ const error = new Error(`Mock 文件格式错误,已禁止写入以免覆盖: ${mocksPath} ${detail}`)
181
+ error.code = 'MOCKS_FILE_INVALID'
182
+ error.filePath = mocksPath
183
+ error.detail = detail
184
+ error.listMessage = `Mock 文件格式错误: ${mocksPath} ${detail}`
185
+ throw error
76
186
  }
77
187
 
78
188
  /**
79
- * Save mock rules to file
189
+ * Read mocks.json. Missing file → null. Empty / whitespace → {}.
190
+ * Invalid JSON or a valid document with the wrong shape (issue #160, e.g. a
191
+ * top-level array) throws MOCKS_FILE_INVALID and must not be treated as [].
192
+ * `message` refuses a write; `listMessage` is what `mock list` prints.
80
193
  */
81
- function saveMockRules(rules) {
82
- const mocksPath = getMocksPath()
83
- const dir = path.dirname(mocksPath)
84
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
194
+ function readMocksDocument(mocksPath) {
195
+ if (!fs.existsSync(mocksPath)) return null
196
+ const text = fs.readFileSync(mocksPath, 'utf8').replace(/^\uFEFF/, '')
197
+ if (text.trim() === '') return {}
198
+ let data
199
+ try {
200
+ data = JSON.parse(text)
201
+ } catch (err) {
202
+ throwMocksFileInvalid(mocksPath, describeMocksParseFailure(text, err))
203
+ }
204
+ const shape = mocksDocumentShapeError(data)
205
+ if (shape) throwMocksFileInvalid(mocksPath, shape)
206
+ return data
207
+ }
208
+
209
+ function rulesFromDocument(data) {
210
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.rules)) return []
211
+ return data.rules
212
+ }
213
+
214
+ function metaFromDocument(data) {
215
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return { nextId: 0, lastId: 0 }
216
+ return { nextId: Number(data.nextId) || 0, lastId: Number(data.lastId) || 0 }
217
+ }
218
+
219
+ /**
220
+ * Load mock rules from file. A corrupt file throws; it is not an empty list.
221
+ */
222
+ function loadMockRules() {
223
+ const data = readMocksDocument(getMocksPath())
224
+ if (data == null) return []
225
+ return rulesFromDocument(data)
226
+ }
85
227
 
86
- // Find max id for sequence
228
+ function readMocksFileMeta(mocksPath) {
229
+ const data = readMocksDocument(mocksPath)
230
+ if (data == null) return { nextId: 0, lastId: 0 }
231
+ return metaFromDocument(data)
232
+ }
233
+
234
+ function maxRuleId(rules) {
87
235
  let maxId = 0
88
236
  rules.forEach(r => {
89
- if (r.id > maxId) maxId = r.id
237
+ if (r && Number.isInteger(r.id) && r.id > maxId) maxId = r.id
90
238
  })
239
+ return maxId
240
+ }
91
241
 
242
+ /**
243
+ * Next id to hand out. Shares the server's `nextId` field so ids stay
244
+ * monotonic across CLI file-mode and the running proxy (issue #115); also
245
+ * honours the legacy CLI `lastId` field.
246
+ */
247
+ function nextMockId(rules, meta) {
248
+ return Math.max(1, meta.nextId || 0, (meta.lastId || 0) + 1, maxRuleId(rules) + 1)
249
+ }
250
+
251
+ /**
252
+ * Save mock rules to file
253
+ */
254
+ function saveMockRules(rules, nextId) {
255
+ const mocksPath = getMocksPath()
256
+ const dir = path.dirname(mocksPath)
257
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
258
+
259
+ const meta = readMocksFileMeta(mocksPath)
260
+ const seq = Math.max(nextId || 0, nextMockId(rules, meta))
92
261
  const data = {
262
+ // `nextId` is what the proxy reads; keep it so deleting the newest rule
263
+ // does not make its id reusable (issue #115). `lastId` kept for older CLIs.
264
+ nextId: seq,
93
265
  rules,
94
- lastId: maxId
266
+ lastId: seq - 1
95
267
  }
96
268
  fs.writeFileSync(mocksPath, JSON.stringify(data, null, 2), 'utf8')
97
269
  }
@@ -103,23 +275,14 @@ function addMockRule(rule) {
103
275
  const rules = loadMockRules()
104
276
  const mocksPath = getMocksPath()
105
277
 
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
- }
278
+ // Issue #115: never reuse an existing (or previously handed-out) id, and
279
+ // ignore any id the caller put on the rule — add never overwrites.
280
+ const nextId = nextMockId(rules, readMocksFileMeta(mocksPath))
118
281
 
119
- rule.id = nextId
120
- rules.push(rule)
121
- saveMockRules(rules)
122
- return rule
282
+ const created = { ...rule, id: nextId }
283
+ rules.push(created)
284
+ saveMockRules(rules, nextId + 1)
285
+ return created
123
286
  }
124
287
 
125
288
  /**
@@ -167,6 +330,30 @@ function getRuleFilePath(name) {
167
330
  /**
168
331
  * List all rule files
169
332
  */
333
+ function getRuleFileOrderNames() {
334
+ const settings = loadSettings()
335
+ const arr = settings.ruleFileOrder
336
+ return Array.isArray(arr) ? arr.filter((name) => typeof name === 'string') : []
337
+ }
338
+
339
+ function resolveRuleFileOrder(diskNames, storedOrder, activeNames) {
340
+ const diskSet = new Set(diskNames)
341
+ const ordered = []
342
+ const seen = new Set()
343
+ const pushUnique = (name) => {
344
+ if (!diskSet.has(name) || seen.has(name)) return
345
+ ordered.push(name)
346
+ seen.add(name)
347
+ }
348
+ if (storedOrder.length > 0) {
349
+ for (const name of storedOrder) pushUnique(name)
350
+ } else {
351
+ for (const name of activeNames) pushUnique(name)
352
+ }
353
+ for (const name of diskNames) pushUnique(name)
354
+ return ordered
355
+ }
356
+
170
357
  function listRuleFiles() {
171
358
  ensureRouteRulesDir()
172
359
  const activeNames = getActiveRuleFileNames()
@@ -175,7 +362,10 @@ function listRuleFiles() {
175
362
  .filter(f => f.endsWith('.txt'))
176
363
  .map(f => f.replace(/\.txt$/, ''))
177
364
 
178
- return files.map(name => {
365
+ const ordered = resolveRuleFileOrder(files, getRuleFileOrderNames(), activeNames)
366
+ const readonlyNames = new Set(getReadonlyRuleFileNames())
367
+
368
+ return ordered.map(name => {
179
369
  const filePath = getRuleFilePath(name)
180
370
  let ruleCount = 0
181
371
  let excludeCount = 0
@@ -191,6 +381,7 @@ function listRuleFiles() {
191
381
  return {
192
382
  name,
193
383
  enabled: activeNames.includes(name),
384
+ readonly: readonlyNames.has(name),
194
385
  ruleCount,
195
386
  excludeCount
196
387
  }
@@ -215,6 +406,7 @@ function getRuleFileContent(name) {
215
406
  */
216
407
  function saveRuleFileContent(name, content) {
217
408
  ensureRouteRulesDir()
409
+ assertRuleFileWritable(name)
218
410
  const filePath = getRuleFilePath(name)
219
411
  fs.writeFileSync(filePath, content, 'utf8')
220
412
  }
@@ -231,6 +423,8 @@ function createRuleFile(name, content = '', enabled = true) {
231
423
  throw new Error(`规则文件 "${safeName}" 已存在`)
232
424
  }
233
425
 
426
+ // Drop stale lock entries so a new file never inherits an old lock.
427
+ getReadonlyRuleFileNames()
234
428
  fs.writeFileSync(filePath, content, 'utf8')
235
429
 
236
430
  if (enabled) {
@@ -242,7 +436,7 @@ function createRuleFile(name, content = '', enabled = true) {
242
436
  }
243
437
 
244
438
  const ruleCount = content.trim() ? Object.keys(parseEprc(content)).length : 0
245
- return { name: safeName, enabled, ruleCount }
439
+ return { name: safeName, enabled, readonly: false, ruleCount }
246
440
  }
247
441
 
248
442
  /**
@@ -253,6 +447,7 @@ function deleteRuleFile(name) {
253
447
  if (!fs.existsSync(filePath)) {
254
448
  throw new Error('规则文件不存在')
255
449
  }
450
+ assertRuleFileWritable(name)
256
451
  fs.unlinkSync(filePath)
257
452
 
258
453
  const activeNames = getActiveRuleFileNames()
@@ -274,6 +469,7 @@ function setRuleFileEnabled(name, enabled) {
274
469
 
275
470
  const activeNames = getActiveRuleFileNames()
276
471
  const idx = activeNames.indexOf(name)
472
+ if ((enabled && idx === -1) || (!enabled && idx !== -1)) assertRuleFileWritable(name)
277
473
 
278
474
  if (enabled && idx === -1) {
279
475
  activeNames.push(name)
@@ -309,6 +505,9 @@ module.exports = {
309
505
  saveSettings,
310
506
  getActiveRuleFileNames,
311
507
  setActiveRuleFileNames,
508
+ getReadonlyRuleFileNames,
509
+ isRuleFileReadonly,
510
+ assertRuleFileWritable,
312
511
 
313
512
  // Mocks
314
513
  getMocksPath,
@@ -17,4 +17,17 @@ function resolveMeddleHome() {
17
17
  return path.resolve(os.homedir(), '.meddle')
18
18
  }
19
19
 
20
- module.exports = { resolveMeddleHome }
20
+ /**
21
+ * Same rules as core/mock-handler.resolveConfiguredMocksPath: `~` expands to
22
+ * the user home directory; a relative path is resolved against MEDDLE_HOME.
23
+ */
24
+ function resolveConfiguredMocksPath(raw, meddleDir, homeDir = os.homedir()) {
25
+ const v = typeof raw === 'string' ? raw.trim() : ''
26
+ if (!v) return null
27
+ let p = v
28
+ if (p === '~') p = homeDir
29
+ else if (p.startsWith('~/') || p.startsWith('~\\')) p = path.join(homeDir, p.slice(2))
30
+ return path.resolve(meddleDir, p)
31
+ }
32
+
33
+ module.exports = { resolveMeddleHome, resolveConfiguredMocksPath }
package/bin/lib/output.js CHANGED
@@ -51,11 +51,18 @@ function success(message) {
51
51
  }
52
52
 
53
53
  /**
54
- * Print error message
54
+ * Print a command failure. Human-readable text goes to stderr so stdout stays
55
+ * empty on error. In --json mode stdout is one `{ success: false }` object and
56
+ * the same message is copied to stderr without a `✗` prefix.
55
57
  */
56
58
  function error(message) {
57
- if (jsonMode) return
58
- console.log(chalk.red(' ✗'), message)
59
+ if (jsonMode) {
60
+ const text = String(message)
61
+ console.log(JSON.stringify({ success: false, error: { message: text } }))
62
+ console.error(text)
63
+ return
64
+ }
65
+ console.error(chalk.red(' ✗'), message)
59
66
  }
60
67
 
61
68
  /**
@@ -128,6 +135,23 @@ function tableRow(columns, widths) {
128
135
  console.log(' ' + row)
129
136
  }
130
137
 
138
+ /**
139
+ * Shared downgrade warning when CLI mutates config but cannot reach a live proxy
140
+ * (issue #85). Same wording for mock + route families.
141
+ */
142
+ const FILE_ONLY_WARNING =
143
+ 'Config written to file, but could not connect to a running proxy — restart required for changes to take effect.'
144
+
145
+ /**
146
+ * Emit the file-only downgrade warning. Always prints to stderr (including
147
+ * --json mode) so the failure mode is never silent.
148
+ * @returns {string} the warning message
149
+ */
150
+ function warnFileOnlyMode() {
151
+ console.error(chalk.yellow(' ⚠'), FILE_ONLY_WARNING)
152
+ return FILE_ONLY_WARNING
153
+ }
154
+
131
155
  module.exports = {
132
156
  setJsonMode,
133
157
  isJsonMode,
@@ -142,5 +166,7 @@ module.exports = {
142
166
  plain,
143
167
  kv,
144
168
  bullet,
145
- tableRow
169
+ tableRow,
170
+ FILE_ONLY_WARNING,
171
+ warnFileOnlyMode,
146
172
  }
@@ -6,6 +6,15 @@
6
6
  * every CLI command session-aware without individual changes: when
7
7
  * --session <id> is set, MEDDLE_HOME is pinned to the session's data dir
8
8
  * and getProxyUrl() returns that session's port.
9
+ *
10
+ * Probe priority (issue #85):
11
+ * 1. MEDDLE_SESSION_PORT (pinned session)
12
+ * 2. mcp-proxy-url.json (written by proxy on every start)
13
+ * 3. DEFAULT_PROXY_BASE (http://127.0.0.1:8989)
14
+ *
15
+ * Stale ports in mcp-proxy-url.json must not stick forever: isProxyRunning /
16
+ * resolveLiveProxyUrl fall through to later candidates when a probe fails,
17
+ * and cache the first live URL for subsequent getProxyUrl() / API calls.
9
18
  */
10
19
 
11
20
  const fs = require('fs')
@@ -16,39 +25,61 @@ const { applySessionContext, GLOBAL_DEFAULT_PORT } = require('./session-args')
16
25
  // Apply --session / MEDDLE_HOME precedence before computing paths.
17
26
  applySessionContext()
18
27
 
28
+ // Resolved once for CLI (MEDDLE_HOME is set before this module loads).
19
29
  const meddleDir = resolveMeddleHome()
20
30
  const mcpFile = path.join(meddleDir, 'mcp-proxy-url.json')
21
31
  const DEFAULT_PROXY_BASE = `http://127.0.0.1:${GLOBAL_DEFAULT_PORT}`
22
32
 
33
+ /** @type {string | null} */
34
+ let resolvedProxyUrl = null
35
+
36
+ function getMcpFilePath() {
37
+ // Prefer live MEDDLE_HOME so tests can point at a temp home after require.
38
+ const home = (process.env.MEDDLE_HOME || '').trim() || meddleDir
39
+ return path.join(home, 'mcp-proxy-url.json')
40
+ }
41
+
23
42
  /**
24
- * Get proxy URL.
25
- *
26
- * Priority:
27
- * 1. MEDDLE_SESSION_PORT env (set by --session resolution)
28
- * 2. mcp-proxy-url.json (written by proxy on startup)
29
- * 3. DEFAULT_PROXY_BASE (http://127.0.0.1:8989)
43
+ * Sync best-effort URL (no liveness check). Prefer a previously resolved live
44
+ * URL so API calls after isProxyRunning() hit the working port.
30
45
  */
31
46
  function getProxyUrl() {
47
+ if (resolvedProxyUrl) return resolvedProxyUrl
48
+ const candidates = getCandidateProxyUrls()
49
+ return candidates[0] || DEFAULT_PROXY_BASE
50
+ }
51
+
52
+ /**
53
+ * Ordered candidate URLs to probe (deduped).
54
+ * Session pin is exclusive — do not fall back to global file/default.
55
+ */
56
+ function getCandidateProxyUrls() {
32
57
  if (process.env.MEDDLE_SESSION_PORT) {
33
- return `http://127.0.0.1:${process.env.MEDDLE_SESSION_PORT}`
58
+ return [`http://127.0.0.1:${process.env.MEDDLE_SESSION_PORT}`]
34
59
  }
60
+ const urls = []
35
61
  try {
36
- if (fs.existsSync(mcpFile)) {
37
- const data = JSON.parse(fs.readFileSync(mcpFile, 'utf8'))
38
- if (data.proxyUrl) return data.proxyUrl
62
+ const file = getMcpFilePath()
63
+ if (fs.existsSync(file)) {
64
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'))
65
+ if (data.proxyUrl && typeof data.proxyUrl === 'string') {
66
+ urls.push(data.proxyUrl.replace(/\/$/, ''))
67
+ }
39
68
  }
40
69
  } catch (_) {}
41
- return DEFAULT_PROXY_BASE
70
+ if (!urls.includes(DEFAULT_PROXY_BASE)) {
71
+ urls.push(DEFAULT_PROXY_BASE)
72
+ }
73
+ return urls
42
74
  }
43
75
 
44
76
  /**
45
- * Check if proxy is running by attempting to connect
46
- * @param {number} timeoutMs - Timeout in milliseconds (default 2000)
77
+ * @param {string} url
78
+ * @param {number} timeoutMs
47
79
  */
48
- async function isProxyRunning(timeoutMs = 2000) {
49
- const url = getProxyUrl()
80
+ async function probeUrl(url, timeoutMs = 2000) {
50
81
  try {
51
- const response = await fetch(url + '/api/mocks', {
82
+ const response = await fetch(url.replace(/\/$/, '') + '/api/mocks', {
52
83
  method: 'GET',
53
84
  signal: AbortSignal.timeout(timeoutMs)
54
85
  })
@@ -58,6 +89,38 @@ async function isProxyRunning(timeoutMs = 2000) {
58
89
  }
59
90
  }
60
91
 
92
+ /**
93
+ * Probe candidates in order; cache and return the first live URL, or null.
94
+ * @param {number} timeoutMs
95
+ * @param {(url: string, timeoutMs: number) => Promise<boolean>} [probe]
96
+ */
97
+ async function resolveLiveProxyUrl(timeoutMs = 2000, probe = probeUrl) {
98
+ if (resolvedProxyUrl) {
99
+ const perTry = Math.min(timeoutMs, 800)
100
+ if (await probe(resolvedProxyUrl, perTry)) return resolvedProxyUrl
101
+ resolvedProxyUrl = null
102
+ }
103
+
104
+ const candidates = getCandidateProxyUrls()
105
+ const perTry = Math.max(200, Math.floor(timeoutMs / Math.max(1, candidates.length)))
106
+ for (const url of candidates) {
107
+ if (await probe(url, perTry)) {
108
+ resolvedProxyUrl = url
109
+ return url
110
+ }
111
+ }
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * Check if proxy is running by probing candidates (file → default fallback).
117
+ * @param {number} timeoutMs - Timeout in milliseconds (default 2000)
118
+ */
119
+ async function isProxyRunning(timeoutMs = 2000) {
120
+ const url = await resolveLiveProxyUrl(timeoutMs)
121
+ return !!url
122
+ }
123
+
61
124
  /**
62
125
  * Wait for proxy to start
63
126
  * @param {number} timeoutMs - Timeout in milliseconds (default 5000)
@@ -69,15 +132,15 @@ function waitForProxyUrl(timeoutMs = 5000) {
69
132
  const check = () => {
70
133
  try {
71
134
  if (process.env.MEDDLE_SESSION_PORT) {
72
- // For --session mode, poll the HTTP endpoint directly.
73
135
  const port = process.env.MEDDLE_SESSION_PORT
74
136
  fetch(`http://127.0.0.1:${port}/api/mocks`, { method: 'GET', signal: AbortSignal.timeout(500) })
75
137
  .then((r) => { if (r.ok) resolve(`http://127.0.0.1:${port}`); else retry() })
76
138
  .catch(() => retry())
77
139
  return
78
140
  }
79
- if (fs.existsSync(mcpFile)) {
80
- const data = JSON.parse(fs.readFileSync(mcpFile, 'utf8'))
141
+ const file = getMcpFilePath()
142
+ if (fs.existsSync(file)) {
143
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'))
81
144
  if (data.proxyUrl) {
82
145
  return resolve(data.proxyUrl)
83
146
  }
@@ -95,11 +158,27 @@ function waitForProxyUrl(timeoutMs = 5000) {
95
158
  })
96
159
  }
97
160
 
161
+ /** Test-only: clear cached live URL so the next probe re-reads candidates. */
162
+ function __resetResolvedProxyUrlForTest() {
163
+ resolvedProxyUrl = null
164
+ }
165
+
166
+ /** Test-only: seed the cached live URL. */
167
+ function __setResolvedProxyUrlForTest(url) {
168
+ resolvedProxyUrl = url || null
169
+ }
170
+
98
171
  module.exports = {
99
172
  meddleDir,
100
173
  mcpFile,
101
174
  DEFAULT_PROXY_BASE,
102
175
  getProxyUrl,
176
+ getCandidateProxyUrls,
177
+ getMcpFilePath,
178
+ resolveLiveProxyUrl,
179
+ probeUrl,
103
180
  isProxyRunning,
104
- waitForProxyUrl
181
+ waitForProxyUrl,
182
+ __resetResolvedProxyUrlForTest,
183
+ __setResolvedProxyUrlForTest,
105
184
  }