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/config.js ADDED
@@ -0,0 +1,552 @@
1
+ // dsh-ris-yds config resolution.
2
+ //
3
+ // Requirements (user 2026-11-06):
4
+ // 1. No machine-specific content in project docs; every IP/port comes from
5
+ // settings.json (project, YonCode standard, READ-ONLY for us) or
6
+ // .env (workspace root, OPTIONAL, machine level; shared with dsh-ris-ide).
7
+ // 2. .env is not required: SOCKS configured -> proxy, else direct.
8
+ // 3. Never casually modify project files: yonasset/.vscode/settings.json is
9
+ // read-only; we write only dsh-owned state (.dsh-ris-yds/, yms-home in the
10
+ // same standard format YonCode itself deploys).
11
+ // 5. No dependence on local resources: a fresh machine + this plugin + the
12
+ // project git clone must be able to develop (confcenter config is pulled
13
+ // live; kernel comes from jfrog).
14
+ //
15
+ // Source priority (low -> high):
16
+ // defaults < .env < process env (YDS_*/YMS_PORT) < CLI overrides.
17
+
18
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
19
+ import { execFileSync } from 'node:child_process'
20
+ import { join, resolve, dirname } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ export const PLUGIN_DIR = dirname(dirname(fileURLToPath(import.meta.url)))
24
+
25
+ export const IS_WINDOWS = process.platform === 'win32'
26
+ export const IS_MAC = process.platform === 'darwin'
27
+ export const IS_WSL = detectWsl()
28
+
29
+ function detectWsl() {
30
+ if (IS_WINDOWS) return false
31
+ if (process.env.WSL_DISTRO_NAME !== undefined) return true
32
+ try {
33
+ return /microsoft/i.test(readFileSync('/proc/version', 'utf8'))
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ /** Parse an env-format file (KEY=VALUE, tolerates CRLF, comments, blanks). */
40
+ export function parseEnvFile(text) {
41
+ const out = {}
42
+ for (const rawLine of text.split(/\r?\n/)) {
43
+ const line = rawLine.trim()
44
+ if (line === '' || line.startsWith('#')) continue
45
+ const eq = line.indexOf('=')
46
+ if (eq <= 0) continue
47
+ const key = line.slice(0, eq).trim()
48
+ let value = line.slice(eq + 1)
49
+ // tolerate quotes the user wrapped around values
50
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
51
+ value = value.slice(1, -1)
52
+ }
53
+ out[key] = value.trim()
54
+ }
55
+ return out
56
+ }
57
+
58
+ /**
59
+ * Find the project root: the directory whose .vscode/settings.json carries
60
+ * yds.* keys. Search order: cwd itself -> up to 4 ancestor levels (agent
61
+ * sessions often start inside the project tree) -> one level of children
62
+ * (the workspace root usually holds the project dir).
63
+ */
64
+ export function findProjectDir(workspace) {
65
+ const candidates = []
66
+ // ancestors (bounded)
67
+ {
68
+ let cur = workspace
69
+ for (let i = 0; i < 5 && cur !== undefined; i += 1) {
70
+ candidates.push(cur)
71
+ const parent = dirname(cur)
72
+ if (parent === cur) break
73
+ cur = parent
74
+ }
75
+ }
76
+
77
+ try {
78
+ for (const name of readdirSync(workspace)) {
79
+ const full = join(workspace, name)
80
+ if (statSync(full).isDirectory() && !name.startsWith('.') && name !== 'node_modules') {
81
+ candidates.push(full)
82
+ }
83
+ }
84
+ } catch { /* unreadable cwd: only the cwd itself */ }
85
+ for (const dir of candidates) {
86
+ const p = join(dir, '.vscode', 'settings.json')
87
+ if (!existsSync(p)) continue
88
+ try {
89
+ const json = JSON.parse(readFileSync(p, 'utf8'))
90
+ if (json !== null && typeof json === 'object' && Object.keys(json).some((k) => k.startsWith('yds.'))) {
91
+ return { dir, settings: json }
92
+ }
93
+ } catch { /* malformed: skip */ }
94
+ }
95
+ return null
96
+ }
97
+
98
+ /** Read the optional machine-level `.env` (workspace root, shared with dsh-ris-ide). */
99
+ /**
100
+ * Find the shared `.env`.
101
+ *
102
+ * It is deliberately NOT inside the project (a clone must not carry the tunnel
103
+ * credentials), so the lookup walks up from the workspace: `<ws>/.env`,
104
+ * `<ws>/../.env`, … up to four levels. First hit wins.
105
+ * @param workspace - the workspace (or project) directory to start from.
106
+ * @returns `{ path, exists, values }` — `path` is where the file WOULD be.
107
+ */
108
+ export function readSettingsEnv(workspace) {
109
+ let dir = workspace
110
+ for (let depth = 0; depth < 5; depth += 1) {
111
+ const path = join(dir, '.env')
112
+ if (existsSync(path)) return { path, exists: true, values: parseEnvFile(readFileSync(path, 'utf8')) }
113
+ const parent = dirname(dir)
114
+ if (parent === dir) break
115
+ dir = parent
116
+ }
117
+ return { path: join(workspace, '.env'), exists: false, values: {} }
118
+ }
119
+
120
+ /** Detect the YDS module name + context from the project tree. */
121
+ export function detectModule(projectDir, ymsHome) {
122
+ // Candidate A: yonbuilder.json in the project root or its direct children
123
+ // (the backend project, e.g. c-ass-kk-hans-be/yonbuilder.json: {code:"..."}).
124
+ // The root-level module.xml often describes the *scripts* module
125
+ // (name=...-scripts, service_type=sql) — wrong for the backend runtime — so
126
+ // it is only a last resort.
127
+ const yonbuilderCodes = []
128
+ const dirs = [projectDir]
129
+ try {
130
+ for (const name of readdirSync(projectDir)) {
131
+ const full = join(projectDir, name)
132
+ if (statSync(full).isDirectory() && !name.startsWith('.') && name !== 'node_modules') dirs.push(full)
133
+ }
134
+ } catch { /* unreadable project dir */ }
135
+ for (const dir of dirs) {
136
+ try {
137
+ const p = join(dir, 'yonbuilder.json')
138
+ if (!existsSync(p)) continue
139
+ const json = JSON.parse(readFileSync(p, 'utf8'))
140
+ if (typeof json?.code === 'string' && json.code !== '' && !yonbuilderCodes.includes(json.code)) {
141
+ yonbuilderCodes.push(json.code)
142
+ }
143
+ } catch { /* malformed: skip */ }
144
+ }
145
+ // Candidate B: modules already deployed into yms-home (deployed = intended).
146
+ const deployed = []
147
+ try {
148
+ for (const name of readdirSync(join(ymsHome, 'modules'))) {
149
+ const full = join(ymsHome, 'modules', name)
150
+ if (statSync(full).isDirectory()) deployed.push(name)
151
+ }
152
+ } catch { /* no yms-home yet */ }
153
+
154
+ let name = null
155
+ const both = yonbuilderCodes.find((code) => deployed.includes(code))
156
+ if (both !== undefined) name = both
157
+ else if (yonbuilderCodes.length > 0) name = yonbuilderCodes[0]
158
+ else if (deployed.length > 0) name = deployed[0]
159
+ if (name === null) {
160
+ // Last resort: module.xml at the project root.
161
+ try {
162
+ const p = join(projectDir, 'module.xml')
163
+ if (existsSync(p)) {
164
+ const m = /<module[^>]*\sname="([^"]+)"/.exec(readFileSync(p, 'utf8'))
165
+ if (m !== null) name = m[1]
166
+ }
167
+ } catch { /* ignore */ }
168
+ }
169
+ if (name === null) return null
170
+ // Context from the deployed module descriptor if available.
171
+ let context = '/'
172
+ try {
173
+ const p = join(ymsHome, 'modules', name, name, 'WEB-INF', 'classes', 'iuap-yms-module', 'module.xml')
174
+ if (existsSync(p)) {
175
+ const m = /<module[^>]*\scontext="([^"]*)"/.exec(readFileSync(p, 'utf8'))
176
+ if (m !== null && m[1] !== '') context = m[1]
177
+ }
178
+ } catch { /* ignore */ }
179
+ return { name, context }
180
+ }
181
+
182
+ /** Locate a JDK: settings/env hint, JAVA_HOME, PATH, yms-home hint, Windows devkit. */
183
+ export function detectJava(cfg) {
184
+ const candidates = []
185
+ const fromEnv = cfg.envValues?.['java.path'] ?? process.env.YDS_JAVA_PATH
186
+ if (typeof fromEnv === 'string' && fromEnv !== '') candidates.push(fromEnv)
187
+ if (process.env.JAVA_HOME !== undefined) {
188
+ candidates.push(join(process.env.JAVA_HOME, 'bin', IS_WINDOWS ? 'java.exe' : 'java'))
189
+ }
190
+ const fromHome = join(cfg.ymsHome ?? '', '.javaExecutable')
191
+ if (cfg.ymsHome !== undefined && existsSync(fromHome)) {
192
+ try {
193
+ const v = readFileSync(fromHome, 'utf8').trim()
194
+ if (v !== '') candidates.push(v)
195
+ } catch { /* ignore */ }
196
+ }
197
+ if (IS_WINDOWS) candidates.push('java.exe') // PATH lookup
198
+ candidates.push('java')
199
+ for (const candidate of candidates) {
200
+ if (candidate.includes('/') || candidate.includes('\\')) {
201
+ if (existsSync(candidate)) return candidate
202
+ } else {
203
+ // PATH lookup: existsSync on the bare name is unreliable cross-OS
204
+ try {
205
+ execFileSync(candidate, ['-version'], { stdio: 'ignore', timeout: 15000 })
206
+ return candidate
207
+ } catch { /* keep trying */ }
208
+ }
209
+ }
210
+ return null
211
+ }
212
+
213
+ /**
214
+ * Resolve the full effective configuration for a workspace.
215
+ * @param {string} workspace - absolute session workspace root
216
+ * @returns the config object + a `problems` list of human-readable gaps.
217
+ */
218
+ /**
219
+ * Pick the front-end project entry that belongs to this module.
220
+ *
221
+ * `yds.webConfig` is a map keyed by front-end project (e.g. `c-ass-kk-hans-fe`);
222
+ * each value carries `yds.domainKey`, `yds.localDevUrl` (where its dev server
223
+ * listens) and the debug/build scripts. The entry whose domainKey matches the
224
+ * backend module is the one whose pages load this module's extender; failing that a
225
+ * single entry is unambiguous, and otherwise the first one wins.
226
+ * @param {object|undefined} webConfig - the raw `yds.webConfig` value.
227
+ * @param {string|null} module - the resolved backend module name.
228
+ * @returns {{code: string, domainKey: string, microFrontendType: string, port: number, devUrl: string, debugScript: string, buildScript: string, buildDistPath: string}} entry (port defaults to 3004).
229
+ */
230
+ export function pickFrontend(webConfig, module) {
231
+ const fallback = { code: '', domainKey: module ?? '', microFrontendType: '', port: DEFAULT_FE_PORT, devUrl: '', debugScript: 'debug', buildScript: 'build', buildDistPath: '' }
232
+ if (webConfig === null || typeof webConfig !== 'object') return fallback
233
+ const entries = Object.entries(webConfig)
234
+ .filter(([, value]) => value !== null && typeof value === 'object')
235
+ .map(([code, value]) => ({ code, value }))
236
+ if (entries.length === 0) return fallback
237
+ const matched = entries.find(({ value }) => module !== null && value['yds.domainKey'] === module)
238
+ ?? (entries.length === 1 ? entries[0] : entries.find(({ value }) => value['yds.domainKey'] !== undefined) ?? entries[0])
239
+ const value = matched.value
240
+ const devUrl = typeof value['yds.localDevUrl'] === 'string' ? value['yds.localDevUrl'] : ''
241
+ return {
242
+ code: matched.code,
243
+ domainKey: typeof value['yds.domainKey'] === 'string' && value['yds.domainKey'] !== '' ? value['yds.domainKey'] : (module ?? ''),
244
+ microFrontendType: typeof value['yds.microFrontendType'] === 'string' ? value['yds.microFrontendType'] : '',
245
+ port: portOfUrl(devUrl) || DEFAULT_FE_PORT,
246
+ devUrl,
247
+ debugScript: typeof value['yds.debugScript'] === 'string' && value['yds.debugScript'] !== '' ? value['yds.debugScript'] : 'debug',
248
+ buildScript: typeof value['yds.buildScript'] === 'string' && value['yds.buildScript'] !== '' ? value['yds.buildScript'] : 'build',
249
+ buildDistPath: typeof value['yds.buildDistPath'] === 'string' ? value['yds.buildDistPath'] : '',
250
+ }
251
+ }
252
+
253
+ /**
254
+ * The TCP port of a URL, or 0 when it has none / does not parse.
255
+ * @param {string} value - e.g. `http://127.0.0.1:3004`.
256
+ * @returns {number} port.
257
+ */
258
+ export function portOfUrl(value) {
259
+ // Only an explicit port counts: a dev URL without one tells us nothing about
260
+ // where the dev server listens, and 80/443 would be a wrong answer.
261
+ try {
262
+ return Number(new URL(String(value)).port) || 0
263
+ } catch {
264
+ return 0
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Remember which port the last start used.
270
+ *
271
+ * The port is chosen from "the first free one", so while an instance runs that port
272
+ * is by definition taken — without the memory every `yds status` would report a
273
+ * different port than the running JVM. Kept in the state directory next to the pid.
274
+ * @param {string} stateDir - `<workspace>/.dsh-ris-yds`.
275
+ * @returns {number|null} the remembered port, or null when there is none.
276
+ */
277
+ export function readRememberedPort(stateDir) {
278
+ try {
279
+ const value = Number(readFileSync(join(stateDir, 'yms-port'), 'utf8').trim())
280
+ return Number.isInteger(value) && value > 0 ? value : null
281
+ } catch {
282
+ return null
283
+ }
284
+ }
285
+
286
+ /** Default webpack dev-server port of a yds front-end project. */
287
+ /** Default webpack dev-server port of a yds front-end project. */
288
+ export const DEFAULT_FE_PORT = 3004
289
+
290
+ /**
291
+ * Find the front-end project by looking at what is on disk.
292
+ *
293
+ * A yds front end ships `resource_override_rules.json` naming the dev server it
294
+ * expects (`http://127.0.0.1:3004/static/…`), so the port can be read from the
295
+ * project instead of assumed. Used only when `yds.webConfig` is absent.
296
+ * @param {string} projectDir - the yds project root.
297
+ * @param {string|null} module - backend module, for the fallback domainKey.
298
+ * @returns {object} a frontend entry (see pickFrontend).
299
+ */
300
+ export function discoverFrontend(projectDir, module) {
301
+ const empty = pickFrontend(undefined, module)
302
+ let entries = []
303
+ try {
304
+ entries = readdirSync(projectDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
305
+ } catch {
306
+ return empty
307
+ }
308
+ for (const entry of entries) {
309
+ const file = join(projectDir, entry.name, 'resource_override_rules.json')
310
+ if (existsSync(file) === false) continue
311
+ let port = 0
312
+ try {
313
+ const text = readFileSync(file, 'utf8')
314
+ const found = /127\.0\.0\.1:(\d{2,5})/.exec(text)
315
+ port = found === null ? 0 : Number(found[1])
316
+ } catch {
317
+ port = 0
318
+ }
319
+ if (port <= 0) continue
320
+ return {
321
+ ...empty,
322
+ code: entry.name,
323
+ // The front-end directory name minus the module suffix is the domainKey the
324
+ // platform uses (`c-ass-kk-hans-fe` → `c-ass-kk-hans`, which is also the module).
325
+ domainKey: module ?? entry.name,
326
+ port,
327
+ devUrl: `http://127.0.0.1:${port}`,
328
+ }
329
+ }
330
+ return empty
331
+ }
332
+
333
+ /**
334
+ * First TCP port at or above `from` that nothing is listening on.
335
+ *
336
+ * Binding is the only reliable test (a pidfile or config can be stale), and it is
337
+ * released immediately — this runs before the JVM starts, never while it runs.
338
+ * @param {number} from - preferred port.
339
+ * @param {{attempts?: number}} [options] - how far to walk.
340
+ * @returns {number} the port chosen (falls back to `from` when all are taken).
341
+ */
342
+ export function findFreePort(from, options = {}) {
343
+ const attempts = options.attempts ?? 50
344
+ for (let offset = 0; offset < attempts; offset += 1) {
345
+ const candidate = from + offset
346
+ if (candidate > 65535) break
347
+ if (isPortFree(candidate)) return candidate
348
+ }
349
+ return from
350
+ }
351
+
352
+ /**
353
+ * Is a port free on the loopback interface?
354
+ * @param {number} port - the port to test.
355
+ * @returns {boolean} free when the bind succeeds.
356
+ */
357
+ export function isPortFree(port) {
358
+ // A bind attempt is the only reliable test (pidfiles and configs go stale), and it
359
+ // has to happen synchronously inside a sync config builder — so it runs in a tiny
360
+ // child process that exits 0 when the bind succeeded.
361
+ const script = `const s=require('net').createServer();s.once('error',()=>process.exit(1));s.listen(${port},'127.0.0.1',()=>s.close(()=>process.exit(0)))`
362
+ try {
363
+ execFileSync(process.execPath, ['-e', script], { timeout: 4000, stdio: 'ignore' })
364
+ return true
365
+ } catch {
366
+ return false
367
+ }
368
+ }
369
+
370
+ export function resolveConfig(workspace) {
371
+ const problems = []
372
+ const project = findProjectDir(workspace)
373
+ const settingsEnv = readSettingsEnv(workspace)
374
+ const envValues = settingsEnv.values
375
+
376
+ const projectSettings = project?.settings ?? {}
377
+ const pick = (key, fallback) => {
378
+ if (typeof projectSettings[key] === 'string' && projectSettings[key] !== '') return projectSettings[key]
379
+ return fallback
380
+ }
381
+
382
+ const ymsHome = resolve(workspace, envValues['local.ymsHome'] ?? process.env.YMS_HOME ?? (project !== null ? join(project.dir, 'yms-home') : join(workspace, 'yms-home')))
383
+
384
+ const module = project !== null ? detectModule(project.dir, ymsHome) : null
385
+ // YonCode keeps the front-end project settings in `.vscode/settings.json` under
386
+ // `yds.webConfig`: one entry per front-end project with its OWN dev URL/port and
387
+ // domainKey. That is the project's declaration of where its dev server lives, so
388
+ // the override rules read it instead of assuming 3004.
389
+ // `yds.webConfig` is written by YonCode into the project it opened; a checkout
390
+ // that never saw YonCode (the WSL copy) has no such key, so the front-end project's
391
+ // own override file is the next witness of where its dev server listens.
392
+ const frontend = projectSettings['yds.webConfig'] !== undefined
393
+ ? pickFrontend(projectSettings['yds.webConfig'], module)
394
+ : discoverFrontend(project !== null ? project.dir : workspace, module)
395
+
396
+ // SOCKS: optional by design (requirement 2). No host -> direct mode.
397
+ const socksHost = envValues['socks.host'] ?? process.env.YDS_SOCKS_HOST
398
+ const socksPortRaw = envValues['socks.port'] ?? process.env.YDS_SOCKS_PORT
399
+ const socks = socksHost !== undefined && socksHost !== ''
400
+ ? { host: socksHost, port: Number(socksPortRaw ?? 1080) || 1080 }
401
+ : null
402
+
403
+ const workbenchUrl = (pick('yds.workbenchUrl') ?? process.env.YDS_WORKBENCH_URL ?? '').replace(/\/+$/, '')
404
+ const serverBindAddress = pick('yds.serverBindAddress') ?? envValues['local.bindAddress'] ?? process.env.YDS_BIND
405
+ const serverPort = Number(pick('yds.serverPort') ?? envValues['local.serverPort'] ?? process.env.YDS_SERVER_PORT ?? 31010) || 31010
406
+ const env = pick('yds.env') ?? 'dev'
407
+ const accessKey = pick('yds.accessKey') ?? process.env.YDS_ACCESS_KEY
408
+ const accessSecret = pick('yds.accessSecret') ?? process.env.YDS_ACCESS_SECRET
409
+
410
+ // Local YMS listen port: the callback port is taken by the host (or by a Windows
411
+ // YonCode that shares it), so start two above it and walk up to the first free
412
+ // one. No configuration to keep in sync, no "port already in use" on a machine
413
+ // that also runs YonCode. An explicit YMS_PORT still wins (tests, CI).
414
+ const ymsPort = Number(process.env.YMS_PORT ?? 0) > 0
415
+ ? Number(process.env.YMS_PORT)
416
+ : (readRememberedPort(join(workspace, '.dsh-ris-yds')) ?? findFreePort(serverPort + 2))
417
+
418
+
419
+ // git (optional, requirement 2: proxy only when SOCKS configured)
420
+ const git = envValues['git.url'] !== undefined
421
+ ? {
422
+ url: envValues['git.url'],
423
+ tokenName: envValues['git.token.name'] ?? '',
424
+ tokenPwd: envValues['git.token.pwd'] ?? '',
425
+ commit: envValues['git.commit'] ?? 'never',
426
+ proxy: socks !== null ? `socks5h://${socks.host}:${socks.port}` : undefined,
427
+ }
428
+ : null
429
+
430
+ // maven: local repo + settings.xml. Auto-detect a workspace-scoped repo
431
+ // (<workspace>/.m2 — used when the machine keeps a project-local repository);
432
+ // otherwise default ~/.m2 and generate jfrog settings at build time.
433
+ const workspaceMavenSettings = join(workspace, '.m2', 'settings.xml')
434
+ const workspaceMavenRepo = join(workspace, '.m2', 'repository')
435
+ const maven = {
436
+ repo: envValues['maven.repo'] ?? process.env.YDS_MAVEN_REPO
437
+ ?? (existsSync(workspaceMavenRepo) ? workspaceMavenRepo : null),
438
+ settingsXml: envValues['maven.settings'] ?? process.env.YDS_MAVEN_SETTINGS
439
+ ?? (existsSync(workspaceMavenSettings) ? workspaceMavenSettings : null),
440
+ }
441
+
442
+ const rawModule = module?.name ?? null
443
+ // module flows into filesystem paths (logs/<module>, modules/<module>...) —
444
+ // reject anything that could traverse or escape (path traversal guard).
445
+ const moduleOk = rawModule !== null && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(rawModule)
446
+ const cfg = {
447
+ workspace,
448
+ platform: process.platform,
449
+ arch: process.arch,
450
+ isWindows: IS_WINDOWS,
451
+ isWsl: IS_WSL,
452
+ projectDir: project?.dir ?? null,
453
+ projectSettingsPath: project !== null ? join(project.dir, '.vscode', 'settings.json') : null,
454
+ settingsEnv,
455
+ envValues,
456
+ module: moduleOk ? rawModule : null,
457
+ context: moduleOk ? (module?.context ?? '/') : '/',
458
+ env,
459
+ workbenchUrl,
460
+ accessKey: accessKey ?? null,
461
+ accessSecret: accessSecret ?? null,
462
+ serverBindAddress: serverBindAddress ?? null,
463
+ serverPort,
464
+ ymsPort,
465
+ ymsHome,
466
+ socks,
467
+ dbForwardPort: Number(envValues['socks.dbPort'] ?? 1521) || 1521,
468
+ redisForwardPort: Number(envValues['socks.redisPort'] ?? 6890) || 6890,
469
+ // Which tunnel engine to use: 'auto' borrows dsh-ris-ide's engine (one owner
470
+ // for the 127.0.0.1 ports, shared with the IDE dialog); 'self' forces the
471
+ // bundled forwarder (a machine without dsh-ris-ide, or a deliberate split).
472
+ // Optional: where YonCode is installed (`yds yoncode`). Empty means "search
473
+ // the platform defaults"; when set it is authoritative.
474
+ yoncodeHome: envValues['yoncode.home'] ?? '',
475
+ proxyEngine: envValues['proxy.engine'] ?? 'auto',
476
+ proxyListenPort: Number(envValues['proxy.listenPort'] ?? 0) || 0,
477
+ // Two mutually exclusive launch modes for the YonCode bundle:
478
+ // proxy - the JVM carries the SOCKS flags and reads yms-home/config,
479
+ // i.e. the copy rewritten to 127.0.0.1 for the local forwarder.
480
+ // direct - the JVM carries no proxy flag at all; when dsh still needs the
481
+ // tunnel, it reads the untouched copy in yms-home/config-direct.
482
+ // The two modes must never share a config directory or leak flags into each
483
+ // other: a 127.0.0.1 address in a direct launch (or a stale socks flag in a
484
+ // proxy-less launch) points the JVM at a port nobody listens on.
485
+ launchMode: String(envValues['ide.launch'] ?? 'proxy').trim() === 'direct' ? 'direct' : 'proxy',
486
+ configDir: join(ymsHome, 'config'),
487
+ configDirectDir: join(ymsHome, 'config-direct'),
488
+ // ProxyBridge rules for `*java*`. Off by default because the YMS JVM gets
489
+ // `-DsocksProxyHost` from the YonCode bundle patch (`yds yoncode patch`), and
490
+ // funnelling Oracle through WinDivert breaks the TNS handshake (reset after
491
+ // CONNECT). Set `proxy.javaRules=on` when you need system-level coverage for
492
+ // java processes that have no JVM proxy flag.
493
+ proxyJavaRules: (envValues['proxy.javaRules'] ?? '') === 'on',
494
+ // Whether `yds start` should also write the scoped git proxy entry (so plain
495
+ // git / dsh-git honour SOCKS without a -c flag). 'off' leaves git alone.
496
+ gitProxy: envValues['git.proxy'] ?? 'auto',
497
+ // Front-end dev server (webpack `debug:extend`): the resource-override rules
498
+ // point at it, so the port is config, not a literal in the rule text.
499
+ // Front-end dev server, as declared by the project (see pickFrontend).
500
+ frontend,
501
+ fePort: frontend.port,
502
+ git,
503
+ maven,
504
+ stateDir: join(workspace, '.dsh-ris-yds'),
505
+ }
506
+ cfg.java = detectJava(cfg)
507
+
508
+ if (project === null) problems.push('未找到含 yds.* 键的 .vscode/settings.json(项目配置缺失或项目未检出)')
509
+ if (cfg.accessKey == null || cfg.accessSecret == null) problems.push('缺少 yds.accessKey / yds.accessSecret(settings.json)——YYCToken 与 confcenter 拉取不可用')
510
+ if (cfg.serverBindAddress == null) problems.push('缺少 yds.serverBindAddress(settings.json)——rdbga 回调地址无法确定')
511
+ if (cfg.module == null) problems.push(rawModule !== null ? `模块名 "${rawModule}" 含非法字符(仅允许字母数字._-),已拒绝` : '未能从 yonbuilder.json / module.xml 识别模块名')
512
+ if (cfg.java == null) problems.push('未找到 JDK(可用 .env 的 java.path 或 JAVA_HOME 指定)')
513
+ const launchRaw = envValues['ide.launch']
514
+ if (launchRaw !== undefined && !['proxy', 'direct'].includes(String(launchRaw).trim())) {
515
+ problems.push(`ide.launch 只能是 proxy / direct(当前 "${launchRaw}")`)
516
+ }
517
+ return { config: cfg, problems }
518
+ }
519
+
520
+ /** Human-readable dump of the effective config (secrets masked). */
521
+ export function describeConfig(cfg) {
522
+ const mask = (v) => (typeof v === 'string' && v !== '' ? `${v.slice(0, 4)}…${v.slice(-2)}(${v.length})` : '(缺失)')
523
+ // never echo user:pass@ / user:token@ embedded in a URL
524
+ const stripUrlUser = (u) => (typeof u === 'string' && u !== '' ? u.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]+@/i, '$1***@') : '(缺失)')
525
+ const lines = [
526
+ `workspace: ${cfg.workspace}`,
527
+ `platform: ${cfg.platform}-${cfg.arch}${cfg.isWsl ? ' (WSL)' : ''}${cfg.isWindows ? ' (Windows)' : ''}`,
528
+ `project: ${cfg.projectDir ?? '(未检出)'} module: ${cfg.module ?? '(未检出)'}`,
529
+ `workbenchUrl: ${cfg.workbenchUrl || '(缺失)'} env: ${cfg.env}`,
530
+ `accessKey: ${mask(cfg.accessKey)} accessSecret: ${mask(cfg.accessSecret)}`,
531
+ `serverBindAddress: ${cfg.serverBindAddress ?? '(缺失)'} serverPort: ${cfg.serverPort}`,
532
+ `ymsPort(本地监听): ${cfg.ymsPort} ymsHome: ${cfg.ymsHome}`,
533
+ `java: ${cfg.java ?? '(未找到)'}`,
534
+ `socks: ${cfg.socks !== null ? `${cfg.socks.host}:${cfg.socks.port} (db→127.0.0.1:${cfg.dbForwardPort}, redis→127.0.0.1:${cfg.redisForwardPort})` : '(未配置,直连)'}`,
535
+ `launch: ide.launch=${cfg.launchMode} config=${cfg.configDir}${cfg.socks !== null ? ` direct=${cfg.configDirectDir}` : ''}`,
536
+ `proxy engine: ${cfg.proxyEngine}${cfg.proxyListenPort > 0 ? ` listen=${cfg.proxyListenPort}` : ''} git.proxy=${cfg.gitProxy} fe.port=${cfg.fePort}(来自项目配置)`,
537
+ `.env: ${cfg.settingsEnv.exists ? cfg.settingsEnv.path : '(不存在,可选)'}`,
538
+ `git: ${cfg.git !== null ? `${stripUrlUser(cfg.git.url)} proxy=${cfg.git.proxy ?? '直连'} commit=${cfg.git.commit}` : '(未配置)'}`,
539
+ `maven: repo=${cfg.maven.repo ?? '默认 ~/.m2'} settings=${cfg.maven.settingsXml ?? '(生成 jfrog settings)'}`,
540
+ ]
541
+ return lines.join('\n')
542
+ }
543
+
544
+ /** jfrog repository base derived from the workbench URL (same host). */
545
+ export function artifactoryUrl(cfg) {
546
+ if (typeof cfg.workbenchUrl !== 'string' || cfg.workbenchUrl.trim() === '') {
547
+ throw new Error('workbenchUrl 缺失——在项目 .vscode/settings.json 配置 yds.workbenchUrl 后重试')
548
+ }
549
+ let u
550
+ try { u = new URL(cfg.workbenchUrl) } catch { throw new Error(`workbenchUrl 不是合法 URL: ${cfg.workbenchUrl}`) }
551
+ return `${u.protocol}//${u.host}/artifactory/repository/`
552
+ }