@jonathanleelx/meddle 0.5.0-beta.9 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIG_STRUCTURE.md +9 -2
- package/README.md +18 -0
- package/bin/commands/mock/add.js +59 -21
- package/bin/commands/mock/delete.js +16 -24
- package/bin/commands/mock/index.js +59 -25
- package/bin/commands/mock/list.js +35 -18
- package/bin/commands/mock/toggle.js +17 -27
- package/bin/commands/mock/update.js +39 -30
- package/bin/commands/route/active.js +20 -3
- package/bin/commands/route/add.js +113 -94
- package/bin/commands/route/create.js +8 -1
- package/bin/commands/route/delete.js +87 -53
- package/bin/commands/route/index.js +74 -26
- package/bin/commands/route/list.js +2 -2
- package/bin/commands/route/preview.js +2 -2
- package/bin/commands/route/show.js +12 -3
- package/bin/commands/route/update.js +135 -92
- package/bin/commands/status.js +27 -11
- package/bin/commands/update.js +4 -0
- package/bin/doctor.js +8 -4
- package/bin/index.js +11 -7
- package/bin/lib/api-client.js +14 -2
- package/bin/lib/file-access.js +234 -35
- package/bin/lib/meddle-home.js +14 -1
- package/bin/lib/output.js +30 -4
- package/bin/lib/proxy-detect.js +99 -20
- package/bin/lib/proxy-or-file.js +93 -0
- 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/bin/lib/update-check.js +67 -6
- package/dist/cert.d.ts +2 -0
- package/dist/cert.d.ts.map +1 -1
- package/dist/cert.js +53 -1
- package/dist/cert.js.map +1 -1
- package/dist/core/browser.d.ts +10 -1
- package/dist/core/browser.d.ts.map +1 -1
- package/dist/core/browser.js +252 -36
- package/dist/core/browser.js.map +1 -1
- package/dist/core/config-diagnostics.d.ts.map +1 -1
- package/dist/core/config-diagnostics.js +6 -1
- package/dist/core/config-diagnostics.js.map +1 -1
- package/dist/core/custom-plugin-loader.d.ts.map +1 -1
- package/dist/core/custom-plugin-loader.js +2 -0
- package/dist/core/custom-plugin-loader.js.map +1 -1
- package/dist/core/mock-handler.d.ts +63 -0
- package/dist/core/mock-handler.d.ts.map +1 -1
- package/dist/core/mock-handler.js +641 -29
- package/dist/core/mock-handler.js.map +1 -1
- package/dist/core/mock-utils.d.ts +58 -0
- package/dist/core/mock-utils.d.ts.map +1 -0
- package/dist/core/mock-utils.js +167 -0
- package/dist/core/mock-utils.js.map +1 -0
- package/dist/core/plugin-bootstrap-runner.d.ts +1 -0
- package/dist/core/plugin-bootstrap-runner.d.ts.map +1 -1
- package/dist/core/plugin-bootstrap-runner.js +37 -1
- package/dist/core/plugin-bootstrap-runner.js.map +1 -1
- package/dist/core/plugin-source.d.ts +24 -0
- package/dist/core/plugin-source.d.ts.map +1 -0
- package/dist/core/plugin-source.js +47 -0
- package/dist/core/plugin-source.js.map +1 -0
- package/dist/core/proxy-record.d.ts +14 -0
- package/dist/core/proxy-record.d.ts.map +1 -1
- package/dist/core/proxy-record.js +39 -0
- package/dist/core/proxy-record.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/core/types.d.ts +15 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/plugins/builtin/mock-plugin.d.ts.map +1 -1
- package/dist/plugins/builtin/mock-plugin.js +6 -5
- package/dist/plugins/builtin/mock-plugin.js.map +1 -1
- package/dist/server/agent/tools.d.ts.map +1 -1
- package/dist/server/agent/tools.js +24 -18
- package/dist/server/agent/tools.js.map +1 -1
- package/dist/server/app-info.d.ts +33 -0
- package/dist/server/app-info.d.ts.map +1 -0
- package/dist/server/app-info.js +116 -0
- package/dist/server/app-info.js.map +1 -0
- package/dist/server/config.d.ts +1 -0
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +114 -16
- package/dist/server/config.js.map +1 -1
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +8 -0
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +16 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +10 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/json-parse-error.d.ts +7 -0
- package/dist/server/json-parse-error.d.ts.map +1 -0
- package/dist/server/json-parse-error.js +28 -0
- package/dist/server/json-parse-error.js.map +1 -0
- package/dist/server/mocks.d.ts +26 -0
- package/dist/server/mocks.d.ts.map +1 -1
- package/dist/server/mocks.js +110 -13
- package/dist/server/mocks.js.map +1 -1
- package/dist/server/plugins.d.ts.map +1 -1
- package/dist/server/plugins.js +56 -11
- package/dist/server/plugins.js.map +1 -1
- package/dist/server/rule-files.d.ts +36 -0
- package/dist/server/rule-files.d.ts.map +1 -1
- package/dist/server/rule-files.js +388 -107
- 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/index.js +128 -16
- package/mcp-server.js +108 -93
- package/package.json +1 -1
- package/plugins/builtin/mock-plugin.ts +9 -5
- package/web/dist/assets/alert-B514Frte.js +1 -0
- package/web/dist/assets/arrow-right-BNum_060.js +1 -0
- package/web/dist/assets/{body-diff-view-B-4XWmL6.js → body-diff-view-SfLCLzs9.js} +2 -2
- package/web/dist/assets/button-BFNlGZ5p.js +1 -0
- package/web/dist/assets/check-BkfHQ6re.js +1 -0
- package/web/dist/assets/checkbox-CQyFsZzS.js +1 -0
- package/web/dist/assets/chevron-down-DHwUtL4O.js +1 -0
- package/web/dist/assets/{circle-alert-Q8cZk9Rv.js → circle-alert-zMagrFuI.js} +1 -1
- package/web/dist/assets/{circle-x-C9USji_R.js → circle-x-BEkinhez.js} +1 -1
- package/web/dist/assets/code-DD-AYxxu.js +1 -0
- package/web/dist/assets/{copy-BYjq1hdN.js → copy-BRnscYy1.js} +1 -1
- package/web/dist/assets/detail-panel-DZRKJG2W.js +4 -0
- package/web/dist/assets/dist-BQN0rzUS.js +1 -0
- package/web/dist/assets/{dist-CEd_wnRC.js → dist-C-E2R2jk.js} +1 -1
- package/web/dist/assets/{dist-C6eaYCUo.js → dist-CFZ4xMSF.js} +1 -1
- package/web/dist/assets/{dist-DWmDaXVN.js → dist-CRmX_NvH.js} +1 -1
- package/web/dist/assets/{dist-C8RHq5At.js → dist-DLQKRjuW.js} +1 -1
- package/web/dist/assets/{dist-Blyulj4_.js → dist-LiAcI8CK.js} +1 -1
- package/web/dist/assets/{dist-Ce4ujolc.js → dist-fzPL-8F9.js} +1 -1
- package/web/dist/assets/{dist-DoE7uybN.js → dist-wv9ebJK_.js} +1 -1
- package/web/dist/assets/editor-pane-actions-_ckwcCII.js +1 -0
- package/web/dist/assets/{es2015-DG46NGN7.js → es2015-DPJTBYa9.js} +1 -1
- package/web/dist/assets/global-panel-shell-Iir-c_pQ.js +6 -0
- package/web/dist/assets/health-panel-Dmf-EhJz.js +1 -0
- package/web/dist/assets/index-C4dIj1nT.js +21 -0
- package/web/dist/assets/index-CdYMl1M7.css +2 -0
- package/web/dist/assets/{mobile-proxy-panel-tU0NyJ3P.js → mobile-proxy-panel-CFcwa11A.js} +6 -6
- package/web/dist/assets/mock-config-CGl50MIr.js +1 -0
- package/web/dist/assets/mock-editor-panel-xcCk23oH.js +20 -0
- package/web/dist/assets/mocks-file-missing-note-CW_ONVLA.js +1 -0
- package/web/dist/assets/monaco-editor-BkP7envQ.js +11 -0
- package/web/dist/assets/pane-bar-5j9Nf7W_.js +1 -0
- package/web/dist/assets/pane-split-C0OSe365.js +1 -0
- package/web/dist/assets/panel-heading-izT5q0ZN.js +1 -0
- package/web/dist/assets/{pencil-line-Cqe41E4h.js → pencil-line-CEZ7D-de.js} +1 -1
- package/web/dist/assets/plugin-code-editor-DKE9dFEE.js +2 -0
- package/web/dist/assets/plugin-config-Cg09TsuV.js +2 -0
- package/web/dist/assets/plugin-generator-CT1f1tu0.js +2 -0
- package/web/dist/assets/plugin-test-dialog-BbLIMnkJ.js +7 -0
- package/web/dist/assets/plus-ixZQRprm.js +1 -0
- package/web/dist/assets/{refresh-cw-CG3HaiQT.js → refresh-cw-BfLoHqg5.js} +1 -1
- package/web/dist/assets/rotate-ccw-CRguJya4.js +1 -0
- package/web/dist/assets/route-preview-CKO7nF4W.js +1 -0
- package/web/dist/assets/{rule-ai-assistant-panel-DMh0F9dr.js → rule-ai-assistant-panel-CtfUqdYG.js} +2 -2
- package/web/dist/assets/rule-config-Cb4ejIXI.js +15 -0
- package/web/dist/assets/rule-overview-panel-xtHTS27O.js +1 -0
- package/web/dist/assets/{server-Cpj4KNwN.js → server-eYXq3PbE.js} +1 -1
- package/web/dist/assets/settings-panel-CpQuE6nr.js +1 -0
- package/web/dist/assets/{shield-check-OqUmVLQt.js → shield-check-hQXjdfZ7.js} +1 -1
- package/web/dist/assets/smartphone-D4Q7cQfm.js +1 -0
- package/web/dist/assets/switch-94vgsMHm.js +1 -0
- package/web/dist/assets/{syntax-highlight-Dk0-ewLI.js → syntax-highlight-BFgN_C0Y.js} +1 -1
- package/web/dist/assets/{table-2-BR5e3z8m.js → table-2-DvGHhMm4.js} +1 -1
- package/web/dist/assets/table-DaXD6jJv.js +1 -0
- package/web/dist/assets/textarea-9wB8Hsz-.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/assets/{wand-sparkles-CJ7fSnom.js → wand-sparkles-Bmt4dDOf.js} +1 -1
- package/web/dist/assets/{wifi-CCLKyySQ.js → wifi-Cjj_Br1u.js} +1 -1
- package/web/dist/assets/{zap-DR-xiUMe.js → zap-BuOtogAv.js} +1 -1
- package/web/dist/index.html +16 -10
- package/web/dist/assets/alert-DCvlZAiv.js +0 -1
- package/web/dist/assets/arrow-right-B4Mrlj2-.js +0 -1
- package/web/dist/assets/button-BTToMaBW.js +0 -1
- package/web/dist/assets/checkbox-D1Z9DtjF.js +0 -1
- package/web/dist/assets/chevron-down-CPngAx4M.js +0 -1
- package/web/dist/assets/chevron-right-V1DGIDbL.js +0 -1
- package/web/dist/assets/code-5_eJ_XpJ.js +0 -1
- package/web/dist/assets/code-fixer-D48sCDtp.js +0 -20
- package/web/dist/assets/detail-panel-BSFz9CUw.js +0 -4
- package/web/dist/assets/file-system-access-_og-Tg9o.js +0 -1
- package/web/dist/assets/global-panel-shell-5cJneeis.js +0 -6
- package/web/dist/assets/health-panel-BS3L2e5d.js +0 -1
- package/web/dist/assets/index-CvTR1IVi.js +0 -18
- package/web/dist/assets/index-bfV7sxx5.css +0 -2
- package/web/dist/assets/mock-config-CULJP6Vt.js +0 -1
- package/web/dist/assets/mock-editor-panel-9wlDaySA.js +0 -1
- package/web/dist/assets/monaco-editor-BttaUoOz.js +0 -11
- package/web/dist/assets/plugin-code-editor-BAIBp4lg.js +0 -2
- package/web/dist/assets/plugin-config-ButUV2SZ.js +0 -1
- package/web/dist/assets/plugin-generator-uceUPSCv.js +0 -2
- package/web/dist/assets/plugin-test-dialog-BrJ4HfRX.js +0 -7
- package/web/dist/assets/plus-tVxSydJ8.js +0 -1
- package/web/dist/assets/rotate-ccw-CAYM6Vv0.js +0 -1
- package/web/dist/assets/route-preview-C4cGrS1-.js +0 -1
- package/web/dist/assets/rule-config-BTTGNwMs.js +0 -15
- package/web/dist/assets/rule-overview-panel-SFR2OloL.js +0 -1
- package/web/dist/assets/select-aBXQ0zNa.js +0 -1
- package/web/dist/assets/settings-panel-BMReDG8b.js +0 -1
- package/web/dist/assets/smartphone-DKeDuJTU.js +0 -1
- package/web/dist/assets/switch-Bes_YiJu.js +0 -1
- package/web/dist/assets/table-nW7uQVcl.js +0 -1
- package/web/dist/assets/textarea-BSI0rnW6.js +0 -1
- /package/web/dist/assets/{babel-DCr4VA1c.js → babel-bwZm0qi3.js} +0 -0
- /package/web/dist/assets/{clipboard-VhPXNN5m.js → clipboard-Bt1qY01V.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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether a proxy API failure may fall back to a direct file write.
|
|
3
|
+
*
|
|
4
|
+
* Fall back only when the proxy is not running, or the request fails before
|
|
5
|
+
* any HTTP response (connection refused, timeout, DNS). A 4xx/5xx is the
|
|
6
|
+
* proxy's answer: callers print that body and must not touch the file.
|
|
7
|
+
* Treating a 409 (corrupt mocks.json) as "proxy down" rewrote the file and
|
|
8
|
+
* dropped every existing rule.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const output = require('./output')
|
|
12
|
+
const { isProxyRunning } = require('./proxy-detect')
|
|
13
|
+
|
|
14
|
+
const UNREACHABLE_CODES = new Set([
|
|
15
|
+
'ECONNREFUSED',
|
|
16
|
+
'ECONNRESET',
|
|
17
|
+
'ENOTFOUND',
|
|
18
|
+
'EAI_AGAIN',
|
|
19
|
+
'EHOSTUNREACH',
|
|
20
|
+
'ENETUNREACH',
|
|
21
|
+
'EPIPE',
|
|
22
|
+
'ETIMEDOUT',
|
|
23
|
+
'ECONNABORTED',
|
|
24
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
25
|
+
'UND_ERR_SOCKET',
|
|
26
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function causeCode(err) {
|
|
30
|
+
if (!err || typeof err !== 'object') return undefined
|
|
31
|
+
if (err.code && err.code !== 'MOCKS_FILE_INVALID') return err.code
|
|
32
|
+
if (err.cause) return causeCode(err.cause)
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when the proxy answered with an HTTP 4xx/5xx (api-client sets err.status). */
|
|
37
|
+
function isApiHttpError(err) {
|
|
38
|
+
const status = err && err.status
|
|
39
|
+
return Number.isInteger(status) && status >= 400 && status <= 599
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True when we never got an HTTP response: connection refused, DNS, reset,
|
|
44
|
+
* timeout, or undici's `TypeError: fetch failed`.
|
|
45
|
+
*/
|
|
46
|
+
function isProxyUnreachableError(err) {
|
|
47
|
+
if (!err || isApiHttpError(err)) return false
|
|
48
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError') return true
|
|
49
|
+
const code = causeCode(err)
|
|
50
|
+
if (code && UNREACHABLE_CODES.has(code)) return true
|
|
51
|
+
if (err.name === 'TypeError' && err.message === 'fetch failed') return true
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Call the proxy when it is up. `file` runs only when it is down or the
|
|
57
|
+
* request never received an HTTP response. HTTP errors reject.
|
|
58
|
+
*
|
|
59
|
+
* @param {{ api: () => Promise<any>, file: () => any }} opts
|
|
60
|
+
* @returns {Promise<{ fileOnly: boolean, value: any }>}
|
|
61
|
+
*/
|
|
62
|
+
async function withProxyOrFile({ api, file }) {
|
|
63
|
+
const running = await isProxyRunning()
|
|
64
|
+
if (!running) return { fileOnly: true, value: file() }
|
|
65
|
+
try {
|
|
66
|
+
return { fileOnly: false, value: await api() }
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (!isProxyUnreachableError(err)) throw err
|
|
69
|
+
return { fileOnly: true, value: file() }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Exit 1. An HTTP error's message is the response body and is printed as-is.
|
|
75
|
+
* `err.rawStderr` prints `err.message` with no `✗` prefix (mock list).
|
|
76
|
+
* Other human-mode failures use the CLI `✗` style.
|
|
77
|
+
*/
|
|
78
|
+
function failCommand(err) {
|
|
79
|
+
const message = err && err.message ? String(err.message) : String(err)
|
|
80
|
+
// HTTP / raw-stderr failures print the body as-is (no JSON wrapper, no `✗`).
|
|
81
|
+
// Other failures go through output.error, which emits `{success:false}` on
|
|
82
|
+
// stdout when --json is set.
|
|
83
|
+
if (isApiHttpError(err) || (err && err.rawStderr)) console.error(message)
|
|
84
|
+
else output.error(message)
|
|
85
|
+
process.exit(1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
isApiHttpError,
|
|
90
|
+
isProxyUnreachableError,
|
|
91
|
+
withProxyOrFile,
|
|
92
|
+
failCommand,
|
|
93
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument parsing for `meddle route add|update|delete` (issue #149).
|
|
3
|
+
*
|
|
4
|
+
* `--exclusions=` clears a rule's exclusions. `--exclusions=/a,/b` replaces
|
|
5
|
+
* them. A bare `--exclusions`, an unknown flag, or an empty `!` token used to
|
|
6
|
+
* be ignored and the command still exited 0 without changing exclusions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { splitPatternArg } = require('./rule-file-edit')
|
|
10
|
+
|
|
11
|
+
class RouteArgError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = 'RouteArgError'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CLEAR_HINT = 'To clear exclusions, pass --exclusions= (empty). To replace them, pass --exclusions=/a,/b.'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} raw value after `=`
|
|
22
|
+
* @returns {string[]}
|
|
23
|
+
*/
|
|
24
|
+
function parseExclusionList(raw) {
|
|
25
|
+
const value = String(raw).trim()
|
|
26
|
+
if (value === '') return []
|
|
27
|
+
const pieces = value.includes(',') ? value.split(',') : value.split(/\s+/)
|
|
28
|
+
const out = []
|
|
29
|
+
for (const piece of pieces) {
|
|
30
|
+
const token = piece.trim()
|
|
31
|
+
if (!token) {
|
|
32
|
+
throw new RouteArgError(`Invalid --exclusions: empty entry. ${CLEAR_HINT}`)
|
|
33
|
+
}
|
|
34
|
+
const body = token.startsWith('!') ? token.slice(1).trim() : token
|
|
35
|
+
if (!body) {
|
|
36
|
+
throw new RouteArgError(`Invalid --exclusions: "!" needs a path, for example --exclusions=/health. ${CLEAR_HINT}`)
|
|
37
|
+
}
|
|
38
|
+
out.push(body)
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string[]} args argv after the subcommand
|
|
45
|
+
* @param {{ allowExclusions?: boolean }} [options]
|
|
46
|
+
* @returns {{ help: boolean, json: boolean, positional: string[], exclusions: string[] | undefined }}
|
|
47
|
+
* `exclusions` is undefined when the flag was not passed. `[]` means clear.
|
|
48
|
+
*/
|
|
49
|
+
function parseRouteWriteArgs(args, { allowExclusions = true } = {}) {
|
|
50
|
+
let help = false
|
|
51
|
+
let json = false
|
|
52
|
+
/** @type {string[] | undefined} */
|
|
53
|
+
let exclusions
|
|
54
|
+
let sawExclusions = false
|
|
55
|
+
const positional = []
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < args.length; i++) {
|
|
58
|
+
const arg = args[i]
|
|
59
|
+
if (arg === '--help' || arg === '-h') {
|
|
60
|
+
help = true
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (arg === '--json') {
|
|
64
|
+
json = true
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (arg === '--exclusions' || arg.startsWith('--exclusions=')) {
|
|
68
|
+
if (!allowExclusions) {
|
|
69
|
+
throw new RouteArgError(`Unsupported option: ${arg === '--exclusions' ? '--exclusions' : '--exclusions='}. route delete does not change exclusions.`)
|
|
70
|
+
}
|
|
71
|
+
if (arg === '--exclusions') {
|
|
72
|
+
throw new RouteArgError(`Unsupported option: --exclusions. ${CLEAR_HINT}`)
|
|
73
|
+
}
|
|
74
|
+
if (sawExclusions) throw new RouteArgError('--exclusions was given more than once.')
|
|
75
|
+
sawExclusions = true
|
|
76
|
+
exclusions = parseExclusionList(arg.slice('--exclusions='.length))
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (arg.startsWith('-')) {
|
|
80
|
+
throw new RouteArgError(`Unsupported option: ${arg}.`)
|
|
81
|
+
}
|
|
82
|
+
positional.push(arg)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { help, json, positional, exclusions: sawExclusions ? exclusions : undefined }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split a pattern argument the way the CLI always has (`"host !/api"`), but
|
|
90
|
+
* refuse a bare `!` — that used to be dropped and looked like a successful clear.
|
|
91
|
+
* @returns {{ patterns: string[], exclusions: string[] }}
|
|
92
|
+
*/
|
|
93
|
+
function parsePatternArg(raw) {
|
|
94
|
+
const parts = String(raw).trim().split(/\s+/).filter(Boolean)
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
if (part.startsWith('!') && part.slice(1).trim() === '') {
|
|
97
|
+
throw new RouteArgError('Invalid exclusion "!": a "!" token needs a path such as "!/api". To clear exclusions, pass --exclusions=.')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return splitPatternArg(raw)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string[]} patternExclusions exclusions parsed from !path tokens
|
|
105
|
+
* @param {string[] | undefined} flagExclusions undefined when --exclusions was omitted
|
|
106
|
+
* @returns {{ explicit: boolean, exclusions: string[] | undefined }}
|
|
107
|
+
*/
|
|
108
|
+
function resolveExclusions(patternExclusions, flagExclusions) {
|
|
109
|
+
if (flagExclusions !== undefined && patternExclusions.length > 0) {
|
|
110
|
+
throw new RouteArgError('Pass exclusions either as !path tokens in the pattern or with --exclusions, not both.')
|
|
111
|
+
}
|
|
112
|
+
if (flagExclusions !== undefined) return { explicit: true, exclusions: flagExclusions }
|
|
113
|
+
if (patternExclusions.length > 0) return { explicit: true, exclusions: patternExclusions }
|
|
114
|
+
return { explicit: false, exclusions: undefined }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const UPDATE_HELP = `
|
|
118
|
+
meddle route update <file> <pattern> [<target>] [--exclusions=<list>] [--json]
|
|
119
|
+
|
|
120
|
+
Update a rule. Edits only the line(s) that hold the pattern; every other line
|
|
121
|
+
stays byte-for-byte, including comments, blank lines and other rules' !path.
|
|
122
|
+
|
|
123
|
+
--exclusions= Clear this rule's exclusions (the line loses !path).
|
|
124
|
+
--exclusions=/a,/b Replace exclusions. Spaces work inside quotes
|
|
125
|
+
(--exclusions="/a /b"). A leading ! is optional.
|
|
126
|
+
"pattern !/a !/b" Replace exclusions (same as before). Omit both
|
|
127
|
+
--exclusions and !path to keep existing exclusions.
|
|
128
|
+
|
|
129
|
+
The <target> may be omitted when --exclusions is set; the current target stays.
|
|
130
|
+
A bare --exclusions, an empty "!" token, an unknown flag, or both exclusion
|
|
131
|
+
forms at once exits 1 and does not change the file.
|
|
132
|
+
|
|
133
|
+
A "#" comments out a whole line only when it is the first non-space character.
|
|
134
|
+
A "#" later on a rule line is a pattern token, not a trailing comment.
|
|
135
|
+
|
|
136
|
+
Examples:
|
|
137
|
+
meddle route update dev "api.example.com" "localhost:4000"
|
|
138
|
+
meddle route update dev "api.example.com" --exclusions=
|
|
139
|
+
meddle route update dev "api.example.com" "localhost:4000" --exclusions=/health
|
|
140
|
+
`
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
CLEAR_HINT,
|
|
144
|
+
RouteArgError,
|
|
145
|
+
UPDATE_HELP,
|
|
146
|
+
parseExclusionList,
|
|
147
|
+
parsePatternArg,
|
|
148
|
+
parseRouteWriteArgs,
|
|
149
|
+
resolveExclusions,
|
|
150
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-preserving edits of route rule files (issue #145).
|
|
3
|
+
*
|
|
4
|
+
* The old write path parsed a rule file into a ruleMap and rebuilt the whole
|
|
5
|
+
* text, which dropped comments, blank lines, disabled (`//`) rules and — in the
|
|
6
|
+
* CLI — every other rule's exclusions (`!path`). These helpers edit only the
|
|
7
|
+
* lines that hold the target pattern; every other line keeps its exact bytes
|
|
8
|
+
* (including its line ending).
|
|
9
|
+
*
|
|
10
|
+
* Kept in sync with core/rule-file-edit.ts (same convention as
|
|
11
|
+
* bin/lib/meddle-home.js ↔ core/meddle-home.ts).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MARKER = /\[([^\]]+)\]/
|
|
15
|
+
const BOM = '\uFEFF'
|
|
16
|
+
const SPLIT_UNSAFE_MESSAGE = '该行剩余的 pattern 以 // 或 # 开头,拆开后会被当成注释,请用文本编辑器手动修改'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Thrown when splitting a line or removing a pattern would put a pattern that
|
|
20
|
+
* starts with `//` or `#` at the start of a line, turning it into a disabled
|
|
21
|
+
* rule or a comment. The rule file is left untouched.
|
|
22
|
+
*/
|
|
23
|
+
class RuleLineSplitError extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super(SPLIT_UNSAFE_MESSAGE)
|
|
26
|
+
this.name = 'RuleLineSplitError'
|
|
27
|
+
this.code = 'RULE_LINE_SPLIT_UNSAFE'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NEW_RULE_COMMENT_MESSAGE = '规则不能以 // 或 # 开头,否则会被当成禁用行或注释。要写注释或禁用规则,请用文本编辑器。'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when a NEW rule's pattern starts with `//` or `#`: appended as a line it
|
|
35
|
+
* would read back as a disabled rule or a comment. Same code as RuleLineSplitError.
|
|
36
|
+
*/
|
|
37
|
+
class RuleNewRuleCommentError extends Error {
|
|
38
|
+
constructor() {
|
|
39
|
+
super(NEW_RULE_COMMENT_MESSAGE)
|
|
40
|
+
this.name = 'RuleNewRuleCommentError'
|
|
41
|
+
this.code = 'RULE_LINE_SPLIT_UNSAFE'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function startsAsComment(pattern) {
|
|
46
|
+
return pattern.startsWith('//') || pattern.startsWith('#')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A leading UTF-8 BOM is not part of the first line: strip it before editing
|
|
51
|
+
* and put it back afterwards, so it stays byte-identical even when the first
|
|
52
|
+
* line is split or deleted (JS `trim()` / `\s` treat U+FEFF as whitespace).
|
|
53
|
+
*/
|
|
54
|
+
function withBom(content, edit) {
|
|
55
|
+
if (!content.startsWith(BOM)) return edit(content)
|
|
56
|
+
const result = edit(content.slice(BOM.length))
|
|
57
|
+
return { ...result, content: BOM + result.content }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Internal key of a pattern token: `[marker]` brackets removed (same as the parser). */
|
|
61
|
+
function patternKey(token) {
|
|
62
|
+
const bm = token.match(MARKER)
|
|
63
|
+
return bm ? token.replace(bm[0], bm[1]) : token
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Split into [line, eol, line, eol, ..., line]; the parser splits on /\r?\n/ too. */
|
|
67
|
+
function splitLines(content) {
|
|
68
|
+
return content.split(/(\r?\n)/)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse one line the way parseEprcWithExclusions does. Returns null for blank
|
|
73
|
+
* lines, `#` comments, `//` disabled rules and lines that are not rules.
|
|
74
|
+
*/
|
|
75
|
+
function parseRuleLine(line) {
|
|
76
|
+
const trimmed = line.trim()
|
|
77
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null
|
|
78
|
+
const parts = trimmed.split(/\s+/).filter(Boolean)
|
|
79
|
+
const exclusionTokens = parts.filter((p) => p.startsWith('!'))
|
|
80
|
+
const regular = parts.filter((p) => !p.startsWith('!'))
|
|
81
|
+
if (regular.length < 2) return null
|
|
82
|
+
return {
|
|
83
|
+
leading: line.match(/^\s*/)[0],
|
|
84
|
+
patterns: regular.slice(0, -1),
|
|
85
|
+
target: regular[regular.length - 1],
|
|
86
|
+
exclusions: exclusionTokens.map((t) => t.slice(1)),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatLine(leading, patterns, exclusions, target) {
|
|
91
|
+
const parts = patterns.slice()
|
|
92
|
+
for (const e of exclusions) parts.push(`!${e}`)
|
|
93
|
+
parts.push(target)
|
|
94
|
+
return `${leading}${parts.join(' ')}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sameList(a, b) {
|
|
98
|
+
return a.length === b.length && a.every((v, i) => v === b[i])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Replace the last regular (target) token in place, keeping all spacing. */
|
|
102
|
+
function replaceTargetInPlace(line, target) {
|
|
103
|
+
const tokens = line.match(/\s+|\S+/g) || []
|
|
104
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
105
|
+
if (/\S/.test(tokens[i]) && !tokens[i].startsWith('!')) {
|
|
106
|
+
tokens[i] = target
|
|
107
|
+
return tokens.join('')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return line
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Remove the given non-space token indexes (and one adjacent separator each). */
|
|
114
|
+
function removePatternTokens(line, keys) {
|
|
115
|
+
const tokens = line.match(/\s+|\S+/g) || []
|
|
116
|
+
const nonSpace = []
|
|
117
|
+
tokens.forEach((t, i) => { if (/\S/.test(t)) nonSpace.push(i) })
|
|
118
|
+
const regularIdx = nonSpace.filter((i) => !tokens[i].startsWith('!'))
|
|
119
|
+
const patternIdx = regularIdx.slice(0, -1)
|
|
120
|
+
const drop = new Set()
|
|
121
|
+
for (const i of patternIdx) {
|
|
122
|
+
if (!keys.has(patternKey(tokens[i]))) continue
|
|
123
|
+
drop.add(i)
|
|
124
|
+
// Prefer the separator after the token (a pattern is never the last token).
|
|
125
|
+
if (i + 1 < tokens.length && !/\S/.test(tokens[i + 1]) && !drop.has(i + 1)) drop.add(i + 1)
|
|
126
|
+
else if (i - 1 > 0 && !/\S/.test(tokens[i - 1])) drop.add(i - 1)
|
|
127
|
+
}
|
|
128
|
+
return tokens.filter((_, i) => !drop.has(i)).join('')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Normalise a user-supplied pattern argument. The CLI accepts exclusions inside
|
|
133
|
+
* the pattern argument (`"app.example.com !/api"`), so split those out.
|
|
134
|
+
*/
|
|
135
|
+
function splitPatternArg(raw) {
|
|
136
|
+
const parts = String(raw).trim().split(/\s+/).filter(Boolean)
|
|
137
|
+
const patterns = parts.filter((p) => !p.startsWith('!'))
|
|
138
|
+
const exclusions = parts.filter((p) => p.startsWith('!')).map((p) => p.slice(1)).filter(Boolean)
|
|
139
|
+
return { patterns, exclusions }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Point every enabled occurrence of `pattern` at `target` (if given) and/or set
|
|
144
|
+
* its exclusions (if given; `undefined` keeps each line's own exclusions).
|
|
145
|
+
* Lines that do not contain the pattern are left untouched.
|
|
146
|
+
* @returns {{ content: string, found: boolean }}
|
|
147
|
+
*/
|
|
148
|
+
function updateRouteRule(content, { pattern, target, exclusions }) {
|
|
149
|
+
return withBom(content, (text) => updateLines(text, { pattern, target, exclusions }))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function updateLines(content, { pattern, target, exclusions }) {
|
|
153
|
+
const key = patternKey(pattern)
|
|
154
|
+
const userHasMarker = MARKER.test(pattern)
|
|
155
|
+
const segments = splitLines(content)
|
|
156
|
+
let found = false
|
|
157
|
+
|
|
158
|
+
for (let s = 0; s < segments.length; s += 2) {
|
|
159
|
+
const line = segments[s]
|
|
160
|
+
const parsed = parseRuleLine(line)
|
|
161
|
+
if (!parsed || !parsed.patterns.some((p) => patternKey(p) === key)) continue
|
|
162
|
+
found = true
|
|
163
|
+
|
|
164
|
+
const nextTarget = target !== undefined ? target : parsed.target
|
|
165
|
+
const nextExclusions = exclusions !== undefined ? exclusions : parsed.exclusions
|
|
166
|
+
const exclusionsChanged = !sameList(nextExclusions, parsed.exclusions)
|
|
167
|
+
const markerChanged = userHasMarker && parsed.patterns.some((p) => patternKey(p) === key && p !== pattern)
|
|
168
|
+
if (nextTarget === parsed.target && !exclusionsChanged && !markerChanged) continue
|
|
169
|
+
|
|
170
|
+
const allMatch = parsed.patterns.every((p) => patternKey(p) === key)
|
|
171
|
+
if (allMatch && !exclusionsChanged && !markerChanged) {
|
|
172
|
+
segments[s] = replaceTargetInPlace(line, nextTarget)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Split the line into consecutive groups so other patterns on the same line
|
|
177
|
+
// keep their target / exclusions and the original order is preserved.
|
|
178
|
+
const groups = []
|
|
179
|
+
for (const p of parsed.patterns) {
|
|
180
|
+
const match = patternKey(p) === key
|
|
181
|
+
const last = groups[groups.length - 1]
|
|
182
|
+
if (last && last.match === match) last.patterns.push(p)
|
|
183
|
+
else groups.push({ match, patterns: [p] })
|
|
184
|
+
}
|
|
185
|
+
if (groups.some((g) => startsAsComment(g.match && userHasMarker ? pattern : g.patterns[0]))) throw new RuleLineSplitError()
|
|
186
|
+
const lines = groups.map((g) => g.match
|
|
187
|
+
? formatLine(parsed.leading, g.patterns.map((p) => (userHasMarker ? pattern : p)), nextExclusions, nextTarget)
|
|
188
|
+
: formatLine(parsed.leading, g.patterns, parsed.exclusions, parsed.target))
|
|
189
|
+
const eol = segments[s + 1] !== undefined ? segments[s + 1] : (content.includes('\r\n') ? '\r\n' : '\n')
|
|
190
|
+
segments[s] = lines.join(eol)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { content: segments.join(''), found }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Add a rule. An existing pattern is updated in place (its exclusions are kept
|
|
198
|
+
* unless `exclusions` is given); otherwise a new line is appended at the end.
|
|
199
|
+
* @returns {{ content: string, existed: boolean }}
|
|
200
|
+
*/
|
|
201
|
+
function addRouteRule(content, { pattern, target, exclusions }) {
|
|
202
|
+
const updated = updateRouteRule(content, { pattern, target, exclusions })
|
|
203
|
+
if (updated.found) return { content: updated.content, existed: true }
|
|
204
|
+
if (startsAsComment(pattern)) throw new RuleNewRuleCommentError()
|
|
205
|
+
|
|
206
|
+
const line = formatLine('', [pattern], exclusions || [], target)
|
|
207
|
+
if (content === '' || content === BOM) return { content: content + line, existed: false }
|
|
208
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n'
|
|
209
|
+
if (/\r?\n$/.test(content)) return { content: `${content}${line}${eol}`, existed: false }
|
|
210
|
+
return { content: `${content}${eol}${line}`, existed: false }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Remove every enabled occurrence of `pattern`. Other patterns on the same line
|
|
215
|
+
* stay; a line left without patterns is removed with its line ending.
|
|
216
|
+
* @returns {{ content: string, found: boolean }}
|
|
217
|
+
*/
|
|
218
|
+
function deleteRouteRule(content, { pattern }) {
|
|
219
|
+
return withBom(content, (text) => deleteLines(text, { pattern }))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function deleteLines(content, { pattern }) {
|
|
223
|
+
const key = patternKey(pattern)
|
|
224
|
+
const segments = splitLines(content)
|
|
225
|
+
const out = []
|
|
226
|
+
let found = false
|
|
227
|
+
|
|
228
|
+
for (let s = 0; s < segments.length; s += 2) {
|
|
229
|
+
const line = segments[s]
|
|
230
|
+
const eol = segments[s + 1]
|
|
231
|
+
const parsed = parseRuleLine(line)
|
|
232
|
+
if (!parsed || !parsed.patterns.some((p) => patternKey(p) === key)) {
|
|
233
|
+
out.push(line)
|
|
234
|
+
if (eol !== undefined) out.push(eol)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
found = true
|
|
238
|
+
if (parsed.patterns.every((p) => patternKey(p) === key)) {
|
|
239
|
+
// Drop the whole line. For a last line without a line ending, drop the
|
|
240
|
+
// previous line's ending instead so no dangling empty line is left behind.
|
|
241
|
+
if (eol === undefined && out.length > 0) out.pop()
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (startsAsComment(parsed.patterns.find((p) => patternKey(p) !== key))) throw new RuleLineSplitError()
|
|
245
|
+
out.push(removePatternTokens(line, new Set([key])))
|
|
246
|
+
if (eol !== undefined) out.push(eol)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { content: out.join(''), found }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
SPLIT_UNSAFE_MESSAGE,
|
|
254
|
+
RuleLineSplitError,
|
|
255
|
+
NEW_RULE_COMMENT_MESSAGE,
|
|
256
|
+
RuleNewRuleCommentError,
|
|
257
|
+
patternKey,
|
|
258
|
+
parseRuleLine,
|
|
259
|
+
splitPatternArg,
|
|
260
|
+
addRouteRule,
|
|
261
|
+
updateRouteRule,
|
|
262
|
+
deleteRouteRule,
|
|
263
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #137: read-only rulesets (rule files).
|
|
3
|
+
*
|
|
4
|
+
* The lock lives in settings.json (`readonlyRuleFiles`) and can only be set or
|
|
5
|
+
* cleared from the Meddle web UI. The CLI has no lock / unlock command; it only
|
|
6
|
+
* reports the state (`readonly` in --json output) and refuses writes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const chalk = require('chalk')
|
|
10
|
+
|
|
11
|
+
const RULESET_READONLY = 'RULESET_READONLY'
|
|
12
|
+
const RULESET_READONLY_MESSAGE = '该规则集已设为只读,只能在 Meddle 界面中解锁'
|
|
13
|
+
/** Dedicated CLI exit code for a write rejected because the ruleset is read-only. */
|
|
14
|
+
const EXIT_RULESET_READONLY = 3
|
|
15
|
+
|
|
16
|
+
class RulesetReadonlyError extends Error {
|
|
17
|
+
constructor(ruleFile) {
|
|
18
|
+
super(RULESET_READONLY_MESSAGE)
|
|
19
|
+
this.name = 'RulesetReadonlyError'
|
|
20
|
+
this.code = RULESET_READONLY
|
|
21
|
+
this.ruleFile = ruleFile
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRulesetReadonlyError(err) {
|
|
26
|
+
return Boolean(err) && err.code === RULESET_READONLY
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Print the rejection and exit with EXIT_RULESET_READONLY.
|
|
31
|
+
* --json: one JSON line on stdout; the human message always goes to stderr.
|
|
32
|
+
*/
|
|
33
|
+
function exitRulesetReadonly(err, { json = false, ruleFile } = {}) {
|
|
34
|
+
const file = (err && err.ruleFile) || ruleFile || null
|
|
35
|
+
if (json) {
|
|
36
|
+
console.log(JSON.stringify({
|
|
37
|
+
success: false,
|
|
38
|
+
error: { code: RULESET_READONLY, message: RULESET_READONLY_MESSAGE, ruleFile: file },
|
|
39
|
+
}))
|
|
40
|
+
}
|
|
41
|
+
console.error(chalk.red(' ✗'), `${RULESET_READONLY_MESSAGE} (${RULESET_READONLY}${file ? `: ${file}` : ''})`)
|
|
42
|
+
process.exit(EXIT_RULESET_READONLY)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Throw if any of `names` is in the given rule-file list with readonly: true. */
|
|
46
|
+
function assertListedFilesWritable(files, names) {
|
|
47
|
+
const locked = new Set((Array.isArray(files) ? files : []).filter((f) => f && f.readonly).map((f) => f.name))
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
if (locked.has(name)) throw new RulesetReadonlyError(name)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
RULESET_READONLY,
|
|
55
|
+
RULESET_READONLY_MESSAGE,
|
|
56
|
+
EXIT_RULESET_READONLY,
|
|
57
|
+
RulesetReadonlyError,
|
|
58
|
+
isRulesetReadonlyError,
|
|
59
|
+
exitRulesetReadonly,
|
|
60
|
+
assertListedFilesWritable,
|
|
61
|
+
}
|
package/bin/lib/update-check.js
CHANGED
|
@@ -27,6 +27,58 @@ const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
|
27
27
|
const DEFAULT_TIMEOUT_MS = 5000
|
|
28
28
|
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120 * 1000
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a GitHub token for private release downloads / latest redirects.
|
|
32
|
+
* Precedence: explicit argument → GH_TOKEN → GITHUB_TOKEN.
|
|
33
|
+
* @param {string} [explicit]
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function resolveGithubToken(explicit) {
|
|
37
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
|
|
38
|
+
const gh = process.env.GH_TOKEN
|
|
39
|
+
if (typeof gh === 'string' && gh.trim()) return gh.trim()
|
|
40
|
+
const github = process.env.GITHUB_TOKEN
|
|
41
|
+
if (typeof github === 'string' && github.trim()) return github.trim()
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} token
|
|
47
|
+
* @returns {Record<string, string>}
|
|
48
|
+
*/
|
|
49
|
+
function githubAuthHeaders(token) {
|
|
50
|
+
if (!token) return {}
|
|
51
|
+
// GitHub accepts Bearer for PATs / fine-grained tokens on release asset URLs.
|
|
52
|
+
return { Authorization: `Bearer ${token}` }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function privateReleaseAuthHint() {
|
|
56
|
+
return (
|
|
57
|
+
'Private GitHub releases require GH_TOKEN or GITHUB_TOKEN '
|
|
58
|
+
+ '(scope: repo, or fine-grained contents:read). '
|
|
59
|
+
+ `Or: gh release download -R ${REPO} -p 'meddle-*'`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {'checksum' | 'download'} kind
|
|
65
|
+
* @param {number} status
|
|
66
|
+
* @param {boolean} hadToken
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function formatDownloadStatusError(kind, status, hadToken) {
|
|
70
|
+
const base = kind === 'checksum'
|
|
71
|
+
? `checksum sidecar missing (${status})`
|
|
72
|
+
: `download failed (${status})`
|
|
73
|
+
if ((status === 404 || status === 401 || status === 403) && !hadToken) {
|
|
74
|
+
return `${base}. ${privateReleaseAuthHint()}`
|
|
75
|
+
}
|
|
76
|
+
if ((status === 401 || status === 403) && hadToken) {
|
|
77
|
+
return `${base}. Token may lack repo/contents:read access to ${REPO}`
|
|
78
|
+
}
|
|
79
|
+
return base
|
|
80
|
+
}
|
|
81
|
+
|
|
30
82
|
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
|
31
83
|
|
|
32
84
|
/**
|
|
@@ -120,12 +172,14 @@ async function getLatestVersionNpm({ fetchImpl, registryUrl, timeoutMs, distTag
|
|
|
120
172
|
* @param {{ fetchImpl?: Function, latestUrl?: string, timeoutMs?: number }} opts
|
|
121
173
|
* @returns {Promise<string>}
|
|
122
174
|
*/
|
|
123
|
-
async function getLatestVersionGithub({ fetchImpl, latestUrl, timeoutMs } = {}) {
|
|
175
|
+
async function getLatestVersionGithub({ fetchImpl, latestUrl, timeoutMs, githubToken } = {}) {
|
|
124
176
|
const doFetch = fetchImpl || fetch
|
|
125
177
|
const url = latestUrl || process.env.MEDDLE_GITHUB_LATEST_URL || DEFAULT_GITHUB_LATEST_URL
|
|
178
|
+
const token = resolveGithubToken(githubToken)
|
|
126
179
|
const response = await doFetch(url, {
|
|
127
180
|
redirect: 'manual',
|
|
128
181
|
signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS),
|
|
182
|
+
headers: githubAuthHeaders(token),
|
|
129
183
|
})
|
|
130
184
|
if (response.status < 300 || response.status >= 400) {
|
|
131
185
|
throw new Error(`GitHub latest release did not redirect (${response.status})`)
|
|
@@ -287,7 +341,7 @@ function sha256Hex(buffer) {
|
|
|
287
341
|
* baseUrl?: string, fetchImpl?: Function, timeoutMs?: number,
|
|
288
342
|
* retries?: number, retryDelayMs?: number,
|
|
289
343
|
* onProgress?: Function, onAttempt?: Function,
|
|
290
|
-
* fsImpl?: object }} opts
|
|
344
|
+
* githubToken?: string, fsImpl?: object }} opts
|
|
291
345
|
* @returns {Promise<{ installed: string, backup: string, version: string }>}
|
|
292
346
|
*/
|
|
293
347
|
async function downloadBinaryAsset(opts) {
|
|
@@ -300,6 +354,8 @@ async function downloadBinaryAsset(opts) {
|
|
|
300
354
|
const retryDelayMs = opts.retryDelayMs === undefined ? 1000 : opts.retryDelayMs
|
|
301
355
|
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {}
|
|
302
356
|
const onAttempt = typeof opts.onAttempt === 'function' ? opts.onAttempt : () => {}
|
|
357
|
+
const token = resolveGithubToken(opts.githubToken)
|
|
358
|
+
const authHeaders = githubAuthHeaders(token)
|
|
303
359
|
|
|
304
360
|
const asset = getAssetName({ platform, arch })
|
|
305
361
|
const assetUrl = `${baseUrl}/v${version}/${asset}`
|
|
@@ -327,8 +383,8 @@ async function downloadBinaryAsset(opts) {
|
|
|
327
383
|
}
|
|
328
384
|
resetIdle()
|
|
329
385
|
try {
|
|
330
|
-
const response = await doFetch(assetUrl, { signal: controller.signal })
|
|
331
|
-
if (!response.ok) throw new Error(
|
|
386
|
+
const response = await doFetch(assetUrl, { signal: controller.signal, headers: authHeaders })
|
|
387
|
+
if (!response.ok) throw new Error(formatDownloadStatusError('download', response.status, Boolean(token)))
|
|
332
388
|
const total = Number(response.headers.get('content-length')) || 0
|
|
333
389
|
const chunks = []
|
|
334
390
|
let received = 0
|
|
@@ -365,8 +421,11 @@ async function downloadBinaryAsset(opts) {
|
|
|
365
421
|
// download — otherwise the sidecar body stream aborts before it is read
|
|
366
422
|
// and the whole install fails with "operation aborted" right after the
|
|
367
423
|
// payload reached 100%.
|
|
368
|
-
const shaResponse = await doFetch(shaUrl, {
|
|
369
|
-
|
|
424
|
+
const shaResponse = await doFetch(shaUrl, {
|
|
425
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
426
|
+
headers: authHeaders,
|
|
427
|
+
})
|
|
428
|
+
if (!shaResponse.ok) throw new Error(formatDownloadStatusError('checksum', shaResponse.status, Boolean(token)))
|
|
370
429
|
const shaText = await shaResponse.text()
|
|
371
430
|
|
|
372
431
|
const payload = await fetchPayload()
|
|
@@ -559,5 +618,7 @@ module.exports = {
|
|
|
559
618
|
setAutoUpdate,
|
|
560
619
|
resolveUpdateBinDir,
|
|
561
620
|
runAsyncUpdateCheck,
|
|
621
|
+
resolveGithubToken,
|
|
622
|
+
githubAuthHeaders,
|
|
562
623
|
DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
563
624
|
}
|