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.
- package/LICENSE +21 -0
- package/README.md +160 -2
- package/assets/yds-chrome-resource-override.crx +0 -0
- package/bin/check-listing.mjs +247 -0
- package/bin/yds.js +19 -0
- package/cordis.patch.yml +13 -0
- package/lib/browser.js +428 -0
- package/lib/build.js +115 -0
- package/lib/cli.js +1130 -0
- package/lib/confcenter.js +377 -0
- package/lib/config.js +552 -0
- package/lib/fe-dev.js +206 -0
- package/lib/ide-proxy.js +906 -0
- package/lib/ide-watch.js +188 -0
- package/lib/ide.js +728 -0
- package/lib/index.js +267 -0
- package/lib/proc.js +270 -0
- package/lib/risd-peer.js +353 -0
- package/lib/rules.js +193 -0
- package/lib/socks.js +279 -0
- package/lib/token.js +36 -0
- package/lib/yms.js +336 -0
- package/lib/yoncode.js +249 -0
- package/native/yds-rust.darwin-arm64.node +0 -0
- package/native/yds-rust.darwin-x64.node +0 -0
- package/native/yds-rust.linux-arm64.node +0 -0
- package/native/yds-rust.linux-x64.node +0 -0
- package/native/yds-rust.win32-arm64.node +0 -0
- package/native/yds-rust.win32-x64.node +0 -0
- package/package.json +58 -4
- package/skills/dsh-ris-yds/SKILL.md +134 -0
- package/test/browser.test.mjs +442 -0
- package/test/cli-stop.test.mjs +40 -0
- package/test/cli.test.mjs +246 -0
- package/test/config.test.mjs +106 -0
- package/test/fe-dev.test.mjs +93 -0
- package/test/hints.test.mjs +13 -0
- package/test/ide-config.test.mjs +155 -0
- package/test/ide-proxy.test.mjs +256 -0
- package/test/ide-watch.test.mjs +79 -0
- package/test/launch-proxy.test.mjs +83 -0
- package/test/lease-release.test.mjs +76 -0
- package/test/mode-separation.test.mjs +147 -0
- package/test/pidfile.test.mjs +105 -0
- package/test/proc.test.mjs +172 -0
- package/test/proxy-standalone.test.mjs +121 -0
- package/test/risd-leases.test.mjs +111 -0
- package/test/risd-tier.test.mjs +361 -0
- package/test/rules-e2e.test.mjs +122 -0
- package/test/rules.test.mjs +154 -0
- package/test/yoncode.test.mjs +159 -0
package/lib/risd-peer.js
ADDED
|
@@ -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
|
+
}
|