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
package/lib/socks.js ADDED
@@ -0,0 +1,279 @@
1
+ // SOCKS forwarder: local TCP listeners that tunnel through a SOCKS5 proxy to
2
+ // the real DB/Redis. Only started when .env carries socks.host;
3
+ // without it everything connects directly (requirement 2).
4
+ //
5
+ // The forwarder runs as its own detached process (`yds socks-fwd`) with a
6
+ // pidfile in .dsh-ris-yds/; start/stop are idempotent.
7
+
8
+ import { spawn } from 'node:child_process'
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
10
+ import { pidAlive, pidIdentity, stopPid } from './proc.js'
11
+ import { join } from 'node:path'
12
+ import net from 'node:net'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ export function socksPidFile(cfg) {
16
+ return join(cfg.stateDir, 'socks-forward.pid')
17
+ }
18
+
19
+ /**
20
+ * Verify a pid is really our process (guards against pid reuse after a
21
+ * crash/reboot: a bare pid from a stale pidfile could be an unrelated
22
+ * process — never signal it). Identity = the pid's command line contains our
23
+ * marker, from /proc on Linux/WSL or the Win32 process table on Windows.
24
+ * `unknown` (probe failed) is NOT "not ours": see pidIdentity (review D-P1-6).
25
+ */
26
+ export function pidIsOurs(pid, marker) {
27
+ if (!Number.isInteger(pid) || pid <= 0) return false
28
+ return pidIdentity(pid, [marker]).state === 'ours'
29
+ }
30
+
31
+ const FORWARDER_MARKER = 'socks-fwd'
32
+
33
+ /**
34
+ * Read + verify the forwarder pidfile.
35
+ * @returns {{pid: number|null, state: 'absent'|'verified'|'stale'|'unknown', reason: string}}
36
+ * Only `verified` yields a pid. `unknown` keeps the pidfile untouched so a
37
+ * transient probe failure cannot cause a duplicate forwarder.
38
+ */
39
+ export function probeForwarder(cfg, options = {}) {
40
+ const pidFile = socksPidFile(cfg)
41
+ if (!existsSync(pidFile)) return { pid: null, state: 'absent', reason: 'pidfile 不存在' }
42
+ const pid = Number(readFileSync(pidFile, 'utf8').trim())
43
+ if (!Number.isInteger(pid) || pid <= 0) {
44
+ try { unlinkSync(pidFile) } catch { /* ignore */ }
45
+ return { pid: null, state: 'stale', reason: 'pidfile 内容无效(已清理)' }
46
+ }
47
+ const identity = pidIdentity(pid, [FORWARDER_MARKER], options)
48
+ if (identity.state === 'ours') return { pid, state: 'verified', reason: '' }
49
+ if (identity.state === 'dead') {
50
+ try { unlinkSync(pidFile) } catch { /* ignore */ }
51
+ return { pid: null, state: 'stale', reason: '转发器进程已退出(已清理 pidfile)' }
52
+ }
53
+ if (identity.state === 'foreign') {
54
+ // pid reused by an unrelated process after a crash/reboot: the pidfile is stale.
55
+ try { unlinkSync(pidFile) } catch { /* ignore */ }
56
+ return { pid: null, state: 'stale', reason: 'pid 已被其它进程复用(已清理 pidfile)' }
57
+ }
58
+ return { pid: null, state: 'unknown', reason: '无法确认 pidfile 进程身份(探测失败),已保留 pidfile 未动' }
59
+ }
60
+
61
+ /** Verified forwarder pid, or null (absent/stale/foreign/unknown). */
62
+ export function forwarderPid(cfg, options = {}) {
63
+ return probeForwarder(cfg, options).pid
64
+ }
65
+
66
+ export function portOpen(host, port, timeout = 1500) {
67
+ return new Promise((resolve_) => {
68
+ const sock = net.connect({ host, port })
69
+ const done = (ok) => { sock.destroy(); resolve_(ok) }
70
+ sock.setTimeout(timeout, () => done(false))
71
+ sock.once('connect', () => done(true))
72
+ sock.once('error', () => done(false))
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Our forwarder is "up" only when the pidfile pid is alive AND is ours AND
78
+ * the port answers. A port answering without our pid = some other local
79
+ * service (e.g. a local Oracle on 1521) — NOT the tunnel.
80
+ */
81
+ export function isForwarderUp(cfg, options = {}) {
82
+ return (async () => {
83
+ const probe = probeForwarder(cfg, options)
84
+ // Identity unreadable (`unknown`): fall back to the listeners, because starting a
85
+ // second forwarder next to a live one is worse than a possible false positive.
86
+ if (probe.state === 'unknown') {
87
+ return (await portOpen('127.0.0.1', cfg.dbForwardPort, 400)) || (await portOpen('127.0.0.1', cfg.redisForwardPort, 400))
88
+ }
89
+ if (probe.pid === null) return false
90
+ return (await portOpen('127.0.0.1', cfg.dbForwardPort, 400)) || (await portOpen('127.0.0.1', cfg.redisForwardPort, 400))
91
+ })()
92
+ }
93
+
94
+ /**
95
+ * Spawn the detached forwarder process (idempotent). `targets` come from the
96
+ * confcenter adaptation: [{ host, port, localPort?, isRedis? }] — an explicit
97
+ * localPort (yonbip extras: kafka/ES/license keep their own port number)
98
+ * wins, db gets dbForwardPort, redis gets redisForwardPort.
99
+ */
100
+ export async function startForwarder(cfg, targets, { logPath } = {}) {
101
+ if (cfg.socks === null) return { started: false, reason: 'socks 未配置(直连模式,无需转发)' }
102
+ if (await isForwarderUp(cfg)) {
103
+ return { started: false, reason: '转发器已在监听' }
104
+ }
105
+ await stopForwarder(cfg)
106
+ mkdirSync(cfg.stateDir, { recursive: true })
107
+ const specs = []
108
+ for (const t of targets) {
109
+ const localPort = Number.isInteger(t.localPort) ? t.localPort
110
+ : (t.isRedis === true ? cfg.redisForwardPort : cfg.dbForwardPort)
111
+ if (specs.some((s) => s.local === localPort)) continue
112
+ specs.push({ local: localPort, host: t.host, port: t.port })
113
+ }
114
+ if (specs.length === 0) specs.push({ local: cfg.dbForwardPort, host: '', port: 0 }) // never used
115
+ const args = specs.map((s) => `${s.local}:${s.host}:${s.port}`)
116
+ const bin = fileURLToPath(new URL('../bin/yds.js', import.meta.url))
117
+ const out = logPath ?? join(cfg.stateDir, 'socks-forward.log')
118
+ const { openSync } = await import('node:fs')
119
+ const outFd = openSync(out, 'a')
120
+ const child = spawn(process.execPath, [bin, 'socks-fwd', `--socks=${cfg.socks.host}:${cfg.socks.port}`, ...args], {
121
+ detached: true,
122
+ stdio: ['ignore', outFd, outFd],
123
+ env: process.env,
124
+ })
125
+ child.unref()
126
+ writeFileSync(socksPidFile(cfg), String(child.pid), 'utf8')
127
+ const portsUp = async () => {
128
+ // every spec must listen; report the first one that does not
129
+ for (const s of specs) {
130
+ // eslint-disable-next-line no-await-in-loop
131
+ if (!(await portOpen('127.0.0.1', s.local, 150))) return s.local
132
+ }
133
+ return null
134
+ }
135
+ for (let i = 0; i < 40; i += 1) {
136
+ // eslint-disable-next-line no-await-in-loop
137
+ const down = await portsUp()
138
+ if (down === null) break
139
+ // eslint-disable-next-line no-await-in-loop
140
+ await new Promise((r) => setTimeout(r, 200))
141
+ }
142
+ // the wait loop above can exhaust without the port ever answering (child
143
+ // crashed instantly, e.g. EADDRINUSE) — verify before reporting success.
144
+ const down = await portsUp()
145
+ const alive = pidAlive(child.pid)
146
+ if (down !== null || !alive) {
147
+ try { unlinkSync(socksPidFile(cfg)) } catch { /* ignore */ }
148
+ throw new Error(`socks 转发器启动失败(端口 ${down ?? '?'} 未监听 / 进程存活=${alive})——详情见 ${out}(端口被占或其他本地服务在 127.0.0.1:${down}?)`)
149
+ }
150
+ return { started: true, pid: child.pid, targets: args, log: out }
151
+ }
152
+
153
+ /** Stop the detached forwarder if its pidfile pid is ours (never kill a reused foreign pid). */
154
+ export async function stopForwarder(cfg, options = {}) {
155
+ const probe = probeForwarder(cfg, options)
156
+ if (probe.pid === null) return { stopped: false, reason: probe.reason }
157
+ const pidFile = socksPidFile(cfg)
158
+ const killed = await stopPid(probe.pid, { graceMs: 5000, ...options })
159
+ if (killed.ok !== true) {
160
+ // Keep the pidfile: the process is still there and `yds status` must keep seeing it
161
+ // (review D-P1-7).
162
+ return { stopped: false, pid: probe.pid, reason: `停止失败:${killed.error ?? '未知原因'}(已保留 pidfile)` }
163
+ }
164
+ try { unlinkSync(pidFile) } catch { /* ignore */ }
165
+ return { stopped: true, pid: probe.pid }
166
+ }
167
+
168
+ /** SOCKS5 no-auth handshake + CONNECT, promise-based (proven implementation). */
169
+ const SOCKS_CONNECT_TIMEOUT_MS = 15000
170
+
171
+ function connectSocks(targetHost, targetPort, socksHost, socksPort) {
172
+ return new Promise((resolve_, reject_) => {
173
+ const sock = net.connect(socksPort, socksHost)
174
+ const timer = setTimeout(() => { try { sock.destroy() } catch { /* ignore */ } reject_(new Error('socks connect timeout')) }, SOCKS_CONNECT_TIMEOUT_MS)
175
+ const settle = (fn) => (arg) => { clearTimeout(timer); fn(arg) }
176
+ sock.once('error', settle(reject_))
177
+ sock.once('connect', () => {
178
+ sock.write(Buffer.from([0x05, 0x01, 0x00])) // greeting: no-auth
179
+ sock.once('data', (buf) => {
180
+ if (buf.length < 2 || buf[0] !== 0x05) { sock.destroy(); return reject_(new Error('bad socks greeting')) }
181
+ if (buf[1] !== 0x00) { sock.destroy(); return reject_(new Error('socks requires auth')) }
182
+ const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(targetHost)
183
+ let req
184
+ if (isIp) {
185
+ req = Buffer.alloc(10)
186
+ req[0] = 0x05; req[1] = 0x01; req[3] = 0x01
187
+ targetHost.split('.').forEach((o, i) => { req[4 + i] = Number(o) })
188
+ req.writeUInt16BE(targetPort, 8)
189
+ } else {
190
+ const name = Buffer.from(targetHost, 'utf8')
191
+ req = Buffer.alloc(7 + name.length)
192
+ req[0] = 0x05; req[1] = 0x01; req[3] = 0x03; req[4] = name.length
193
+ name.copy(req, 5)
194
+ req.writeUInt16BE(targetPort, 5 + name.length)
195
+ }
196
+ sock.write(req)
197
+ let acc = Buffer.alloc(0)
198
+ const onReply = (buf) => {
199
+ acc = Buffer.concat([acc, buf])
200
+ if (acc.length < 4) return
201
+ const atyp = acc[3]
202
+ const need = atyp === 0x01 ? 10 : (atyp === 0x03 ? 5 + acc[4] + 2 : (atyp === 0x04 ? 22 : -1))
203
+ if (need < 0 || acc.length < need) return
204
+ sock.removeListener('data', onReply)
205
+ if (acc[1] !== 0x00) { sock.destroy(); return reject_(new Error(`socks CONNECT failed rep=${acc[1]}`)) }
206
+ if (acc.length > need) sock.unshift(acc.subarray(need))
207
+ clearTimeout(timer)
208
+ resolve_(sock)
209
+ }
210
+ sock.on('data', onReply)
211
+ })
212
+ })
213
+ })
214
+ }
215
+
216
+ /** The forwarder loop (runs inside `yds socks-fwd`). */
217
+ /**
218
+ * Health probe: full SOCKS5 handshake + CONNECT to one target. Returns true
219
+ * when the tunnel actually forwards (rep=0). Distinguishes "forwarder
220
+ * listening" from "remote machine can still reach the resource" — the
221
+ * one-way tunnel on the remote dev machine can go down independently.
222
+ */
223
+ export function probeSocksConnect(socks, target, timeout = 8000) {
224
+ return new Promise((resolve_) => {
225
+ let phase = 0
226
+ let settled = false
227
+ const done = (v) => { if (!settled) { settled = true; resolve_(v) } }
228
+ let socket
229
+ try { socket = net.connect(socks.port, socks.host) } catch { resolve_(false); return }
230
+ socket.setTimeout(timeout, () => { done(false); socket.destroy() })
231
+ socket.on('error', () => done(false))
232
+ socket.on('connect', () => {
233
+ try { socket.write(Buffer.from([5, 1, 0])) } catch { done(false) }
234
+ })
235
+ socket.on('data', (d) => {
236
+ if (settled) return
237
+ if (phase === 0) {
238
+ if (d.length < 2 || d[0] !== 5 || d[1] !== 0) { done(false); socket.destroy(); return }
239
+ phase = 1
240
+ const host = Buffer.from(target.host, 'utf8')
241
+ const port = target.port
242
+ const req = Buffer.from([5, 1, 0, 3, host.length, ...host, Math.floor(port / 256), port % 256])
243
+ try { socket.write(req) } catch { done(false) }
244
+ } else {
245
+ done(d.length >= 2 && d[1] === 0)
246
+ socket.destroy()
247
+ }
248
+ })
249
+ })
250
+ }
251
+
252
+ export function runForwarder(socksHost, socksPort, specs) {
253
+ console.log(`[yds] socks=${socksHost}:${socksPort}`)
254
+ let failures = 0
255
+ for (const s of specs) {
256
+ const server = net.createServer((client) => {
257
+ connectSocks(s.host, s.port, socksHost, socksPort).then((remote) => {
258
+ client.pipe(remote)
259
+ remote.pipe(client)
260
+ const done = () => { try { client.destroy(); remote.destroy() } catch { /* ignore */ } }
261
+ client.on('error', done); remote.on('error', done)
262
+ client.on('close', done); remote.on('close', done)
263
+ }).catch((e) => {
264
+ console.error(`[yds] ${s.local}->${s.host}:${s.port} failed: ${e.message}`)
265
+ client.destroy()
266
+ })
267
+ })
268
+ // without this listener EADDRINUSE (or any bind failure) becomes an
269
+ // uncaught exception in the detached child — parent then reports success.
270
+ server.on('error', (e) => {
271
+ failures += 1
272
+ console.error(`[yds] listen 127.0.0.1:${s.local} failed: ${e.message}`)
273
+ if (failures >= specs.length) process.exit(1)
274
+ })
275
+ server.listen(s.local, '127.0.0.1', () => {
276
+ console.log(`[yds] forward 127.0.0.1:${s.local} -> ${s.host}:${s.port}`)
277
+ })
278
+ }
279
+ }
package/lib/token.js ADDED
@@ -0,0 +1,36 @@
1
+ // YYCToken generation. The signing key derivation lives in the closed-source
2
+ // yds-rust native module, so we load the prebuilt binary per platform from
3
+ // native/ (darwin/linux/win32 × x64/arm64 — extracted from the YonCode
4
+ // extension, bundled here so the plugin needs no YonCode install).
5
+
6
+ import { existsSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { pathToFileURL } from 'node:url'
9
+ import { PLUGIN_DIR } from './config.js'
10
+
11
+ let cached = null
12
+
13
+ function loadNative() {
14
+ if (cached !== null) return cached
15
+ const file = join(PLUGIN_DIR, 'native', `yds-rust.${process.platform}-${process.arch}.node`)
16
+ if (!existsSync(file)) {
17
+ throw new Error(`当前平台无 yds-rust 原生模块: ${process.platform}-${process.arch} (${file})`)
18
+ }
19
+ cached = { file, mod: null }
20
+ return cached
21
+ }
22
+
23
+ /**
24
+ * Generate a YYCToken for a URL.
25
+ * @param {string} url - the exact request URL the token will authorize.
26
+ * @param {string} accessKey
27
+ * @param {string} accessSecret
28
+ * @returns {Promise<string>} the token (URL-bound; regenerate per URL).
29
+ */
30
+ export async function getYycToken(url, accessKey, accessSecret) {
31
+ const entry = loadNative()
32
+ if (entry.mod === null) entry.mod = await import(pathToFileURL(entry.file).href) // pathToFileURL: survives spaces on Windows
33
+ const fn = entry.mod.getYycToken ?? entry.mod.default?.getYycToken
34
+ if (typeof fn !== 'function') throw new Error('yds-rust 模块缺少 getYycToken 导出')
35
+ return fn(url, accessKey, accessSecret)
36
+ }
package/lib/yms.js ADDED
@@ -0,0 +1,336 @@
1
+ // YMS runtime: build the launch (env + JVM flags + rdbga), start in foreground
2
+ // (for the dsh-ris-ide run-with-log wrapper) or detached (own pidfile + logs),
3
+ // stop, status. Ports the proven work/start-yms.sh logic into cross-OS node.
4
+
5
+ import { spawn } from 'node:child_process'
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, renameSync, readSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import http from 'node:http'
9
+ import { portOpen } from './socks.js'
10
+ import { pidIdentity, stopPid } from './proc.js'
11
+ import { ymsHomeIsBuilt } from './build.js'
12
+
13
+ export function ymsPidFile(cfg) {
14
+ return join(cfg.stateDir, 'yms.pid')
15
+ }
16
+
17
+ // EXACT flag set from the proven work/start-yms.sh (YonCode launch template).
18
+ // Do NOT edit module/package pairs from memory — wrong module names are
19
+ // silently ignored by the JVM and break Liberty JNDI init (observed:
20
+ // PseudoContext "no JNDI feature is configured" forever, module deploy fails).
21
+ // The last 2 exports + 3 opens come from the YonCode-generated
22
+ // .vscode/launch.json (ensureLaunchConfig) — same provenance, kept in sync.
23
+ const ADD_EXPORTS = [
24
+ 'java.base/sun.security.action=ALL-UNNAMED',
25
+ 'java.naming/com.sun.jndi.ldap=ALL-UNNAMED',
26
+ 'java.naming/com.sun.jndi.url.ldap=ALL-UNNAMED',
27
+ 'jdk.naming.dns/com.sun.jndi.dns=ALL-UNNAMED',
28
+ 'jdk.naming.dns/com.sun.jndi.url.dns=ALL-UNNAMED',
29
+ 'java.security.jgss/sun.security.krb5.internal=ALL-UNNAMED',
30
+ 'jdk.attach/sun.tools.attach=ALL-UNNAMED',
31
+ 'jdk.management.agent/jdk.internal.agent=ALL-UNNAMED',
32
+ 'java.base/jdk.internal.vm=ALL-UNNAMED',
33
+ ]
34
+ const ADD_OPENS = [
35
+ 'java.base/java.util=ALL-UNNAMED',
36
+ 'java.base/java.lang=ALL-UNNAMED',
37
+ 'java.base/java.util.concurrent=ALL-UNNAMED',
38
+ 'java.base/java.io=ALL-UNNAMED',
39
+ 'java.base/java.nio=ALL-UNNAMED',
40
+ 'java.base/sun.nio.ch=ALL-UNNAMED',
41
+ 'java.naming/javax.naming.spi=ALL-UNNAMED',
42
+ 'java.naming/com.sun.naming.internal=ALL-UNNAMED',
43
+ 'jdk.naming.rmi/com.sun.jndi.url.rmi=ALL-UNNAMED',
44
+ 'java.naming/javax.naming=ALL-UNNAMED',
45
+ 'java.rmi/java.rmi=ALL-UNNAMED',
46
+ 'java.sql/java.sql=ALL-UNNAMED',
47
+ 'java.management/javax.management=ALL-UNNAMED',
48
+ 'java.base/java.lang.reflect=ALL-UNNAMED',
49
+ 'java.desktop/java.awt.image=ALL-UNNAMED',
50
+ 'java.base/java.security=ALL-UNNAMED',
51
+ 'java.base/java.net=ALL-UNNAMED',
52
+ 'java.base/java.text=ALL-UNNAMED',
53
+ 'java.base/sun.net.www.protocol.https=ALL-UNNAMED',
54
+ ]
55
+
56
+ /**
57
+ * rdbga cookie value (base64 of "module,/context@bind:port").
58
+ *
59
+ * Every part is validated: a half-filled config used to produce
60
+ * `base64("null,/@null:31010")`, and browser tools would then happily write that into
61
+ * the workbench — pointing the gateway at an instance that cannot exist. Fail loudly.
62
+ */
63
+ export function rdbgaValue(cfg) {
64
+ if (cfg.module == null) throw new Error('rdbga 值缺少 module——先在项目目录下运行(yonbuilder.json / module.xml)')
65
+ const bind = String(cfg.serverBindAddress ?? '')
66
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(bind) !== true) {
67
+ throw new Error('rdbga 值缺少合法的 serverBindAddress(IPv4)——补项目 .vscode/settings.json 的 yds.serverBindAddress')
68
+ }
69
+ const port = Number(cfg.serverPort)
70
+ if (Number.isInteger(port) !== true || port < 1 || port > 65535) {
71
+ throw new Error('rdbga 值缺少合法的 serverPort——补项目 .vscode/settings.json 的 yds.serverPort')
72
+ }
73
+ const context = String(cfg.context ?? '')
74
+ const raw = `${cfg.module},${context}@${bind}:${port}`
75
+ return Buffer.from(raw, 'utf8').toString('base64')
76
+ }
77
+
78
+ /** Full launch description: env, jvm args, args. */
79
+ export function launchSpec(cfg, { jvmArgs = [] } = {}) {
80
+ if (cfg.module == null) throw new Error('未识别模块名')
81
+ const logsDir = join(cfg.ymsHome, 'logs')
82
+ const env = {
83
+ ...process.env,
84
+ mw_profiles_active: cfg.env,
85
+ YDS_STATE: 'debugging',
86
+ access_key: cfg.accessKey ?? '',
87
+ access_secret: cfg.accessSecret ?? '',
88
+ 'access.key': cfg.accessKey ?? '',
89
+ 'access.secret': cfg.accessSecret ?? '',
90
+ enable_cloud_debug: 'true',
91
+ YMS_ENABLE_MODULE: cfg.module,
92
+ yms_enable_module: cfg.module,
93
+ }
94
+ const jvm = [
95
+ `-DYMS_WORK_PATH=${join(cfg.ymsHome, 'workarea')}`,
96
+ '-DYMS-DEV=true',
97
+ '-DYMS-DEV-MODE=debug',
98
+ `-Dyms.log.path=${logsDir}`,
99
+ `-Dyms.dev.libProjectsFile=${join(cfg.ymsHome, '.libraryProjects.list')}`,
100
+ `-Dapp.metainfo.preferred-networks=${cfg.serverBindAddress}`,
101
+ '-Dapp.metainfo.ping_connect_timeout=200',
102
+ `-Ddomain.url=${cfg.workbenchUrl}`,
103
+ `-Dserver.port=${cfg.ymsPort}`,
104
+ `-Drdbga=${rdbgaValue(cfg)}`,
105
+ // start_mode=static: server discovery reads config/ip_node_mapping.json
106
+ // instead of eureka (unreachable from a dev machine) — from YonCode's
107
+ // generated .vscode/launch.json. Makes outbound RPC fall back to the
108
+ // domain.url cloud-debug rewrite for unreachable pod instances.
109
+ '-Dapp.metainfo.start_mode=static',
110
+ '-Dyms_fetch_config_version=2.0.0',
111
+ '-Dyms.bootstrap.enableNewConfig=true',
112
+ ...ADD_EXPORTS.map((v) => `--add-exports=${v}`),
113
+ ...ADD_OPENS.map((v) => `--add-opens=${v}`),
114
+ ...jvmArgs.filter((v) => typeof v === 'string' && v.trim() !== '').map((v) => v.trim()),
115
+ ]
116
+ return { java: cfg.java, env, jvm, main: 'iuap.yms.YMSLauncher', cp: join(cfg.ymsHome, 'yms.jar'), logsDir }
117
+ }
118
+
119
+ /** Ensure .javaExecutable / .libraryProjects.list exist so the home is self-contained. */
120
+ export function ensureHomeMarkers(cfg) {
121
+ if (cfg.java == null) throw new Error('未找到 JDK')
122
+ const exe = join(cfg.ymsHome, '.javaExecutable')
123
+ if (!existsSync(exe)) writeFileSync(exe, cfg.java + '\n', 'utf8')
124
+ const libs = join(cfg.ymsHome, '.libraryProjects.list')
125
+ if (!existsSync(libs)) writeFileSync(libs, '\n', 'utf8')
126
+ }
127
+
128
+ /**
129
+ * Detached start: spawn java setsid, stdout/stderr -> yms-console.log,
130
+ * pidfile in .dsh-ris-yds/yms.pid. Returns { started, pid } or
131
+ * { alreadyRunning }.
132
+ */
133
+ /** Remember the port the running instance listens on (read back by config). */
134
+ function rememberPort(cfg) {
135
+ try {
136
+ mkdirSync(cfg.stateDir, { recursive: true })
137
+ writeFileSync(join(cfg.stateDir, 'yms-port'), `${cfg.ymsPort}\n`, { encoding: 'utf8' })
138
+ } catch { /* the port file is a convenience, never a requirement */ }
139
+ }
140
+
141
+ export async function startDetached(cfg, { jvmArgs = [] } = {}) {
142
+ const pidFile = ymsPidFile(cfg)
143
+ const running = await readRunning(cfg)
144
+ if (running !== null) {
145
+ if (running.state === 'unknown') {
146
+ return { started: false, alreadyRunning: false, unknown: true, pid: running.pid, reason: running.reason }
147
+ }
148
+ return { started: false, alreadyRunning: true, pid: running.pid }
149
+ }
150
+ if (!ymsHomeIsBuilt(cfg)) throw new Error('yms-home 未构建(先执行 yds build)')
151
+ if (cfg.java == null) throw new Error('未找到 JDK——用 .env 的 java.path 或 JAVA_HOME 指定')
152
+ if (cfg.module == null) throw new Error('未识别模块名——检查项目 yonbuilder.json / module.xml')
153
+ const spec = launchSpec(cfg, { jvmArgs })
154
+ ensureHomeMarkers(cfg)
155
+ rememberPort(cfg)
156
+ mkdirSync(cfg.stateDir, { recursive: true })
157
+ const log = join(cfg.stateDir, 'yms-console.log')
158
+ // crude rotation: keep the last full log, never let it grow unbounded
159
+ try {
160
+ const st = statSync(log)
161
+ if (st.size > 16 * 1024 * 1024) renameSync(log, `${log}.old`)
162
+ } catch { /* absent */ }
163
+ const out = openSync(log, 'a')
164
+ const err = openSync(log, 'a')
165
+ const child = spawn(spec.java, [...spec.jvm, '-cp', spec.cp, spec.main], {
166
+ cwd: cfg.ymsHome,
167
+ env: spec.env,
168
+ detached: true,
169
+ stdio: ['ignore', out, err],
170
+ windowsHide: true,
171
+ })
172
+ child.unref()
173
+ // parent copies of the log fds are dup'd into the child; close ours
174
+ try { closeSync(out) } catch { /* ignore */ }
175
+ try { closeSync(err) } catch { /* ignore */ }
176
+ writeFileSync(pidFile, String(child.pid), 'utf8')
177
+ // wait for the Liberty port to accept (~35s typical; hard budget 120s)
178
+ let up = false
179
+ for (let i = 0; i < 120 && !up; i += 1) {
180
+ // eslint-disable-next-line no-await-in-loop
181
+ up = await portOpen('127.0.0.1', cfg.ymsPort, 500)
182
+ if (!up) {
183
+ try { process.kill(child.pid, 0) } catch { throw new Error('YMS 进程已退出,查看 .dsh-ris-yds/yms-console.log') }
184
+ // eslint-disable-next-line no-await-in-loop
185
+ await new Promise((r) => setTimeout(r, 500))
186
+ }
187
+ }
188
+ if (!up) {
189
+ // TERM → grace → KILL (Windows: taskkill /T /F) so a half-started java tree cannot
190
+ // survive the failure and keep the port (review D-P2-5); the pidfile only goes
191
+ // away once the process is actually gone.
192
+ try { await stopPid(child.pid, { graceMs: 5000, group: true }) } catch { /* best effort */ }
193
+ try { unlinkSync(pidFile) } catch { /* ignore */ }
194
+ throw new Error(`YMS 端口 ${cfg.ymsPort} 在 120 秒内未就绪——查看 ${log}`)
195
+ }
196
+ return { started: true, pid: child.pid, port: cfg.ymsPort, consoleLog: log }
197
+ }
198
+
199
+ /** Foreground start: java as our child, signals forwarded (for run-with-log wrappers). */
200
+ export function startForeground(cfg, { jvmArgs = [], onStdout, signal } = {}) {
201
+ if (!ymsHomeIsBuilt(cfg)) throw new Error('yms-home 未构建(先执行 yds build)')
202
+ if (cfg.java == null) throw new Error('未找到 JDK——用 .env 的 java.path 或 JAVA_HOME 指定')
203
+ if (cfg.module == null) throw new Error('未识别模块名——检查项目 yonbuilder.json / module.xml')
204
+ const spec = launchSpec(cfg, { jvmArgs })
205
+ ensureHomeMarkers(cfg)
206
+ const child = spawn(spec.java, [...spec.jvm, '-cp', spec.cp, spec.main], {
207
+ cwd: cfg.ymsHome,
208
+ env: spec.env,
209
+ stdio: ['inherit', 'inherit', 'inherit'],
210
+ signal,
211
+ windowsHide: true,
212
+ })
213
+ const fwd = (sig) => {
214
+ try { child.kill(sig) } catch { /* ignore */ }
215
+ }
216
+ process.on('SIGTERM', () => fwd('SIGTERM'))
217
+ process.on('SIGINT', () => fwd('SIGINT'))
218
+ // record the java pid so `yds status` / `yds stop` are not blind to
219
+ // IDE-started (foreground) instances; unlink when the child goes away.
220
+ try {
221
+ mkdirSync(cfg.stateDir, { recursive: true })
222
+ writeFileSync(ymsPidFile(cfg), String(child.pid), 'utf8')
223
+ child.once('close', () => { try { unlinkSync(ymsPidFile(cfg)) } catch { /* ignore */ } })
224
+ } catch { /* best effort */ }
225
+ return child
226
+ }
227
+
228
+ const YMS_MARKERS = ['yms.jar', 'YMSLauncher']
229
+
230
+ /**
231
+ * Identity of a pidfile owner: `ours` / `foreign` / `dead` / `unknown`. The four-way
232
+ * answer matters: a probe that could not read the command line must never be treated
233
+ * as "not our process" (that deletes the pidfile and starts a second YMS — review
234
+ * D-P1-6).
235
+ * @returns {{state: 'ours'|'foreign'|'dead'|'unknown', commandLine: string}}
236
+ */
237
+ export function probeYmsPid(pid, options = {}) {
238
+ return pidIdentity(pid, YMS_MARKERS, options)
239
+ }
240
+
241
+ /**
242
+ * Read the pidfile; verify the process is alive AND ours.
243
+ * @returns {null | {pid: number, state?: 'unknown', reason?: string}}
244
+ * `null` when absent/stale/foreign (the stale pidfile is unlinked). An `unknown`
245
+ * entry keeps the pidfile and must be treated as "do not start a second one, do not
246
+ * kill it blindly".
247
+ */
248
+ export async function readRunning(cfg, options = {}) {
249
+ const pidFile = ymsPidFile(cfg)
250
+ if (!existsSync(pidFile)) return null
251
+ const pid = Number(readFileSync(pidFile, 'utf8').trim())
252
+ if (!Number.isInteger(pid) || pid <= 0) {
253
+ try { unlinkSync(pidFile) } catch { /* ignore */ }
254
+ return null
255
+ }
256
+ const identity = probeYmsPid(pid, options)
257
+ if (identity.state === 'ours') return { pid }
258
+ if (identity.state === 'dead' || identity.state === 'foreign') {
259
+ // Gone, or the pid was reused by an unrelated process after a crash/reboot.
260
+ try { unlinkSync(pidFile) } catch { /* ignore */ }
261
+ return null
262
+ }
263
+ return {
264
+ pid,
265
+ state: 'unknown',
266
+ reason: '无法确认 pidfile 进程身份(命令行为空或探测失败),已保留 pidfile 未动',
267
+ }
268
+ }
269
+
270
+ /** Stop the detached YMS: TERM, grace (YMS is slow), KILL; Windows uses taskkill /T /F. */
271
+ export async function stopYms(cfg, { graceMs = 30000, ...options } = {}) {
272
+ const running = await readRunning(cfg, options) // identity-verified: never kills a reused pid
273
+ if (running === null) return { stopped: false, reason: '未在运行' }
274
+ if (running.state === 'unknown') {
275
+ return {
276
+ stopped: false,
277
+ pid: running.pid,
278
+ reason: `${running.reason};为避免误杀,未发送停止信号(请手动确认该 pid)`,
279
+ }
280
+ }
281
+ // Windows has no graceful signal path: taskkill the tree; POSIX TERM→grace→KILL.
282
+ const killed = await stopPid(running.pid, { graceMs, ...options })
283
+ if (killed.ok !== true) {
284
+ // Keep the pidfile so `yds status` still shows the process (review D-P1-7).
285
+ return { stopped: false, pid: running.pid, reason: `停止失败:${killed.error ?? '未知原因'}(已保留 pidfile)` }
286
+ }
287
+ try { unlinkSync(ymsPidFile(cfg)) } catch { /* ignore */ }
288
+ return { stopped: true, pid: running.pid }
289
+ }
290
+
291
+ /** Status row: process + port + forwarder + config state. */
292
+ export async function status(cfg) {
293
+ const running = await readRunning(cfg) // identity-checked
294
+ const port = await portOpen('127.0.0.1', cfg.ymsPort, 800)
295
+ let ready = null
296
+ if (port === true) {
297
+ ready = await httpGet(`http://127.0.0.1:${cfg.ymsPort}/_yms/ready`, 3000)
298
+ }
299
+ return {
300
+ process: running,
301
+ processUnknown: running !== null && running.state === 'unknown',
302
+ port,
303
+ ready: ready !== null ? ready.status : null,
304
+ ymsHomeBuilt: ymsHomeIsBuilt(cfg),
305
+ configPresent: existsSync(join(cfg.ymsHome, 'config', 'yms_middleware.json')),
306
+ socksForwarder: cfg.socks === null ? null : await portOpen('127.0.0.1', cfg.dbForwardPort, 300),
307
+ }
308
+ }
309
+
310
+ function httpGet(url, timeout) {
311
+ return new Promise((resolve_) => {
312
+ const done = (value) => resolve_(value)
313
+ const r = http.get(url, (res) => { res.resume(); done({ status: res.statusCode }) })
314
+ r.setTimeout(timeout, () => { r.destroy(); done(null) })
315
+ r.once('error', () => done(null))
316
+ })
317
+ }
318
+
319
+ /** Tail helper for yds logs — reads only the last 256KB (log can be huge). */
320
+ export function tailFile(path, lines = 80) {
321
+ if (!existsSync(path)) return null
322
+ const st = statSync(path)
323
+ const readSize = Math.min(st.size, 256 * 1024)
324
+ const fh = openSync(path, 'r')
325
+ try {
326
+ const buf = Buffer.alloc(readSize)
327
+ readSync(fh, buf, 0, readSize, st.size - readSize)
328
+ const text = buf.toString('utf8')
329
+ const all = text.split('\n')
330
+ // first line may be a partial line from the mid-file cut — drop it
331
+ if (readSize < st.size && all.length > 1) all.shift()
332
+ return all.slice(-lines).join('\n')
333
+ } finally {
334
+ closeSync(fh)
335
+ }
336
+ }