@linxin666/dsh-remote-web-ui 0.1.19 → 0.2.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 (144) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +41 -19
  3. package/README.zh.md +17 -12
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +57 -13
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +408 -130
  10. package/lib/mobile.js +296 -22
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/FooterRemoteEntry.js +1 -1
  13. package/lib/types/client/PluginSettingsCard.d.ts +7 -1
  14. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/PluginSettingsCard.js +5 -3
  16. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  17. package/lib/types/client/RemoteEntry.js +43 -13
  18. package/lib/types/client/RemotePanel.d.ts +1 -0
  19. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  20. package/lib/types/client/RemotePanel.js +2 -2
  21. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  22. package/lib/types/client/RemoteSettingsCard.js +3 -3
  23. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  24. package/lib/types/client/UpdateEntry.js +37 -4
  25. package/lib/types/client/UpdatePanel.js +1 -1
  26. package/lib/types/client/deep-link.js +2 -2
  27. package/lib/types/client/index.js +7 -7
  28. package/lib/types/client/locales.d.ts +2 -0
  29. package/lib/types/client/locales.d.ts.map +1 -1
  30. package/lib/types/client/locales.js +2 -0
  31. package/lib/types/client/settings-form.d.ts.map +1 -1
  32. package/lib/types/client/update-api.d.ts.map +1 -1
  33. package/lib/types/gate.d.ts +9 -0
  34. package/lib/types/gate.d.ts.map +1 -1
  35. package/lib/types/gate.js +16 -5
  36. package/lib/types/http.d.ts +14 -0
  37. package/lib/types/http.d.ts.map +1 -0
  38. package/lib/types/http.js +27 -0
  39. package/lib/types/index.d.ts +28 -1
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/index.js +41 -22
  42. package/lib/types/invariant.d.ts.map +1 -1
  43. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  44. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  45. package/lib/types/mobile/PairRequiredView.js +28 -0
  46. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  47. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  48. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  49. package/lib/types/mobile/api.js +1 -1
  50. package/lib/types/mobile/index.js +17 -4
  51. package/lib/types/mobile/markdown.d.ts +6 -4
  52. package/lib/types/mobile/markdown.d.ts.map +1 -1
  53. package/lib/types/mobile/markdown.js +8 -4
  54. package/lib/types/mobile/markdown.test.js +5 -1
  55. package/lib/types/mobile/messages.d.ts.map +1 -1
  56. package/lib/types/mobile/messages.js +14 -1
  57. package/lib/types/mobile/messages.test.js +1 -1
  58. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  59. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  60. package/lib/types/mobile/mobile-styles.js +48 -0
  61. package/lib/types/mobile/mobile-theme.test.js +2 -2
  62. package/lib/types/mobile/mux.d.ts.map +1 -1
  63. package/lib/types/mobile/mux.js +1 -1
  64. package/lib/types/mobile/mux.test.js +1 -1
  65. package/lib/types/mobile/pairing.d.ts +31 -0
  66. package/lib/types/mobile/pairing.d.ts.map +1 -0
  67. package/lib/types/mobile/pairing.js +62 -0
  68. package/lib/types/mobile/rpc.d.ts.map +1 -1
  69. package/lib/types/mobile/theme-toggle.js +1 -1
  70. package/lib/types/mobile/views/App.d.ts +12 -16
  71. package/lib/types/mobile/views/App.d.ts.map +1 -1
  72. package/lib/types/mobile/views/App.js +39 -12
  73. package/lib/types/mobile/views/App.test.d.ts +2 -0
  74. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  75. package/lib/types/mobile/views/App.test.js +52 -0
  76. package/lib/types/mobile/views/ChatView.js +6 -6
  77. package/lib/types/mobile/views/ChatView.test.js +3 -3
  78. package/lib/types/mobile/views/SessionListView.js +3 -3
  79. package/lib/types/mobile/views/SessionListView.test.js +2 -2
  80. package/lib/types/mobile/views/WorkspaceView.js +3 -3
  81. package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
  82. package/lib/types/mobile-api.d.ts +1 -1
  83. package/lib/types/mobile-api.d.ts.map +1 -1
  84. package/lib/types/mobile-api.js +11 -18
  85. package/lib/types/mobile-pwa.d.ts +6 -0
  86. package/lib/types/mobile-pwa.d.ts.map +1 -0
  87. package/lib/types/mobile-pwa.js +17 -0
  88. package/lib/types/mobile-routes.d.ts +5 -8
  89. package/lib/types/mobile-routes.d.ts.map +1 -1
  90. package/lib/types/mobile-routes.js +130 -39
  91. package/lib/types/mount-once.d.ts +25 -0
  92. package/lib/types/mount-once.d.ts.map +1 -0
  93. package/lib/types/mount-once.js +42 -0
  94. package/lib/types/pairing-access.d.ts +28 -0
  95. package/lib/types/pairing-access.d.ts.map +1 -0
  96. package/lib/types/pairing-access.js +26 -0
  97. package/lib/types/pairing.d.ts +27 -0
  98. package/lib/types/pairing.d.ts.map +1 -1
  99. package/lib/types/pairing.js +68 -0
  100. package/lib/types/pairing.persist.test.d.ts +2 -0
  101. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  102. package/lib/types/pairing.persist.test.js +128 -0
  103. package/lib/types/routes.d.ts +6 -6
  104. package/lib/types/routes.d.ts.map +1 -1
  105. package/lib/types/routes.js +15 -20
  106. package/lib/types/tunnel.d.ts.map +1 -1
  107. package/lib/types/update-routes.d.ts +2 -2
  108. package/lib/types/update-routes.d.ts.map +1 -1
  109. package/lib/types/update-routes.js +1 -6
  110. package/lib/types/update.d.ts +3 -3
  111. package/lib/types/update.d.ts.map +1 -1
  112. package/lib/types/update.js +40 -14
  113. package/package.json +12 -12
  114. package/src/client/PluginSettingsCard.tsx +10 -0
  115. package/src/client/RemoteEntry.tsx +40 -8
  116. package/src/client/RemotePanel.tsx +1 -1
  117. package/src/client/RemoteSettingsCard.tsx +1 -0
  118. package/src/client/UpdateEntry.tsx +36 -4
  119. package/src/client/deep-link.ts +1 -1
  120. package/src/client/locales.ts +2 -0
  121. package/src/client/remote.module.css +26 -0
  122. package/src/gate.ts +15 -3
  123. package/src/http.ts +30 -0
  124. package/src/index.ts +50 -16
  125. package/src/mobile/PairRequiredView.test.tsx +28 -0
  126. package/src/mobile/PairRequiredView.tsx +58 -0
  127. package/src/mobile/index.tsx +15 -1
  128. package/src/mobile/markdown.test.ts +5 -0
  129. package/src/mobile/markdown.ts +7 -4
  130. package/src/mobile/messages.ts +14 -1
  131. package/src/mobile/mobile-styles.ts +48 -0
  132. package/src/mobile/pairing.ts +77 -0
  133. package/src/mobile/views/App.test.tsx +62 -0
  134. package/src/mobile/views/App.tsx +53 -18
  135. package/src/mobile-api.ts +9 -17
  136. package/src/mobile-pwa.ts +19 -0
  137. package/src/mobile-routes.ts +137 -38
  138. package/src/mount-once.ts +48 -0
  139. package/src/pairing-access.ts +36 -0
  140. package/src/pairing.persist.test.ts +145 -0
  141. package/src/pairing.ts +70 -1
  142. package/src/routes.ts +12 -18
  143. package/src/update-routes.ts +1 -7
  144. package/src/update.ts +46 -15
@@ -0,0 +1,145 @@
1
+ /**
2
+ * PairingService device-session persistence: with `devicesFile` set, sessions
3
+ * written on accept/stop survive a process restart (a new PairingService
4
+ * instance), so a previously paired phone — whose cookie already lives 365
5
+ * days — never needs to re-scan the QR after `dsh web` restarts.
6
+ */
7
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
11
+ import { pairingConfigOf } from './index.ts'
12
+ import { PairingService, type PairingClock, type PairingConfig } from './pairing.ts'
13
+
14
+ const BASE_CONFIG: Omit<PairingConfig, 'devicesFile'> = {
15
+ tokenTtlMs: 60_000,
16
+ offlineAfterMs: 25_000,
17
+ maxDevices: 4,
18
+ cookieName: 'dsh_pair',
19
+ }
20
+
21
+ /** Deterministic clock: tokens are issued in a fixed, readable sequence. */
22
+ function makeClock(): PairingClock {
23
+ let n = 0
24
+ return {
25
+ now: () => 1_000_000 + n,
26
+ randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
27
+ }
28
+ }
29
+
30
+ /** Pair a device on a service: issue a token and immediately accept it. */
31
+ function pairDevice(service: PairingService): string {
32
+ service.setPublicBaseUrl('https://pairing.example.trycloudflare.com')
33
+ const { token } = service.issue()
34
+ const result = service.accept(token)
35
+ if (!result.ok) throw new Error(`pair failed: ${result.code}`)
36
+ return result.deviceId
37
+ }
38
+
39
+ describe('PairingService device persistence', () => {
40
+ let dir: string
41
+
42
+ beforeEach(() => {
43
+ dir = mkdtempSync(join(tmpdir(), 'remote-web-ui-persist-'))
44
+ })
45
+
46
+ afterEach(() => {
47
+ rmSync(dir, { recursive: true, force: true })
48
+ })
49
+
50
+ it('restores paired sessions across a process restart', () => {
51
+ const file = join(dir, 'devices.json')
52
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
53
+ const deviceId = pairDevice(first)
54
+ expect(readFileSync(file, 'utf8')).toContain(deviceId)
55
+ // Device ids are session credentials: the persisted file is 0600.
56
+ expect(statSync(file).mode & 0o777).toBe(0o600)
57
+
58
+ // Simulated restart: a brand-new service instance reading the same file.
59
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
60
+ expect(second.hasDevice(deviceId)).toBe(true)
61
+ })
62
+
63
+ it('carries devicesFile through the config mapping used by sync() (regression)', () => {
64
+ const file = join(dir, 'devices.json')
65
+ const mapped = pairingConfigOf({
66
+ tokenTtlMs: 60_000,
67
+ offlineAfterMs: 25_000,
68
+ maxDevices: 4,
69
+ cookieName: 'dsh_pair',
70
+ devicesFile: file,
71
+ })
72
+ expect(mapped.devicesFile).toBe(file)
73
+ })
74
+
75
+ it('keeps persisting after a sync-style service.config rebuild', () => {
76
+ const file = join(dir, 'devices.json')
77
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
78
+ // applyImpl/sync() reassigns service.config from the resolved config on
79
+ // mount and on every settings change; the rebuild must not drop devicesFile.
80
+ service.config = pairingConfigOf({
81
+ tokenTtlMs: 60_000,
82
+ offlineAfterMs: 25_000,
83
+ maxDevices: 4,
84
+ cookieName: 'dsh_pair',
85
+ devicesFile: file,
86
+ })
87
+ const deviceId = pairDevice(service)
88
+ const restarted = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
89
+ expect(restarted.hasDevice(deviceId)).toBe(true)
90
+ })
91
+
92
+ it('clamps a persisted table to maxDevices when the file holds more sessions', () => {
93
+ const file = join(dir, 'devices.json')
94
+ writeFileSync(file, JSON.stringify({
95
+ dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
96
+ dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
97
+ dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
98
+ }))
99
+ const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
100
+ expect(service.hasDevice('dev3')).toBe(true)
101
+ expect(service.hasDevice('dev2')).toBe(true)
102
+ expect(service.hasDevice('dev1')).toBe(false)
103
+ })
104
+
105
+ it('keeps sessions memory-only when devicesFile is unset', () => {
106
+ const first = new PairingService({ ...BASE_CONFIG }, makeClock())
107
+ const deviceId = pairDevice(first)
108
+ // No file was written; a fresh instance without the option knows nothing.
109
+ const second = new PairingService({ ...BASE_CONFIG }, makeClock())
110
+ expect(second.hasDevice(deviceId)).toBe(false)
111
+ })
112
+
113
+ it('persists revocation by stop() across a restart', () => {
114
+ const file = join(dir, 'devices.json')
115
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
116
+ const deviceId = pairDevice(first)
117
+ first.stop()
118
+
119
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
120
+ expect(second.hasDevice(deviceId)).toBe(false)
121
+ })
122
+
123
+ it('persists FIFO eviction when the device cap is reached', () => {
124
+ const file = join(dir, 'devices.json')
125
+ const first = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
126
+ const firstDevice = pairDevice(first)
127
+ const secondDevice = pairDevice(first)
128
+ const thirdDevice = pairDevice(first) // evicts firstDevice
129
+ expect(first.hasDevice(firstDevice)).toBe(false)
130
+
131
+ const second = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
132
+ expect(second.hasDevice(secondDevice)).toBe(true)
133
+ expect(second.hasDevice(thirdDevice)).toBe(true)
134
+ expect(second.hasDevice(firstDevice)).toBe(false)
135
+ })
136
+
137
+ it('tolerates a corrupt or missing file instead of refusing to boot', () => {
138
+ const file = join(dir, 'devices.json')
139
+ writeFileSync(file, '{ this is not json !!!')
140
+ expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())).not.toThrow()
141
+
142
+ const ghost = join(dir, 'does-not-exist.json')
143
+ expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow()
144
+ })
145
+ })
package/src/pairing.ts CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  import { randomBytes } from 'node:crypto'
19
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
20
+ import { dirname } from 'node:path'
19
21
 
20
22
  /** The observable pairing phases the panel renders. */
21
23
  export type PairingPhase =
@@ -95,6 +97,13 @@ export interface PairingConfig {
95
97
  maxDevices: number
96
98
  /** Cookie name carrying the device id. */
97
99
  cookieName: string
100
+ /**
101
+ * Path to a JSON file where paired device sessions are persisted. When
102
+ * set, sessions survive process restarts (the phone keeps its 365-day
103
+ * cookie), so re-pairing after a dsh web restart is not required. When
104
+ * unset (default), sessions stay memory-only — the previous behavior.
105
+ */
106
+ devicesFile?: string
98
107
  }
99
108
 
100
109
  /** Result of one accept() attempt. */
@@ -154,7 +163,65 @@ export class PairingService {
154
163
  constructor(
155
164
  public config: PairingConfig,
156
165
  private readonly clock: PairingClock = defaultClock,
157
- ) {}
166
+ ) {
167
+ this.loadPersisted()
168
+ }
169
+
170
+ /**
171
+ * Restore device sessions persisted by a previous process run. A corrupt
172
+ * or missing file is tolerated (an empty device table, never a throw) —
173
+ * persistence is an availability convenience, not a security boundary.
174
+ */
175
+ private loadPersisted(): void {
176
+ const file = this.config.devicesFile
177
+ if (file === undefined) return
178
+ try {
179
+ const saved = JSON.parse(readFileSync(file, 'utf8')) as unknown
180
+ if (typeof saved !== 'object' || saved === null) return
181
+ for (const [deviceId, session] of Object.entries(saved)) {
182
+ if (typeof deviceId !== 'string') continue
183
+ if (typeof session !== 'object' || session === null) continue
184
+ const { createdAt, lastSeenAt } = session as { createdAt?: unknown; lastSeenAt?: unknown }
185
+ if (typeof createdAt !== 'number' || typeof lastSeenAt !== 'number') continue
186
+ this.devices.set(deviceId, { createdAt, lastSeenAt })
187
+ }
188
+ this.clampToMaxDevices()
189
+ } catch {
190
+ // Unreadable/corrupt: start empty rather than refusing to boot.
191
+ }
192
+ }
193
+
194
+ /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
195
+ private clampToMaxDevices(): void {
196
+ if (this.devices.size <= this.config.maxDevices) return
197
+ const overflow = this.devices.size - this.config.maxDevices
198
+ const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
199
+ for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id)
200
+ }
201
+
202
+ /**
203
+ * Write the current device table to the configured file. Called on the
204
+ * mutation boundaries that change the set of live sessions (accept and
205
+ * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
206
+ * per-request write storm is avoided — after a restart the phone's first
207
+ * heartbeat re-warms its own session.
208
+ *
209
+ * Device ids are session credentials (the gate authorizes requests by the
210
+ * cookie's device id), so the file is written 0600 via a temp file and
211
+ * atomic rename; a crash mid-write can never leave a half-written store.
212
+ */
213
+ private persist(): void {
214
+ const file = this.config.devicesFile
215
+ if (file === undefined) return
216
+ try {
217
+ mkdirSync(dirname(file), { recursive: true })
218
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
219
+ writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 0o600 })
220
+ renameSync(temp, file)
221
+ } catch (error) {
222
+ console.error('remote-web-ui: failed to persist paired devices', error)
223
+ }
224
+ }
158
225
 
159
226
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
160
227
  get lanBaseUrl(): string | undefined {
@@ -254,6 +321,7 @@ export class PairingService {
254
321
  if (oldest !== undefined) this.devices.delete(oldest.id)
255
322
  }
256
323
  this.devices.set(deviceId, { createdAt: now, lastSeenAt: now })
324
+ this.persist()
257
325
  this.notify()
258
326
  return { ok: true, deviceId }
259
327
  }
@@ -266,6 +334,7 @@ export class PairingService {
266
334
  stop(): void {
267
335
  this.tokens.clear()
268
336
  this.devices.clear()
337
+ this.persist()
269
338
  this.stopped = true
270
339
  this.notify()
271
340
  }
package/src/routes.ts CHANGED
@@ -13,6 +13,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
13
13
  import { z, type ZodType } from 'zod'
14
14
  import { UnknownLanAddressError, type PairingService, type PairingSnapshot } from './pairing.ts'
15
15
  import { isLoopbackClient, readCookie } from './gate.ts'
16
+ import { readBoundedJson, writeJson } from './http.ts'
16
17
 
17
18
  /**
18
19
  * Browser-trust fence for the /api/pair routes, mirroring the connection
@@ -120,25 +121,10 @@ function parsePairPayload<T>(schema: ZodType<T>, body: Record<string, unknown> |
120
121
  return result.success ? result.data : undefined
121
122
  }
122
123
 
123
- /** One JSON response. */
124
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
125
- const payload = JSON.stringify(body)
126
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
127
- res.end(payload)
128
- }
129
-
130
- /** Read a request body up to MAX_BODY_BYTES and parse it as JSON. */
124
+ /** Read a request body up to MAX_BODY_BYTES and parse it as JSON (undefined on failure). */
131
125
  async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
132
- const chunks: Buffer[] = []
133
- let size = 0
134
- for await (const chunk of req) {
135
- const buffer = chunk as Buffer
136
- size += buffer.length
137
- if (size > MAX_BODY_BYTES) return undefined
138
- chunks.push(buffer)
139
- }
140
126
  try {
141
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
127
+ const parsed: unknown = await readBoundedJson(req, MAX_BODY_BYTES)
142
128
  return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
143
129
  } catch {
144
130
  return undefined
@@ -238,6 +224,14 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
238
224
  const rateLimitAccept = (req: IncomingMessage): boolean => {
239
225
  const ip = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
240
226
  const nowMs = Date.now()
227
+ // The map lives as long as the plugin: prune expired windows once the
228
+ // table grows past a modest size so distinct source IPs (LAN clients,
229
+ // brute-force scans) cannot accumulate forever.
230
+ if (acceptAttempts.size > 256) {
231
+ for (const [key, attempt] of acceptAttempts) {
232
+ if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key)
233
+ }
234
+ }
241
235
  const entry = acceptAttempts.get(ip)
242
236
  if (entry === undefined || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
243
237
  acceptAttempts.set(ip, { count: 1, windowStart: nowMs })
@@ -270,7 +264,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
270
264
  const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`
271
265
  writeJson(res, 200, {
272
266
  ok: true,
273
- url: `${base}/?pair=${token}${workspaceQuery}`,
267
+ url: `${base}/m/?pair=${token}${workspaceQuery}`,
274
268
  token,
275
269
  expiresAt,
276
270
  // Every constructible base, so a multi-homed panel can switch the
@@ -8,6 +8,7 @@
8
8
  import type { IncomingMessage, ServerResponse } from 'node:http'
9
9
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
10
10
  import type { UpdateRunResult, UpdateStatus } from './update.ts'
11
+ import { writeJson } from './http.ts'
11
12
 
12
13
  /** Route paths (exact matches under /api). */
13
14
  export const UPDATE_PATHS = {
@@ -25,13 +26,6 @@ export interface UpdateRoutesDeps {
25
26
  run(): Promise<UpdateRunResult>
26
27
  }
27
28
 
28
- /** One JSON response. */
29
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
30
- const payload = JSON.stringify(body)
31
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
32
- res.end(payload)
33
- }
34
-
35
29
  /**
36
30
  * Build the /api/update route family.
37
31
  * @param deps - fence + check/run seams.
package/src/update.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Remote update support for the dsh-web-ui family — host half. Detects the
3
- * installed aggregate package (@linxin666/dsh-web-ui-all) and its family
4
- * children, probes the npm registry for newer releases, and runs the actual
5
- * update as `pnpm update --latest` inside the owning dsh profile directory.
3
+ * installed aggregate package (@linxin666/dsh-web-ui-all), or the directly
4
+ * installed family packages when the aggregate is absent, probes npm for newer
5
+ * releases, and runs `pnpm update --latest` inside the owning dsh profile.
6
6
  *
7
7
  * Pure logic with injected seams (manifest reading, registry fetches, process
8
8
  * spawning) so the whole surface is unit-testable without touching disk,
@@ -235,10 +235,13 @@ export function resolveUpdateTarget(
235
235
  const profileManifest = readManifest(join(profile.dir, 'package.json'))
236
236
  const spec = (profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor]
237
237
  if (isLinkedSpec(spec)) return { error: 'link' }
238
+ // Standalone installs carry no aggregate: the anchor's own dependency
239
+ // list misses every sibling @linxin666/* plugin installed directly into
240
+ // the profile, so union the profile's direct family deps (#377).
238
241
  return {
239
242
  profileName: profile.name,
240
243
  profileDir: profile.dir,
241
- packages: [anchor, ...familyChildren(manifest)],
244
+ packages: familyUpdatePackages(anchor, manifest, profileManifest),
242
245
  }
243
246
  }
244
247
 
@@ -253,6 +256,21 @@ export function familyChildren(anchorManifest: Record<string, unknown>): string[
253
256
  return names
254
257
  }
255
258
 
259
+ /** Registry-managed family packages covered by one update operation. */
260
+ function familyUpdatePackages(
261
+ anchor: string,
262
+ anchorManifest: Record<string, unknown>,
263
+ profileManifest: Record<string, unknown> | undefined,
264
+ ): string[] {
265
+ const names = new Set([anchor, ...familyChildren(anchorManifest)])
266
+ const dependencies = profileManifest?.dependencies
267
+ if (typeof dependencies !== 'object' || dependencies === null) return [...names]
268
+ for (const [name, spec] of Object.entries(dependencies)) {
269
+ if (name.startsWith(FAMILY_SCOPE) && typeof spec === 'string' && !isLinkedSpec(spec)) names.add(name)
270
+ }
271
+ return [...names]
272
+ }
273
+
256
274
  /**
257
275
  * Probe the npm registry for one package's latest release.
258
276
  * @param name - the package name (scope slash URL-encoded).
@@ -292,14 +310,21 @@ export async function fetchLatestVersion(
292
310
  const VERSION_UNKNOWN = '0.0.0'
293
311
 
294
312
  /** The resolved current version of one family package (probe failure tolerated). */
295
- function readInstalledVersion(resolve: (specifier: string) => string | undefined, name: string): string {
313
+ function readInstalledVersion(
314
+ resolve: (specifier: string) => string | undefined,
315
+ name: string,
316
+ profileDir?: string,
317
+ ): string {
296
318
  try {
297
319
  const path = resolve(name + '/package.json')
298
320
  const version = path === undefined ? undefined : readManifest(path)?.version
299
- return typeof version === 'string' ? version : VERSION_UNKNOWN
300
- } catch {
301
- return VERSION_UNKNOWN
302
- }
321
+ if (typeof version === 'string') return version
322
+ } catch { /* fall through to the profile's direct dependency path */ }
323
+ // Names originate in the profile dependency map, but still validate the
324
+ // npm package shape before using one as path segments.
325
+ if (profileDir === undefined || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN
326
+ const version = readManifest(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'))?.version
327
+ return typeof version === 'string' ? version : VERSION_UNKNOWN
303
328
  }
304
329
 
305
330
  /**
@@ -331,20 +356,26 @@ export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus>
331
356
  if (profile === undefined) {
332
357
  return { mode: 'link', packages: [], outdated: false }
333
358
  }
334
- const names = [anchor, ...familyChildren(manifest)]
359
+ // Union the profile's direct family deps so standalone installs (no
360
+ // aggregate) still check every installed @linxin666/* plugin (#377);
361
+ // familyUpdatePackages skips link:/file: development dependencies.
362
+ const names = familyUpdatePackages(anchor, manifest, profileManifest)
363
+ // The registry probes are independent: run them together instead of
364
+ // serializing up to N x 10s of registry latency behind one status call.
365
+ const latestList = await Promise.all(names.map(name => deps.fetchLatest(name)))
335
366
  const packages: UpdatePackageStatus[] = []
336
367
  let probeFailures = 0
337
- for (const name of names) {
338
- const latest = await deps.fetchLatest(name)
368
+ names.forEach((name, index) => {
369
+ const latest = latestList[index]
339
370
  if (latest === undefined) probeFailures++
340
- const current = readInstalledVersion(deps.resolve, name)
371
+ const current = readInstalledVersion(deps.resolve, name, profile.dir)
341
372
  packages.push({
342
373
  name,
343
374
  current,
344
375
  latest,
345
376
  outdated: latest !== undefined && latest !== current && compareVersions(latest, current) > 0,
346
377
  })
347
- }
378
+ })
348
379
  // Registry unreachable: every probe failed — report the outage distinctly
349
380
  // instead of a misleading "all up to date" (the panel needs the reason).
350
381
  const error = probeFailures === names.length && names.length > 0 ? 'registry-unreachable' : undefined
@@ -601,7 +632,7 @@ export async function runUpdateVerified(deps: UpdateRunVerifiedDeps): Promise<Up
601
632
  // green exit that left everything in place could be mistaken for success.
602
633
  const before = new Map<string, string>()
603
634
  for (const name of deps.run.packages) {
604
- const version = readInstalledVersion(deps.check.resolve, name)
635
+ const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir)
605
636
  if (version !== VERSION_UNKNOWN) before.set(name, version)
606
637
  }
607
638
  const result = await runUpdate(deps.run)