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/ide.js ADDED
@@ -0,0 +1,702 @@
1
+ // dsh-ris-ide integration (user requirement 4): check / update the workspace
2
+ // run config `.dsh-ris-ide/config.json` so the YMS program appears in the
3
+ // ris-ide panel and is start/stop/JDWP-debuggable through ris_dev_* tools.
4
+ //
5
+ // We only UPSERT our own entry by name; every other entry the user keeps is
6
+ // preserved verbatim. JSONC comments are tolerated on read (ris-ide parses
7
+ // JSONC); our write serializes plain JSON with a short header comment — the
8
+ // same thing the ris-ide panel does when it generates the file.
9
+ //
10
+ // ALSO: `.vscode/launch.json` — YonCode generates a VS Code java launch
11
+ // entry ("yms-middleware-cloudnative-jdk-21") on startup via its
12
+ // ensureLaunchConfig so F5 starts/debugs the YMS middleware inside YonCode.
13
+ // We generate the byte-equivalent entry (same name → no duplicate when both
14
+ // sides write) so a workspace shared with YonCode keeps a working F5 path
15
+ // that either side can (re)create. ${command:yds.*}/${config:yds.*}
16
+ // placeholders are resolved by YonCode itself — that is intentional.
17
+
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
19
+ import { basename, dirname, join, resolve } from 'node:path'
20
+ import { resolveConfig } from './config.js'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ export const IDE_CONFIG = join('.dsh-ris-ide', 'config.json')
24
+ export const LAUNCH_CONFIG = join('.vscode', 'launch.json')
25
+ export const LAUNCH_ENTRY_NAME = 'yms-middleware-cloudnative-jdk-21'
26
+ // Sibling of yms-home/config holding the UNTOUCHED confcenter copy, used by
27
+ // the direct launch mode while dsh itself still goes through SOCKS.
28
+ export const DIRECT_CONFIG_DIRNAME = 'config-direct'
29
+ const HEADER = '// dsh-ris-ide 程序配置(JSONC:允许注释与尾逗号)\n'
30
+ + '// dsh-ris-yds 维护 YMS 程序条目(名字=项目 parent.artifactId;startCommand 内含 ${JDWP_ARGS} 占位符,ris_dev_debug 会替换为 JDWP 参数)。\n'
31
+ '// 其余条目由用户/面板维护,此工具不会触碰。\n'
32
+ const LAUNCH_HEADER = '// dsh-ris-yds 维护 yms-middleware 运行配置(与 YonCode ensureLaunchConfig 生成的条目同名同构,任一侧写入均有效)。\n'
33
+ '// 其余 configuration 条目由用户/YonCode 维护,此工具不会触碰。\n'
34
+
35
+ /** First match of one XML attribute in the whole document, `''` when absent. */
36
+ export function readXmlAttribute(text, tag, attribute) {
37
+ const match = new RegExp(`<${tag}\\b[^>]*\\s${attribute}\\s*=\\s*"([^"]*)"`).exec(text)
38
+ return match === null ? '' : match[1].trim()
39
+ }
40
+
41
+ /** `<parent><artifactId>` of a pom, `''` when the pom has no parent block. */
42
+ export function readPomParentArtifactId(pomPath) {
43
+ try {
44
+ const text = readFileSync(pomPath, 'utf8')
45
+ const parent = /<parent>([\s\S]*?)<\/parent>/.exec(text)
46
+ if (parent === null) return ''
47
+ const artifact = /<artifactId>\s*([^<\s]+)\s*<\/artifactId>/.exec(parent[1])
48
+ return artifact === null ? '' : artifact[1]
49
+ } catch {
50
+ return ''
51
+ }
52
+ }
53
+
54
+ /** The bootstrap module's pom (`*bootstrap/pom.xml`) under a yds project root. */
55
+ export function findBootstrapPom(projectDir) {
56
+ if (typeof projectDir !== 'string' || projectDir === '') return ''
57
+ try {
58
+ for (const entry of readdirSync(projectDir, { withFileTypes: true })) {
59
+ if (entry.isDirectory() !== true) continue
60
+ if (/bootstrap$/.test(entry.name) !== true) continue
61
+ const pom = join(projectDir, entry.name, 'pom.xml')
62
+ if (existsSync(pom)) return pom
63
+ }
64
+ } catch {
65
+ // A missing/unreadable project root just falls back to the module name.
66
+ }
67
+ return ''
68
+ }
69
+
70
+ /**
71
+ * Name of the java program, as the user asked: `parent.artifactId` of the
72
+ * bootstrap pom (the aggregator's artifactId, e.g. `c-ass-kk-hans`) — the same
73
+ * value the YMS module is deployed under, so the panel row reads like the
74
+ * project rather than like our own bookkeeping.
75
+ * @param {object} cfg - resolved plugin config.
76
+ * @returns {string} project name, falling back to the YMS module name.
77
+ */
78
+ export function javaProjectName(cfg) {
79
+ const pom = findBootstrapPom(cfg?.projectDir)
80
+ const name = pom === '' ? '' : readPomParentArtifactId(pom)
81
+ if (name !== '') return name
82
+ return cfg?.module ?? 'local'
83
+ }
84
+
85
+ /**
86
+ * Name of a frontend program: `module.xml`'s `<module name="…">` that sits
87
+ * beside `package.json` (the MDF convention), not the directory name.
88
+ * @param {object} cfg - resolved plugin config.
89
+ * @param {string} [dir] - frontend directory; defaults to the configured one.
90
+ * @returns {string} project name, `''` when no such module.xml exists.
91
+ */
92
+ export function frontendProjectName(cfg, dir) {
93
+ const explicit = typeof dir === 'string' && dir !== '' ? dir : ''
94
+ if (explicit !== '') {
95
+ const file = join(explicit, 'module.xml')
96
+ if (existsSync(file)) {
97
+ const name = readXmlAttribute(readFileSync(file, 'utf8'), 'module', 'name')
98
+ if (name !== '') return name
99
+ }
100
+ return ''
101
+ }
102
+ const root = cfg?.projectDir
103
+ if (typeof root !== 'string' || root === '') return ''
104
+ const preferred = cfg?.frontend?.code
105
+ const candidates = []
106
+ try {
107
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
108
+ if (entry.isDirectory() !== true || entry.name.startsWith('.') === true) continue
109
+ const dirPath = join(root, entry.name)
110
+ const moduleXml = join(dirPath, 'module.xml')
111
+ if (!existsSync(moduleXml) || !existsSync(join(dirPath, 'package.json'))) continue
112
+ const name = readXmlAttribute(readFileSync(moduleXml, 'utf8'), 'module', 'name')
113
+ if (name !== '') candidates.push({ dir: dirPath, name })
114
+ }
115
+ } catch {
116
+ return ''
117
+ }
118
+ if (candidates.length === 0) return ''
119
+ const match = candidates.find((item) => item.name === preferred || item.dir.endsWith(`/${preferred}`) === true)
120
+ return (match ?? candidates[0]).name
121
+ }
122
+
123
+ export function entryName(cfg) {
124
+ return javaProjectName(cfg)
125
+ }
126
+
127
+ /**
128
+ * A row is ours when it runs our CLI, or when it is a spring row named after the
129
+ * project (the legacy name counts too, so apply/remove stay idempotent).
130
+ *
131
+ * The `kind` guard matters: a *frontend* row can legitimately carry the same name
132
+ * (a yds project's package.json name is the backend domainKey), and treating that
133
+ * row as ours would delete it on the next apply.
134
+ */
135
+ export function isOurs(entry, cfg) {
136
+ if (entry === null || typeof entry !== 'object') return false
137
+ // Our launcher is always a spring row. Checking the kind *first* keeps a frontend
138
+ // row from claiming ours-ness just because an old update copied the command onto it.
139
+ if (entry.kind !== undefined && entry.kind !== 'spring' && entry.kind !== 'manual') return false
140
+ const command = typeof entry.startCommand === 'string' ? entry.startCommand : ''
141
+ if (command.includes('bin/yds.js')) return true
142
+ if (entry.name === entryName(cfg)) return true
143
+ return typeof cfg?.module === 'string' && entry.name === `yds-yms-${cfg.module}`
144
+ }
145
+
146
+ /**
147
+ * The program entry we manage. startCommand runs our cross-OS node CLI in
148
+ * FOREGROUND mode — dsh-ris-ide's run-with-log wrapper owns the lifecycle.
149
+ */
150
+ export function ourEntry(cfg) {
151
+ const bin = fileURLToPath(new URL('../bin/yds.js', import.meta.url))
152
+ // The startCommand is executed through a shell by ris-ide wrappers (bash /
153
+ // PowerShell). Inside double quotes both treat $, backtick and " as live
154
+ // metacharacters — a plugin path containing them would be command
155
+ // injection. Refuse up front instead of trying to out-quote two shells.
156
+ if (/[$`"]/.test(bin)) {
157
+ throw new Error(`插件路径含 shell 元字符(\$ \` "),无法安全生成 startCommand: ${bin}——请将插件安装到不含这些字符的路径`)
158
+ }
159
+ const esc = bin
160
+ return {
161
+ // A stable id of our own: the config update matches by id first, and without one
162
+ // it fell back to *name* — which hijacked a discovered frontend row that carried
163
+ // the same name and dropped this launcher from the config.
164
+ id: `yds:${entryName(cfg)}`,
165
+ name: entryName(cfg),
166
+ // kind=spring is required for ris_dev_debug (JDWP) eligibility: the panel
167
+ // only debugs spring rows. Our startCommand still wins over the default
168
+ // spring launch (custom startCommand bypasses mvn/mainClass), so this only
169
+ // unlocks the debug button without changing the start path.
170
+ kind: 'spring',
171
+ dir: cfg.workspace,
172
+ startCommand: `node "${esc}" start --foreground --jvm-args "${'${JDWP_ARGS}'}"`,
173
+ logName: entryName(cfg),
174
+ stopGraceMs: 60000,
175
+ }
176
+ }
177
+
178
+ /** Read the current file: { exists, parsed, raw, path }. */
179
+ export function readIdeConfig(workspace) {
180
+ const path = join(workspace, IDE_CONFIG)
181
+ if (!existsSync(path)) return { exists: false, path, parsed: null }
182
+ const raw = readFileSync(path, 'utf8')
183
+ let parsed = null
184
+ try {
185
+ parsed = JSON.parse(stripJsonc(raw))
186
+ } catch { parsed = null }
187
+ return { exists: true, path, parsed, raw }
188
+ }
189
+
190
+ /**
191
+ * The yds project a program directory belongs to, judged from the **parent** dir up.
192
+ *
193
+ * A program row only gives us a directory. The yds project root always sits *between*
194
+ * that directory and the workspace: a module dir (or the project dir itself) never
195
+ * carries the IDE settings, one of its ancestors does. So the walk starts at `dir` and
196
+ * climbs to the workspace; the first level whose `.vscode/settings.json` contains
197
+ * `yds.`-prefixed settings is the project root, and everything beneath it belongs to
198
+ * that project.
199
+ * @param {string} workspace - session workspace; the walk stops there.
200
+ * @param {string} dir - absolute directory of a program (e.g. `…/c-ass-kk-hans-fe`).
201
+ * @returns {string} the project root, `''` when the program is not in a yds project.
202
+ */
203
+ export function ydsProjectRootOf(workspace, dir) {
204
+ if (typeof dir !== 'string' || dir === '') return ''
205
+ const stop = typeof workspace === 'string' && workspace !== '' ? resolve(workspace) : ''
206
+ let current = resolve(dir)
207
+ for (let depth = 0; depth < 8; depth += 1) {
208
+ const settings = join(current, '.vscode', 'settings.json')
209
+ if (existsSync(settings)) {
210
+ try {
211
+ // JSONC: the key prefix is what matters, a comment cannot fake it.
212
+ if (/"yds\.[A-Za-z]/.test(readFileSync(settings, 'utf8'))) return current
213
+ } catch {
214
+ // unreadable settings: keep walking up
215
+ }
216
+ }
217
+ const parent = dirname(current)
218
+ if (parent === current) return ''
219
+ if (stop !== '' && current === stop) return ''
220
+ current = parent
221
+ }
222
+ return ''
223
+ }
224
+
225
+ /**
226
+ * The name a yds program should carry, taken from the project's own files.
227
+ *
228
+ * `module.xml`'s `<module name>` for a frontend (falling back to the folder name),
229
+ * and for a java module the pom's `parent.artifactId` — the aggregator's identity —
230
+ * or, for the aggregator itself, its own artifactId.
231
+ * @param {string} dir - absolute program directory.
232
+ * @returns {string} the intended name, `''` when the files say nothing.
233
+ */
234
+ export function ydsProgramName(dir) {
235
+ if (typeof dir !== 'string' || dir === '') return ''
236
+ const moduleXml = join(dir, 'module.xml')
237
+ if (existsSync(moduleXml)) {
238
+ const named = readXmlAttribute(readFileSync(moduleXml, 'utf8'), 'module', 'name')
239
+ return named !== '' ? named : basename(dir)
240
+ }
241
+ const pom = join(dir, 'pom.xml')
242
+ if (!existsSync(pom)) return ''
243
+ const text = readFileSync(pom, 'utf8')
244
+ if (/<modules>/.test(text) === true) return readPomArtifactId(text)
245
+ return readPomParentArtifactId(pom)
246
+ }
247
+
248
+ /** The pom's own artifactId: the first one outside the `<parent>` block. */
249
+ export function readPomArtifactId(text) {
250
+ if (typeof text !== 'string') return ''
251
+ const own = text.replace(/<parent>[\s\S]*?<\/parent>/, '')
252
+ const match = /<artifactId>([^<]*)<\/artifactId>/.exec(own)
253
+ return match === null ? '' : match[1].trim()
254
+ }
255
+
256
+ /** Whether a config row belongs to a yds project (decided by `dir`, never by name). */
257
+ export function belongsToYdsProject(cfg, row) {
258
+ if (row === null || typeof row !== 'object') return false
259
+ const dir = typeof row.dir === 'string' && row.dir !== '' ? resolve(cfg.workspace, row.dir) : ''
260
+ return dir !== '' && ydsProjectRootOf(cfg.workspace, dir) !== ''
261
+ }
262
+
263
+ /**
264
+ * Rename every row that belongs to a yds project, using that project's own files.
265
+ *
266
+ * A row is judged by `dir` alone: walk up to the nearest `.vscode/settings.json` with
267
+ * `yds.` settings, then name the row after `module.xml` (frontend) or the aggregator
268
+ * pom's `parent.artifactId` (java). Rows outside a yds project are never touched, and
269
+ * a rename that would duplicate another row's name is skipped instead of applied.
270
+ * @param {object} cfg - resolved plugin config (workspace is the walk bound).
271
+ * @param {object[]} programs - config rows.
272
+ * @returns {{programs: object[], renamed: object[], skipped: object[]}} the outcome.
273
+ */
274
+ export function repairFrontendNames(cfg, programs) {
275
+ const renamed = []
276
+ const skipped = []
277
+ if (!Array.isArray(programs)) return { programs, renamed, skipped }
278
+ const rows = programs.map((row) => (row === null || typeof row !== 'object' ? row : { ...row }))
279
+ // Names of rows that will not be renamed stay reserved, so we never create a clash.
280
+ const taken = new Set()
281
+ const targets = []
282
+ for (const row of rows) {
283
+ if (row === null || typeof row !== 'object') continue
284
+ const dir = typeof row.dir === 'string' && row.dir !== '' ? resolve(cfg.workspace, row.dir) : ''
285
+ if (dir === '' || ydsProjectRootOf(cfg.workspace, dir) === '') { taken.add(row.name); continue }
286
+ targets.push({ row, dir })
287
+ }
288
+ for (const { row, dir } of targets) {
289
+ const name = ydsProgramName(dir)
290
+ if (name === '' || name === row.name) { taken.add(row.name); continue }
291
+ if (taken.has(name) === true) { skipped.push({ from: row.name, to: name, reason: '名字已被另一条目占用' }); taken.add(row.name); continue }
292
+ renamed.push({ from: row.name, to: name })
293
+ row.name = name
294
+ taken.add(name)
295
+ }
296
+ return { programs: rows, renamed, skipped }
297
+ }
298
+
299
+ /**
300
+ * Reconcile every row that belongs to a yds project with what that project *is*.
301
+ *
302
+ * Three things happen to rows inside a yds project (judged by `dir` → the nearest
303
+ * ancestor `.vscode/settings.json` with `yds.` settings):
304
+ * 1. rename after the project's own files (`module.xml`, pom parent artifactId);
305
+ * 2. disable the discovered backend rows — the yds launcher starts that same app
306
+ * against the same port, so leaving both enabled creates two rival starters;
307
+ * 3. strip a `bin/yds.js` startCommand from any row that is not ours — an older
308
+ * update could copy the launcher's command onto a frontend row.
309
+ * @param {object} cfg - resolved plugin config.
310
+ * @param {object[]} programs - config rows.
311
+ * @returns {{programs: object[], renamed: object[], skipped: object[], disabled: object[], cleaned: object[]}}
312
+ */
313
+ export function repairYdsRows(cfg, programs) {
314
+ const repaired = repairFrontendNames(cfg, programs)
315
+ const { renamed, skipped } = repaired
316
+ const disabled = []
317
+ const cleaned = []
318
+ const rows = repaired.programs.map((row) => {
319
+ if (row === null || typeof row !== 'object') return row
320
+ const ours = isOurs(row, cfg) === true
321
+ if (ours !== true && typeof row.startCommand === 'string' && row.startCommand.includes('bin/yds.js')) {
322
+ cleaned.push({ name: row.name, startCommand: row.startCommand })
323
+ const { startCommand, ...rest } = row
324
+ row = rest
325
+ }
326
+ if (ours !== true && row.kind === 'spring' && row.disabled !== true && belongsToYdsProject(cfg, row) === true) {
327
+ disabled.push({ name: row.name, dir: row.dir })
328
+ row = { ...row, disabled: true }
329
+ }
330
+ return row
331
+ })
332
+ return { programs: rows, renamed, skipped, disabled, cleaned }
333
+ }
334
+
335
+ /**
336
+ * Apply our entry: add or refresh it, preserving all others.
337
+ * @returns { changed, action: 'created'|'added'|'updated'|'unchanged', path }
338
+ */
339
+ /** The document ris-ide itself generates, so an initialised file looks native. */
340
+ export function emptyIdeConfigDocument() {
341
+ return { version: 1, defaults: { jvmArgs: [] }, programs: [] }
342
+ }
343
+
344
+ /**
345
+ * Create the config file when it does not exist yet.
346
+ *
347
+ * Writing our row into a file ris-ide never generated would leave it without the
348
+ * `version`/`defaults` keys the panel writes and expects; initialising first keeps
349
+ * one shape whether the panel or this plugin created it.
350
+ * @returns {{created: boolean, path: string}} what happened.
351
+ */
352
+ export function initializeIdeConfig(workspace) {
353
+ const path = join(workspace, IDE_CONFIG)
354
+ if (existsSync(path)) return { created: false, path }
355
+ mkdirSync(dirname(path), { recursive: true })
356
+ writeIdeConfig(path, emptyIdeConfigDocument())
357
+ return { created: true, path }
358
+ }
359
+
360
+ export function applyIdeConfig(cfg) {
361
+ const initialized = initializeIdeConfig(cfg.workspace)
362
+ const current = readIdeConfig(cfg.workspace)
363
+ const entry = ourEntry(cfg)
364
+ if (current.exists === false) {
365
+ mkdirSync(dirname(current.path), { recursive: true })
366
+ writeIdeConfig(current.path, { ...emptyIdeConfigDocument(), programs: [entry] })
367
+ return { changed: true, action: 'created', initialized: initialized.created, path: current.path, entry }
368
+ }
369
+ if (current.parsed === null) {
370
+ return { changed: false, action: 'error', error: `${current.path} 不是合法 JSON,为避免破坏用户配置未写入`, path: current.path }
371
+ }
372
+ const programs = Array.isArray(current.parsed.programs) ? current.parsed.programs : []
373
+ const others = programs.filter((p) => isOurs(p, cfg) !== true)
374
+ const idx = programs.findIndex((p) => isOurs(p, cfg) === true)
375
+ if (idx >= 0 && JSON.stringify(programs[idx]) === JSON.stringify(entry)) {
376
+ return { changed: false, action: 'unchanged', initialized: initialized.created, path: current.path, entry }
377
+ }
378
+ const repaired = repairYdsRows(cfg, [...others, entry])
379
+ const next = { ...current.parsed }
380
+ next.programs = repaired.programs
381
+ writeIdeConfig(current.path, next)
382
+ const action = initialized.created ? 'created' : (idx >= 0 ? 'updated' : 'added')
383
+ return {
384
+ changed: true, action, initialized: initialized.created, path: current.path, entry,
385
+ renamed: repaired.renamed, skipped: repaired.skipped, disabled: repaired.disabled, cleaned: repaired.cleaned,
386
+ }
387
+ }
388
+
389
+ /** Remove our entry if present. */
390
+ export function removeIdeConfig(cfg) {
391
+ const current = readIdeConfig(cfg.workspace)
392
+ if (current.exists === false) return { changed: false, action: 'missing' }
393
+ if (current.parsed === null) return { changed: false, action: 'error', error: '配置文件不是合法 JSON' }
394
+ const programs = Array.isArray(current.parsed.programs) ? current.parsed.programs : []
395
+ const rest = programs.filter((p) => isOurs(p, cfg) !== true)
396
+ if (rest.length === programs.length) return { changed: false, action: 'not-present' }
397
+ const next = { ...current.parsed, programs: rest }
398
+ writeIdeConfig(current.path, next)
399
+ return { changed: true, action: 'removed' }
400
+ }
401
+
402
+ function writeIdeConfig(path, doc) {
403
+ writeFileSync(path, `${HEADER}${JSON.stringify(doc, null, 2)}\n`, 'utf8')
404
+ }
405
+
406
+ /**
407
+ * JVM flags that make a YonCode/VS Code-launched java use the SOCKS tunnel.
408
+ *
409
+ * This is the only way to proxy a process we do not spawn ourselves: YonCode
410
+ * reads vmArgs from launch.json, so the flags travel with the configuration.
411
+ * JDBC is not affected (drivers ignore SOCKS — those need the 127.0.0.1 map).
412
+ * @param cfg - resolved plugin config (uses `cfg.socks`).
413
+ * @returns the vmArgs to append (`[]` when no tunnel is configured).
414
+ */
415
+ export function proxyVmArgs(cfg) {
416
+ const socks = cfg?.socks ?? null
417
+ if (socks === null || typeof socks !== 'object') return []
418
+ return [
419
+ `-DsocksProxyHost=${socks.host}`,
420
+ `-DsocksProxyPort=${socks.port}`,
421
+ '-Djava.net.useSystemProxies=false',
422
+ ]
423
+ }
424
+
425
+ /**
426
+ * The JVM flags that select the launch mode — the ONLY place proxy flags may
427
+ * come from, so the two modes can never be mixed:
428
+ * - proxy (default): {@link proxyVmArgs} (the SOCKS tunnel flags);
429
+ * - direct: nothing, except `-Dlocal_conf_path=<ymsHome>/config-direct/` when
430
+ * SOCKS is still configured (dsh needs the tunnel, YonCode must not).
431
+ * @param cfg - resolved plugin config (uses `cfg.launchMode`, `cfg.socks`).
432
+ * @returns the vmArgs to append.
433
+ */
434
+ export function launchModeVmArgs(cfg) {
435
+ const sep = process.platform === 'win32' ? '\\' : '/'
436
+ if ((cfg?.launchMode ?? 'proxy') === 'direct') {
437
+ const socks = cfg?.socks ?? null
438
+ if (socks === null || typeof socks !== 'object') return []
439
+ return [
440
+ `-Dlocal_conf_path=\${config:yds.ymsHome}${sep}${DIRECT_CONFIG_DIRNAME}${sep}`,
441
+ '-Dyms_use_local_conf=true',
442
+ ]
443
+ }
444
+ return proxyVmArgs(cfg)
445
+ }
446
+
447
+ /** True for the flags {@link proxyVmArgs} writes — used when taking them back out. */
448
+ function isProxyVmArg(value) {
449
+ return typeof value === 'string'
450
+ && (/^-DsocksProxy(Host|Port)=/.test(value) || value === '-Djava.net.useSystemProxies=false')
451
+ }
452
+
453
+ /**
454
+ * The launch.json configuration we manage — byte-equivalent to what YonCode's
455
+ * ensureLaunchConfig writes (verified against the generated file), so YonCode
456
+ * F5 and a regenerated file stay interchangeable. Windows path separators on
457
+ * win32, forward slashes elsewhere.
458
+ * @param cfg - resolved plugin config; the mode-dependent flags come from
459
+ * {@link launchModeVmArgs} (`ide.launch=proxy|direct`).
460
+ */
461
+ export function ourLaunchEntry(cfg) {
462
+ const win = process.platform === 'win32'
463
+ const sep = win ? '\\' : '/'
464
+ const javaExec = `\${config:yds.ymsHome}${sep}${win ? 'win32-x64-java.exe' : 'linux-x64-java'}`
465
+ const javaHome = `\${command:yds.javaHome}${sep}bin${sep}java${win ? '.exe' : ''}`
466
+ const exports = [
467
+ ['--add-exports', 'java.base/sun.security.action=ALL-UNNAMED'],
468
+ ['--add-exports', 'java.naming/com.sun.jndi.ldap=ALL-UNNAMED'],
469
+ ['--add-exports', 'java.naming/com.sun.jndi.url.ldap=ALL-UNNAMED'],
470
+ ['--add-exports', 'jdk.naming.dns/com.sun.jndi.dns=ALL-UNNAMED'],
471
+ ['--add-exports', 'jdk.naming.dns/com.sun.jndi.url.dns=ALL-UNNAMED'],
472
+ ['--add-exports', 'java.security.jgss/sun.security.krb5.internal=ALL-UNNAMED'],
473
+ ['--add-exports', 'jdk.attach/sun.tools.attach=ALL-UNNAMED'],
474
+ ]
475
+ const opens = [
476
+ ['--add-opens', 'java.base/java.util=ALL-UNNAMED'],
477
+ ['--add-opens', 'java.base/java.lang=ALL-UNNAMED'],
478
+ ['--add-opens', 'java.base/java.util.concurrent=ALL-UNNAMED'],
479
+ ['--add-opens', 'java.base/java.io=ALL-UNNAMED'],
480
+ ['--add-opens', 'java.base/java.nio=ALL-UNNAMED'],
481
+ ['--add-opens', 'java.base/sun.nio.ch=ALL-UNNAMED'],
482
+ ['--add-opens', 'java.naming/javax.naming.spi=ALL-UNNAMED'],
483
+ ['--add-opens', 'java.naming/com.sun.naming.internal=ALL-UNNAMED'],
484
+ ['--add-opens', 'jdk.naming.rmi/com.sun.jndi.url.rmi=ALL-UNNAMED'],
485
+ ['--add-opens', 'java.naming/javax.naming=ALL-UNNAMED'],
486
+ ['--add-opens', 'java.rmi/java.rmi=ALL-UNNAMED'],
487
+ ['--add-opens', 'java.sql/java.sql=ALL-UNNAMED'],
488
+ ['--add-opens', 'java.management/javax.management=ALL-UNNAMED'],
489
+ ['--add-opens', 'java.base/java.lang.reflect=ALL-UNNAMED'],
490
+ ['--add-opens', 'java.desktop/java.awt.image=ALL-UNNAMED'],
491
+ ['--add-opens', 'java.base/java.security=ALL-UNNAMED'],
492
+ ['--add-opens', 'java.base/java.net=ALL-UNNAMED'],
493
+ ['--add-opens', 'java.base/java.text=ALL-UNNAMED'],
494
+ ['--add-opens', 'java.base/sun.net.www.protocol.https=ALL-UNNAMED'],
495
+ ]
496
+ return {
497
+ type: 'java',
498
+ name: LAUNCH_ENTRY_NAME,
499
+ javaExec,
500
+ request: 'launch',
501
+ mainClass: 'iuap.yms.YMSLauncher',
502
+ cwd: '${config:yds.ymsHome}',
503
+ classPaths: [`\${config:yds.ymsHome}${sep}yms.jar`],
504
+ args: [],
505
+ vmArgs: [
506
+ `-Djavax.net.ssl.trustStore=\${config:yds.ymsHome}${sep}cacerts`,
507
+ '-Dapp.metainfo.ping_connect_timeout=200',
508
+ `-DYMS_WORK_PATH=\${config:yds.ymsHome}${sep}workarea`,
509
+ '-DYMS-DEV=true',
510
+ '-DYMS-DEV-MODE=debug',
511
+ '-Dyms.log.path=${config:yds.ymsHome}' + sep + 'logs',
512
+ '-Dyms.dev.libProjectsFile=${command:yds.libraryProjectsFile}',
513
+ '-Dapp.metainfo.preferred-networks=${command:yds.serverBindAddress}',
514
+ '-Dserver.port=${config:yds.serverPort}',
515
+ ...exports.flat(),
516
+ ...opens.flat(),
517
+ // YonCode appends these two exports AFTER the opens block — same flags,
518
+ // order kept identical for byte-parity with ensureLaunchConfig output.
519
+ '--add-exports', 'jdk.management.agent/jdk.internal.agent=ALL-UNNAMED',
520
+ '--add-exports', 'java.base/jdk.internal.vm=ALL-UNNAMED',
521
+ '-Dyms_fetch_config_version=2.0.0',
522
+ '-Dinotify_enable=false',
523
+ '-Dapp.metainfo.start_mode=static',
524
+ '-Dyms.bootstrap.enableNewConfig=true',
525
+ '-Drdbga=${command:yds.getEncryptedRDBGA}',
526
+ // Mode switch: proxy → SOCKS flags (removed again by `yds proxy stop`);
527
+ // direct → no proxy flag, and (when dsh still tunnels) the untouched
528
+ // config-direct via local_conf_path. See launchModeVmArgs.
529
+ ...launchModeVmArgs(cfg),
530
+ ],
531
+ env: {
532
+ JAVA_EXECUTABLE: javaHome,
533
+ mw_profiles_active: '${command:yds.env}',
534
+ YDS_STATE: '${command:yds.preLaunch}',
535
+ 'access.key': '${config:yds.accessKey}',
536
+ 'access.secret': '${config:yds.accessSecret}',
537
+ enable_cloud_debug: 'true',
538
+ YMS_ENABLE_MODULE: '${command:yds.getSelectedYmsModules}',
539
+ yms_enable_module: '${command:yds.getSelectedYmsModules}',
540
+ },
541
+ projectName: 'yds',
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Where .vscode/launch.json lives: the PROJECT folder the IDE actually opens
547
+ * (`cfg.projectDir`, i.e. the dir holding .vscode/settings.json — e.g.
548
+ * yonasset/), NOT the DSH session workspace root. Falling back to the
549
+ * session workspace when no project dir was detected keeps this usable in a
550
+ * bare checkout.
551
+ */
552
+ export function launchRoot(cfg) {
553
+ return cfg.projectDir ?? cfg.workspace
554
+ }
555
+
556
+ /** Read .vscode/launch.json: { exists, parsed, path }. */
557
+ export function readLaunchConfig(workspace) {
558
+ const path = join(workspace, LAUNCH_CONFIG)
559
+ if (!existsSync(path)) return { exists: false, path, parsed: null }
560
+ const raw = readFileSync(path, 'utf8')
561
+ let parsed = null
562
+ try {
563
+ parsed = JSON.parse(stripJsonc(raw))
564
+ } catch { parsed = null }
565
+ return { exists: true, path, parsed, raw }
566
+ }
567
+
568
+ /**
569
+ * Upsert our launch.json configuration, preserving all others (Current File /
570
+ * MDFApplication / workbench / anything YonCode wrote).
571
+ */
572
+ export function applyLaunchConfig(cfg) {
573
+ const current = readLaunchConfig(launchRoot(cfg))
574
+ const entry = ourLaunchEntry(cfg)
575
+ if (current.exists === false) {
576
+ mkdirSync(dirname(current.path), { recursive: true })
577
+ writeLaunchConfig(current.path, { version: '0.2.0', configurations: [entry] })
578
+ return { changed: true, action: 'created', path: current.path, entry }
579
+ }
580
+ if (current.parsed === null) {
581
+ return { changed: false, action: 'error', error: `${current.path} 不是合法 JSON,为避免破坏用户配置未写入`, path: current.path }
582
+ }
583
+ const configs = Array.isArray(current.parsed.configurations) ? current.parsed.configurations : []
584
+ const others = configs.filter((c) => c?.name !== entry.name)
585
+ const idx = configs.findIndex((c) => c?.name === entry.name)
586
+ if (idx >= 0 && JSON.stringify(configs[idx]) === JSON.stringify(entry)) {
587
+ return { changed: false, action: 'unchanged', path: current.path, entry }
588
+ }
589
+ const next = { ...current.parsed }
590
+ next.configurations = [...others, entry]
591
+ writeLaunchConfig(current.path, next)
592
+ return { changed: true, action: idx >= 0 ? 'updated' : 'added', path: current.path, entry }
593
+ }
594
+
595
+ /**
596
+ * Take the tunnel flags back out of our launch entry (everything else stays).
597
+ *
598
+ * Called when the proxy engine stops: leaving `-DsocksProxyHost` behind would
599
+ * point YonCode's java at a dead port on the next F5.
600
+ * @param cfg - resolved plugin config.
601
+ * @returns `{ changed, action }`.
602
+ */
603
+ export function stripLaunchProxy(cfg) {
604
+ const current = readLaunchConfig(launchRoot(cfg))
605
+ if (current.exists === false) return { changed: false, action: 'missing' }
606
+ if (current.parsed === null) return { changed: false, action: 'error', error: 'launch.json 不是合法 JSON' }
607
+ const configs = Array.isArray(current.parsed.configurations) ? current.parsed.configurations : []
608
+ const idx = configs.findIndex((c) => c?.name === LAUNCH_ENTRY_NAME)
609
+ if (idx < 0) return { changed: false, action: 'not-present' }
610
+ const vmArgs = Array.isArray(configs[idx].vmArgs) ? configs[idx].vmArgs : []
611
+ const kept = vmArgs.filter((value) => !isProxyVmArg(value))
612
+ if (kept.length === vmArgs.length) return { changed: false, action: 'no-proxy-args' }
613
+ const next = { ...current.parsed }
614
+ next.configurations = configs.map((entry, index) => (index === idx ? { ...entry, vmArgs: kept } : entry))
615
+ writeLaunchConfig(current.path, next)
616
+ return { changed: true, action: 'stripped' }
617
+ }
618
+
619
+ /** Remove our launch.json configuration if present. */
620
+ export function removeLaunchConfig(cfg) {
621
+ const current = readLaunchConfig(launchRoot(cfg))
622
+ if (current.exists === false) return { changed: false, action: 'missing' }
623
+ if (current.parsed === null) return { changed: false, action: 'error', error: 'launch.json 不是合法 JSON' }
624
+ const configs = Array.isArray(current.parsed.configurations) ? current.parsed.configurations : []
625
+ const rest = configs.filter((c) => c?.name !== LAUNCH_ENTRY_NAME)
626
+ if (rest.length === configs.length) return { changed: false, action: 'not-present' }
627
+ const next = { ...current.parsed, configurations: rest }
628
+ writeLaunchConfig(current.path, next)
629
+ return { changed: true, action: 'removed' }
630
+ }
631
+
632
+ function writeLaunchConfig(path, doc) {
633
+ writeFileSync(path, `${LAUNCH_HEADER}${JSON.stringify(doc, null, 2)}\n`, 'utf8')
634
+ }
635
+
636
+ /**
637
+ * String-aware JSONC strip in a single pass: removes // and /* *\/ comments
638
+ * and trailing commas ONLY outside string literals. The old regex version
639
+ * ate "http://" inside strings and ",]" sequences in startCommands —
640
+ * silently corrupting user config on round-trip.
641
+ */
642
+ function stripJsonc(text) {
643
+ let outText = ''
644
+ let i = 0
645
+ const n = text.length
646
+ const skipWsAndComments = (pos) => {
647
+ let j = pos
648
+ for (;;) {
649
+ const ch = text[j]
650
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { j += 1; continue }
651
+ if (ch === '/' && text[j + 1] === '/') { while (j < n && text[j] !== '\n' && text[j] !== '\r') j += 1; continue }
652
+ if (ch === '/' && text[j + 1] === '*') { const close = text.indexOf('*/', j + 2); j = close === -1 ? n : close + 2; continue }
653
+ return j
654
+ }
655
+ }
656
+ while (i < n) {
657
+ const c = text[i]
658
+ if (c === '"') {
659
+ let j = i + 1
660
+ while (j < n) {
661
+ if (text[j] === '\\') { j += 2; continue }
662
+ if (text[j] === '"') break
663
+ j += 1
664
+ }
665
+ const stop = Math.min(j + 1, n)
666
+ outText += text.slice(i, stop)
667
+ i = stop
668
+ continue
669
+ }
670
+ if (c === '/' && text[i + 1] === '/') { i = skipWsAndComments(i); continue }
671
+ if (c === '/' && text[i + 1] === '*') { i = skipWsAndComments(i); continue }
672
+ if (c === ',') {
673
+ const j = skipWsAndComments(i + 1)
674
+ if (text[j] === '}' || text[j] === ']') { i = j; continue } // trailing comma — drop
675
+ outText += c
676
+ i += 1
677
+ continue
678
+ }
679
+ outText += c
680
+ i += 1
681
+ }
682
+ return outText
683
+ }
684
+
685
+ /**
686
+ * Reconcile the panel config of one workspace with what its yds project is.
687
+ *
688
+ * Used by the config watcher (see `lib/ide-watch.js`): ris-ide rewrites the file on
689
+ * init/refresh, we put our launcher row and the yds names back. Nothing happens when
690
+ * the workspace has no yds project or no config file — the add-on never creates one.
691
+ * @param workspace - absolute workspace root.
692
+ * @returns {Promise<{changed: boolean, reason: string}>} outcome for logging.
693
+ */
694
+ export async function reconcileWorkspaceConfig(workspace) {
695
+ const { config: cfg } = resolveConfig(workspace)
696
+ if (typeof cfg.projectDir !== 'string' || cfg.projectDir === '') return { changed: false, reason: 'no-project' }
697
+ if (existsSync(join(cfg.workspace, '.dsh-ris-ide', 'config.json')) !== true) {
698
+ return { changed: false, reason: 'no-config' }
699
+ }
700
+ const result = applyIdeConfig(cfg)
701
+ return { changed: result.changed === true, reason: result.action }
702
+ }