dsh-ris-yds 1.0.0 → 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.
- package/README.md +20 -1
- package/lib/cli.js +158 -31
- package/lib/ide-proxy.js +296 -72
- package/lib/ide.js +31 -5
- package/lib/proc.js +270 -0
- package/lib/risd-peer.js +353 -0
- package/lib/rules.js +193 -0
- package/lib/socks.js +51 -32
- package/lib/yms.js +59 -25
- package/package.json +1 -1
- package/test/cli-stop.test.mjs +40 -0
- package/test/hints.test.mjs +13 -0
- package/test/ide-config.test.mjs +4 -0
- package/test/ide-proxy.test.mjs +80 -18
- package/test/lease-release.test.mjs +76 -0
- package/test/pidfile.test.mjs +105 -0
- package/test/proc.test.mjs +172 -0
- package/test/proxy-standalone.test.mjs +3 -0
- package/test/risd-leases.test.mjs +111 -0
- package/test/risd-tier.test.mjs +361 -0
- package/test/rules-e2e.test.mjs +122 -0
- package/test/rules.test.mjs +154 -0
package/lib/ide-proxy.js
CHANGED
|
@@ -21,9 +21,12 @@ import { execFileSync } from 'node:child_process'
|
|
|
21
21
|
import { yoncodeCandidates } from './yoncode.js'
|
|
22
22
|
import { pathToFileURL } from 'node:url'
|
|
23
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'
|
|
24
26
|
|
|
25
27
|
/** Marker `source` used for every rule this plugin owns. */
|
|
26
|
-
|
|
28
|
+
// `YDS_RULE_SOURCE` now lives in ./rules.js (shared with risd-peer).
|
|
29
|
+
export { YDS_RULE_SOURCE, programKeyOf } from './rules.js'
|
|
27
30
|
|
|
28
31
|
/** Where a dsh-ris-ide checkout/install may keep its proxy engine. */
|
|
29
32
|
export function ideProxyCandidates(cfg) {
|
|
@@ -203,22 +206,22 @@ export function yoncodeProcesses(options = {}) {
|
|
|
203
206
|
// turn into a ProcessName that matches programs we never meant to intercept.
|
|
204
207
|
if (/^jdk/i.test(entry.name) !== true) continue
|
|
205
208
|
if (existingFile(join(devkit, entry.name, 'bin', 'java.exe')) === false) continue
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
out.java = toProcessPattern(join(devkit, 'jdk*', 'bin', '*.exe'))
|
|
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'
|
|
210
212
|
out.home = home
|
|
211
213
|
break
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
if (out.git === '' && existingFile(join(devkit, 'git', 'bin', 'git.exe')) === true) {
|
|
215
|
-
out.git =
|
|
217
|
+
out.git = 'git.exe'
|
|
216
218
|
}
|
|
217
219
|
}
|
|
218
220
|
if (out.git === '') {
|
|
219
221
|
for (const candidate of Array.isArray(options.gitPaths) === true ? options.gitPaths : []) {
|
|
220
222
|
if (typeof candidate !== 'string' || candidate === '' || existingFile(candidate) !== true) continue
|
|
221
|
-
|
|
223
|
+
// Same idea as java: one git.exe row covers the devkit copy and every other install.
|
|
224
|
+
out.git = 'git.exe'
|
|
222
225
|
break
|
|
223
226
|
}
|
|
224
227
|
}
|
|
@@ -271,7 +274,6 @@ export function yoncodeRules(cfg, options = {}) {
|
|
|
271
274
|
homes: options.homes ?? yoncodeHomes(cfg),
|
|
272
275
|
gitPaths: options.gitPaths ?? candidateGitPaths(options),
|
|
273
276
|
})
|
|
274
|
-
const workspace = options.workspace ?? cfg?.workspace ?? ''
|
|
275
277
|
const gitHost = options.gitHost ?? ''
|
|
276
278
|
const rules = []
|
|
277
279
|
if (withJava && found.java !== '' && hosts.length > 0) {
|
|
@@ -279,12 +281,14 @@ export function yoncodeRules(cfg, options = {}) {
|
|
|
279
281
|
id: 'yds-yoncode-java',
|
|
280
282
|
label: 'YonCode java',
|
|
281
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',
|
|
282
287
|
hosts: hosts.join(','),
|
|
283
288
|
ports: '*',
|
|
284
289
|
enabled: true,
|
|
285
290
|
shadow: false,
|
|
286
291
|
source: YDS_RULE_SOURCE,
|
|
287
|
-
workspace,
|
|
288
292
|
})
|
|
289
293
|
}
|
|
290
294
|
if (found.git !== '' && gitHost !== '') {
|
|
@@ -292,12 +296,12 @@ export function yoncodeRules(cfg, options = {}) {
|
|
|
292
296
|
id: 'yds-yoncode-git',
|
|
293
297
|
label: 'YonCode git',
|
|
294
298
|
program: found.git,
|
|
299
|
+
side: 'windows',
|
|
295
300
|
hosts: gitHost,
|
|
296
301
|
ports: '*',
|
|
297
302
|
enabled: true,
|
|
298
303
|
shadow: false,
|
|
299
304
|
source: YDS_RULE_SOURCE,
|
|
300
|
-
workspace,
|
|
301
305
|
})
|
|
302
306
|
}
|
|
303
307
|
return rules
|
|
@@ -328,6 +332,7 @@ export function shadowTargetsFromTargets(targets) {
|
|
|
328
332
|
return out
|
|
329
333
|
}
|
|
330
334
|
|
|
335
|
+
|
|
331
336
|
/**
|
|
332
337
|
* Rules this project contributes to the machine-wide table.
|
|
333
338
|
*
|
|
@@ -376,7 +381,12 @@ export function privateSegmentsOf(targets, extraHosts = []) {
|
|
|
376
381
|
// (the YMS JVM gets -DsocksProxyHost from `yds yoncode patch`, and WinDivert resets the
|
|
377
382
|
// Oracle TNS handshake). Both entry points must agree, or a direct helper call silently
|
|
378
383
|
// turns java interception back on.
|
|
379
|
-
|
|
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 } = {}) {
|
|
380
390
|
const rules = []
|
|
381
391
|
const segments = new Set()
|
|
382
392
|
for (const target of Array.isArray(targets) ? targets : []) {
|
|
@@ -393,33 +403,47 @@ export function proxyRulesFromTargets(targets, workspace = '', { gitHost = '', j
|
|
|
393
403
|
if (isPrivateIpv4(value) !== true) continue
|
|
394
404
|
segments.add(value.split('.').slice(0, 3).join('.'))
|
|
395
405
|
}
|
|
396
|
-
//
|
|
397
|
-
// bootstrap never lists (license, ES, kafka), so a
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
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
|
+
}
|
|
423
447
|
}
|
|
424
448
|
return rules
|
|
425
449
|
}
|
|
@@ -435,41 +459,53 @@ export function gitHostOf(cfg) {
|
|
|
435
459
|
* Merge the YDS-owned rules into the IDE's proxy config and write it back.
|
|
436
460
|
* @returns {{config: object, rules: object[]}} the merged config.
|
|
437
461
|
*/
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
// IDE's java would only be covered by the generic `java` pattern.
|
|
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.
|
|
446
469
|
...yoncodeRules(cfg, {
|
|
447
470
|
gitHost: gitHostOf(cfg),
|
|
448
|
-
workspace: cfg.workspace ?? '',
|
|
449
471
|
java,
|
|
450
472
|
hosts: privateSegmentsOf(targets, extraHostsOf(cfg)),
|
|
451
473
|
}),
|
|
452
|
-
]
|
|
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 })
|
|
453
489
|
// Bound only while the system layer is unavailable; in interception mode this is
|
|
454
490
|
// empty, so nothing is bound on loopback at all.
|
|
455
491
|
const shadows = mapping === false ? [] : shadowTargetsFromTargets(targets)
|
|
456
|
-
const stored = mod.readProxyConfig(cfg.workspace)
|
|
457
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.
|
|
458
496
|
const merged = mod.mergeConfigs(stored, {
|
|
459
497
|
socks: upstream === null ? { host: '', port: 0 } : { host: upstream.host, port: upstream.port },
|
|
460
|
-
listenPort: cfg.proxyListenPort ?? stored
|
|
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,
|
|
498
|
+
listenPort: cfg.proxyListenPort ?? stored?.listenPort,
|
|
464
499
|
shadows,
|
|
465
|
-
|
|
466
|
-
// replaced, other workspaces keep theirs.
|
|
467
|
-
workspace: cfg.workspace ?? '',
|
|
500
|
+
replaceAllRules: true,
|
|
468
501
|
rules,
|
|
469
502
|
})
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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) }
|
|
473
509
|
}
|
|
474
510
|
|
|
475
511
|
/** Run one `git config --global` mutation; a missing git is a reported no-op. */
|
|
@@ -590,14 +626,36 @@ export async function startBorrowedRedirector(cfg, { log, sides } = {}) {
|
|
|
590
626
|
* @param {{log?: Function, keepGit?: boolean}} [options] - logger / skip git undo.
|
|
591
627
|
* @returns {Promise<{redirector: object, git: object}>} per-layer outcome.
|
|
592
628
|
*/
|
|
593
|
-
export async function stopBorrowedSideEffects(cfg, { log, keepGit = false } = {}) {
|
|
594
|
-
const loaded = await loadIdeRedirector(cfg)
|
|
629
|
+
export async function stopBorrowedSideEffects(cfg, { log, keepGit = false, daemonReload = null } = {}) {
|
|
595
630
|
let redirector = { stopped: false, reason: '未找到 dsh-ris-ide 的 ProxyBridge 适配器' }
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
+
}
|
|
601
659
|
}
|
|
602
660
|
}
|
|
603
661
|
let git = { ok: true, key: '' }
|
|
@@ -616,11 +674,47 @@ export async function stopBorrowedSideEffects(cfg, { log, keepGit = false } = {}
|
|
|
616
674
|
* @returns {Promise<{mode: string, rules?: object[]}>} sync outcome.
|
|
617
675
|
*/
|
|
618
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
|
+
}
|
|
619
690
|
const loaded = await loadIdeProxy(cfg)
|
|
620
691
|
cfg.__ideProxy = loaded
|
|
621
692
|
if (!loaded.available) return { mode: 'unavailable' }
|
|
622
693
|
const { rules } = syncProxyConfig(cfg, targets, { log, mapping: false })
|
|
623
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
|
+
}
|
|
624
718
|
return { mode: 'ide', rules }
|
|
625
719
|
}
|
|
626
720
|
|
|
@@ -633,6 +727,21 @@ export async function ensureBorrowedProxy(cfg, targets, { log, mapping = true }
|
|
|
633
727
|
return { mode: 'none' }
|
|
634
728
|
}
|
|
635
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
|
+
}
|
|
636
745
|
const loaded = await loadIdeProxy(cfg)
|
|
637
746
|
cfg.__ideProxy = loaded
|
|
638
747
|
if (!loaded.available) {
|
|
@@ -646,7 +755,12 @@ export async function ensureBorrowedProxy(cfg, targets, { log, mapping = true }
|
|
|
646
755
|
const before = mod.readProxyConfig(cfg.workspace)
|
|
647
756
|
const { config, rules } = syncProxyConfig(cfg, targets, { log, mapping })
|
|
648
757
|
const running = mod.proxyPid(cfg.workspace) !== null
|
|
649
|
-
//
|
|
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).
|
|
650
764
|
const changed = JSON.stringify(before.rules) !== JSON.stringify(config.rules)
|
|
651
765
|
let restarted = false
|
|
652
766
|
if (running && changed) {
|
|
@@ -666,17 +780,127 @@ export async function ensureBorrowedProxy(cfg, targets, { log, mapping = true }
|
|
|
666
780
|
return { mode: 'ide', ok: true, path: loaded.path, pid: started.pid ?? null, rules, shadowPorts, restarted }
|
|
667
781
|
}
|
|
668
782
|
|
|
669
|
-
/**
|
|
670
|
-
|
|
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
|
+
}
|
|
671
865
|
const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
|
|
672
866
|
if (loaded.available !== true) return { ok: false, error: loaded.error }
|
|
673
|
-
|
|
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) }
|
|
674
877
|
}
|
|
675
878
|
|
|
676
|
-
/** Engine status for `yds status` / `yds proxy status
|
|
879
|
+
/** Engine status for `yds status` / `yds proxy status`, whichever tier owns it. */
|
|
677
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
|
+
}
|
|
678
902
|
const loaded = cfg.__ideProxy ?? await loadIdeProxy(cfg)
|
|
679
903
|
if (loaded.available !== true) return { available: false, error: loaded.error }
|
|
680
904
|
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 ?? [] }
|
|
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 ?? [] }
|
|
682
906
|
}
|
package/lib/ide.js
CHANGED
|
@@ -271,10 +271,17 @@ export function belongsToYdsProject(cfg, row) {
|
|
|
271
271
|
* @param {object[]} programs - config rows.
|
|
272
272
|
* @returns {{programs: object[], renamed: object[], skipped: object[]}} the outcome.
|
|
273
273
|
*/
|
|
274
|
+
/** A log/pid name segment: the same alphabet ris-ide's discovery uses. */
|
|
275
|
+
export function sanitizeLogName(value) {
|
|
276
|
+
const cleaned = String(value).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80)
|
|
277
|
+
return cleaned === '' ? 'program' : cleaned
|
|
278
|
+
}
|
|
279
|
+
|
|
274
280
|
export function repairFrontendNames(cfg, programs) {
|
|
275
281
|
const renamed = []
|
|
276
282
|
const skipped = []
|
|
277
|
-
|
|
283
|
+
const renamedLogs = []
|
|
284
|
+
if (!Array.isArray(programs)) return { programs, renamed, skipped, renamedLogs }
|
|
278
285
|
const rows = programs.map((row) => (row === null || typeof row !== 'object' ? row : { ...row }))
|
|
279
286
|
// Names of rows that will not be renamed stay reserved, so we never create a clash.
|
|
280
287
|
const taken = new Set()
|
|
@@ -293,7 +300,25 @@ export function repairFrontendNames(cfg, programs) {
|
|
|
293
300
|
row.name = name
|
|
294
301
|
taken.add(name)
|
|
295
302
|
}
|
|
296
|
-
|
|
303
|
+
// A frontend row's logName is its own name (the panel used to derive "<project>-<name>",
|
|
304
|
+
// which collided with the backend row of the same yds module). Kept unique because
|
|
305
|
+
// logName keys the log file and the pidfile.
|
|
306
|
+
const usedLogs = new Set()
|
|
307
|
+
for (const row of rows) {
|
|
308
|
+
if (row === null || typeof row !== 'object') continue
|
|
309
|
+
const dir = typeof row.dir === 'string' && row.dir !== '' ? resolve(cfg.workspace, row.dir) : ''
|
|
310
|
+
const inProject = dir !== '' && ydsProjectRootOf(cfg.workspace, dir) !== ''
|
|
311
|
+
if (inProject !== true || row.kind !== 'frontend') {
|
|
312
|
+
if (typeof row.logName === 'string' && row.logName !== '') usedLogs.add(row.logName)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
let logName = sanitizeLogName(row.name)
|
|
316
|
+
for (let n = 2; usedLogs.has(logName); n += 1) logName = sanitizeLogName(`${row.name}-${n}`)
|
|
317
|
+
if (row.logName !== logName) renamedLogs.push({ name: row.name, from: row.logName, to: logName })
|
|
318
|
+
row.logName = logName
|
|
319
|
+
usedLogs.add(logName)
|
|
320
|
+
}
|
|
321
|
+
return { programs: rows, renamed, skipped, renamedLogs }
|
|
297
322
|
}
|
|
298
323
|
|
|
299
324
|
/**
|
|
@@ -312,7 +337,7 @@ export function repairFrontendNames(cfg, programs) {
|
|
|
312
337
|
*/
|
|
313
338
|
export function repairYdsRows(cfg, programs) {
|
|
314
339
|
const repaired = repairFrontendNames(cfg, programs)
|
|
315
|
-
const { renamed, skipped } = repaired
|
|
340
|
+
const { renamed, skipped, renamedLogs } = repaired
|
|
316
341
|
const disabled = []
|
|
317
342
|
const cleaned = []
|
|
318
343
|
const rows = repaired.programs.map((row) => {
|
|
@@ -329,7 +354,7 @@ export function repairYdsRows(cfg, programs) {
|
|
|
329
354
|
}
|
|
330
355
|
return row
|
|
331
356
|
})
|
|
332
|
-
return { programs: rows, renamed, skipped, disabled, cleaned }
|
|
357
|
+
return { programs: rows, renamed, skipped, renamedLogs, disabled, cleaned }
|
|
333
358
|
}
|
|
334
359
|
|
|
335
360
|
/**
|
|
@@ -382,7 +407,8 @@ export function applyIdeConfig(cfg) {
|
|
|
382
407
|
const action = initialized.created ? 'created' : (idx >= 0 ? 'updated' : 'added')
|
|
383
408
|
return {
|
|
384
409
|
changed: true, action, initialized: initialized.created, path: current.path, entry,
|
|
385
|
-
renamed: repaired.renamed, skipped: repaired.skipped,
|
|
410
|
+
renamed: repaired.renamed, skipped: repaired.skipped, renamedLogs: repaired.renamedLogs,
|
|
411
|
+
disabled: repaired.disabled, cleaned: repaired.cleaned,
|
|
386
412
|
}
|
|
387
413
|
}
|
|
388
414
|
|