@jonathanleelx/meddle 0.5.0-beta.23 → 0.5.0-beta.25
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.
- package/CONFIG_STRUCTURE.md +1 -0
- package/README.md +16 -0
- package/bin/commands/route/active.js +14 -1
- package/bin/commands/route/add.js +112 -103
- package/bin/commands/route/delete.js +73 -49
- package/bin/commands/route/index.js +20 -2
- package/bin/commands/route/list.js +2 -2
- package/bin/commands/route/show.js +10 -1
- package/bin/commands/route/update.js +134 -101
- package/bin/index.js +3 -1
- package/bin/lib/api-client.js +14 -2
- package/bin/lib/file-access.js +39 -1
- package/bin/lib/route-write-args.js +150 -0
- package/bin/lib/rule-file-edit.js +263 -0
- package/bin/lib/ruleset-readonly.js +61 -0
- package/dist/core/mock-handler.d.ts.map +1 -1
- package/dist/core/mock-handler.js +34 -5
- package/dist/core/mock-handler.js.map +1 -1
- package/dist/core/rule-file-edit.d.ts +81 -0
- package/dist/core/rule-file-edit.d.ts.map +1 -0
- package/dist/core/rule-file-edit.js +243 -0
- package/dist/core/rule-file-edit.js.map +1 -0
- package/dist/core/static-server.d.ts.map +1 -1
- package/dist/core/static-server.js +15 -13
- package/dist/core/static-server.js.map +1 -1
- package/dist/server/agent/tools.d.ts.map +1 -1
- package/dist/server/agent/tools.js +20 -18
- package/dist/server/agent/tools.js.map +1 -1
- package/dist/server/app-info.d.ts +14 -0
- package/dist/server/app-info.d.ts.map +1 -1
- package/dist/server/app-info.js +33 -1
- package/dist/server/app-info.js.map +1 -1
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +51 -9
- package/dist/server/config.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/rule-files.d.ts +23 -0
- package/dist/server/rule-files.d.ts.map +1 -1
- package/dist/server/rule-files.js +169 -81
- package/dist/server/rule-files.js.map +1 -1
- package/dist/server/ruleset-lock.d.ts +62 -0
- package/dist/server/ruleset-lock.d.ts.map +1 -0
- package/dist/server/ruleset-lock.js +234 -0
- package/dist/server/ruleset-lock.js.map +1 -0
- package/mcp-server.js +87 -88
- package/package.json +1 -1
- package/web/dist/assets/{detail-panel-D4JSaFH3.js → detail-panel-CyOc7gn8.js} +3 -3
- package/web/dist/assets/dist-BQN0rzUS.js +1 -0
- package/web/dist/assets/{global-panel-shell-DXvcxmbd.js → global-panel-shell-jXQTb9nw.js} +3 -3
- package/web/dist/assets/{health-panel-Bo0eD8_R.js → health-panel-CSV7OqBv.js} +1 -1
- package/web/dist/assets/index-C6dPxxT3.css +2 -0
- package/web/dist/assets/index-CSXxaYkG.js +21 -0
- package/web/dist/assets/{mobile-proxy-panel-FQwFQFmS.js → mobile-proxy-panel-a09S-R79.js} +2 -2
- package/web/dist/assets/mock-config-D_E5YOE_.js +1 -0
- package/web/dist/assets/{mock-editor-panel-kx0bzGv4.js → mock-editor-panel-t4TDXeaR.js} +3 -3
- package/web/dist/assets/{mocks-file-missing-note-BvL7-uzS.js → mocks-file-missing-note-Ddy1TdMX.js} +1 -1
- package/web/dist/assets/{monaco-editor-BLHN8gCU.js → monaco-editor-GpPLOKA5.js} +2 -2
- package/web/dist/assets/pane-bar-zJmE3xI5.js +1 -0
- package/web/dist/assets/{pane-split-DfQcgd1v.js → pane-split-CJ1VXJDy.js} +1 -1
- package/web/dist/assets/panel-heading-DlFWoYAR.js +1 -0
- package/web/dist/assets/plugin-code-editor-CtaamKWJ.js +2 -0
- package/web/dist/assets/plugin-config-Dg_FCb-L.js +2 -0
- package/web/dist/assets/{plugin-generator-B5FRaaKc.js → plugin-generator-CVREuZs7.js} +2 -2
- package/web/dist/assets/plugin-test-dialog-B-iV8ocm.js +7 -0
- package/web/dist/assets/route-preview-CLJeg_9v.js +1 -0
- package/web/dist/assets/{rule-ai-assistant-panel-BEmXPzli.js → rule-ai-assistant-panel-BvTqm4zV.js} +2 -2
- package/web/dist/assets/rule-config-DmNPaKxc.js +15 -0
- package/web/dist/assets/{rule-overview-panel-DsNEU8L1.js → rule-overview-panel-CB9b-b0t.js} +1 -1
- package/web/dist/assets/settings-panel-DLdeJ8ox.js +1 -0
- package/web/dist/assets/toast-9yeTf7d9.js +1 -0
- package/web/dist/assets/use-mocks-file-status-BUIUJ4oQ.js +1 -0
- package/web/dist/index.html +11 -9
- package/web/dist/assets/index-Cbis1y9h.js +0 -18
- package/web/dist/assets/index-dKaIrLee.css +0 -2
- package/web/dist/assets/mock-config-CL9nXIkx.js +0 -1
- package/web/dist/assets/pane-bar-BSzN6D93.js +0 -1
- package/web/dist/assets/panel-heading-Dev_soZA.js +0 -1
- package/web/dist/assets/plugin-code-editor-uI_-iAXY.js +0 -2
- package/web/dist/assets/plugin-config-BpfIX4Uk.js +0 -2
- package/web/dist/assets/plugin-test-dialog-Cptd2bs0.js +0 -7
- package/web/dist/assets/route-preview-DEc50Y_R.js +0 -1
- package/web/dist/assets/rule-config-DQkdF3tr.js +0 -15
- package/web/dist/assets/select-CFTzQ8Uy.js +0 -1
- package/web/dist/assets/settings-panel-C1v0UmZQ.js +0 -1
- /package/web/dist/assets/{alert-DFypnYT1.js → alert-B514Frte.js} +0 -0
- /package/web/dist/assets/{babel-DCr4VA1c.js → babel-bwZm0qi3.js} +0 -0
- /package/web/dist/assets/{body-diff-view-CCwbvDYx.js → body-diff-view-SfLCLzs9.js} +0 -0
- /package/web/dist/assets/{clipboard-VhPXNN5m.js → clipboard-Bt1qY01V.js} +0 -0
- /package/web/dist/assets/{dist-Dk9a2sJl.js → dist-DLQKRjuW.js} +0 -0
- /package/web/dist/assets/{editor-pane-actions-BGttNlkS.js → editor-pane-actions-_ckwcCII.js} +0 -0
- /package/web/dist/assets/{estree-qO_m2Qaw.js → estree-MatbZDwq.js} +0 -0
- /package/web/dist/assets/{html-VDGahtyk.js → html-CCrsbFYX.js} +0 -0
- /package/web/dist/assets/{postcss-BUm8EKMw.js → postcss-BxN7Gr8n.js} +0 -0
- /package/web/dist/assets/{standalone-Bj8SSd8Q.js → standalone-DsnFnsqM.js} +0 -0
- /package/web/dist/assets/{syntax-highlight-Dg_CTbXd.js → syntax-highlight-BFgN_C0Y.js} +0 -0
- /package/web/dist/assets/{table-B10JkPhg.js → table-DaXD6jJv.js} +0 -0
- /package/web/dist/assets/{textarea-BjWF0pk4.js → textarea-9wB8Hsz-.js} +0 -0
- /package/web/dist/assets/{wand-sparkles-DDGjzjnX.js → wand-sparkles-Bmt4dDOf.js} +0 -0
- /package/web/dist/assets/{wifi-QkTcTrED.js → wifi-Cjj_Br1u.js} +0 -0
- /package/web/dist/assets/{zap-CEVoVkAg.js → zap-BuOtogAv.js} +0 -0
|
@@ -1,136 +1,169 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* meddle route update - Update a rule in a route file
|
|
3
|
+
*
|
|
4
|
+
* Issue #149: `--exclusions=` clears a rule's exclusions. Omitting the flag
|
|
5
|
+
* (and any !path in the pattern) still keeps them.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
const output = require('../../lib/output')
|
|
6
9
|
const { isProxyRunning } = require('../../lib/proxy-detect')
|
|
7
10
|
const { apiGet, apiPut } = require('../../lib/api-client')
|
|
8
|
-
const { getRuleFileContent, saveRuleFileContent
|
|
11
|
+
const { getRuleFileContent, saveRuleFileContent } = require('../../lib/file-access')
|
|
12
|
+
const { updateRouteRule, patternKey, parseRuleLine } = require('../../lib/rule-file-edit')
|
|
13
|
+
const { isRulesetReadonlyError, exitRulesetReadonly } = require('../../lib/ruleset-readonly')
|
|
14
|
+
const {
|
|
15
|
+
parseRouteWriteArgs,
|
|
16
|
+
parsePatternArg,
|
|
17
|
+
resolveExclusions,
|
|
18
|
+
UPDATE_HELP,
|
|
19
|
+
} = require('../../lib/route-write-args')
|
|
9
20
|
|
|
10
|
-
// Parse arguments (skip 'route' and 'update' from argv)
|
|
11
21
|
const args = process.argv.slice(4)
|
|
12
|
-
|
|
22
|
+
|
|
23
|
+
function fail(message, jsonFlag) {
|
|
24
|
+
if (jsonFlag) {
|
|
25
|
+
console.log(JSON.stringify({ success: false, error: { message } }))
|
|
26
|
+
console.error(message)
|
|
27
|
+
} else {
|
|
28
|
+
output.setJsonMode(false)
|
|
29
|
+
output.error(message)
|
|
30
|
+
}
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let parsed
|
|
35
|
+
try {
|
|
36
|
+
parsed = parseRouteWriteArgs(args)
|
|
37
|
+
} catch (e) {
|
|
38
|
+
fail(e.message, args.includes('--json'))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (parsed.help) {
|
|
42
|
+
console.log(UPDATE_HELP.trim())
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const jsonFlag = parsed.json
|
|
13
47
|
output.setJsonMode(jsonFlag)
|
|
14
48
|
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const pattern = positionalArgs[1]
|
|
19
|
-
const newTarget = positionalArgs[2]
|
|
49
|
+
const fileName = parsed.positional[0]
|
|
50
|
+
const pattern = parsed.positional[1]
|
|
51
|
+
const newTarget = parsed.positional[2]
|
|
20
52
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
53
|
+
/** Set once arguments are valid. `explicit` means the user asked to set exclusions, including clear. */
|
|
54
|
+
let explicitExclusions = false
|
|
55
|
+
let appliedExclusions = []
|
|
56
|
+
|
|
57
|
+
function planUpdate() {
|
|
58
|
+
const { patterns, exclusions: patternExclusions } = parsePatternArg(pattern)
|
|
59
|
+
if (patterns.length === 0) {
|
|
60
|
+
fail('Missing required argument: <pattern>', jsonFlag)
|
|
25
61
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
62
|
+
const resolved = resolveExclusions(patternExclusions, parsed.exclusions)
|
|
63
|
+
explicitExclusions = resolved.explicit
|
|
64
|
+
appliedExclusions = resolved.exclusions || []
|
|
65
|
+
return { patterns, exclusions: resolved.explicit ? resolved.exclusions : undefined }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Issue #145: rewrite only the line(s) holding this pattern. The rule keeps its
|
|
70
|
+
* own `!exclusions` unless new ones are given. Issue #149: `--exclusions=` passes
|
|
71
|
+
* `[]`, which removes `!path` from that line. Every other line keeps its bytes.
|
|
72
|
+
*/
|
|
73
|
+
function applyUpdate(text, plan) {
|
|
74
|
+
const target = newTarget ? newTarget.trim() : undefined
|
|
75
|
+
let next = text
|
|
76
|
+
for (const p of plan.patterns) {
|
|
77
|
+
const result = updateRouteRule(next, { pattern: p, target, exclusions: plan.exclusions })
|
|
78
|
+
if (!result.found) {
|
|
79
|
+
fail(`Pattern not found: ${pattern.trim()}`, jsonFlag)
|
|
80
|
+
}
|
|
81
|
+
next = result.content
|
|
29
82
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
83
|
+
return next
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Target token actually written, for feedback when the user did not pass one. */
|
|
87
|
+
function targetOnDisk(content, patternToken) {
|
|
88
|
+
const key = patternKey(patternToken)
|
|
89
|
+
const lines = content.split(/\r?\n/)
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
const parsedLine = parseRuleLine(line)
|
|
92
|
+
if (parsedLine && parsedLine.patterns.some((p) => patternKey(p) === key)) return parsedLine.target
|
|
33
93
|
}
|
|
94
|
+
return ''
|
|
95
|
+
}
|
|
34
96
|
|
|
35
|
-
|
|
97
|
+
function emitUpdated({ fileOnly, target }) {
|
|
98
|
+
const payload = { success: true, file: fileName, pattern: pattern.trim(), newTarget: target }
|
|
99
|
+
if (explicitExclusions) payload.exclusions = appliedExclusions
|
|
100
|
+
if (fileOnly) {
|
|
101
|
+
payload.fileOnly = true
|
|
102
|
+
payload.warning = output.FILE_ONLY_WARNING
|
|
103
|
+
}
|
|
104
|
+
if (jsonFlag) {
|
|
105
|
+
output.jsonRaw(payload)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
output.header('Route Rule Updated')
|
|
109
|
+
output.success(`${pattern.trim()} -> ${target}`)
|
|
110
|
+
if (explicitExclusions) {
|
|
111
|
+
output.kv('Exclusions', appliedExclusions.length ? appliedExclusions.map((e) => `!${e}`).join(' ') : 'cleared')
|
|
112
|
+
}
|
|
113
|
+
output.kv('File', fileName)
|
|
114
|
+
}
|
|
36
115
|
|
|
37
|
-
|
|
38
|
-
|
|
116
|
+
async function run() {
|
|
117
|
+
if (!fileName) fail('Missing required argument: <file>', jsonFlag)
|
|
118
|
+
if (!pattern) fail('Missing required argument: <pattern>', jsonFlag)
|
|
119
|
+
if (parsed.positional.length > 3) fail(`Unexpected argument: ${parsed.positional[3]}`, jsonFlag)
|
|
120
|
+
|
|
121
|
+
let plan
|
|
122
|
+
try {
|
|
123
|
+
plan = planUpdate()
|
|
124
|
+
} catch (e) {
|
|
125
|
+
fail(e.message, jsonFlag)
|
|
126
|
+
}
|
|
127
|
+
if (!newTarget && !explicitExclusions) fail('Missing required argument: <target>', jsonFlag)
|
|
128
|
+
|
|
129
|
+
const running = await isProxyRunning()
|
|
39
130
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
131
|
+
const finish = (content, fileOnly) => {
|
|
132
|
+
const target = newTarget ? newTarget.trim() : targetOnDisk(content, plan.patterns[0])
|
|
133
|
+
emitUpdated({ fileOnly, target })
|
|
134
|
+
}
|
|
43
135
|
|
|
44
136
|
if (running) {
|
|
45
137
|
try {
|
|
46
138
|
let content = await apiGet(`/api/rule-files/${encodeURIComponent(fileName)}/content`)
|
|
47
139
|
const text = typeof content === 'string' ? content : String(content)
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
|
|
51
|
-
output.error(`Pattern not found: ${pat}`)
|
|
52
|
-
process.exit(1)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
ruleMap[internalKey] = bm ? tgt + bm[0] : tgt
|
|
56
|
-
const newContent = ruleMapToEprcText(ruleMap)
|
|
140
|
+
const newContent = applyUpdate(text, plan)
|
|
57
141
|
await apiPut(`/api/rule-files/${encodeURIComponent(fileName)}/content`, { content: newContent })
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt })
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
output.header('Route Rule Updated')
|
|
65
|
-
output.success(`${pat} -> ${tgt}`)
|
|
66
|
-
output.kv('File', fileName)
|
|
142
|
+
finish(newContent, false)
|
|
143
|
+
return
|
|
67
144
|
} catch (e) {
|
|
145
|
+
// Issue #137: a locked ruleset is a final answer, never a cue to write the file directly.
|
|
146
|
+
if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
|
|
68
147
|
// Fallback to file mode
|
|
69
148
|
const content = getRuleFileContent(fileName)
|
|
70
|
-
if (!content) {
|
|
71
|
-
|
|
72
|
-
process.exit(1)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const ruleMap = parseEprc(content)
|
|
76
|
-
if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
|
|
77
|
-
output.error(`Pattern not found: ${pat}`)
|
|
78
|
-
process.exit(1)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
ruleMap[internalKey] = bm ? tgt + bm[0] : tgt
|
|
82
|
-
const newContent = ruleMapToEprcText(ruleMap)
|
|
149
|
+
if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
|
|
150
|
+
const newContent = applyUpdate(content, plan)
|
|
83
151
|
saveRuleFileContent(fileName, newContent)
|
|
84
152
|
output.warnFileOnlyMode()
|
|
85
|
-
|
|
86
|
-
if (jsonFlag) {
|
|
87
|
-
output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt,
|
|
88
|
-
fileOnly: true,
|
|
89
|
-
warning: output.FILE_ONLY_WARNING
|
|
90
|
-
})
|
|
91
|
-
return
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
output.header('Route Rule Updated')
|
|
96
|
-
output.success(`${pat} -> ${tgt}`)
|
|
97
|
-
output.kv('File', fileName)
|
|
98
|
-
}
|
|
99
|
-
} else {
|
|
100
|
-
// File mode
|
|
101
|
-
const content = getRuleFileContent(fileName)
|
|
102
|
-
if (!content) {
|
|
103
|
-
output.error(`Route file not found: ${fileName}`)
|
|
104
|
-
process.exit(1)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const ruleMap = parseEprc(content)
|
|
108
|
-
if (!Object.prototype.hasOwnProperty.call(ruleMap, internalKey)) {
|
|
109
|
-
output.error(`Pattern not found: ${pat}`)
|
|
110
|
-
process.exit(1)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
ruleMap[internalKey] = bm ? tgt + bm[0] : tgt
|
|
114
|
-
const newContent = ruleMapToEprcText(ruleMap)
|
|
115
|
-
saveRuleFileContent(fileName, newContent)
|
|
116
|
-
output.warnFileOnlyMode()
|
|
117
|
-
|
|
118
|
-
if (jsonFlag) {
|
|
119
|
-
output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt,
|
|
120
|
-
fileOnly: true,
|
|
121
|
-
warning: output.FILE_ONLY_WARNING
|
|
122
|
-
})
|
|
153
|
+
finish(newContent, true)
|
|
123
154
|
return
|
|
124
155
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
output.header('Route Rule Updated')
|
|
128
|
-
output.success(`${pat} -> ${tgt}`)
|
|
129
|
-
output.kv('File', fileName)
|
|
130
156
|
}
|
|
157
|
+
|
|
158
|
+
const content = getRuleFileContent(fileName)
|
|
159
|
+
if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
|
|
160
|
+
const newContent = applyUpdate(content, plan)
|
|
161
|
+
saveRuleFileContent(fileName, newContent)
|
|
162
|
+
output.warnFileOnlyMode()
|
|
163
|
+
finish(newContent, true)
|
|
131
164
|
}
|
|
132
165
|
|
|
133
166
|
run().catch(e => {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
})
|
|
167
|
+
if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
|
|
168
|
+
fail(e.message, jsonFlag)
|
|
169
|
+
})
|
package/bin/index.js
CHANGED
|
@@ -107,7 +107,8 @@ Route 命令:
|
|
|
107
107
|
meddle route active set <file> 设置激活的路由文件
|
|
108
108
|
meddle route create <name> 创建路由文件
|
|
109
109
|
meddle route add <file> <pattern> <target> 添加路由规则
|
|
110
|
-
meddle route update <file> <pattern> <target>
|
|
110
|
+
meddle route update <file> <pattern> [<target>] [--exclusions=]
|
|
111
|
+
更新路由规则;--exclusions= 清空排除项
|
|
111
112
|
meddle route delete <file> <pattern> 删除路由规则
|
|
112
113
|
|
|
113
114
|
全局选项:
|
|
@@ -137,6 +138,7 @@ Route 命令:
|
|
|
137
138
|
meddle route preview "https://api.example.com/v1/users" # 预览转发目标
|
|
138
139
|
meddle route preview "https://cdn.com/assets/js/app.js" --file dev-rules --json
|
|
139
140
|
meddle route add dev-rules "api.test.com" "localhost:3000"
|
|
141
|
+
meddle route update dev-rules "api.test.com" --exclusions=
|
|
140
142
|
meddle route active set beta-rules
|
|
141
143
|
|
|
142
144
|
更多信息请访问: https://github.com/JonathanLee-LX/meddle
|
package/bin/lib/api-client.js
CHANGED
|
@@ -25,14 +25,26 @@ async function apiRequest(method, pathname, body, timeoutMs = DEFAULT_TIMEOUT) {
|
|
|
25
25
|
|
|
26
26
|
if (!response.ok) {
|
|
27
27
|
const text = await response.text()
|
|
28
|
-
|
|
28
|
+
const err = new Error(text || response.statusText)
|
|
29
|
+
err.status = response.status
|
|
30
|
+
// Structured errors (e.g. issue #137 RULESET_READONLY) keep their code.
|
|
31
|
+
try {
|
|
32
|
+
const data = JSON.parse(text)
|
|
33
|
+
if (data && typeof data === 'object') {
|
|
34
|
+
if (typeof data.code === 'string') err.code = data.code
|
|
35
|
+
if (typeof data.ruleFile === 'string') err.ruleFile = data.ruleFile
|
|
36
|
+
}
|
|
37
|
+
} catch (_) {}
|
|
38
|
+
throw err
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
const contentType = response.headers.get('content-type')
|
|
32
42
|
if (contentType && contentType.includes('application/json')) {
|
|
33
43
|
return response.json()
|
|
34
44
|
}
|
|
35
|
-
|
|
45
|
+
// Issue #145: `Response.text()` strips a leading UTF-8 BOM, so a rule file
|
|
46
|
+
// read and written back through the API would silently lose it.
|
|
47
|
+
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(await response.arrayBuffer())
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
/**
|
package/bin/lib/file-access.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const fs = require('fs')
|
|
6
6
|
const path = require('path')
|
|
7
7
|
const { meddleDir } = require('./proxy-detect')
|
|
8
|
+
const { RulesetReadonlyError } = require('./ruleset-readonly')
|
|
8
9
|
|
|
9
10
|
const routeRulesDir = path.join(meddleDir, 'route-rules')
|
|
10
11
|
const settingsPath = path.join(meddleDir, 'settings.json')
|
|
@@ -51,6 +52,33 @@ function setActiveRuleFileNames(names) {
|
|
|
51
52
|
saveSettings(settings)
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Issue #137: locked rule files (settings.json `readonlyRuleFiles`). Entries
|
|
57
|
+
* whose file no longer exists are dropped silently. A missing field = none locked.
|
|
58
|
+
*/
|
|
59
|
+
function getReadonlyRuleFileNames() {
|
|
60
|
+
const settings = loadSettings()
|
|
61
|
+
if (!Object.prototype.hasOwnProperty.call(settings, 'readonlyRuleFiles')) return []
|
|
62
|
+
const raw = settings.readonlyRuleFiles
|
|
63
|
+
const names = Array.isArray(raw) ? raw.filter((n, i, arr) => typeof n === 'string' && n && arr.indexOf(n) === i) : []
|
|
64
|
+
const live = names.filter((name) => fs.existsSync(path.join(routeRulesDir, `${name}.txt`)))
|
|
65
|
+
if (!Array.isArray(raw) || live.length !== raw.length) {
|
|
66
|
+
settings.readonlyRuleFiles = live
|
|
67
|
+
saveSettings(settings)
|
|
68
|
+
}
|
|
69
|
+
return live
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isRuleFileReadonly(name) {
|
|
73
|
+
const fold = (n) => (process.platform === 'darwin' || process.platform === 'win32' ? n.toLowerCase() : n)
|
|
74
|
+
return getReadonlyRuleFileNames().some((item) => fold(item) === fold(name))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Throws RulesetReadonlyError (code RULESET_READONLY) for a locked rule file. */
|
|
78
|
+
function assertRuleFileWritable(name) {
|
|
79
|
+
if (isRuleFileReadonly(name)) throw new RulesetReadonlyError(name)
|
|
80
|
+
}
|
|
81
|
+
|
|
54
82
|
// ========== Mocks ==========
|
|
55
83
|
|
|
56
84
|
/**
|
|
@@ -217,6 +245,7 @@ function listRuleFiles() {
|
|
|
217
245
|
.map(f => f.replace(/\.txt$/, ''))
|
|
218
246
|
|
|
219
247
|
const ordered = resolveRuleFileOrder(files, getRuleFileOrderNames(), activeNames)
|
|
248
|
+
const readonlyNames = new Set(getReadonlyRuleFileNames())
|
|
220
249
|
|
|
221
250
|
return ordered.map(name => {
|
|
222
251
|
const filePath = getRuleFilePath(name)
|
|
@@ -234,6 +263,7 @@ function listRuleFiles() {
|
|
|
234
263
|
return {
|
|
235
264
|
name,
|
|
236
265
|
enabled: activeNames.includes(name),
|
|
266
|
+
readonly: readonlyNames.has(name),
|
|
237
267
|
ruleCount,
|
|
238
268
|
excludeCount
|
|
239
269
|
}
|
|
@@ -258,6 +288,7 @@ function getRuleFileContent(name) {
|
|
|
258
288
|
*/
|
|
259
289
|
function saveRuleFileContent(name, content) {
|
|
260
290
|
ensureRouteRulesDir()
|
|
291
|
+
assertRuleFileWritable(name)
|
|
261
292
|
const filePath = getRuleFilePath(name)
|
|
262
293
|
fs.writeFileSync(filePath, content, 'utf8')
|
|
263
294
|
}
|
|
@@ -274,6 +305,8 @@ function createRuleFile(name, content = '', enabled = true) {
|
|
|
274
305
|
throw new Error(`规则文件 "${safeName}" 已存在`)
|
|
275
306
|
}
|
|
276
307
|
|
|
308
|
+
// Drop stale lock entries so a new file never inherits an old lock.
|
|
309
|
+
getReadonlyRuleFileNames()
|
|
277
310
|
fs.writeFileSync(filePath, content, 'utf8')
|
|
278
311
|
|
|
279
312
|
if (enabled) {
|
|
@@ -285,7 +318,7 @@ function createRuleFile(name, content = '', enabled = true) {
|
|
|
285
318
|
}
|
|
286
319
|
|
|
287
320
|
const ruleCount = content.trim() ? Object.keys(parseEprc(content)).length : 0
|
|
288
|
-
return { name: safeName, enabled, ruleCount }
|
|
321
|
+
return { name: safeName, enabled, readonly: false, ruleCount }
|
|
289
322
|
}
|
|
290
323
|
|
|
291
324
|
/**
|
|
@@ -296,6 +329,7 @@ function deleteRuleFile(name) {
|
|
|
296
329
|
if (!fs.existsSync(filePath)) {
|
|
297
330
|
throw new Error('规则文件不存在')
|
|
298
331
|
}
|
|
332
|
+
assertRuleFileWritable(name)
|
|
299
333
|
fs.unlinkSync(filePath)
|
|
300
334
|
|
|
301
335
|
const activeNames = getActiveRuleFileNames()
|
|
@@ -317,6 +351,7 @@ function setRuleFileEnabled(name, enabled) {
|
|
|
317
351
|
|
|
318
352
|
const activeNames = getActiveRuleFileNames()
|
|
319
353
|
const idx = activeNames.indexOf(name)
|
|
354
|
+
if ((enabled && idx === -1) || (!enabled && idx !== -1)) assertRuleFileWritable(name)
|
|
320
355
|
|
|
321
356
|
if (enabled && idx === -1) {
|
|
322
357
|
activeNames.push(name)
|
|
@@ -352,6 +387,9 @@ module.exports = {
|
|
|
352
387
|
saveSettings,
|
|
353
388
|
getActiveRuleFileNames,
|
|
354
389
|
setActiveRuleFileNames,
|
|
390
|
+
getReadonlyRuleFileNames,
|
|
391
|
+
isRuleFileReadonly,
|
|
392
|
+
assertRuleFileWritable,
|
|
355
393
|
|
|
356
394
|
// Mocks
|
|
357
395
|
getMocksPath,
|
|
@@ -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
|
+
}
|