@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.
- package/README.i18n.yaml +2 -2
- package/README.md +5 -2
- package/README.zh.md +1 -1
- package/lib/client.js +30 -17
- package/lib/client.js.map +1 -1
- package/lib/index.js +177 -34
- package/lib/mobile.js +44 -25
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +27 -7
- package/lib/types/client/locales.js +2 -2
- package/lib/types/client/settings-form.d.ts +14 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +38 -10
- package/lib/types/mobile/mux.d.ts +13 -4
- package/lib/types/mobile/mux.d.ts.map +1 -1
- package/lib/types/mobile/mux.js +50 -30
- package/lib/types/mobile/mux.test.js +75 -6
- package/lib/types/mobile-api.d.ts +2 -0
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +4 -1
- package/lib/types/routes.d.ts +17 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +49 -10
- package/lib/types/update.d.ts +5 -1
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +122 -26
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +83 -17
- package/src/client/locales.ts +2 -2
- package/src/client/settings-card.module.css +6 -0
- package/src/client/settings-form.ts +41 -9
- package/src/mobile/mux.test.ts +84 -6
- package/src/mobile/mux.ts +46 -25
- package/src/mobile-api.ts +6 -1
- package/src/routes.ts +51 -10
- 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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
94
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
if (
|
|
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)
|
|
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()
|
package/src/mobile/mux.test.ts
CHANGED
|
@@ -80,8 +80,9 @@ describe('MuxClient polling fallback', () => {
|
|
|
80
80
|
await vi.advanceTimersByTimeAsync(400)
|
|
81
81
|
expect(pollLatest).not.toHaveBeenCalled()
|
|
82
82
|
|
|
83
|
-
//
|
|
84
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
102
|
-
private
|
|
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.
|
|
131
|
+
this.startTick()
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
/** Close for good. */
|
|
127
135
|
stop(): void {
|
|
128
136
|
this.stopped = true
|
|
129
|
-
this.
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
if (this.
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
},
|
|
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
|
|
229
|
-
|
|
230
|
-
:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
277
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
}
|