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
@@ -0,0 +1,256 @@
1
+ // Tests for the borrowed proxy engine: rule generation, discovery order, and the
2
+ // fallback decision when dsh-ris-ide is not installed.
3
+ import assert from 'node:assert/strict'
4
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import test from 'node:test'
8
+
9
+ import {
10
+ YDS_RULE_SOURCE, applyGitProxy, ensureBorrowedProxy, gitHostOf, ideProxyCandidates,
11
+ isIpv4Literal, isPrivateIpv4, loadIdeProxy, privateSegmentsOf, proxyRulesFromTargets,
12
+ shadowTargetsFromTargets,
13
+ toProcessPattern, yoncodeRules, programKeyOf, ydsRulesFor,
14
+ } from '../lib/ide-proxy.js'
15
+ import { mergeYdsRules } from '../lib/rules.js'
16
+
17
+ test('only a dotted-quad IPv4 literal counts as bindable', () => {
18
+ assert.equal(isIpv4Literal('172.20.235.80'), true)
19
+ assert.equal(isIpv4Literal('172.20.235.*'), false, 'a wildcard cannot be pre-bound')
20
+ assert.equal(isIpv4Literal('srmdev.hanslaser.com'), false)
21
+ assert.equal(isIpv4Literal(''), false)
22
+ })
23
+
24
+ test('rules name real programs on both sides: java/git and their WSL-side twins', () => {
25
+ const targets = [
26
+ { host: '172.20.235.80', port: 1521 },
27
+ { host: '172.20.235.81', port: 6890, isRedis: true },
28
+ { host: '172.20.235.81', port: 6880 },
29
+ // a second internal /24 must fold into the SAME java row
30
+ { host: '172.20.63.131', port: 31010 },
31
+ // duplicates collapse, garbage is dropped
32
+ { host: '172.20.235.81', port: 6880 },
33
+ { host: 'srmdev.hanslaser.com', port: 443 },
34
+ { host: '', port: 1 },
35
+ { host: '10.0.0.1', port: 0 },
36
+ { host: '10.0.0.1', port: 70000 },
37
+ ]
38
+ // On a WSL/Linux host the WSL side is the one that matters: `java.exe` is the Windows
39
+ // image, `java` is the process name ProxyBridge matches on the Linux side, and the
40
+ // missing local rows were why a WSL JVM was never intercepted.
41
+ const rules = proxyRulesFromTargets(targets, { gitHost: 'gitlab.hanslaser.com', java: true, platform: 'linux' })
42
+ assert.deepEqual(rules.map((rule) => `${rule.id}|${rule.side}|${rule.program}|${rule.hosts}|${rule.ports}`), [
43
+ 'yds-java|windows|java.exe|172.20.235.*,172.20.63.*|*',
44
+ 'yds-git|windows|git.exe|gitlab.hanslaser.com|*',
45
+ 'yds-java-local|local|java|172.20.235.*,172.20.63.*|*',
46
+ 'yds-git-local|local|git|gitlab.hanslaser.com|*',
47
+ ])
48
+ assert.equal(rules.every((rule) => rule.source === YDS_RULE_SOURCE), true)
49
+ assert.equal(rules.every((rule) => rule.program !== 'jdbc'), true, 'jdbc is not a process name')
50
+ assert.equal(programKeyOf(rules[0].program), 'java')
51
+ assert.equal(programKeyOf(rules[2].program), 'java', 'the local row names the same program on its side')
52
+ assert.equal(rules.every((rule) => rule.shadow === false), true, 'the table holds intent only; no mapping rows')
53
+ assert.equal(rules.every((rule) => rule.workspace === undefined), true, 'the table is global: workspace is not part of a row')
54
+ // Windows has no Linux side: only the `.exe` rows are generated there.
55
+ const windows = proxyRulesFromTargets(targets, { gitHost: 'gitlab.hanslaser.com', java: true, platform: 'win32' })
56
+ assert.deepEqual(windows.map((rule) => rule.id), ['yds-java', 'yds-git'])
57
+ // Without a git URL there is no git rule to write (on either side).
58
+ const noGit = proxyRulesFromTargets([{ host: '172.20.235.80', port: 1521 }], { gitHost: '', java: true, platform: 'linux' })
59
+ assert.equal(noGit.some((rule) => rule.program === 'git'), false)
60
+ })
61
+
62
+ test('the no-root fallback is a separate listener list, not rules', () => {
63
+ const targets = [
64
+ { host: '172.20.235.80', port: 1521 },
65
+ { host: '172.20.235.81', port: 6890 },
66
+ { host: '172.20.235.81', port: 6890 },
67
+ { host: 'srmdev.hanslaser.com', port: 443 },
68
+ { host: '10.0.0.1', port: 70000 },
69
+ ]
70
+ assert.deepEqual(shadowTargetsFromTargets(targets), [
71
+ { host: '172.20.235.80', port: 1521 },
72
+ { host: '172.20.235.81', port: 6890 },
73
+ ], 'one listener per literal IPv4 endpoint, duplicates collapsed, hostnames skipped')
74
+ assert.deepEqual(shadowTargetsFromTargets([null, undefined, {}]), [])
75
+ })
76
+
77
+
78
+ test('the git host comes from the clone URL, never from a guess', () => {
79
+ assert.equal(gitHostOf({ git: { url: 'https://gitlab.hanslaser.com/hans-dev/hans-asset/yonasset.git' } }), 'gitlab.hanslaser.com')
80
+ assert.equal(gitHostOf({ git: { url: 'not a url' } }), '')
81
+ assert.equal(gitHostOf({ git: null }), '')
82
+ })
83
+
84
+ test('candidate locations cover the profile link, a workspace install and a sibling checkout', () => {
85
+ const list = ideProxyCandidates({ workspace: '/w' })
86
+ assert.ok(list.some((path) => path === '/w/node_modules/dsh-ris-ide/lib/proxy.js'))
87
+ assert.ok(list.some((path) => path === '/w/work/dsh-ris-ide/lib/proxy.js'))
88
+ // join() normalizes, so the sibling checkout resolves next to the workspace.
89
+ assert.ok(list.some((path) => path === '/dsh-ris-ide/lib/proxy.js'))
90
+ assert.ok(list.some((path) => path.endsWith('.dsh/profiles/web/node_modules/dsh-ris-ide/lib/proxy.js')))
91
+ })
92
+
93
+ test('a machine without dsh-ris-ide reports unavailable instead of throwing', async () => {
94
+ const dir = mkdtempSync(join(tmpdir(), 'yds-proxy-'))
95
+ const previous = process.env.YDS_IDE_PROXY
96
+ try {
97
+ // The documented off switch: no search at all, so the machine's real install
98
+ // cannot leak into the test.
99
+ process.env.YDS_IDE_PROXY = 'none'
100
+ const loaded = await loadIdeProxy({ workspace: dir })
101
+ assert.equal(loaded.available, false)
102
+ assert.match(loaded.error, /YDS_IDE_PROXY=none/)
103
+ } finally {
104
+ if (previous === undefined) delete process.env.YDS_IDE_PROXY
105
+ else process.env.YDS_IDE_PROXY = previous
106
+ rmSync(dir, { recursive: true, force: true })
107
+ }
108
+ })
109
+
110
+ test('direct mode never touches the proxy engine', async () => {
111
+ const dir = mkdtempSync(join(tmpdir(), 'yds-proxy-'))
112
+ try {
113
+ const result = await ensureBorrowedProxy({ workspace: dir, socks: null, proxyEngine: 'auto' }, [{ host: '10.0.0.1', port: 1521 }])
114
+ assert.equal(result.mode, 'none')
115
+ // …and the git helper refuses politely instead of writing a broken entry.
116
+ const git = await applyGitProxy({ workspace: dir, socks: null }, { enabled: true })
117
+ assert.equal(git.ok, false)
118
+ } finally {
119
+ rmSync(dir, { recursive: true, force: true })
120
+ }
121
+ })
122
+
123
+ test('proxy.engine=self disables the borrow even when the engine is present', async () => {
124
+ const dir = mkdtempSync(join(tmpdir(), 'yds-proxy-'))
125
+ try {
126
+ const result = await ensureBorrowedProxy(
127
+ { workspace: dir, socks: { host: '10.3.91.121', port: 1080 }, proxyEngine: 'self' },
128
+ [{ host: '172.20.235.80', port: 1521 }],
129
+ )
130
+ assert.equal(result.mode, 'unavailable')
131
+ assert.match(result.error, /proxy\.engine=self/)
132
+ } finally {
133
+ rmSync(dir, { recursive: true, force: true })
134
+ }
135
+ })
136
+
137
+ test('a broken engine module is reported, not fatal', async () => {
138
+ const dir = mkdtempSync(join(tmpdir(), 'yds-proxy-'))
139
+ const previous = process.env.YDS_IDE_PROXY
140
+ try {
141
+ mkdirSync(join(dir, 'lib'), { recursive: true })
142
+ writeFileSync(join(dir, 'lib', 'proxy.js'), 'export default {}\n')
143
+ process.env.YDS_IDE_PROXY = join(dir, 'lib', 'proxy.js')
144
+ const loaded = await loadIdeProxy({ workspace: dir })
145
+ assert.equal(loaded.available, false)
146
+ assert.match(loaded.error, /不完整/)
147
+ } finally {
148
+ if (previous === undefined) delete process.env.YDS_IDE_PROXY
149
+ else process.env.YDS_IDE_PROXY = previous
150
+ rmSync(dir, { recursive: true, force: true })
151
+ }
152
+ })
153
+
154
+ test('YonCode own executables get their own labelled rules', async () => {
155
+ const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs')
156
+ const { tmpdir } = await import('node:os')
157
+ const home = mkdtempSync(join(tmpdir(), 'yds-yoncode-'))
158
+ mkdirSync(join(home, 'devkit', 'jdk21.0.7', 'bin'), { recursive: true })
159
+ writeFileSync(join(home, 'devkit', 'jdk21.0.7', 'bin', 'java.exe'), '')
160
+ const gitDir = mkdtempSync(join(tmpdir(), 'yds-git-'))
161
+ const gitExe = join(gitDir, 'git.exe')
162
+ writeFileSync(gitExe, '')
163
+ // A POSIX home keeps the pattern POSIX; /mnt/<drive>/ paths are rewritten to a
164
+ // drive letter, which is what ProxyBridge on Windows compares against.
165
+ const rules = yoncodeRules({ workspace: '/w' }, {
166
+ homes: [home],
167
+ gitPaths: [gitExe],
168
+ gitHost: 'gitlab.example.com',
169
+ hosts: ['172.20.235.*', '172.20.63.*'],
170
+ })
171
+ assert.deepEqual(rules.map((rule) => rule.id), ['yds-yoncode-java', 'yds-yoncode-git'])
172
+ assert.deepEqual(rules.map((rule) => rule.label), ['YonCode java', 'YonCode git'])
173
+ // The image name, not a file under the devkit: a JDK upgrade keeps working and
174
+ // every java.exe on the machine is covered.
175
+ assert.equal(rules[0].program, 'java.exe')
176
+ // The range comes from the endpoints we resolved, never from a hardcoded vendor range.
177
+ assert.equal(rules[0].hosts, '172.20.235.*,172.20.63.*')
178
+ assert.equal(rules[0].ports, '*')
179
+ assert.equal(rules[0].shadow, false)
180
+ assert.equal(rules[1].program, 'git.exe')
181
+ assert.equal(rules[1].hosts, 'gitlab.example.com')
182
+ assert.equal(rules.every((rule) => rule.source === 'yds'), true)
183
+ // No YonCode install and no git: no rows invented.
184
+ assert.deepEqual(yoncodeRules({}, { homes: [home], gitPaths: [], gitHost: 'x', hosts: ['10.0.0.*'] }).map((r) => r.id), ['yds-yoncode-java'])
185
+ // No resolved endpoint range = no java row invented (a row with an empty host list
186
+ // would match nothing, and a guessed range would redirect the wrong traffic).
187
+ assert.deepEqual(yoncodeRules({}, { homes: [home], gitPaths: [], gitHost: 'x' }), [])
188
+ assert.deepEqual(yoncodeRules({}, { homes: [], gitPaths: [], gitHost: 'x' }), [])
189
+ })
190
+
191
+ test('executable rows inherit the private ranges of the resolved endpoints', () => {
192
+ const segments = privateSegmentsOf(
193
+ [{ host: '172.20.235.80', port: 1521 }, { host: '172.20.235.81', port: 6890 }, { host: 'gitlab.example.com', port: 443 }],
194
+ ['172.20.63.131'],
195
+ )
196
+ assert.deepEqual(segments, ['172.20.235.*', '172.20.63.*'])
197
+ assert.deepEqual(privateSegmentsOf([], []), [])
198
+ })
199
+
200
+ test('the yds rows are a whole-table write: the global set replaces the old yds rows', () => {
201
+ // The table is one global list. A save carries the complete table: the user's rows
202
+ // pass through, the previous yds rows are replaced by this run's set (never unioned
203
+ // with another project's, because there is no other project's share any more).
204
+ const merged = mergeYdsRules(
205
+ [
206
+ { id: 'user-1', program: 'navicat.exe', hosts: '10.9.9.*', source: 'user' },
207
+ { id: 'yds-java-old', program: 'java.exe', hosts: '172.20.235.*', ports: '*', enabled: true, source: 'yds', workspace: '/ws-a' },
208
+ { id: 'yds-git-old', program: 'git.exe', hosts: 'gitlab.example.com', ports: '*', enabled: true, source: 'yds', workspace: '/ws-a' },
209
+ ],
210
+ [
211
+ { id: 'yds-java', program: 'java.exe', side: 'windows', hosts: '172.20.235.*,172.20.63.*', ports: '*', enabled: true, source: 'yds' },
212
+ { id: 'yds-java-local', program: 'java', side: 'local', hosts: '172.20.235.*,172.20.63.*', ports: '*', enabled: true, source: 'yds' },
213
+ ],
214
+ )
215
+ assert.deepEqual(merged.map((row) => row.id), ['user-1', 'yds-java', 'yds-java-local'])
216
+ assert.equal(merged.filter((row) => row.source === 'yds').length, 2, 'one row per side, not per workspace')
217
+ assert.equal(merged[1].hosts, '172.20.235.*,172.20.63.*')
218
+ assert.equal(merged.every((row) => row.workspace === undefined || row.source !== 'yds'), true, 'no workspace column on global rows')
219
+ })
220
+
221
+ test('the rules this project writes are the full side-aware set', () => {
222
+ const rows = ydsRulesFor(
223
+ { workspace: '/w', proxyJavaRules: true, git: { url: 'https://gitlab.hanslaser.com/x/y.git' } },
224
+ [{ host: '172.20.235.80', port: 1521 }],
225
+ { platform: 'linux' },
226
+ )
227
+ assert.deepEqual(rows.map((row) => row.id), ['yds-java', 'yds-git', 'yds-java-local', 'yds-git-local'])
228
+ assert.deepEqual(rows.map((row) => row.side), ['windows', 'windows', 'local', 'local'])
229
+ // A second start from another workspace produces the same shape: the last save wins.
230
+ const again = ydsRulesFor(
231
+ { workspace: '/other', proxyJavaRules: true, git: { url: 'https://gitlab.hanslaser.com/x/y.git' } },
232
+ [{ host: '172.20.63.131', port: 1521 }],
233
+ { platform: 'linux' },
234
+ )
235
+ assert.equal(again.find((row) => row.id === 'yds-java').hosts, '172.20.63.*', 'the fresh run owns the row')
236
+ })
237
+
238
+ test('a mounted YonCode path becomes a drive-letter pattern', () => {
239
+ assert.equal(toProcessPattern('/mnt/d/Program Files/YonCode/devkit/x/bin/java.exe'), 'D:\\Program Files\\YonCode\\devkit\\x\\bin\\java.exe')
240
+ assert.equal(toProcessPattern('C:\\Program Files\\YonCode\\x\\java.exe'), 'C:\\Program Files\\YonCode\\x\\java.exe')
241
+ assert.equal(toProcessPattern('/opt/YonCode/devkit/x/bin/java'), '/opt/YonCode/devkit/x/bin/java')
242
+ })
243
+
244
+ test('duplicates collapse only within one side+program, never across programs', () => {
245
+ const rows = mergeYdsRules([], [
246
+ { id: 'a', program: 'java.exe', side: 'windows', hosts: '10.0.0.*' },
247
+ { id: 'b', program: 'C:\\Program Files\\YonCode\\devkit\\jdk21\\bin\\java.exe', side: 'windows', hosts: '10.0.0.*' },
248
+ { id: 'c', program: 'git.exe', side: 'windows', hosts: 'gitlab.example.com' },
249
+ { id: 'd', program: 'git', side: 'local', hosts: 'gitlab.example.com' },
250
+ { id: 'e', program: '' },
251
+ ])
252
+ assert.deepEqual(rows.map((row) => row.id), ['a', 'c', 'd'])
253
+ assert.equal(rows[0].hosts, '10.0.0.*', 'the duplicate spelling contributes no new host')
254
+ assert.equal(programKeyOf('C:\\Program Files\\Git\\bin\\git.exe'), 'git')
255
+ assert.equal(programKeyOf('java'), 'java')
256
+ })
@@ -0,0 +1,79 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
4
+ import { readFileSync as readFile } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { listWatched, reconcileOnce, registerWorkspace, unregisterWorkspace, watchWorkspace } from '../lib/ide-watch.js'
8
+
9
+ function sandbox() {
10
+ return mkdtempSync(join(tmpdir(), 'yds-watch-'))
11
+ }
12
+
13
+ test('registration survives in the state file and remove takes it out', () => {
14
+ const home = sandbox()
15
+ const ws = sandbox()
16
+ assert.deepEqual(listWatched(home), [])
17
+ const added = registerWorkspace(ws, home)
18
+ assert.equal(added.ok, true)
19
+ assert.deepEqual(added.workspaces, [ws])
20
+ assert.deepEqual(listWatched(home), [ws])
21
+ registerWorkspace(ws, home)
22
+ assert.equal(listWatched(home).length, 1, 'registering twice keeps one entry')
23
+ const removed = unregisterWorkspace(ws, home)
24
+ assert.deepEqual(removed.workspaces, [])
25
+ assert.equal(existsSync(join(home, '.dsh-ris-yds', 'ide-watch.json')), false, 'last removal drops the file')
26
+ rmSync(home, { recursive: true, force: true })
27
+ rmSync(ws, { recursive: true, force: true })
28
+ })
29
+
30
+ test('nothing happens without a config file or without a repair step', async () => {
31
+ const ws = sandbox()
32
+ let calls = 0
33
+ const repair = async () => { calls += 1; return { changed: true } }
34
+ assert.deepEqual(await reconcileOnce(ws, { repair }), { changed: false, reason: 'no-config' })
35
+ mkdirSync(join(ws, '.dsh-ris-ide'), { recursive: true })
36
+ writeFileSync(join(ws, '.dsh-ris-ide', 'config.json'), '{"programs":[]}')
37
+ assert.deepEqual(await reconcileOnce(ws, {}), { changed: false, reason: 'no-repair' })
38
+ assert.equal(calls, 0)
39
+ rmSync(ws, { recursive: true, force: true })
40
+ })
41
+
42
+ test('a restore that writes nothing is reported as a no-op', async () => {
43
+ const ws = sandbox()
44
+ mkdirSync(join(ws, '.dsh-ris-ide'), { recursive: true })
45
+ writeFileSync(join(ws, '.dsh-ris-ide', 'config.json'), '{"programs":[]}')
46
+ const result = await reconcileOnce(ws, { repair: async () => ({ changed: true, reason: 'unchanged' }) })
47
+ assert.deepEqual(result, { changed: false, reason: 'no-op' })
48
+ rmSync(ws, { recursive: true, force: true })
49
+ })
50
+
51
+ test('a rewrite by ris-ide triggers the repair and the row comes back', async () => {
52
+ const ws = sandbox()
53
+ mkdirSync(join(ws, '.dsh-ris-ide'), { recursive: true })
54
+ const configPath = join(ws, '.dsh-ris-ide', 'config.json')
55
+ writeFileSync(configPath, '{"programs":[]}')
56
+ // minimal stand-in for applyIdeConfig: put our row back
57
+ const repair = async () => {
58
+ const doc = JSON.parse(readFileSync(configPath, 'utf8'))
59
+ doc.programs = [{ id: 'yds:demo', name: 'demo', kind: 'spring' }]
60
+ writeFileSync(configPath, JSON.stringify(doc))
61
+ return { changed: true, reason: 'added' }
62
+ }
63
+ const stop = watchWorkspace(ws, { repair })
64
+ try {
65
+ await new Promise((resolve) => setTimeout(resolve, 200))
66
+ // ris-ide regenerates the file without our row
67
+ writeFileSync(configPath, '{"programs":[{"id":"frontend:x#start","name":"x","kind":"frontend"}]}')
68
+ for (let i = 0; i < 60; i += 1) {
69
+ await new Promise((resolve) => setTimeout(resolve, 100))
70
+ const rows = JSON.parse(readFileSync(configPath, 'utf8')).programs
71
+ if (rows.some((row) => row.id === 'yds:demo')) break
72
+ }
73
+ const rows = JSON.parse(readFile(configPath, 'utf8')).programs
74
+ assert.ok(rows.some((row) => row.id === 'yds:demo'), 'the launcher row is back')
75
+ } finally {
76
+ stop()
77
+ rmSync(ws, { recursive: true, force: true })
78
+ }
79
+ })
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The tunnel flags we add to .vscode/launch.json: written only while a tunnel
3
+ * exists, and taken back out when the proxy stops.
4
+ */
5
+ import assert from 'node:assert/strict'
6
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { test } from 'node:test'
10
+
11
+ import { LAUNCH_CONFIG, LAUNCH_ENTRY_NAME, applyLaunchConfig, ourLaunchEntry, proxyVmArgs, readLaunchConfig, stripLaunchProxy } from '../lib/ide.js'
12
+
13
+ /** A project dir with a user-owned launch.json that must survive untouched. */
14
+ function project(withEntry = false) {
15
+ const dir = mkdtempSync(join(tmpdir(), 'yds-launch-'))
16
+ mkdirSync(join(dir, '.vscode'), { recursive: true })
17
+ const doc = {
18
+ version: '0.2.0',
19
+ configurations: [{ type: 'java', name: 'MDFApplication', mainClass: 'x.Y', vmArgs: ['-Xmx512m'] }],
20
+ }
21
+ if (withEntry) {
22
+ doc.configurations.push({ type: 'java', name: LAUNCH_ENTRY_NAME, vmArgs: ['-Dyms.dev=true'] })
23
+ }
24
+ writeFileSync(join(dir, LAUNCH_CONFIG), `${JSON.stringify(doc, null, 2)}\n`)
25
+ return dir
26
+ }
27
+
28
+ const cfg = (dir, socks) => ({ projectDir: dir, workspace: dir, socks })
29
+
30
+ test('proxy flags are derived from .env SOCKS and absent without one', () => {
31
+ assert.deepEqual(proxyVmArgs({ socks: { host: '10.3.91.121', port: 1080 } }), [
32
+ '-DsocksProxyHost=10.3.91.121',
33
+ '-DsocksProxyPort=1080',
34
+ '-Djava.net.useSystemProxies=false',
35
+ ])
36
+ assert.deepEqual(proxyVmArgs({ socks: null }), [], 'no tunnel, no flags')
37
+ assert.deepEqual(proxyVmArgs({}), [])
38
+ })
39
+
40
+ test('apply writes the flags for our entry and leaves other configurations alone', () => {
41
+ const dir = project()
42
+ try {
43
+ const result = applyLaunchConfig(cfg(dir, { host: '10.3.91.121', port: 1080 }))
44
+ assert.equal(result.changed, true)
45
+ const doc = readLaunchConfig(dir).parsed
46
+ const ours = doc.configurations.find((entry) => entry.name === LAUNCH_ENTRY_NAME)
47
+ assert.ok(ours.vmArgs.includes('-DsocksProxyHost=10.3.91.121'))
48
+ const user = doc.configurations.find((entry) => entry.name === 'MDFApplication')
49
+ assert.deepEqual(user.vmArgs, ['-Xmx512m'], 'the user configuration must be byte-identical')
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true })
52
+ }
53
+ })
54
+
55
+ test('stripping removes exactly the proxy flags and is a no-op afterwards', () => {
56
+ const dir = project()
57
+ try {
58
+ applyLaunchConfig(cfg(dir, { host: '10.3.91.121', port: 1080 }))
59
+ const first = stripLaunchProxy(cfg(dir, null))
60
+ assert.equal(first.changed, true)
61
+ assert.equal(first.action, 'stripped')
62
+ const ours = readLaunchConfig(dir).parsed.configurations.find((entry) => entry.name === LAUNCH_ENTRY_NAME)
63
+ assert.equal(ours.vmArgs.filter((value) => /socksProxy|useSystemProxies/.test(value)).length, 0)
64
+ assert.deepEqual(ours.vmArgs, ourLaunchEntry({}).vmArgs, 'the entry is back to its canonical (proxy-free) form')
65
+ assert.equal(stripLaunchProxy(cfg(dir, null)).changed, false, 'nothing left to strip')
66
+ // The user's configuration is still untouched by either side of the cycle.
67
+ assert.deepEqual(readLaunchConfig(dir).parsed.configurations.find((entry) => entry.name === 'MDFApplication').vmArgs, ['-Xmx512m'])
68
+ } finally {
69
+ rmSync(dir, { recursive: true, force: true })
70
+ }
71
+ })
72
+
73
+ test('stripping a file without our entry does nothing', () => {
74
+ const dir = project()
75
+ try {
76
+ const result = stripLaunchProxy(cfg(dir, null))
77
+ assert.equal(result.changed, false)
78
+ assert.equal(result.action, 'not-present')
79
+ assert.equal(readFileSync(join(dir, LAUNCH_CONFIG), 'utf8').includes(LAUNCH_ENTRY_NAME), false)
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true })
82
+ }
83
+ })
@@ -0,0 +1,76 @@
1
+ // A failed lease release must keep the local record: forgetting it turned a
2
+ // transient daemon failure into "nobody owns the engine", resolved only by the
3
+ // daemon's pid sweep (review D-P2-6).
4
+ import assert from 'node:assert/strict'
5
+ import { createServer } from 'node:http'
6
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import test from 'node:test'
10
+
11
+ const home = mkdtempSync(join(tmpdir(), 'yds-lease-'))
12
+ const WS = mkdtempSync(join(tmpdir(), 'yds-lease-ws-'))
13
+ let mode = 'ok'
14
+ let server = null
15
+ let port = 0
16
+
17
+ test.before(async () => {
18
+ server = createServer((req, res) => {
19
+ const send = (status, payload) => {
20
+ res.writeHead(status, { 'content-type': 'application/json' })
21
+ res.end(`${JSON.stringify(payload)}\n`)
22
+ }
23
+ if (req.method === 'GET' && req.url === '/v1/health') {
24
+ return send(200, { ok: true, apiVersion: 1, version: 'test' })
25
+ }
26
+ if (req.method === 'POST' && req.url === '/v1/leases') {
27
+ return send(200, { ok: true, lease: { id: 'lease-1' } })
28
+ }
29
+ if (req.method === 'DELETE' && req.url.startsWith('/v1/leases/')) {
30
+ if (mode === 'fail') return send(500, { ok: false, error: 'daemon broke' })
31
+ return send(200, { ok: true, released: true })
32
+ }
33
+ return send(404, { ok: false })
34
+ })
35
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
36
+ port = server.address().port
37
+ writeFileSync(join(home, 'risd.json'), `${JSON.stringify({
38
+ apiVersion: 1, version: 'test', pid: process.pid, port, token: 'test-token',
39
+ })}\n`, { mode: 0o600 })
40
+ process.env.DSH_RIS_IDE_PROXY_HOME = home
41
+ process.env.YDS_IDE_PROXY = 'none'
42
+ })
43
+
44
+ test.after(() => {
45
+ server?.close()
46
+ rmSync(home, { recursive: true, force: true })
47
+ rmSync(join(home, '..', '.dsh-ris-yds'), { recursive: true, force: true })
48
+ rmSync(WS, { recursive: true, force: true })
49
+ })
50
+
51
+ function cfg() {
52
+ return { workspace: WS, socks: { host: '1.2.3.4', port: 1080 }, git: null, proxyJavaRules: true }
53
+ }
54
+
55
+ test('a failed release keeps the lease record so a retry can finish the job', async () => {
56
+ const { holdBorrowedLease, releaseBorrowedLease } = await import('../lib/ide-proxy.js')
57
+ const { readLease } = await import('../lib/risd-peer.js')
58
+ const held = await holdBorrowedLease(cfg(), { ownerPid: process.pid })
59
+ assert.equal(held.ok, true)
60
+ assert.equal(readLease(WS)?.id, 'lease-1')
61
+
62
+ mode = 'fail'
63
+ const failed = await releaseBorrowedLease(cfg())
64
+ assert.equal(failed.ok, false)
65
+ assert.match(failed.reason, /daemon broke|释放租约失败/)
66
+ assert.equal(readLease(WS)?.id, 'lease-1', 'the record survives a failed release')
67
+
68
+ mode = 'ok'
69
+ const retried = await releaseBorrowedLease(cfg())
70
+ assert.equal(retried.ok, true)
71
+ assert.equal(retried.released, true)
72
+ assert.equal(readLease(WS), null, 'a successful release forgets the record')
73
+
74
+ const leases = JSON.parse(readFileSync(join(home, '..', '.dsh-ris-yds', 'risd-leases.json'), 'utf8'))
75
+ assert.equal(leases[WS], undefined)
76
+ })
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The two launch modes must never be mixed:
3
+ * - proxy : YonCode's JVM carries -DsocksProxyHost/Port and reads
4
+ * yms-home/config (rewritten to 127.0.0.1).
5
+ * - direct : no proxy flag at all; when dsh still tunnels, YonCode reads the
6
+ * untouched copy in yms-home/config-direct.
7
+ * These tests pin the boundaries: flags, config dirs, mode switching, and the
8
+ * default derived from .env `ide.launch`.
9
+ */
10
+ import assert from 'node:assert/strict'
11
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { test } from 'node:test'
15
+
16
+ import { adaptAndWriteConfig } from '../lib/confcenter.js'
17
+ import { resolveConfig } from '../lib/config.js'
18
+ import { LAUNCH_ENTRY_NAME, applyLaunchConfig, launchModeVmArgs, ourLaunchEntry, readLaunchConfig } from '../lib/ide.js'
19
+
20
+ const SOCKS = { host: '10.3.91.121', port: 1080 }
21
+
22
+ function workspace(env = '') {
23
+ const dir = mkdtempSync(join(tmpdir(), 'yds-modes-'))
24
+ if (env !== '') writeFileSync(join(dir, '.env'), env, 'utf8')
25
+ return dir
26
+ }
27
+
28
+ function launchProject() {
29
+ const dir = mkdtempSync(join(tmpdir(), 'yds-launch-mode-'))
30
+ mkdirSync(join(dir, '.vscode'), { recursive: true })
31
+ writeFileSync(join(dir, '.vscode', 'launch.json'), `${JSON.stringify({ version: '0.2.0', configurations: [] }, null, 2)}\n`)
32
+ return dir
33
+ }
34
+
35
+ const entryVmArgs = (dir) => readLaunchConfig(dir).parsed.configurations.find((c) => c.name === LAUNCH_ENTRY_NAME).vmArgs
36
+
37
+ test('proxy mode: socks flags, no local_conf_path', () => {
38
+ const flags = launchModeVmArgs({ launchMode: 'proxy', socks: SOCKS })
39
+ assert.deepEqual(flags, [
40
+ '-DsocksProxyHost=10.3.91.121',
41
+ '-DsocksProxyPort=1080',
42
+ '-Djava.net.useSystemProxies=false',
43
+ ])
44
+ const entry = ourLaunchEntry({ launchMode: 'proxy', socks: SOCKS })
45
+ assert.ok(entry.vmArgs.includes('-DsocksProxyHost=10.3.91.121'))
46
+ assert.ok(!entry.vmArgs.some((v) => String(v).startsWith('-Dlocal_conf_path=')), 'proxy mode must not read config-direct')
47
+ })
48
+
49
+ test('direct mode on a tunneled machine: local_conf_path only, never socks flags', () => {
50
+ const flags = launchModeVmArgs({ launchMode: 'direct', socks: SOCKS })
51
+ assert.equal(flags.length, 2)
52
+ assert.ok(flags[0].startsWith('-Dlocal_conf_path=') && flags[0].endsWith('/config-direct/'), `unexpected: ${flags[0]}`)
53
+ assert.equal(flags[1], '-Dyms_use_local_conf=true')
54
+ const entry = ourLaunchEntry({ launchMode: 'direct', socks: SOCKS })
55
+ assert.ok(!entry.vmArgs.some((v) => /^-DsocksProxy/.test(String(v))), 'direct mode must never carry proxy flags')
56
+ assert.ok(!entry.vmArgs.includes('-Djava.net.useSystemProxies=false'))
57
+ })
58
+
59
+ test('direct mode without SOCKS needs no override at all', () => {
60
+ assert.deepEqual(launchModeVmArgs({ launchMode: 'direct', socks: null }), [])
61
+ assert.deepEqual(launchModeVmArgs({ launchMode: 'proxy', socks: null }), [])
62
+ })
63
+
64
+ test('switching modes rewrites the entry with no residue from the other mode', () => {
65
+ const dir = launchProject()
66
+ try {
67
+ const proxyCfg = { projectDir: dir, workspace: dir, launchMode: 'proxy', socks: SOCKS }
68
+ applyLaunchConfig(proxyCfg)
69
+ assert.ok(entryVmArgs(dir).includes('-DsocksProxyHost=10.3.91.121'))
70
+
71
+ const directCfg = { ...proxyCfg, launchMode: 'direct' }
72
+ applyLaunchConfig(directCfg)
73
+ const direct = entryVmArgs(dir)
74
+ assert.ok(!direct.some((v) => /^-DsocksProxy/.test(String(v))))
75
+ assert.ok(direct.some((v) => String(v).startsWith('-Dlocal_conf_path=')))
76
+
77
+ applyLaunchConfig(proxyCfg)
78
+ const back = entryVmArgs(dir)
79
+ assert.ok(back.includes('-DsocksProxyHost=10.3.91.121'))
80
+ assert.ok(!back.some((v) => String(v).startsWith('-Dlocal_conf_path=')), 'stale direct override after switching back')
81
+ } finally {
82
+ rmSync(dir, { recursive: true, force: true })
83
+ }
84
+ })
85
+
86
+ test('config-direct keeps the real endpoints while config/ stays adapted', () => {
87
+ const dir = workspace()
88
+ try {
89
+ const cfg = {
90
+ workspace: dir,
91
+ stateDir: join(dir, '.dsh-ris-yds'),
92
+ ymsHome: join(dir, 'yms-home'),
93
+ configDir: join(dir, 'yms-home', 'config'),
94
+ configDirectDir: join(dir, 'yms-home', 'config-direct'),
95
+ ymsPort: 8080,
96
+ socks: SOCKS,
97
+ dbForwardPort: 1521,
98
+ redisForwardPort: 6890,
99
+ module: 'c-ass-kk-hans',
100
+ context: '/',
101
+ serverBindAddress: '172.20.63.131',
102
+ serverPort: 31010,
103
+ }
104
+ mkdirSync(cfg.stateDir, { recursive: true })
105
+ const files = {
106
+ 'yms_middleware.json': JSON.stringify({
107
+ connectionPools: { pool1: { url: 'jdbc:oracle:thin:@//10.3.91.100:31000/ris' } },
108
+ 'redis.pools': { redis1: { host: '172.20.235.81', port: 6890 } },
109
+ }),
110
+ 'module_config.json': '{"module":true}',
111
+ 'yonbip_config.json': '{"platform":true}',
112
+ }
113
+
114
+ adaptAndWriteConfig(cfg, files)
115
+ adaptAndWriteConfig(cfg, files, { rewrite: false, dir: cfg.configDirectDir })
116
+
117
+ const adapted = readFileSync(join(cfg.configDir, 'yms_middleware.json'), 'utf8')
118
+ const untouched = readFileSync(join(cfg.configDirectDir, 'yms_middleware.json'), 'utf8')
119
+ assert.ok(adapted.includes('127.0.0.1:1521'), 'proxy config must point at the local forwarder')
120
+ assert.ok(adapted.includes('127.0.0.1'), 'proxy config must not keep the real DB host')
121
+ assert.ok(!adapted.includes('10.3.91.100:31000'))
122
+ assert.ok(untouched.includes('10.3.91.100:31000'), 'direct config must keep the real DB host')
123
+ assert.ok(!untouched.includes('127.0.0.1:1521'))
124
+ // the two writes did not leak into each other's directory
125
+ assert.ok(readFileSync(join(cfg.configDir, 'yms_middleware.json'), 'utf8').includes('127.0.0.1:1521'))
126
+ } finally {
127
+ rmSync(dir, { recursive: true, force: true })
128
+ }
129
+ })
130
+
131
+ test('ide.launch selects the mode; an invalid value is reported and falls back to proxy', () => {
132
+ const direct = workspace('ide.launch=direct\n')
133
+ const bogus = workspace('ide.launch=both\n')
134
+ try {
135
+ const d = resolveConfig(direct)
136
+ assert.equal(d.config.launchMode, 'direct')
137
+ assert.ok(d.config.configDirectDir.endsWith('config-direct'))
138
+ assert.notEqual(d.config.configDir, d.config.configDirectDir)
139
+
140
+ const b = resolveConfig(bogus)
141
+ assert.equal(b.config.launchMode, 'proxy', 'unknown values must not become direct silently')
142
+ assert.ok(b.problems.some((p) => p.includes('ide.launch')), `missing problem: ${b.problems.join(' | ')}`)
143
+ } finally {
144
+ rmSync(direct, { recursive: true, force: true })
145
+ rmSync(bogus, { recursive: true, force: true })
146
+ }
147
+ })