@linxin666/dsh-remote-web-ui 0.1.13 → 0.1.15

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 (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -2
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +30 -17
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +177 -34
  7. package/lib/mobile.js +44 -25
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +27 -7
  12. package/lib/types/client/locales.js +2 -2
  13. package/lib/types/client/settings-form.d.ts +14 -5
  14. package/lib/types/client/settings-form.d.ts.map +1 -1
  15. package/lib/types/client/settings-form.js +38 -10
  16. package/lib/types/mobile/mux.d.ts +13 -4
  17. package/lib/types/mobile/mux.d.ts.map +1 -1
  18. package/lib/types/mobile/mux.js +50 -30
  19. package/lib/types/mobile/mux.test.js +75 -6
  20. package/lib/types/mobile-api.d.ts +2 -0
  21. package/lib/types/mobile-api.d.ts.map +1 -1
  22. package/lib/types/mobile-api.js +4 -1
  23. package/lib/types/routes.d.ts +17 -0
  24. package/lib/types/routes.d.ts.map +1 -1
  25. package/lib/types/routes.js +49 -10
  26. package/lib/types/update.d.ts +5 -1
  27. package/lib/types/update.d.ts.map +1 -1
  28. package/lib/types/update.js +122 -26
  29. package/package.json +1 -1
  30. package/src/client/PluginSettingsCard.tsx +83 -17
  31. package/src/client/locales.ts +2 -2
  32. package/src/client/settings-card.module.css +6 -0
  33. package/src/client/settings-form.ts +41 -9
  34. package/src/mobile/mux.test.ts +84 -6
  35. package/src/mobile/mux.ts +46 -25
  36. package/src/mobile-api.ts +6 -1
  37. package/src/routes.ts +51 -10
  38. package/src/update.ts +116 -26
@@ -1,10 +1,11 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
3
  * Staged form model behind the plugin settings card. A card stages what the
3
4
  * user types and writes it only when they save — the settings write is a
4
5
  * durable, revision-fenced document mutation, so staging keeps what is on
5
- * screen exactly what a save would store. Mirrors the official
6
- * ui-plugin-config card-store pattern in a self-contained slice: this
7
- * package must not depend on a sibling UI package.
6
+ * screen exactly what a save would store. Family-shared slice inlined into
7
+ * each plugin's client bundle; mirrors the official ui-plugin-config
8
+ * card-store pattern.
8
9
  */
9
10
 
10
11
  import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -90,8 +91,17 @@ interface PlannedWrite {
90
91
  run: (() => Promise<boolean>) | undefined
91
92
  }
92
93
 
93
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
94
- export function numberField(field: string): FieldSpec {
94
+ /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
95
+ export interface NumberConstraints {
96
+ /** The accepted value must be a whole number. */
97
+ integer?: boolean
98
+ /** The accepted value must be at least this. */
99
+ min?: number
100
+ }
101
+
102
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
103
+ export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
104
+ const { integer = false, min } = constraints
95
105
  return {
96
106
  field,
97
107
  format: value => typeof value === 'number' ? String(value) : '',
@@ -99,7 +109,10 @@ export function numberField(field: string): FieldSpec {
99
109
  const trimmed = text.trim()
100
110
  if (trimmed === '') return { kind: 'clear' }
101
111
  const parsed = Number(trimmed)
102
- return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
112
+ if (!Number.isFinite(parsed)) return undefined
113
+ if (integer && !Number.isInteger(parsed)) return undefined
114
+ if (min !== undefined && parsed < min) return undefined
115
+ return { kind: 'set', value: parsed }
103
116
  },
104
117
  }
105
118
  }
@@ -122,13 +135,27 @@ export function booleanField(field: string): FieldSpec {
122
135
  field,
123
136
  format: value => typeof value === 'boolean' ? String(value) : '',
124
137
  parse: (text) => {
125
- if (text === 'true') return { kind: 'set', value: true }
126
- if (text === 'false') return { kind: 'set', value: false }
138
+ const trimmed = text.trim()
139
+ if (trimmed === '') return { kind: 'clear' }
140
+ if (trimmed === 'true') return { kind: 'set', value: true }
141
+ if (trimmed === 'false') return { kind: 'set', value: false }
127
142
  return undefined
128
143
  },
129
144
  }
130
145
  }
131
146
 
147
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
148
+ export function choiceField(field: string, choices: readonly string[]): FieldSpec {
149
+ return {
150
+ field,
151
+ format: value => typeof value === 'string' && choices.includes(value) ? value : '',
152
+ parse: (text) => {
153
+ if (text === '') return { kind: 'clear' }
154
+ return choices.includes(text) ? { kind: 'set', value: text } : undefined
155
+ },
156
+ }
157
+ }
158
+
132
159
  /**
133
160
  * Stages one card's edits over one settings namespace and writes them on save.
134
161
  *
@@ -215,6 +242,9 @@ export class CardForm<T> {
215
242
  const plan = this.plan()
216
243
  const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
217
244
  if (plan.length === 0 || this.saving || writes.length !== plan.length) return
245
+ // Snapshot the fields this save writes, so edits staged while it is in
246
+ // flight survive: only the staged keys this save actually wrote are cleared.
247
+ const fields = new Set(plan.map(item => item.field))
218
248
  this.saving = true
219
249
  this.failed = false
220
250
  this.publish()
@@ -222,7 +252,9 @@ export class CardForm<T> {
222
252
  for (const write of writes) {
223
253
  landed = await write() && landed
224
254
  }
225
- if (landed) this.staged.clear()
255
+ if (landed) {
256
+ for (const field of fields) this.staged.delete(field)
257
+ }
226
258
  this.saving = false
227
259
  this.failed = !landed
228
260
  this.publish()
@@ -80,8 +80,9 @@ describe('MuxClient polling fallback', () => {
80
80
  await vi.advanceTimersByTimeAsync(400)
81
81
  expect(pollLatest).not.toHaveBeenCalled()
82
82
 
83
- // Past the stall threshold the first poll runs and emits seq 0.
84
- await vi.advanceTimersByTimeAsync(700) // 1100ms total -> first stall-checker tick
83
+ // The single tick (400 ms) crosses the 800 ms stall threshold at 1200 ms:
84
+ // the same tick then runs the first poll and emits seq 0.
85
+ await vi.advanceTimersByTimeAsync(800) // 1200ms total -> first stall crossing tick
85
86
  expect(pollLatest).toHaveBeenCalledWith('s1')
86
87
  expect(frames).toHaveLength(1)
87
88
  expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' })
@@ -104,7 +105,7 @@ describe('MuxClient polling fallback', () => {
104
105
  client.start()
105
106
  client.observe('s1')
106
107
 
107
- await vi.advanceTimersByTimeAsync(1100) // first poll -> 3 events
108
+ await vi.advanceTimersByTimeAsync(1200) // first poll -> 3 events
108
109
  expect(frames).toHaveLength(3)
109
110
 
110
111
  await vi.advanceTimersByTimeAsync(400) // second poll -> same page, nothing new
@@ -119,7 +120,7 @@ describe('MuxClient polling fallback', () => {
119
120
  client.start()
120
121
  client.observe('s1')
121
122
 
122
- await vi.advanceTimersByTimeAsync(1100)
123
+ await vi.advanceTimersByTimeAsync(1200)
123
124
  expect(pollLatest).toHaveBeenCalled()
124
125
 
125
126
  client.observe(undefined)
@@ -136,7 +137,7 @@ describe('MuxClient polling fallback', () => {
136
137
  client.start()
137
138
  client.observe('s1')
138
139
 
139
- await vi.advanceTimersByTimeAsync(1100)
140
+ await vi.advanceTimersByTimeAsync(1200)
140
141
  expect(pollLatest).toHaveBeenCalled()
141
142
 
142
143
  client.stop()
@@ -156,7 +157,7 @@ describe('MuxClient polling fallback', () => {
156
157
  client.observe('s1')
157
158
 
158
159
  // Stall into polling.
159
- await vi.advanceTimersByTimeAsync(1100)
160
+ await vi.advanceTimersByTimeAsync(1200)
160
161
  expect(pollLatest).toHaveBeenCalledTimes(1)
161
162
 
162
163
  // A live mux frame proves SSE delivers again -> fallback stops.
@@ -169,4 +170,81 @@ describe('MuxClient polling fallback', () => {
169
170
  expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' })
170
171
  client.stop()
171
172
  })
173
+
174
+ it('drives the whole lifecycle on a single scheduler tick (one interval)', async () => {
175
+ const { factory } = makeSources()
176
+ const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
177
+ try {
178
+ const pages = [pageOf([0]), pageOf([0, 1])]
179
+ const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
180
+ const client = new MuxClient('/m/api/events.mux', {
181
+ sourceFactory: factory,
182
+ pollLatest,
183
+ stallThresholdMs: 1500,
184
+ pollIntervalMs: 2000,
185
+ })
186
+ client.start()
187
+ client.observe('s1')
188
+
189
+ // Exactly one interval is ever created: the single tick scheduler.
190
+ expect(setIntervalSpy).toHaveBeenCalledTimes(1)
191
+
192
+ // The tick fires below the stall threshold without polling.
193
+ await vi.advanceTimersByTimeAsync(1000)
194
+ expect(pollLatest).not.toHaveBeenCalled()
195
+
196
+ // The same interval arms the fallback once silence passes the threshold
197
+ // (tick at 2000 crosses 1500) and runs the first poll immediately.
198
+ await vi.advanceTimersByTimeAsync(1100) // 2100ms total
199
+ expect(pollLatest).toHaveBeenCalledTimes(1)
200
+ expect(setIntervalSpy).toHaveBeenCalledTimes(1) // still one timer
201
+
202
+ // Subsequent polls ride the same tick at the poll cadence (2000 ms).
203
+ await vi.advanceTimersByTimeAsync(2000)
204
+ expect(pollLatest).toHaveBeenCalledTimes(2)
205
+ client.stop()
206
+ } finally {
207
+ setIntervalSpy.mockRestore()
208
+ }
209
+ })
210
+
211
+ it('paces polls on the tick only after the stall phase ends', async () => {
212
+ const { factory } = makeSources()
213
+ const pages = [pageOf([0]), pageOf([0, 1]), pageOf([0, 1, 2]), pageOf([0, 1, 2, 3])]
214
+ const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
215
+ const client = new MuxClient('/m/api/events.mux', {
216
+ sourceFactory: factory,
217
+ pollLatest,
218
+ stallThresholdMs: 800,
219
+ pollIntervalMs: 400,
220
+ })
221
+ client.start()
222
+ client.observe('s1')
223
+
224
+ // At 1200 ms the stall threshold is crossed on this single 400 ms tick.
225
+ await vi.advanceTimersByTimeAsync(1200)
226
+ expect(pollLatest).toHaveBeenCalledTimes(1)
227
+
228
+ // Each subsequent 400 ms tick is a poll, matching the poll cadence.
229
+ await vi.advanceTimersByTimeAsync(400)
230
+ await vi.advanceTimersByTimeAsync(400)
231
+ await vi.advanceTimersByTimeAsync(400)
232
+ expect(pollLatest).toHaveBeenCalledTimes(4)
233
+ client.stop()
234
+ })
235
+
236
+ it('observe() already in the stall window starts the single-tick poller immediately', async () => {
237
+ const { factory } = makeSources()
238
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
239
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
240
+ client.start()
241
+ // Advance well past the stall threshold while nothing is observed.
242
+ await vi.advanceTimersByTimeAsync(2000)
243
+ expect(pollLatest).not.toHaveBeenCalled()
244
+
245
+ // Observing a session now is already in the stall window: poll right away.
246
+ client.observe('s1')
247
+ expect(pollLatest).toHaveBeenCalledTimes(1)
248
+ client.stop()
249
+ })
172
250
  })
package/src/mobile/mux.ts CHANGED
@@ -66,6 +66,12 @@ type SessionEventFrame = Extract<MuxFrame, { type: 'session/event' }>
66
66
 
67
67
  const DEFAULT_POLL_INTERVAL_MS = 3000
68
68
  const DEFAULT_STALL_THRESHOLD_MS = 12000
69
+ /**
70
+ * Stall-check granularity: the single scheduler tick runs at least this
71
+ * often so fallback arms within a second of the stall threshold passing,
72
+ * while the poll cadence itself stays {@link MuxClientOptions.pollIntervalMs}.
73
+ */
74
+ const STALL_CHECK_MS = 1000
69
75
  /** Poll window: enough recent events to cover a few seconds of agent output. */
70
76
  const DEFAULT_POLL_PAGE_SIZE = 50
71
77
 
@@ -98,9 +104,11 @@ export class MuxClient {
98
104
  private sseAlive = false
99
105
  /** Per-session highest event seq already emitted, for poll dedup. */
100
106
  private readonly pollWatermark = new Map<string, number>()
101
- private stallTimer: ReturnType<typeof setInterval> | undefined
102
- private pollTimer: ReturnType<typeof setInterval> | undefined
107
+ /** Single scheduler tick: both the stall check and the poll cadence ride this one interval. */
108
+ private tickTimer: ReturnType<typeof setInterval> | undefined
103
109
  private polling = false
110
+ /** Epoch ms of the next due poll while polling (kept on the same tick timer). */
111
+ private nextPollAt = 0
104
112
 
105
113
  /**
106
114
  * @param url - the mobile events endpoint (browser-relative).
@@ -120,16 +128,17 @@ export class MuxClient {
120
128
  this.stopped = false
121
129
  this.lastDataAt = this.now()
122
130
  if (this.source === undefined) this.connect()
123
- this.startStallChecker()
131
+ this.startTick()
124
132
  }
125
133
 
126
134
  /** Close for good. */
127
135
  stop(): void {
128
136
  this.stopped = true
129
- this.stopStallChecker()
137
+ this.stopTick()
130
138
  this.stopPolling()
131
139
  this.closeSource()
132
140
  this.observeSessionId = undefined
141
+ this.nextPollAt = 0
133
142
  }
134
143
 
135
144
  /** Subscribe to validated frames; returns an unsubscribe function. */
@@ -176,40 +185,52 @@ export class MuxClient {
176
185
  }
177
186
  }
178
187
 
179
- private startStallChecker(): void {
180
- this.stopStallChecker()
181
- this.stallTimer = setInterval(() => {
182
- if (this.stopped) return
183
- if (this.observeSessionId === undefined) return
184
- if (this.polling) return
185
- // A live SSE channel only goes silent while the agent idles; never
186
- // poll against it. Fallback arms again only via onerror or a stream
187
- // that has never delivered.
188
- if (this.sseAlive) return
189
- if ((this.now() - this.lastDataAt) > this.stallThresholdMs) this.startPolling()
190
- }, 1000)
188
+ /**
189
+ * The single scheduler tick is the only interval this client owns. One
190
+ * timer (at the finer of the stall-check and poll cadences) both arms the
191
+ * polling fallback once the stall threshold passes and drives each poll at
192
+ * {@link MuxClientOptions.pollIntervalMs} — one timer instead of two.
193
+ */
194
+ private startTick(): void {
195
+ if (this.tickTimer !== undefined) return
196
+ const cadence = Math.min(this.pollIntervalMs, STALL_CHECK_MS)
197
+ this.tickTimer = setInterval(() => { this.tick() }, cadence)
198
+ }
199
+
200
+ private stopTick(): void {
201
+ if (this.tickTimer !== undefined) {
202
+ clearInterval(this.tickTimer)
203
+ this.tickTimer = undefined
204
+ }
191
205
  }
192
206
 
193
- private stopStallChecker(): void {
194
- if (this.stallTimer !== undefined) {
195
- clearInterval(this.stallTimer)
196
- this.stallTimer = undefined
207
+ private tick(): void {
208
+ if (this.stopped) return
209
+ if (this.observeSessionId === undefined) return
210
+ if (this.polling) {
211
+ // The stall phase has ended; the same tick now paces the polls.
212
+ if (this.now() >= this.nextPollAt) {
213
+ this.nextPollAt = this.now() + this.pollIntervalMs
214
+ void this.pollTick()
215
+ }
216
+ return
197
217
  }
218
+ // A live SSE channel only goes silent while the agent idles; never poll
219
+ // against it. Fallback arms again only via onerror or a stream that has
220
+ // never delivered.
221
+ if (this.sseAlive) return
222
+ if ((this.now() - this.lastDataAt) > this.stallThresholdMs) this.startPolling()
198
223
  }
199
224
 
200
225
  private startPolling(): void {
201
226
  if (this.polling || this.stopped) return
202
227
  this.polling = true
228
+ this.nextPollAt = this.now() + this.pollIntervalMs
203
229
  void this.pollTick()
204
- this.pollTimer = setInterval(() => { void this.pollTick() }, this.pollIntervalMs)
205
230
  }
206
231
 
207
232
  private stopPolling(): void {
208
233
  this.polling = false
209
- if (this.pollTimer !== undefined) {
210
- clearInterval(this.pollTimer)
211
- this.pollTimer = undefined
212
- }
213
234
  }
214
235
 
215
236
  /**
package/src/mobile-api.ts CHANGED
@@ -49,6 +49,8 @@ const MOBILE_PREFERENCES_METHOD = 'mobile.preferences'
49
49
 
50
50
  /** One session.list page (thin phones load incrementally). */
51
51
  const SESSION_PAGE_SIZE = 20
52
+ /** SSE keep-alive ping cadence for the live mux stream (single connection). */
53
+ const DEFAULT_EVENTS_HEARTBEAT_MS = 15_000
52
54
 
53
55
  /** Encode one list position as an opaque continuation cursor. */
54
56
  function sessionListCursor(updatedAt: number, sessionId: string): string {
@@ -78,6 +80,8 @@ export interface MobileApiDeps {
78
80
  apiProxy: ApiProxy
79
81
  /** The resolved mobile composer preference (live per request). */
80
82
  mobileEnterToSend: () => boolean
83
+ /** SSE keep-alive ping cadence for the mux stream (default 15000 ms; test seam). */
84
+ eventsHeartbeatMs?: number
81
85
  }
82
86
 
83
87
  /** Mobile API route paths. */
@@ -97,6 +101,7 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`
97
101
  */
98
102
  export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
99
103
  const { service, apiProxy, mobileEnterToSend } = deps
104
+ const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS
100
105
 
101
106
  /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
102
107
  const gateOk = (req: IncomingMessage): boolean => {
@@ -190,7 +195,7 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
190
195
  } catch {
191
196
  // The write failed; the close handler tears the subscription down.
192
197
  }
193
- }, 15_000)
198
+ }, eventsHeartbeatMs)
194
199
  const onClose = (): void => {
195
200
  if (closed) return
196
201
  closed = true
package/src/routes.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { IncomingMessage, ServerResponse } from 'node:http'
12
12
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
13
+ import { z, type ZodType } from 'zod'
13
14
  import { UnknownLanAddressError, type PairingService, type PairingSnapshot } from './pairing.ts'
14
15
  import { isLoopbackClient, readCookie } from './gate.ts'
15
16
 
@@ -91,6 +92,34 @@ export const PAIR_PATHS = {
91
92
  events: '/api/pair/events',
92
93
  } as const
93
94
 
95
+ /**
96
+ * /api/pair request payload contracts. Each POST endpoint validates its body
97
+ * against one of these instead of reaching into a hand-parsed object: the
98
+ * control-plane endpoints that carry no meaningful payload use the permissive
99
+ * pairActionPayloadSchema so their smoke calls keep working unchanged, while
100
+ * issue/accept enforce their optional/required fields. Unknown (extra) keys
101
+ * are tolerated exactly as the previous manual reads ignored them.
102
+ */
103
+ export const issuePayloadSchema = z.object({
104
+ workspaceId: z.string().min(1).optional(),
105
+ address: z.string().min(1).optional(),
106
+ })
107
+ export const acceptPayloadSchema = z.object({
108
+ token: z.string().default(''),
109
+ })
110
+ export const pairActionPayloadSchema = z.object({}).passthrough()
111
+
112
+ /**
113
+ * Parse a pair request body through schema. A missing/empty or non-object
114
+ * body is treated as an empty object (the same way the previous manual reads
115
+ * yielded absent fields), and a value that fails the schema returns
116
+ * `undefined` so the caller can answer with the existing error shape.
117
+ */
118
+ function parsePairPayload<T>(schema: ZodType<T>, body: Record<string, unknown> | undefined): T | undefined {
119
+ const result = schema.safeParse(body ?? {})
120
+ return result.success ? result.data : undefined
121
+ }
122
+
94
123
  /** One JSON response. */
95
124
  function writeJson(res: ServerResponse, status: number, body: unknown): void {
96
125
  const payload = JSON.stringify(body)
@@ -225,12 +254,12 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
225
254
  return
226
255
  }
227
256
  const body = await readJsonBody(req)
228
- const workspaceId = body === undefined || typeof body.workspaceId !== 'string' || body.workspaceId === ''
229
- ? undefined
230
- : body.workspaceId
231
- const address = body === undefined || typeof body.address !== 'string' || body.address === ''
232
- ? undefined
233
- : body.address
257
+ const payload = parsePairPayload(issuePayloadSchema, body)
258
+ if (payload === undefined) {
259
+ writeJson(res, 400, { ok: false, code: 'bad-payload' })
260
+ return
261
+ }
262
+ const { workspaceId, address } = payload
234
263
  try {
235
264
  const { token, expiresAt } = service.issue(workspaceId, address)
236
265
  // The default base is the public (tunneled) URL when configured — a
@@ -273,8 +302,12 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
273
302
  return
274
303
  }
275
304
  const body = await readJsonBody(req)
276
- const token = typeof body?.token === 'string' ? body.token : ''
277
- const result = service.accept(token)
305
+ const payload = parsePairPayload(acceptPayloadSchema, body)
306
+ if (payload === undefined) {
307
+ writeJson(res, 400, { ok: false, code: 'bad-payload' })
308
+ return
309
+ }
310
+ const result = service.accept(payload.token)
278
311
  if (!result.ok) {
279
312
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
280
313
  return
@@ -294,7 +327,11 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
294
327
  writeJson(res, 403, { ok: false, code: 'forbidden' })
295
328
  return
296
329
  }
297
- await readJsonBody(req)
330
+ const body = await readJsonBody(req)
331
+ if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
332
+ writeJson(res, 400, { ok: false, code: 'bad-payload' })
333
+ return
334
+ }
298
335
  service.stop()
299
336
  writeJson(res, 200, { ok: true })
300
337
  }
@@ -305,7 +342,11 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
305
342
  writeJson(res, 403, { ok: false, code: 'forbidden' })
306
343
  return
307
344
  }
308
- await readJsonBody(req)
345
+ const body = await readJsonBody(req)
346
+ if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
347
+ writeJson(res, 400, { ok: false, code: 'bad-payload' })
348
+ return
349
+ }
309
350
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
310
351
  if (deviceId === undefined || !service.heartbeat(deviceId)) {
311
352
  writeJson(res, 401, { ok: false, code: 'unpaired' })
package/src/update.ts CHANGED
@@ -383,53 +383,143 @@ export interface UpdateRunDeps {
383
383
  spawnImpl?: typeof spawn
384
384
  /** Hard timeout; the child is killed on expiry. */
385
385
  timeoutMs?: number
386
+ /** Platform override (defaults to process.platform; test seam). */
387
+ platform?: NodeJS.Platform
386
388
  }
387
389
 
388
390
  /** Cap on captured pnpm output (keeps error payloads bounded). */
389
391
  const OUTPUT_CAP = 16 * 1024
390
392
 
391
393
  /**
392
- * Run the update: `pnpm update <packages>` inside the profile directory.
394
+ * Windows cmd command-not-found stderr. With shell:true a missing shim
395
+ * exits with code 1 (cmd cannot report ENOENT), so the fallback chain
396
+ * detects this message instead of the spawn error event.
397
+ */
398
+ const WIN_CMD_MISSING_RE = /not recognized as an internal or external command/i
399
+
400
+ /**
401
+ * Run the update inside the profile directory. Tries pnpm first, falls back
402
+ * to corepack and then npx when the previous command is missing (ENOENT);
403
+ * all candidates share one hard timeout and keep accumulating output.
393
404
  * @param deps - profile dir, package list, and spawn/timeout seams.
394
405
  * @returns the outcome with captured output.
395
406
  */
396
407
  export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
397
408
  return new Promise((resolve) => {
398
- const child = (deps.spawnImpl ?? spawn)('pnpm', ['update', ...deps.packages], {
409
+ const spawnImpl = deps.spawnImpl ?? spawn
410
+ const packages = deps.packages
411
+ const platform = deps.platform ?? process.platform
412
+ // Windows ships pnpm/corepack/npx as .cmd shims; Node's spawn cannot
413
+ // start .cmd files directly (ENOENT even when installed), so route
414
+ // them through cmd.exe there. POSIX spawns stay shell-free.
415
+ const spawnOptions: Record<string, unknown> = {
399
416
  cwd: deps.profileDir,
400
417
  stdio: ['ignore', 'pipe', 'pipe'],
401
- })
418
+ ...(platform === 'win32' ? { shell: true } : {}),
419
+ }
420
+ // Ordered fallback chain: each is tried only when the previous one is
421
+ // missing on PATH (ENOENT on the spawn error event, never on close).
422
+ const candidates: ReadonlyArray<{ command: string; args: string[] }> = [
423
+ { command: 'pnpm', args: ['update', ...packages] },
424
+ { command: 'corepack', args: ['pnpm', 'update', ...packages] },
425
+ { command: 'npx', args: ['--yes', 'pnpm', 'update', ...packages] },
426
+ ]
427
+ // `output` accumulates across candidates for UI display; `currentOutput`
428
+ // is reset per candidate and carries only that candidate's own diagnostics
429
+ // (its tail is subject to OUTPUT_CAP). The win32 missing-command test must
430
+ // run against `currentOutput` so a previous candidate's 'not recognized'
431
+ // stderr cannot misclassify a real failure in the next one.
402
432
  let output = ''
433
+ let currentOutput = ''
403
434
  const append = (chunk: Buffer): void => {
404
435
  output += chunk.toString('utf8')
405
436
  if (output.length > OUTPUT_CAP) output = output.slice(output.length - OUTPUT_CAP)
437
+ currentOutput += chunk.toString('utf8')
438
+ if (currentOutput.length > OUTPUT_CAP) currentOutput = currentOutput.slice(currentOutput.length - OUTPUT_CAP)
439
+ }
440
+ let currentChild: ReturnType<typeof spawnImpl> | undefined
441
+ // Terminal guard: once a result is produced the promise is settled and no
442
+ // further candidate is started (a killed child's late close must not
443
+ // respawn anything after a timeout resolution).
444
+ let finished = false
445
+ const finish = (result: UpdateRunResult): void => {
446
+ if (finished) return
447
+ finished = true
448
+ clearTimeout(timer)
449
+ resolve(result)
406
450
  }
407
- child.stdout?.on('data', append)
408
- child.stderr?.on('data', append)
409
451
  const timer = setTimeout(() => {
410
- child.kill('SIGTERM')
411
- resolve({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' })
452
+ if (finished) return
453
+ if (platform === 'win32') {
454
+ // cmd.exe under shell:true only kills the shell wrapper; kill the whole
455
+ // process tree best-effort so pnpm/npx do not keep running.
456
+ const pid = (currentChild as { pid?: number } | undefined)?.pid
457
+ if (pid !== undefined && pid > 0) {
458
+ try {
459
+ spawnImpl('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' })
460
+ } catch {
461
+ // Best-effort kill; fall through to the timeout result.
462
+ }
463
+ }
464
+ } else {
465
+ currentChild?.kill('SIGTERM')
466
+ }
467
+ finish({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' })
412
468
  }, deps.timeoutMs ?? 10 * 60_000)
413
- child.on('error', (error: Error) => {
414
- clearTimeout(timer)
415
- const missing = (error as NodeJS.ErrnoException).code === 'ENOENT'
416
- resolve({
417
- ok: false,
418
- exitCode: null,
419
- output,
420
- error: missing ? 'pnpm not found on PATH' : error.message,
421
- errorCode: missing ? 'pnpm-missing' : undefined,
469
+ const runCandidate = (index: number): void => {
470
+ if (finished) return
471
+ if (index >= candidates.length) {
472
+ finish({
473
+ ok: false,
474
+ exitCode: null,
475
+ output,
476
+ error: 'pnpm not found on PATH (tried pnpm, corepack, npx); install pnpm and restart the app',
477
+ errorCode: 'pnpm-missing',
478
+ })
479
+ return
480
+ }
481
+ const candidate = candidates[index]
482
+ currentOutput = ''
483
+ const child = spawnImpl(candidate.command, candidate.args, spawnOptions)
484
+ currentChild = child
485
+ let settled = false
486
+ const once = (fn: () => void): void => {
487
+ if (settled) return
488
+ settled = true
489
+ fn()
490
+ }
491
+ child.stdout?.on('data', append)
492
+ child.stderr?.on('data', append)
493
+ child.on('error', (error: Error) => {
494
+ once(() => {
495
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
496
+ // Command missing: try the next candidate in the chain.
497
+ runCandidate(index + 1)
498
+ } else {
499
+ finish({ ok: false, exitCode: null, output, error: error.message, errorCode: undefined })
500
+ }
501
+ })
422
502
  })
423
- })
424
- child.on('close', (code) => {
425
- clearTimeout(timer)
426
- resolve({
427
- ok: code === 0,
428
- exitCode: code,
429
- output,
430
- error: code === 0 ? undefined : 'pnpm exited with code ' + String(code),
431
- ...(code === 0 ? {} : { errorCode: 'pnpm-failed' as const }),
503
+ child.on('close', (code: number | null) => {
504
+ if (settled || finished) return
505
+ // Windows + shell: a missing command reports exit 1 with
506
+ // 'not recognized' instead of ENOENT — keep the chain going. Checked
507
+ // against this candidate's own output so a prior fallback's stderr
508
+ // cannot misclassify a real failure here.
509
+ if (platform === 'win32' && code !== 0 && WIN_CMD_MISSING_RE.test(currentOutput)) {
510
+ runCandidate(index + 1)
511
+ return
512
+ }
513
+ settled = true
514
+ finish({
515
+ ok: code === 0,
516
+ exitCode: code,
517
+ output,
518
+ error: code === 0 ? undefined : 'pnpm exited with code ' + String(code),
519
+ ...(code === 0 ? {} : { errorCode: 'pnpm-failed' as const }),
520
+ })
432
521
  })
433
- })
522
+ }
523
+ runCandidate(0)
434
524
  })
435
525
  }