@jonathanleelx/meddle 0.5.0-beta.24 → 0.5.0-beta.26

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 (39) hide show
  1. package/README.md +1 -1
  2. package/bin/commands/route/add.js +98 -86
  3. package/bin/commands/route/delete.js +48 -25
  4. package/bin/commands/route/index.js +16 -2
  5. package/bin/commands/route/update.js +118 -87
  6. package/bin/index.js +7 -3
  7. package/bin/lib/output.js +4 -2
  8. package/bin/lib/route-write-args.js +150 -0
  9. package/dist/core/browser.d.ts +8 -1
  10. package/dist/core/browser.d.ts.map +1 -1
  11. package/dist/core/browser.js +239 -36
  12. package/dist/core/browser.js.map +1 -1
  13. package/dist/server/agent/tools.d.ts.map +1 -1
  14. package/dist/server/agent/tools.js +2 -1
  15. package/dist/server/agent/tools.js.map +1 -1
  16. package/index.js +3 -2
  17. package/package.json +1 -1
  18. package/web/dist/assets/{detail-panel-D56nCma2.js → detail-panel-CyOc7gn8.js} +1 -1
  19. package/web/dist/assets/{global-panel-shell-SKPshqTf.js → global-panel-shell-jXQTb9nw.js} +1 -1
  20. package/web/dist/assets/{health-panel-CvI-VsvG.js → health-panel-CSV7OqBv.js} +1 -1
  21. package/web/dist/assets/{index-Dcm171u1.js → index-CSXxaYkG.js} +2 -2
  22. package/web/dist/assets/{mobile-proxy-panel-cpkvsdBT.js → mobile-proxy-panel-a09S-R79.js} +1 -1
  23. package/web/dist/assets/{mock-config-CtZ866iI.js → mock-config-D_E5YOE_.js} +1 -1
  24. package/web/dist/assets/{mock-editor-panel-CweYBdeM.js → mock-editor-panel-t4TDXeaR.js} +1 -1
  25. package/web/dist/assets/{mocks-file-missing-note-C-QJ8Bpm.js → mocks-file-missing-note-Ddy1TdMX.js} +1 -1
  26. package/web/dist/assets/{monaco-editor-Cge8h-Ca.js → monaco-editor-GpPLOKA5.js} +1 -1
  27. package/web/dist/assets/{pane-bar-Dvi-MHuB.js → pane-bar-zJmE3xI5.js} +1 -1
  28. package/web/dist/assets/{pane-split-ChCHxR7K.js → pane-split-CJ1VXJDy.js} +1 -1
  29. package/web/dist/assets/{panel-heading-Crmy9fo0.js → panel-heading-DlFWoYAR.js} +1 -1
  30. package/web/dist/assets/{plugin-code-editor-VaL2lVyD.js → plugin-code-editor-CtaamKWJ.js} +1 -1
  31. package/web/dist/assets/{plugin-config-DSiOk77l.js → plugin-config-Dg_FCb-L.js} +2 -2
  32. package/web/dist/assets/{plugin-generator-iAksMY2Q.js → plugin-generator-CVREuZs7.js} +1 -1
  33. package/web/dist/assets/{plugin-test-dialog-Cx6ifDnm.js → plugin-test-dialog-B-iV8ocm.js} +1 -1
  34. package/web/dist/assets/{route-preview-T0s6si4V.js → route-preview-CLJeg_9v.js} +1 -1
  35. package/web/dist/assets/{rule-ai-assistant-panel-DzfobTWM.js → rule-ai-assistant-panel-BvTqm4zV.js} +1 -1
  36. package/web/dist/assets/{rule-config-ESpfkJg6.js → rule-config-DmNPaKxc.js} +1 -1
  37. package/web/dist/assets/{rule-overview-panel-BswSByar.js → rule-overview-panel-CB9b-b0t.js} +1 -1
  38. package/web/dist/assets/{settings-panel-Cj-1Z8Ow.js → settings-panel-DLdeJ8ox.js} +1 -1
  39. package/web/dist/index.html +1 -1
package/README.md CHANGED
@@ -188,7 +188,7 @@ meddle --remote --no-intercept-https
188
188
  - 传非空数组:用它替换原有的排除规则。
189
189
  - 如果一行写了多个 pattern,修改或删除其中一个时,若剩下的 pattern 以 `//` 或 `#` 开头(拆开后会被当成注释),写入会被拒绝并提示「该行剩余的 pattern 以 // 或 # 开头,拆开后会被当成注释,请用文本编辑器手动修改」,规则文件不变。
190
190
  - 新增规则的 pattern 不能以 `#` 或 `//` 开头(例如 `#foo.com`、`//foo.com`),否则写进文件后会被当成注释或禁用行。写入会被拒绝并提示「规则不能以 // 或 # 开头,否则会被当成禁用行或注释。要写注释或禁用规则,请用文本编辑器。」,规则文件不变。
191
- - CLI 没有单独的排除规则参数:`meddle route add/update` 的 pattern 参数里写了 `!path`(如 `"api.example.com !/health"`)就替换,不写就保留原有的;CLI 无法清空排除规则,需要清空时用 MCP 传 `[]`,或在 Web 界面里编辑。
191
+ - CLI:`meddle route update <file> <pattern> --exclusions=` 清空该规则的排除项(该行不再有 `!path`,其余行逐字节不变)。`--exclusions=/a,/b` 替换。pattern 里写 `!path`(如 `"api.example.com !/health"`)同样是替换。两者都不写则保留原有排除项。单独的 `--exclusions`(没有 `=`)、空的 `!`、未知参数,或 `!path` 与 `--exclusions` 同时出现,都会非 0 退出且不改文件。`#` 只有出现在一行开头(前导空白之后)才是整行注释;行尾的 `#` 不是注释。
192
192
 
193
193
  **只读规则集**([详细说明](./docs/RULESET_READONLY.md)):规则集(规则文件)可以在 Meddle Web 界面中加只读锁,防止 Agent 误改。
194
194
 
@@ -6,129 +6,141 @@ const output = require('../../lib/output')
6
6
  const { isProxyRunning } = require('../../lib/proxy-detect')
7
7
  const { apiGet, apiPut } = require('../../lib/api-client')
8
8
  const { getRuleFileContent, saveRuleFileContent } = require('../../lib/file-access')
9
- const { addRouteRule, splitPatternArg } = require('../../lib/rule-file-edit')
9
+ const { addRouteRule } = require('../../lib/rule-file-edit')
10
10
  const { isRulesetReadonlyError, exitRulesetReadonly } = require('../../lib/ruleset-readonly')
11
+ const { parseRouteWriteArgs, parsePatternArg, resolveExclusions } = require('../../lib/route-write-args')
12
+
13
+ const ADD_HELP = `
14
+ meddle route add <file> <pattern> <target> [--exclusions=<list>] [--json]
15
+
16
+ Add a rule. If the pattern already exists, its target is overwritten in place.
17
+ --exclusions= clears that rule's exclusions; --exclusions=/a,/b replaces them.
18
+ Omit --exclusions and any !path in the pattern to keep existing exclusions.
19
+ See: meddle route update --help
20
+ `
11
21
 
12
- // Parse arguments (skip 'route' and 'add' from argv)
13
22
  const args = process.argv.slice(4)
14
- const jsonFlag = args.includes('--json')
23
+
24
+ function fail(message, jsonFlag) {
25
+ if (jsonFlag) {
26
+ console.log(JSON.stringify({ success: false, error: { message } }))
27
+ console.error(message)
28
+ } else {
29
+ output.setJsonMode(false)
30
+ output.error(message)
31
+ }
32
+ process.exit(1)
33
+ }
34
+
35
+ let parsed
36
+ try {
37
+ parsed = parseRouteWriteArgs(args)
38
+ } catch (e) {
39
+ fail(e.message, args.includes('--json'))
40
+ }
41
+
42
+ if (parsed.help) {
43
+ console.log(ADD_HELP.trim())
44
+ process.exit(0)
45
+ }
46
+
47
+ const jsonFlag = parsed.json
15
48
  output.setJsonMode(jsonFlag)
16
49
 
17
- // Get positional arguments: file, pattern, target
18
- const positionalArgs = args.filter(a => !a.startsWith('--'))
19
- const fileName = positionalArgs[0]
20
- const pattern = positionalArgs[1]
21
- const target = positionalArgs[2]
50
+ const fileName = parsed.positional[0]
51
+ const pattern = parsed.positional[1]
52
+ const target = parsed.positional[2]
53
+
54
+ let explicitExclusions = false
55
+ let appliedExclusions = []
56
+
57
+ function planAdd() {
58
+ const { patterns, exclusions: patternExclusions } = parsePatternArg(pattern)
59
+ if (patterns.length === 0) fail('Missing required argument: <pattern>', jsonFlag)
60
+ const resolved = resolveExclusions(patternExclusions, parsed.exclusions)
61
+ explicitExclusions = resolved.explicit
62
+ appliedExclusions = resolved.exclusions || []
63
+ return { patterns, exclusions: resolved.explicit ? resolved.exclusions : undefined }
64
+ }
22
65
 
23
66
  /**
24
67
  * Issue #145: edit only the line(s) holding this pattern; comments, blank
25
68
  * lines and other rules (with their `!exclusions`) keep their exact bytes.
26
- * Exclusions may be given inside the pattern argument ("app.example.com !/api").
69
+ * Exclusions may be given inside the pattern argument ("app.example.com !/api")
70
+ * or with --exclusions. `--exclusions=` clears them (issue #149).
27
71
  */
28
- function applyAdd(text) {
29
- const { patterns, exclusions } = splitPatternArg(pattern)
30
- if (patterns.length === 0) {
31
- output.error('Missing required argument: <pattern>')
32
- process.exit(1)
33
- }
72
+ function applyAdd(text, plan) {
34
73
  let next = text
35
- for (const p of patterns) {
36
- next = addRouteRule(next, { pattern: p, target: target.trim(), exclusions: exclusions.length ? exclusions : undefined }).content
74
+ for (const p of plan.patterns) {
75
+ next = addRouteRule(next, { pattern: p, target: target.trim(), exclusions: plan.exclusions }).content
37
76
  }
38
77
  return next
39
78
  }
40
79
 
41
- async function run() {
42
- if (!fileName) {
43
- output.error('Missing required argument: <file>')
44
- process.exit(1)
80
+ function emitAdded({ fileOnly }) {
81
+ const payload = { success: true, file: fileName, pattern: pattern.trim(), target: target.trim() }
82
+ if (explicitExclusions) payload.exclusions = appliedExclusions
83
+ if (fileOnly) {
84
+ payload.fileOnly = true
85
+ payload.warning = output.FILE_ONLY_WARNING
45
86
  }
46
- if (!pattern) {
47
- output.error('Missing required argument: <pattern>')
48
- process.exit(1)
87
+ if (jsonFlag) {
88
+ output.jsonRaw(payload)
89
+ return
49
90
  }
50
- if (!target) {
51
- output.error('Missing required argument: <target>')
52
- process.exit(1)
91
+ output.header('Route Rule Added')
92
+ output.success(`${pattern.trim()} -> ${target.trim()}`)
93
+ if (explicitExclusions) {
94
+ output.kv('Exclusions', appliedExclusions.length ? appliedExclusions.map((e) => `!${e}`).join(' ') : 'cleared')
53
95
  }
96
+ output.kv('File', fileName)
97
+ }
54
98
 
55
- const running = await isProxyRunning()
99
+ async function run() {
100
+ if (!fileName) fail('Missing required argument: <file>', jsonFlag)
101
+ if (!pattern) fail('Missing required argument: <pattern>', jsonFlag)
102
+ if (!target) fail('Missing required argument: <target>', jsonFlag)
103
+ if (parsed.positional.length > 3) fail(`Unexpected argument: ${parsed.positional[3]}`, jsonFlag)
104
+
105
+ let plan
106
+ try {
107
+ plan = planAdd()
108
+ } catch (e) {
109
+ fail(e.message, jsonFlag)
110
+ }
56
111
 
57
- const pat = pattern.trim()
58
- const tgt = target.trim()
112
+ const running = await isProxyRunning()
59
113
 
60
114
  if (running) {
61
115
  try {
62
- // Get current content
63
116
  let content = await apiGet(`/api/rule-files/${encodeURIComponent(fileName)}/content`)
64
117
  const text = typeof content === 'string' ? content : String(content)
65
- const newContent = applyAdd(text)
118
+ const newContent = applyAdd(text, plan)
66
119
  await apiPut(`/api/rule-files/${encodeURIComponent(fileName)}/content`, { content: newContent })
67
-
68
- if (jsonFlag) {
69
- output.jsonRaw({ success: true, file: fileName, pattern: pat, target: tgt })
70
- return
71
- }
72
-
73
- output.header('Route Rule Added')
74
- output.success(`${pat} -> ${tgt}`)
75
- output.kv('File', fileName)
120
+ emitAdded({ fileOnly: false })
121
+ return
76
122
  } catch (e) {
77
123
  // Issue #137: a locked ruleset is a final answer, never a cue to write the file directly.
78
124
  if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
79
- // Fallback to file mode
80
125
  const content = getRuleFileContent(fileName)
81
- if (!content) {
82
- output.error(`Route file not found: ${fileName}`)
83
- process.exit(1)
84
- }
85
-
86
- const newContent = applyAdd(content)
126
+ if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
127
+ const newContent = applyAdd(content, plan)
87
128
  saveRuleFileContent(fileName, newContent)
88
129
  output.warnFileOnlyMode()
89
-
90
- if (jsonFlag) {
91
- output.jsonRaw({ success: true, file: fileName, pattern: pat, target: tgt,
92
- fileOnly: true,
93
- warning: output.FILE_ONLY_WARNING
94
- })
95
- return
96
- }
97
-
98
-
99
- output.header('Route Rule Added')
100
- output.success(`${pat} -> ${tgt}`)
101
- output.kv('File', fileName)
102
- }
103
- } else {
104
- // File mode
105
- const content = getRuleFileContent(fileName)
106
- if (!content) {
107
- output.error(`Route file not found: ${fileName}`)
108
- process.exit(1)
109
- }
110
-
111
- const newContent = applyAdd(content)
112
- saveRuleFileContent(fileName, newContent)
113
- output.warnFileOnlyMode()
114
-
115
- if (jsonFlag) {
116
- output.jsonRaw({ success: true, file: fileName, pattern: pat, target: tgt,
117
- fileOnly: true,
118
- warning: output.FILE_ONLY_WARNING
119
- })
130
+ emitAdded({ fileOnly: true })
120
131
  return
121
132
  }
122
-
123
-
124
- output.header('Route Rule Added')
125
- output.success(`${pat} -> ${tgt}`)
126
- output.kv('File', fileName)
127
133
  }
134
+
135
+ const content = getRuleFileContent(fileName)
136
+ if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
137
+ const newContent = applyAdd(content, plan)
138
+ saveRuleFileContent(fileName, newContent)
139
+ output.warnFileOnlyMode()
140
+ emitAdded({ fileOnly: true })
128
141
  }
129
142
 
130
143
  run().catch(e => {
131
144
  if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
132
- output.error(e.message)
133
- process.exit(1)
145
+ fail(e.message, jsonFlag)
134
146
  })
@@ -6,35 +6,61 @@ const output = require('../../lib/output')
6
6
  const { isProxyRunning } = require('../../lib/proxy-detect')
7
7
  const { apiGet, apiPut } = require('../../lib/api-client')
8
8
  const { getRuleFileContent, saveRuleFileContent } = require('../../lib/file-access')
9
- const { deleteRouteRule, splitPatternArg } = require('../../lib/rule-file-edit')
9
+ const { deleteRouteRule } = require('../../lib/rule-file-edit')
10
10
  const { isRulesetReadonlyError, exitRulesetReadonly } = require('../../lib/ruleset-readonly')
11
+ const { parseRouteWriteArgs, parsePatternArg } = require('../../lib/route-write-args')
12
+
13
+ const DELETE_HELP = `
14
+ meddle route delete <file> <pattern> [--json]
15
+
16
+ Delete a rule. This command does not take --exclusions.
17
+ `
11
18
 
12
- // Parse arguments (skip 'route' and 'delete' from argv)
13
19
  const args = process.argv.slice(4)
14
- const jsonFlag = args.includes('--json')
20
+
21
+ function fail(message, jsonFlag) {
22
+ if (jsonFlag) {
23
+ console.log(JSON.stringify({ success: false, error: { message } }))
24
+ console.error(message)
25
+ } else {
26
+ output.setJsonMode(false)
27
+ output.error(message)
28
+ }
29
+ process.exit(1)
30
+ }
31
+
32
+ let parsed
33
+ try {
34
+ parsed = parseRouteWriteArgs(args, { allowExclusions: false })
35
+ } catch (e) {
36
+ fail(e.message, args.includes('--json'))
37
+ }
38
+
39
+ if (parsed.help) {
40
+ console.log(DELETE_HELP.trim())
41
+ process.exit(0)
42
+ }
43
+
44
+ const jsonFlag = parsed.json
15
45
  output.setJsonMode(jsonFlag)
16
46
 
17
- // Get positional arguments: file, pattern
18
- const positionalArgs = args.filter(a => !a.startsWith('--'))
19
- const fileName = positionalArgs[0]
20
- const pattern = positionalArgs[1]
47
+ const fileName = parsed.positional[0]
48
+ const pattern = parsed.positional[1]
21
49
 
22
50
  /**
23
51
  * Issue #145: remove only this pattern; every other line (comments, blank
24
52
  * lines, other rules and their `!exclusions`) keeps its exact bytes.
25
53
  */
26
54
  function applyDelete(text) {
27
- const { patterns } = splitPatternArg(pattern)
55
+ const { patterns } = parsePatternArg(pattern)
28
56
  if (patterns.length === 0) {
29
- output.error('Missing required argument: <pattern>')
30
- process.exit(1)
57
+ fail('Missing required argument: <pattern>', jsonFlag)
31
58
  }
32
59
  let next = text
33
60
  for (const p of patterns) {
34
61
  const result = deleteRouteRule(next, { pattern: p })
35
62
  if (!result.found) {
36
- output.error(`Pattern not found: ${pattern.trim()}`)
37
- process.exit(1)
63
+ fail(`Pattern not found: ${pattern.trim()}`, jsonFlag)
38
64
  }
39
65
  next = result.content
40
66
  }
@@ -42,13 +68,13 @@ function applyDelete(text) {
42
68
  }
43
69
 
44
70
  async function run() {
45
- if (!fileName) {
46
- output.error('Missing required argument: <file>')
47
- process.exit(1)
48
- }
49
- if (!pattern) {
50
- output.error('Missing required argument: <pattern>')
51
- process.exit(1)
71
+ if (!fileName) fail('Missing required argument: <file>', jsonFlag)
72
+ if (!pattern) fail('Missing required argument: <pattern>', jsonFlag)
73
+ if (parsed.positional.length > 2) fail(`Unexpected argument: ${parsed.positional[2]}`, jsonFlag)
74
+ try {
75
+ parsePatternArg(pattern)
76
+ } catch (e) {
77
+ fail(e.message, jsonFlag)
52
78
  }
53
79
 
54
80
  const running = await isProxyRunning()
@@ -76,8 +102,7 @@ async function run() {
76
102
  // Fallback to file mode
77
103
  const content = getRuleFileContent(fileName)
78
104
  if (!content) {
79
- output.error(`Route file not found: ${fileName}`)
80
- process.exit(1)
105
+ fail(`Route file not found: ${fileName}`, jsonFlag)
81
106
  }
82
107
 
83
108
  const newContent = applyDelete(content)
@@ -101,8 +126,7 @@ async function run() {
101
126
  // File mode
102
127
  const content = getRuleFileContent(fileName)
103
128
  if (!content) {
104
- output.error(`Route file not found: ${fileName}`)
105
- process.exit(1)
129
+ fail(`Route file not found: ${fileName}`, jsonFlag)
106
130
  }
107
131
 
108
132
  const newContent = applyDelete(content)
@@ -126,6 +150,5 @@ async function run() {
126
150
 
127
151
  run().catch(e => {
128
152
  if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
129
- output.error(e.message)
130
- process.exit(1)
153
+ fail(e.message, jsonFlag)
131
154
  })
@@ -39,8 +39,10 @@ Route Commands:
39
39
  meddle route active Show active route files
40
40
  meddle route active set <file> Set active route file
41
41
  meddle route create <name> [--content <text>] Create a route file
42
- meddle route add <file> <pattern> <target> Add a rule to file
43
- meddle route update <file> <pattern> <target> Update a rule in file
42
+ meddle route add <file> <pattern> <target> [--exclusions=<list>]
43
+ Add a rule to file
44
+ meddle route update <file> <pattern> [<target>] [--exclusions=<list>]
45
+ Update a rule in file
44
46
  meddle route delete <file> <pattern> Delete a rule from file
45
47
 
46
48
  Read-only route files:
@@ -57,7 +59,19 @@ Examples:
57
59
  meddle route create staging --content "example.com localhost:3000"
58
60
  meddle route add dev-rules "api.test.com" "localhost:8080"
59
61
  meddle route update dev-rules "api.test.com" "localhost:9000"
62
+ meddle route update dev-rules "api.test.com" --exclusions=
60
63
  meddle route delete dev-rules "api.test.com"
64
+
65
+ Exclusions:
66
+ Omit --exclusions and any !path in the pattern to keep the rule's exclusions.
67
+ --exclusions= Clear exclusions (that line loses every !path).
68
+ --exclusions=/a,/b Replace exclusions. A leading ! is optional.
69
+ "pattern !/a" Replace exclusions. A bare "!" is an error.
70
+ A bare --exclusions, an unknown flag, or both forms at once exits 1
71
+ and does not change the file. meddle route update --help has the details.
72
+
73
+ A "#" comments out a whole line only when it is the first non-space
74
+ character. A "#" later on a rule line is a pattern, not a trailing comment.
61
75
  `)
62
76
  process.exit(1)
63
77
  }
@@ -1,138 +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
11
  const { getRuleFileContent, saveRuleFileContent } = require('../../lib/file-access')
9
- const { updateRouteRule, splitPatternArg } = require('../../lib/rule-file-edit')
12
+ const { updateRouteRule, patternKey, parseRuleLine } = require('../../lib/rule-file-edit')
10
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')
11
20
 
12
- // Parse arguments (skip 'route' and 'update' from argv)
13
21
  const args = process.argv.slice(4)
14
- const jsonFlag = args.includes('--json')
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
15
47
  output.setJsonMode(jsonFlag)
16
48
 
17
- // Get positional arguments: file, pattern, newTarget
18
- const positionalArgs = args.filter(a => !a.startsWith('--'))
19
- const fileName = positionalArgs[0]
20
- const pattern = positionalArgs[1]
21
- const newTarget = positionalArgs[2]
49
+ const fileName = parsed.positional[0]
50
+ const pattern = parsed.positional[1]
51
+ const newTarget = parsed.positional[2]
52
+
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)
61
+ }
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
+ }
22
67
 
23
68
  /**
24
69
  * Issue #145: rewrite only the line(s) holding this pattern. The rule keeps its
25
- * own `!exclusions` unless new ones are given in the pattern argument; every
26
- * other line keeps its exact bytes.
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.
27
72
  */
28
- function applyUpdate(text) {
29
- const { patterns, exclusions } = splitPatternArg(pattern)
30
- if (patterns.length === 0) {
31
- output.error('Missing required argument: <pattern>')
32
- process.exit(1)
33
- }
73
+ function applyUpdate(text, plan) {
74
+ const target = newTarget ? newTarget.trim() : undefined
34
75
  let next = text
35
- for (const p of patterns) {
36
- const result = updateRouteRule(next, { pattern: p, target: newTarget.trim(), exclusions: exclusions.length ? exclusions : undefined })
76
+ for (const p of plan.patterns) {
77
+ const result = updateRouteRule(next, { pattern: p, target, exclusions: plan.exclusions })
37
78
  if (!result.found) {
38
- output.error(`Pattern not found: ${pattern.trim()}`)
39
- process.exit(1)
79
+ fail(`Pattern not found: ${pattern.trim()}`, jsonFlag)
40
80
  }
41
81
  next = result.content
42
82
  }
43
83
  return next
44
84
  }
45
85
 
46
- async function run() {
47
- if (!fileName) {
48
- output.error('Missing required argument: <file>')
49
- process.exit(1)
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
93
+ }
94
+ return ''
95
+ }
96
+
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
50
103
  }
51
- if (!pattern) {
52
- output.error('Missing required argument: <pattern>')
53
- process.exit(1)
104
+ if (jsonFlag) {
105
+ output.jsonRaw(payload)
106
+ return
54
107
  }
55
- if (!newTarget) {
56
- output.error('Missing required argument: <target>')
57
- process.exit(1)
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')
58
112
  }
113
+ output.kv('File', fileName)
114
+ }
115
+
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)
59
128
 
60
129
  const running = await isProxyRunning()
61
130
 
62
- const pat = pattern.trim()
63
- const tgt = newTarget.trim()
131
+ const finish = (content, fileOnly) => {
132
+ const target = newTarget ? newTarget.trim() : targetOnDisk(content, plan.patterns[0])
133
+ emitUpdated({ fileOnly, target })
134
+ }
64
135
 
65
136
  if (running) {
66
137
  try {
67
138
  let content = await apiGet(`/api/rule-files/${encodeURIComponent(fileName)}/content`)
68
139
  const text = typeof content === 'string' ? content : String(content)
69
- const newContent = applyUpdate(text)
140
+ const newContent = applyUpdate(text, plan)
70
141
  await apiPut(`/api/rule-files/${encodeURIComponent(fileName)}/content`, { content: newContent })
71
-
72
- if (jsonFlag) {
73
- output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt })
74
- return
75
- }
76
-
77
- output.header('Route Rule Updated')
78
- output.success(`${pat} -> ${tgt}`)
79
- output.kv('File', fileName)
142
+ finish(newContent, false)
143
+ return
80
144
  } catch (e) {
81
145
  // Issue #137: a locked ruleset is a final answer, never a cue to write the file directly.
82
146
  if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
83
147
  // Fallback to file mode
84
148
  const content = getRuleFileContent(fileName)
85
- if (!content) {
86
- output.error(`Route file not found: ${fileName}`)
87
- process.exit(1)
88
- }
89
-
90
- const newContent = applyUpdate(content)
149
+ if (!content) fail(`Route file not found: ${fileName}`, jsonFlag)
150
+ const newContent = applyUpdate(content, plan)
91
151
  saveRuleFileContent(fileName, newContent)
92
152
  output.warnFileOnlyMode()
93
-
94
- if (jsonFlag) {
95
- output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt,
96
- fileOnly: true,
97
- warning: output.FILE_ONLY_WARNING
98
- })
99
- return
100
- }
101
-
102
-
103
- output.header('Route Rule Updated')
104
- output.success(`${pat} -> ${tgt}`)
105
- output.kv('File', fileName)
106
- }
107
- } else {
108
- // File mode
109
- const content = getRuleFileContent(fileName)
110
- if (!content) {
111
- output.error(`Route file not found: ${fileName}`)
112
- process.exit(1)
113
- }
114
-
115
- const newContent = applyUpdate(content)
116
- saveRuleFileContent(fileName, newContent)
117
- output.warnFileOnlyMode()
118
-
119
- if (jsonFlag) {
120
- output.jsonRaw({ success: true, file: fileName, pattern: pat, newTarget: tgt,
121
- fileOnly: true,
122
- warning: output.FILE_ONLY_WARNING
123
- })
153
+ finish(newContent, true)
124
154
  return
125
155
  }
126
-
127
-
128
- output.header('Route Rule Updated')
129
- output.success(`${pat} -> ${tgt}`)
130
- output.kv('File', fileName)
131
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)
132
164
  }
133
165
 
134
166
  run().catch(e => {
135
167
  if (isRulesetReadonlyError(e)) exitRulesetReadonly(e, { json: jsonFlag, ruleFile: fileName })
136
- output.error(e.message)
137
- process.exit(1)
138
- })
168
+ fail(e.message, jsonFlag)
169
+ })