@jonathanleelx/meddle 0.5.0-beta.9 → 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 (204) 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/mock-handler.d.ts +59 -0
  42. package/dist/core/mock-handler.d.ts.map +1 -1
  43. package/dist/core/mock-handler.js +627 -29
  44. package/dist/core/mock-handler.js.map +1 -1
  45. package/dist/core/mock-utils.d.ts +58 -0
  46. package/dist/core/mock-utils.d.ts.map +1 -0
  47. package/dist/core/mock-utils.js +167 -0
  48. package/dist/core/mock-utils.js.map +1 -0
  49. package/dist/core/plugin-bootstrap-runner.d.ts +1 -0
  50. package/dist/core/plugin-bootstrap-runner.d.ts.map +1 -1
  51. package/dist/core/plugin-bootstrap-runner.js +37 -1
  52. package/dist/core/plugin-bootstrap-runner.js.map +1 -1
  53. package/dist/core/plugin-source.d.ts +24 -0
  54. package/dist/core/plugin-source.d.ts.map +1 -0
  55. package/dist/core/plugin-source.js +47 -0
  56. package/dist/core/plugin-source.js.map +1 -0
  57. package/dist/core/rule-file-edit.d.ts +81 -0
  58. package/dist/core/rule-file-edit.d.ts.map +1 -0
  59. package/dist/core/rule-file-edit.js +243 -0
  60. package/dist/core/rule-file-edit.js.map +1 -0
  61. package/dist/core/static-server.d.ts.map +1 -1
  62. package/dist/core/static-server.js +15 -13
  63. package/dist/core/static-server.js.map +1 -1
  64. package/dist/core/types.d.ts +13 -1
  65. package/dist/core/types.d.ts.map +1 -1
  66. package/dist/plugins/builtin/mock-plugin.d.ts.map +1 -1
  67. package/dist/plugins/builtin/mock-plugin.js +6 -5
  68. package/dist/plugins/builtin/mock-plugin.js.map +1 -1
  69. package/dist/server/agent/tools.d.ts.map +1 -1
  70. package/dist/server/agent/tools.js +24 -18
  71. package/dist/server/agent/tools.js.map +1 -1
  72. package/dist/server/app-info.d.ts +33 -0
  73. package/dist/server/app-info.d.ts.map +1 -0
  74. package/dist/server/app-info.js +116 -0
  75. package/dist/server/app-info.js.map +1 -0
  76. package/dist/server/config.d.ts +1 -0
  77. package/dist/server/config.d.ts.map +1 -1
  78. package/dist/server/config.js +101 -9
  79. package/dist/server/config.js.map +1 -1
  80. package/dist/server/health.d.ts.map +1 -1
  81. package/dist/server/health.js +8 -0
  82. package/dist/server/health.js.map +1 -1
  83. package/dist/server/index.d.ts +15 -0
  84. package/dist/server/index.d.ts.map +1 -1
  85. package/dist/server/index.js +3 -0
  86. package/dist/server/index.js.map +1 -1
  87. package/dist/server/mocks.d.ts +26 -0
  88. package/dist/server/mocks.d.ts.map +1 -1
  89. package/dist/server/mocks.js +110 -13
  90. package/dist/server/mocks.js.map +1 -1
  91. package/dist/server/plugins.d.ts.map +1 -1
  92. package/dist/server/plugins.js +56 -11
  93. package/dist/server/plugins.js.map +1 -1
  94. package/dist/server/rule-files.d.ts +36 -0
  95. package/dist/server/rule-files.d.ts.map +1 -1
  96. package/dist/server/rule-files.js +395 -107
  97. package/dist/server/rule-files.js.map +1 -1
  98. package/dist/server/ruleset-lock.d.ts +62 -0
  99. package/dist/server/ruleset-lock.d.ts.map +1 -0
  100. package/dist/server/ruleset-lock.js +234 -0
  101. package/dist/server/ruleset-lock.js.map +1 -0
  102. package/index.js +36 -7
  103. package/mcp-server.js +90 -88
  104. package/package.json +1 -1
  105. package/plugins/builtin/mock-plugin.ts +9 -5
  106. package/web/dist/assets/alert-B514Frte.js +1 -0
  107. package/web/dist/assets/arrow-right-BNum_060.js +1 -0
  108. package/web/dist/assets/{body-diff-view-B-4XWmL6.js → body-diff-view-SfLCLzs9.js} +2 -2
  109. package/web/dist/assets/button-BFNlGZ5p.js +1 -0
  110. package/web/dist/assets/check-BkfHQ6re.js +1 -0
  111. package/web/dist/assets/checkbox-CQyFsZzS.js +1 -0
  112. package/web/dist/assets/chevron-down-DHwUtL4O.js +1 -0
  113. package/web/dist/assets/{circle-alert-Q8cZk9Rv.js → circle-alert-zMagrFuI.js} +1 -1
  114. package/web/dist/assets/{circle-x-C9USji_R.js → circle-x-BEkinhez.js} +1 -1
  115. package/web/dist/assets/code-DD-AYxxu.js +1 -0
  116. package/web/dist/assets/{copy-BYjq1hdN.js → copy-BRnscYy1.js} +1 -1
  117. package/web/dist/assets/detail-panel-BtgWWeu3.js +4 -0
  118. package/web/dist/assets/dist-BQN0rzUS.js +1 -0
  119. package/web/dist/assets/{dist-CEd_wnRC.js → dist-C-E2R2jk.js} +1 -1
  120. package/web/dist/assets/{dist-C6eaYCUo.js → dist-CFZ4xMSF.js} +1 -1
  121. package/web/dist/assets/{dist-DWmDaXVN.js → dist-CRmX_NvH.js} +1 -1
  122. package/web/dist/assets/{dist-C8RHq5At.js → dist-DLQKRjuW.js} +1 -1
  123. package/web/dist/assets/{dist-Blyulj4_.js → dist-LiAcI8CK.js} +1 -1
  124. package/web/dist/assets/{dist-Ce4ujolc.js → dist-fzPL-8F9.js} +1 -1
  125. package/web/dist/assets/{dist-DoE7uybN.js → dist-wv9ebJK_.js} +1 -1
  126. package/web/dist/assets/editor-pane-actions-_ckwcCII.js +1 -0
  127. package/web/dist/assets/{es2015-DG46NGN7.js → es2015-DPJTBYa9.js} +1 -1
  128. package/web/dist/assets/{global-panel-shell-5cJneeis.js → global-panel-shell-NaUhzX7J.js} +3 -3
  129. package/web/dist/assets/health-panel-DhXLqcOU.js +1 -0
  130. package/web/dist/assets/index-B-6nUrqW.js +21 -0
  131. package/web/dist/assets/index-EWNlAdFB.css +2 -0
  132. package/web/dist/assets/mobile-proxy-panel-BY8Oi-qC.js +8 -0
  133. package/web/dist/assets/mock-config-TPmfJwAt.js +1 -0
  134. package/web/dist/assets/mock-editor-panel-WQwcHSTD.js +20 -0
  135. package/web/dist/assets/mocks-file-missing-note-BR5oWFLh.js +1 -0
  136. package/web/dist/assets/monaco-editor-BGLPrb0C.js +11 -0
  137. package/web/dist/assets/pane-bar-gFRISZLD.js +1 -0
  138. package/web/dist/assets/pane-split-B6aLmY7G.js +1 -0
  139. package/web/dist/assets/panel-heading-DT_nXayM.js +1 -0
  140. package/web/dist/assets/{pencil-line-Cqe41E4h.js → pencil-line-CEZ7D-de.js} +1 -1
  141. package/web/dist/assets/plugin-code-editor--Gf4ZwGb.js +2 -0
  142. package/web/dist/assets/plugin-config-DsX9xCW_.js +2 -0
  143. package/web/dist/assets/plugin-generator-Bj4f6dxo.js +2 -0
  144. package/web/dist/assets/plugin-test-dialog-DICuykBK.js +7 -0
  145. package/web/dist/assets/plus-ixZQRprm.js +1 -0
  146. package/web/dist/assets/{refresh-cw-CG3HaiQT.js → refresh-cw-BfLoHqg5.js} +1 -1
  147. package/web/dist/assets/rotate-ccw-CRguJya4.js +1 -0
  148. package/web/dist/assets/route-preview-C-LzS1lT.js +1 -0
  149. package/web/dist/assets/{rule-ai-assistant-panel-DMh0F9dr.js → rule-ai-assistant-panel-BEfPNBbx.js} +2 -2
  150. package/web/dist/assets/rule-config-Dw9pcuah.js +15 -0
  151. package/web/dist/assets/rule-overview-panel-C4QQ7U3S.js +1 -0
  152. package/web/dist/assets/{server-Cpj4KNwN.js → server-eYXq3PbE.js} +1 -1
  153. package/web/dist/assets/settings-panel-D8DEH8aH.js +1 -0
  154. package/web/dist/assets/{shield-check-OqUmVLQt.js → shield-check-hQXjdfZ7.js} +1 -1
  155. package/web/dist/assets/smartphone-D4Q7cQfm.js +1 -0
  156. package/web/dist/assets/switch-94vgsMHm.js +1 -0
  157. package/web/dist/assets/{syntax-highlight-Dk0-ewLI.js → syntax-highlight-BFgN_C0Y.js} +1 -1
  158. package/web/dist/assets/{table-2-BR5e3z8m.js → table-2-DvGHhMm4.js} +1 -1
  159. package/web/dist/assets/table-DaXD6jJv.js +1 -0
  160. package/web/dist/assets/textarea-9wB8Hsz-.js +1 -0
  161. package/web/dist/assets/toast-9yeTf7d9.js +1 -0
  162. package/web/dist/assets/use-mocks-file-status-BUIUJ4oQ.js +1 -0
  163. package/web/dist/assets/{wand-sparkles-CJ7fSnom.js → wand-sparkles-Bmt4dDOf.js} +1 -1
  164. package/web/dist/assets/{wifi-CCLKyySQ.js → wifi-Cjj_Br1u.js} +1 -1
  165. package/web/dist/assets/{zap-DR-xiUMe.js → zap-BuOtogAv.js} +1 -1
  166. package/web/dist/index.html +16 -10
  167. package/web/dist/assets/alert-DCvlZAiv.js +0 -1
  168. package/web/dist/assets/arrow-right-B4Mrlj2-.js +0 -1
  169. package/web/dist/assets/button-BTToMaBW.js +0 -1
  170. package/web/dist/assets/checkbox-D1Z9DtjF.js +0 -1
  171. package/web/dist/assets/chevron-down-CPngAx4M.js +0 -1
  172. package/web/dist/assets/chevron-right-V1DGIDbL.js +0 -1
  173. package/web/dist/assets/code-5_eJ_XpJ.js +0 -1
  174. package/web/dist/assets/code-fixer-D48sCDtp.js +0 -20
  175. package/web/dist/assets/detail-panel-BSFz9CUw.js +0 -4
  176. package/web/dist/assets/file-system-access-_og-Tg9o.js +0 -1
  177. package/web/dist/assets/health-panel-BS3L2e5d.js +0 -1
  178. package/web/dist/assets/index-CvTR1IVi.js +0 -18
  179. package/web/dist/assets/index-bfV7sxx5.css +0 -2
  180. package/web/dist/assets/mobile-proxy-panel-tU0NyJ3P.js +0 -8
  181. package/web/dist/assets/mock-config-CULJP6Vt.js +0 -1
  182. package/web/dist/assets/mock-editor-panel-9wlDaySA.js +0 -1
  183. package/web/dist/assets/monaco-editor-BttaUoOz.js +0 -11
  184. package/web/dist/assets/plugin-code-editor-BAIBp4lg.js +0 -2
  185. package/web/dist/assets/plugin-config-ButUV2SZ.js +0 -1
  186. package/web/dist/assets/plugin-generator-uceUPSCv.js +0 -2
  187. package/web/dist/assets/plugin-test-dialog-BrJ4HfRX.js +0 -7
  188. package/web/dist/assets/plus-tVxSydJ8.js +0 -1
  189. package/web/dist/assets/rotate-ccw-CAYM6Vv0.js +0 -1
  190. package/web/dist/assets/route-preview-C4cGrS1-.js +0 -1
  191. package/web/dist/assets/rule-config-BTTGNwMs.js +0 -15
  192. package/web/dist/assets/rule-overview-panel-SFR2OloL.js +0 -1
  193. package/web/dist/assets/select-aBXQ0zNa.js +0 -1
  194. package/web/dist/assets/settings-panel-BMReDG8b.js +0 -1
  195. package/web/dist/assets/smartphone-DKeDuJTU.js +0 -1
  196. package/web/dist/assets/switch-Bes_YiJu.js +0 -1
  197. package/web/dist/assets/table-nW7uQVcl.js +0 -1
  198. package/web/dist/assets/textarea-BSI0rnW6.js +0 -1
  199. /package/web/dist/assets/{babel-DCr4VA1c.js → babel-bwZm0qi3.js} +0 -0
  200. /package/web/dist/assets/{clipboard-VhPXNN5m.js → clipboard-Bt1qY01V.js} +0 -0
  201. /package/web/dist/assets/{estree-qO_m2Qaw.js → estree-MatbZDwq.js} +0 -0
  202. /package/web/dist/assets/{html-VDGahtyk.js → html-CCrsbFYX.js} +0 -0
  203. /package/web/dist/assets/{postcss-BUm8EKMw.js → postcss-BxN7Gr8n.js} +0 -0
  204. /package/web/dist/assets/{standalone-Bj8SSd8Q.js → standalone-DsnFnsqM.js} +0 -0
package/bin/lib/output.js CHANGED
@@ -51,11 +51,13 @@ 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 this is a no-op; the caller prints the
56
+ * `{ success: false }` object on stdout.
55
57
  */
56
58
  function error(message) {
57
59
  if (jsonMode) return
58
- console.log(chalk.red(' ✗'), message)
60
+ console.error(chalk.red(' ✗'), message)
59
61
  }
60
62
 
61
63
  /**
@@ -128,6 +130,23 @@ function tableRow(columns, widths) {
128
130
  console.log(' ' + row)
129
131
  }
130
132
 
133
+ /**
134
+ * Shared downgrade warning when CLI mutates config but cannot reach a live proxy
135
+ * (issue #85). Same wording for mock + route families.
136
+ */
137
+ const FILE_ONLY_WARNING =
138
+ 'Config written to file, but could not connect to a running proxy — restart required for changes to take effect.'
139
+
140
+ /**
141
+ * Emit the file-only downgrade warning. Always prints to stderr (including
142
+ * --json mode) so the failure mode is never silent.
143
+ * @returns {string} the warning message
144
+ */
145
+ function warnFileOnlyMode() {
146
+ console.error(chalk.yellow(' ⚠'), FILE_ONLY_WARNING)
147
+ return FILE_ONLY_WARNING
148
+ }
149
+
131
150
  module.exports = {
132
151
  setJsonMode,
133
152
  isJsonMode,
@@ -142,5 +161,7 @@ module.exports = {
142
161
  plain,
143
162
  kv,
144
163
  bullet,
145
- tableRow
164
+ tableRow,
165
+ FILE_ONLY_WARNING,
166
+ warnFileOnlyMode,
146
167
  }
@@ -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
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Decide whether a proxy API failure may fall back to a direct file write.
3
+ *
4
+ * Fall back only when the proxy is not running, or the request fails before
5
+ * any HTTP response (connection refused, timeout, DNS). A 4xx/5xx is the
6
+ * proxy's answer: callers print that body and must not touch the file.
7
+ * Treating a 409 (corrupt mocks.json) as "proxy down" rewrote the file and
8
+ * dropped every existing rule.
9
+ */
10
+
11
+ const output = require('./output')
12
+ const { isProxyRunning } = require('./proxy-detect')
13
+
14
+ const UNREACHABLE_CODES = new Set([
15
+ 'ECONNREFUSED',
16
+ 'ECONNRESET',
17
+ 'ENOTFOUND',
18
+ 'EAI_AGAIN',
19
+ 'EHOSTUNREACH',
20
+ 'ENETUNREACH',
21
+ 'EPIPE',
22
+ 'ETIMEDOUT',
23
+ 'ECONNABORTED',
24
+ 'UND_ERR_CONNECT_TIMEOUT',
25
+ 'UND_ERR_SOCKET',
26
+ 'UND_ERR_HEADERS_TIMEOUT',
27
+ ])
28
+
29
+ function causeCode(err) {
30
+ if (!err || typeof err !== 'object') return undefined
31
+ if (err.code && err.code !== 'MOCKS_FILE_INVALID') return err.code
32
+ if (err.cause) return causeCode(err.cause)
33
+ return undefined
34
+ }
35
+
36
+ /** True when the proxy answered with an HTTP 4xx/5xx (api-client sets err.status). */
37
+ function isApiHttpError(err) {
38
+ const status = err && err.status
39
+ return Number.isInteger(status) && status >= 400 && status <= 599
40
+ }
41
+
42
+ /**
43
+ * True when we never got an HTTP response: connection refused, DNS, reset,
44
+ * timeout, or undici's `TypeError: fetch failed`.
45
+ */
46
+ function isProxyUnreachableError(err) {
47
+ if (!err || isApiHttpError(err)) return false
48
+ if (err.name === 'AbortError' || err.name === 'TimeoutError') return true
49
+ const code = causeCode(err)
50
+ if (code && UNREACHABLE_CODES.has(code)) return true
51
+ if (err.name === 'TypeError' && err.message === 'fetch failed') return true
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * Call the proxy when it is up. `file` runs only when it is down or the
57
+ * request never received an HTTP response. HTTP errors reject.
58
+ *
59
+ * @param {{ api: () => Promise<any>, file: () => any }} opts
60
+ * @returns {Promise<{ fileOnly: boolean, value: any }>}
61
+ */
62
+ async function withProxyOrFile({ api, file }) {
63
+ const running = await isProxyRunning()
64
+ if (!running) return { fileOnly: true, value: file() }
65
+ try {
66
+ return { fileOnly: false, value: await api() }
67
+ } catch (err) {
68
+ if (!isProxyUnreachableError(err)) throw err
69
+ return { fileOnly: true, value: file() }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Exit 1. An HTTP error's message is the response body and is printed as-is.
75
+ * `err.rawStderr` prints `err.message` with no `✗` prefix (mock list).
76
+ * Other human-mode failures use the CLI `✗` style.
77
+ */
78
+ function failCommand(err) {
79
+ const message = err && err.message ? String(err.message) : String(err)
80
+ if (isApiHttpError(err) || (err && err.rawStderr) || output.isJsonMode()) console.error(message)
81
+ else output.error(message)
82
+ process.exit(1)
83
+ }
84
+
85
+ module.exports = {
86
+ isApiHttpError,
87
+ isProxyUnreachableError,
88
+ withProxyOrFile,
89
+ failCommand,
90
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Argument parsing for `meddle route add|update|delete` (issue #149).
3
+ *
4
+ * `--exclusions=` clears a rule's exclusions. `--exclusions=/a,/b` replaces
5
+ * them. A bare `--exclusions`, an unknown flag, or an empty `!` token used to
6
+ * be ignored and the command still exited 0 without changing exclusions.
7
+ */
8
+
9
+ const { splitPatternArg } = require('./rule-file-edit')
10
+
11
+ class RouteArgError extends Error {
12
+ constructor(message) {
13
+ super(message)
14
+ this.name = 'RouteArgError'
15
+ }
16
+ }
17
+
18
+ const CLEAR_HINT = 'To clear exclusions, pass --exclusions= (empty). To replace them, pass --exclusions=/a,/b.'
19
+
20
+ /**
21
+ * @param {string} raw value after `=`
22
+ * @returns {string[]}
23
+ */
24
+ function parseExclusionList(raw) {
25
+ const value = String(raw).trim()
26
+ if (value === '') return []
27
+ const pieces = value.includes(',') ? value.split(',') : value.split(/\s+/)
28
+ const out = []
29
+ for (const piece of pieces) {
30
+ const token = piece.trim()
31
+ if (!token) {
32
+ throw new RouteArgError(`Invalid --exclusions: empty entry. ${CLEAR_HINT}`)
33
+ }
34
+ const body = token.startsWith('!') ? token.slice(1).trim() : token
35
+ if (!body) {
36
+ throw new RouteArgError(`Invalid --exclusions: "!" needs a path, for example --exclusions=/health. ${CLEAR_HINT}`)
37
+ }
38
+ out.push(body)
39
+ }
40
+ return out
41
+ }
42
+
43
+ /**
44
+ * @param {string[]} args argv after the subcommand
45
+ * @param {{ allowExclusions?: boolean }} [options]
46
+ * @returns {{ help: boolean, json: boolean, positional: string[], exclusions: string[] | undefined }}
47
+ * `exclusions` is undefined when the flag was not passed. `[]` means clear.
48
+ */
49
+ function parseRouteWriteArgs(args, { allowExclusions = true } = {}) {
50
+ let help = false
51
+ let json = false
52
+ /** @type {string[] | undefined} */
53
+ let exclusions
54
+ let sawExclusions = false
55
+ const positional = []
56
+
57
+ for (let i = 0; i < args.length; i++) {
58
+ const arg = args[i]
59
+ if (arg === '--help' || arg === '-h') {
60
+ help = true
61
+ continue
62
+ }
63
+ if (arg === '--json') {
64
+ json = true
65
+ continue
66
+ }
67
+ if (arg === '--exclusions' || arg.startsWith('--exclusions=')) {
68
+ if (!allowExclusions) {
69
+ throw new RouteArgError(`Unsupported option: ${arg === '--exclusions' ? '--exclusions' : '--exclusions='}. route delete does not change exclusions.`)
70
+ }
71
+ if (arg === '--exclusions') {
72
+ throw new RouteArgError(`Unsupported option: --exclusions. ${CLEAR_HINT}`)
73
+ }
74
+ if (sawExclusions) throw new RouteArgError('--exclusions was given more than once.')
75
+ sawExclusions = true
76
+ exclusions = parseExclusionList(arg.slice('--exclusions='.length))
77
+ continue
78
+ }
79
+ if (arg.startsWith('-')) {
80
+ throw new RouteArgError(`Unsupported option: ${arg}.`)
81
+ }
82
+ positional.push(arg)
83
+ }
84
+
85
+ return { help, json, positional, exclusions: sawExclusions ? exclusions : undefined }
86
+ }
87
+
88
+ /**
89
+ * Split a pattern argument the way the CLI always has (`"host !/api"`), but
90
+ * refuse a bare `!` — that used to be dropped and looked like a successful clear.
91
+ * @returns {{ patterns: string[], exclusions: string[] }}
92
+ */
93
+ function parsePatternArg(raw) {
94
+ const parts = String(raw).trim().split(/\s+/).filter(Boolean)
95
+ for (const part of parts) {
96
+ if (part.startsWith('!') && part.slice(1).trim() === '') {
97
+ throw new RouteArgError('Invalid exclusion "!": a "!" token needs a path such as "!/api". To clear exclusions, pass --exclusions=.')
98
+ }
99
+ }
100
+ return splitPatternArg(raw)
101
+ }
102
+
103
+ /**
104
+ * @param {string[]} patternExclusions exclusions parsed from !path tokens
105
+ * @param {string[] | undefined} flagExclusions undefined when --exclusions was omitted
106
+ * @returns {{ explicit: boolean, exclusions: string[] | undefined }}
107
+ */
108
+ function resolveExclusions(patternExclusions, flagExclusions) {
109
+ if (flagExclusions !== undefined && patternExclusions.length > 0) {
110
+ throw new RouteArgError('Pass exclusions either as !path tokens in the pattern or with --exclusions, not both.')
111
+ }
112
+ if (flagExclusions !== undefined) return { explicit: true, exclusions: flagExclusions }
113
+ if (patternExclusions.length > 0) return { explicit: true, exclusions: patternExclusions }
114
+ return { explicit: false, exclusions: undefined }
115
+ }
116
+
117
+ const UPDATE_HELP = `
118
+ meddle route update <file> <pattern> [<target>] [--exclusions=<list>] [--json]
119
+
120
+ Update a rule. Edits only the line(s) that hold the pattern; every other line
121
+ stays byte-for-byte, including comments, blank lines and other rules' !path.
122
+
123
+ --exclusions= Clear this rule's exclusions (the line loses !path).
124
+ --exclusions=/a,/b Replace exclusions. Spaces work inside quotes
125
+ (--exclusions="/a /b"). A leading ! is optional.
126
+ "pattern !/a !/b" Replace exclusions (same as before). Omit both
127
+ --exclusions and !path to keep existing exclusions.
128
+
129
+ The <target> may be omitted when --exclusions is set; the current target stays.
130
+ A bare --exclusions, an empty "!" token, an unknown flag, or both exclusion
131
+ forms at once exits 1 and does not change the file.
132
+
133
+ A "#" comments out a whole line only when it is the first non-space character.
134
+ A "#" later on a rule line is a pattern token, not a trailing comment.
135
+
136
+ Examples:
137
+ meddle route update dev "api.example.com" "localhost:4000"
138
+ meddle route update dev "api.example.com" --exclusions=
139
+ meddle route update dev "api.example.com" "localhost:4000" --exclusions=/health
140
+ `
141
+
142
+ module.exports = {
143
+ CLEAR_HINT,
144
+ RouteArgError,
145
+ UPDATE_HELP,
146
+ parseExclusionList,
147
+ parsePatternArg,
148
+ parseRouteWriteArgs,
149
+ resolveExclusions,
150
+ }