dsh-ris-yds 0.0.0-stage → 1.1.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/assets/yds-chrome-resource-override.crx +0 -0
  4. package/bin/check-listing.mjs +247 -0
  5. package/bin/yds.js +19 -0
  6. package/cordis.patch.yml +13 -0
  7. package/lib/browser.js +428 -0
  8. package/lib/build.js +115 -0
  9. package/lib/cli.js +1130 -0
  10. package/lib/confcenter.js +377 -0
  11. package/lib/config.js +552 -0
  12. package/lib/fe-dev.js +206 -0
  13. package/lib/ide-proxy.js +906 -0
  14. package/lib/ide-watch.js +188 -0
  15. package/lib/ide.js +728 -0
  16. package/lib/index.js +267 -0
  17. package/lib/proc.js +270 -0
  18. package/lib/risd-peer.js +353 -0
  19. package/lib/rules.js +193 -0
  20. package/lib/socks.js +279 -0
  21. package/lib/token.js +36 -0
  22. package/lib/yms.js +336 -0
  23. package/lib/yoncode.js +249 -0
  24. package/native/yds-rust.darwin-arm64.node +0 -0
  25. package/native/yds-rust.darwin-x64.node +0 -0
  26. package/native/yds-rust.linux-arm64.node +0 -0
  27. package/native/yds-rust.linux-x64.node +0 -0
  28. package/native/yds-rust.win32-arm64.node +0 -0
  29. package/native/yds-rust.win32-x64.node +0 -0
  30. package/package.json +58 -4
  31. package/skills/dsh-ris-yds/SKILL.md +134 -0
  32. package/test/browser.test.mjs +442 -0
  33. package/test/cli-stop.test.mjs +40 -0
  34. package/test/cli.test.mjs +246 -0
  35. package/test/config.test.mjs +106 -0
  36. package/test/fe-dev.test.mjs +93 -0
  37. package/test/hints.test.mjs +13 -0
  38. package/test/ide-config.test.mjs +155 -0
  39. package/test/ide-proxy.test.mjs +256 -0
  40. package/test/ide-watch.test.mjs +79 -0
  41. package/test/launch-proxy.test.mjs +83 -0
  42. package/test/lease-release.test.mjs +76 -0
  43. package/test/mode-separation.test.mjs +147 -0
  44. package/test/pidfile.test.mjs +105 -0
  45. package/test/proc.test.mjs +172 -0
  46. package/test/proxy-standalone.test.mjs +121 -0
  47. package/test/risd-leases.test.mjs +111 -0
  48. package/test/risd-tier.test.mjs +361 -0
  49. package/test/rules-e2e.test.mjs +122 -0
  50. package/test/rules.test.mjs +154 -0
  51. package/test/yoncode.test.mjs +159 -0
@@ -0,0 +1,906 @@
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
+ import { acquireLease, dropDeadLease, engineStatus, forgetLease, getView, probeRisd, putRules, readLease, releaseLease, rememberLease, startEngine } from './risd-peer.js'
25
+ import { YDS_RULE_SOURCE, mergeYdsRules, programKeyOf, withdrawnBetween } from './rules.js'
26
+
27
+ /** Marker `source` used for every rule this plugin owns. */
28
+ // `YDS_RULE_SOURCE` now lives in ./rules.js (shared with risd-peer).
29
+ export { YDS_RULE_SOURCE, programKeyOf } from './rules.js'
30
+
31
+ /** Where a dsh-ris-ide checkout/install may keep its proxy engine. */
32
+ export function ideProxyCandidates(cfg) {
33
+ const list = []
34
+ const explicit = process.env.YDS_IDE_PROXY
35
+ if (typeof explicit === 'string' && explicit !== '') list.push(explicit)
36
+ const workspace = typeof cfg?.workspace === 'string' ? cfg.workspace : ''
37
+ if (workspace !== '') {
38
+ // A link-installed plugin lives in the profile's node_modules; a workspace
39
+ // install (or a sibling checkout) is the other common layout.
40
+ list.push(join(workspace, 'node_modules', 'dsh-ris-ide', 'lib', 'proxy.js'))
41
+ list.push(join(workspace, 'work', 'dsh-ris-ide', 'lib', 'proxy.js'))
42
+ list.push(join(workspace, '..', 'dsh-ris-ide', 'lib', 'proxy.js'))
43
+ }
44
+ list.push(join(homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-ris-ide', 'lib', 'proxy.js'))
45
+ return list
46
+ }
47
+
48
+ /**
49
+ * Resolve and import the IDE's proxy engine.
50
+ * @returns {Promise<{available: boolean, path: string, mod: object|null, error: string}>}
51
+ */
52
+ export async function loadIdeProxy(cfg) {
53
+ const explicit = process.env.YDS_IDE_PROXY
54
+ // `none` is the documented off switch (same effect as proxy.engine=self, but
55
+ // it also drops the search): tests and machines with a broken install use it.
56
+ if (explicit === 'none') return { available: false, path: '', mod: null, error: '已通过 YDS_IDE_PROXY=none 禁用' }
57
+ const tried = []
58
+ let lastError = ''
59
+ for (const candidate of ideProxyCandidates(cfg)) {
60
+ tried.push(candidate)
61
+ if (!existsSync(candidate)) continue
62
+ try {
63
+ const mod = await import(pathToFileURL(candidate).href)
64
+ if (typeof mod.startProxy !== 'function' || typeof mod.mergeConfigs !== 'function') {
65
+ lastError = `模块不完整(缺少 startProxy/mergeConfigs): ${candidate}`
66
+ if (candidate === explicit) break
67
+ continue
68
+ }
69
+ return { available: true, path: candidate, mod, error: '' }
70
+ } catch (error) {
71
+ lastError = `${candidate}: ${error?.message ?? error}`
72
+ // An explicit override that exists but does not load is decisive: falling
73
+ // through to the profile install would hide the user's broken setting.
74
+ if (candidate === explicit) break
75
+ }
76
+ }
77
+ return { available: false, path: '', mod: null, error: lastError === '' ? `未找到 dsh-ris-ide 的代理引擎(已尝试 ${tried.length} 处)` : lastError }
78
+ }
79
+
80
+ /**
81
+ * Resolve the IDE's ProxyBridge adapter (the system-level interceptor).
82
+ *
83
+ * It is a SIBLING module of the proxy engine (`lib/redirector.js` in the same
84
+ * install), so a machine with the engine always has it. Loading it is optional:
85
+ * the bridge then exports the same rule table as a `.pbprofile` and reports the
86
+ * command that would start it (starting needs root/UAC, which we never have).
87
+ * @param {object} cfg - resolved yds config.
88
+ * @returns {Promise<{available: boolean, path: string, mod: object|null}>}
89
+ */
90
+ export async function loadIdeRedirector(cfg) {
91
+ const engine = await loadIdeProxy(cfg)
92
+ if (engine.available === false) return { available: false, path: '', mod: null }
93
+ const candidate = join(dirname(engine.path), 'redirector.js')
94
+ if (!existsSync(candidate)) return { available: false, path: candidate, mod: null }
95
+ try {
96
+ const mod = await import(pathToFileURL(candidate).href)
97
+ if (typeof mod.syncRedirector !== 'function' || typeof mod.redirectorStatus !== 'function') {
98
+ return { available: false, path: candidate, mod: null }
99
+ }
100
+ return { available: true, path: candidate, mod }
101
+ } catch {
102
+ return { available: false, path: candidate, mod: null }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Export the machine rule table as a ProxyBridge profile.
108
+ *
109
+ * Deliberately never starts it: the process needs root (Linux eBPF) or UAC
110
+ * (Windows WinDivert), and a CLI that silently asked for a password in the middle
111
+ * of `yds start` would be worse than handing back the exact command.
112
+ * @param {object} cfg - resolved yds config.
113
+ * @returns {Promise<{available: boolean, profilePath?: string, rules?: number, command?: string, reason?: string}>}
114
+ */
115
+ export async function syncRedirectorProfile(cfg) {
116
+ const loaded = await loadIdeRedirector(cfg)
117
+ if (loaded.available === false) return { available: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
118
+ const status = loaded.mod.redirectorStatus()
119
+ if (status.local.available !== true && status.windows.available !== true) {
120
+ return { available: false, reason: '未找到 ProxyBridge CLI(本侧与 Windows 侧都没有)' }
121
+ }
122
+ const result = await loaded.mod.syncRedirector(cfg?.workspace ?? '', { start: false })
123
+ const display = typeof result?.command === 'object' && result.command !== null ? String(result.command.display ?? '') : ''
124
+ return {
125
+ available: true,
126
+ profilePath: result?.written?.path ?? status.profilePath,
127
+ rules: result?.written?.rules ?? 0,
128
+ command: display,
129
+ reason: typeof result?.reason === 'string' ? result.reason : '',
130
+ running: status.running === true,
131
+ }
132
+ }
133
+
134
+ /** True for a dotted-quad IPv4 literal (the only host shape a shadow can bind). */
135
+ export function isIpv4Literal(host) {
136
+ return typeof host === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
137
+ }
138
+
139
+ /**
140
+ * Whether a literal IPv4 address sits in an RFC1918 range.
141
+ *
142
+ * Only such destinations need interception: a public host (the workbench, the
143
+ * gitlab front door) is reachable from this machine anyway, and routing it
144
+ * through the tunnel would only add a hop.
145
+ * @param {string} host - dotted-quad address.
146
+ * @returns {boolean} true for 10/8, 172.16/12 and 192.168/16.
147
+ */
148
+ export function isPrivateIpv4(host) {
149
+ if (!isIpv4Literal(host)) return false
150
+ const [a, b] = host.split('.').map(Number)
151
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
152
+ }
153
+
154
+ /**
155
+ * A path the way ProxyBridge compares it: on Windows it always sees backslashes
156
+ * and a drive letter, so a WSL path has to become `D:\...` before it can match
157
+ * anything. POSIX paths (a native Linux YonCode) are returned unchanged.
158
+ */
159
+ export function toProcessPattern(value) {
160
+ if (typeof value !== 'string' || value === '') return ''
161
+ const mounted = /^\/mnt\/([a-z])\/(.+)$/i.exec(value)
162
+ if (mounted !== null) return `${mounted[1].toUpperCase()}:\\${mounted[2].replace(/\//g, '\\')}`
163
+ if (/^[A-Za-z]:[\\/]/.test(value) === true || value.includes('\\') === true) return value.replace(/\//g, '\\')
164
+ return value
165
+ }
166
+
167
+ function existingDirectory(value) {
168
+ try {
169
+ return statSync(value).isDirectory()
170
+ } catch {
171
+ return false
172
+ }
173
+ }
174
+
175
+ function existingFile(value) {
176
+ try {
177
+ return statSync(value).isFile()
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The executables YonCode itself runs: the JDK and maven it ships, plus the git it
185
+ * shells out to. They live outside the project, so a rule written from the project
186
+ * config would never name them — yet these are exactly the processes that need the
187
+ * tunnel to reach 172.20.235.* and gitlab.
188
+ */
189
+ export function yoncodeProcesses(options = {}) {
190
+ const homes = Array.isArray(options.homes) === true ? options.homes : []
191
+ const out = { java: '', git: '', home: '' }
192
+ for (const home of homes) {
193
+ if (typeof home !== 'string' || home === '' || existingDirectory(home) !== true) continue
194
+ const devkit = join(home, 'devkit')
195
+ if (existingDirectory(devkit) === false) continue
196
+ if (out.java === '') {
197
+ let versions = []
198
+ try {
199
+ versions = readdirSync(devkit, { withFileTypes: true })
200
+ } catch {
201
+ versions = []
202
+ }
203
+ for (const entry of versions) {
204
+ if (entry.isDirectory() !== true) continue
205
+ // Only JDK trees: devkit also holds maven/node/etc., and a bare `*` would
206
+ // turn into a ProcessName that matches programs we never meant to intercept.
207
+ if (/^jdk/i.test(entry.name) !== true) continue
208
+ if (existingFile(join(devkit, entry.name, 'bin', 'java.exe')) === false) continue
209
+ // The rule names the IMAGE (java.exe), not a pinned file: YonCode upgrades its
210
+ // JDK in place, and other java.exe processes on the box need the same tunnel.
211
+ out.java = 'java.exe'
212
+ out.home = home
213
+ break
214
+ }
215
+ }
216
+ if (out.git === '' && existingFile(join(devkit, 'git', 'bin', 'git.exe')) === true) {
217
+ out.git = 'git.exe'
218
+ }
219
+ }
220
+ if (out.git === '') {
221
+ for (const candidate of Array.isArray(options.gitPaths) === true ? options.gitPaths : []) {
222
+ if (typeof candidate !== 'string' || candidate === '' || existingFile(candidate) !== true) continue
223
+ // Same idea as java: one git.exe row covers the devkit copy and every other install.
224
+ out.git = 'git.exe'
225
+ break
226
+ }
227
+ }
228
+ return out
229
+ }
230
+
231
+ /** Where YonCode runs on this machine, as paths this process can stat. */
232
+ export function yoncodeHomes(cfg) {
233
+ const explicit = typeof cfg?.yoncodeHome === 'string' && cfg.yoncodeHome !== ''
234
+ ? [cfg.yoncodeHome]
235
+ : yoncodeCandidates(cfg ?? {})
236
+ return explicit.filter((value) => existingDirectory(value) === true)
237
+ }
238
+
239
+ /**
240
+ * The git YonCode uses when it is not the one bundled with the devkit: the Windows
241
+ * installs reachable from WSL, then whatever `git` resolves to here.
242
+ */
243
+ export function candidateGitPaths(options = {}) {
244
+ if (Array.isArray(options.gitPaths) === true) return options.gitPaths
245
+ const out = [
246
+ '/mnt/c/Program Files/Git/bin/git.exe',
247
+ '/mnt/d/Program Files/Git/bin/git.exe',
248
+ '/mnt/c/Program Files/Git/cmd/git.exe',
249
+ '/mnt/d/Program Files/Git/cmd/git.exe',
250
+ ]
251
+ if (process.platform === 'win32') {
252
+ try {
253
+ const where = execFileSync('where.exe', ['git'], { encoding: 'utf8', timeout: 4000, windowsHide: true })
254
+ for (const line of where.split(/\r?\n/)) if (line.trim() !== '') out.unshift(line.trim())
255
+ } catch {
256
+ // Git is optional here: without it the generic `git` rule already covers PATH.
257
+ }
258
+ }
259
+ return out
260
+ }
261
+
262
+ /**
263
+ * Rules that name YonCode's own executables, labelled so the dialog and the
264
+ * ProxyBridge profile say where they came from.
265
+ *
266
+ * `hosts` is the set of private /24 segments the backend really dials, derived from the
267
+ * resolved endpoints: YonCode's java must be redirected to the same place our own
268
+ * `java` rows point, and hardcoding any range would silently miss another environment.
269
+ */
270
+ export function yoncodeRules(cfg, options = {}) {
271
+ const withJava = options.java !== false
272
+ const hosts = Array.isArray(options.hosts) ? options.hosts.filter((host) => String(host).trim() !== '') : []
273
+ const found = yoncodeProcesses({
274
+ homes: options.homes ?? yoncodeHomes(cfg),
275
+ gitPaths: options.gitPaths ?? candidateGitPaths(options),
276
+ })
277
+ const gitHost = options.gitHost ?? ''
278
+ const rules = []
279
+ if (withJava && found.java !== '' && hosts.length > 0) {
280
+ rules.push({
281
+ id: 'yds-yoncode-java',
282
+ label: 'YonCode java',
283
+ program: found.java,
284
+ // YonCode's own JDK/git are Windows executables: they belong to the Windows side
285
+ // even when this process runs inside WSL.
286
+ side: 'windows',
287
+ hosts: hosts.join(','),
288
+ ports: '*',
289
+ enabled: true,
290
+ shadow: false,
291
+ source: YDS_RULE_SOURCE,
292
+ })
293
+ }
294
+ if (found.git !== '' && gitHost !== '') {
295
+ rules.push({
296
+ id: 'yds-yoncode-git',
297
+ label: 'YonCode git',
298
+ program: found.git,
299
+ side: 'windows',
300
+ hosts: gitHost,
301
+ ports: '*',
302
+ enabled: true,
303
+ shadow: false,
304
+ source: YDS_RULE_SOURCE,
305
+ })
306
+ }
307
+ return rules
308
+ }
309
+
310
+ /**
311
+ * The fallback loopback listeners, derived from the same endpoint list.
312
+ *
313
+ * With ProxyBridge down a JDBC/Redis client inside the JVM cannot be told to use
314
+ * SOCKS, so the tunnel is exposed on `127.0.0.1:<same port>` and the adapted config
315
+ * points there instead. These are ENGINE INPUTS, not rule rows: the dialog shows
316
+ * intent (which programs are proxied), never this plumbing.
317
+ * @param {Array<{host: string, port: number}>} targets - endpoints from the config adapter.
318
+ * @returns {Array<{host: string, port: number}>} one listener per literal-IPv4 endpoint.
319
+ */
320
+ export function shadowTargetsFromTargets(targets) {
321
+ const out = []
322
+ const seen = new Set()
323
+ for (const target of Array.isArray(targets) ? targets : []) {
324
+ const host = String(target?.host ?? '')
325
+ const port = Number(target?.port)
326
+ if (!isIpv4Literal(host) || !Number.isInteger(port) || port < 1 || port > 65535) continue
327
+ const key = `${host}:${port}`
328
+ if (seen.has(key)) continue
329
+ seen.add(key)
330
+ out.push({ host, port })
331
+ }
332
+ return out
333
+ }
334
+
335
+
336
+ /**
337
+ * Rules this project contributes to the machine-wide table.
338
+ *
339
+ * Two rows, both naming a real process: `java` for the internal segment (the JVM
340
+ * is the client of every JDBC/Redis/ES/license endpoint) and `git` for its remote.
341
+ * Endpoints are deliberately NOT rows: a per-endpoint row would have to invent a
342
+ * program name (the old `jdbc` label matched no process at all) and would repeat
343
+ * what the /24 rule already covers.
344
+ * @param {Array<{host: string, port: number}>} targets - endpoints from the config adapter.
345
+ * @param {string} [workspace] - provenance label, not part of the rule identity.
346
+ * @param {{gitHost?: string}} [options] - host of the git remote, when known.
347
+ * @returns {object[]} rule rows in the engine's schema.
348
+ */
349
+ // Addresses the backend must reach that are not database/redis endpoints: the
350
+ // bind/callback host (relayed back to this machine) and anything the project
351
+ // settings name explicitly. Same rule as the endpoints: private ranges only.
352
+ export function extraHostsOf(cfg) {
353
+ const hosts = []
354
+ const push = (value) => {
355
+ const text = String(value ?? '').trim()
356
+ if (text === '') return
357
+ const host = text.includes(':') ? text.slice(0, text.lastIndexOf(':')) : text
358
+ if (hosts.includes(host) !== true) hosts.push(host)
359
+ }
360
+ push(cfg?.serverBindAddress)
361
+ return hosts
362
+ }
363
+
364
+ /**
365
+ * Private /24 segments (as `1.2.3.*` patterns) for rows that name an executable
366
+ * instead of a service: the java process calls the same databases/redis as the app.
367
+ */
368
+ export function privateSegmentsOf(targets, extraHosts = []) {
369
+ const segments = new Set()
370
+ const add = (host) => {
371
+ const text = String(host ?? '').trim()
372
+ if (isPrivateIpv4(text) !== true) return
373
+ segments.add(`${text.split('.').slice(0, 3).join('.')}.*`)
374
+ }
375
+ for (const target of Array.isArray(targets) ? targets : []) add(target?.host)
376
+ for (const host of Array.isArray(extraHosts) ? extraHosts : []) add(host)
377
+ return [...segments].sort()
378
+ }
379
+
380
+ // `java` defaults to false on purpose: config.js's `proxy.javaRules` also defaults off
381
+ // (the YMS JVM gets -DsocksProxyHost from `yds yoncode patch`, and WinDivert resets the
382
+ // Oracle TNS handshake). Both entry points must agree, or a direct helper call silently
383
+ // turns java interception back on.
384
+ //
385
+ // Two SIDES, not two workspaces: ProxyBridge matches `java.exe`/`git.exe` on Windows and
386
+ // `java`/`git` on WSL/Linux. Both rule sets are generated in one go, so the panel's
387
+ // global table shows the WSL-side rows that used to be missing (the WSL JVM was never
388
+ // intercepted). On Windows only the `.exe` rows make sense and are generated.
389
+ export function proxyRulesFromTargets(targets, { gitHost = '', java = false, extraHosts = [], platform = process.platform } = {}) {
390
+ const rules = []
391
+ const segments = new Set()
392
+ for (const target of Array.isArray(targets) ? targets : []) {
393
+ const host = String(target?.host ?? '')
394
+ const port = Number(target?.port)
395
+ if (host === '' || !Number.isInteger(port) || port <= 0 || port > 65535) continue
396
+ if (isPrivateIpv4(host)) segments.add(host.split('.').slice(0, 3).join('.'))
397
+ }
398
+ // Hosts the app itself has to dial that the bootstrap never lists: the callback /
399
+ // bind address (the reverse-proxied machine that relays gateway traffic back here)
400
+ // is such a case — Java cannot reach it without the tunnel either.
401
+ for (const host of Array.isArray(extraHosts) ? extraHosts : []) {
402
+ const value = String(host ?? '').trim()
403
+ if (isPrivateIpv4(value) !== true) continue
404
+ segments.add(value.split('.').slice(0, 3).join('.'))
405
+ }
406
+ // ONE java rule per side for every internal segment, hosts comma-separated: the
407
+ // backend also talks to hosts the bootstrap never lists (license, ES, kafka), so a
408
+ // port list would go stale — and one row per /24 only made the table longer.
409
+ const javaHosts = java ? Array.from(segments).sort().map((segment) => `${segment}.*`) : []
410
+ const sides = platform === 'win32'
411
+ ? [{ side: 'windows', java: 'java.exe', git: 'git.exe' }]
412
+ : [
413
+ { side: 'windows', java: 'java.exe', git: 'git.exe' },
414
+ { side: 'local', java: 'java', git: 'git' },
415
+ ]
416
+ for (const entry of sides) {
417
+ const local = entry.side === 'local'
418
+ if (javaHosts.length > 0) {
419
+ rules.push({
420
+ id: local ? 'yds-java-local' : 'yds-java',
421
+ // The IMAGE name, not a file path: one row covers every java.exe on the box
422
+ // (YonCode's bundled JDK, the IDE's own, a terminal's), and a JDK upgrade
423
+ // never invalidates it. The local side matches the WSL/Linux process name.
424
+ program: entry.java,
425
+ side: entry.side,
426
+ hosts: javaHosts.join(','),
427
+ ports: '*',
428
+ enabled: true,
429
+ shadow: false,
430
+ source: YDS_RULE_SOURCE,
431
+ })
432
+ }
433
+ // git reaches its remote through the same tunnel; `git config http.<host>.proxy`
434
+ // stays as the belt for terminals that do not go through ProxyBridge.
435
+ if (gitHost !== '') {
436
+ rules.push({
437
+ id: local ? 'yds-git-local' : 'yds-git',
438
+ program: entry.git,
439
+ side: entry.side,
440
+ hosts: gitHost,
441
+ ports: '*',
442
+ enabled: true,
443
+ shadow: false,
444
+ source: YDS_RULE_SOURCE,
445
+ })
446
+ }
447
+ }
448
+ return rules
449
+ }
450
+
451
+ /** The git host from the configured clone URL, or `''` when it has none. */
452
+ export function gitHostOf(cfg) {
453
+ const url = typeof cfg?.git?.url === 'string' ? cfg.git.url : ''
454
+ if (url === '') return ''
455
+ try { return new URL(url).host } catch { return '' }
456
+ }
457
+
458
+ /**
459
+ * Merge the YDS-owned rules into the IDE's proxy config and write it back.
460
+ * @returns {{config: object, rules: object[]}} the merged config.
461
+ */
462
+ /** The rules this project owns, in one deterministic order (generic rows first). */
463
+ export function ydsRulesFor(cfg, targets, { java = cfg?.proxyJavaRules === true, platform = process.platform, log } = {}) {
464
+ return mergeYdsRules([], [
465
+ ...proxyRulesFromTargets(targets, { gitHost: gitHostOf(cfg), java, extraHosts: extraHostsOf(cfg), platform }),
466
+ // YonCode runs its own JDK/git from outside the project. These rows are kept as a
467
+ // fallback for invocations where the generic rows above are absent; when they are
468
+ // present the side+program key is already taken and the duplicate is merged.
469
+ ...yoncodeRules(cfg, {
470
+ gitHost: gitHostOf(cfg),
471
+ java,
472
+ hosts: privateSegmentsOf(targets, extraHostsOf(cfg)),
473
+ }),
474
+ ], { log })
475
+ }
476
+
477
+ export function syncProxyConfig(cfg, targets, { log, mapping = true, java = cfg?.proxyJavaRules === true, platform = process.platform } = {}) {
478
+ const loaded = cfg.__ideProxy
479
+ if (loaded === undefined || loaded === null || loaded.available !== true) throw new Error('代理引擎未加载')
480
+ const mod = loaded.mod
481
+ // One read, one write: reading the shared table twice around the merge opened a
482
+ // lost-update window between them (review D-P2).
483
+ const stored = mod.readProxyConfig(cfg.workspace)
484
+ // The table is machine-wide AND global, and the daemon saves it wholesale
485
+ // (`POST /v1/proxy` is a whole-table PUT). So the complete table is built here:
486
+ // every non-yds row kept, every yds row replaced by this run's set.
487
+ const fresh = ydsRulesFor(cfg, targets, { java, platform, log })
488
+ const rules = mergeYdsRules(stored?.rules, fresh, { log })
489
+ // Bound only while the system layer is unavailable; in interception mode this is
490
+ // empty, so nothing is bound on loopback at all.
491
+ const shadows = mapping === false ? [] : shadowTargetsFromTargets(targets)
492
+ const upstream = cfg.socks ?? null
493
+ // The borrowed tier goes through `mergeConfigs` with `replaceAllRules`: the IDE
494
+ // keeps owning socks/listenPort/shadows, while the rules are replaced wholesale by
495
+ // the complete table above. `replaceRulesFrom` (the old per-source patch) is retired.
496
+ const merged = mod.mergeConfigs(stored, {
497
+ socks: upstream === null ? { host: '', port: 0 } : { host: upstream.host, port: upstream.port },
498
+ listenPort: cfg.proxyListenPort ?? stored?.listenPort,
499
+ shadows,
500
+ replaceAllRules: true,
501
+ rules,
502
+ })
503
+ // Pin the table we computed even if an older dsh-ris-ide build ignores the flag: a
504
+ // stale yds row left behind here would resurface as an intercepted process.
505
+ const saved = mod.writeProxyConfig(cfg.workspace, { ...merged, rules })
506
+ const config = saved
507
+ log?.(`代理规则已同步:${fresh.length} 条 yds 规则(共享表共 ${rules.length} 条)`)
508
+ return { config: saved, rules, withdrawn: withdrawnBetween(stored?.rules, rules) }
509
+ }
510
+
511
+ /** Run one `git config --global` mutation; a missing git is a reported no-op. */
512
+ function runGitConfig(args) {
513
+ return new Promise((resolvePromise) => {
514
+ const child = spawn('git', ['config', '--global', ...args], { windowsHide: true })
515
+ let stderr = ''
516
+ let settled = false
517
+ const done = (result) => { if (!settled) { settled = true; resolvePromise(result) } }
518
+ child.stderr?.on('data', (chunk) => { if (stderr.length < 4096) stderr += chunk.toString('utf8') })
519
+ child.on('error', (error) => done({ ok: false, error: String(error?.message ?? error) }))
520
+ child.on('close', (code) => done(code === 0 ? { ok: true } : { ok: false, error: stderr.trim() || `git exited with ${code}` }))
521
+ })
522
+ }
523
+
524
+ /**
525
+ * Write (or clear) the scoped git proxy entry, so EVERY git invocation honours
526
+ * SOCKS — including dsh-git's own commands, which never get `-c http.proxy`.
527
+ *
528
+ * The key is scoped to the configured clone host (`http.https://<host>/.proxy`)
529
+ * rather than the global `http.proxy`: a user's other remotes must not silently
530
+ * start going through this tunnel.
531
+ * @returns {Promise<{ok: boolean, key: string, value?: string, error?: string}>}
532
+ */
533
+ /**
534
+ * Persist (or forget) "this git proxy entry is ours" for the exit hook.
535
+ *
536
+ * dsh-ris-ide owns the file (`~/.dsh-ris-ide/git-proxy.json`) and its unset is
537
+ * keyed on exactly this record; writing it from the CLI path means a `yds stop`
538
+ * — or simply closing dsh — undoes the entry no matter which side wrote it.
539
+ * @param {object} mod - the loaded dsh-ris-ide proxy module.
540
+ * @param {{key: string, host: string}|null} state - null clears the record.
541
+ */
542
+ function recordGitProxyState(mod, state) {
543
+ try {
544
+ if (typeof mod?.proxyHomeDir !== 'function') return
545
+ const dir = mod.proxyHomeDir()
546
+ const path = join(dir, 'git-proxy.json')
547
+ if (state === null) {
548
+ rmSync(path, { force: true })
549
+ return
550
+ }
551
+ mkdirSync(dir, { recursive: true })
552
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
553
+ } catch {
554
+ // A missing state file only costs the exit-hook undo; never fail the command.
555
+ }
556
+ }
557
+
558
+ export async function applyGitProxy(cfg, { enabled = true } = {}) {
559
+ const loaded = cfg.__ideProxy
560
+ if (loaded === undefined || loaded === null || loaded.available !== true) return { ok: false, key: '', error: '代理引擎未加载' }
561
+ const host = gitHostOf(cfg)
562
+ if (host === '' && enabled) return { ok: false, key: '', error: '未配置 git.url,无法确定要写哪一条 git 代理' }
563
+ const plan = loaded.mod.gitProxyPlan(loaded.mod.readProxyConfig(cfg.workspace), { host, enabled })
564
+ const result = enabled
565
+ ? await runGitConfig([plan.key, plan.value])
566
+ : await runGitConfig(['--unset', plan.key])
567
+ // The Windows-side git has its own config file and cannot resolve the internal
568
+ // host, so the same scoped entry has to be written there too — YonCode runs git on
569
+ // that side of the boundary.
570
+ const windows = typeof loaded.mod.runWindowsGitConfig === 'function'
571
+ ? loaded.mod.runWindowsGitConfig(enabled ? [plan.key, plan.value] : ['--unset', plan.key])
572
+ : { ok: false, skipped: true, error: '引擎版本过旧,不支持 Windows 侧 git' }
573
+ // Record it where dsh-ris-ide keeps the same fact: its exit hook then clears a
574
+ // tunnel we wrote from the CLI, so a dead tunnel never leaves git pointed at it.
575
+ recordGitProxyState(loaded.mod, enabled ? { key: plan.key, host, windows: windows.ok === true } : null)
576
+ return Object.assign({ key: plan.key, value: enabled ? plan.value : '', windows }, result)
577
+ }
578
+
579
+ /**
580
+ * Make sure the IDE engine runs the current YDS rules.
581
+ *
582
+ * A changed rule set needs a restart: the engine binds its shadows at startup and
583
+ * never re-reads the file (that is also why the dialog says "restart to apply").
584
+ * @returns {Promise<{mode: string, ok?: boolean, error?: string, path?: string,
585
+ * rules?: object[], shadowPorts?: number[], restarted?: boolean}>}
586
+ */
587
+ /**
588
+ * Start the system-level interception that pairs with the tunnel.
589
+ *
590
+ * The engine only covers programs we spawn, so the redirector is what makes an
591
+ * IDE-launched java or a terminal git go through the tunnel at all. Elevation is
592
+ * the normal case: a Windows side triggers UAC, a Linux side needs passwordless
593
+ * sudo — a side that cannot be elevated is reported, never fatal.
594
+ * @param {object} cfg - resolved plugin config.
595
+ * @param {{log?: Function}} [options] - logger.
596
+ * @returns {Promise<{available: boolean, started: object[], skipped: object[], running: boolean}>} outcome.
597
+ */
598
+ export async function startBorrowedRedirector(cfg, { log, sides } = {}) {
599
+ const loaded = await loadIdeRedirector(cfg)
600
+ if (loaded.available === false) return { available: false, started: [], skipped: [], running: false }
601
+ if (typeof loaded.mod.startRedirectors !== 'function') return { available: false, started: [], skipped: [], running: false }
602
+ // `reload: true` makes an already-running bridge restart with the profile we just
603
+ // wrote — edited yds rules must apply without a manual stop/start (or a new UAC).
604
+ const result = await loaded.mod.startRedirectors(Array.isArray(sides) ? { sides, reload: true } : { reload: true })
605
+ const started = Array.isArray(result?.started) ? result.started : []
606
+ const skipped = Array.isArray(result?.skipped) ? result.skipped : []
607
+ if (started.length > 0) {
608
+ const reloaded = started.filter((item) => item.reloaded === true)
609
+ const fresh = started.filter((item) => item.reloaded !== true)
610
+ const parts = []
611
+ if (fresh.length > 0) parts.push(`已启动(${fresh.map((item) => `${item.side} pid ${item.pid ?? '?'}`).join(',')})`)
612
+ if (reloaded.length > 0) parts.push(`已按新规则重载(${reloaded.map((item) => item.side).join(',')})`)
613
+ log?.(`系统层拦截${parts.join(';')}`)
614
+ }
615
+ else if (skipped.length > 0 && typeof skipped[0]?.reason === 'string') log?.(`系统层拦截未启动:${skipped[0].reason}`)
616
+ return { available: true, started, skipped, running: result?.running === true }
617
+ }
618
+
619
+ /**
620
+ * Stop the redirector and clear the git proxy that were started with the tunnel.
621
+ *
622
+ * Both are machine-scoped side effects of `yds start`, so `yds stop` removes them
623
+ * even if this run did not create them — a leftover redirector keeps routing
624
+ * traffic with no UI, and a leftover git proxy breaks `git` once the tunnel dies.
625
+ * @param {object} cfg - resolved plugin config.
626
+ * @param {{log?: Function, keepGit?: boolean}} [options] - logger / skip git undo.
627
+ * @returns {Promise<{redirector: object, git: object}>} per-layer outcome.
628
+ */
629
+ export async function stopBorrowedSideEffects(cfg, { log, keepGit = false, daemonReload = null } = {}) {
630
+ let redirector = { stopped: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
631
+ if (daemonReload !== null) {
632
+ // risd tier: POST /v1/proxy already reloaded (or reported why it could not
633
+ // reload) the machine bridge. Reloading it here too would be a second writer
634
+ // that is not serialised with the daemon (review D-P2-10), so render its note.
635
+ redirector = {
636
+ stopped: false,
637
+ viaDaemon: true,
638
+ reason: daemonReload === ''
639
+ ? '由 risd daemon 管理(规则已保存;daemon 未要求重启桥接)'
640
+ : `由 risd daemon 管理:${daemonReload}`,
641
+ }
642
+ log?.(`系统层拦截:${redirector.reason}`)
643
+ } else {
644
+ const loaded = await loadIdeRedirector(cfg)
645
+ // The bridge is machine-wide: an IDE window may still need it. Reload it with the
646
+ // profile that no longer contains our rules instead of stopping it; stopping is
647
+ // what used to kill the IDE's interception on `yds stop`.
648
+ if (loaded.available === true && typeof loaded.mod.reloadRedirectorsWithProfile === 'function') {
649
+ const status = typeof loaded.mod.redirectorStatus === 'function' ? loaded.mod.redirectorStatus() : { running: false }
650
+ if (status.running !== true) redirector = { stopped: false, reason: '未在运行' }
651
+ else {
652
+ const reload = await loaded.mod.reloadRedirectorsWithProfile(cfg.workspace, { sides: ['windows', 'local'] })
653
+ const reloaded = Array.isArray(reload.reloaded) ? reload.reloaded.length : 0
654
+ redirector = reloaded > 0
655
+ ? { stopped: false, reloaded: true, reason: '已按剩余规则重载(引擎与拦截保留)' }
656
+ : { stopped: false, reason: '未能重载,系统层拦截仍按旧规则运行(需手动重启桥接)' }
657
+ log?.(`系统层拦截:${redirector.reason}`)
658
+ }
659
+ }
660
+ }
661
+ let git = { ok: true, key: '' }
662
+ if (keepGit !== true) git = await applyGitProxy(cfg, { enabled: false })
663
+ return { redirector, git }
664
+ }
665
+
666
+ /**
667
+ * Write this project's rules into the shared table WITHOUT starting an engine.
668
+ *
669
+ * Interception mode needs the rules (the ProxyBridge profile is built from them)
670
+ * but must not bind anything: the redirector already carries the connections.
671
+ * @param {object} cfg - resolved plugin config.
672
+ * @param {object[]} targets - endpoints collected from the config.
673
+ * @param {{log?: Function}} [options] - logger.
674
+ * @returns {Promise<{mode: string, rules?: object[]}>} sync outcome.
675
+ */
676
+ export async function syncBorrowedRules(cfg, targets, { log } = {}) {
677
+ // Preferred tier: a running risd owns the table, the engine and the bridge.
678
+ const risd = await probeRisd()
679
+ if (risd.usable === true) {
680
+ const rules = ydsRulesFor(cfg, targets)
681
+ // No `shadows` argument on purpose: the daemon's write contract only takes
682
+ // socks/listenPort/rules (incoming.shadows is dropped), so passing `[]` here
683
+ // would look like "clear the mappings" while doing nothing (review D-P2-3).
684
+ // 127.0.0.1 mappings of the risd engine are owned by the daemon / IDE side.
685
+ const saved = await putRules(cfg.workspace ?? '', rules, { gitHost: gitHostOf(cfg), log })
686
+ if (saved.ok !== true) throw new Error(`代理规则写入 risd 失败:${saved.error}`)
687
+ log?.(`代理规则已写入 risd(${rules.length} 条,source=${YDS_RULE_SOURCE})`)
688
+ return { mode: 'risd', rules, reloadNote: saved.reloadNote ?? '' }
689
+ }
690
+ const loaded = await loadIdeProxy(cfg)
691
+ cfg.__ideProxy = loaded
692
+ if (!loaded.available) return { mode: 'unavailable' }
693
+ const { rules } = syncProxyConfig(cfg, targets, { log, mapping: false })
694
+ log?.('代理规则已写入(系统层拦截模式,无 127.0.0.1 映射)')
695
+ // A running bridge parsed its profile once at start, so rewriting the table is not
696
+ // enough: restart it with the new rules — or, if that fails, stop it. Leaving the
697
+ // old profile running is exactly the "old proxy cannot be cancelled" failure.
698
+ const redirector = await loadIdeRedirector(cfg)
699
+ if (redirector.available === true && typeof redirector.mod.reloadRedirectorsWithProfile === 'function') {
700
+ const status = typeof redirector.mod.redirectorStatus === 'function' ? redirector.mod.redirectorStatus() : { running: false }
701
+ if (status.running === true) {
702
+ const reload = await redirector.mod.reloadRedirectorsWithProfile(cfg.workspace, { sides: ['windows', 'local'] })
703
+ const reloaded = Array.isArray(reload.reloaded) ? reload.reloaded.length : 0
704
+ if (reloaded > 0) {
705
+ log?.('系统层拦截已按新规则重启')
706
+ return { mode: 'ide', rules, reloadNote: '系统层拦截已按新规则自动重启' }
707
+ }
708
+ const stopped = typeof redirector.mod.stopRedirector === 'function'
709
+ ? await redirector.mod.stopRedirector()
710
+ : { stopped: false }
711
+ const note = stopped.stopped === true
712
+ ? '系统层拦截未能重载,已停止(避免继续按旧规则代理)'
713
+ : '系统层拦截未能重载,旧规则可能仍在生效(请重启桥接)'
714
+ log?.(note)
715
+ return { mode: 'ide', rules, reloadNote: note }
716
+ }
717
+ }
718
+ return { mode: 'ide', rules }
719
+ }
720
+
721
+ export async function ensureBorrowedProxy(cfg, targets, { log, mapping = true } = {}) {
722
+ // Direct mode (no `.env` SOCKS settings) has nothing to forward through: the
723
+ // config's own addresses are reachable, so no engine is started at all.
724
+ if (cfg.socks === null || cfg.socks === undefined) return { mode: 'none' }
725
+ if (Array.isArray(targets) && targets.length === 0) {
726
+ log?.('代理引擎:bootstrap 没有需要转发的内网端点,跳过')
727
+ return { mode: 'none' }
728
+ }
729
+ if (cfg.proxyEngine === 'self') { log?.('代理引擎:按 .env 配置使用内置转发器(proxy.engine=self)'); return { mode: 'unavailable', error: 'disabled by proxy.engine=self' } }
730
+ // Preferred tier: the daemon owns the engine, so yds writes rules + a lease and
731
+ // never starts a second engine (two owners would fight for the same shadows).
732
+ const risd = await probeRisd()
733
+ if (risd.usable === true) {
734
+ const rules = ydsRulesFor(cfg, targets)
735
+ const saved = await putRules(cfg.workspace ?? '', rules, { gitHost: gitHostOf(cfg), log })
736
+ if (saved.ok !== true) return { mode: 'risd', ok: false, error: `代理规则写入 risd 失败:${saved.error}` }
737
+ const started = await startEngine(cfg.workspace ?? '')
738
+ if (started.ok !== true) return { mode: 'risd', ok: false, error: `risd 启动引擎失败:${started.error}` }
739
+ const status = await engineStatus(cfg.workspace ?? '')
740
+ const pid = status.engine?.pid ?? started.result?.pid ?? null
741
+ const ports = Array.isArray(status.engine?.listen) ? status.engine.listen.map((item) => item.port).filter((port) => Number.isInteger(port)) : []
742
+ log?.(`代理引擎:risd daemon 运行中(pid ${pid ?? '?'})${ports.length > 0 ? `,监听 ${ports.join(', ')}` : ''}`)
743
+ return { mode: 'risd', ok: true, pid, rules, shadowPorts: ports, listenPorts: ports }
744
+ }
745
+ const loaded = await loadIdeProxy(cfg)
746
+ cfg.__ideProxy = loaded
747
+ if (!loaded.available) {
748
+ // Standalone by design: the built-in forwarder is a complete fallback, so
749
+ // the sibling plugin is only ever a recommendation (one shared engine).
750
+ log?.(`代理引擎:未找到 dsh-ris-ide(${loaded.error}),回退内置转发器`
751
+ + '(可选:装上 dsh-ris-ide 可在同一个 dsh 里获得 IDE 面板与服务调试能力)')
752
+ return { mode: 'unavailable', error: loaded.error }
753
+ }
754
+ const mod = loaded.mod
755
+ const before = mod.readProxyConfig(cfg.workspace)
756
+ const { config, rules } = syncProxyConfig(cfg, targets, { log, mapping })
757
+ const running = mod.proxyPid(cfg.workspace) !== null
758
+ // The engine watches `proxy.json` and reloads it (dsh-ris-ide/lib/proxy.js:
759
+ // watchFile -> reload -> applyShadows), so an edited table reaches a running engine
760
+ // within about a second. The restart below is belt-and-braces: it guarantees the
761
+ // new shadow listeners are bound BEFORE the process we are about to launch dials
762
+ // 127.0.0.1; it is not needed because "a running engine holds the old rules" — that
763
+ // model is obsolete (review D-P2-9).
764
+ const changed = JSON.stringify(before.rules) !== JSON.stringify(config.rules)
765
+ let restarted = false
766
+ if (running && changed) {
767
+ log?.('代理规则变化,重启代理引擎…')
768
+ await mod.stopProxy(cfg.workspace)
769
+ restarted = true
770
+ }
771
+ const started = await mod.startProxy(cfg.workspace)
772
+ if (started.ok !== true) return { mode: 'ide', ok: false, error: started.error ?? '代理引擎启动失败', path: loaded.path, rules }
773
+ // The loopback listeners are a config field now, not rule rows: read them back
774
+ // from the file the engine just loaded instead of guessing from the rules.
775
+ const shadowPorts = (mod.readProxyConfig(cfg.workspace)?.shadows ?? []).map((entry) => entry.localPort)
776
+ const shadowNote = shadowPorts.length > 0
777
+ ? `,127.0.0.1 兜底映射 ${shadowPorts.length} 个端口(ProxyBridge 未运行时的后备)`
778
+ : ',无 127.0.0.1 兜底映射(系统层拦截生效)'
779
+ log?.(`代理引擎:dsh-ris-ide 运行中(pid ${started.pid ?? '?'})${shadowNote}${restarted ? '(已重启)' : ''}`)
780
+ return { mode: 'ide', ok: true, path: loaded.path, pid: started.pid ?? null, rules, shadowPorts, restarted }
781
+ }
782
+
783
+ /**
784
+ * Take a daemon lease owned by a long-lived pid (the YMS process, or the
785
+ * foreground CLI) so the engine outlives this short `yds` process.
786
+ */
787
+ export async function holdBorrowedLease(cfg, { ownerPid = process.pid, log } = {}) {
788
+ const risd = await probeRisd()
789
+ if (risd.usable !== true) return { ok: false, reason: risd.reason }
790
+ // A previous YMS that was killed with -9 leaves a record whose pid is gone; the
791
+ // daemon only reaps by pid, so drop our stale record before taking a fresh one
792
+ // (review D-P2-6).
793
+ if (dropDeadLease(cfg.workspace ?? '') === true) log?.('已清理该 workspace 的死租约记录')
794
+ const lease = await acquireLease({ cwd: cfg.workspace ?? '', state: risd.state, ownerPid })
795
+ if (lease.ok !== true) {
796
+ log?.(`risd 租约未登记:${lease.error}`)
797
+ return { ok: false, error: lease.error }
798
+ }
799
+ rememberLease(cfg.workspace ?? '', { id: lease.id, ownerPid, port: risd.state.port })
800
+ log?.(`risd 租约已登记(owner pid ${ownerPid})`)
801
+ return { ok: true, id: lease.id }
802
+ }
803
+
804
+ /** Drop one specific daemon lease (the owner pid changed, e.g. CLI → YMS). */
805
+ export async function dropBorrowedLease(id, { log } = {}) {
806
+ if (typeof id !== 'string' || id === '') return { ok: true, released: false }
807
+ const risd = await probeRisd()
808
+ if (risd.usable !== true) return { ok: false, reason: risd.reason }
809
+ const released = await releaseLease(id, { state: risd.state })
810
+ if (released.ok === true) log?.('risd 租约已移交(旧 owner 释放)')
811
+ return released
812
+ }
813
+
814
+ /** Release the lease recorded for this workspace; idempotent when none exists. */
815
+ export async function releaseBorrowedLease(cfg, { log } = {}) {
816
+ const entry = readLease(cfg.workspace ?? '')
817
+ if (entry === null) return { ok: true, released: false }
818
+ const risd = await probeRisd()
819
+ if (risd.usable !== true) {
820
+ // No daemon: nothing holds the lease any more, so the local record is stale.
821
+ forgetLease(cfg.workspace ?? '')
822
+ return { ok: true, released: false, reason: 'risd 未运行' }
823
+ }
824
+ const released = await releaseLease(entry.id, { state: risd.state })
825
+ if (released.ok !== true) {
826
+ // Keep the record so a retry (`yds stop` again) can release it instead of
827
+ // waiting for the daemon's 3 s sweep to notice the dead owner (review D-P2-6).
828
+ return { ok: false, released: false, reason: released.error || '释放租约失败' }
829
+ }
830
+ forgetLease(cfg.workspace ?? '')
831
+ log?.(`risd 租约已释放(${entry.id})`)
832
+ return { ok: true, released: true }
833
+ }
834
+
835
+ /**
836
+ * Withdraw this plugin's share of the tunnel — and leave the ENGINE running.
837
+ *
838
+ * The engine is machine-wide: an IDE window (or another dsh) may still be using it.
839
+ * So `yds stop` releases the risd lease, removes every yds rule from the global table
840
+ * and lets the machine-level layers settle; killing the engine here is what used to
841
+ * interrupt the IDE's tunnel (the old "yds stop kills the shared engine" defect).
842
+ */
843
+ export async function stopBorrowedProxy(cfg, { log } = {}) {
844
+ const workspace = cfg.workspace ?? ''
845
+ const risd = await probeRisd()
846
+ if (risd.usable === true) {
847
+ await releaseBorrowedLease(cfg)
848
+ // `[]` removes EVERY yds row: the table is global and yds owns exactly the rows
849
+ // it injected, so a stop must not leave its rows behind — and there is no
850
+ // per-workspace share to preserve any more (global-table contract).
851
+ // The daemon reloads the bridge inside POST /v1/proxy; its note is what
852
+ // `yds stop` renders instead of reloading the bridge locally a second time
853
+ // (review D-P2-10).
854
+ const saved = await putRules(workspace, [], { gitHost: '', log })
855
+ return {
856
+ ok: saved.ok === true,
857
+ stopped: false,
858
+ engine: 'kept',
859
+ mode: 'risd',
860
+ withdrawn: saved.withdrawn ?? { rules: 0, hosts: 0 },
861
+ reloadNote: saved.reloadNote ?? '',
862
+ error: saved.ok === true ? '' : saved.error,
863
+ }
864
+ }
865
+ const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
866
+ if (loaded.available !== true) return { ok: false, error: loaded.error }
867
+ // Borrowed-engine tier: remove every yds row and keep the engine (and the user's
868
+ // own rows) for the IDE. Same blast radius as the risd tier (review D-P1-3/P1-8).
869
+ const mod = loaded.mod
870
+ const stored = mod.readProxyConfig(workspace)
871
+ const rules = mergeYdsRules(stored?.rules, [], { log })
872
+ const merged = typeof mod.mergeConfigs === 'function'
873
+ ? mod.mergeConfigs(stored, { replaceAllRules: true, rules })
874
+ : stored
875
+ mod.writeProxyConfig(workspace, { ...(merged !== null && typeof merged === 'object' ? merged : {}), rules })
876
+ return { ok: true, stopped: false, engine: 'kept', mode: 'ide', withdrawn: withdrawnBetween(stored?.rules, rules) }
877
+ }
878
+
879
+ /** Engine status for `yds status` / `yds proxy status`, whichever tier owns it. */
880
+ export async function borrowedProxyStatus(cfg) {
881
+ const risd = await probeRisd()
882
+ if (risd.usable === true) {
883
+ const view = await getView(cfg.workspace ?? '', { state: risd.state })
884
+ const engine = await engineStatus(cfg.workspace ?? '', { state: risd.state })
885
+ const payload = view.view ?? {}
886
+ const info = engine.engine ?? {}
887
+ const listen = Array.isArray(info.listen) ? info.listen : []
888
+ const listenPorts = listen.map((item) => item.port).filter((port) => Number.isInteger(port))
889
+ return {
890
+ available: true,
891
+ mode: 'risd',
892
+ path: `risd:${risd.state.port}`,
893
+ // /v1/engine is the authoritative process view (running/pid/listen); the rule
894
+ // table's numbers describe what was SAVED and can lag the engine (review D-P2-5).
895
+ running: info.running === true || (info.running === undefined && payload.running === true),
896
+ pid: info.pid ?? payload.pid ?? null,
897
+ listenPort: payload.listenPort ?? listenPorts[0] ?? null,
898
+ listenPorts,
899
+ rules: Array.isArray(payload.rules) ? payload.rules : [],
900
+ }
901
+ }
902
+ const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
903
+ if (loaded.available !== true) return { available: false, error: loaded.error }
904
+ const status = await loaded.mod.proxyStatus(cfg.workspace)
905
+ return { available: true, mode: 'ide', path: loaded.path, running: status.running === true, pid: status.pid ?? null, listenPort: status.config?.listenPort ?? null, rules: status.config?.rules ?? [] }
906
+ }