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,353 @@
1
+ // Talk to the dsh-ris-ide daemon (`risd`) over its HTTP API.
2
+ //
3
+ // This is the preferred tier: when risd owns the engine, its rule table and the
4
+ // system-level intercept, yds must not start or stop anything — it only (a) takes a
5
+ // lease so the daemon does not tear the tunnel down while a launch is in flight,
6
+ // (b) writes its `source: 'yds'` rules through the daemon's CAS, and (c) releases
7
+ // the lease on `yds stop` (the engine stays up: it is machine-wide and an IDE may
8
+ // still be using it).
9
+ //
10
+ // The daemon's state file (`risd.json`) carries a short-lived token; `GET /v1/health`
11
+ // is unauthenticated and cheap, so discovery is always "read state + probe health".
12
+ import { mergeYdsRules, withdrawnBetween, ydsRowsOf } from './rules.js'
13
+ import {
14
+ closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
15
+ } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { dirname, join } from 'node:path'
18
+
19
+ /** API version this peer understands. Mirrors the daemon's `RISD_API_VERSION`. */
20
+ export const RISD_API_VERSION = 1
21
+ /** Header carrying the API version; a mismatch is refused (409) rather than guessed. */
22
+ const API_VERSION_HEADER = 'x-risd-api-version'
23
+ const REQUEST_TIMEOUT_MS = 2000
24
+ const PROBE_TIMEOUT_MS = 700
25
+
26
+ /** `YDS_RISD=none` is the documented escape hatch (tests, broken install). */
27
+ export function risdDisabled() {
28
+ return process.env.YDS_RISD === 'none'
29
+ }
30
+
31
+ /** Where the shared plugin home lives (the daemon writes `risd.json` there). */
32
+ export function risdHome() {
33
+ const explicit = process.env.DSH_RIS_IDE_PROXY_HOME || process.env.YDS_RISD_HOME
34
+ return typeof explicit === 'string' && explicit !== '' ? explicit : join(homedir(), '.dsh-ris-ide')
35
+ }
36
+
37
+ export function risdStatePath(home = risdHome()) {
38
+ return join(home, 'risd.json')
39
+ }
40
+
41
+ /** Read the daemon state file; unreadable/partial files mean "not running". */
42
+ export function readRisdState(home = risdHome()) {
43
+ const path = risdStatePath(home)
44
+ if (existsSync(path) !== true) return null
45
+ try {
46
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
47
+ if (parsed === null || typeof parsed !== 'object') return null
48
+ if (!Number.isInteger(parsed.port) || parsed.port <= 0) return null
49
+ return parsed
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ /** One daemon request. Never throws; a transport failure is `{ ok:false, error }`. */
56
+ export async function risdRequest(state, path, { method = 'GET', body, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
57
+ const headers = { [API_VERSION_HEADER]: String(RISD_API_VERSION) }
58
+ if (typeof state?.token === 'string' && state.token !== '') headers.authorization = `Bearer ${state.token}`
59
+ if (body !== undefined) headers['content-type'] = 'application/json'
60
+ let response
61
+ try {
62
+ response = await fetch(`http://127.0.0.1:${state.port}${path}`, {
63
+ method,
64
+ headers,
65
+ body: body === undefined ? undefined : JSON.stringify(body),
66
+ signal: AbortSignal.timeout(timeoutMs),
67
+ })
68
+ } catch (error) {
69
+ return { ok: false, status: 0, body: null, error: String(error?.message ?? error) }
70
+ }
71
+ let payload = null
72
+ try { payload = await response.json() } catch { payload = null }
73
+ return { ok: response.ok === true && (payload === null || payload.ok !== false), status: response.status, body: payload, error: payload !== null && typeof payload.message === 'string' ? payload.message : '' }
74
+ }
75
+
76
+ /**
77
+ * Is a usable daemon running?
78
+ * Unauthenticated health is the liveness signal; a mismatched API version is
79
+ * reported explicitly so an old daemon is not silently half-used.
80
+ */
81
+ export async function probeRisd({ home = risdHome() } = {}) {
82
+ if (risdDisabled()) return { usable: false, reason: 'disabled', state: null }
83
+ const state = readRisdState(home)
84
+ if (state === null) return { usable: false, reason: 'absent', state: null }
85
+ const health = await risdRequest(state, '/v1/health', { timeoutMs: PROBE_TIMEOUT_MS })
86
+ if (health.ok !== true) return { usable: false, reason: 'unreachable', state, error: health.error }
87
+ const apiVersion = Number(health.body?.apiVersion)
88
+ if (apiVersion !== RISD_API_VERSION) {
89
+ return { usable: false, reason: 'api-mismatch', state, daemonVersion: apiVersion, version: health.body?.version }
90
+ }
91
+ return { usable: true, reason: '', state, health: health.body }
92
+ }
93
+
94
+ /** Take a lease so the daemon keeps the engine while this launch is in flight. */
95
+ export async function acquireLease({ cwd, state = null, ownerPid = process.pid } = {}) {
96
+ const resolved = state ?? (await probeRisd()).state
97
+ if (resolved === null) return { ok: false, error: 'risd 未运行' }
98
+ const result = await risdRequest(resolved, '/v1/leases', {
99
+ method: 'POST',
100
+ body: { ownerPid, sessionId: 'yds', cwd: typeof cwd === 'string' ? cwd : undefined },
101
+ })
102
+ const id = typeof result.body?.lease?.id === 'string' ? result.body.lease.id : ''
103
+ return { ok: result.ok === true && id !== '', id, state: resolved, status: result.status, error: result.error || (id === '' ? 'risd 未返回租约 id' : '') }
104
+ }
105
+
106
+ /** Release one lease (null-safe: nothing to do when none was taken). */
107
+ export async function releaseLease(id, { state = null } = {}) {
108
+ if (typeof id !== 'string' || id === '') return { ok: true, released: false }
109
+ const resolved = state ?? (await probeRisd()).state
110
+ if (resolved === null) return { ok: true, released: false }
111
+ const result = await risdRequest(resolved, `/v1/leases/${encodeURIComponent(id)}`, { method: 'DELETE' })
112
+ return { ok: result.ok === true, released: result.ok === true, status: result.status, error: result.error }
113
+ }
114
+
115
+ // A lease must outlive the `yds` process that took it (the YMS runtime keeps using
116
+ // the tunnel after the CLI exits), so it is recorded on disk keyed by workspace and
117
+ // owned by the long-lived pid (the YMS process, or the foreground CLI).
118
+
119
+ function leasesPath(home = risdHome()) {
120
+ return join(home, '..', '.dsh-ris-yds', 'risd-leases.json')
121
+ }
122
+
123
+ function readLeases(home = risdHome()) {
124
+ const path = leasesPath(home)
125
+ if (existsSync(path) !== true) return {}
126
+ try {
127
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
128
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
129
+ } catch {
130
+ return {}
131
+ }
132
+ }
133
+
134
+ const LEASE_LOCK_TIMEOUT_MS = 2000
135
+ const LEASE_LOCK_STALE_MS = 5000
136
+ const LEASE_LOCK_RETRY_MS = 20
137
+
138
+ function sleepSync(ms) {
139
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
140
+ }
141
+
142
+ /**
143
+ * Run `fn` while holding a lock beside the lease file.
144
+ *
145
+ * The lease record is a tiny read-modify-write file, and two `yds start/stop` runs in
146
+ * different workspaces could otherwise drop each other's entry (review D-P2-6). The
147
+ * lock is `O_EXCL`, so a crashed writer cannot deadlock us: a lock older than
148
+ * LEASE_LOCK_STALE_MS is treated as abandoned. On timeout we still run `fn` unlocked —
149
+ * losing the lease record is worse than an unlocked write.
150
+ */
151
+ function withLeaseLock(path, fn) {
152
+ const lockPath = `${path}.lock`
153
+ mkdirSync(dirname(path), { recursive: true })
154
+ const deadline = Date.now() + LEASE_LOCK_TIMEOUT_MS
155
+ let held = false
156
+ while (held !== true) {
157
+ try {
158
+ const fd = openSync(lockPath, 'wx')
159
+ try {
160
+ writeFileSync(fd, String(process.pid), 'utf8')
161
+ } finally {
162
+ closeSync(fd)
163
+ }
164
+ held = true
165
+ } catch (error) {
166
+ if (error?.code !== 'EEXIST') throw error
167
+ try {
168
+ if (Date.now() - statSync(lockPath).mtimeMs > LEASE_LOCK_STALE_MS) {
169
+ unlinkSync(lockPath)
170
+ continue
171
+ }
172
+ } catch {
173
+ continue // raced with the owner releasing it: retry immediately
174
+ }
175
+ if (Date.now() > deadline) return fn()
176
+ sleepSync(LEASE_LOCK_RETRY_MS)
177
+ }
178
+ }
179
+ try {
180
+ return fn()
181
+ } finally {
182
+ try { unlinkSync(lockPath) } catch { /* ignore */ }
183
+ }
184
+ }
185
+
186
+ /** Write the lease map atomically so a crash never leaves half a JSON object. */
187
+ function writeLeases(path, all) {
188
+ const tmp = `${path}.${process.pid}.tmp`
189
+ writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 })
190
+ renameSync(tmp, path)
191
+ }
192
+
193
+ /** Remember a lease id so a later `yds stop` (another process) can release it. */
194
+ export function rememberLease(cwd, entry, { home = risdHome() } = {}) {
195
+ const path = leasesPath(home)
196
+ try {
197
+ return withLeaseLock(path, () => {
198
+ const all = readLeases(home)
199
+ all[cwd] = Object.assign({}, entry, { at: new Date().toISOString() })
200
+ writeLeases(path, all)
201
+ return true
202
+ })
203
+ } catch {
204
+ return false
205
+ }
206
+ }
207
+
208
+ /** The lease recorded for this workspace, or null. */
209
+ export function readLease(cwd, { home = risdHome() } = {}) {
210
+ const entry = readLeases(home)[cwd]
211
+ return entry !== null && typeof entry === 'object' ? entry : null
212
+ }
213
+
214
+ /** Drop the record (after releasing, or when the daemon says it is gone). */
215
+ export function forgetLease(cwd, { home = risdHome() } = {}) {
216
+ const path = leasesPath(home)
217
+ try {
218
+ withLeaseLock(path, () => {
219
+ const all = readLeases(home)
220
+ if (all[cwd] === undefined) return
221
+ delete all[cwd]
222
+ writeLeases(path, all)
223
+ })
224
+ } catch { /* best effort */ }
225
+ }
226
+
227
+ function pidIsAlive(pid) {
228
+ if (Number.isInteger(pid) !== true || pid <= 0) return false
229
+ try {
230
+ process.kill(pid, 0)
231
+ return true
232
+ } catch (error) {
233
+ return error?.code === 'EPERM'
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Drop a recorded lease whose owner process is gone (YMS killed with -9).
239
+ *
240
+ * The daemon only reaps by pid, so a dead owner keeps the engine alive until the
241
+ * 3 s sweep — clearing our own record first means `yds stop` will not try to release
242
+ * a lease the daemon already forgot (review D-P2-6).
243
+ */
244
+ export function dropDeadLease(cwd, { home = risdHome(), isAlive = pidIsAlive } = {}) {
245
+ const entry = readLease(cwd, { home })
246
+ if (entry === null) return false
247
+ if (isAlive(Number(entry.ownerPid)) === true) return false
248
+ forgetLease(cwd, { home })
249
+ return true
250
+ }
251
+
252
+ /** The daemon's own view of the rule table (carries the CAS revision). */
253
+ export async function getView(cwd, { state = null } = {}) {
254
+ const resolved = state ?? (await probeRisd()).state
255
+ if (resolved === null) return { ok: false, error: 'risd 未运行' }
256
+ const result = await risdRequest(resolved, `/v1/proxy?cwd=${encodeURIComponent(cwd)}`)
257
+ return { ok: result.ok === true, view: result.body, status: result.status, error: result.error }
258
+ }
259
+
260
+ /**
261
+ * Replace the `source: 'yds'` rows of the global rule table through the daemon.
262
+ *
263
+ * The table is one machine-wide list and the panel saves it wholesale, so the
264
+ * payload below is the COMPLETE table: every non-yds row read from proxy.json is
265
+ * passed through unchanged and the yds rows are exactly `rules` (an empty array
266
+ * therefore removes them all). The file is read directly for the other sources'
267
+ * rows: the daemon's view omits them on purpose (the plugin decorates the table per
268
+ * session), and a blind write would drop user/auto rules. A revision conflict is
269
+ * retried once with the fresh revision — another dsh window may have saved in between.
270
+ */
271
+ export async function putRules(cwd, rules, { gitHost = '', home = risdHome(), attempts = 2, log } = {}) {
272
+ const state = readRisdState(home)
273
+ if (state === null) return { ok: false, error: 'risd 未运行' }
274
+ let lastError = ''
275
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
276
+ const view = await getView(cwd, { state })
277
+ if (view.ok !== true) return { ok: false, status: view.status, error: view.error || '读取 risd 视图失败' }
278
+ // An empty revision is the daemon's deliberate "force" path. yds must never take
279
+ // it silently: if the view carries no revision we cannot tell whether somebody
280
+ // else wrote in between, so fail instead of clobbering their change (D-P2-8).
281
+ const baseRevision = String(view.view?.revision ?? '').trim()
282
+ if (baseRevision === '') {
283
+ return {
284
+ ok: false,
285
+ status: 0,
286
+ error: 'revision-missing:risd 未返回规则表 revision(旧版/异常 daemon),已拒绝无 CAS 的强写,请升级 dsh-ris-ide 后重试',
287
+ }
288
+ }
289
+ const stored = readStoredConfig(home)
290
+ // Whole-table save: non-yds rows are preserved, the yds rows become exactly the
291
+ // set this run generated (global table, no cross-workspace bookkeeping).
292
+ const before = ydsRowsOf(stored.rules, { log })
293
+ const table = mergeYdsRules(stored.rules, rules, { log })
294
+ // Only the fields the daemon actually consumes are sent. `shadows` used to be
295
+ // included, but POST /v1/proxy reads socks/listenPort/rules only, so a value here
296
+ // could never take effect and looked like a successful mapping update
297
+ // (review D-P2-8). The daemon keeps the stored shadow list itself.
298
+ const merged = {
299
+ socks: stored.socks ?? { host: '', port: 0 },
300
+ listenPort: stored.listenPort,
301
+ rules: table,
302
+ }
303
+ const result = await risdRequest(state, '/v1/proxy', {
304
+ method: 'POST',
305
+ body: {
306
+ cwd,
307
+ config: merged,
308
+ revision: baseRevision,
309
+ gitHost,
310
+ },
311
+ timeoutMs: 8000,
312
+ })
313
+ if (result.ok === true) {
314
+ return {
315
+ ok: true,
316
+ revision: result.body?.revision,
317
+ reloadNote: result.body?.reloadNote ?? '',
318
+ withdrawn: withdrawnBetween(before, table),
319
+ status: result.status,
320
+ }
321
+ }
322
+ lastError = result.error || `risd 保存失败(${result.status})`
323
+ if (result.status !== 409) return { ok: false, status: result.status, error: lastError }
324
+ }
325
+ return { ok: false, status: 409, error: lastError }
326
+ }
327
+
328
+ function readStoredConfig(home) {
329
+ const path = join(home, 'proxy.json')
330
+ if (existsSync(path) !== true) return {}
331
+ try {
332
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
333
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
334
+ } catch {
335
+ return {}
336
+ }
337
+ }
338
+
339
+ /** Ensure the daemon owns a running engine for this workspace. */
340
+ export async function startEngine(cwd, { state = null } = {}) {
341
+ const resolved = state ?? (await probeRisd()).state
342
+ if (resolved === null) return { ok: false, error: 'risd 未运行' }
343
+ const result = await risdRequest(resolved, '/v1/action', { method: 'POST', body: { action: 'start', cwd }, timeoutMs: 30000 })
344
+ return { ok: result.ok === true, status: result.status, result: result.body?.result, error: result.error }
345
+ }
346
+
347
+ /** Read the daemon engine's status (pid, listen ports, log path). */
348
+ export async function engineStatus(cwd, { state = null } = {}) {
349
+ const resolved = state ?? (await probeRisd()).state
350
+ if (resolved === null) return { ok: false, error: 'risd 未运行' }
351
+ const response = await risdRequest(resolved, `/v1/engine?cwd=${encodeURIComponent(cwd)}`)
352
+ return { ok: response.ok === true, status: response.status, engine: response.body?.engine ?? null, error: response.error }
353
+ }
package/lib/rules.js ADDED
@@ -0,0 +1,193 @@
1
+ // The proxy rule table is machine-wide AND global: there is exactly one table for the
2
+ // whole box, and the panel saves it wholesale ("全局一张表、保存即整表覆盖"). The daemon's
3
+ // `POST /v1/proxy` mirrors that contract, so yds owns its share outright:
4
+ //
5
+ // * `yds start` writes the COMPLETE set of yds rows (whatever yds rows were there
6
+ // before — from any workspace — are replaced),
7
+ // * `yds stop` removes EVERY `source: 'yds'` row.
8
+ //
9
+ // There is no cross-workspace bookkeeping any more: no `owners` map, no per-workspace
10
+ // subtraction. Two projects that both run `yds start` do not merge their endpoints;
11
+ // the last save simply wins, which is exactly what "没有交叉配置" means.
12
+ //
13
+ // The only identity that must stay distinct is the program: `java.exe` is the Windows
14
+ // image and `java` is the process name a WSL/Linux side matches, so the group key is
15
+ // `side|program` and the two live side by side instead of collapsing into one row.
16
+ export const YDS_RULE_SOURCE = 'yds'
17
+
18
+ /** Program identity for duplicate detection: basename, lower-case, no script suffix. */
19
+ export function programKeyOf(program) {
20
+ if (typeof program !== 'string') return ''
21
+ const text = program.trim()
22
+ if (text === '') return ''
23
+ // A wildcard program (`C:\devkit\jdk*\bin\*.exe`) has no basename identity: its
24
+ // basename is `*.exe`, which would collapse EVERY wildcard program into one row.
25
+ // Keep the whole pattern as the key instead.
26
+ if (/[*?]/.test(text)) return `raw:${text.toLowerCase()}`
27
+ const base = text.split(/[\\/]/).pop() ?? ''
28
+ return base.toLowerCase().replace(/\.(exe|cmd|bat|sh)$/, '')
29
+ }
30
+
31
+ /**
32
+ * Identity of one rule inside the yds share: the side it applies to plus the program.
33
+ * `windows|java` and `local|java` are different rows on purpose (the Windows engine
34
+ * matches `java.exe`, the WSL/Linux side matches `java`).
35
+ * @param {{side?: string, program?: string}} rule
36
+ * @returns {string}
37
+ */
38
+ export function ruleGroupKey(rule) {
39
+ const side = typeof rule?.side === 'string' ? rule.side.trim().toLowerCase() : ''
40
+ return `${side}|${programKeyOf(rule?.program)}`
41
+ }
42
+
43
+ function hostList(value) {
44
+ return String(value ?? '')
45
+ .split(',')
46
+ .map((item) => item.trim())
47
+ .filter((item) => item !== '')
48
+ }
49
+
50
+ function portList(value) {
51
+ return String(value ?? '')
52
+ .split(',')
53
+ .map((item) => item.trim())
54
+ .filter((item) => item !== '')
55
+ }
56
+
57
+ function uniquePush(target, values) {
58
+ for (const value of values) {
59
+ if (!target.includes(value)) target.push(value)
60
+ }
61
+ }
62
+
63
+ function hostText(hosts) {
64
+ return hosts.join(',')
65
+ }
66
+
67
+ /** A rule allows every port when any caller says `*`: keep the wildcard form. */
68
+ function portText(ports) {
69
+ return ports.includes('*') ? '*' : ports.join(',')
70
+ }
71
+
72
+ /**
73
+ * Validate one fresh row and canonicalize the fields the engine reads. Unknown fields
74
+ * (id, label, side, ...) are kept as they came; only the list/boolean fields are
75
+ * normalized so a hand-written value cannot reach the engine half-parsed.
76
+ * @returns {object|null} null when the row cannot be a rule (logged).
77
+ */
78
+ function normalizeFreshRow(row, { log } = {}) {
79
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
80
+ log?.('代理规则:忽略一条不是对象的规则')
81
+ return null
82
+ }
83
+ const program = typeof row.program === 'string' ? row.program.trim() : ''
84
+ if (program === '') {
85
+ log?.(`代理规则:忽略一条没有 program 的规则(id=${String(row.id ?? '?')})`)
86
+ return null
87
+ }
88
+ const hosts = hostList(row.hosts)
89
+ const ports = portList(row.ports)
90
+ if (hosts.length === 0 && ports.length === 0) {
91
+ log?.(`代理规则:忽略一条既没有 host 也没有 port 的规则(id=${String(row.id ?? '?')})`)
92
+ return null
93
+ }
94
+ return {
95
+ ...row,
96
+ program,
97
+ hosts: hostText(hosts),
98
+ ports: portText(ports),
99
+ enabled: row.enabled !== false,
100
+ shadow: row.shadow === true,
101
+ direct: row.direct === true,
102
+ source: YDS_RULE_SOURCE,
103
+ }
104
+ }
105
+
106
+ /** Union two fresh rows that name the same side+program (a caller bug, not the norm). */
107
+ function mergeFreshRows(into, extra) {
108
+ const hosts = hostList(into.hosts)
109
+ uniquePush(hosts, hostList(extra.hosts))
110
+ const ports = portList(into.ports)
111
+ uniquePush(ports, portList(extra.ports))
112
+ return {
113
+ ...into,
114
+ hosts: hostText(hosts),
115
+ ports: portText(ports),
116
+ // Lists union; `enabled` is satisfied by either row; the opt-ins only survive if
117
+ // every copy asked for them (a stray duplicate must not turn interception off).
118
+ enabled: into.enabled === true || extra.enabled === true,
119
+ shadow: into.shadow === true && extra.shadow === true,
120
+ direct: into.direct === true && extra.direct === true,
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Build the table to save: every non-yds row is passed through untouched, then the
126
+ * fresh yds rows are appended (one row per side+program, ids preserved).
127
+ *
128
+ * This is a WHOLE-TABLE function: the return value is the complete rule list, so the
129
+ * caller can PUT it to the daemon as-is. An empty `freshRules` array therefore removes
130
+ * every yds row (that is `yds stop`) and leaves the non-yds rows alone.
131
+ * @param {object[]} existingRules - rows currently stored (any source).
132
+ * @param {object[]} freshRules - the yds rows to own; empty = remove them all.
133
+ * @param {{log?: (line: string) => void}} [options]
134
+ * @returns {object[]} the complete table, non-yds rows first.
135
+ */
136
+ export function mergeYdsRules(existingRules, freshRules, { log } = {}) {
137
+ const rows = []
138
+ for (const row of Array.isArray(existingRules) ? existingRules : []) {
139
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) continue
140
+ // The yds share is rebuilt from `freshRules` below, never inherited: a row that
141
+ // is not regenerated must disappear (an endpoint that moved, or another
142
+ // workspace's rows, which the global table replaces wholesale).
143
+ if (row.source === YDS_RULE_SOURCE) continue
144
+ rows.push(row)
145
+ }
146
+ const fresh = new Map()
147
+ const order = []
148
+ for (const row of Array.isArray(freshRules) ? freshRules : []) {
149
+ const normalized = normalizeFreshRow(row, { log })
150
+ if (normalized === null) continue
151
+ const key = ruleGroupKey(normalized)
152
+ const existing = fresh.get(key)
153
+ if (existing === undefined) {
154
+ fresh.set(key, normalized)
155
+ order.push(key)
156
+ continue
157
+ }
158
+ fresh.set(key, mergeFreshRows(existing, normalized))
159
+ }
160
+ for (const key of order) rows.push(fresh.get(key))
161
+ return rows
162
+ }
163
+
164
+ /** Just the yds rows of a stored table (for counts and withdrawal math). */
165
+ export function ydsRowsOf(existingRules) {
166
+ return (Array.isArray(existingRules) ? existingRules : [])
167
+ .filter((row) => row !== null && typeof row === 'object' && row.source === YDS_RULE_SOURCE)
168
+ }
169
+
170
+ /**
171
+ * How many yds rows and distinct hosts disappear between two table snapshots, for the
172
+ * CLI text. Counts yds rows only: the non-yds rows are never ours to report on.
173
+ * @returns {{rules: number, hosts: number}}
174
+ */
175
+ export function withdrawnBetween(beforeRules, afterRules) {
176
+ const hosts = (rows) => {
177
+ const out = new Set()
178
+ for (const row of ydsRowsOf(rows)) {
179
+ for (const host of hostList(row.hosts)) out.add(host)
180
+ }
181
+ return out
182
+ }
183
+ const before = hosts(beforeRules)
184
+ const after = hosts(afterRules)
185
+ let removed = 0
186
+ for (const host of before) {
187
+ if (!after.has(host)) removed += 1
188
+ }
189
+ return {
190
+ rules: Math.max(0, ydsRowsOf(beforeRules).length - ydsRowsOf(afterRules).length),
191
+ hosts: removed,
192
+ }
193
+ }