dsh-ris-yds 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js ADDED
@@ -0,0 +1,1003 @@
1
+ // CLI dispatcher: every yds_* tool and the bin/yds.js entry funnel into this
2
+ // one place, so behavior is identical for the agent (tools), the shell (bin),
3
+ // and the dsh-ris-ide startCommand (foreground mode).
4
+
5
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
6
+ import os from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { resolveConfig, describeConfig, IS_WSL } from './config.js'
9
+ import { describeYoncode, detectYoncode, patchYoncode, restoreYoncode, yoncodeWarning } from './yoncode.js'
10
+ import { pullConfcenter, readConfCache, writeConfCache, adaptAndWriteConfig, parseJdbcEndpoint, extractYonbipTargets, adaptMarker, ADAPT_VERSION } from './confcenter.js'
11
+ import { startForwarder, stopForwarder, runForwarder, isForwarderUp, probeSocksConnect } from './socks.js'
12
+ import {
13
+ ensureBorrowedProxy, syncBorrowedRules, stopBorrowedProxy, borrowedProxyStatus, applyGitProxy, loadIdeProxy,
14
+ loadIdeRedirector, syncRedirectorProfile, startBorrowedRedirector, stopBorrowedSideEffects,
15
+ } from './ide-proxy.js'
16
+ import { buildYmsHome, ymsHomeIsBuilt, ensureMavenSettings, findBootstrapDir } from './build.js'
17
+ import { startDetached, startForeground, stopYms, readRunning, status, rdbgaValue, tailFile } from './yms.js'
18
+ import { classifyRdbgaCookies, describeRdbgaCookies, hostVariants, isPlausibleRdbga, probeDevtoolsPorts, rdbgaFromRunningJvm, readRdbgaCookies, repairRdbgaCookie, workbenchTargets, DEVTOOLS_PORTS } from './browser.js'
19
+ import {
20
+ applyIdeConfig, removeIdeConfig, readIdeConfig, entryName, ourEntry,
21
+ applyLaunchConfig, removeLaunchConfig, readLaunchConfig, ourLaunchEntry, LAUNCH_ENTRY_NAME, launchRoot, stripLaunchProxy,
22
+ } from './ide.js'
23
+ import { getYycToken } from './token.js'
24
+ import { registerWorkspace, unregisterWorkspace } from './ide-watch.js'
25
+ import { feConfig, overrideRules, requestRedirects, writeOverrideRules, pageUrlWithScriptUrl } from './fe-dev.js'
26
+
27
+ /** Pull targets out of the cached yms_middleware.json + yonbip_config.json (for the forwarder). */
28
+ function forwarderTargets(cfg) {
29
+ const cache = readConfCache(cfg)
30
+ const targets = []
31
+ try {
32
+ const mw = JSON.parse(cache['yms_middleware.json'])
33
+ for (const pool of Object.values(mw.connectionPools ?? {})) {
34
+ const ep = parseJdbcEndpoint(String(pool?.url ?? ''))
35
+ if (ep !== null) targets.push({ host: ep.host, port: ep.port })
36
+ }
37
+ for (const pool of Object.values(mw['redis.pools'] ?? {})) {
38
+ if (typeof pool?.host === 'string') targets.push({ host: pool.host, port: Number(pool.port) || 6379, isRedis: true })
39
+ }
40
+ } catch { /* cache unreadable */ }
41
+ try {
42
+ // yonbip extras (kafka/ES/license...) keep their own port numbers locally
43
+ for (const t of extractYonbipTargets(cache['yonbip_config.json'])) {
44
+ if (!targets.some((x) => x.host === t.host && x.port === t.port)) targets.push(t)
45
+ }
46
+ } catch { /* cache unreadable / not pulled */ }
47
+ return targets
48
+ }
49
+
50
+ /** True when yms-home/config was adapted by the current adapter version. */
51
+ /**
52
+ * Whether yms-home/config was written by the CURRENT adapter in the CURRENT mode.
53
+ *
54
+ * The mode matters: with ProxyBridge running the config must keep real addresses,
55
+ * without it they must be rewritten to 127.0.0.1. Same-version writes from the
56
+ * other mode are re-adapted.
57
+ * @param {object} cfg - resolved plugin config.
58
+ * @param {boolean} rewrite - mode the caller is about to use.
59
+ * @returns {boolean} true when the existing files can be reused as-is.
60
+ */
61
+ function adaptIsCurrent(cfg, rewrite) {
62
+ try {
63
+ return readFileSync(join(cfg.stateDir, 'adapt-version'), 'utf8').trim() === adaptMarker(rewrite)
64
+ } catch { return false }
65
+ }
66
+
67
+ /**
68
+ * Shared start pipeline: (auto)build yms-home -> ensure config -> socks
69
+ * forwarder. log() receives progress lines and mvn's full output goes to
70
+ * stdout, so both the detached CLI and the dsh-ris-ide foreground entry
71
+ * (run-with-log.sh capture) make the build trackable via `yds logs` /
72
+ * `ris_dev_logs`.
73
+ * @returns {boolean|`${string}`} true when ready to launch, else the reason.
74
+ */
75
+ async function prepareStart(cfg, { log }) {
76
+ // fail fast on unusable config before touching anything long-running
77
+ if (cfg.module == null) return '未识别模块名——检查项目 yonbuilder.json / module.xml'
78
+ if (cfg.java == null) return '未找到 JDK——用 .env 的 java.path 或 JAVA_HOME 指定'
79
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(String(cfg.serverBindAddress ?? '')) !== true) {
80
+ return '缺少合法的 serverBindAddress(回调地址)——补项目 .vscode/settings.json 的 yds.serverBindAddress'
81
+ }
82
+ if (!ymsHomeIsBuilt(cfg)) {
83
+ log('yms-home 未构建,开始 mvn 构建(首次含 kernel 下载约 19 分钟,缓存后约 1 分钟)…')
84
+ try {
85
+ await buildYmsHome(cfg, { onStdout: (t) => process.stdout.write(t) })
86
+ log('mvn 构建完成')
87
+ } catch (e) {
88
+ return `构建失败: ${e.message}(完整输出见日志)`
89
+ }
90
+ }
91
+ // A tunnel is only usable when a proxy address is configured AND it really
92
+ // reaches the internal endpoints; probe before deciding anything else.
93
+ let targets = []
94
+ if (cfg.socks !== null) {
95
+ targets = forwarderTargets(cfg)
96
+ if (targets.length === 0) {
97
+ return 'socks 已配置但无转发目标(confcenter 缓存里没有 DB/Redis 地址)——先运行 yds confcenter'
98
+ }
99
+ log(`socks 隧道预检: ${cfg.socks.host}:${cfg.socks.port} -> ${targets[0].host}:${targets[0].port}`)
100
+ const healthy = await probeSocksConnect(cfg.socks, targets[0])
101
+ if (!healthy) {
102
+ return `SOCKS 隧道不可用(${cfg.socks.host}:${cfg.socks.port} 无法转发到 ${targets[0].host}:${targets[0].port})——检查远程开发机的隧道状态后重试`
103
+ }
104
+ }
105
+ // Interception mode = proxy configured + reachable + the system-level
106
+ // redirector is RUNNING. Only then may the config keep its real endpoints:
107
+ // ProxyBridge redirects the java process inside the kernel, so nothing is
108
+ // bound on loopback and no mapping rows appear anywhere.
109
+ const bridge = await loadIdeRedirector(cfg)
110
+ // Only the THIS-SIDE hook can cover the JVM we are about to start: a Windows
111
+ // ProxyBridge hooks the Windows stack, so yds must keep the 127.0.0.1 rewrite
112
+ // unless the Linux/macOS side is actually intercepting.
113
+ let bridgeRunning = bridge?.available === true
114
+ && (typeof bridge.mod.localRedirectorRunning === 'function'
115
+ ? bridge.mod.localRedirectorRunning() === true
116
+ : bridge.mod.redirectorStatus().running === true)
117
+ // The redirector belongs to the tunnel lifecycle: if the proxy is configured and
118
+ // reachable, bring the system-level interception up with it. Elevation is the
119
+ // expected prompt here (UAC on Windows, passwordless sudo on Linux); a side that
120
+ // cannot be elevated is reported below, never fatal.
121
+ if (cfg.socks !== null && bridge?.available === true && !bridgeRunning) {
122
+ // Start the local side first (that is the one this run needs) and let the
123
+ // Windows side come up too, so an IDE-launched java is covered as well.
124
+ const startedBridge = await startBorrowedRedirector(cfg, { log, sides: ['local', 'windows'] })
125
+ bridgeRunning = typeof bridge.mod.localRedirectorRunning === 'function'
126
+ ? bridge.mod.localRedirectorRunning() === true
127
+ : startedBridge.running === true
128
+ }
129
+ const intercepting = cfg.socks !== null && bridgeRunning
130
+ if (bridge?.available === true) {
131
+ log(intercepting
132
+ ? 'ProxyBridge 运行中:保留真实地址,由系统层拦截 java 的连接'
133
+ : 'ProxyBridge 未运行:config 改写为 127.0.0.1 并启用本地转发(兜底)')
134
+ }
135
+ if (!existsSync(join(cfg.ymsHome, 'config', 'yms_middleware.json')) || !adaptIsCurrent(cfg, !intercepting)) {
136
+ let files = readConfCache(cfg)
137
+ if (files['yms_middleware.json'] === undefined) {
138
+ log('config 缺失,从 confcenter 拉取…')
139
+ try {
140
+ files = await pullConfcenter(cfg)
141
+ writeConfCache(cfg, files)
142
+ } catch (e) {
143
+ return `配置缺失且无法拉取(${e.message})——先运行 yds confcenter`
144
+ }
145
+ } else {
146
+ log('适配版本/模式变更,从缓存重新适配 config…')
147
+ }
148
+ try {
149
+ adaptAndWriteConfig(cfg, files, { rewrite: !intercepting })
150
+ } catch (e) {
151
+ return `config 适配失败: ${e.message}`
152
+ }
153
+ log(`config 已就绪(${intercepting ? '真实地址' : '127.0.0.1 兜底'})`)
154
+ }
155
+ if (cfg.socks !== null) {
156
+ if (intercepting) {
157
+ // Rules still go into the shared table (the redirector profile is built
158
+ // from them) but no engine is started: nothing may bind a loopback port
159
+ // that would shadow the real endpoint.
160
+ log('系统层拦截已生效:跳过本地转发与 127.0.0.1 映射')
161
+ await syncBorrowedRules(cfg, targets, { log })
162
+ } else {
163
+ // One tunnel engine per machine: borrow dsh-ris-ide's when it is installed,
164
+ // so the IDE dialog and this plugin never fight over 127.0.0.1:1521.
165
+ const borrowed = await ensureBorrowedProxy(cfg, targets, { log, mapping: true })
166
+ if (borrowed.mode === 'ide') {
167
+ if (borrowed.ok !== true) return `代理引擎启动失败:${borrowed.error}`
168
+ } else {
169
+ if (!(await isForwarderUp(cfg))) {
170
+ log(`socks 转发器启动: ${targets.map((t) => `127.0.0.1:${Number.isInteger(t.localPort) ? t.localPort : (t.isRedis === true ? cfg.redisForwardPort : cfg.dbForwardPort)}→${t.host}:${t.port}`).join(', ')}`)
171
+ await startForwarder(cfg, targets)
172
+ }
173
+ }
174
+ }
175
+ // Keep the redirector profile in step with the rule table either way: the
176
+ // user may start ProxyBridge right after this launch.
177
+ const synced = await syncRedirectorProfile(cfg)
178
+ if (synced.available === true) {
179
+ log(synced.running === true
180
+ ? `ProxyBridge: 系统层拦截运行中(profile ${synced.rules} 条规则)`
181
+ : `ProxyBridge: profile 已导出(${synced.rules} 条规则),启用系统层拦截需执行:${synced.command}`)
182
+ }
183
+ // git honours SOCKS through its own config, so dsh-git and plain `git`
184
+ // commands inherit the tunnel without a per-command `-c http.proxy`.
185
+ if (cfg.gitProxy !== 'off' && cfg.git !== null) {
186
+ const gitResult = await applyGitProxy(cfg, { enabled: true })
187
+ log(gitResult.ok === true ? `git 代理已写入:${gitResult.key}` : `git 代理未写入:${gitResult.error}`)
188
+ }
189
+ }
190
+ return true
191
+ }
192
+
193
+ /**
194
+ * Best-effort rollback for a failed launch: stop the forwarder we started
195
+ * during prepareStart so an aborted start leaves no orphan tunnel.
196
+ */
197
+ async function rollbackForwarder(cfg) {
198
+ try { await stopBorrowedProxy(cfg) } catch { /* best effort */ }
199
+ try { await stopForwarder(cfg) } catch { /* best effort */ }
200
+ }
201
+
202
+ /**
203
+ * One line telling the operator which launch mode is active and, for a direct
204
+ * launch on a tunneled machine, which config dir YonCode will read. Kept next to
205
+ * the command so `yds ide apply` output and docs can never disagree.
206
+ * @param cfg - resolved plugin config.
207
+ * @returns the note, ending with a newline.
208
+ */
209
+ function launchModeNote(cfg) {
210
+ if (cfg.launchMode === 'direct') {
211
+ if (cfg.socks === null) return 'launch 模式: direct(未配置 SOCKS,整机直连;config 已是原始地址)\n'
212
+ const missing = !existsSync(join(cfg.configDirectDir, 'yms_middleware.json'))
213
+ return `launch 模式: direct(YonCode 不设代理); config -> ${cfg.configDirectDir}`
214
+ + `${missing ? '(⚠️ 缺少直连副本:先运行 yds confcenter)' : ''}\n`
215
+ + '(YonCode 端代理参数需为未注入:yds yoncode patch --no-socks)\n'
216
+ }
217
+ if (cfg.socks === null) return 'launch 模式: proxy(未配置 SOCKS,实际直连)\n'
218
+ return `launch 模式: proxy; YonCode -> -DsocksProxyHost=${cfg.socks.host}:${cfg.socks.port}; config -> ${cfg.configDir}\n`
219
+ }
220
+
221
+ export async function run(argv, { cwd = process.cwd() } = {}) {
222
+ const [cmd, ...rest] = argv
223
+ const flags = parseFlags(rest)
224
+ const { config: cfg, problems } = resolveConfig(cwd)
225
+
226
+ switch (cmd) {
227
+ case 'doctor': {
228
+ const st = cfg.module != null ? await status(cfg) : null
229
+ // Companion plugin is optional: when the borrowed engine is missing we say
230
+ // so once, with what it would buy (one shared engine + system interception),
231
+ // and never make it a problem of this workspace.
232
+ const engineAvailable = cfg.socks !== null && cfg.proxyEngine !== 'self' && (await loadIdeProxy(cfg)).available === true
233
+ const siblingHint = cfg.socks !== null && cfg.proxyEngine !== 'self' && !engineAvailable
234
+ ? '\n提示: 未检测到 dsh-ris-ide(可选)。装上后可在同一个 dsh 里启停本地服务、查看实时日志、做断点调试;不装也不影响本插件的功能。\n'
235
+ : ''
236
+ // WSL note: when the callback address is not on any local interface
237
+ // (NAT-mode WSL or a public DNAT address), the gateway cannot dial
238
+ // back without port forwarding — surface it instead of failing silently.
239
+ let netWarn = ''
240
+ if (cfg.module != null && typeof cfg.serverBindAddress === 'string') {
241
+ const locals = new Set()
242
+ for (const addrs of Object.values(os.networkInterfaces())) {
243
+ for (const a of addrs ?? []) locals.add(a.address)
244
+ }
245
+ if (!locals.has(cfg.serverBindAddress)) {
246
+ netWarn = `\n警告: 回调地址 ${cfg.serverBindAddress} 不在本机网卡上${IS_WSL ? '(WSL NAT 模式或外网映射地址——网关回连需要端口转发/公网映射,参见 SKILL.md)' : '(外网映射地址——确认已映射到本机)'}\n`
247
+ }
248
+ }
249
+ return text(`$ yds doctor\n${describeConfig(cfg)}\n${netWarn}`
250
+ + siblingHint
251
+ + `socks 转发器: ${cfg.socks === null ? '不适用(未配置)' : (await isForwarderUp(cfg) ? '运行中' : '未运行')}\n`
252
+ + `yms-home: ${ymsHomeIsBuilt(cfg) ? '已构建' : '未构建'}\n`
253
+ + `${describeYoncode(detectYoncode(cfg))}\n`
254
+ + (st !== null ? `YMS: ${st.process ? `pid ${st.process.pid}` : '未运行'}${st.ready !== null ? `, /_yms/ready=${st.ready}` : ''}\n` : '')
255
+ + (problems.length > 0 ? `\n问题:\n- ${problems.join('\n- ')}` : '\n无配置问题'))
256
+ }
257
+ case 'config': {
258
+ return text(describeConfig(cfg) + (problems.length > 0 ? `\n\n问题:\n- ${problems.join('\n- ')}` : ''))
259
+ }
260
+ case 'token': {
261
+ const url = flags._[0]
262
+ if (url === undefined) throw new Error('用法: yds token <完整URL>')
263
+ mustCred(cfg)
264
+ const token = await getYycToken(url, cfg.accessKey, cfg.accessSecret)
265
+ return text(token)
266
+ }
267
+ case 'confcenter': {
268
+ let files
269
+ if (flags['--offline'] === true) {
270
+ files = readConfCache(cfg)
271
+ if (files['yms_middleware.json'] === undefined) throw new Error('无 confcenter 缓存(先联网拉取一次)')
272
+ } else {
273
+ files = await pullConfcenter(cfg)
274
+ writeConfCache(cfg, files)
275
+ }
276
+ const result = adaptAndWriteConfig(cfg, files)
277
+ writeConfCache(cfg, result.files) // cache the merged ip_node_mapping too
278
+ // The two launch modes get physically separate config dirs: config/ is
279
+ // the adapted copy (127.0.0.1) read by a proxy launch; config-direct/ is
280
+ // the untouched copy read by a direct launch on a machine where dsh still
281
+ // tunnels. Write both here so they never drift; with no SOCKS there is
282
+ // only one mode, so drop the split copy instead of leaving a stale one.
283
+ let directNote
284
+ if (cfg.socks !== null) {
285
+ adaptAndWriteConfig(cfg, files, { rewrite: false, dir: cfg.configDirectDir })
286
+ directNote = `直连副本 -> ${cfg.configDirectDir}(原始地址;只有 ide.launch=direct 读它)\n`
287
+ } else {
288
+ rmSync(cfg.configDirectDir, { recursive: true, force: true })
289
+ directNote = ''
290
+ }
291
+ return text(`confcenter -> ${cfg.configDir}\n模式: ${result.mode}\n${directNote}文件: yms_middleware.json / module_config.json / yonbip_config.json / ip_node_mapping.json\n`
292
+ + `回调: ${cfg.module} -> ${cfg.serverBindAddress}:${cfg.serverPort}\n`
293
+ + (result.mode === 'socks' ? `转发目标: ${result.dbTargets.map((t) => `${t.host}:${t.port}`).join(', ')}\n(下一步: yds start 会自动起本地转发器 127.0.0.1:${cfg.dbForwardPort}/${cfg.redisForwardPort})` : '直连模式: 使用配置中的原始地址'))
294
+ }
295
+ case 'build': {
296
+ if (problems.length > 0 && cfg.workbenchUrl === '') warnProblems(problems)
297
+ const settingsXml = ensureMavenSettings(cfg)
298
+ const bootstrap = findBootstrapDir(cfg)
299
+ const result = await buildYmsHome(cfg, {
300
+ onStdout: (t) => process.stdout.write(t),
301
+ })
302
+ // pull + adapt config right after a fresh build
303
+ let conf = ''
304
+ try {
305
+ const files = await pullConfcenter(cfg)
306
+ writeConfCache(cfg, files)
307
+ adaptAndWriteConfig(cfg, files)
308
+ conf = 'confcenter 配置已拉取并适配。\n'
309
+ } catch (e) {
310
+ conf = `confcenter 拉取失败(${e.message})——可离线用缓存: yds confcenter --offline\n`
311
+ }
312
+ return text(`构建完成: ${cfg.ymsHome}\nbootstrap: ${bootstrap}\nmaven settings: ${settingsXml}\n${conf}`)
313
+ }
314
+ case 'start': {
315
+ const jvmArgs = splitJvmArgs(flags['--jvm-args'])
316
+ const foreground = flags['--foreground'] === true
317
+ // shared prep pipeline: build (auto) -> config -> socks forwarder.
318
+ // log() output lands in stdout — in foreground mode that is the
319
+ // dsh-ris-ide run-with-log.sh capture, so mvn/progress is trackable
320
+ // via ris_dev_logs.
321
+ const log = (t) => process.stdout.write(`[yds] ${t}\n`)
322
+ const prep = await prepareStart(cfg, { log })
323
+ if (prep !== true) {
324
+ if (foreground) process.stderr.write(`[yds] 启动中止: ${prep}\n`)
325
+ return fail(`启动中止: ${prep}`)
326
+ }
327
+ if (foreground) {
328
+ // Foreground mode hands the process to dsh-ris-ide and never returns, so the
329
+ // cookie has to be calibrated BEFORE the JVM starts (same value it will use).
330
+ const healedEarly = await healBrowserCookie(cfg, { log, budgetMs: 5000 })
331
+ if (healedEarly.ok !== true) log(`浏览器 rdbga 未校准:${healedEarly.reason}(不影响启动)`)
332
+ log(`前台启动 YMS(Liberty,日志同时输出到这里)`)
333
+ log(`rdbga 回调: ${cfg.module},${cfg.context}@${cfg.serverBindAddress}:${cfg.serverPort} (base64 ${rdbgaValue(cfg)})`)
334
+ log(browserCookieHint(cfg, rdbgaValue(cfg)))
335
+ const child = startForeground(cfg, { jvmArgs })
336
+ return new Promise((resolve_, reject_) => {
337
+ child.once('error', reject_)
338
+ child.once('close', (code) => resolve_(text(`YMS 前台退出 code=${code}`)))
339
+ })
340
+ }
341
+ let result
342
+ try {
343
+ result = await startDetached(cfg, { jvmArgs })
344
+ } catch (e) {
345
+ await rollbackForwarder(cfg)
346
+ throw e
347
+ }
348
+ if (result.alreadyRunning === true) {
349
+ const healed = await healBrowserCookie(cfg, { log })
350
+ const healNote = healed.ok === true ? `浏览器 rdbga 已校准(清理 ${healed.removed} 个)` : `浏览器 rdbga 未校准:${healed.reason}`
351
+ return text(`YMS 已在运行 pid=${result.pid}\n${healNote}`)
352
+ }
353
+ // The browser usually opens BEFORE the JVM is ready, so the rdbga it was given
354
+ // can be empty/stale by now: calibrate it as part of a successful start.
355
+ const healed = await healBrowserCookie(cfg, { log })
356
+ const healNote = healed.ok === true ? `浏览器 rdbga 已校准(清理 ${healed.removed} 个)` : `浏览器 rdbga 未校准:${healed.reason}`
357
+ return text(`YMS 已启动 pid=${result.pid}, 端口 ${cfg.ymsPort}, 日志 ${result.consoleLog}\n`
358
+ + `${healNote}\n`
359
+ + `回调 rdbga: ${cfg.module},${cfg.context}@${cfg.serverBindAddress}:${cfg.serverPort} (base64 ${rdbgaValue(cfg)})\n`
360
+ + browserCookieHint(cfg, rdbgaValue(cfg)))
361
+ }
362
+ case 'stop': {
363
+ const yms = await stopYms(cfg)
364
+ let fwd
365
+ let side = { redirector: { stopped: false }, git: { ok: true, key: '' } }
366
+ if (flags['--keep-socks'] === true) {
367
+ fwd = { stopped: false, reason: '保留' }
368
+ } else {
369
+ // Both engines are stopped unconditionally: whichever one is running is
370
+ // ours (prepareStart starts exactly one), and leaving the other behind
371
+ // would keep 127.0.0.1:1521 bound.
372
+ const borrowed = await stopBorrowedProxy(cfg)
373
+ const own = await stopForwarder(cfg)
374
+ const stopped = [borrowed, own].filter((r) => r && r.stopped === true)
375
+ fwd = { stopped: stopped.length > 0, pid: stopped.map((r) => r.pid).filter(Boolean).join(','), reason: stopped.length > 0 ? '' : '未运行' }
376
+ // Machine-scoped layers that went up with the tunnel come down with it.
377
+ side = await stopBorrowedSideEffects(cfg, { log })
378
+ }
379
+ const bridgeLine = side.redirector.stopped === true
380
+ ? `已停止 pid=${side.redirector.pid}`
381
+ : (side.redirector.reason ?? '未运行')
382
+ const gitLine = side.git.key !== undefined && side.git.key !== '' ? `已清除 ${side.git.key}` : '未写入'
383
+ return text(`YMS: ${yms.stopped ? `已停止 pid=${yms.pid}` : yms.reason}\nsocks 转发器: ${fwd.stopped ? `已停止 pid=${fwd.pid}` : fwd.reason}\n系统层拦截: ${bridgeLine}\ngit 代理: ${gitLine}`)
384
+ }
385
+ case 'fe': {
386
+ // The dev server serves the extender at /static; the platform page loads it
387
+ // from /mdf-wh/<domainKey>/… — the override rules bridge the two, and every
388
+ // value in them comes from the project config, never from the old copy.
389
+ const sub = flags._[0] ?? 'rules'
390
+ const feUsage = '用法: yds fe [rules|plan|url <page>]'
391
+ if (sub !== 'rules' && sub !== 'plan' && sub !== 'url') return fail(`未知动作: ${sub}\n${feUsage}`)
392
+ const feArgProblem = checkArgs(flags, { positionals: sub === 'url' ? 2 : 1 })
393
+ if (feArgProblem !== '') return fail(`${feArgProblem}\n${feUsage}`)
394
+ const fe = feConfig(cfg)
395
+ if (fe.module === '') return fail('未能识别模块名(需要 yonbuilder.json 或已部署的 yms-home/modules)——先 yds config 检查项目')
396
+ if (sub === 'rules') {
397
+ const written = writeOverrideRules(cfg)
398
+ const crx = fe.crxPath === '' ? '(插件未带 assets/' + 'yds-chrome-resource-override.crx)' : fe.crxPath
399
+ return text([
400
+ `模块: ${written.module} 工作台: ${written.host} 前端端口: ${written.fePort}`,
401
+ `规则文件: ${written.path}(${written.rules} 条)`,
402
+ '',
403
+ '用法一(Chrome 扩展 Resource Override):',
404
+ ` 1) 安装 ${crx}`,
405
+ ' 2) 扩展里导入上面这个 JSON(Import rules)',
406
+ ` 3) 页面请求 ${written.module} 的 extend*.min.js 会被换成 http://127.0.0.1:${written.fePort}/static/…`,
407
+ '',
408
+ '用法二(不装扩展):yds fe plan 拿到 正则+替换 清单,用浏览器 CDP Fetch 拦截自行改路',
409
+ `用法三(零配置):页面 URL 追加 scriptUrl=http://127.0.0.1:${written.fePort}/static`,
410
+ ].join('\n'))
411
+ }
412
+ if (sub === 'plan') {
413
+ const redirects = requestRedirects(cfg)
414
+ return text([
415
+ `模块 ${fe.module} · 前端端口 ${fe.fePort} · 共 ${redirects.length} 条`,
416
+ '',
417
+ ...redirects.map((item) => `#${item.id} ${item.match}\n → ${item.replace}\n regex: ${item.regex}`),
418
+ ].join('\n'))
419
+ }
420
+ if (sub === 'url') {
421
+ const page = flags._[1] ?? ''
422
+ if (page === '') return fail('用法: yds fe url <页面URL或站内路径>')
423
+ const url = pageUrlWithScriptUrl(cfg, page)
424
+ if (url === '') return fail('无法构造 URL:需要 .vscode/settings.json 里的 yds.workbenchUrl')
425
+ return text(url)
426
+ }
427
+ return fail(feUsage)
428
+ }
429
+ case 'status': {
430
+ const st = await status(cfg)
431
+ let fwd = '不适用'
432
+ if (cfg.socks !== null) {
433
+ const borrowed = await borrowedProxyStatus(cfg)
434
+ fwd = borrowed.available === true
435
+ ? (borrowed.running ? `运行中(dsh-ris-ide 引擎 pid ${borrowed.pid})` : '未运行(dsh-ris-ide 引擎可用)')
436
+ : ((await isForwarderUp(cfg)) ? '运行中(内置转发器)' : `未运行(${borrowed.error})`)
437
+ }
438
+ return text(`YMS 进程: ${st.process ? `pid ${st.process.pid}` : '未运行'}\n端口 ${cfg.ymsPort}: ${st.port ? '监听中' : '未监听'}\n/_yms/ready: ${st.ready ?? '不可达'}\nyms-home: ${st.ymsHomeBuilt ? '已构建' : '未构建'}\nconfig: ${st.configPresent ? '已就绪' : '缺失'}\nsocks 转发器: ${fwd}`)
439
+ }
440
+ case 'logs': {
441
+ const lines = Number(flags['--lines'] ?? 80) || 80
442
+ const consoleLog = tailFile(join(cfg.stateDir, 'yms-console.log'), lines)
443
+ const appLog = tailFile(join(cfg.ymsHome, 'logs', 'c-ass-kk-hans', 'yms-application.log'), lines)
444
+ ?? tailFile(join(cfg.ymsHome, 'logs', `${cfg.module}`, 'yms-application.log'), lines)
445
+ return text([consoleLog && `--- yms-console.log ---\n${consoleLog}`, appLog && `--- yms-application.log ---\n${appLog}`].filter(Boolean).join('\n') || '无日志')
446
+ }
447
+ case 'ide': {
448
+ const action = flags._[0] ?? 'check'
449
+ const ideUsage = '用法: yds ide [apply|remove|check]'
450
+ if (action !== 'apply' && action !== 'remove' && action !== 'check') return fail(`未知动作: ${action}\n${ideUsage}`)
451
+ const ideArgProblem = checkArgs(flags, { positionals: 1 })
452
+ if (ideArgProblem !== '') return fail(`${ideArgProblem}\n${ideUsage}`)
453
+ if (action === 'apply') {
454
+ const yonWarn = yoncodeWarning(detectYoncode(cfg))
455
+ const watch = registerWorkspace(cfg.workspace)
456
+ const r = applyIdeConfig(cfg)
457
+ const l = applyLaunchConfig(cfg)
458
+ const ok = !r.error && !l.error && watch.ok === true
459
+ return (ok ? text : fail)(
460
+ `.dsh-ris-ide/config.json: ${r.action}${r.changed ? '(已写入)' : ''}${r.error ? ` —— ${r.error}` : ''}\n`
461
+ + `条目: ${JSON.stringify(r.entry ?? ourEntry(cfg), null, 2)}\n`
462
+ + `.vscode/launch.json: ${l.action}${l.changed ? '(已写入)' : ''}${l.error ? ` —— ${l.error}` : ''}\n`
463
+ + `launch 条目: ${JSON.stringify(l.entry ?? ourLaunchEntry(cfg), null, 2)}\n`
464
+ + launchModeNote(cfg)
465
+ + (Array.isArray(r.renamed) && r.renamed.length > 0
466
+ ? `条目按项目文件改名: ${r.renamed.map((item) => `${item.from} → ${item.to}`).join(',')}\n`
467
+ : '')
468
+ + (Array.isArray(r.disabled) && r.disabled.length > 0
469
+ ? `已禁用被 yds 启动器取代的后端条目: ${r.disabled.map((item) => item.name).join(',')}\n`
470
+ : '')
471
+ + (Array.isArray(r.cleaned) && r.cleaned.length > 0
472
+ ? `已清除误挂到其它条目的 yds startCommand: ${r.cleaned.map((item) => item.name).join(',')}\n`
473
+ : '')
474
+ + (watch.ok === true
475
+ ? `监听已开启(共 ${watch.workspaces.length} 个工作区):ris-ide 初始化/刷新配置后自动补回本条目\n`
476
+ : `监听注册失败: ${watch.error}\n`)
477
+ + `之后可用 ris_dev_start/ris_dev_debug 启停调试 ${entryName(cfg)};YonCode 端 F5 用 ${LAUNCH_ENTRY_NAME}`
478
+ + (yonWarn === null ? '' : `\n\n注意: ${yonWarn}`),
479
+ )
480
+ }
481
+ if (action === 'remove') {
482
+ const r = removeIdeConfig(cfg)
483
+ const l = removeLaunchConfig(cfg)
484
+ const watch = unregisterWorkspace(cfg.workspace)
485
+ return text(
486
+ `.dsh-ris-ide/config.json: ${r.action}\n.vscode/launch.json: ${l.action}`
487
+ + `\n监听: 已停止(剩余 ${watch.workspaces.length} 个工作区)`,
488
+ )
489
+ }
490
+ const current = readIdeConfig(cwd)
491
+ const entry = current.parsed?.programs?.find((p) => p?.name === entryName(cfg))
492
+ const launch = readLaunchConfig(launchRoot(cfg))
493
+ const launchEntry = launch.parsed?.configurations?.find((c) => c?.name === LAUNCH_ENTRY_NAME)
494
+ return text(
495
+ `.dsh-ris-ide/config.json: ${current.exists ? current.path : '(不存在)'}\n`
496
+ + `yds 条目: ${entry ? JSON.stringify(entry, null, 2) : '缺失(yds ide apply 添加)'}\n`
497
+ + `.vscode/launch.json: ${launch.exists ? launch.path : '(不存在)'}\n`
498
+ + `yds launch 条目: ${launchEntry ? LAUNCH_ENTRY_NAME + '(在)' : '缺失(yds ide apply 添加)'}`,
499
+ )
500
+ }
501
+ case 'yoncode': {
502
+ // YonCode advertises whatever address settings.json holds, but refuses to
503
+ // launch unless it is a local NIC IP. Our runs use a callback address
504
+ // (NAT / WSL / port forward), so the check has to become a warning.
505
+ const action = flags._[0] ?? 'status'
506
+ const yoncodeUsage = '用法: yds yoncode [status|patch|revert](patch 可加 --no-socks)'
507
+ if (action !== 'status' && action !== 'patch' && action !== 'revert') return fail(`未知动作: ${action}\n${yoncodeUsage}`)
508
+ const yoncodeArgProblem = checkArgs(flags, { positionals: 1, allowed: ['--no-socks'] })
509
+ if (yoncodeArgProblem !== '') return fail(`${yoncodeArgProblem}\n${yoncodeUsage}`)
510
+ const detected = detectYoncode(cfg)
511
+ if (action === 'status') {
512
+ const socks = detected.socksState === 'patched'
513
+ ? `JVM 代理参数: 已注入${cfg.socks === null || cfg.socks === undefined ? '(.env 未配置 socks,值无法校验)' : `(${cfg.socks.host}:${cfg.socks.port})`}`
514
+ : 'JVM 代理参数: 未注入'
515
+ return text(`${describeYoncode(detected)}\n${socks}`)
516
+ }
517
+ if (action === 'patch') {
518
+ // `--no-socks` keeps the NIC fix only: the JVM flags are a workaround for
519
+ // ProxyBridge's WinDivert path, and some setups do not need it.
520
+ // launch mode decides the default: a direct-mode YonCode must not get
521
+ // -DsocksProxyHost, otherwise the very next F5 mixes the two modes even
522
+ // though launch.json is clean. `--no-socks` still wins for the odd case.
523
+ const wantSocks = '--no-socks' in flags ? null : (cfg.launchMode === 'direct' ? null : (cfg.socks ?? null))
524
+ const r = patchYoncode(detected, { socks: wantSocks })
525
+ if (r.ok && !r.changed) return text(`不需要修改:${describeYoncode(detected)}`)
526
+ if (!r.ok) {
527
+ return fail(`打补丁失败:${r.error}${r.needElevation ? '\n需要写 YonCode 安装目录的权限:用管理员身份运行一次,或把 DSH 会话策略设为 danger-full-access 后重试' : ''}`)
528
+ }
529
+ const done = [`已把 serverBindAddress 校验改为告警`]
530
+ if (r.socksState === 'patched' && wantSocks !== null && wantSocks !== undefined) {
531
+ done.push(`已给 YMS 启动参数注入 -DsocksProxyHost=${cfg.socks.host} -DsocksProxyPort=${cfg.socks.port}(java 不再走 ProxyBridge)`)
532
+ }
533
+ return text(`${done.join(';')}(备份 ${detected.file}.bak)。重启 YonCode(Reload Window)后生效。`)
534
+ }
535
+ if (action === 'revert') {
536
+ const r = restoreYoncode(detected)
537
+ if (!r.ok) {
538
+ return fail(`还原失败:${r.error}${r.needElevation ? '\n需要写 YonCode 安装目录的权限(管理员或 danger-full-access)' : ''}`)
539
+ }
540
+ return text('已从 .bak 还原 YonCode 原始文件;Reload Window 后恢复原校验行为。')
541
+ }
542
+ return fail(yoncodeUsage)
543
+ }
544
+ case 'proxy': {
545
+ // Borrowed-engine control surface: the same engine the IDE's 🌐 dialog
546
+ // shows, so a rule written here is visible there and vice versa.
547
+ const action = flags._[0] ?? 'status'
548
+ const proxyUsage = '用法: yds proxy [status|sync|start|stop|git-on|git-off|redirector [status|sync]]'
549
+ const proxyActions = ['status', 'sync', 'start', 'stop', 'git-on', 'git-off', 'redirector']
550
+ if (proxyActions.includes(action) !== true) return fail(`未知动作: ${action}\n${proxyUsage}`)
551
+ const proxyArgProblem = checkArgs(flags, { positionals: action === 'redirector' ? 2 : 1 })
552
+ if (proxyArgProblem !== '') return fail(`${proxyArgProblem}\n${proxyUsage}`)
553
+ const loaded = await loadIdeProxy(cfg)
554
+ cfg.__ideProxy = loaded
555
+ // dsh-ris-ide is optional: without it the bundled SOCKS forwarder is the
556
+ // tunnel (the same fallback `yds start` uses in prepareStart). Only the
557
+ // actions that genuinely need the borrowed engine (its rules, the panel)
558
+ // report the absence as an error.
559
+ const engineMissing = loaded.available !== true
560
+ const engineNote = engineMissing
561
+ ? `代理引擎不可用:${loaded.error}\n(未安装 dsh-ris-ide 时 yds 使用内置 socks 转发器;也可用 .env 的 proxy.engine=self 强制)`
562
+ : ''
563
+ if (engineMissing && action === 'status') {
564
+ const targets = cfg.socks === null ? [] : forwarderTargets(cfg)
565
+ const up = cfg.socks !== null && await isForwarderUp(cfg)
566
+ return text(`${engineNote}\n`
567
+ + `内置转发器: ${cfg.socks === null ? '不适用(未配置 SOCKS,直连模式)' : up ? '运行中(127.0.0.1 兜底映射由它提供)' : '未运行(yds start / yds proxy start 会拉起)'}\n`
568
+ + `转发目标: ${targets.length === 0 ? '(无:confcenter 缓存里没有 DB/Redis 地址)' : targets.map((t) => `${t.host}:${t.port}`).join(', ')}`)
569
+ }
570
+ if (action === 'status') {
571
+ const st = await borrowedProxyStatus(cfg)
572
+ const own = st.rules.filter((rule) => rule.source === 'yds')
573
+ return text(`引擎: ${loaded.path}\n运行: ${st.running ? `是(pid ${st.pid})` : '否'}\n监听端口: ${st.listenPort ?? '(未知)'}\n`
574
+ + `yds 规则: ${own.length === 0 ? '(尚未生成,yds start 时自动写)' : own.map((rule) => `${rule.hosts}:${rule.ports}${rule.shadow ? ' [127.0.0.1 映射]' : ''}`).join(', ')}\n`
575
+ + `其它规则: ${st.rules.length - own.length} 条(用户/IDE 自己的)`)
576
+ }
577
+ if (action === 'sync' || action === 'start') {
578
+ const targets = forwarderTargets(cfg)
579
+ if (targets.length === 0) return fail('无转发目标(confcenter 缓存里没有 DB/Redis 地址)——先运行 yds confcenter')
580
+ const result = await ensureBorrowedProxy(cfg, targets, { log: (line) => process.stdout.write(`[yds] ${line}\n`) })
581
+ if (result.mode === 'none') return text('直连模式(.env 未配置 SOCKS):不需要代理引擎,配置里的地址就是可达地址')
582
+ if (result.mode === 'unavailable') {
583
+ // Standalone path: no borrowed engine (dsh-ris-ide absent, or
584
+ // proxy.engine=self) — bring the bundled forwarder up instead.
585
+ const notes = [engineNote]
586
+ if (!(await isForwarderUp(cfg))) {
587
+ const started = await startForwarder(cfg, targets)
588
+ if (started.started !== true) return fail(`${engineNote}\n内置 socks 转发器未启动:${started.reason ?? '(未知原因)'}`)
589
+ notes.push(`内置 socks 转发器已启动(pid ${started.pid}):${started.targets.join(', ')}`)
590
+ } else {
591
+ notes.push('内置 socks 转发器已在运行')
592
+ }
593
+ const launch = readLaunchConfig(launchRoot(cfg))
594
+ let launchNote = ''
595
+ if (launch.exists && Array.isArray(launch.parsed?.configurations)
596
+ && launch.parsed.configurations.some((c) => c?.name === LAUNCH_ENTRY_NAME)) {
597
+ const applied = applyLaunchConfig(cfg)
598
+ launchNote = `\nlaunch.json: ${applied.action}${applied.changed ? '(已写入 java 代理参数,YonCode/VS Code F5 启动的 java 也会走 SOCKS)' : ''}`
599
+ }
600
+ return text(`${notes.filter(Boolean).join('\n')}${launchNote}`)
601
+ }
602
+ if (result.ok !== true) return fail(`代理引擎启动失败:${result.error ?? '(未返回原因)'}`)
603
+ // A tunnel exists now: let YonCode's own F5 run use it too (we cannot
604
+ // intercept a process we do not spawn, but launch.json travels with it).
605
+ const launch = readLaunchConfig(launchRoot(cfg))
606
+ let launchNote = ''
607
+ if (launch.exists && Array.isArray(launch.parsed?.configurations)
608
+ && launch.parsed.configurations.some((c) => c?.name === LAUNCH_ENTRY_NAME)) {
609
+ const applied = applyLaunchConfig(cfg)
610
+ launchNote = `\nlaunch.json: ${applied.action}${applied.changed ? '(已写入 java 代理参数,YonCode/VS Code F5 启动的 java 也会走 SOCKS)' : ''}`
611
+ }
612
+ return text(`代理引擎已就绪(pid ${result.pid ?? '?'}):${(result.shadowPorts ?? []).length} 个 127.0.0.1 映射${launchNote}`)
613
+ }
614
+ if (action === 'stop') {
615
+ const r = engineMissing
616
+ ? { ok: false, error: loaded.error }
617
+ : await stopBorrowedProxy(cfg)
618
+ // The bundled forwarder is a tunnel too: stopping the proxy has to take
619
+ // it down whether or not dsh-ris-ide is installed.
620
+ const fwd = cfg.socks === null
621
+ ? { stopped: false, reason: 'socks 未配置(直连模式)' }
622
+ : await stopForwarder(cfg).catch((error) => ({ stopped: false, reason: String(error?.message ?? error) }))
623
+ // The tunnel is gone, so the flags it justified must go too — otherwise
624
+ // YonCode's next F5 points java at a dead loopback port.
625
+ const stripped = stripLaunchProxy(cfg)
626
+ const stopNote = stripped.changed
627
+ ? '\nlaunch.json: 已移除 java 代理参数(-DsocksProxyHost/-DsocksProxyPort)'
628
+ : ''
629
+ const engineLine = r.ok === true || r.stopped === true
630
+ ? '代理引擎已停止'
631
+ : engineMissing ? `代理引擎未启用(${loaded.error})` : `代理引擎未停止:${r.error ?? r.reason ?? '未运行'}`
632
+ const fwdLine = fwd.stopped === true ? `\n内置转发器已停止(pid ${fwd.pid})` : ''
633
+ return text(engineLine + fwdLine + stopNote)
634
+ }
635
+ if (action === 'git-on' || action === 'git-off') {
636
+ const r = await applyGitProxy(cfg, { enabled: action === 'git-on' })
637
+ return r.ok === true
638
+ ? text(`git ${action === 'git-on' ? '代理已写入' : '代理已清除'}:${r.key}`)
639
+ : fail(`git 操作失败:${r.error}`)
640
+ }
641
+ if (action === 'redirector') {
642
+ // ProxyBridge bridge: export/status only. Starting it needs root/UAC, so
643
+ // the command is reported for the user (or an agent with rights) to run.
644
+ const sub = flags._[1] ?? 'status'
645
+ const redirectorUsage = '用法: yds proxy redirector [status|sync]'
646
+ if (sub !== 'status' && sub !== 'sync') return fail(`未知动作: ${sub}\n${redirectorUsage}`)
647
+ const loaded = await loadIdeRedirector(cfg)
648
+ if (loaded.available !== true) {
649
+ const message = 'ProxyBridge 适配器不可用(dsh-ris-ide 未安装或版本过旧)'
650
+ // status only reports availability; sync was asked to export a profile.
651
+ return sub === 'status' ? text(message) : fail(message)
652
+ }
653
+ const status = loaded.mod.redirectorStatus()
654
+ const describe = (side, label) => {
655
+ const state = status[side] ?? {}
656
+ return `${label}: ${state.available === true ? state.path + (state.version ? ` (v${state.version})` : '') : '未安装'}`
657
+ }
658
+ if (sub === 'status') {
659
+ return text([
660
+ describe('local', '本侧'),
661
+ describe('windows', 'Windows'),
662
+ `profile: ${status.profilePath}${status.profileExists ? '' : '(尚未导出)'}`,
663
+ // Two sides, two answers: a Windows engine does nothing for processes in
664
+ // WSL, and vice versa, so the label has to say which one is up.
665
+ `运行: ${status.running ? `${status.runningSide === 'windows' ? 'Windows 侧' : '本侧'}(pid ${status.pid})` : '否(本侧需要 root,Windows 侧需要管理员)'}`,
666
+ '',
667
+ '生命周期:随 yds start 自动启动(提权走系统弹窗)、随 yds stop 自动停止;',
668
+ '手动:yds proxy redirector start|stop,或执行 sync 输出的命令',
669
+ ].join('\n'))
670
+ }
671
+ if (sub === 'sync') {
672
+ const result = await syncRedirectorProfile(cfg)
673
+ if (result.available !== true) return fail(`导出失败:${result.reason ?? '(未知原因)'}`)
674
+ return text([
675
+ `profile: ${result.profilePath}(${result.rules} 条规则)`,
676
+ result.running ? 'ProxyBridge 已在运行:重启后新规则才生效' : '启动命令(需要 sudo / 管理员):',
677
+ result.command,
678
+ ].join('\n'))
679
+ }
680
+ return fail(redirectorUsage)
681
+ }
682
+ return fail(proxyUsage)
683
+ }
684
+ case 'browser': {
685
+ // The workbench browser is where rdbga must live: an empty host-only cookie
686
+ // silently sends every request to the remote pod (page fine, local log empty,
687
+ // breakpoints never hit). status shows it, fix repairs it, --dry-run previews.
688
+ const usageLine = '用法: yds browser [status|fix] [--rdbga=<base64>] [--dry-run]'
689
+ const action = flags._[0] ?? 'status'
690
+ if (action !== 'status' && action !== 'fix') return fail(`未知动作: ${action}\n${usageLine}`)
691
+ const argProblem = checkArgs(flags, { positionals: 1, allowed: ['--rdbga', '--dry-run'] })
692
+ if (argProblem !== '') return fail(`${argProblem}\n${usageLine}`)
693
+ if ('--rdbga' in flags && (typeof flags['--rdbga'] !== 'string' || flags['--rdbga'].trim() === '')) {
694
+ return fail(`--rdbga 需要一个 base64 值\n${usageLine}`)
695
+ }
696
+ const dryRun = flags['--dry-run'] === true
697
+ if (dryRun && action !== 'fix') return fail(`--dry-run 只用于 fix(status 只读)\n${usageLine}`)
698
+ const override = typeof flags['--rdbga'] === 'string' ? flags['--rdbga'] : ''
699
+ // Probe the browser before asking the JVM: with no DevTools port there is nowhere
700
+ // to write, so the 1–2s PowerShell query would be wasted (m7).
701
+ const found = await findWorkbenchPort(cfg)
702
+ if (found.port === 0) return fail(`${found.error}\n${noDevtoolsHint(cfg)}`)
703
+ if (action === 'status') {
704
+ // The cookie read and the JVM query are independent: do both at once.
705
+ const [expected, read] = await Promise.all([
706
+ expectedRdbga(cfg, override),
707
+ readRdbgaCookies({ port: found.port, targets: found.targets, hosts: found.hosts }),
708
+ ])
709
+ const lines = [
710
+ `DevTools: 127.0.0.1:${found.port}(${found.browser || '未知版本'})`,
711
+ `工作台页签: ${found.targets.length}`,
712
+ `期望 rdbga: ${expected.ok === true ? expected.value : '(无法确定)'}(来源:${expected.source || '无'})`,
713
+ ]
714
+ if (expected.ok !== true) lines.push(`原因: ${expected.reason}`)
715
+ else if (typeof expected.hint === 'string' && expected.hint !== '') lines.push(`提示: ${expected.hint}`)
716
+ lines.push(read.ok === true
717
+ ? describeRdbgaCookies(read.cookies, expected.ok === true ? expected.value : '', found.hosts)
718
+ : `读取 cookie 失败: ${read.error}`)
719
+ return text(lines.join('\n'))
720
+ }
721
+ // fix: reuse the targets just probed (a second listing can differ).
722
+ const result = await healBrowserCookie(cfg, { override, dryRun, findPort: async () => found })
723
+ if (result.ok !== true) return fail(`${dryRun === true ? '预览失败' : '校准失败'}: ${result.reason}`)
724
+ if (dryRun === true) {
725
+ return text(`预览(--dry-run 未改动浏览器):将清理 ${result.removed} 个旧/空 cookie,${result.wrote} 个页签将写入 rdbga=${result.expected}(来源 ${result.source})`)
726
+ }
727
+ return text(`已校准: 清理 ${result.removed} 个旧/空 cookie,${result.tabs} 个页签持有 rdbga=${result.expected}(来源 ${result.source})`)
728
+ }
729
+ case 'socks-fwd': {
730
+ // internal: long-running forwarder process spawned by startForwarder()
731
+ // argv shape: [--socks=<host:port>] <localPort:targetHost:targetPort>...
732
+ let socksHost = '127.0.0.1'
733
+ let socksPort = 1080
734
+ if (typeof flags['--socks'] === 'string') {
735
+ const [h, p] = flags['--socks'].split(':')
736
+ socksHost = h
737
+ socksPort = Number(p) || 1080
738
+ }
739
+ const specs = []
740
+ for (const arg of flags._) {
741
+ const parts = String(arg).split(':')
742
+ if (parts.length === 3) specs.push({ local: Number(parts[0]), host: parts[1], port: Number(parts[2]) })
743
+ }
744
+ runForwarder(socksHost, socksPort, specs)
745
+ return new Promise(() => { }) // runs until killed
746
+ }
747
+ default:
748
+ return fail(usage())
749
+ }
750
+ }
751
+
752
+ function mustCred(cfg) {
753
+ if (cfg.accessKey == null || cfg.accessSecret == null) throw new Error('缺少 accessKey/accessSecret(settings.json)')
754
+ return [cfg.accessKey, cfg.accessSecret]
755
+ }
756
+
757
+ /** Split a JVM-args string into argv entries, honoring double quotes. */
758
+ function splitJvmArgs(raw) {
759
+ if (typeof raw !== 'string') return []
760
+ const out = []
761
+ let cur = ''
762
+ let inQuote = false
763
+ for (let i = 0; i < raw.length; i += 1) {
764
+ const c = raw[i]
765
+ if (c === '"') { inQuote = !inQuote; continue }
766
+ if (c === ' ' && !inQuote) {
767
+ if (cur !== '') { out.push(cur); cur = '' }
768
+ continue
769
+ }
770
+ cur += c
771
+ }
772
+ if (cur !== '') out.push(cur)
773
+ return out
774
+ }
775
+
776
+ const BOOLEAN_FLAGS = new Set(['--foreground', '--offline', '--keep-socks', '--no-socks', '--dry-run'])
777
+
778
+ function asFlagValue(v) {
779
+ if (v === 'true' || v === '1' || v === 'yes') return true
780
+ if (v === 'false' || v === '0' || v === 'no') return false
781
+ return v
782
+ }
783
+
784
+ function parseFlags(args) {
785
+ const flags = { _: [] }
786
+ for (let i = 0; i < args.length; i += 1) {
787
+ const a = args[i]
788
+ if (a.startsWith('--')) {
789
+ const eq = a.indexOf('=')
790
+ if (eq > 0) {
791
+ const name = a.slice(0, eq)
792
+ const val = a.slice(eq + 1)
793
+ flags[name] = BOOLEAN_FLAGS.has(name) ? asFlagValue(val) : val
794
+ } else if (BOOLEAN_FLAGS.has(a)) flags[a] = true
795
+ else { const next = args[i + 1]; flags[a] = next; i += 1 }
796
+ } else flags._.push(a)
797
+ }
798
+ return flags
799
+ }
800
+
801
+ function warnProblems(problems) {
802
+ process.stderr.write(`警告:\n- ${problems.join('\n- ')}\n`)
803
+ }
804
+
805
+ /** Hosts whose cookies are the workbench's; derived from config, never hardcoded. */
806
+ function workbenchHosts(cfg) {
807
+ try { return hostVariants(new URL(String(cfg.workbenchUrl)).hostname) } catch { return [] }
808
+ }
809
+
810
+ /**
811
+ * The rdbga the local YMS uses, plus where that value came from.
812
+ *
813
+ * Writing a guessed value is worse than writing nothing: `fix` deletes the working
814
+ * cookie first, so a wrong value sends the gateway to an instance that does not exist.
815
+ * Hence: only a value we can verify is returned, and a JVM that reports a DIFFERENT
816
+ * value aborts instead of being overruled.
817
+ * @param {object} cfg resolved plugin config
818
+ * @param {string} override --rdbga value (highest priority)
819
+ * @param {{liveQuery?: () => Promise<{ok: boolean, values: string[], error: string}>}} options
820
+ */
821
+ export async function expectedRdbga(cfg, override = '', { liveQuery = rdbgaFromRunningJvm } = {}) {
822
+ if (override !== '') {
823
+ return isPlausibleRdbga(override) === true
824
+ ? { ok: true, value: override, source: '命令行指定' }
825
+ : { ok: false, value: '', source: '', reason: '--rdbga 不是合法值(应为 base64("module,/context@ip:port"))' }
826
+ }
827
+ if (cfg.module == null) {
828
+ return { ok: false, value: '', source: '', reason: '未识别模块名——请在项目目录下运行(或补 yonbuilder.json)' }
829
+ }
830
+ const bind = String(cfg.serverBindAddress ?? '')
831
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(bind) !== true) {
832
+ return { ok: false, value: '', source: '', reason: '缺少合法的 serverBindAddress(回调地址)——补项目 .vscode/settings.json 的 yds.serverBindAddress' }
833
+ }
834
+ const port = Number(cfg.serverPort)
835
+ if (Number.isInteger(port) !== true || port < 1 || port > 65535) {
836
+ return { ok: false, value: '', source: '', reason: '缺少合法的 serverPort——补项目 .vscode/settings.json 的 yds.serverPort' }
837
+ }
838
+ let own = ''
839
+ try { own = rdbgaValue(cfg) } catch (e) { return { ok: false, value: '', source: '', reason: e.message } }
840
+ const live = await liveQuery()
841
+ if (live.ok === true && live.values.length > 0) {
842
+ if (live.values.includes(own)) return { ok: true, value: own, source: '运行中的 JVM' }
843
+ return {
844
+ ok: false, value: '', source: '运行中的 JVM',
845
+ reason: `运行中的 JVM 用的是另一个 rdbga(${live.values.length} 个实例:${live.values.map((v) => `${String(v).slice(0, 10)}…`).join(', ')};与配置值 ${own.slice(0, 10)}… 都不一致)——多实例时用 --rdbga=<值> 指定,或先停掉别的实例`,
846
+ }
847
+ }
848
+ if (isPlausibleRdbga(own) !== true) return { ok: false, value: '', source: '', reason: '按配置算出的 rdbga 不合法' }
849
+ return { ok: true, value: own, source: '本机配置', hint: live.error }
850
+ }
851
+
852
+ /** First DevTools port that actually holds a workbench tab (not just any open port). */
853
+ async function findWorkbenchPort(cfg) {
854
+ const hosts = workbenchHosts(cfg)
855
+ if (hosts.length === 0) return { port: 0, targets: [], hosts, error: `无法从 workbenchUrl 推断工作台域名(${cfg.workbenchUrl || '缺失'})` }
856
+ for (const port of DEVTOOLS_PORTS) {
857
+ const listed = await workbenchTargets(port, { hosts })
858
+ if (listed.ok === true && listed.targets.length > 0) {
859
+ const version = await probeDevtoolsPorts([port])
860
+ return { port, targets: listed.targets, hosts, browser: version.browser, error: '' }
861
+ }
862
+ }
863
+ return { port: 0, targets: [], hosts, error: '没有开着工作台页签的 DevTools 端口(浏览器未启动或未打开工作台)' }
864
+ }
865
+
866
+ /**
867
+ * Where to plant the cookie by hand. `workbenchUrl` may be missing (running outside the
868
+ * project), and interpolating it blindly produced a dangling "在 域下" — so the missing
869
+ * case gets its own sentence.
870
+ */
871
+ export function browserCookieHint(cfg, value) {
872
+ const url = String(cfg?.workbenchUrl ?? '').trim()
873
+ if (url === '') {
874
+ return `浏览器侧: workbenchUrl 未配置——先进入项目目录/补 .vscode/settings.json 的 yds.workbenchUrl,再在浏览器里种 cookie rdbga=${value}`
875
+ }
876
+ return `浏览器侧: 在 ${url} 域下种 cookie rdbga=${value}`
877
+ }
878
+
879
+ /** Why there is nothing to calibrate, with the same empty-URL care as browserCookieHint. */
880
+ export function noDevtoolsHint(cfg) {
881
+ const url = String(cfg?.workbenchUrl ?? '').trim()
882
+ if (url === '') {
883
+ return '(workbenchUrl 未配置,且浏览器没有开 DevTools 端口——先进入项目目录/补 .vscode/settings.json 的 yds.workbenchUrl,打开工作台后重试)'
884
+ }
885
+ return `(浏览器没有开 DevTools 端口——先打开工作台页面再重试;也可手工在 ${url} 域下种 cookie rdbga=<yds start 输出的值>)`
886
+ }
887
+
888
+ /**
889
+ * Repair the workbench browser's rdbga cookie. Never throws and never blocks the caller
890
+ * for long: a browser whose DevTools socket accepts but stops answering must not hang
891
+ * `yds start` (the value of the deadline is that the CLI still reports the start).
892
+ * @param {object} cfg
893
+ * @param {{log?: (line: string) => void, override?: string, budgetMs?: number, dryRun?: boolean,
894
+ * findPort?: Function, repair?: Function, read?: Function, liveQuery?: Function}} options
895
+ */
896
+ export async function healBrowserCookie(cfg, {
897
+ log,
898
+ override = '',
899
+ budgetMs = 8000,
900
+ dryRun = false,
901
+ findPort = findWorkbenchPort,
902
+ repair = repairRdbgaCookie,
903
+ read = readRdbgaCookies,
904
+ liveQuery = rdbgaFromRunningJvm,
905
+ } = {}) {
906
+ const work = (async () => {
907
+ // The browser is probed FIRST: with no DevTools port the expected value has nowhere
908
+ // to go, so the 1–2s PowerShell lookup is not paid for nothing (m7).
909
+ const found = await findPort(cfg)
910
+ if (found.port === 0) return { ok: false, reason: found.error, expected: '', source: '' }
911
+ const expected = await expectedRdbga(cfg, override, { liveQuery })
912
+ if (expected.ok !== true) return { ok: false, reason: expected.reason, expected: '' }
913
+ // Reuse the targets just listed: a second /json/list may see a different tab set.
914
+ const result = await repair({ port: found.port, expected: expected.value, hosts: found.hosts, targets: found.targets, dryRun })
915
+ if (result.ok !== true) return { ok: false, reason: result.error, expected: expected.value, source: expected.source, port: found.port }
916
+ if (dryRun === true) {
917
+ if (typeof log === 'function') log(`浏览器 rdbga 预览(--dry-run):将清理 ${result.removed} 个,${result.wrote} 个页签将写入(来源 ${expected.source})`)
918
+ return { ...result, port: found.port, expected: expected.value, source: expected.source, dryRun: true }
919
+ }
920
+ // Read back: only a single, correct cookie means the gateway will route here.
921
+ const after = await read({ port: found.port, targets: found.targets, hosts: found.hosts })
922
+ if (after.ok !== true) {
923
+ // A silent "success" with an unreadable cookie store would be a lie: the whole
924
+ // point of the read-back is to prove the gateway will route here.
925
+ return { ok: false, reason: `写入后回读 cookie 失败: ${after.error}`, expected: expected.value, source: expected.source, port: found.port }
926
+ }
927
+ const verdict = classifyRdbgaCookies(after.cookies, expected.value, found.hosts)
928
+ if (verdict.keep.length !== 1 || verdict.remove.length !== 0) {
929
+ return { ok: false, reason: '写入后仍有多个/错误的 rdbga(请手动检查浏览器)', expected: expected.value, source: expected.source, port: found.port }
930
+ }
931
+ if (typeof log === 'function') log(`浏览器 rdbga 已校准(端口 ${found.port},清理 ${result.removed} 个,${result.tabs} 个页签,来源 ${expected.source})`)
932
+ return { ...result, port: found.port, expected: expected.value, source: expected.source }
933
+ })()
934
+ // The budget may win the race; a later failure of `work` must not surface as unhandled.
935
+ work.catch(() => {})
936
+ let timer = null
937
+ const budget = new Promise((resolve) => {
938
+ timer = setTimeout(() => resolve({ ok: false, reason: `浏览器校准超时(${budgetMs}ms)`, expected: '' }), budgetMs)
939
+ })
940
+ try {
941
+ return await Promise.race([work, budget])
942
+ } catch (e) {
943
+ return { ok: false, reason: e.message, expected: '', source: '' }
944
+ } finally {
945
+ // Without this the pending timer keeps the event loop (and the CLI) alive for the
946
+ // whole budget even when the repair already succeeded.
947
+ clearTimeout(timer)
948
+ }
949
+ }
950
+
951
+ function usage() {
952
+ return [
953
+ 'yds — YonBIP YDS 本地运行时 (dsh-ris-yds)',
954
+ '',
955
+ '用法: node yds.js <command> [options]',
956
+ ' doctor 环境体检:配置解析/运行状态/缺失项',
957
+ ' config 显示生效配置(来源: settings.json + .env + env)',
958
+ ' token <url> 生成 YYCToken',
959
+ ' confcenter [--offline] 拉取(或用缓存)并适配 4 个 YMS 配置到 yms-home/config',
960
+ ' build maven 部署 yms-home(首次含 kernel 下载)+ 拉配置',
961
+ ' start [--jvm-args "…"] 后台启动 YMS(SOCKS 自动转发; 幂等)',
962
+ ' start --foreground 前台启动(dsh-ris-ide startCommand 用)',
963
+ ' stop [--keep-socks] 停 YMS(默认连转发器一起停)',
964
+ ' status 进程/端口/ready/配置状态',
965
+ ' logs [--lines N] tail 控制台与应用日志',
966
+ ' ide [apply|remove|check] 维护 .dsh-ris-ide/config.json 的 yds 程序条目(+ .vscode/launch.json)',
967
+ ' proxy [status|sync|start|stop|git-on|git-off] 借用 dsh-ris-ide 的代理引擎',
968
+ ' proxy redirector [status|sync] ProxyBridge 系统层拦截(需 root/管理员启动)',
969
+ ' browser [status|fix] [--rdbga=<base64>] [--dry-run] 工作台浏览器 rdbga cookie 体检/校准(--dry-run 只预览)',
970
+ ' fe [rules|plan|url <page>] 前端调试接入(Resource Override 规则 / CDP 改路清单 / scriptUrl)',
971
+ ' yoncode [status|patch|revert] YonCode 网卡 IP 校验:检测/改为告警/还原',
972
+ ' patch --no-socks 只改网卡校验,不注入 JVM 代理参数',
973
+ '',
974
+ '配置来源(优先级 env > .env > settings.json > 默认):',
975
+ ' yonasset/.vscode/settings.json 项目标准配置(YonCode 兼容,只读)',
976
+ ' <workspace>/.env 可选机器配置: socks.host/port, git.*,',
977
+ ' ymsHome, java.path, maven.*(ymsPort 自动选空闲端口)',
978
+ '',
979
+ '失败时退出码非 0:bin 落地 run() 返回的 exitCode,agent 工具结果里带 ok:false。',
980
+ ].join('\n')
981
+ }
982
+
983
+ function text(value) {
984
+ return { text: value }
985
+ }
986
+
987
+ /** A failed command: text plus the non-zero exit code the bin (or tool) applies. */
988
+ function fail(value) {
989
+ return { text: value, exitCode: 1 }
990
+ }
991
+
992
+ /**
993
+ * Reject stray positionals/options instead of silently ignoring them: `yds browser fixx`
994
+ * used to run nothing and still report success.
995
+ * @returns {string} human-readable problem ('' when the arguments are fine)
996
+ */
997
+ function checkArgs(flags, { positionals = 0, allowed = [] } = {}) {
998
+ const extra = flags._.slice(positionals)
999
+ if (extra.length > 0) return `多余参数: ${extra.join(' ')}`
1000
+ const unknown = Object.keys(flags).filter((key) => key !== '_' && !allowed.includes(key))
1001
+ if (unknown.length > 0) return `未知选项: ${unknown.join(' ')}`
1002
+ return ''
1003
+ }