@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
@@ -0,0 +1,93 @@
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
+ // HTTP / raw-stderr failures print the body as-is (no JSON wrapper, no `✗`).
81
+ // Other failures go through output.error, which emits `{success:false}` on
82
+ // stdout when --json is set.
83
+ if (isApiHttpError(err) || (err && err.rawStderr)) console.error(message)
84
+ else output.error(message)
85
+ process.exit(1)
86
+ }
87
+
88
+ module.exports = {
89
+ isApiHttpError,
90
+ isProxyUnreachableError,
91
+ withProxyOrFile,
92
+ failCommand,
93
+ }
@@ -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
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Line-preserving edits of route rule files (issue #145).
3
+ *
4
+ * The old write path parsed a rule file into a ruleMap and rebuilt the whole
5
+ * text, which dropped comments, blank lines, disabled (`//`) rules and — in the
6
+ * CLI — every other rule's exclusions (`!path`). These helpers edit only the
7
+ * lines that hold the target pattern; every other line keeps its exact bytes
8
+ * (including its line ending).
9
+ *
10
+ * Kept in sync with core/rule-file-edit.ts (same convention as
11
+ * bin/lib/meddle-home.js ↔ core/meddle-home.ts).
12
+ */
13
+
14
+ const MARKER = /\[([^\]]+)\]/
15
+ const BOM = '\uFEFF'
16
+ const SPLIT_UNSAFE_MESSAGE = '该行剩余的 pattern 以 // 或 # 开头,拆开后会被当成注释,请用文本编辑器手动修改'
17
+
18
+ /**
19
+ * Thrown when splitting a line or removing a pattern would put a pattern that
20
+ * starts with `//` or `#` at the start of a line, turning it into a disabled
21
+ * rule or a comment. The rule file is left untouched.
22
+ */
23
+ class RuleLineSplitError extends Error {
24
+ constructor() {
25
+ super(SPLIT_UNSAFE_MESSAGE)
26
+ this.name = 'RuleLineSplitError'
27
+ this.code = 'RULE_LINE_SPLIT_UNSAFE'
28
+ }
29
+ }
30
+
31
+ const NEW_RULE_COMMENT_MESSAGE = '规则不能以 // 或 # 开头,否则会被当成禁用行或注释。要写注释或禁用规则,请用文本编辑器。'
32
+
33
+ /**
34
+ * Thrown when a NEW rule's pattern starts with `//` or `#`: appended as a line it
35
+ * would read back as a disabled rule or a comment. Same code as RuleLineSplitError.
36
+ */
37
+ class RuleNewRuleCommentError extends Error {
38
+ constructor() {
39
+ super(NEW_RULE_COMMENT_MESSAGE)
40
+ this.name = 'RuleNewRuleCommentError'
41
+ this.code = 'RULE_LINE_SPLIT_UNSAFE'
42
+ }
43
+ }
44
+
45
+ function startsAsComment(pattern) {
46
+ return pattern.startsWith('//') || pattern.startsWith('#')
47
+ }
48
+
49
+ /**
50
+ * A leading UTF-8 BOM is not part of the first line: strip it before editing
51
+ * and put it back afterwards, so it stays byte-identical even when the first
52
+ * line is split or deleted (JS `trim()` / `\s` treat U+FEFF as whitespace).
53
+ */
54
+ function withBom(content, edit) {
55
+ if (!content.startsWith(BOM)) return edit(content)
56
+ const result = edit(content.slice(BOM.length))
57
+ return { ...result, content: BOM + result.content }
58
+ }
59
+
60
+ /** Internal key of a pattern token: `[marker]` brackets removed (same as the parser). */
61
+ function patternKey(token) {
62
+ const bm = token.match(MARKER)
63
+ return bm ? token.replace(bm[0], bm[1]) : token
64
+ }
65
+
66
+ /** Split into [line, eol, line, eol, ..., line]; the parser splits on /\r?\n/ too. */
67
+ function splitLines(content) {
68
+ return content.split(/(\r?\n)/)
69
+ }
70
+
71
+ /**
72
+ * Parse one line the way parseEprcWithExclusions does. Returns null for blank
73
+ * lines, `#` comments, `//` disabled rules and lines that are not rules.
74
+ */
75
+ function parseRuleLine(line) {
76
+ const trimmed = line.trim()
77
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null
78
+ const parts = trimmed.split(/\s+/).filter(Boolean)
79
+ const exclusionTokens = parts.filter((p) => p.startsWith('!'))
80
+ const regular = parts.filter((p) => !p.startsWith('!'))
81
+ if (regular.length < 2) return null
82
+ return {
83
+ leading: line.match(/^\s*/)[0],
84
+ patterns: regular.slice(0, -1),
85
+ target: regular[regular.length - 1],
86
+ exclusions: exclusionTokens.map((t) => t.slice(1)),
87
+ }
88
+ }
89
+
90
+ function formatLine(leading, patterns, exclusions, target) {
91
+ const parts = patterns.slice()
92
+ for (const e of exclusions) parts.push(`!${e}`)
93
+ parts.push(target)
94
+ return `${leading}${parts.join(' ')}`
95
+ }
96
+
97
+ function sameList(a, b) {
98
+ return a.length === b.length && a.every((v, i) => v === b[i])
99
+ }
100
+
101
+ /** Replace the last regular (target) token in place, keeping all spacing. */
102
+ function replaceTargetInPlace(line, target) {
103
+ const tokens = line.match(/\s+|\S+/g) || []
104
+ for (let i = tokens.length - 1; i >= 0; i--) {
105
+ if (/\S/.test(tokens[i]) && !tokens[i].startsWith('!')) {
106
+ tokens[i] = target
107
+ return tokens.join('')
108
+ }
109
+ }
110
+ return line
111
+ }
112
+
113
+ /** Remove the given non-space token indexes (and one adjacent separator each). */
114
+ function removePatternTokens(line, keys) {
115
+ const tokens = line.match(/\s+|\S+/g) || []
116
+ const nonSpace = []
117
+ tokens.forEach((t, i) => { if (/\S/.test(t)) nonSpace.push(i) })
118
+ const regularIdx = nonSpace.filter((i) => !tokens[i].startsWith('!'))
119
+ const patternIdx = regularIdx.slice(0, -1)
120
+ const drop = new Set()
121
+ for (const i of patternIdx) {
122
+ if (!keys.has(patternKey(tokens[i]))) continue
123
+ drop.add(i)
124
+ // Prefer the separator after the token (a pattern is never the last token).
125
+ if (i + 1 < tokens.length && !/\S/.test(tokens[i + 1]) && !drop.has(i + 1)) drop.add(i + 1)
126
+ else if (i - 1 > 0 && !/\S/.test(tokens[i - 1])) drop.add(i - 1)
127
+ }
128
+ return tokens.filter((_, i) => !drop.has(i)).join('')
129
+ }
130
+
131
+ /**
132
+ * Normalise a user-supplied pattern argument. The CLI accepts exclusions inside
133
+ * the pattern argument (`"app.example.com !/api"`), so split those out.
134
+ */
135
+ function splitPatternArg(raw) {
136
+ const parts = String(raw).trim().split(/\s+/).filter(Boolean)
137
+ const patterns = parts.filter((p) => !p.startsWith('!'))
138
+ const exclusions = parts.filter((p) => p.startsWith('!')).map((p) => p.slice(1)).filter(Boolean)
139
+ return { patterns, exclusions }
140
+ }
141
+
142
+ /**
143
+ * Point every enabled occurrence of `pattern` at `target` (if given) and/or set
144
+ * its exclusions (if given; `undefined` keeps each line's own exclusions).
145
+ * Lines that do not contain the pattern are left untouched.
146
+ * @returns {{ content: string, found: boolean }}
147
+ */
148
+ function updateRouteRule(content, { pattern, target, exclusions }) {
149
+ return withBom(content, (text) => updateLines(text, { pattern, target, exclusions }))
150
+ }
151
+
152
+ function updateLines(content, { pattern, target, exclusions }) {
153
+ const key = patternKey(pattern)
154
+ const userHasMarker = MARKER.test(pattern)
155
+ const segments = splitLines(content)
156
+ let found = false
157
+
158
+ for (let s = 0; s < segments.length; s += 2) {
159
+ const line = segments[s]
160
+ const parsed = parseRuleLine(line)
161
+ if (!parsed || !parsed.patterns.some((p) => patternKey(p) === key)) continue
162
+ found = true
163
+
164
+ const nextTarget = target !== undefined ? target : parsed.target
165
+ const nextExclusions = exclusions !== undefined ? exclusions : parsed.exclusions
166
+ const exclusionsChanged = !sameList(nextExclusions, parsed.exclusions)
167
+ const markerChanged = userHasMarker && parsed.patterns.some((p) => patternKey(p) === key && p !== pattern)
168
+ if (nextTarget === parsed.target && !exclusionsChanged && !markerChanged) continue
169
+
170
+ const allMatch = parsed.patterns.every((p) => patternKey(p) === key)
171
+ if (allMatch && !exclusionsChanged && !markerChanged) {
172
+ segments[s] = replaceTargetInPlace(line, nextTarget)
173
+ continue
174
+ }
175
+
176
+ // Split the line into consecutive groups so other patterns on the same line
177
+ // keep their target / exclusions and the original order is preserved.
178
+ const groups = []
179
+ for (const p of parsed.patterns) {
180
+ const match = patternKey(p) === key
181
+ const last = groups[groups.length - 1]
182
+ if (last && last.match === match) last.patterns.push(p)
183
+ else groups.push({ match, patterns: [p] })
184
+ }
185
+ if (groups.some((g) => startsAsComment(g.match && userHasMarker ? pattern : g.patterns[0]))) throw new RuleLineSplitError()
186
+ const lines = groups.map((g) => g.match
187
+ ? formatLine(parsed.leading, g.patterns.map((p) => (userHasMarker ? pattern : p)), nextExclusions, nextTarget)
188
+ : formatLine(parsed.leading, g.patterns, parsed.exclusions, parsed.target))
189
+ const eol = segments[s + 1] !== undefined ? segments[s + 1] : (content.includes('\r\n') ? '\r\n' : '\n')
190
+ segments[s] = lines.join(eol)
191
+ }
192
+
193
+ return { content: segments.join(''), found }
194
+ }
195
+
196
+ /**
197
+ * Add a rule. An existing pattern is updated in place (its exclusions are kept
198
+ * unless `exclusions` is given); otherwise a new line is appended at the end.
199
+ * @returns {{ content: string, existed: boolean }}
200
+ */
201
+ function addRouteRule(content, { pattern, target, exclusions }) {
202
+ const updated = updateRouteRule(content, { pattern, target, exclusions })
203
+ if (updated.found) return { content: updated.content, existed: true }
204
+ if (startsAsComment(pattern)) throw new RuleNewRuleCommentError()
205
+
206
+ const line = formatLine('', [pattern], exclusions || [], target)
207
+ if (content === '' || content === BOM) return { content: content + line, existed: false }
208
+ const eol = content.includes('\r\n') ? '\r\n' : '\n'
209
+ if (/\r?\n$/.test(content)) return { content: `${content}${line}${eol}`, existed: false }
210
+ return { content: `${content}${eol}${line}`, existed: false }
211
+ }
212
+
213
+ /**
214
+ * Remove every enabled occurrence of `pattern`. Other patterns on the same line
215
+ * stay; a line left without patterns is removed with its line ending.
216
+ * @returns {{ content: string, found: boolean }}
217
+ */
218
+ function deleteRouteRule(content, { pattern }) {
219
+ return withBom(content, (text) => deleteLines(text, { pattern }))
220
+ }
221
+
222
+ function deleteLines(content, { pattern }) {
223
+ const key = patternKey(pattern)
224
+ const segments = splitLines(content)
225
+ const out = []
226
+ let found = false
227
+
228
+ for (let s = 0; s < segments.length; s += 2) {
229
+ const line = segments[s]
230
+ const eol = segments[s + 1]
231
+ const parsed = parseRuleLine(line)
232
+ if (!parsed || !parsed.patterns.some((p) => patternKey(p) === key)) {
233
+ out.push(line)
234
+ if (eol !== undefined) out.push(eol)
235
+ continue
236
+ }
237
+ found = true
238
+ if (parsed.patterns.every((p) => patternKey(p) === key)) {
239
+ // Drop the whole line. For a last line without a line ending, drop the
240
+ // previous line's ending instead so no dangling empty line is left behind.
241
+ if (eol === undefined && out.length > 0) out.pop()
242
+ continue
243
+ }
244
+ if (startsAsComment(parsed.patterns.find((p) => patternKey(p) !== key))) throw new RuleLineSplitError()
245
+ out.push(removePatternTokens(line, new Set([key])))
246
+ if (eol !== undefined) out.push(eol)
247
+ }
248
+
249
+ return { content: out.join(''), found }
250
+ }
251
+
252
+ module.exports = {
253
+ SPLIT_UNSAFE_MESSAGE,
254
+ RuleLineSplitError,
255
+ NEW_RULE_COMMENT_MESSAGE,
256
+ RuleNewRuleCommentError,
257
+ patternKey,
258
+ parseRuleLine,
259
+ splitPatternArg,
260
+ addRouteRule,
261
+ updateRouteRule,
262
+ deleteRouteRule,
263
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Issue #137: read-only rulesets (rule files).
3
+ *
4
+ * The lock lives in settings.json (`readonlyRuleFiles`) and can only be set or
5
+ * cleared from the Meddle web UI. The CLI has no lock / unlock command; it only
6
+ * reports the state (`readonly` in --json output) and refuses writes.
7
+ */
8
+
9
+ const chalk = require('chalk')
10
+
11
+ const RULESET_READONLY = 'RULESET_READONLY'
12
+ const RULESET_READONLY_MESSAGE = '该规则集已设为只读,只能在 Meddle 界面中解锁'
13
+ /** Dedicated CLI exit code for a write rejected because the ruleset is read-only. */
14
+ const EXIT_RULESET_READONLY = 3
15
+
16
+ class RulesetReadonlyError extends Error {
17
+ constructor(ruleFile) {
18
+ super(RULESET_READONLY_MESSAGE)
19
+ this.name = 'RulesetReadonlyError'
20
+ this.code = RULESET_READONLY
21
+ this.ruleFile = ruleFile
22
+ }
23
+ }
24
+
25
+ function isRulesetReadonlyError(err) {
26
+ return Boolean(err) && err.code === RULESET_READONLY
27
+ }
28
+
29
+ /**
30
+ * Print the rejection and exit with EXIT_RULESET_READONLY.
31
+ * --json: one JSON line on stdout; the human message always goes to stderr.
32
+ */
33
+ function exitRulesetReadonly(err, { json = false, ruleFile } = {}) {
34
+ const file = (err && err.ruleFile) || ruleFile || null
35
+ if (json) {
36
+ console.log(JSON.stringify({
37
+ success: false,
38
+ error: { code: RULESET_READONLY, message: RULESET_READONLY_MESSAGE, ruleFile: file },
39
+ }))
40
+ }
41
+ console.error(chalk.red(' ✗'), `${RULESET_READONLY_MESSAGE} (${RULESET_READONLY}${file ? `: ${file}` : ''})`)
42
+ process.exit(EXIT_RULESET_READONLY)
43
+ }
44
+
45
+ /** Throw if any of `names` is in the given rule-file list with readonly: true. */
46
+ function assertListedFilesWritable(files, names) {
47
+ const locked = new Set((Array.isArray(files) ? files : []).filter((f) => f && f.readonly).map((f) => f.name))
48
+ for (const name of names) {
49
+ if (locked.has(name)) throw new RulesetReadonlyError(name)
50
+ }
51
+ }
52
+
53
+ module.exports = {
54
+ RULESET_READONLY,
55
+ RULESET_READONLY_MESSAGE,
56
+ EXIT_RULESET_READONLY,
57
+ RulesetReadonlyError,
58
+ isRulesetReadonlyError,
59
+ exitRulesetReadonly,
60
+ assertListedFilesWritable,
61
+ }
@@ -27,6 +27,58 @@ const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000
27
27
  const DEFAULT_TIMEOUT_MS = 5000
28
28
  const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120 * 1000
29
29
 
30
+ /**
31
+ * Resolve a GitHub token for private release downloads / latest redirects.
32
+ * Precedence: explicit argument → GH_TOKEN → GITHUB_TOKEN.
33
+ * @param {string} [explicit]
34
+ * @returns {string}
35
+ */
36
+ function resolveGithubToken(explicit) {
37
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
38
+ const gh = process.env.GH_TOKEN
39
+ if (typeof gh === 'string' && gh.trim()) return gh.trim()
40
+ const github = process.env.GITHUB_TOKEN
41
+ if (typeof github === 'string' && github.trim()) return github.trim()
42
+ return ''
43
+ }
44
+
45
+ /**
46
+ * @param {string} token
47
+ * @returns {Record<string, string>}
48
+ */
49
+ function githubAuthHeaders(token) {
50
+ if (!token) return {}
51
+ // GitHub accepts Bearer for PATs / fine-grained tokens on release asset URLs.
52
+ return { Authorization: `Bearer ${token}` }
53
+ }
54
+
55
+ function privateReleaseAuthHint() {
56
+ return (
57
+ 'Private GitHub releases require GH_TOKEN or GITHUB_TOKEN '
58
+ + '(scope: repo, or fine-grained contents:read). '
59
+ + `Or: gh release download -R ${REPO} -p 'meddle-*'`
60
+ )
61
+ }
62
+
63
+ /**
64
+ * @param {'checksum' | 'download'} kind
65
+ * @param {number} status
66
+ * @param {boolean} hadToken
67
+ * @returns {string}
68
+ */
69
+ function formatDownloadStatusError(kind, status, hadToken) {
70
+ const base = kind === 'checksum'
71
+ ? `checksum sidecar missing (${status})`
72
+ : `download failed (${status})`
73
+ if ((status === 404 || status === 401 || status === 403) && !hadToken) {
74
+ return `${base}. ${privateReleaseAuthHint()}`
75
+ }
76
+ if ((status === 401 || status === 403) && hadToken) {
77
+ return `${base}. Token may lack repo/contents:read access to ${REPO}`
78
+ }
79
+ return base
80
+ }
81
+
30
82
  const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
31
83
 
32
84
  /**
@@ -120,12 +172,14 @@ async function getLatestVersionNpm({ fetchImpl, registryUrl, timeoutMs, distTag
120
172
  * @param {{ fetchImpl?: Function, latestUrl?: string, timeoutMs?: number }} opts
121
173
  * @returns {Promise<string>}
122
174
  */
123
- async function getLatestVersionGithub({ fetchImpl, latestUrl, timeoutMs } = {}) {
175
+ async function getLatestVersionGithub({ fetchImpl, latestUrl, timeoutMs, githubToken } = {}) {
124
176
  const doFetch = fetchImpl || fetch
125
177
  const url = latestUrl || process.env.MEDDLE_GITHUB_LATEST_URL || DEFAULT_GITHUB_LATEST_URL
178
+ const token = resolveGithubToken(githubToken)
126
179
  const response = await doFetch(url, {
127
180
  redirect: 'manual',
128
181
  signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS),
182
+ headers: githubAuthHeaders(token),
129
183
  })
130
184
  if (response.status < 300 || response.status >= 400) {
131
185
  throw new Error(`GitHub latest release did not redirect (${response.status})`)
@@ -287,7 +341,7 @@ function sha256Hex(buffer) {
287
341
  * baseUrl?: string, fetchImpl?: Function, timeoutMs?: number,
288
342
  * retries?: number, retryDelayMs?: number,
289
343
  * onProgress?: Function, onAttempt?: Function,
290
- * fsImpl?: object }} opts
344
+ * githubToken?: string, fsImpl?: object }} opts
291
345
  * @returns {Promise<{ installed: string, backup: string, version: string }>}
292
346
  */
293
347
  async function downloadBinaryAsset(opts) {
@@ -300,6 +354,8 @@ async function downloadBinaryAsset(opts) {
300
354
  const retryDelayMs = opts.retryDelayMs === undefined ? 1000 : opts.retryDelayMs
301
355
  const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {}
302
356
  const onAttempt = typeof opts.onAttempt === 'function' ? opts.onAttempt : () => {}
357
+ const token = resolveGithubToken(opts.githubToken)
358
+ const authHeaders = githubAuthHeaders(token)
303
359
 
304
360
  const asset = getAssetName({ platform, arch })
305
361
  const assetUrl = `${baseUrl}/v${version}/${asset}`
@@ -327,8 +383,8 @@ async function downloadBinaryAsset(opts) {
327
383
  }
328
384
  resetIdle()
329
385
  try {
330
- const response = await doFetch(assetUrl, { signal: controller.signal })
331
- if (!response.ok) throw new Error(`download failed (${response.status})`)
386
+ const response = await doFetch(assetUrl, { signal: controller.signal, headers: authHeaders })
387
+ if (!response.ok) throw new Error(formatDownloadStatusError('download', response.status, Boolean(token)))
332
388
  const total = Number(response.headers.get('content-length')) || 0
333
389
  const chunks = []
334
390
  let received = 0
@@ -365,8 +421,11 @@ async function downloadBinaryAsset(opts) {
365
421
  // download — otherwise the sidecar body stream aborts before it is read
366
422
  // and the whole install fails with "operation aborted" right after the
367
423
  // payload reached 100%.
368
- const shaResponse = await doFetch(shaUrl, { signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) })
369
- if (!shaResponse.ok) throw new Error(`checksum sidecar missing (${shaResponse.status})`)
424
+ const shaResponse = await doFetch(shaUrl, {
425
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
426
+ headers: authHeaders,
427
+ })
428
+ if (!shaResponse.ok) throw new Error(formatDownloadStatusError('checksum', shaResponse.status, Boolean(token)))
370
429
  const shaText = await shaResponse.text()
371
430
 
372
431
  const payload = await fetchPayload()
@@ -559,5 +618,7 @@ module.exports = {
559
618
  setAutoUpdate,
560
619
  resolveUpdateBinDir,
561
620
  runAsyncUpdateCheck,
621
+ resolveGithubToken,
622
+ githubAuthHeaders,
562
623
  DEFAULT_DOWNLOAD_TIMEOUT_MS,
563
624
  }