@ljwei-stak/model-router-galgame 0.4.18 → 0.4.20

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.
@@ -26,6 +26,7 @@ import {
26
26
  webCapabilityStatus,
27
27
  webInstruction,
28
28
  } from './shared/web-routing.mjs'
29
+ import { registerNpmUpdateRoute } from './shared/npm-update.mjs'
29
30
 
30
31
  let settingsRuntimePromise
31
32
  let routerSettings = { ...DEFAULT_ROUTER_SETTINGS }
@@ -427,6 +428,12 @@ function createOpenCodeRepairScheduler(ctx) {
427
428
  }
428
429
 
429
430
  export function apply(ctx) {
431
+ // Desktop-only package mutation is exposed through an authenticated,
432
+ // fixed-purpose route when the optional native capabilities are present.
433
+ ctx.inject?.(['connection', 'desktopProfiles', 'desktopPnpm'], desktopCtx => {
434
+ registerNpmUpdateRoute(desktopCtx, import.meta.url)
435
+ })
436
+
430
437
  // Settings are optional in headless test/minimal hosts. In a full Harness
431
438
  // process this registers the editable pricing, LiveBench and budget section;
432
439
  // the dynamic import keeps the standalone plugin loadable during bootstrap.
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  export const APPROVAL_GATE_PACKAGE = '@ljwei-stak/dsh-approval-gate'
12
- export const APPROVAL_GATE_VERSION = '0.5.2'
12
+ export const APPROVAL_GATE_VERSION = '0.5.3'
13
13
 
14
14
  const ESCALATION_RE = /escalate\s+sandbox\s+to\s+([^\s:]+):?\s*([\s\S]*)/i
15
15
 
@@ -0,0 +1,312 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ export const ROUTER_PACKAGE_NAME = '@ljwei-stak/model-router-galgame'
6
+ export const ROUTER_NPM_REGISTRY = 'https://registry.npmjs.org/'
7
+ export const ROUTER_UPDATE_CHANNEL = '/model-router-update'
8
+ export const ROUTER_UPDATE_ENDPOINT = 'install-plugin'
9
+ export const ROUTER_UPDATE_TIMEOUT_MS = 120_000
10
+
11
+ const MAX_REGISTRY_BODY_BYTES = 64 * 1024
12
+ const MAX_PNPM_OUTPUT_BYTES = 32 * 1024
13
+ const MAX_LOG_CAUSE_LENGTH = 4 * 1024
14
+ const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
15
+
16
+ function versionParts(value) {
17
+ const match = String(value ?? '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/)
18
+ if (match === null) return null
19
+ return { core: match.slice(1, 4).map(Number), prerelease: match[4]?.split('.') ?? [] }
20
+ }
21
+
22
+ function compareVersions(left, right) {
23
+ const a = versionParts(left)
24
+ const b = versionParts(right)
25
+ if (a === null || b === null) return null
26
+ for (let index = 0; index < 3; index += 1) {
27
+ if (a.core[index] !== b.core[index]) return a.core[index] < b.core[index] ? -1 : 1
28
+ }
29
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) {
30
+ return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1
31
+ }
32
+ const length = Math.max(a.prerelease.length, b.prerelease.length)
33
+ for (let index = 0; index < length; index += 1) {
34
+ if (a.prerelease[index] === undefined) return -1
35
+ if (b.prerelease[index] === undefined) return 1
36
+ if (a.prerelease[index] === b.prerelease[index]) continue
37
+ const aNumber = /^\d+$/.test(a.prerelease[index])
38
+ const bNumber = /^\d+$/.test(b.prerelease[index])
39
+ if (aNumber && bNumber) return Number(a.prerelease[index]) < Number(b.prerelease[index]) ? -1 : 1
40
+ if (aNumber !== bNumber) return aNumber ? -1 : 1
41
+ return a.prerelease[index] < b.prerelease[index] ? -1 : 1
42
+ }
43
+ return 0
44
+ }
45
+
46
+ function rpcFailure(code, message, details = {}) {
47
+ return { ok: false, error: { code, message, details } }
48
+ }
49
+
50
+ function errorText(error) {
51
+ const value = error instanceof Error ? error.stack ?? error.message : String(error)
52
+ return value.length <= MAX_LOG_CAUSE_LENGTH
53
+ ? value
54
+ : `${value.slice(0, MAX_LOG_CAUSE_LENGTH)}\n[cause truncated]`
55
+ }
56
+
57
+ async function readTextLimited(stream, maximum) {
58
+ if (stream === null) return ''
59
+ const reader = stream.getReader()
60
+ const decoder = new TextDecoder()
61
+ let size = 0
62
+ let text = ''
63
+ try {
64
+ while (true) {
65
+ const { value, done } = await reader.read()
66
+ if (done) break
67
+ size += value.byteLength
68
+ if (size > maximum) throw new Error('response body is too large')
69
+ text += decoder.decode(value, { stream: true })
70
+ }
71
+ return text + decoder.decode()
72
+ } finally {
73
+ reader.releaseLock()
74
+ }
75
+ }
76
+
77
+ function isEmptyRecord(value) {
78
+ return value !== null
79
+ && typeof value === 'object'
80
+ && !Array.isArray(value)
81
+ && Object.keys(value).length === 0
82
+ }
83
+
84
+ /** Read the running package version from its own ancestor manifest. */
85
+ export async function findInstalledPackageVersion(moduleUrl, read = readFile) {
86
+ let current = dirname(fileURLToPath(moduleUrl))
87
+ while (true) {
88
+ try {
89
+ const manifest = JSON.parse(await read(join(current, 'package.json'), 'utf8'))
90
+ if (manifest?.name === ROUTER_PACKAGE_NAME && typeof manifest.version === 'string' && VERSION_PATTERN.test(manifest.version)) {
91
+ return manifest.version
92
+ }
93
+ } catch {
94
+ // Keep walking until the package root is found.
95
+ }
96
+ const parent = dirname(current)
97
+ if (parent === current) return null
98
+ current = parent
99
+ }
100
+ }
101
+
102
+ async function npmLatest(fetchImpl, signal) {
103
+ const encoded = encodeURIComponent(ROUTER_PACKAGE_NAME)
104
+ const response = await fetchImpl(`${ROUTER_NPM_REGISTRY}${encoded}/latest`, {
105
+ method: 'GET',
106
+ redirect: 'error',
107
+ cache: 'no-store',
108
+ signal,
109
+ headers: {
110
+ accept: 'application/json',
111
+ 'user-agent': 'model-router-galgame-updater',
112
+ },
113
+ })
114
+ if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`)
115
+ const metadata = JSON.parse(await readTextLimited(response.body, MAX_REGISTRY_BODY_BYTES))
116
+ if (metadata?.name !== ROUTER_PACKAGE_NAME || typeof metadata.version !== 'string' || !VERSION_PATTERN.test(metadata.version)) {
117
+ throw new Error('npm registry returned invalid latest metadata')
118
+ }
119
+ return metadata.version
120
+ }
121
+
122
+ function captureBoundedOutput(stream) {
123
+ let output = Buffer.alloc(0)
124
+ let truncated = false
125
+ let settle
126
+ const done = new Promise(resolveDone => { settle = resolveDone })
127
+ const onData = chunk => {
128
+ const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
129
+ if (next.byteLength >= MAX_PNPM_OUTPUT_BYTES) {
130
+ output = next.subarray(-MAX_PNPM_OUTPUT_BYTES)
131
+ truncated = true
132
+ return
133
+ }
134
+ if (output.byteLength + next.byteLength > MAX_PNPM_OUTPUT_BYTES) {
135
+ output = Buffer.concat([output, next]).subarray(-MAX_PNPM_OUTPUT_BYTES)
136
+ truncated = true
137
+ return
138
+ }
139
+ output = Buffer.concat([output, next])
140
+ }
141
+ const onSettled = () => settle()
142
+ stream.on('data', onData)
143
+ stream.once('end', onSettled)
144
+ stream.once('close', onSettled)
145
+ stream.once('error', onSettled)
146
+ stream.resume()
147
+ return {
148
+ done,
149
+ read() {
150
+ const value = output.toString('utf8').trimEnd()
151
+ return truncated ? `[earlier output truncated]\n${value}` : value
152
+ },
153
+ stop() {
154
+ stream.off('data', onData)
155
+ stream.off('end', onSettled)
156
+ stream.off('close', onSettled)
157
+ stream.off('error', onSettled)
158
+ },
159
+ }
160
+ }
161
+
162
+ function abortPromise(signal) {
163
+ let rejectPromise
164
+ const promise = new Promise((_, reject) => { rejectPromise = reject })
165
+ const reject = () => rejectPromise(signal.reason ?? new Error('operation aborted'))
166
+ if (signal.aborted) reject()
167
+ else signal.addEventListener('abort', reject, { once: true })
168
+ return {
169
+ promise,
170
+ dispose() { signal.removeEventListener('abort', reject) },
171
+ }
172
+ }
173
+
174
+ async function runPnpmUpdate(pnpm, profileDir, version, signal) {
175
+ const args = [
176
+ 'add',
177
+ '--save-exact',
178
+ `${ROUTER_PACKAGE_NAME}@${version}`,
179
+ `--registry=${ROUTER_NPM_REGISTRY}`,
180
+ ]
181
+ let handle
182
+ try {
183
+ handle = pnpm.runPlugin(args, profileDir, signal)
184
+ } catch (error) {
185
+ const busy = /another desktop pnpm operation is already running/i.test(String(error))
186
+ return { ok: false, busy, details: errorText(error) }
187
+ }
188
+ const stdout = captureBoundedOutput(handle.stdout)
189
+ const stderr = captureBoundedOutput(handle.stderr)
190
+ const onAbort = () => handle.cancel()
191
+ const aborted = abortPromise(signal)
192
+ signal.addEventListener('abort', onAbort, { once: true })
193
+ try {
194
+ let outcome
195
+ try {
196
+ outcome = await Promise.race([handle.done, aborted.promise])
197
+ await Promise.race([Promise.all([stdout.done, stderr.done]), aborted.promise])
198
+ } catch (error) {
199
+ signal.throwIfAborted()
200
+ return { ok: false, busy: false, details: [errorText(error), stdout.read(), stderr.read()].filter(Boolean).join('\n\n') }
201
+ }
202
+ signal.throwIfAborted()
203
+ if (outcome.exitCode !== 0 || outcome.signal !== null) {
204
+ return {
205
+ ok: false,
206
+ busy: false,
207
+ details: [
208
+ `exitCode=${outcome.exitCode ?? 'null'} signal=${outcome.signal ?? 'none'}`,
209
+ stdout.read(),
210
+ stderr.read(),
211
+ ].filter(Boolean).join('\n\n'),
212
+ }
213
+ }
214
+ return { ok: true }
215
+ } finally {
216
+ aborted.dispose()
217
+ signal.removeEventListener('abort', onAbort)
218
+ stdout.stop()
219
+ stderr.stop()
220
+ }
221
+ }
222
+
223
+ export function createNpmUpdateHandler({
224
+ moduleUrl,
225
+ pnpm,
226
+ profileDir,
227
+ fetchImpl = globalThis.fetch,
228
+ logger,
229
+ timeoutMs = ROUTER_UPDATE_TIMEOUT_MS,
230
+ }) {
231
+ let active = false
232
+ return async (endpoint, payload, requestSignal = new AbortController().signal) => {
233
+ if (endpoint !== ROUTER_UPDATE_ENDPOINT) {
234
+ return rpcFailure('router/not-found', 'unknown Router update operation')
235
+ }
236
+ if (!isEmptyRecord(payload)) {
237
+ return rpcFailure('router/bad-request', 'the Router update request must be an empty object')
238
+ }
239
+ if (active) return rpcFailure('router/busy', 'a Router update is already running')
240
+ active = true
241
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
242
+ const signal = AbortSignal.any([requestSignal, timeoutSignal])
243
+ try {
244
+ const version = await npmLatest(fetchImpl, signal)
245
+ const currentVersion = await findInstalledPackageVersion(moduleUrl)
246
+ const order = currentVersion === null ? null : compareVersions(currentVersion, version)
247
+ if (order !== null && order >= 0) {
248
+ return {
249
+ ok: true,
250
+ value: {
251
+ changed: false,
252
+ packageName: ROUTER_PACKAGE_NAME,
253
+ currentVersion,
254
+ version,
255
+ restartRequired: false,
256
+ message: order === 0
257
+ ? `npm 插件已经是最新版 ${version}。`
258
+ : `当前插件 ${currentVersion} 新于 npm latest ${version},不会降级。`,
259
+ },
260
+ }
261
+ }
262
+ const result = await runPnpmUpdate(pnpm, profileDir, version, signal)
263
+ if (!result.ok) {
264
+ try { logger?.warn?.(`model-router: npm update failed\n${result.details}`) } catch {}
265
+ return rpcFailure(
266
+ result.busy ? 'router/busy' : 'router/install-failed',
267
+ result.busy ? 'DSH Desktop 正在执行其他插件操作,请稍后重试。' : 'npm 插件更新失败,请查看 DSH Desktop 日志。',
268
+ )
269
+ }
270
+ return {
271
+ ok: true,
272
+ value: {
273
+ changed: true,
274
+ packageName: ROUTER_PACKAGE_NAME,
275
+ currentVersion,
276
+ version,
277
+ restartRequired: true,
278
+ message: `npm 插件及其依赖已更新为 ${version}。请完全退出并重新启动 DSH Desktop。`,
279
+ },
280
+ }
281
+ } catch (error) {
282
+ const timedOut = timeoutSignal.aborted && !requestSignal.aborted
283
+ try { logger?.warn?.(`model-router: npm update failed: ${errorText(error)}`) } catch {}
284
+ return rpcFailure(
285
+ timedOut ? 'router/timeout' : requestSignal.aborted ? 'router/cancelled' : 'router/npm-failed',
286
+ timedOut
287
+ ? 'npm 插件更新超时,请稍后重试。'
288
+ : requestSignal.aborted
289
+ ? 'npm 插件更新已取消。'
290
+ : '无法从 npm 检查或更新插件。',
291
+ )
292
+ } finally {
293
+ active = false
294
+ }
295
+ }
296
+ }
297
+
298
+ export function registerNpmUpdateRoute(ctx, moduleUrl) {
299
+ const connection = ctx.get?.('connection') ?? ctx.connection
300
+ const profiles = ctx.get?.('desktopProfiles') ?? ctx.desktopProfiles
301
+ const pnpm = ctx.get?.('desktopPnpm') ?? ctx.desktopPnpm
302
+ if (typeof connection?.rpc?.handle !== 'function'
303
+ || typeof pnpm?.runPlugin !== 'function'
304
+ || typeof profiles?.current?.dir !== 'string') return false
305
+ connection.rpc.handle(ROUTER_UPDATE_CHANNEL, createNpmUpdateHandler({
306
+ moduleUrl,
307
+ pnpm,
308
+ profileDir: profiles.current.dir,
309
+ logger: ctx.logger,
310
+ }))
311
+ return true
312
+ }
@@ -10,7 +10,7 @@
10
10
  export const MODSEARCH_PACKAGE = '@liustack/modsearch'
11
11
  export const MODSEARCH_VERSION = '5.10.1'
12
12
  export const EGO_BROWSER_PACKAGE = '@ljwei-stak/dsh-ego-browser'
13
- export const EGO_BROWSER_VERSION = '0.8.2'
13
+ export const EGO_BROWSER_VERSION = '0.8.3'
14
14
 
15
15
  const WEB_RE = /https?:\/\/|www\.|联网|上网|网页|网站|搜索|查找|资料|文献|新闻|最新|实时|网页内容|页面|来源|引用|网络|x\s*帖子|twitter|推特|github|反爬|验证码|登录|人机验证/i
16
16
  const BROWSER_RE = /反爬|验证码|人机验证|cloudflare|turnstile|recaptcha|hcaptcha|登录|需要点击|动态页面|网页窗口|浏览器|可见窗口|手动验证|页面交互/i