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.
@@ -0,0 +1,682 @@
1
+ // Borrow dsh-ris-ide's proxy engine instead of running a second tunnel stack.
2
+ //
3
+ // The IDE plugin owns `.dsh-ris-ide/proxy.json` and a single engine process that
4
+ // can (a) bind `127.0.0.1:<port>` shadows for literal IPv4 targets and (b) serve
5
+ // an HTTP CONNECT proxy restricted by the rule table. YDS needs exactly (a): the
6
+ // YMS runtime's JDBC/Redis endpoints are rewritten to `127.0.0.1:<same port>`, so
7
+ // the engine's shadows ARE the old `socks-forward.js` — with one owner for the
8
+ // ports, one pidfile, and a UI the user can inspect.
9
+ //
10
+ // So this module does not transport anything by itself: it generates the
11
+ // `source: 'yds'` rules (replaced wholesale on every start, user rows untouched),
12
+ // makes sure the engine is running them, and (optionally) writes the scoped git
13
+ // proxy entry so plain `git` commands — not just ones we spawn — honour SOCKS.
14
+ //
15
+ // When dsh-ris-ide is not installed the caller falls back to `lib/socks.js`, so
16
+ // the plugin still works on a machine that has only dsh-ris-yds.
17
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { dirname, join } from 'node:path'
20
+ import { execFileSync } from 'node:child_process'
21
+ import { yoncodeCandidates } from './yoncode.js'
22
+ import { pathToFileURL } from 'node:url'
23
+ import { spawn } from 'node:child_process'
24
+
25
+ /** Marker `source` used for every rule this plugin owns. */
26
+ export const YDS_RULE_SOURCE = 'yds'
27
+
28
+ /** Where a dsh-ris-ide checkout/install may keep its proxy engine. */
29
+ export function ideProxyCandidates(cfg) {
30
+ const list = []
31
+ const explicit = process.env.YDS_IDE_PROXY
32
+ if (typeof explicit === 'string' && explicit !== '') list.push(explicit)
33
+ const workspace = typeof cfg?.workspace === 'string' ? cfg.workspace : ''
34
+ if (workspace !== '') {
35
+ // A link-installed plugin lives in the profile's node_modules; a workspace
36
+ // install (or a sibling checkout) is the other common layout.
37
+ list.push(join(workspace, 'node_modules', 'dsh-ris-ide', 'lib', 'proxy.js'))
38
+ list.push(join(workspace, 'work', 'dsh-ris-ide', 'lib', 'proxy.js'))
39
+ list.push(join(workspace, '..', 'dsh-ris-ide', 'lib', 'proxy.js'))
40
+ }
41
+ list.push(join(homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-ris-ide', 'lib', 'proxy.js'))
42
+ return list
43
+ }
44
+
45
+ /**
46
+ * Resolve and import the IDE's proxy engine.
47
+ * @returns {Promise<{available: boolean, path: string, mod: object|null, error: string}>}
48
+ */
49
+ export async function loadIdeProxy(cfg) {
50
+ const explicit = process.env.YDS_IDE_PROXY
51
+ // `none` is the documented off switch (same effect as proxy.engine=self, but
52
+ // it also drops the search): tests and machines with a broken install use it.
53
+ if (explicit === 'none') return { available: false, path: '', mod: null, error: '已通过 YDS_IDE_PROXY=none 禁用' }
54
+ const tried = []
55
+ let lastError = ''
56
+ for (const candidate of ideProxyCandidates(cfg)) {
57
+ tried.push(candidate)
58
+ if (!existsSync(candidate)) continue
59
+ try {
60
+ const mod = await import(pathToFileURL(candidate).href)
61
+ if (typeof mod.startProxy !== 'function' || typeof mod.mergeConfigs !== 'function') {
62
+ lastError = `模块不完整(缺少 startProxy/mergeConfigs): ${candidate}`
63
+ if (candidate === explicit) break
64
+ continue
65
+ }
66
+ return { available: true, path: candidate, mod, error: '' }
67
+ } catch (error) {
68
+ lastError = `${candidate}: ${error?.message ?? error}`
69
+ // An explicit override that exists but does not load is decisive: falling
70
+ // through to the profile install would hide the user's broken setting.
71
+ if (candidate === explicit) break
72
+ }
73
+ }
74
+ return { available: false, path: '', mod: null, error: lastError === '' ? `未找到 dsh-ris-ide 的代理引擎(已尝试 ${tried.length} 处)` : lastError }
75
+ }
76
+
77
+ /**
78
+ * Resolve the IDE's ProxyBridge adapter (the system-level interceptor).
79
+ *
80
+ * It is a SIBLING module of the proxy engine (`lib/redirector.js` in the same
81
+ * install), so a machine with the engine always has it. Loading it is optional:
82
+ * the bridge then exports the same rule table as a `.pbprofile` and reports the
83
+ * command that would start it (starting needs root/UAC, which we never have).
84
+ * @param {object} cfg - resolved yds config.
85
+ * @returns {Promise<{available: boolean, path: string, mod: object|null}>}
86
+ */
87
+ export async function loadIdeRedirector(cfg) {
88
+ const engine = await loadIdeProxy(cfg)
89
+ if (engine.available === false) return { available: false, path: '', mod: null }
90
+ const candidate = join(dirname(engine.path), 'redirector.js')
91
+ if (!existsSync(candidate)) return { available: false, path: candidate, mod: null }
92
+ try {
93
+ const mod = await import(pathToFileURL(candidate).href)
94
+ if (typeof mod.syncRedirector !== 'function' || typeof mod.redirectorStatus !== 'function') {
95
+ return { available: false, path: candidate, mod: null }
96
+ }
97
+ return { available: true, path: candidate, mod }
98
+ } catch {
99
+ return { available: false, path: candidate, mod: null }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Export the machine rule table as a ProxyBridge profile.
105
+ *
106
+ * Deliberately never starts it: the process needs root (Linux eBPF) or UAC
107
+ * (Windows WinDivert), and a CLI that silently asked for a password in the middle
108
+ * of `yds start` would be worse than handing back the exact command.
109
+ * @param {object} cfg - resolved yds config.
110
+ * @returns {Promise<{available: boolean, profilePath?: string, rules?: number, command?: string, reason?: string}>}
111
+ */
112
+ export async function syncRedirectorProfile(cfg) {
113
+ const loaded = await loadIdeRedirector(cfg)
114
+ if (loaded.available === false) return { available: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
115
+ const status = loaded.mod.redirectorStatus()
116
+ if (status.local.available !== true && status.windows.available !== true) {
117
+ return { available: false, reason: '未找到 ProxyBridge CLI(本侧与 Windows 侧都没有)' }
118
+ }
119
+ const result = await loaded.mod.syncRedirector(cfg?.workspace ?? '', { start: false })
120
+ const display = typeof result?.command === 'object' && result.command !== null ? String(result.command.display ?? '') : ''
121
+ return {
122
+ available: true,
123
+ profilePath: result?.written?.path ?? status.profilePath,
124
+ rules: result?.written?.rules ?? 0,
125
+ command: display,
126
+ reason: typeof result?.reason === 'string' ? result.reason : '',
127
+ running: status.running === true,
128
+ }
129
+ }
130
+
131
+ /** True for a dotted-quad IPv4 literal (the only host shape a shadow can bind). */
132
+ export function isIpv4Literal(host) {
133
+ return typeof host === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
134
+ }
135
+
136
+ /**
137
+ * Whether a literal IPv4 address sits in an RFC1918 range.
138
+ *
139
+ * Only such destinations need interception: a public host (the workbench, the
140
+ * gitlab front door) is reachable from this machine anyway, and routing it
141
+ * through the tunnel would only add a hop.
142
+ * @param {string} host - dotted-quad address.
143
+ * @returns {boolean} true for 10/8, 172.16/12 and 192.168/16.
144
+ */
145
+ export function isPrivateIpv4(host) {
146
+ if (!isIpv4Literal(host)) return false
147
+ const [a, b] = host.split('.').map(Number)
148
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
149
+ }
150
+
151
+ /**
152
+ * A path the way ProxyBridge compares it: on Windows it always sees backslashes
153
+ * and a drive letter, so a WSL path has to become `D:\...` before it can match
154
+ * anything. POSIX paths (a native Linux YonCode) are returned unchanged.
155
+ */
156
+ export function toProcessPattern(value) {
157
+ if (typeof value !== 'string' || value === '') return ''
158
+ const mounted = /^\/mnt\/([a-z])\/(.+)$/i.exec(value)
159
+ if (mounted !== null) return `${mounted[1].toUpperCase()}:\\${mounted[2].replace(/\//g, '\\')}`
160
+ if (/^[A-Za-z]:[\\/]/.test(value) === true || value.includes('\\') === true) return value.replace(/\//g, '\\')
161
+ return value
162
+ }
163
+
164
+ function existingDirectory(value) {
165
+ try {
166
+ return statSync(value).isDirectory()
167
+ } catch {
168
+ return false
169
+ }
170
+ }
171
+
172
+ function existingFile(value) {
173
+ try {
174
+ return statSync(value).isFile()
175
+ } catch {
176
+ return false
177
+ }
178
+ }
179
+
180
+ /**
181
+ * The executables YonCode itself runs: the JDK and maven it ships, plus the git it
182
+ * shells out to. They live outside the project, so a rule written from the project
183
+ * config would never name them — yet these are exactly the processes that need the
184
+ * tunnel to reach 172.20.235.* and gitlab.
185
+ */
186
+ export function yoncodeProcesses(options = {}) {
187
+ const homes = Array.isArray(options.homes) === true ? options.homes : []
188
+ const out = { java: '', git: '', home: '' }
189
+ for (const home of homes) {
190
+ if (typeof home !== 'string' || home === '' || existingDirectory(home) !== true) continue
191
+ const devkit = join(home, 'devkit')
192
+ if (existingDirectory(devkit) === false) continue
193
+ if (out.java === '') {
194
+ let versions = []
195
+ try {
196
+ versions = readdirSync(devkit, { withFileTypes: true })
197
+ } catch {
198
+ versions = []
199
+ }
200
+ for (const entry of versions) {
201
+ if (entry.isDirectory() !== true) continue
202
+ // Only JDK trees: devkit also holds maven/node/etc., and a bare `*` would
203
+ // turn into a ProcessName that matches programs we never meant to intercept.
204
+ if (/^jdk/i.test(entry.name) !== true) continue
205
+ if (existingFile(join(devkit, entry.name, 'bin', 'java.exe')) === false) continue
206
+ // Wildcard the version directory: YonCode upgrades its JDK in place and the
207
+ // rule must survive that.
208
+ // `jdk*\bin\*.exe` covers java.exe/javaw.exe and survives a JDK upgrade.
209
+ out.java = toProcessPattern(join(devkit, 'jdk*', 'bin', '*.exe'))
210
+ out.home = home
211
+ break
212
+ }
213
+ }
214
+ if (out.git === '' && existingFile(join(devkit, 'git', 'bin', 'git.exe')) === true) {
215
+ out.git = toProcessPattern(join(devkit, 'git', 'bin', 'git.exe'))
216
+ }
217
+ }
218
+ if (out.git === '') {
219
+ for (const candidate of Array.isArray(options.gitPaths) === true ? options.gitPaths : []) {
220
+ if (typeof candidate !== 'string' || candidate === '' || existingFile(candidate) !== true) continue
221
+ out.git = toProcessPattern(candidate)
222
+ break
223
+ }
224
+ }
225
+ return out
226
+ }
227
+
228
+ /** Where YonCode runs on this machine, as paths this process can stat. */
229
+ export function yoncodeHomes(cfg) {
230
+ const explicit = typeof cfg?.yoncodeHome === 'string' && cfg.yoncodeHome !== ''
231
+ ? [cfg.yoncodeHome]
232
+ : yoncodeCandidates(cfg ?? {})
233
+ return explicit.filter((value) => existingDirectory(value) === true)
234
+ }
235
+
236
+ /**
237
+ * The git YonCode uses when it is not the one bundled with the devkit: the Windows
238
+ * installs reachable from WSL, then whatever `git` resolves to here.
239
+ */
240
+ export function candidateGitPaths(options = {}) {
241
+ if (Array.isArray(options.gitPaths) === true) return options.gitPaths
242
+ const out = [
243
+ '/mnt/c/Program Files/Git/bin/git.exe',
244
+ '/mnt/d/Program Files/Git/bin/git.exe',
245
+ '/mnt/c/Program Files/Git/cmd/git.exe',
246
+ '/mnt/d/Program Files/Git/cmd/git.exe',
247
+ ]
248
+ if (process.platform === 'win32') {
249
+ try {
250
+ const where = execFileSync('where.exe', ['git'], { encoding: 'utf8', timeout: 4000, windowsHide: true })
251
+ for (const line of where.split(/\r?\n/)) if (line.trim() !== '') out.unshift(line.trim())
252
+ } catch {
253
+ // Git is optional here: without it the generic `git` rule already covers PATH.
254
+ }
255
+ }
256
+ return out
257
+ }
258
+
259
+ /**
260
+ * Rules that name YonCode's own executables, labelled so the dialog and the
261
+ * ProxyBridge profile say where they came from.
262
+ *
263
+ * `hosts` is the set of private /24 segments the backend really dials, derived from the
264
+ * resolved endpoints: YonCode's java must be redirected to the same place our own
265
+ * `java` rows point, and hardcoding any range would silently miss another environment.
266
+ */
267
+ export function yoncodeRules(cfg, options = {}) {
268
+ const withJava = options.java !== false
269
+ const hosts = Array.isArray(options.hosts) ? options.hosts.filter((host) => String(host).trim() !== '') : []
270
+ const found = yoncodeProcesses({
271
+ homes: options.homes ?? yoncodeHomes(cfg),
272
+ gitPaths: options.gitPaths ?? candidateGitPaths(options),
273
+ })
274
+ const workspace = options.workspace ?? cfg?.workspace ?? ''
275
+ const gitHost = options.gitHost ?? ''
276
+ const rules = []
277
+ if (withJava && found.java !== '' && hosts.length > 0) {
278
+ rules.push({
279
+ id: 'yds-yoncode-java',
280
+ label: 'YonCode java',
281
+ program: found.java,
282
+ hosts: hosts.join(','),
283
+ ports: '*',
284
+ enabled: true,
285
+ shadow: false,
286
+ source: YDS_RULE_SOURCE,
287
+ workspace,
288
+ })
289
+ }
290
+ if (found.git !== '' && gitHost !== '') {
291
+ rules.push({
292
+ id: 'yds-yoncode-git',
293
+ label: 'YonCode git',
294
+ program: found.git,
295
+ hosts: gitHost,
296
+ ports: '*',
297
+ enabled: true,
298
+ shadow: false,
299
+ source: YDS_RULE_SOURCE,
300
+ workspace,
301
+ })
302
+ }
303
+ return rules
304
+ }
305
+
306
+ /**
307
+ * The fallback loopback listeners, derived from the same endpoint list.
308
+ *
309
+ * With ProxyBridge down a JDBC/Redis client inside the JVM cannot be told to use
310
+ * SOCKS, so the tunnel is exposed on `127.0.0.1:<same port>` and the adapted config
311
+ * points there instead. These are ENGINE INPUTS, not rule rows: the dialog shows
312
+ * intent (which programs are proxied), never this plumbing.
313
+ * @param {Array<{host: string, port: number}>} targets - endpoints from the config adapter.
314
+ * @returns {Array<{host: string, port: number}>} one listener per literal-IPv4 endpoint.
315
+ */
316
+ export function shadowTargetsFromTargets(targets) {
317
+ const out = []
318
+ const seen = new Set()
319
+ for (const target of Array.isArray(targets) ? targets : []) {
320
+ const host = String(target?.host ?? '')
321
+ const port = Number(target?.port)
322
+ if (!isIpv4Literal(host) || !Number.isInteger(port) || port < 1 || port > 65535) continue
323
+ const key = `${host}:${port}`
324
+ if (seen.has(key)) continue
325
+ seen.add(key)
326
+ out.push({ host, port })
327
+ }
328
+ return out
329
+ }
330
+
331
+ /**
332
+ * Rules this project contributes to the machine-wide table.
333
+ *
334
+ * Two rows, both naming a real process: `java` for the internal segment (the JVM
335
+ * is the client of every JDBC/Redis/ES/license endpoint) and `git` for its remote.
336
+ * Endpoints are deliberately NOT rows: a per-endpoint row would have to invent a
337
+ * program name (the old `jdbc` label matched no process at all) and would repeat
338
+ * what the /24 rule already covers.
339
+ * @param {Array<{host: string, port: number}>} targets - endpoints from the config adapter.
340
+ * @param {string} [workspace] - provenance label, not part of the rule identity.
341
+ * @param {{gitHost?: string}} [options] - host of the git remote, when known.
342
+ * @returns {object[]} rule rows in the engine's schema.
343
+ */
344
+ // Addresses the backend must reach that are not database/redis endpoints: the
345
+ // bind/callback host (relayed back to this machine) and anything the project
346
+ // settings name explicitly. Same rule as the endpoints: private ranges only.
347
+ export function extraHostsOf(cfg) {
348
+ const hosts = []
349
+ const push = (value) => {
350
+ const text = String(value ?? '').trim()
351
+ if (text === '') return
352
+ const host = text.includes(':') ? text.slice(0, text.lastIndexOf(':')) : text
353
+ if (hosts.includes(host) !== true) hosts.push(host)
354
+ }
355
+ push(cfg?.serverBindAddress)
356
+ return hosts
357
+ }
358
+
359
+ /**
360
+ * Private /24 segments (as `1.2.3.*` patterns) for rows that name an executable
361
+ * instead of a service: the java process calls the same databases/redis as the app.
362
+ */
363
+ export function privateSegmentsOf(targets, extraHosts = []) {
364
+ const segments = new Set()
365
+ const add = (host) => {
366
+ const text = String(host ?? '').trim()
367
+ if (isPrivateIpv4(text) !== true) return
368
+ segments.add(`${text.split('.').slice(0, 3).join('.')}.*`)
369
+ }
370
+ for (const target of Array.isArray(targets) ? targets : []) add(target?.host)
371
+ for (const host of Array.isArray(extraHosts) ? extraHosts : []) add(host)
372
+ return [...segments].sort()
373
+ }
374
+
375
+ // `java` defaults to false on purpose: config.js's `proxy.javaRules` also defaults off
376
+ // (the YMS JVM gets -DsocksProxyHost from `yds yoncode patch`, and WinDivert resets the
377
+ // Oracle TNS handshake). Both entry points must agree, or a direct helper call silently
378
+ // turns java interception back on.
379
+ export function proxyRulesFromTargets(targets, workspace = '', { gitHost = '', java = false, extraHosts = [] } = {}) {
380
+ const rules = []
381
+ const segments = new Set()
382
+ for (const target of Array.isArray(targets) ? targets : []) {
383
+ const host = String(target?.host ?? '')
384
+ const port = Number(target?.port)
385
+ if (host === '' || !Number.isInteger(port) || port <= 0 || port > 65535) continue
386
+ if (isPrivateIpv4(host)) segments.add(host.split('.').slice(0, 3).join('.'))
387
+ }
388
+ // Hosts the app itself has to dial that the bootstrap never lists: the callback /
389
+ // bind address (the reverse-proxied machine that relays gateway traffic back here)
390
+ // is such a case — Java cannot reach it without the tunnel either.
391
+ for (const host of Array.isArray(extraHosts) ? extraHosts : []) {
392
+ const value = String(host ?? '').trim()
393
+ if (isPrivateIpv4(value) !== true) continue
394
+ segments.add(value.split('.').slice(0, 3).join('.'))
395
+ }
396
+ // One rule per internal segment and ANY port: the backend also talks to hosts the
397
+ // bootstrap never lists (license, ES, kafka), so a port list would go stale.
398
+ for (const segment of java ? Array.from(segments).sort() : []) {
399
+ rules.push({
400
+ id: `yds-java-${segment.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`,
401
+ program: 'java',
402
+ hosts: `${segment}.*`,
403
+ ports: '*',
404
+ enabled: true,
405
+ shadow: false,
406
+ source: YDS_RULE_SOURCE,
407
+ workspace,
408
+ })
409
+ }
410
+ // git reaches its remote through the same tunnel; `git config http.<host>.proxy`
411
+ // stays as the belt for terminals that do not go through ProxyBridge.
412
+ if (gitHost !== '') {
413
+ rules.push({
414
+ id: 'yds-git',
415
+ program: 'git',
416
+ hosts: gitHost,
417
+ ports: '*',
418
+ enabled: true,
419
+ shadow: false,
420
+ source: YDS_RULE_SOURCE,
421
+ workspace,
422
+ })
423
+ }
424
+ return rules
425
+ }
426
+
427
+ /** The git host from the configured clone URL, or `''` when it has none. */
428
+ export function gitHostOf(cfg) {
429
+ const url = typeof cfg?.git?.url === 'string' ? cfg.git.url : ''
430
+ if (url === '') return ''
431
+ try { return new URL(url).host } catch { return '' }
432
+ }
433
+
434
+ /**
435
+ * Merge the YDS-owned rules into the IDE's proxy config and write it back.
436
+ * @returns {{config: object, rules: object[]}} the merged config.
437
+ */
438
+ export function syncProxyConfig(cfg, targets, { log, mapping = true, java = cfg?.proxyJavaRules === true } = {}) {
439
+ const loaded = cfg.__ideProxy
440
+ if (loaded === undefined || loaded === null || loaded.available !== true) throw new Error('代理引擎未加载')
441
+ const mod = loaded.mod
442
+ const rules = [
443
+ ...proxyRulesFromTargets(targets, cfg.workspace ?? '', { gitHost: gitHostOf(cfg), java, extraHosts: extraHostsOf(cfg) }),
444
+ // YonCode runs its own JDK/git from outside the project; without these rows the
445
+ // IDE's java would only be covered by the generic `java` pattern.
446
+ ...yoncodeRules(cfg, {
447
+ gitHost: gitHostOf(cfg),
448
+ workspace: cfg.workspace ?? '',
449
+ java,
450
+ hosts: privateSegmentsOf(targets, extraHostsOf(cfg)),
451
+ }),
452
+ ]
453
+ // Bound only while the system layer is unavailable; in interception mode this is
454
+ // empty, so nothing is bound on loopback at all.
455
+ const shadows = mapping === false ? [] : shadowTargetsFromTargets(targets)
456
+ const stored = mod.readProxyConfig(cfg.workspace)
457
+ const upstream = cfg.socks ?? null
458
+ const merged = mod.mergeConfigs(stored, {
459
+ socks: upstream === null ? { host: '', port: 0 } : { host: upstream.host, port: upstream.port },
460
+ listenPort: cfg.proxyListenPort ?? stored.listenPort,
461
+ // Replacing wholesale is the whole point: rules this plugin wrote last run
462
+ // must disappear when the endpoint list shrinks, while user rows survive.
463
+ replaceRulesFrom: YDS_RULE_SOURCE,
464
+ shadows,
465
+ // The config file is machine-wide now: only this workspace's yds rows are
466
+ // replaced, other workspaces keep theirs.
467
+ workspace: cfg.workspace ?? '',
468
+ rules,
469
+ })
470
+ mod.writeProxyConfig(cfg.workspace, merged)
471
+ log?.(`代理规则已同步:${rules.length} 条(source=${YDS_RULE_SOURCE})`)
472
+ return { config: merged, rules }
473
+ }
474
+
475
+ /** Run one `git config --global` mutation; a missing git is a reported no-op. */
476
+ function runGitConfig(args) {
477
+ return new Promise((resolvePromise) => {
478
+ const child = spawn('git', ['config', '--global', ...args], { windowsHide: true })
479
+ let stderr = ''
480
+ let settled = false
481
+ const done = (result) => { if (!settled) { settled = true; resolvePromise(result) } }
482
+ child.stderr?.on('data', (chunk) => { if (stderr.length < 4096) stderr += chunk.toString('utf8') })
483
+ child.on('error', (error) => done({ ok: false, error: String(error?.message ?? error) }))
484
+ child.on('close', (code) => done(code === 0 ? { ok: true } : { ok: false, error: stderr.trim() || `git exited with ${code}` }))
485
+ })
486
+ }
487
+
488
+ /**
489
+ * Write (or clear) the scoped git proxy entry, so EVERY git invocation honours
490
+ * SOCKS — including dsh-git's own commands, which never get `-c http.proxy`.
491
+ *
492
+ * The key is scoped to the configured clone host (`http.https://<host>/.proxy`)
493
+ * rather than the global `http.proxy`: a user's other remotes must not silently
494
+ * start going through this tunnel.
495
+ * @returns {Promise<{ok: boolean, key: string, value?: string, error?: string}>}
496
+ */
497
+ /**
498
+ * Persist (or forget) "this git proxy entry is ours" for the exit hook.
499
+ *
500
+ * dsh-ris-ide owns the file (`~/.dsh-ris-ide/git-proxy.json`) and its unset is
501
+ * keyed on exactly this record; writing it from the CLI path means a `yds stop`
502
+ * — or simply closing dsh — undoes the entry no matter which side wrote it.
503
+ * @param {object} mod - the loaded dsh-ris-ide proxy module.
504
+ * @param {{key: string, host: string}|null} state - null clears the record.
505
+ */
506
+ function recordGitProxyState(mod, state) {
507
+ try {
508
+ if (typeof mod?.proxyHomeDir !== 'function') return
509
+ const dir = mod.proxyHomeDir()
510
+ const path = join(dir, 'git-proxy.json')
511
+ if (state === null) {
512
+ rmSync(path, { force: true })
513
+ return
514
+ }
515
+ mkdirSync(dir, { recursive: true })
516
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
517
+ } catch {
518
+ // A missing state file only costs the exit-hook undo; never fail the command.
519
+ }
520
+ }
521
+
522
+ export async function applyGitProxy(cfg, { enabled = true } = {}) {
523
+ const loaded = cfg.__ideProxy
524
+ if (loaded === undefined || loaded === null || loaded.available !== true) return { ok: false, key: '', error: '代理引擎未加载' }
525
+ const host = gitHostOf(cfg)
526
+ if (host === '' && enabled) return { ok: false, key: '', error: '未配置 git.url,无法确定要写哪一条 git 代理' }
527
+ const plan = loaded.mod.gitProxyPlan(loaded.mod.readProxyConfig(cfg.workspace), { host, enabled })
528
+ const result = enabled
529
+ ? await runGitConfig([plan.key, plan.value])
530
+ : await runGitConfig(['--unset', plan.key])
531
+ // The Windows-side git has its own config file and cannot resolve the internal
532
+ // host, so the same scoped entry has to be written there too — YonCode runs git on
533
+ // that side of the boundary.
534
+ const windows = typeof loaded.mod.runWindowsGitConfig === 'function'
535
+ ? loaded.mod.runWindowsGitConfig(enabled ? [plan.key, plan.value] : ['--unset', plan.key])
536
+ : { ok: false, skipped: true, error: '引擎版本过旧,不支持 Windows 侧 git' }
537
+ // Record it where dsh-ris-ide keeps the same fact: its exit hook then clears a
538
+ // tunnel we wrote from the CLI, so a dead tunnel never leaves git pointed at it.
539
+ recordGitProxyState(loaded.mod, enabled ? { key: plan.key, host, windows: windows.ok === true } : null)
540
+ return Object.assign({ key: plan.key, value: enabled ? plan.value : '', windows }, result)
541
+ }
542
+
543
+ /**
544
+ * Make sure the IDE engine runs the current YDS rules.
545
+ *
546
+ * A changed rule set needs a restart: the engine binds its shadows at startup and
547
+ * never re-reads the file (that is also why the dialog says "restart to apply").
548
+ * @returns {Promise<{mode: string, ok?: boolean, error?: string, path?: string,
549
+ * rules?: object[], shadowPorts?: number[], restarted?: boolean}>}
550
+ */
551
+ /**
552
+ * Start the system-level interception that pairs with the tunnel.
553
+ *
554
+ * The engine only covers programs we spawn, so the redirector is what makes an
555
+ * IDE-launched java or a terminal git go through the tunnel at all. Elevation is
556
+ * the normal case: a Windows side triggers UAC, a Linux side needs passwordless
557
+ * sudo — a side that cannot be elevated is reported, never fatal.
558
+ * @param {object} cfg - resolved plugin config.
559
+ * @param {{log?: Function}} [options] - logger.
560
+ * @returns {Promise<{available: boolean, started: object[], skipped: object[], running: boolean}>} outcome.
561
+ */
562
+ export async function startBorrowedRedirector(cfg, { log, sides } = {}) {
563
+ const loaded = await loadIdeRedirector(cfg)
564
+ if (loaded.available === false) return { available: false, started: [], skipped: [], running: false }
565
+ if (typeof loaded.mod.startRedirectors !== 'function') return { available: false, started: [], skipped: [], running: false }
566
+ // `reload: true` makes an already-running bridge restart with the profile we just
567
+ // wrote — edited yds rules must apply without a manual stop/start (or a new UAC).
568
+ const result = await loaded.mod.startRedirectors(Array.isArray(sides) ? { sides, reload: true } : { reload: true })
569
+ const started = Array.isArray(result?.started) ? result.started : []
570
+ const skipped = Array.isArray(result?.skipped) ? result.skipped : []
571
+ if (started.length > 0) {
572
+ const reloaded = started.filter((item) => item.reloaded === true)
573
+ const fresh = started.filter((item) => item.reloaded !== true)
574
+ const parts = []
575
+ if (fresh.length > 0) parts.push(`已启动(${fresh.map((item) => `${item.side} pid ${item.pid ?? '?'}`).join(',')})`)
576
+ if (reloaded.length > 0) parts.push(`已按新规则重载(${reloaded.map((item) => item.side).join(',')})`)
577
+ log?.(`系统层拦截${parts.join(';')}`)
578
+ }
579
+ else if (skipped.length > 0 && typeof skipped[0]?.reason === 'string') log?.(`系统层拦截未启动:${skipped[0].reason}`)
580
+ return { available: true, started, skipped, running: result?.running === true }
581
+ }
582
+
583
+ /**
584
+ * Stop the redirector and clear the git proxy that were started with the tunnel.
585
+ *
586
+ * Both are machine-scoped side effects of `yds start`, so `yds stop` removes them
587
+ * even if this run did not create them — a leftover redirector keeps routing
588
+ * traffic with no UI, and a leftover git proxy breaks `git` once the tunnel dies.
589
+ * @param {object} cfg - resolved plugin config.
590
+ * @param {{log?: Function, keepGit?: boolean}} [options] - logger / skip git undo.
591
+ * @returns {Promise<{redirector: object, git: object}>} per-layer outcome.
592
+ */
593
+ export async function stopBorrowedSideEffects(cfg, { log, keepGit = false } = {}) {
594
+ const loaded = await loadIdeRedirector(cfg)
595
+ let redirector = { stopped: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
596
+ if (loaded.available === true && typeof loaded.mod.stopRedirector === 'function') {
597
+ redirector = await loaded.mod.stopRedirector()
598
+ if (redirector.stopped === true) log?.(`系统层拦截已停止(pid ${redirector.pid ?? '?'})`)
599
+ else if (typeof redirector.reason === 'string' && redirector.reason !== '' && redirector.reason !== '未在运行') {
600
+ log?.(`系统层拦截未停止:${redirector.reason}`)
601
+ }
602
+ }
603
+ let git = { ok: true, key: '' }
604
+ if (keepGit !== true) git = await applyGitProxy(cfg, { enabled: false })
605
+ return { redirector, git }
606
+ }
607
+
608
+ /**
609
+ * Write this project's rules into the shared table WITHOUT starting an engine.
610
+ *
611
+ * Interception mode needs the rules (the ProxyBridge profile is built from them)
612
+ * but must not bind anything: the redirector already carries the connections.
613
+ * @param {object} cfg - resolved plugin config.
614
+ * @param {object[]} targets - endpoints collected from the config.
615
+ * @param {{log?: Function}} [options] - logger.
616
+ * @returns {Promise<{mode: string, rules?: object[]}>} sync outcome.
617
+ */
618
+ export async function syncBorrowedRules(cfg, targets, { log } = {}) {
619
+ const loaded = await loadIdeProxy(cfg)
620
+ cfg.__ideProxy = loaded
621
+ if (!loaded.available) return { mode: 'unavailable' }
622
+ const { rules } = syncProxyConfig(cfg, targets, { log, mapping: false })
623
+ log?.('代理规则已写入(系统层拦截模式,无 127.0.0.1 映射)')
624
+ return { mode: 'ide', rules }
625
+ }
626
+
627
+ export async function ensureBorrowedProxy(cfg, targets, { log, mapping = true } = {}) {
628
+ // Direct mode (no `.env` SOCKS settings) has nothing to forward through: the
629
+ // config's own addresses are reachable, so no engine is started at all.
630
+ if (cfg.socks === null || cfg.socks === undefined) return { mode: 'none' }
631
+ if (Array.isArray(targets) && targets.length === 0) {
632
+ log?.('代理引擎:bootstrap 没有需要转发的内网端点,跳过')
633
+ return { mode: 'none' }
634
+ }
635
+ if (cfg.proxyEngine === 'self') { log?.('代理引擎:按 .env 配置使用内置转发器(proxy.engine=self)'); return { mode: 'unavailable', error: 'disabled by proxy.engine=self' } }
636
+ const loaded = await loadIdeProxy(cfg)
637
+ cfg.__ideProxy = loaded
638
+ if (!loaded.available) {
639
+ // Standalone by design: the built-in forwarder is a complete fallback, so
640
+ // the sibling plugin is only ever a recommendation (one shared engine).
641
+ log?.(`代理引擎:未找到 dsh-ris-ide(${loaded.error}),回退内置转发器`
642
+ + '(可选:装上 dsh-ris-ide 可在同一个 dsh 里获得 IDE 面板与服务调试能力)')
643
+ return { mode: 'unavailable', error: loaded.error }
644
+ }
645
+ const mod = loaded.mod
646
+ const before = mod.readProxyConfig(cfg.workspace)
647
+ const { config, rules } = syncProxyConfig(cfg, targets, { log, mapping })
648
+ const running = mod.proxyPid(cfg.workspace) !== null
649
+ // A running engine holds the OLD rules; only a restart applies the new ones.
650
+ const changed = JSON.stringify(before.rules) !== JSON.stringify(config.rules)
651
+ let restarted = false
652
+ if (running && changed) {
653
+ log?.('代理规则变化,重启代理引擎…')
654
+ await mod.stopProxy(cfg.workspace)
655
+ restarted = true
656
+ }
657
+ const started = await mod.startProxy(cfg.workspace)
658
+ if (started.ok !== true) return { mode: 'ide', ok: false, error: started.error ?? '代理引擎启动失败', path: loaded.path, rules }
659
+ // The loopback listeners are a config field now, not rule rows: read them back
660
+ // from the file the engine just loaded instead of guessing from the rules.
661
+ const shadowPorts = (mod.readProxyConfig(cfg.workspace)?.shadows ?? []).map((entry) => entry.localPort)
662
+ const shadowNote = shadowPorts.length > 0
663
+ ? `,127.0.0.1 兜底映射 ${shadowPorts.length} 个端口(ProxyBridge 未运行时的后备)`
664
+ : ',无 127.0.0.1 兜底映射(系统层拦截生效)'
665
+ log?.(`代理引擎:dsh-ris-ide 运行中(pid ${started.pid ?? '?'})${shadowNote}${restarted ? '(已重启)' : ''}`)
666
+ return { mode: 'ide', ok: true, path: loaded.path, pid: started.pid ?? null, rules, shadowPorts, restarted }
667
+ }
668
+
669
+ /** Stop the borrowed engine (no-op when dsh-ris-ide is not installed). */
670
+ export async function stopBorrowedProxy(cfg) {
671
+ const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
672
+ if (loaded.available !== true) return { ok: false, error: loaded.error }
673
+ return loaded.mod.stopProxy(cfg.workspace)
674
+ }
675
+
676
+ /** Engine status for `yds status` / `yds proxy status`. */
677
+ export async function borrowedProxyStatus(cfg) {
678
+ const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
679
+ if (loaded.available !== true) return { available: false, error: loaded.error }
680
+ const status = await loaded.mod.proxyStatus(cfg.workspace)
681
+ return { available: true, path: loaded.path, running: status.running === true, pid: status.pid ?? null, listenPort: status.config?.listenPort ?? null, rules: status.config?.rules ?? [] }
682
+ }