@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
@@ -36,15 +36,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.MOCKS_MISSING_RULES_MESSAGE = exports.MocksFileInvalidError = void 0;
39
40
  exports.dedupeMockRulesById = dedupeMockRulesById;
41
+ exports.formatMocksParseError = formatMocksParseError;
42
+ exports.mocksDocumentShapeError = mocksDocumentShapeError;
43
+ exports.parseMocksFileText = parseMocksFileText;
44
+ exports.findJsonErrorOffset = findJsonErrorOffset;
45
+ exports.resolveMocksWriteTarget = resolveMocksWriteTarget;
46
+ exports.resolveConfiguredMocksPath = resolveConfiguredMocksPath;
47
+ exports.detectLegacyMocksPath = detectLegacyMocksPath;
48
+ exports.formatLegacyMocksNotice = formatLegacyMocksNotice;
49
+ exports.readConfiguredMocksPath = readConfiguredMocksPath;
40
50
  exports.createMockHandler = createMockHandler;
41
51
  const fs = __importStar(require("fs"));
42
52
  const path = __importStar(require("path"));
53
+ const os = __importStar(require("os"));
54
+ const chokidar_1 = __importDefault(require("chokidar"));
43
55
  const debug_1 = __importDefault(require("debug"));
44
56
  const map_local_1 = require("./map-local");
45
57
  const proxy_record_1 = require("./proxy-record");
46
58
  const client_identity_1 = require("./client-identity");
47
59
  const body_utils_1 = require("./body-utils");
60
+ const mock_utils_1 = require("./mock-utils");
48
61
  const proxyDebug = (0, debug_1.default)('proxy');
49
62
  /** 按 id 去重,后出现的条目覆盖先前的(与前端 use-mocks 行为一致) */
50
63
  function dedupeMockRulesById(rules) {
@@ -56,13 +69,274 @@ function dedupeMockRulesById(rules) {
56
69
  }
57
70
  return Array.from(byId.values()).sort((a, b) => a.id - b.id);
58
71
  }
72
+ /** Thrown when a write would overwrite a mocks file that failed to parse (issue #120). */
73
+ class MocksFileInvalidError extends Error {
74
+ constructor(filePath, parseError) {
75
+ super(formatMocksParseError(filePath, parseError));
76
+ this.filePath = filePath;
77
+ this.parseError = parseError;
78
+ this.code = 'MOCKS_FILE_INVALID';
79
+ this.name = 'MocksFileInvalidError';
80
+ }
81
+ }
82
+ exports.MocksFileInvalidError = MocksFileInvalidError;
83
+ function formatMocksParseError(filePath, err) {
84
+ const loc = err.line != null ? `第 ${err.line} 行第 ${err.column} 列:` : '';
85
+ return `Mock 文件格式错误,已禁止写入以免覆盖: ${filePath} ${loc}${err.message}`;
86
+ }
87
+ /** Valid mocks.json is an object with a `rules` array. Empty / whitespace files skip this. */
88
+ exports.MOCKS_MISSING_RULES_MESSAGE = '缺少 rules 数组';
89
+ function mocksDocumentShapeError(data) {
90
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.rules)) {
91
+ return exports.MOCKS_MISSING_RULES_MESSAGE;
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * Parse mocks file text. Issue #120: empty / whitespace-only text is an empty
97
+ * rule list (not an error); anything else that fails JSON.parse returns a
98
+ * parse error with 1-based line/column. Issue #160: JSON that parses but is
99
+ * not `{ rules: [...] }` is the same class of error (do not treat as []).
100
+ */
101
+ function parseMocksFileText(text) {
102
+ if (text.replace(/^\uFEFF/, '').trim() === '')
103
+ return { data: {}, error: null };
104
+ try {
105
+ const data = JSON.parse(text.replace(/^\uFEFF/, ''));
106
+ const shape = mocksDocumentShapeError(data);
107
+ if (shape)
108
+ return { data: null, error: { line: null, column: null, message: shape } };
109
+ return { data, error: null };
110
+ }
111
+ catch (e) {
112
+ const message = String(e?.message || e);
113
+ let line = null;
114
+ let column = null;
115
+ const body = text.replace(/^\uFEFF/, '');
116
+ const lc = /\(line (\d+) column (\d+)\)/.exec(message);
117
+ const pos = /at position (\d+)/.exec(message);
118
+ const offset = lc ? null : pos ? Number(pos[1]) : findJsonErrorOffset(body);
119
+ if (lc) {
120
+ line = Number(lc[1]);
121
+ column = Number(lc[2]);
122
+ }
123
+ else {
124
+ const before = body.slice(0, offset ?? body.length).split('\n');
125
+ line = before.length;
126
+ column = before[before.length - 1].length + 1;
127
+ }
128
+ // Drop V8's quoted source snippet and the "(line x column y)" suffix; position is reported separately.
129
+ const short = message.replace(/\s*\(line \d+ column \d+\)/, '').split(/,\s*(?:\.\.\.)?"/s)[0].replace(/\s+in JSON at position \d+/, '').trim();
130
+ return { data: null, error: { line, column, message: short } };
131
+ }
132
+ }
133
+ /**
134
+ * Offset of the first JSON syntax error (V8 does not always report a
135
+ * position, e.g. "Unexpected token ']', ... is not valid JSON").
136
+ */
137
+ function findJsonErrorOffset(t) {
138
+ let i = 0;
139
+ const ws = () => { while (i < t.length && ' \t\n\r'.includes(t[i]))
140
+ i++; };
141
+ const fail = () => { throw i; };
142
+ const str = () => {
143
+ i++;
144
+ while (i < t.length && t[i] !== '"') {
145
+ if (t[i] === '\\')
146
+ i++;
147
+ else if (t.charCodeAt(i) < 0x20)
148
+ fail();
149
+ i++;
150
+ }
151
+ if (i >= t.length)
152
+ fail();
153
+ i++;
154
+ };
155
+ const value = () => {
156
+ ws();
157
+ const c = t[i];
158
+ if (c === '{') {
159
+ i++;
160
+ ws();
161
+ if (t[i] === '}') {
162
+ i++;
163
+ return;
164
+ }
165
+ for (;;) {
166
+ ws();
167
+ if (t[i] !== '"')
168
+ fail();
169
+ str();
170
+ ws();
171
+ if (t[i] !== ':')
172
+ fail();
173
+ i++;
174
+ value();
175
+ ws();
176
+ if (t[i] === ',') {
177
+ i++;
178
+ continue;
179
+ }
180
+ if (t[i] === '}') {
181
+ i++;
182
+ return;
183
+ }
184
+ fail();
185
+ }
186
+ }
187
+ else if (c === '[') {
188
+ i++;
189
+ ws();
190
+ if (t[i] === ']') {
191
+ i++;
192
+ return;
193
+ }
194
+ for (;;) {
195
+ value();
196
+ ws();
197
+ if (t[i] === ',') {
198
+ i++;
199
+ continue;
200
+ }
201
+ if (t[i] === ']') {
202
+ i++;
203
+ return;
204
+ }
205
+ fail();
206
+ }
207
+ }
208
+ else if (c === '"')
209
+ str();
210
+ else if (/[-0-9]/.test(c || '')) {
211
+ const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(t.slice(i));
212
+ if (!m)
213
+ fail();
214
+ i += m[0].length;
215
+ }
216
+ else {
217
+ for (const w of ['true', 'false', 'null'])
218
+ if (t.startsWith(w, i)) {
219
+ i += w.length;
220
+ return;
221
+ }
222
+ fail();
223
+ }
224
+ };
225
+ try {
226
+ value();
227
+ ws();
228
+ return i < t.length ? i : t.length;
229
+ }
230
+ catch (at) {
231
+ return typeof at === 'number' ? Math.min(at, t.length) : t.length;
232
+ }
233
+ }
234
+ /**
235
+ * Issue #123: the real file a write to `file` must replace. Symlinks are
236
+ * followed so the link itself is never replaced. A dangling link (target
237
+ * missing) creates the target at the link destination when its directory
238
+ * exists, otherwise throws a clear error (the link is left untouched).
239
+ */
240
+ /** Follow a symlink chain lexically (works for dangling links). */
241
+ function followSymlinkChain(file) {
242
+ let cur = path.resolve(file);
243
+ for (let hops = 0; hops < 40; hops++) {
244
+ let st;
245
+ try {
246
+ st = fs.lstatSync(cur);
247
+ }
248
+ catch (_) {
249
+ break;
250
+ }
251
+ if (!st.isSymbolicLink())
252
+ break;
253
+ cur = path.resolve(path.dirname(cur), fs.readlinkSync(cur));
254
+ }
255
+ return cur;
256
+ }
257
+ function resolveMocksWriteTarget(file) {
258
+ try {
259
+ return fs.realpathSync(file);
260
+ }
261
+ catch (_) { /* missing or dangling */ }
262
+ const cur = followSymlinkChain(file);
263
+ if (cur !== path.resolve(file)) {
264
+ const parent = path.dirname(cur);
265
+ let parentReal;
266
+ try {
267
+ parentReal = fs.realpathSync(parent);
268
+ }
269
+ catch (_) {
270
+ throw new Error(`Mock 文件是指向不存在位置的符号链接,无法写入: ${file} -> ${cur}(目录 ${parent} 不存在)`);
271
+ }
272
+ return path.join(parentReal, path.basename(cur));
273
+ }
274
+ return cur;
275
+ }
276
+ /**
277
+ * Issue #121: turn the configured `mocksFilePath` into an absolute path.
278
+ * `~` / `~/…` expand to the user's home directory; a relative path is
279
+ * resolved against the meddle directory (MEDDLE_HOME), not the directory the
280
+ * proxy happened to be started from.
281
+ */
282
+ function resolveConfiguredMocksPath(raw, meddleDir, homeDir = os.homedir()) {
283
+ const v = typeof raw === 'string' ? raw.trim() : '';
284
+ if (!v)
285
+ return null;
286
+ let p = v;
287
+ if (p === '~')
288
+ p = homeDir;
289
+ else if (p.startsWith('~/') || p.startsWith('~\\'))
290
+ p = path.join(homeDir, p.slice(2));
291
+ return path.resolve(meddleDir, p);
292
+ }
293
+ /**
294
+ * Issue #121 migration notice: a relative `mocksFilePath` used to resolve
295
+ * against the proxy's start directory. If the file exists there but not
296
+ * under the new rule (MEDDLE_HOME), report both paths so the user can switch
297
+ * to the old absolute path. Never migrates or rewrites settings.
298
+ */
299
+ function detectLegacyMocksPath(raw, meddleDir, cwd = process.cwd(), homeDir = os.homedir()) {
300
+ const v = typeof raw === 'string' ? raw.trim() : '';
301
+ if (!v || path.isAbsolute(v) || v === '~' || v.startsWith('~/') || v.startsWith('~\\'))
302
+ return null;
303
+ const newPath = resolveConfiguredMocksPath(v, meddleDir, homeDir);
304
+ const oldPath = path.resolve(cwd, v);
305
+ if (!newPath || oldPath === newPath)
306
+ return null;
307
+ const isFile = (p) => { try {
308
+ return fs.statSync(p).isFile();
309
+ }
310
+ catch (_) {
311
+ return false;
312
+ } };
313
+ if (!isFile(oldPath) || isFile(newPath))
314
+ return null;
315
+ return { configured: v, oldPath, newPath };
316
+ }
317
+ function formatLegacyMocksNotice(l) {
318
+ return `Mock 文件路径「${l.configured}」现在相对于 Meddle 配置目录解析为 ${l.newPath}(不存在);` +
319
+ `按旧规则(相对于启动目录)找到的文件是 ${l.oldPath}。如需继续使用它,请把路径改为该绝对路径。`;
320
+ }
321
+ /** Read the raw (unresolved) mocksFilePath from settings.json. */
322
+ function readConfiguredMocksPath(settingsPath) {
323
+ try {
324
+ if (!fs.existsSync(settingsPath))
325
+ return null;
326
+ const v = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).mocksFilePath;
327
+ return typeof v === 'string' ? v : null;
328
+ }
329
+ catch (_) {
330
+ return null;
331
+ }
332
+ }
59
333
  function createMockHandler(ctx) {
60
334
  const DEFAULT_MOCK_FILE = path.join(ctx.meddleDir, 'mocks.json');
61
335
  function loadCustomPathsFromSettings() {
62
336
  try {
63
337
  if (fs.existsSync(ctx.settingsPath)) {
64
338
  const settings = JSON.parse(fs.readFileSync(ctx.settingsPath, 'utf8'));
65
- return { mocksFilePath: settings.mocksFilePath || null };
339
+ return { mocksFilePath: resolveConfiguredMocksPath(settings.mocksFilePath, ctx.meddleDir) };
66
340
  }
67
341
  }
68
342
  catch (error) {
@@ -70,45 +344,165 @@ function createMockHandler(ctx) {
70
344
  }
71
345
  return { mocksFilePath: null };
72
346
  }
347
+ /**
348
+ * Effective mocks file. Issue #118: a configured `mocksFilePath` is honoured
349
+ * even when the file does not exist yet (same as the CLI file mode) — the
350
+ * proxy starts with an empty rule list and the file is created on the first
351
+ * save. Previously a missing custom file silently fell back to the default
352
+ * file, so "switching" to a new file kept reading/writing the old one.
353
+ */
73
354
  function getMockFilePath() {
74
355
  if (ctx.currentMocksPath)
75
356
  return ctx.currentMocksPath;
76
357
  const customPaths = loadCustomPathsFromSettings();
77
- if (customPaths.mocksFilePath && fs.existsSync(customPaths.mocksFilePath)) {
78
- ctx.currentMocksPath = customPaths.mocksFilePath;
79
- return ctx.currentMocksPath;
358
+ ctx.currentMocksPath = customPaths.mocksFilePath || DEFAULT_MOCK_FILE;
359
+ return ctx.currentMocksPath;
360
+ }
361
+ /** Path whose contents are currently in ctx.mockRules (null before first load). */
362
+ let loadedMocksPath = null;
363
+ /** Issue #120: parse failure of the loaded file; while set, every write is refused. */
364
+ let parseError = null;
365
+ function getMocksParseError() { return parseError; }
366
+ /**
367
+ * Issue #123 (CI flake root cause): chokidar does not report writes that
368
+ * land while it is still setting up (ignoreInitial). Remember what was
369
+ * loaded so the watcher can re-check once it is ready.
370
+ */
371
+ let loadedSig = null;
372
+ function fileSig(file) {
373
+ try {
374
+ const st = fs.statSync(file);
375
+ return `${st.ino}:${st.size}:${st.mtimeMs}`;
376
+ }
377
+ catch (_) {
378
+ return 'missing';
80
379
  }
81
- ctx.currentMocksPath = DEFAULT_MOCK_FILE;
82
- return DEFAULT_MOCK_FILE;
83
380
  }
84
381
  function loadMockRules() {
382
+ const mockFile = getMockFilePath();
383
+ // Issue #118: switching to another file must not inherit the old
384
+ // file's id counter or rules; re-reading the same file keeps the
385
+ // counter monotonic (issue #115).
386
+ const switched = loadedMocksPath !== null && loadedMocksPath !== mockFile;
387
+ const firstLoadOfThisFile = loadedMocksPath !== mockFile;
388
+ if (switched) {
389
+ proxyDebug(`Mock 文件切换: ${loadedMocksPath} -> ${mockFile}`);
390
+ ctx.mockIdSeq = 1;
391
+ }
392
+ loadedMocksPath = mockFile;
393
+ if (watcherState && watcherState.file !== mockFile)
394
+ startWatcher(mockFile);
85
395
  try {
86
- const mockFile = getMockFilePath();
87
396
  if (fs.existsSync(mockFile)) {
88
- const data = JSON.parse(fs.readFileSync(mockFile, 'utf8'));
397
+ loadedSig = fileSig(mockFile);
398
+ const parsed = parseMocksFileText(fs.readFileSync(mockFile, 'utf8'));
399
+ if (parsed.error) {
400
+ // Issue #120: never treat a broken file as empty-and-writable.
401
+ const wasSame = parseError && parseError.message === parsed.error.message && parseError.line === parsed.error.line;
402
+ parseError = parsed.error;
403
+ ctx.mockRules = [];
404
+ if (!wasSame)
405
+ console.error(formatMocksParseError(mockFile, parsed.error));
406
+ return;
407
+ }
408
+ if (parseError)
409
+ console.log(`Mock 文件已修复,恢复正常: ${mockFile}`);
410
+ parseError = null;
411
+ const data = parsed.data || {};
89
412
  const loaded = Array.isArray(data.rules) ? data.rules : [];
90
- ctx.mockRules = dedupeMockRulesById(loaded);
91
- if (loaded.length !== ctx.mockRules.length) {
92
- proxyDebug(`Mock 规则去重: ${loaded.length} -> ${ctx.mockRules.length}`);
413
+ // Issue #115: never drop rules that share an id — give the
414
+ // later duplicates fresh ids instead (no silent data loss).
415
+ const { rules, reassigned } = (0, mock_utils_1.ensureUniqueMockRuleIds)(loaded);
416
+ ctx.mockRules = rules;
417
+ if (reassigned > 0) {
418
+ console.warn(`Mock 规则 id 重复或缺失,已为 ${reassigned} 条规则重新分配 id (${mockFile})`);
93
419
  }
94
- ctx.mockIdSeq = (data.nextId || Math.max(0, ...ctx.mockRules.map(r => r.id || 0))) + 1;
420
+ // nextId is the next id to hand out; a stale/missing value must
421
+ // never collide with ids already on disk (issue #115).
422
+ ctx.mockIdSeq = (0, mock_utils_1.nextMockRuleId)(ctx.mockRules, Math.max(Number(data.nextId) || 0, (Number(data.lastId) || 0) + 1, ctx.mockIdSeq || 0));
95
423
  proxyDebug(`已加载 ${ctx.mockRules.length} 条 Mock 规则 (${mockFile})`);
96
424
  }
425
+ else {
426
+ // File removed / never created — clear in-memory rules (watcher
427
+ // unlink, or a configured path that does not exist yet). The
428
+ // file is created on the first save; nothing else is touched.
429
+ ctx.mockRules = [];
430
+ parseError = null;
431
+ loadedSig = 'missing';
432
+ if (firstLoadOfThisFile && mockFile !== DEFAULT_MOCK_FILE) {
433
+ console.warn(`Mock 规则文件不存在,将以空列表开始,首次保存时创建: ${mockFile}`);
434
+ }
435
+ proxyDebug(`Mock 规则文件不存在,已清空内存规则 (${mockFile})`);
436
+ }
97
437
  }
98
438
  catch (err) {
99
439
  console.error('加载 mock 规则失败:', err.message);
100
440
  ctx.mockRules = [];
101
441
  }
102
442
  }
443
+ function assertMocksWritable() {
444
+ const mockFile = getMockFilePath();
445
+ // Re-validate what is on disk right before writing: the file may have
446
+ // been broken after the last load (watcher not fired yet, stale tab).
447
+ if (!parseError && fs.existsSync(mockFile)) {
448
+ try {
449
+ const parsed = parseMocksFileText(fs.readFileSync(mockFile, 'utf8'));
450
+ if (parsed.error) {
451
+ parseError = parsed.error;
452
+ console.error(formatMocksParseError(mockFile, parsed.error));
453
+ }
454
+ }
455
+ catch (_) { /* unreadable: let the write report it */ }
456
+ }
457
+ if (parseError)
458
+ throw new MocksFileInvalidError(mockFile, parseError);
459
+ }
103
460
  function saveMockRules() {
461
+ // Issue #120: single choke point for every write — refuse to overwrite a broken file.
462
+ assertMocksWritable();
104
463
  try {
105
- ctx.mockRules = dedupeMockRulesById(ctx.mockRules);
464
+ // Issue #115: keep every rule (re-id duplicates instead of dropping
465
+ // them) and persist a sequence that is above every stored id.
466
+ ctx.mockRules = (0, mock_utils_1.ensureUniqueMockRuleIds)(ctx.mockRules).rules;
467
+ ctx.mockIdSeq = (0, mock_utils_1.nextMockRuleId)(ctx.mockRules, ctx.mockIdSeq);
106
468
  const mockFile = getMockFilePath();
107
- fs.writeFileSync(mockFile, JSON.stringify({ nextId: ctx.mockIdSeq, rules: ctx.mockRules }, null, 2), 'utf8');
469
+ const dir = path.dirname(mockFile);
470
+ if (!fs.existsSync(dir))
471
+ fs.mkdirSync(dir, { recursive: true });
472
+ // Issue #123: write next to the real file so a symlinked mocks file
473
+ // (e.g. into a dotfiles repo) stays a symlink, and keep its mode/owner.
474
+ const target = resolveMocksWriteTarget(mockFile);
475
+ let existing = null;
476
+ try {
477
+ existing = fs.statSync(target);
478
+ }
479
+ catch (_) { /* new file */ }
480
+ // Write to a temp file then rename, so a crash never leaves half a file (issue #120).
481
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
482
+ fs.writeFileSync(tmp, JSON.stringify({ nextId: ctx.mockIdSeq, rules: ctx.mockRules }, null, 2), { encoding: 'utf8', mode: existing ? existing.mode & 0o7777 : 0o666 });
483
+ try {
484
+ if (existing) {
485
+ fs.chmodSync(tmp, existing.mode & 0o7777);
486
+ try {
487
+ fs.chownSync(tmp, existing.uid, existing.gid);
488
+ }
489
+ catch (_) { /* not permitted: best effort */ }
490
+ }
491
+ fs.renameSync(tmp, target);
492
+ loadedSig = fileSig(mockFile);
493
+ }
494
+ catch (e) {
495
+ try {
496
+ fs.unlinkSync(tmp);
497
+ }
498
+ catch (_) { /* ignore */ }
499
+ throw e;
500
+ }
108
501
  proxyDebug(`Mock 规则已保存到 ${mockFile}`);
109
502
  }
110
503
  catch (err) {
111
504
  console.error('保存 mock 规则失败:', err.message);
505
+ throw err;
112
506
  }
113
507
  }
114
508
  function matchMockRule(url, method) {
@@ -117,25 +511,23 @@ function createMockHandler(ctx) {
117
511
  return false;
118
512
  if (rule.method && rule.method !== '*' && rule.method.toUpperCase() !== method.toUpperCase())
119
513
  return false;
120
- try {
121
- return new RegExp(rule.urlPattern).test(url);
122
- }
123
- catch {
124
- return url.includes(rule.urlPattern);
125
- }
514
+ if (!(0, mock_utils_1.matchUrlPattern)(url, rule.urlPattern))
515
+ return false;
516
+ if (!(0, mock_utils_1.matchQueryCondition)(url, rule.query))
517
+ return false;
518
+ return true;
126
519
  }) || null;
127
520
  }
128
- function buildMockResponseForTest(rule) {
521
+ function buildMockResponseForTest(rule, requestHeaders) {
129
522
  const statusCode = rule.statusCode || 200;
130
523
  const ruleHeaders = {};
131
524
  for (const [key, value] of Object.entries(rule.headers || {})) {
132
525
  ruleHeaders[key.toLowerCase()] = value;
133
526
  }
134
- const headers = {
527
+ const headers = (0, mock_utils_1.resolveHeaderPlaceholders)((0, mock_utils_1.mergeResponseHeaders)({
135
528
  'x-mock-rule': encodeURIComponent(rule.name || rule.id?.toString() || rule.urlPattern || ''),
136
- 'content-type': ruleHeaders['content-type'] || 'application/json',
137
- ...ruleHeaders,
138
- };
529
+ 'content-type': 'application/json',
530
+ }, ruleHeaders), requestHeaders);
139
531
  let body = '';
140
532
  if (rule.bodyType === 'file' && rule.body) {
141
533
  let filePath = rule.body.replace(/^file:\/\//, '').replace(/^\/[A-Za-z]:\//, '');
@@ -171,13 +563,12 @@ function createMockHandler(ctx) {
171
563
  for (const [key, value] of Object.entries(rule.headers || {})) {
172
564
  ruleHeaders[key.toLowerCase()] = value;
173
565
  }
174
- const responseHeaders = {
566
+ const responseHeaders = (0, mock_utils_1.resolveHeaderPlaceholders)((0, mock_utils_1.mergeResponseHeaders)({
175
567
  'X-Mock-Rule': encodeURIComponent(mockRuleName),
176
568
  'Access-Control-Allow-Origin': '*',
177
569
  'Access-Control-Allow-Methods': '*',
178
570
  'Access-Control-Allow-Headers': '*',
179
- ...ruleHeaders
180
- };
571
+ }, ruleHeaders), req.headers);
181
572
  let finalStatusCode = statusCode;
182
573
  let statusMessage = 'OK (Mock)';
183
574
  if (isFileBody) {
@@ -338,9 +729,230 @@ function createMockHandler(ctx) {
338
729
  doSend();
339
730
  }
340
731
  }
732
+ /** Active file watcher (issue #118: follows the effective mocks file). */
733
+ let watcherState = null;
734
+ let watcherOnReload;
735
+ function startWatcher(mockFile) {
736
+ if (watcherState) {
737
+ watcherState.close();
738
+ proxyDebug(`stopped watching mocks file: ${watcherState.file}`);
739
+ }
740
+ const dir = path.dirname(mockFile);
741
+ const base = path.basename(mockFile);
742
+ try {
743
+ if (!fs.existsSync(dir))
744
+ fs.mkdirSync(dir, { recursive: true });
745
+ }
746
+ catch (_) { /* ignore */ }
747
+ const debugWatch = process.env.MEDDLE_DEBUG_WATCH === '1';
748
+ const t0 = Date.now();
749
+ const dbg = (msg) => { if (debugWatch)
750
+ console.error(`[watch +${Date.now() - t0}ms] ${msg}`); };
751
+ // Issue #123: when the mocks path is a symlink, edits land on the real
752
+ // target (git pull in a dotfiles repo, fixing JSON there), so watch the
753
+ // target's directory too and accept events for either path.
754
+ const resolveTarget = () => {
755
+ try {
756
+ return resolveMocksWriteTarget(mockFile);
757
+ }
758
+ catch (_) {
759
+ // Issue #125: a dangling link whose target directory does not
760
+ // exist yet still has a destination to wait for.
761
+ return followSymlinkChain(mockFile);
762
+ }
763
+ };
764
+ let target = resolveTarget();
765
+ let targetDir = null;
766
+ // Watch the directory (not only the file) so atomic replace / recreate
767
+ // still triggers — same pattern as route-rules watcher.
768
+ const initialTargetDir = path.dirname(target);
769
+ targetDir = path.resolve(initialTargetDir) !== path.resolve(dir) && fs.existsSync(initialTargetDir) ? initialTargetDir : null;
770
+ // Both directories go into the initial watch so they are ready together.
771
+ const watcher = chokidar_1.default.watch(targetDir ? [dir, targetDir] : dir, {
772
+ ignoreInitial: true,
773
+ depth: 0,
774
+ // Follow the symlink target explicitly (below) instead of relying on
775
+ // chokidar's implicit following, which differs per platform backend.
776
+ followSymlinks: false,
777
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
778
+ });
779
+ // Catch up on a write chokidar did not report because it landed while a
780
+ // directory watch was still being set up (ignoreInitial).
781
+ const catchUp = (why) => {
782
+ if (closed || !watcherState || watcherState.file !== mockFile)
783
+ return;
784
+ recheckTargetDir(why);
785
+ if (fileSig(mockFile) !== loadedSig)
786
+ reload(why);
787
+ };
788
+ // Issue #125: the target directory may not exist yet (dangling link to a
789
+ // repo that is cloned later). Watch its nearest existing ancestor
790
+ // instead and move on to the real directory once it shows up.
791
+ // A separate watcher instance is used for the ancestor: chokidar 3's
792
+ // unwatch() adds '<dir>/**' to the ignore list, which would also
793
+ // swallow every later event for the link / target directories below it.
794
+ let pendingAncestor = null;
795
+ let ancestorWatcher = null;
796
+ const isWithin = (parent, child) => {
797
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
798
+ return rel === '' || !(rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
799
+ };
800
+ const nearestExistingAncestor = (p) => {
801
+ let cur = path.dirname(p);
802
+ while (!fs.existsSync(cur) && path.dirname(cur) !== cur)
803
+ cur = path.dirname(cur);
804
+ return cur;
805
+ };
806
+ const stopAncestorWatch = () => {
807
+ if (ancestorWatcher)
808
+ ancestorWatcher.close().catch(() => { });
809
+ ancestorWatcher = null;
810
+ pendingAncestor = null;
811
+ };
812
+ const watchTargetDir = (force = false) => {
813
+ const want = path.dirname(target);
814
+ const next = path.resolve(want) === path.resolve(dir) ? null : want;
815
+ const exists = !!next && fs.existsSync(next);
816
+ if (!force && next === targetDir && (exists || !next) && !pendingAncestor)
817
+ return;
818
+ stopAncestorWatch();
819
+ if (targetDir && (targetDir !== next || !exists))
820
+ watcher.unwatch(targetDir);
821
+ targetDir = next;
822
+ if (targetDir && exists) {
823
+ watcher.add(targetDir);
824
+ // chokidar emits no 'ready' for add(); re-check a few times
825
+ // while the new directory watch comes up.
826
+ for (const ms of [100, 300, 1000]) {
827
+ setTimeout(() => catchUp('changed while target watch was starting'), ms).unref?.();
828
+ }
829
+ }
830
+ else if (targetDir) {
831
+ // Issue #125: the target directory does not exist (yet, or any
832
+ // more): wait for it in its nearest existing ancestor.
833
+ const ancestor = nearestExistingAncestor(targetDir);
834
+ pendingAncestor = ancestor;
835
+ const aw = chokidar_1.default.watch(ancestor, { ignoreInitial: true, depth: 0, followSymlinks: false });
836
+ aw.on('addDir', (p) => {
837
+ if (closed || ancestorWatcher !== aw || !targetDir)
838
+ return;
839
+ if (isWithin(p, targetDir))
840
+ watchTargetDir(true);
841
+ });
842
+ // Issue #141: the ancestor itself can vanish too — `rm -rf repo`
843
+ // removes repo/dotfiles first, and if that unlinkDir is handled
844
+ // before repo is gone, the ancestor picked here is repo, which a
845
+ // moment later no longer exists: a watch on a deleted directory
846
+ // never fires again. The directory may also have appeared while
847
+ // this watch was starting. Re-validate on ready and on any raw
848
+ // event (inotify reports the watched directory's own removal).
849
+ const recheck = (why) => { if (!closed && ancestorWatcher === aw)
850
+ recheckTargetDir(why); };
851
+ aw.on('ready', () => { dbg(`ancestor watch ready: ${ancestor}`); recheck('ancestor watch ready'); });
852
+ aw.on('unlinkDir', () => recheck('ancestor directory removed'));
853
+ aw.on('raw', () => recheck('ancestor raw event'));
854
+ aw.on('error', () => recheck('ancestor watch error'));
855
+ ancestorWatcher = aw;
856
+ }
857
+ dbg(`target ${target}; extra dir ${targetDir ?? '(none)'}${pendingAncestor ? ` (missing; waiting in ${pendingAncestor})` : ''}`);
858
+ };
859
+ /**
860
+ * Issue #141: make sure the watch plan still matches the disk. chokidar
861
+ * does not report every directory removal: a target directory removed
862
+ * while the watch is still starting yields no unlinkDir, and an
863
+ * ancestor removed right after it was picked leaves a dead watch.
864
+ * Either way the recreated directory would never be noticed again.
865
+ */
866
+ const recheckTargetDir = (why) => {
867
+ if (closed || !targetDir)
868
+ return;
869
+ const stale = pendingAncestor
870
+ ? fs.existsSync(targetDir) || nearestExistingAncestor(targetDir) !== pendingAncestor
871
+ : !fs.existsSync(targetDir);
872
+ if (!stale)
873
+ return;
874
+ dbg(`re-planning target watch (${why})`);
875
+ watchTargetDir(true);
876
+ };
877
+ watchTargetDir();
878
+ let closed = false;
879
+ let trailingCheck = null;
880
+ const reload = (event, changedPath) => {
881
+ // Ignore late events from a watcher that was replaced, and any
882
+ // file in the directory other than the active mocks file / its target.
883
+ if (closed || !watcherState || watcherState.file !== mockFile)
884
+ return;
885
+ if (changedPath) {
886
+ const p = path.resolve(changedPath);
887
+ if (p !== path.resolve(mockFile) && p !== target)
888
+ return;
889
+ }
890
+ // The link itself may have been retargeted: follow the new target.
891
+ const nextTarget = resolveTarget();
892
+ if (nextTarget !== target) {
893
+ target = nextTarget;
894
+ watchTargetDir();
895
+ }
896
+ else
897
+ recheckTargetDir(event);
898
+ proxyDebug(`mocks file ${event}: ${base}`);
899
+ loadMockRules();
900
+ if (watcherOnReload)
901
+ watcherOnReload();
902
+ // Issue #116: chokidar throttles raw fs events per file on the
903
+ // leading edge (5ms, no trailing emit), so a write landing right
904
+ // after the one being handled (e.g. broken -> fixed a few ms apart,
905
+ // especially while the watcher is still starting) can be dropped.
906
+ // Re-check the file signature once the burst is over.
907
+ if (trailingCheck)
908
+ clearTimeout(trailingCheck);
909
+ trailingCheck = setTimeout(() => { trailingCheck = null; catchUp('changed during a burst of writes'); }, 150);
910
+ trailingCheck.unref?.();
911
+ };
912
+ if (debugWatch) {
913
+ watcher.on('all', (ev, p) => dbg(`${ev} ${p}`));
914
+ }
915
+ watcher.on('ready', () => {
916
+ dbg('ready');
917
+ catchUp('changed before watcher ready');
918
+ });
919
+ watcher.on('change', (p) => reload('changed', p));
920
+ watcher.on('add', (p) => reload('added', p));
921
+ watcher.on('unlink', (p) => reload('removed', p));
922
+ // Issue #125: the target directory itself was removed (repo deleted /
923
+ // re-cloned): fall back to waiting for it in its nearest ancestor.
924
+ watcher.on('unlinkDir', (p) => {
925
+ if (closed || !targetDir)
926
+ return;
927
+ if (path.resolve(p) === path.resolve(targetDir))
928
+ watchTargetDir(true);
929
+ });
930
+ watcherState = {
931
+ file: mockFile,
932
+ close: () => {
933
+ closed = true;
934
+ if (trailingCheck)
935
+ clearTimeout(trailingCheck);
936
+ stopAncestorWatch();
937
+ watcher.close().catch(() => { });
938
+ },
939
+ };
940
+ proxyDebug(`watching mocks file: ${mockFile}`);
941
+ }
942
+ /**
943
+ * Watch the mocks file for external edits (CLI file-mode / hand edits).
944
+ * Aligns with route-rules watcher so file-only writes still hot-reload (issue #85).
945
+ * When the effective mocks file changes (issue #118), loadMockRules moves
946
+ * the watcher to the new file automatically.
947
+ */
948
+ function initMockFileWatcher(onReload) {
949
+ watcherOnReload = onReload;
950
+ startWatcher(getMockFilePath());
951
+ }
341
952
  return {
342
- getMockFilePath, loadMockRules, saveMockRules, matchMockRule,
953
+ getMockFilePath, loadMockRules, saveMockRules, assertMocksWritable, getMocksParseError, matchMockRule,
343
954
  buildMockResponseForTest, sendMockResponse, loadCustomPathsFromSettings,
955
+ initMockFileWatcher,
344
956
  };
345
957
  }
346
958
  //# sourceMappingURL=mock-handler.js.map