@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
@@ -33,20 +33,35 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.INVALID_RULE_CONTENT_FIELD = exports.INVALID_RULE_CONTENT_JSON = void 0;
37
+ exports.getRulesDir = getRulesDir;
38
+ exports.resolveRuleFileOrder = resolveRuleFileOrder;
39
+ exports.syncActiveRuleFilesToOrder = syncActiveRuleFilesToOrder;
40
+ exports.setRuleFileOrder = setRuleFileOrder;
36
41
  exports.getActiveRuleFileNames = getActiveRuleFileNames;
37
42
  exports.readRuleFileContent = readRuleFileContent;
38
43
  exports.writeRuleFileContent = writeRuleFileContent;
39
44
  exports.createRuleFile = createRuleFile;
40
45
  exports.setActiveRuleFileNames = setActiveRuleFileNames;
41
46
  exports.listRuleFiles = listRuleFiles;
47
+ exports.getRuleFileDetail = getRuleFileDetail;
48
+ exports.updateRuleFile = updateRuleFile;
49
+ exports.deleteRuleFile = deleteRuleFile;
42
50
  exports.mergeActiveRules = mergeActiveRules;
43
51
  exports.buildRuleOverview = buildRuleOverview;
44
52
  exports.ensureRouteRules = ensureRouteRules;
45
53
  exports.registerRuleFilesRoutes = registerRuleFilesRoutes;
54
+ exports.registerUiTokenRoute = registerUiTokenRoute;
46
55
  const fs = __importStar(require("fs"));
47
56
  const path = __importStar(require("path"));
48
57
  const helpers_1 = require("../helpers");
58
+ const ruleset_lock_1 = require("./ruleset-lock");
49
59
  const DEFAULT_RULE_NAME = '默认规则';
60
+ /** PUT /api/rule-files/:name/content: body was not JSON. */
61
+ exports.INVALID_RULE_CONTENT_JSON = '请求体不是合法的 JSON';
62
+ /** PUT /api/rule-files/:name/content: `content` is missing or not a string. */
63
+ exports.INVALID_RULE_CONTENT_FIELD = 'content 必须是字符串';
64
+ /** Directory the server reads/writes route-rule files from (<MEDDLE_HOME>/route-rules). */
50
65
  function getRulesDir(ctx) {
51
66
  return path.join(ctx.meddleDir, 'route-rules');
52
67
  }
@@ -91,6 +106,107 @@ function setActiveFileNames(ctx, names) {
91
106
  settings.activeRuleFiles = names;
92
107
  saveSettings(ctx, settings);
93
108
  }
109
+ function getStoredRuleFileOrder(ctx) {
110
+ const settings = loadSettings(ctx);
111
+ const arr = settings.ruleFileOrder;
112
+ return Array.isArray(arr) ? arr.filter((name) => typeof name === 'string') : [];
113
+ }
114
+ function setStoredRuleFileOrder(ctx, names) {
115
+ const settings = loadSettings(ctx);
116
+ settings.ruleFileOrder = names;
117
+ saveSettings(ctx, settings);
118
+ }
119
+ /**
120
+ * Resolve display/tab order for rule files.
121
+ * Prefer persisted ruleFileOrder; when empty, seed as activeRuleFiles then remaining disk names
122
+ * so existing merge order is preserved for enabled files.
123
+ */
124
+ function resolveRuleFileOrder(diskNames, storedOrder, activeNames) {
125
+ const diskSet = new Set(diskNames);
126
+ const ordered = [];
127
+ const seen = new Set();
128
+ const pushUnique = (name) => {
129
+ if (!diskSet.has(name) || seen.has(name))
130
+ return;
131
+ ordered.push(name);
132
+ seen.add(name);
133
+ };
134
+ if (storedOrder.length > 0) {
135
+ for (const name of storedOrder)
136
+ pushUnique(name);
137
+ }
138
+ else {
139
+ for (const name of activeNames)
140
+ pushUnique(name);
141
+ }
142
+ for (const name of diskNames)
143
+ pushUnique(name);
144
+ return ordered;
145
+ }
146
+ /** Keep activeRuleFiles relative order aligned with tab/file order. */
147
+ function syncActiveRuleFilesToOrder(activeNames, fileOrder) {
148
+ const activeSet = new Set(activeNames);
149
+ const synced = fileOrder.filter((name) => activeSet.has(name));
150
+ for (const name of activeNames) {
151
+ if (!synced.includes(name))
152
+ synced.push(name);
153
+ }
154
+ return synced;
155
+ }
156
+ function readDiskRuleFileNames(ctx) {
157
+ const dir = getRulesDir(ctx);
158
+ ensureRulesDir(ctx);
159
+ return fs.readdirSync(dir)
160
+ .filter((f) => f.endsWith('.txt'))
161
+ .map((f) => f.replace(/\.txt$/, ''));
162
+ }
163
+ function ensureRuleFileInOrder(ctx, name) {
164
+ const diskNames = readDiskRuleFileNames(ctx);
165
+ const ordered = resolveRuleFileOrder(diskNames, getStoredRuleFileOrder(ctx), getActiveFileNames(ctx));
166
+ if (!ordered.includes(name))
167
+ ordered.push(name);
168
+ setStoredRuleFileOrder(ctx, ordered);
169
+ return ordered;
170
+ }
171
+ function renameRuleFileInOrder(ctx, oldName, newName) {
172
+ const diskNames = readDiskRuleFileNames(ctx);
173
+ const ordered = resolveRuleFileOrder(diskNames, getStoredRuleFileOrder(ctx), getActiveFileNames(ctx));
174
+ const next = [];
175
+ const seen = new Set();
176
+ for (const name of ordered) {
177
+ const resolved = name === oldName ? newName : name;
178
+ if (seen.has(resolved))
179
+ continue;
180
+ next.push(resolved);
181
+ seen.add(resolved);
182
+ }
183
+ if (!seen.has(newName))
184
+ next.push(newName);
185
+ setStoredRuleFileOrder(ctx, next);
186
+ }
187
+ function removeRuleFileFromOrder(ctx, name) {
188
+ const ordered = getStoredRuleFileOrder(ctx).filter((item) => item !== name);
189
+ setStoredRuleFileOrder(ctx, ordered);
190
+ }
191
+ function setRuleFileOrder(ctx, names) {
192
+ ensureRulesDir(ctx);
193
+ const diskNames = readDiskRuleFileNames(ctx);
194
+ const diskSet = new Set(diskNames);
195
+ const safeNames = names.map((name) => normalizeRuleFileName(name));
196
+ if (safeNames.length !== diskNames.length
197
+ || new Set(safeNames).size !== safeNames.length
198
+ || safeNames.some((name) => !diskSet.has(name))) {
199
+ throw new Error('规则文件顺序必须包含全部现有文件且无重复');
200
+ }
201
+ const activeNames = getActiveFileNames(ctx);
202
+ const syncedActive = syncActiveRuleFilesToOrder(activeNames, safeNames);
203
+ const settings = loadSettings(ctx);
204
+ settings.ruleFileOrder = safeNames;
205
+ settings.activeRuleFiles = syncedActive;
206
+ saveSettings(ctx, settings);
207
+ ctx.reloadAllRuleFiles();
208
+ return safeNames;
209
+ }
94
210
  function getActiveRuleFileNames(ctx) {
95
211
  return getActiveFileNames(ctx);
96
212
  }
@@ -108,8 +224,9 @@ function writeRuleFileContent(ctx, name, content) {
108
224
  const safeName = normalizeRuleFileName(name);
109
225
  const filePath = ruleFilePath(ctx, safeName);
110
226
  if (!fs.existsSync(filePath)) {
111
- throw new Error(`规则文件 "${safeName}" 不存在`);
227
+ throw ruleFileNotFound(safeName);
112
228
  }
229
+ (0, ruleset_lock_1.assertRuleFileWritable)(ctx, safeName);
113
230
  fs.writeFileSync(filePath, content, 'utf8');
114
231
  ctx.reloadAllRuleFiles();
115
232
  }
@@ -120,12 +237,15 @@ function createRuleFile(ctx, name, content = '', enabled = true) {
120
237
  if (fs.existsSync(filePath)) {
121
238
  throw new Error(`规则文件 "${safeName}" 已存在`);
122
239
  }
240
+ // Drop stale lock entries first so a new file never inherits an old lock.
241
+ (0, ruleset_lock_1.getReadonlyRuleFileNames)(ctx);
123
242
  fs.writeFileSync(filePath, content, 'utf8');
243
+ ensureRuleFileInOrder(ctx, safeName);
124
244
  if (enabled) {
125
245
  const activeNames = getActiveFileNames(ctx);
126
246
  if (!activeNames.includes(safeName)) {
127
- activeNames.push(safeName);
128
- setActiveFileNames(ctx, activeNames);
247
+ const fileOrder = resolveRuleFileOrder(readDiskRuleFileNames(ctx), getStoredRuleFileOrder(ctx), activeNames);
248
+ setActiveFileNames(ctx, syncActiveRuleFilesToOrder([...activeNames, safeName], fileOrder));
129
249
  }
130
250
  }
131
251
  ctx.reloadAllRuleFiles();
@@ -133,6 +253,7 @@ function createRuleFile(ctx, name, content = '', enabled = true) {
133
253
  return {
134
254
  name: safeName,
135
255
  enabled,
256
+ readonly: false,
136
257
  ruleCount: Object.keys(ruleMap).length,
137
258
  excludeCount: Object.values(excludeMap).reduce((total, exclusions) => total + exclusions.length, 0),
138
259
  };
@@ -144,7 +265,44 @@ function setActiveRuleFileNames(ctx, names) {
144
265
  if (missingNames.length > 0) {
145
266
  throw new Error(`规则文件不存在: ${missingNames.join(', ')}`);
146
267
  }
268
+ // A locked ruleset may not be switched on or off.
269
+ const previous = new Set(getActiveFileNames(ctx));
270
+ const next = new Set(safeNames);
271
+ const changed = [...previous].filter((name) => !next.has(name)).concat(safeNames.filter((name) => !previous.has(name)));
272
+ (0, ruleset_lock_1.assertRuleFilesWritable)(ctx, changed);
147
273
  setActiveFileNames(ctx, safeNames);
274
+ // Keep tab order's enabled-file relative positions aligned with activeRuleFiles
275
+ const diskNames = readDiskRuleFileNames(ctx);
276
+ const currentOrder = resolveRuleFileOrder(diskNames, getStoredRuleFileOrder(ctx), safeNames);
277
+ const activeSet = new Set(safeNames);
278
+ let activeIndex = 0;
279
+ const nextOrder = currentOrder.map((name) => {
280
+ if (!activeSet.has(name))
281
+ return name;
282
+ const replacement = safeNames[activeIndex];
283
+ activeIndex += 1;
284
+ return replacement;
285
+ });
286
+ while (activeIndex < safeNames.length) {
287
+ nextOrder.push(safeNames[activeIndex]);
288
+ activeIndex += 1;
289
+ }
290
+ // Deduplicate while preserving first occurrence
291
+ const deduped = [];
292
+ const seen = new Set();
293
+ for (const name of nextOrder) {
294
+ if (seen.has(name))
295
+ continue;
296
+ deduped.push(name);
297
+ seen.add(name);
298
+ }
299
+ for (const name of diskNames) {
300
+ if (!seen.has(name)) {
301
+ deduped.push(name);
302
+ seen.add(name);
303
+ }
304
+ }
305
+ setStoredRuleFileOrder(ctx, deduped);
148
306
  ctx.reloadAllRuleFiles();
149
307
  return safeNames;
150
308
  }
@@ -152,13 +310,20 @@ function setActiveRuleFileNames(ctx, names) {
152
310
  * Scan the route-rules directory and return file info list.
153
311
  */
154
312
  function listRuleFiles(ctx) {
155
- const dir = getRulesDir(ctx);
156
313
  ensureRulesDir(ctx);
157
314
  const activeNames = getActiveFileNames(ctx);
158
- const files = fs.readdirSync(dir)
159
- .filter(f => f.endsWith('.txt'))
160
- .map(f => f.replace(/\.txt$/, ''));
161
- return files.map(name => {
315
+ const files = readDiskRuleFileNames(ctx);
316
+ const orderedNames = resolveRuleFileOrder(files, getStoredRuleFileOrder(ctx), activeNames);
317
+ const readonlyNames = new Set((0, ruleset_lock_1.getReadonlyRuleFileNames)(ctx));
318
+ // Persist seeded order and newly discovered / deleted names so tabs stay stable
319
+ const stored = getStoredRuleFileOrder(ctx);
320
+ const needsPersist = stored.length === 0
321
+ || orderedNames.some((name) => !stored.includes(name))
322
+ || stored.some((name) => !orderedNames.includes(name));
323
+ if (needsPersist) {
324
+ setStoredRuleFileOrder(ctx, orderedNames);
325
+ }
326
+ return orderedNames.map(name => {
162
327
  const filePath = ruleFilePath(ctx, name);
163
328
  let ruleCount = 0;
164
329
  let excludeCount = 0;
@@ -175,11 +340,83 @@ function listRuleFiles(ctx) {
175
340
  return {
176
341
  name,
177
342
  enabled: activeNames.includes(name),
343
+ readonly: readonlyNames.has(name),
178
344
  ruleCount,
179
345
  excludeCount,
180
346
  };
181
347
  });
182
348
  }
349
+ /** Detail of one ruleset (info + raw content). Read-only operation. */
350
+ function getRuleFileDetail(ctx, name) {
351
+ const content = readRuleFileContent(ctx, name);
352
+ const safeName = normalizeRuleFileName(name);
353
+ const info = listRuleFiles(ctx).find((file) => file.name === safeName);
354
+ if (!info)
355
+ throw new Error(`规则文件 "${safeName}" 不存在`);
356
+ return { ...info, content };
357
+ }
358
+ function ruleFileNotFound(name) {
359
+ return Object.assign(new Error(`规则文件 "${name}" 不存在`), { status: 404 });
360
+ }
361
+ /**
362
+ * Enable / disable and / or rename a ruleset. A locked ruleset is rejected
363
+ * before anything changes (the web UI must unlock it first).
364
+ */
365
+ function updateRuleFile(ctx, rawName, changes) {
366
+ const name = normalizeRuleFileName(rawName);
367
+ const filePath = ruleFilePath(ctx, name);
368
+ if (!fs.existsSync(filePath))
369
+ throw ruleFileNotFound(name);
370
+ (0, ruleset_lock_1.assertRuleFileWritable)(ctx, name);
371
+ const { enabled, newName } = changes;
372
+ let currentName = name;
373
+ const activeNames = getActiveFileNames(ctx);
374
+ if (newName && newName.trim() && newName.trim() !== name) {
375
+ const safeName = normalizeRuleFileName(newName);
376
+ const newPath = ruleFilePath(ctx, safeName);
377
+ if (fs.existsSync(newPath)) {
378
+ throw Object.assign(new Error(`规则文件 "${safeName}" 已存在`), { status: 409 });
379
+ }
380
+ // Drop stale lock entries so the new name does not inherit an old lock.
381
+ (0, ruleset_lock_1.getReadonlyRuleFileNames)(ctx);
382
+ fs.renameSync(filePath, newPath);
383
+ const idx = activeNames.indexOf(name);
384
+ if (idx !== -1)
385
+ activeNames[idx] = safeName;
386
+ currentName = safeName;
387
+ renameRuleFileInOrder(ctx, name, safeName);
388
+ }
389
+ if (enabled !== undefined) {
390
+ const idx = activeNames.indexOf(currentName);
391
+ if (enabled && idx === -1) {
392
+ activeNames.push(currentName);
393
+ }
394
+ else if (!enabled && idx !== -1) {
395
+ activeNames.splice(idx, 1);
396
+ }
397
+ }
398
+ const fileOrder = resolveRuleFileOrder(readDiskRuleFileNames(ctx), getStoredRuleFileOrder(ctx), activeNames);
399
+ setActiveFileNames(ctx, syncActiveRuleFilesToOrder(activeNames, fileOrder));
400
+ ctx.reloadAllRuleFiles();
401
+ return { name: currentName };
402
+ }
403
+ /** Delete a ruleset. A locked ruleset is rejected and stays on disk. */
404
+ function deleteRuleFile(ctx, rawName) {
405
+ const name = normalizeRuleFileName(rawName);
406
+ const filePath = ruleFilePath(ctx, name);
407
+ if (!fs.existsSync(filePath))
408
+ throw ruleFileNotFound(name);
409
+ (0, ruleset_lock_1.assertRuleFileWritable)(ctx, name);
410
+ fs.unlinkSync(filePath);
411
+ const activeNames = getActiveFileNames(ctx);
412
+ const idx = activeNames.indexOf(name);
413
+ if (idx !== -1) {
414
+ activeNames.splice(idx, 1);
415
+ setActiveFileNames(ctx, activeNames);
416
+ }
417
+ removeRuleFileFromOrder(ctx, name);
418
+ ctx.reloadAllRuleFiles();
419
+ }
183
420
  /**
184
421
  * 按启用文件顺序合并路由规则;匹配时按 rules 数组顺序逐条验证。
185
422
  */
@@ -303,13 +540,17 @@ function ensureRouteRules(ctx) {
303
540
  fs.writeFileSync(defaultPath, '', 'utf8');
304
541
  }
305
542
  let activeNames = getActiveFileNames(ctx);
543
+ const allFiles = readDiskRuleFileNames(ctx);
306
544
  if (activeNames.length === 0) {
307
- const allFiles = fs.readdirSync(dir).filter(f => f.endsWith('.txt')).map(f => f.replace(/\.txt$/, ''));
308
545
  if (allFiles.length > 0) {
309
546
  activeNames = [allFiles[0]];
310
547
  setActiveFileNames(ctx, activeNames);
311
548
  }
312
549
  }
550
+ const ordered = resolveRuleFileOrder(allFiles, getStoredRuleFileOrder(ctx), activeNames);
551
+ if (getStoredRuleFileOrder(ctx).length === 0 && ordered.length > 0) {
552
+ setStoredRuleFileOrder(ctx, ordered);
553
+ }
313
554
  return activeNames;
314
555
  }
315
556
  function registerRuleFilesRoutes(app, ctx) {
@@ -325,6 +566,26 @@ function registerRuleFilesRoutes(app, ctx) {
325
566
  res.write(JSON.stringify(buildRuleOverview(ctx)));
326
567
  res.end();
327
568
  });
569
+ // PUT /api/rule-files/order - 持久化 Tab / 文件顺序,并同步启用文件合并顺序
570
+ app.put('/api/rule-files/order', (req, res) => {
571
+ res.setHeader('Content-Type', 'application/json');
572
+ try {
573
+ const order = req.body?.order;
574
+ if (!Array.isArray(order) || order.some((name) => typeof name !== 'string')) {
575
+ res.statusCode = 400;
576
+ res.write(JSON.stringify({ error: '缺少有效的 order 数组' }));
577
+ res.end();
578
+ return;
579
+ }
580
+ const nextOrder = setRuleFileOrder(ctx, order);
581
+ res.write(JSON.stringify({ status: 'success', order: nextOrder }));
582
+ }
583
+ catch (err) {
584
+ res.statusCode = 400;
585
+ res.write(JSON.stringify({ error: err.message }));
586
+ }
587
+ res.end();
588
+ });
328
589
  // POST /api/rule-files - 创建新规则文件
329
590
  app.post('/api/rule-files', (req, res) => {
330
591
  res.setHeader('Content-Type', 'application/json');
@@ -336,30 +597,18 @@ function registerRuleFilesRoutes(app, ctx) {
336
597
  res.end();
337
598
  return;
338
599
  }
339
- const safeName = name.trim().replace(/[/\\:*?"<>|]/g, '_');
340
- const filePath = ruleFilePath(ctx, safeName);
341
- if (fs.existsSync(filePath)) {
600
+ const ruleFile = createRuleFile(ctx, name, content, enabled !== false);
601
+ res.write(JSON.stringify({ status: 'success', ruleFile: { name: ruleFile.name, enabled: ruleFile.enabled, ruleCount: ruleFile.ruleCount } }));
602
+ }
603
+ catch (err) {
604
+ const message = err.message;
605
+ if (message.includes('已存在')) {
342
606
  res.statusCode = 409;
343
- res.write(JSON.stringify({ error: `规则文件 "${safeName}" 已存在` }));
344
- res.end();
345
- return;
346
607
  }
347
- ensureRulesDir(ctx);
348
- fs.writeFileSync(filePath, content, 'utf8');
349
- if (enabled) {
350
- const activeNames = getActiveFileNames(ctx);
351
- if (!activeNames.includes(safeName)) {
352
- activeNames.push(safeName);
353
- setActiveFileNames(ctx, activeNames);
354
- }
608
+ else {
609
+ res.statusCode = 500;
355
610
  }
356
- ctx.reloadAllRuleFiles();
357
- const ruleCount = Object.keys((0, helpers_1.parseEprcWithExclusions)(content).ruleMap).length;
358
- res.write(JSON.stringify({ status: 'success', ruleFile: { name: safeName, enabled, ruleCount } }));
359
- }
360
- catch (err) {
361
- res.statusCode = 500;
362
- res.write(JSON.stringify({ error: err.message }));
611
+ res.write(JSON.stringify({ error: message }));
363
612
  }
364
613
  res.end();
365
614
  });
@@ -375,108 +624,147 @@ function registerRuleFilesRoutes(app, ctx) {
375
624
  return;
376
625
  }
377
626
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
627
+ res.setHeader('X-Meddle-Ruleset-Readonly', (0, ruleset_lock_1.isRuleFileReadonly)(ctx, name) ? 'true' : 'false');
378
628
  res.write(fs.readFileSync(filePath, 'utf8'));
379
629
  res.end();
380
630
  });
381
- // PUT /api/rule-files/:name/content - 保存规则文件内容
382
- app.put('/api/rule-files/:name/content', (req, res) => {
383
- res.setHeader('Content-Type', 'application/json');
631
+ // GET /api/rule-files/:name - 规则集详情(含 readonly 与内容)
632
+ app.get('/api/rule-files/:name', (req, res) => {
384
633
  const name = decodeURIComponent(req.params.name);
385
- const filePath = ruleFilePath(ctx, name);
386
- if (!fs.existsSync(filePath)) {
387
- res.statusCode = 404;
388
- res.write(JSON.stringify({ error: '规则文件不存在' }));
389
- res.end();
634
+ if (!fs.existsSync(ruleFilePath(ctx, name))) {
635
+ sendJson(res, 404, { error: '规则文件不存在' });
390
636
  return;
391
637
  }
392
638
  try {
393
- const text = typeof req.body === 'string' ? req.body : (req.body.content ?? '');
394
- fs.writeFileSync(filePath, text, 'utf8');
395
- ctx.reloadAllRuleFiles();
396
- res.write(JSON.stringify({ status: 'success' }));
639
+ sendJson(res, 200, getRuleFileDetail(ctx, name));
397
640
  }
398
641
  catch (err) {
399
- res.statusCode = 500;
400
- res.write(JSON.stringify({ error: err.message }));
642
+ sendError(res, err);
401
643
  }
402
- res.end();
403
644
  });
404
- // PUT /api/rule-files/:name - 更新规则文件属性(启用/禁用、重命名)
405
- app.put('/api/rule-files/:name', (req, res) => {
406
- res.setHeader('Content-Type', 'application/json');
645
+ // PUT /api/rule-files/:name/content - 保存规则文件内容(锁住时拒绝)
646
+ // A missing or non-string `content` used to fall through to '' and empty the file.
647
+ app.put('/api/rule-files/:name/content', (req, res) => {
407
648
  const name = decodeURIComponent(req.params.name);
408
- const filePath = ruleFilePath(ctx, name);
409
- if (!fs.existsSync(filePath)) {
410
- res.statusCode = 404;
411
- res.write(JSON.stringify({ error: '规则文件不存在' }));
412
- res.end();
649
+ const contentType = String(req.headers['content-type'] || '').toLowerCase();
650
+ if (!contentType.includes('application/json')) {
651
+ sendJson(res, 400, { error: exports.INVALID_RULE_CONTENT_JSON });
652
+ return;
653
+ }
654
+ if (req.body == null || typeof req.body !== 'object' || Array.isArray(req.body) || typeof req.body.content !== 'string') {
655
+ sendJson(res, 400, { error: exports.INVALID_RULE_CONTENT_FIELD });
413
656
  return;
414
657
  }
415
658
  try {
416
- const { enabled, newName } = req.body;
417
- let currentName = name;
418
- const activeNames = getActiveFileNames(ctx);
419
- if (newName && newName.trim() && newName.trim() !== name) {
420
- const safeName = newName.trim().replace(/[/\\:*?"<>|]/g, '_');
421
- const newPath = ruleFilePath(ctx, safeName);
422
- if (fs.existsSync(newPath)) {
423
- res.statusCode = 409;
424
- res.write(JSON.stringify({ error: `规则文件 "${safeName}" 已存在` }));
425
- res.end();
426
- return;
427
- }
428
- fs.renameSync(filePath, newPath);
429
- const idx = activeNames.indexOf(name);
430
- if (idx !== -1)
431
- activeNames[idx] = safeName;
432
- currentName = safeName;
433
- }
434
- if (enabled !== undefined) {
435
- const idx = activeNames.indexOf(currentName);
436
- if (enabled && idx === -1) {
437
- activeNames.push(currentName);
438
- }
439
- else if (!enabled && idx !== -1) {
440
- activeNames.splice(idx, 1);
441
- }
442
- }
443
- setActiveFileNames(ctx, activeNames);
444
- ctx.reloadAllRuleFiles();
445
- res.write(JSON.stringify({ status: 'success', name: currentName }));
659
+ writeRuleFileContent(ctx, name, req.body.content);
660
+ sendJson(res, 200, { status: 'success' });
446
661
  }
447
662
  catch (err) {
448
- res.statusCode = 500;
449
- res.write(JSON.stringify({ error: err.message }));
663
+ sendError(res, err);
450
664
  }
451
- res.end();
452
665
  });
453
- // DELETE /api/rule-files/:name - 删除规则文件
454
- app.delete('/api/rule-files/:name', (req, res) => {
455
- res.setHeader('Content-Type', 'application/json');
456
- const name = decodeURIComponent(req.params.name);
457
- const filePath = ruleFilePath(ctx, name);
458
- if (!fs.existsSync(filePath)) {
459
- res.statusCode = 404;
460
- res.write(JSON.stringify({ error: '规则文件不存在' }));
461
- res.end();
666
+ // PUT /api/rule-files/:name/readonly - 加锁 / 解锁(仅 Web 界面:页面令牌 + 同源)
667
+ app.put('/api/rule-files/:name/readonly', (req, res) => {
668
+ const rawName = decodeURIComponent(req.params.name);
669
+ if (!(0, ruleset_lock_1.isSameOriginRequest)(req)) {
670
+ sendJson(res, 403, { error: '只能在 Meddle 界面中加锁或解锁', code: 'UI_ORIGIN_REQUIRED' });
671
+ return;
672
+ }
673
+ if (!(0, ruleset_lock_1.hasValidUiToken)(req, ctx)) {
674
+ sendJson(res, 403, { error: '界面令牌无效,请刷新 Meddle 页面后重试', code: 'UI_TOKEN_INVALID' });
675
+ return;
676
+ }
677
+ const readonly = req.body?.readonly;
678
+ if (typeof readonly !== 'boolean') {
679
+ sendJson(res, 400, { error: '缺少 readonly 布尔值' });
462
680
  return;
463
681
  }
464
682
  try {
465
- fs.unlinkSync(filePath);
466
- const activeNames = getActiveFileNames(ctx);
467
- const idx = activeNames.indexOf(name);
468
- if (idx !== -1) {
469
- activeNames.splice(idx, 1);
470
- setActiveFileNames(ctx, activeNames);
471
- }
472
- ctx.reloadAllRuleFiles();
473
- res.write(JSON.stringify({ status: 'success' }));
683
+ const name = normalizeRuleFileName(rawName);
684
+ if (!fs.existsSync(ruleFilePath(ctx, name)))
685
+ throw ruleFileNotFound(name);
686
+ (0, ruleset_lock_1.setRuleFileReadonly)(ctx, name, readonly);
687
+ sendJson(res, 200, { status: 'success', name, readonly });
474
688
  }
475
689
  catch (err) {
476
- res.statusCode = 500;
477
- res.write(JSON.stringify({ error: err.message }));
690
+ sendError(res, err);
478
691
  }
479
- res.end();
692
+ });
693
+ // PUT /api/rule-files/:name - 更新规则文件属性(启用/禁用、重命名;锁住时拒绝)
694
+ app.put('/api/rule-files/:name', (req, res) => {
695
+ const name = decodeURIComponent(req.params.name);
696
+ try {
697
+ const { enabled, newName } = req.body || {};
698
+ const result = updateRuleFile(ctx, name, { enabled, newName });
699
+ sendJson(res, 200, { status: 'success', name: result.name });
700
+ }
701
+ catch (err) {
702
+ sendError(res, err);
703
+ }
704
+ });
705
+ // DELETE /api/rule-files/:name - 删除规则文件(锁住时拒绝)
706
+ app.delete('/api/rule-files/:name', (req, res) => {
707
+ const name = decodeURIComponent(req.params.name);
708
+ try {
709
+ deleteRuleFile(ctx, name);
710
+ sendJson(res, 200, { status: 'success' });
711
+ }
712
+ catch (err) {
713
+ sendError(res, err);
714
+ }
715
+ });
716
+ // express.json() rejects invalid JSON before this route runs and would
717
+ // otherwise answer with an HTML 400. A non-JSON content type never reaches
718
+ // that parser, so the route itself rejects it before any write.
719
+ app.use((err, req, res, next) => {
720
+ if (isRuleFileContentPut(req) && isJsonParseFailure(err)) {
721
+ if (res.headersSent) {
722
+ next(err);
723
+ return;
724
+ }
725
+ sendJson(res, 400, { error: exports.INVALID_RULE_CONTENT_JSON });
726
+ return;
727
+ }
728
+ next(err);
729
+ });
730
+ }
731
+ function isRuleFileContentPut(req) {
732
+ if (req.method !== 'PUT')
733
+ return false;
734
+ const pathOnly = String(req.path || req.url || '').split('?')[0];
735
+ return /^\/api\/rule-files\/[^/]+\/content$/.test(pathOnly);
736
+ }
737
+ function isJsonParseFailure(err) {
738
+ if (!err)
739
+ return false;
740
+ const status = err.status || err.statusCode;
741
+ if (status !== 400)
742
+ return false;
743
+ return err.type === 'entity.parse.failed' || err instanceof SyntaxError;
744
+ }
745
+ function sendJson(res, status, body) {
746
+ res.statusCode = status;
747
+ res.setHeader('Content-Type', 'application/json');
748
+ res.write(JSON.stringify(body));
749
+ res.end();
750
+ }
751
+ function sendError(res, err) {
752
+ if ((0, ruleset_lock_1.isRulesetReadonlyError)(err)) {
753
+ sendJson(res, ruleset_lock_1.RULESET_READONLY_STATUS, (0, ruleset_lock_1.rulesetReadonlyBody)(err));
754
+ return;
755
+ }
756
+ const status = typeof err?.status === 'number' ? err.status : 500;
757
+ sendJson(res, status, { error: err.message });
758
+ }
759
+ /** Page bootstrap for the UI token (fallback when index.html was not served by Meddle, e.g. Vite dev). */
760
+ function registerUiTokenRoute(app, ctx) {
761
+ app.get('/api/ui-token', (req, res) => {
762
+ res.setHeader('Cache-Control', 'no-store');
763
+ if (!(0, ruleset_lock_1.isSameOriginRequest)(req)) {
764
+ sendJson(res, 403, { error: '只能在 Meddle 界面中获取', code: 'UI_ORIGIN_REQUIRED' });
765
+ return;
766
+ }
767
+ sendJson(res, 200, { token: (0, ruleset_lock_1.getUiToken)(ctx) });
480
768
  });
481
769
  }
482
770
  //# sourceMappingURL=rule-files.js.map