dsh-ris-yds 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/browser.js ADDED
@@ -0,0 +1,428 @@
1
+ // Workbench browser cookie hygiene.
2
+ //
3
+ // The gateway only routes a request to the local YMS when the browser carries the
4
+ // rdbga cookie whose value is the base64 of "module,/context@bind:port". A stale or
5
+ // EMPTY rdbga wins over a good one (host-only beats a domain cookie), and then every
6
+ // request silently goes to the remote pod: the page looks fine, the local log stays
7
+ // empty and breakpoints never hit. This module inspects and repairs that cookie over
8
+ // the browser's own DevTools endpoint, so the failure heals instead of hiding.
9
+ //
10
+ // Two rules learned the hard way:
11
+ // • never guess the value — a wrong rdbga is worse than a stale one, because the
12
+ // gateway then routes to a different instance; only a value that can be checked
13
+ // against the local configuration is written;
14
+ // • never wait forever — a browser whose DevTools socket accepts but never answers
15
+ // must not hang `yds start`; every command has a deadline.
16
+
17
+ import { execFile } from 'node:child_process'
18
+ import { promisify } from 'node:util'
19
+
20
+ const execFileAsync = promisify(execFile)
21
+
22
+ /** Cookie name the iuap gateway reads to pick a debug instance. */
23
+ export const RDBGA_NAME = 'rdbga'
24
+
25
+ /** DevTools ports worth probing: YonCode's own browser, then the usual fallbacks. */
26
+ export const DEVTOOLS_PORTS = [9222, 9223, 9224, 9333]
27
+
28
+ /** Deadline for one CDP command and for the WebSocket handshake. */
29
+ export const CDP_TIMEOUT_MS = 10_000
30
+
31
+ /** Deadline for the small HTTP probes against the DevTools endpoint. */
32
+ export const HTTP_TIMEOUT_MS = 2_000
33
+
34
+ /** `-Drdbga=<value>` of a running JVM's command line, or '' when it is absent. */
35
+ export function rdbgaFromCmdline(commandLine) {
36
+ const text = String(commandLine ?? '')
37
+ const match = text.match(/-Drdbga=([^\s"']+)/)
38
+ return match === null ? '' : match[1]
39
+ }
40
+
41
+ /** A host and the domain-cookie spelling of it (`.host`), lower-cased. */
42
+ export function hostVariants(host) {
43
+ const name = String(host ?? '').trim().toLowerCase()
44
+ if (name === '') return []
45
+ return name.startsWith('.') ? [name, name.slice(1)] : [name, `.${name}`]
46
+ }
47
+
48
+ /** Does a cookie's domain belong to one of these hosts? Empty list = no filter. */
49
+ export function domainMatches(cookieDomain, hosts) {
50
+ const list = (Array.isArray(hosts) ? hosts : []).map((host) => String(host).toLowerCase().replace(/^\./, ''))
51
+ if (list.length === 0) return true
52
+ const domain = String(cookieDomain ?? '').toLowerCase().replace(/^\./, '')
53
+ return list.some((host) => domain === host || domain.endsWith(`.${host}`))
54
+ }
55
+
56
+ /**
57
+ * A value we are willing to WRITE: base64 of "module,/context@ipv4:port".
58
+ * Anything else (empty, garbage, `null,/@null:31010`) must be refused, because writing
59
+ * it deletes a working cookie and points the gateway at an instance that does not exist.
60
+ */
61
+ export function isPlausibleRdbga(value) {
62
+ const text = String(value ?? '').trim()
63
+ if (text === '' || /^[A-Za-z0-9+/]+={0,2}$/.test(text) !== true) return false
64
+ let decoded = ''
65
+ try { decoded = Buffer.from(text, 'base64').toString('utf8') } catch { return false }
66
+ return /^[^,\s]+,\/[^@\s]*@(?:\d{1,3}\.){3}\d{1,3}:\d{1,5}$/.test(decoded)
67
+ }
68
+
69
+ /**
70
+ * Which rdbga cookies to keep and which to delete.
71
+ *
72
+ * Exactly one cookie may survive, and it has to carry the expected value: an empty
73
+ * host-only cookie shadows the correct domain cookie, and two cookies make the
74
+ * gateway pick whichever the client happens to send first.
75
+ * @param {object[]} cookies CDP cookies
76
+ * @param {string} expected the value the running JVM was launched with
77
+ * @param {string[]|null} hosts restrict to these hosts (null = every domain)
78
+ * @returns {{keep: object[], remove: object[]}}
79
+ */
80
+ export function classifyRdbgaCookies(cookies, expected, hosts = null) {
81
+ // Compare as strings on both sides: the cookie store may hand back numbers while the
82
+ // expected value is derived from config, and status/repair must agree on the verdict.
83
+ const wanted = String(expected ?? '')
84
+ const keep = []
85
+ const remove = []
86
+ for (const cookie of Array.isArray(cookies) ? cookies : []) {
87
+ if (cookie?.name !== RDBGA_NAME) continue
88
+ if (domainMatches(cookie.domain, hosts) !== true) continue
89
+ const value = String(cookie.value ?? '')
90
+ if (value !== '' && value === wanted && keep.length === 0) keep.push(cookie)
91
+ else remove.push(cookie)
92
+ }
93
+ return { keep, remove }
94
+ }
95
+
96
+ /** Error raised when the browser answers a command with a CDP error object. */
97
+ export class CdpError extends Error {
98
+ constructor(method, detail) {
99
+ super(`${method}: ${detail?.message ?? JSON.stringify(detail)}`)
100
+ this.name = 'CdpError'
101
+ this.method = method
102
+ this.detail = detail
103
+ }
104
+ }
105
+
106
+ function withDeadline(promise, ms, message) {
107
+ let timer = null
108
+ const deadline = new Promise((_resolve, reject) => {
109
+ timer = setTimeout(() => reject(new Error(message)), ms)
110
+ })
111
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
112
+ }
113
+
114
+ /**
115
+ * Minimal CDP client over the WebSocket node ships with (no dependency).
116
+ * Every command settles: resolved with `result`, rejected on CDP error, on a closed
117
+ * socket, or when the deadline passes — a silent browser must not hang the caller.
118
+ */
119
+ export async function connectCdp(wsUrl, { timeout = CDP_TIMEOUT_MS, WebSocketImpl = globalThis.WebSocket } = {}) {
120
+ if (typeof WebSocketImpl !== 'function') throw new Error('当前 node 没有 WebSocket 实现')
121
+ const socket = new WebSocketImpl(wsUrl)
122
+ const pending = new Map()
123
+ let seq = 0
124
+ let closed = ''
125
+
126
+ const failAll = (reason) => {
127
+ for (const [, entry] of pending) {
128
+ clearTimeout(entry.timer)
129
+ entry.reject(new Error(reason))
130
+ }
131
+ pending.clear()
132
+ }
133
+
134
+ await withDeadline(new Promise((resolve, reject) => {
135
+ socket.onopen = resolve
136
+ socket.onerror = () => reject(new Error(`无法连接 ${wsUrl}`))
137
+ }), timeout, `DevTools 握手超时(${timeout}ms)`).catch((error) => {
138
+ try { socket.close() } catch { /* already gone */ }
139
+ throw error
140
+ })
141
+
142
+ socket.onmessage = (event) => {
143
+ let message = null
144
+ try { message = JSON.parse(event.data) } catch { return }
145
+ const entry = pending.get(message.id)
146
+ if (entry === undefined) return
147
+ pending.delete(message.id)
148
+ clearTimeout(entry.timer)
149
+ if (message.error !== undefined) entry.reject(new CdpError(entry.method, message.error))
150
+ else entry.resolve(message.result ?? {})
151
+ }
152
+ socket.onclose = () => {
153
+ closed = 'DevTools 连接已关闭'
154
+ failAll(closed)
155
+ }
156
+ socket.onerror = () => {
157
+ closed = 'DevTools 连接出错'
158
+ failAll(closed)
159
+ }
160
+
161
+ const send = (method, params = {}) => {
162
+ if (closed !== '') return Promise.reject(new Error(closed))
163
+ seq += 1
164
+ const id = seq
165
+ return new Promise((resolve, reject) => {
166
+ const timer = setTimeout(() => {
167
+ pending.delete(id)
168
+ reject(new Error(`${method} 超时(${timeout}ms)`))
169
+ }, timeout)
170
+ pending.set(id, { resolve, reject, timer, method })
171
+ try {
172
+ socket.send(JSON.stringify({ id, method, params }))
173
+ } catch (error) {
174
+ clearTimeout(timer)
175
+ pending.delete(id)
176
+ reject(error instanceof Error ? error : new Error(String(error)))
177
+ }
178
+ })
179
+ }
180
+
181
+ const close = () => {
182
+ try { socket.close() } catch { /* already gone */ }
183
+ }
184
+
185
+ return { send, close }
186
+ }
187
+
188
+ /**
189
+ * GET a DevTools HTTP endpoint with a deadline.
190
+ *
191
+ * The abort signal alone is not enough: a socket that accepts the connection and then
192
+ * never answers keeps `fetch` pending forever, and an injected `fetchImpl` may ignore
193
+ * the signal entirely. The race guarantees this returns even when the fetch never does.
194
+ */
195
+ async function devtoolsJson(port, path, { fetchImpl = fetch, timeout = HTTP_TIMEOUT_MS } = {}) {
196
+ const controller = new AbortController()
197
+ const timer = setTimeout(() => controller.abort(), timeout)
198
+ const request = (async () => {
199
+ const response = await fetchImpl(`http://127.0.0.1:${port}${path}`, { signal: controller.signal })
200
+ if (response.ok !== true) throw new Error(`HTTP ${response.status}`)
201
+ return await response.json()
202
+ })()
203
+ try {
204
+ return await withDeadline(request, timeout, `${path} 超时(${timeout}ms)`)
205
+ } finally {
206
+ clearTimeout(timer)
207
+ request.catch(() => {})
208
+ }
209
+ }
210
+
211
+ /** First DevTools port that answers `/json/version`, with its browser string. */
212
+ export async function probeDevtoolsPorts(ports = DEVTOOLS_PORTS, { fetchImpl = fetch, timeout = HTTP_TIMEOUT_MS } = {}) {
213
+ for (const port of Array.isArray(ports) ? ports : []) {
214
+ try {
215
+ const body = await devtoolsJson(port, '/json/version', { fetchImpl, timeout })
216
+ return { port, browser: String(body?.Browser ?? '') }
217
+ } catch { /* this port has no DevTools endpoint */ }
218
+ }
219
+ return { port: 0, browser: '' }
220
+ }
221
+
222
+ /** URL host of a page target ('' when the URL cannot be parsed). */
223
+ function hostOf(targetUrl) {
224
+ try { return new URL(String(targetUrl)).hostname.toLowerCase() } catch { return '' }
225
+ }
226
+
227
+ /**
228
+ * Every page target whose URL HOST is exactly one of `hosts` (empty list = every page).
229
+ *
230
+ * Exact host, not `url.includes(host)`: a substring test matches
231
+ * `srmdev.hanslaser.com.evil.com` and query strings that merely mention the host, and it
232
+ * would also accept any subdomain as "the workbench" — none of those pages carries the
233
+ * cookie the gateway reads.
234
+ */
235
+ export async function workbenchTargets(port, { fetchImpl = fetch, hosts = null, timeout = HTTP_TIMEOUT_MS } = {}) {
236
+ let list = null
237
+ try {
238
+ list = await devtoolsJson(port, '/json/list', { fetchImpl, timeout })
239
+ } catch {
240
+ return { ok: false, targets: [], error: '读取浏览器页签列表失败' }
241
+ }
242
+ const wanted = (Array.isArray(hosts) ? hosts : [])
243
+ .map((host) => String(host).toLowerCase().replace(/^\./, '').trim())
244
+ .filter((host) => host !== '')
245
+ const targets = (Array.isArray(list) ? list : []).filter((target) => target?.type === 'page'
246
+ && typeof target.webSocketDebuggerUrl === 'string'
247
+ && (wanted.length === 0 || wanted.includes(hostOf(target.url))))
248
+ return { ok: true, targets, error: '' }
249
+ }
250
+
251
+ /**
252
+ * All cookies the browser holds for the sites of these targets.
253
+ * `Network.getCookies` is called WITHOUT a URL: with a URL it only returns the cookies
254
+ * that would be sent to that exact path, so an rdbga with `path=/c-ass-kk-hans` stays
255
+ * invisible — and several real breakages come from exactly such a stray cookie.
256
+ */
257
+ export async function cookiesOfTargets(targets, { connect = connectCdp } = {}) {
258
+ const cookies = []
259
+ for (const target of targets) {
260
+ const cdp = await connect(target.webSocketDebuggerUrl)
261
+ try {
262
+ await cdp.send('Network.enable')
263
+ const result = await cdp.send('Network.getCookies')
264
+ for (const cookie of Array.isArray(result?.cookies) ? result.cookies : []) cookies.push(cookie)
265
+ } finally {
266
+ cdp.close()
267
+ }
268
+ }
269
+ return cookies
270
+ }
271
+
272
+ /**
273
+ * Strip stale/empty rdbga cookies from every workbench tab and set the expected one.
274
+ *
275
+ * `targets` skips the second `/json/list` round-trip when the caller already listed the
276
+ * tabs — two listings can observe different tab sets, and the repair must act on the
277
+ * tabs whose cookies were just reported. `dryRun` only computes the plan; nothing is
278
+ * deleted or written, so it is safe to run before asking the user.
279
+ * @returns {Promise<{ok: boolean, tabs: number, removed: number, wrote: number, set: string, dryRun: boolean, error?: string, failed?: string[]}>}
280
+ */
281
+ export async function repairRdbgaCookie({ port, expected, fetchImpl = fetch, connect = connectCdp, hosts = null, targets = null, timeout = CDP_TIMEOUT_MS, dryRun = false } = {}) {
282
+ const value = String(expected ?? '').trim()
283
+ const planned = dryRun === true
284
+ if (isPlausibleRdbga(value) !== true) {
285
+ return { ok: false, tabs: 0, removed: 0, wrote: 0, set: '', dryRun: planned, error: `拒绝写入可疑的 rdbga 值(${value === '' ? '空' : value})` }
286
+ }
287
+ let tabsHere = Array.isArray(targets) ? targets : null
288
+ if (tabsHere === null) {
289
+ const listed = await workbenchTargets(port, { fetchImpl, hosts })
290
+ if (listed.ok !== true) return { ok: false, tabs: 0, removed: 0, wrote: 0, set: value, dryRun: planned, error: listed.error }
291
+ tabsHere = listed.targets
292
+ }
293
+ if (tabsHere.length === 0) return { ok: false, tabs: 0, removed: 0, wrote: 0, set: value, dryRun: planned, error: '没有找到工作台页面(浏览器未打开工作台?)' }
294
+ let removed = 0
295
+ let wrote = 0
296
+ let tabs = 0
297
+ const failed = []
298
+ for (const target of tabsHere) {
299
+ const host = hostOf(target.url)
300
+ const hostsHere = hostVariants(host)
301
+ let cdp = null
302
+ try {
303
+ cdp = await connect(target.webSocketDebuggerUrl, { timeout })
304
+ await cdp.send('Network.enable')
305
+ const result = await cdp.send('Network.getCookies')
306
+ const cookies = Array.isArray(result?.cookies) ? result.cookies : []
307
+ const verdict = classifyRdbgaCookies(cookies, value, hostsHere)
308
+ const willWrite = verdict.keep.length === 0
309
+ if (planned === true) {
310
+ removed += verdict.remove.length
311
+ if (willWrite) wrote += 1
312
+ } else {
313
+ for (const cookie of verdict.remove) {
314
+ await cdp.send('Network.deleteCookies', {
315
+ name: cookie.name,
316
+ domain: cookie.domain,
317
+ path: cookie.path ?? '/',
318
+ })
319
+ removed += 1
320
+ }
321
+ if (willWrite) {
322
+ await cdp.send('Network.setCookie', {
323
+ name: RDBGA_NAME,
324
+ value,
325
+ url: `https://${host}/`,
326
+ path: '/',
327
+ })
328
+ wrote += 1
329
+ }
330
+ }
331
+ tabs += 1
332
+ } catch (error) {
333
+ failed.push(`${target.url}: ${error.message}`)
334
+ } finally {
335
+ if (cdp !== null) cdp.close()
336
+ }
337
+ }
338
+ if (tabs === 0) {
339
+ return {
340
+ ok: false, tabs, removed, wrote, set: value, dryRun: planned, failed,
341
+ error: `所有页签都失败(${failed.length} 个):${failed.join('; ') || '原因未知'}`,
342
+ }
343
+ }
344
+ if (failed.length > 0) {
345
+ // A tab we could not reach may still hold a stale cookie; the read-back only sees
346
+ // the first tab, so report this as a failure instead of a half-verified success.
347
+ return {
348
+ ok: false, tabs, removed, wrote, set: value, dryRun: planned, failed,
349
+ error: `部分页签失败(成功 ${tabs} 个,失败 ${failed.length} 个):${failed.join('; ')}`,
350
+ }
351
+ }
352
+ return { ok: true, tabs, removed, wrote, set: value, dryRun: planned, failed }
353
+ }
354
+
355
+ /**
356
+ * Read (never modify) the rdbga cookies of the first workbench tab.
357
+ * `targets` is reused from the caller for the same reason as in repairRdbgaCookie.
358
+ */
359
+ export async function readRdbgaCookies({ port, fetchImpl = fetch, connect = connectCdp, hosts = null, targets = null, timeout = CDP_TIMEOUT_MS } = {}) {
360
+ let tabsHere = Array.isArray(targets) ? targets : null
361
+ if (tabsHere === null) {
362
+ const listed = await workbenchTargets(port, { fetchImpl, hosts })
363
+ if (listed.ok !== true) return { ok: false, cookies: [], error: listed.error }
364
+ tabsHere = listed.targets
365
+ }
366
+ if (tabsHere.length === 0) return { ok: false, cookies: [], error: '没有工作台页签' }
367
+ try {
368
+ const cookies = await cookiesOfTargets([tabsHere[0]], { connect })
369
+ return { ok: true, cookies, error: '' }
370
+ } catch (error) {
371
+ return { ok: false, cookies: [], error: error.message }
372
+ }
373
+ }
374
+
375
+ /**
376
+ * `-Drdbga=` values of the running JVMs.
377
+ * `ok:false` means "the question could not be asked" (no PowerShell, no interop, not
378
+ * Windows) — not "there is no JVM". Both are told apart so the caller never mistakes an
379
+ * unreadable value for confirmation of its own guess.
380
+ * Async on purpose: the PowerShell query takes 1–2s and must not block the event loop
381
+ * (and with it the DevTools probes and the CDP deadlines).
382
+ * @returns {Promise<{ok: boolean, values: string[], error: string}>}
383
+ */
384
+ export async function rdbgaFromRunningJvm() {
385
+ if (process.platform !== 'win32' && process.env.WSL_DISTRO_NAME === undefined) {
386
+ return { ok: false, values: [], error: '只有 Windows/WSL 能查询 Windows 侧的 java 进程' }
387
+ }
388
+ try {
389
+ const script = '@(Get-CimInstance Win32_Process -Filter "Name=\'java.exe\'") | ForEach-Object { $_.CommandLine }'
390
+ const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], {
391
+ encoding: 'utf8',
392
+ timeout: 20_000,
393
+ windowsHide: true,
394
+ })
395
+ const values = []
396
+ for (const line of String(stdout ?? '').split(/\r?\n/)) {
397
+ const value = rdbgaFromCmdline(line)
398
+ if (value !== '' && values.includes(value) !== true) values.push(value)
399
+ }
400
+ return { ok: true, values, error: '' }
401
+ } catch (error) {
402
+ return { ok: false, values: [], error: `查询不到运行中的 JVM(${error.message})` }
403
+ }
404
+ }
405
+
406
+ /** 前 10 + 尾 6 + 总长:rdbga 尾部含回调端口,同前缀的两个值只能靠尾部区分。 */
407
+ function shortRdbga(value) {
408
+ const text = String(value ?? '')
409
+ if (text.length <= 24) return text
410
+ return `${text.slice(0, 10)}…${text.slice(-6)}(${text.length} 字符)`
411
+ }
412
+
413
+ /** Human-readable one-liner per rdbga cookie for `yds browser status`. */
414
+ export function describeRdbgaCookies(cookies, expected, hosts = null) {
415
+ const wanted = String(expected ?? '')
416
+ const found = (Array.isArray(cookies) ? cookies : []).filter((cookie) => cookie?.name === RDBGA_NAME)
417
+ const verdict = classifyRdbgaCookies(cookies, wanted, hosts)
418
+ if (found.length === 0) return `未找到 ${RDBGA_NAME} cookie(网关会按普通请求路由到远端)`
419
+ const lines = [`${RDBGA_NAME} cookie: ${found.length} 个(期望 ${wanted === '' ? '(无法确定)' : shortRdbga(wanted)})`]
420
+ found.forEach((cookie, index) => {
421
+ const value = String(cookie.value ?? '')
422
+ // An unknown expectation cannot call anything "wrong": say so instead of guessing.
423
+ const state = value === '' ? '空值' : wanted === '' ? '值未知' : (value === wanted ? '正确' : '值不匹配')
424
+ lines.push(`${index + 1}. ${value === '' ? '(空)' : shortRdbga(value)} @ ${cookie.domain}${cookie.path ?? '/'} → ${state}`)
425
+ })
426
+ if (wanted !== '' && verdict.remove.length > 0) lines.push(`需要清理 ${verdict.remove.length} 个(空值/重复/不匹配)`)
427
+ return lines.join('\n')
428
+ }
package/lib/build.js ADDED
@@ -0,0 +1,115 @@
1
+ // yms-home build: the same maven deploy YonCode's deployYmsHome runs, plus the
2
+ // config pull/adapt and JDK pointer. From scratch on a fresh machine this
3
+ // needs only: JDK, maven, network to jfrog (anonymous read) — no local assets.
4
+
5
+ import { spawn } from 'node:child_process'
6
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
7
+ import { join, resolve } from 'node:path'
8
+ import { artifactoryUrl } from './config.js'
9
+
10
+ export function ymsHomeIsBuilt(cfg) {
11
+ return existsSync(join(cfg.ymsHome, 'yms.jar')) && existsSync(join(cfg.ymsHome, 'middleware', 'lib'))
12
+ }
13
+
14
+ /** Locate the bootstrap module dir (the one whose mvn build deploys the war). */
15
+ export function findBootstrapDir(cfg) {
16
+ if (cfg.projectDir == null) throw new Error('未找到项目目录')
17
+ const direct = join(cfg.projectDir, 'c-ass-kk-hans-be', 'dev-c-ass-kk-hans-bootstrap')
18
+ if (existsSync(direct)) return direct
19
+ // generic: scan every child dir of the project for a *bootstrap module
20
+ // with a pom.xml (covers <module>-be/dev-<module>-bootstrap and any layout)
21
+ for (const name of readdirSync(cfg.projectDir)) {
22
+ const be = join(cfg.projectDir, name)
23
+ try {
24
+ if (!statSync(be).isDirectory() || name.startsWith('.') || name === 'node_modules') continue
25
+ } catch { continue }
26
+ for (const sub of readdirSync(be)) {
27
+ if (/bootstrap/.test(sub) && existsSync(join(be, sub, 'pom.xml'))) return join(be, sub)
28
+ }
29
+ }
30
+ throw new Error('未找到 bootstrap 模块目录(*bootstrap/pom.xml)')
31
+ }
32
+
33
+ /**
34
+ * Generate a maven settings.xml with the jfrog repo derived from the
35
+ * workbench URL (anonymous read works) — used when the machine has no
36
+ * settings of its own or ~/.m2 has a hijacking mirror.
37
+ */
38
+ /** XML-escape a value interpolated into the generated settings.xml. */
39
+ function xmlEsc(v) {
40
+ return String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;')
42
+ }
43
+
44
+ export function ensureMavenSettings(cfg) {
45
+ if (cfg.maven.settingsXml !== null && existsSync(cfg.maven.settingsXml)) return cfg.maven.settingsXml
46
+ const path = join(cfg.stateDir, 'maven-settings.xml')
47
+ mkdirSync(cfg.stateDir, { recursive: true })
48
+ const repo = artifactoryUrl(cfg)
49
+ const localRepo = cfg.maven.repo !== null ? `\n <localRepository>${xmlEsc(resolve(cfg.maven.repo))}</localRepository>` : ''
50
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
51
+ <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
52
+ <!-- generated by dsh-ris-yds: jfog repo = workbench host artifactory -->
53
+ ${localRepo}
54
+ <profiles>
55
+ <profile>
56
+ <id>yds</id>
57
+ <repositories>
58
+ <repository>
59
+ <id>jfrog</id>
60
+ <url>${xmlEsc(repo)}</url>
61
+ <releases><enabled>true</enabled><checksumPolicy>ignore</checksumPolicy></releases>
62
+ <snapshots><enabled>true</enabled><checksumPolicy>ignore</checksumPolicy></snapshots>
63
+ </repository>
64
+ </repositories>
65
+ <pluginRepositories>
66
+ <pluginRepository>
67
+ <id>jfrog</id>
68
+ <url>${xmlEsc(repo)}</url>
69
+ <releases><enabled>true</enabled><checksumPolicy>ignore</checksumPolicy></releases>
70
+ <snapshots><enabled>true</enabled><checksumPolicy>ignore</checksumPolicy></snapshots>
71
+ </pluginRepository>
72
+ </pluginRepositories>
73
+ </profile>
74
+ </profiles>
75
+ <activeProfiles><activeProfile>yds</activeProfile></activeProfiles>
76
+ </settings>
77
+ `
78
+ writeFileSync(path, xml, 'utf8')
79
+ return path
80
+ }
81
+
82
+ /** Run the yms-module maven deploy into cfg.ymsHome. Long (first run: kernel 77MB download). */
83
+ export function buildYmsHome(cfg, { onStdout, signal } = {}) {
84
+ const bootstrap = findBootstrapDir(cfg)
85
+ const settingsXml = ensureMavenSettings(cfg)
86
+ // libProjectsFile: /dev/null is a POSIX-only trick — use the real marker
87
+ // file (ensureHomeMarkers writes it) so Windows maven gets a valid path.
88
+ const libProjectsFile = join(cfg.ymsHome, '.libraryProjects.list')
89
+ const args = [
90
+ '-B', '-ntp', '-s', settingsXml,
91
+ 'com.yonyou.iuap:yms-module-simplify-maven-plugin:1.0.2-RELEASE:dev',
92
+ '-Dyms.dev.skipRun=true',
93
+ `-Dyms.workDirectory=${cfg.ymsHome}`,
94
+ `-Dyms.dev.libProjectsFile=${libProjectsFile}`,
95
+ '-Dmaven.test.skip=true',
96
+ ]
97
+ return new Promise((resolve_, reject_) => {
98
+ // Windows: mvn is mvn.cmd and .cmd needs a cmd.exe host (Node ≥18.20
99
+ // refuses .cmd without shell). POSIX: keep plain spawn.
100
+ const useShell = process.platform === 'win32'
101
+ const child = spawn(useShell ? 'mvn.cmd' : 'mvn', args, { cwd: bootstrap, signal, shell: useShell, windowsHide: true })
102
+ // output is streamed to onStdout only — never buffered (a 19-minute
103
+ // first build must not accumulate the whole log in memory).
104
+ const onData = (chunk) => {
105
+ if (onStdout !== undefined) onStdout(chunk.toString())
106
+ }
107
+ child.stdout.on('data', onData)
108
+ child.stderr.on('data', onData)
109
+ child.once('error', reject_)
110
+ child.once('close', (code) => {
111
+ if (code === 0) resolve_({ ok: true, bootstrap, settingsXml })
112
+ else reject_(new Error(`mvn 退出码 ${code}(输出尾部见日志)`))
113
+ })
114
+ })
115
+ }