@linxin666/dsh-remote-web-ui 0.4.2 → 0.4.3

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 (69) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +8 -6
  3. package/README.zh.md +8 -6
  4. package/lib/client.js +154 -594
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +323 -908
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +8 -9
  9. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/RemoteSettingsCard.js +1 -1
  11. package/lib/types/client/index.d.ts +32 -3
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js +51 -8
  14. package/lib/types/client/locales.d.ts +0 -84
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/client/locales.js +0 -84
  17. package/lib/types/client/settings-entry-form.d.ts +50 -0
  18. package/lib/types/client/settings-entry-form.d.ts.map +1 -0
  19. package/lib/types/client/settings-entry-form.js +98 -0
  20. package/lib/types/index.d.ts +99 -7
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/index.js +104 -137
  23. package/lib/types/lan-bind-plan.d.ts +14 -0
  24. package/lib/types/lan-bind-plan.d.ts.map +1 -1
  25. package/lib/types/lan-bind-plan.js +16 -0
  26. package/lib/types/lan-bind.d.ts +1 -1
  27. package/lib/types/lan-bind.js +1 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/mount-once.d.ts.map +1 -1
  30. package/lib/types/mount-once.js +96 -12
  31. package/lib/types/tunnel.d.ts +60 -3
  32. package/lib/types/tunnel.d.ts.map +1 -1
  33. package/lib/types/tunnel.js +136 -3
  34. package/package.json +2 -2
  35. package/src/client/RemoteEntry.tsx +7 -9
  36. package/src/client/RemoteSettingsCard.tsx +0 -2
  37. package/src/client/index.ts +53 -9
  38. package/src/client/locales.ts +0 -84
  39. package/src/client/remote.module.css +50 -200
  40. package/src/client/settings-entry-form.ts +142 -0
  41. package/src/index.ts +156 -152
  42. package/src/lan-bind-plan.ts +22 -0
  43. package/src/lan-bind.ts +1 -1
  44. package/src/mount-once.ts +107 -13
  45. package/src/tunnel.ts +142 -3
  46. package/lib/types/client/UpdateEntry.d.ts +0 -14
  47. package/lib/types/client/UpdateEntry.d.ts.map +0 -1
  48. package/lib/types/client/UpdateEntry.js +0 -112
  49. package/lib/types/client/UpdatePanel.d.ts +0 -42
  50. package/lib/types/client/UpdatePanel.d.ts.map +0 -1
  51. package/lib/types/client/UpdatePanel.js +0 -101
  52. package/lib/types/client/update-api.d.ts +0 -29
  53. package/lib/types/client/update-api.d.ts.map +0 -1
  54. package/lib/types/client/update-api.js +0 -47
  55. package/lib/types/console-output.d.ts +0 -41
  56. package/lib/types/console-output.d.ts.map +0 -1
  57. package/lib/types/console-output.js +0 -89
  58. package/lib/types/update-routes.d.ts +0 -30
  59. package/lib/types/update-routes.d.ts.map +0 -1
  60. package/lib/types/update-routes.js +0 -47
  61. package/lib/types/update.d.ts +0 -282
  62. package/lib/types/update.d.ts.map +0 -1
  63. package/lib/types/update.js +0 -773
  64. package/src/client/UpdateEntry.tsx +0 -144
  65. package/src/client/UpdatePanel.tsx +0 -256
  66. package/src/client/update-api.ts +0 -49
  67. package/src/console-output.ts +0 -97
  68. package/src/update-routes.ts +0 -65
  69. package/src/update.ts +0 -915
@@ -1,144 +0,0 @@
1
- /**
2
- * The sidebar update seat: the download trigger beside the remote-control
3
- * trigger plus the update panel modal. Owns the flow — probe the registry
4
- * on open, show the result, and let the user start the update from the
5
- * result view (#507), then report the outcome (restart hint on success,
6
- * translated failure on error).
7
- * Component-local state per the client stack rules.
8
- */
9
- import { useCallback, useEffect, useRef, useState } from "react"
10
- import { createPortal } from "react-dom"
11
- import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
12
- import { IconDownloadOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
13
- import type { UpdateStatus } from '../update.ts'
14
- import { fetchUpdateStatus, runUpdate, UpdateStatusError } from "./update-api.ts"
15
- import { UpdatePanel, type UpdateView } from "./UpdatePanel.tsx"
16
- import css from "./remote.module.css"
17
-
18
- /** Entry props: the sidebar column state + the standard locale seat. */
19
- export interface UpdateEntryProps {
20
- /** Whether the sidebar renders wide content (false = 56px rail). */
21
- wide: boolean
22
- t: TranslateNS<'remote'>
23
- }
24
-
25
- /**
26
- * Render the update trigger and panel.
27
- * @param props - column state and locale seat.
28
- * @returns the entry element tree.
29
- */
30
- export function UpdateEntry({ wide, t }: UpdateEntryProps) {
31
- const [open, setOpen] = useState(false)
32
- const [view, setView] = useState<UpdateView>({ kind: "checking" })
33
- const [updateAvailable, setUpdateAvailable] = useState(false)
34
- const runToken = useRef(0)
35
- const availabilityToken = useRef(0)
36
- const mounted = useRef(false)
37
-
38
- const probeAvailability = useCallback(async (): Promise<void> => {
39
- const token = ++availabilityToken.current
40
- try {
41
- const status = await fetchUpdateStatus()
42
- if (token === availabilityToken.current) {
43
- setUpdateAvailable(status.mode === "npm" && status.outdated)
44
- }
45
- } catch {
46
- if (token === availabilityToken.current) setUpdateAvailable(false)
47
- }
48
- }, [])
49
-
50
- const check = useCallback(async (): Promise<void> => {
51
- const availabilityCheck = ++availabilityToken.current
52
- setView({ kind: "checking" })
53
- let status: UpdateStatus
54
- try {
55
- status = await fetchUpdateStatus()
56
- } catch (error) {
57
- if (availabilityCheck === availabilityToken.current) setUpdateAvailable(false)
58
- // HTTP 404: the update route is not mounted — the host process runs an
59
- // older plugin build (client refreshed, host did not). Restarting dsh
60
- // web loads the new plugin; a plain network failure gets the generic
61
- // offline copy instead of a misleading "cannot reach update source".
62
- if (error instanceof UpdateStatusError && error.status === 404) {
63
- setView({ kind: "error", message: t("update.unmounted"), detail: t("update.unmountedDetail") })
64
- return
65
- }
66
- setView({ kind: "error", message: t("update.offline"), detail: t("update.offlineDetail") })
67
- return
68
- }
69
- if (availabilityCheck === availabilityToken.current) {
70
- setUpdateAvailable(status.mode === "npm" && status.outdated)
71
- }
72
- if (status.error === "registry-unreachable") {
73
- setView({ kind: "result", status })
74
- return
75
- }
76
- // Checking never starts an update by itself (#507): the result view
77
- // carries the confirmation button that calls startUpdate.
78
- setView({ kind: "result", status })
79
- }, [t])
80
-
81
- const startUpdate = useCallback(async (status: UpdateStatus): Promise<void> => {
82
- if (status.mode !== "npm" || !status.outdated) return
83
- setView({ kind: "updating", status })
84
- const token = ++runToken.current
85
- try {
86
- const result = await runUpdate()
87
- if (result.ok && mounted.current) setUpdateAvailable(false)
88
- if (token !== runToken.current) return
89
- setView({ kind: "done", result })
90
- } catch {
91
- if (token !== runToken.current) return
92
- setView({ kind: "error", message: t("update.error"), detail: t("update.offlineDetail") })
93
- }
94
- }, [t])
95
-
96
- const openPanel = useCallback((): void => {
97
- setOpen(true)
98
- void check()
99
- }, [check])
100
-
101
- const closePanel = useCallback((): void => {
102
- runToken.current++
103
- setOpen(false)
104
- }, [])
105
-
106
- // Check once after mount without opening the panel or starting an update.
107
- useEffect(() => {
108
- mounted.current = true
109
- void probeAvailability()
110
- return () => {
111
- mounted.current = false
112
- runToken.current++
113
- availabilityToken.current++
114
- }
115
- }, [probeAvailability])
116
-
117
- const updateLabel = updateAvailable ? t("update.availableLabel") : t("update.label")
118
-
119
- return (
120
- <>
121
- <button
122
- type="button"
123
- className={css.trigger}
124
- data-wide={wide ? (updateAvailable ? "wide" : undefined) : "rail"}
125
- data-update-available={updateAvailable ? "true" : undefined}
126
- aria-label={updateLabel}
127
- aria-expanded={open}
128
- title={updateLabel}
129
- onClick={openPanel}
130
- >
131
- <IconDownloadOutlineRegular size={wide ? 16 : 18} />
132
- {updateAvailable && wide && (
133
- <span className={css.updateBadgeText}>{t("update.badge")}</span>
134
- )}
135
- </button>
136
- {open && createPortal((
137
- <div className={css.overlay} role="presentation">
138
- <div className={css.mask} aria-hidden="true" onClick={closePanel} />
139
- <UpdatePanel t={t} view={view} onClose={closePanel} onRecheck={() => { void check() }} onStartUpdate={(status) => { void startUpdate(status) }} />
140
- </div>
141
- ), document.body)}
142
- </>
143
- )
144
- }
@@ -1,256 +0,0 @@
1
- /**
2
- * The update panel body: check progress, version comparison, the
3
- * auto-update in-flight state, and the outcome (restart hint on success,
4
- * translated failure on error). Pure presentation — all state arrives
5
- * through props from the entry behavior component.
6
- */
7
- import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
8
- import { IconCloseOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
9
- import type { UpdateReleaseNotes, UpdateRunResult, UpdateStatus } from '../update.ts'
10
- import css from "./remote.module.css"
11
-
12
- /** The panel view state, owned by the entry component. */
13
- export type UpdateView =
14
- | { kind: 'checking' }
15
- | { kind: 'result'; status: UpdateStatus }
16
- | { kind: 'updating'; status: UpdateStatus }
17
- | { kind: 'done'; result: UpdateRunResult }
18
- | { kind: 'error'; message: string; detail?: string }
19
-
20
- /** Full panel props: copy + view state + actions. */
21
- export interface UpdatePanelProps {
22
- t: TranslateNS<'remote'>
23
- view: UpdateView
24
- onClose(): void
25
- /** Re-run the check from a terminal state. */
26
- onRecheck(): void
27
- /** Start the update from the result view (the confirmation step, #507). */
28
- onStartUpdate(status: UpdateStatus): void
29
- }
30
-
31
- /** The anchor package name (aggregate first) for copy purposes. */
32
- function anchorName(status: UpdateStatus | undefined): string | undefined {
33
- return status?.anchor ?? status?.packages[0]?.name
34
- }
35
-
36
- /** The latest npm release of the anchor, for reference copy. */
37
- function anchorLatest(status: UpdateStatus | undefined): string | undefined {
38
- return status?.packages[0]?.latest
39
- }
40
-
41
- /**
42
- * Render the update panel.
43
- * @param props - copy, view state, and actions.
44
- * @returns the panel element tree.
45
- */
46
- export function UpdatePanel({ t, view, onClose, onRecheck, onStartUpdate }: UpdatePanelProps) {
47
- const status = view.kind === "result" || view.kind === "updating" ? view.status : undefined
48
- const title = view.kind === "done" && view.result.ok ? t("update.done") : t("update.title")
49
- const subtitle = subtitleOf(t, view)
50
- return (
51
- <div className={css.panel} role="dialog" aria-modal="true" aria-label={title}>
52
- <div className={css.header}>
53
- <div className={css.heading}>
54
- <h2 className={css.title}>{title}</h2>
55
- {subtitle !== undefined && <p className={css.subtitle}>{subtitle}</p>}
56
- </div>
57
- <button type="button" className={css.close} aria-label={t("update.close")} onClick={onClose}>
58
- <IconCloseOutlineRegular />
59
- </button>
60
- </div>
61
- {view.kind === "checking" && <p className={css.updateStatus}>{t("update.checking")}</p>}
62
- {view.kind === "result" && status !== undefined && <ResultBody t={t} status={status} />}
63
- {view.kind === "result" && status !== undefined && status.mode === "npm" && status.outdated && status.error === undefined && (
64
- <div className={css.updateActions}>
65
- <button type="button" className={css.updateRetry} onClick={() => onStartUpdate(status)}>
66
- {t("update.start")}
67
- </button>
68
- </div>
69
- )}
70
- {view.kind === "updating" && status !== undefined && (
71
- <div>
72
- <p className={css.updateStatus}>
73
- {t("update.updating", { name: anchorName(status) ?? "", version: anchorLatest(status) ?? "" })}
74
- </p>
75
- <p className={css.updateDetail}>{t("update.cooldownNotice")}</p>
76
- <PackageSummary t={t} status={status} />
77
- </div>
78
- )}
79
- {view.kind === "done" && <DoneBody t={t} result={view.result} />}
80
- {view.kind === "error" && (
81
- <div>
82
- <p className={css.updateError}>{view.message}</p>
83
- {view.detail !== undefined && <pre className={css.updateOutput}>{view.detail}</pre>}
84
- </div>
85
- )}
86
- {(view.kind === "done" || view.kind === "error") && (
87
- <div className={css.updateActions}>
88
- <button type="button" className={css.updateRetry} onClick={onRecheck}>
89
- <IconRefreshOutlineRegular /> {t("update.retry")}
90
- </button>
91
- </div>
92
- )}
93
- </div>
94
- )
95
- }
96
-
97
- /** The subtitle copy per view state (absent on plain results). */
98
- function subtitleOf(t: TranslateNS<"remote">, view: UpdateView): string | undefined {
99
- switch (view.kind) {
100
- case "checking":
101
- return t("update.checking")
102
- case "updating":
103
- return t("update.updatingTitle")
104
- case "result":
105
- return undefined
106
- case "done":
107
- return view.result.ok ? t("update.doneDetail") : t("update.error")
108
- case "error":
109
- // The error body below carries the message; no subtitle duplication.
110
- return undefined
111
- }
112
- }
113
-
114
- /** The checked result body: mode banner + version list. */
115
- function ResultBody({ t, status }: { t: TranslateNS<"remote">; status: UpdateStatus }) {
116
- const anchor = anchorName(status)
117
- const latest = anchorLatest(status)
118
- if (status.mode === "link") {
119
- return (
120
- <div>
121
- <p className={css.updateStatus}>{t("update.linkMode")}</p>
122
- <p className={css.updateDetail}>{t("update.linkModeDetail", { version: latest ?? "-" })}</p>
123
- </div>
124
- )
125
- }
126
- if (status.mode === "missing") {
127
- return (
128
- <div>
129
- <p className={css.updateStatus}>{t("update.missing")}</p>
130
- <p className={css.updateDetail}>{t("update.missingDetail")}</p>
131
- </div>
132
- )
133
- }
134
- if (status.error === "registry-unreachable") {
135
- return (
136
- <div>
137
- <p className={css.updateStatus}>{t("update.offline")}</p>
138
- <p className={css.updateDetail}>{t("update.offlineDetail")}</p>
139
- </div>
140
- )
141
- }
142
- if (status.outdated) {
143
- return (
144
- <div>
145
- <p className={css.updateStatus}>{t("update.found")}</p>
146
- <p className={css.updateDetail}>
147
- {anchor !== undefined ? t("update.foundDetail", { name: anchor, version: latest ?? "" }) : ""}
148
- </p>
149
- <p className={css.updateDetail}>{t("update.cooldownNotice")}</p>
150
- <PackageSummary t={t} status={status} />
151
- </div>
152
- )
153
- }
154
- return (
155
- <div>
156
- <p className={css.updateStatus}>{t("update.upToDate")}</p>
157
- {anchor !== undefined && latest !== undefined && (
158
- <p className={css.updateDetail}>{t("update.upToDateDetail", { name: anchor, version: latest })}</p>
159
- )}
160
- <PackageSummary t={t} status={status} />
161
- </div>
162
- )
163
- }
164
-
165
- /** Release-note summary when available; otherwise fall back to the package list. */
166
- function PackageSummary({ t, status }: { t: TranslateNS<'remote'>; status: UpdateStatus }) {
167
- if (status.notes === undefined) return <PackageList status={status} />
168
- return (
169
- <div>
170
- <ReleaseNotes t={t} notes={status.notes} />
171
- <details className={css.updateVersions}>
172
- <summary className={css.updateVersionsSummary}>{t("update.componentVersions")}</summary>
173
- <PackageList status={status} />
174
- </details>
175
- </div>
176
- )
177
- }
178
-
179
- /** Render GitHub Release sections as a compact three-group list. */
180
- function ReleaseNotes({ t, notes }: { t: TranslateNS<'remote'>; notes: UpdateReleaseNotes }) {
181
- const sections = [
182
- { key: 'features', title: t("update.releaseFeatures"), items: notes.features },
183
- { key: 'fixes', title: t("update.releaseFixes"), items: notes.fixes },
184
- { key: 'other', title: t("update.releaseOther"), items: notes.other },
185
- ].filter(section => section.items.length > 0)
186
- if (sections.length === 0) return <p className={css.updateDetail}>{t("update.releaseUnavailable")}</p>
187
- return (
188
- <div className={css.updateNotes}>
189
- <h3 className={css.updateNotesTitle}>{t("update.releaseNotes", { version: notes.version })}</h3>
190
- {sections.map(section => (
191
- <section key={section.key} className={css.updateNoteSection}>
192
- <h4 className={css.updateNoteHeading}>{section.title}</h4>
193
- <ul className={css.updateNoteList}>
194
- {section.items.map((item, index) => (
195
- <li key={index} className={css.updateNoteItem}>{item}</li>
196
- ))}
197
- </ul>
198
- </section>
199
- ))}
200
- </div>
201
- )
202
- }
203
-
204
- /** The per-package current → latest comparison list. */
205
- function PackageList({ status }: { status: UpdateStatus }) {
206
- if (status.packages.length === 0) return null
207
- return (
208
- <ul className={css.updateList}>
209
- {status.packages.map(packageStatus => (
210
- <li key={packageStatus.name} className={css.updateListItem}>
211
- <span className={css.updateListName}>{packageStatus.name}</span>
212
- <span className={css.updateListVersions}>
213
- {packageStatus.current}
214
- {packageStatus.latest !== undefined && packageStatus.latest !== packageStatus.current && (
215
- <> → {packageStatus.latest}</>
216
- )}
217
- </span>
218
- </li>
219
- ))}
220
- </ul>
221
- )
222
- }
223
-
224
- /** The outcome body: success + restart hint, or the translated failure. */
225
- function DoneBody({ t, result }: { t: TranslateNS<"remote">; result: UpdateRunResult }) {
226
- if (result.ok) {
227
- // The title already reads "Update complete"; the body carries the details.
228
- return (
229
- <div>
230
- <p className={css.updateDetail}>{t("update.doneDetail")}</p>
231
- <p className={css.updateDetail}>{t("update.restartHint")}</p>
232
- </div>
233
- )
234
- }
235
- const message = errorMessageOf(t, result)
236
- return (
237
- <div>
238
- <p className={css.updateError}>{message}</p>
239
- {result.output.trim() !== "" && <pre className={css.updateOutput}>{result.output.trim()}</pre>}
240
- </div>
241
- )
242
- }
243
-
244
- /** Translate a structured failure code; fall back to the raw message. */
245
- function errorMessageOf(t: TranslateNS<"remote">, result: UpdateRunResult): string {
246
- switch (result.errorCode) {
247
- case "pnpm-missing": return t("update.error.pnpmMissing")
248
- case "timeout": return t("update.error.timeout")
249
- case "not-found": return t("update.error.notFound")
250
- case "link": return t("update.error.link")
251
- case "pnpm-failed": return t("update.error.pnpmFailed", { code: String(result.exitCode ?? "?") })
252
- case "stale": return t("update.error.stale")
253
- case "verify-failed": return t("update.error.verifyFailed")
254
- default: return result.error ?? t("update.error.unknown")
255
- }
256
- }
@@ -1,49 +0,0 @@
1
- /**
2
- * Browser-side wire helpers for the /api/update surface. Plain fetch over
3
- * same-origin /api like the pairing client; the host half enforces the
4
- * loopback-only fence and owns the pnpm run.
5
- */
6
-
7
- import type { UpdateRunResult, UpdateStatus } from '../update.ts'
8
-
9
- /**
10
- * Error thrown when the update status probe fails: carries the HTTP status
11
- * so the panel can tell "the update route is not mounted" (404 — the host
12
- * process runs an older plugin build) apart from real network failures.
13
- */
14
- export class UpdateStatusError extends Error {
15
- /** HTTP status of the failed response (0 when the fetch never returned). */
16
- readonly status: number
17
- constructor(status: number) {
18
- super('update status unavailable (HTTP ' + String(status) + ')')
19
- this.status = status
20
- }
21
- }
22
-
23
- /**
24
- * Probe the update status: install mode, owning profile, and the
25
- * current-vs-latest comparison for every family package.
26
- * @returns the status snapshot.
27
- */
28
- export async function fetchUpdateStatus(): Promise<UpdateStatus> {
29
- let response: Response
30
- try {
31
- response = await fetch('api/update/status')
32
- } catch {
33
- // Network-level failure: nothing came back at all.
34
- throw new UpdateStatusError(0)
35
- }
36
- if (!response.ok) throw new UpdateStatusError(response.status)
37
- return await response.json() as UpdateStatus
38
- }
39
-
40
- /**
41
- * Run the update (pnpm update in the owning profile). Blocks until pnpm
42
- * exits — the panel shows an in-flight state meanwhile.
43
- * @returns the run outcome.
44
- */
45
- export async function runUpdate(): Promise<UpdateRunResult> {
46
- const response = await fetch('api/update/run', { method: 'POST' })
47
- if (!response.ok) throw new Error('update run unavailable')
48
- return await response.json() as UpdateRunResult
49
- }
@@ -1,97 +0,0 @@
1
- // Generated by scripts/sync-shared.mjs from shared/host/console-output.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
- /**
3
- * Console-output capture shared by the family's Host halves.
4
- *
5
- * A child process's stdout/stderr are raw bytes, and on Windows a console
6
- * program writes the OEM code page (CP936/GBK on a zh-CN system), not UTF-8.
7
- * Decoding each chunk on its own corrupts the text twice over: the
8
- * encoding is guessed wrong, and a multi-byte character split across two reads
9
- * decodes into two replacement characters. This capture accumulates raw bytes
10
- * under a byte budget and decodes exactly once, at read time.
11
- */
12
-
13
- /** Bounded byte accumulator for one child process's interleaved stdout/stderr. */
14
- export interface OutputCapture {
15
- /** Append one raw chunk (the bytes are copied into the budget). */
16
- push(chunk: Uint8Array): void
17
- /**
18
- * Decode everything captured so far. Idempotent, so a caller may read the
19
- * tail both after a failure branch and at settlement.
20
- */
21
- read(): string
22
- }
23
-
24
- /** Count the U+FFFD replacement characters one candidate decode produced. */
25
- function replacementCount(text: string): number {
26
- let count = 0
27
- for (const character of text) {
28
- if (character === '\uFFFD') count += 1
29
- }
30
- return count
31
- }
32
-
33
- /**
34
- * Decode one console byte buffer. Strict UTF-8 wins whenever it is valid;
35
- * otherwise the bytes are not UTF-8 at all and the console wrote its own code
36
- * page, so the candidate that loses the least text is taken.
37
- * @param bytes - the captured bytes.
38
- * @returns the decoded text.
39
- */
40
- export function decodeConsoleBytes(bytes: Uint8Array): string {
41
- try {
42
- return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
43
- } catch {
44
- // Not valid UTF-8. Prefer whichever candidate leaves the fewest
45
- // replacement characters so real UTF-8 text carrying one bad byte is not
46
- // rewritten wholesale as GBK gibberish; without the legacy code page
47
- // (small-ICU builds) the lenient UTF-8 pass is all that is left.
48
- let best: string | undefined
49
- let bestLoss = Number.POSITIVE_INFINITY
50
- for (const label of ['gbk', 'utf-8']) {
51
- let text: string
52
- try {
53
- text = new TextDecoder(label).decode(bytes)
54
- } catch {
55
- continue
56
- }
57
- const loss = replacementCount(text)
58
- if (loss < bestLoss) {
59
- best = text
60
- bestLoss = loss
61
- }
62
- }
63
- /* v8 ignore next 3 -- reachable only without the legacy code page AND without the lenient pass */
64
- if (best === undefined) return Buffer.from(bytes).toString('utf8')
65
- return best
66
- }
67
- }
68
-
69
- /**
70
- * Create a capture bounded to a byte budget. Overflow drops from the FRONT
71
- * (the tail is what carries a failure reason), and the cut never lands inside
72
- * a character: leading UTF-8 continuation bytes are dropped with it, and a
73
- * legacy code page's dangling lead byte — indistinguishable from a real byte
74
- * without knowing the encoding until the whole buffer is decoded — is removed
75
- * with the one replacement character it produced, so an over-long stream still
76
- * reads cleanly at the seam.
77
- * @param maxBytes - maximum retained bytes.
78
- * @returns the capture.
79
- */
80
- export function createOutputCapture(maxBytes: number): OutputCapture {
81
- let buffer = Buffer.alloc(0)
82
- let trimmed = false
83
- return {
84
- push(chunk: Uint8Array): void {
85
- buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, Buffer.from(chunk)])
86
- if (buffer.length <= maxBytes) return
87
- let start = buffer.length - maxBytes
88
- while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1
89
- buffer = Buffer.from(buffer.subarray(start))
90
- trimmed = true
91
- },
92
- read(): string {
93
- const text = decodeConsoleBytes(buffer)
94
- return trimmed && text.startsWith('\uFFFD') ? text.slice(1) : text
95
- },
96
- }
97
- }
@@ -1,65 +0,0 @@
1
- /**
2
- * The /api/update route family: the status probe and the update run. Both
3
- * are loopback-only control surfaces — the run endpoint triggers a real
4
- * pnpm install inside the owning profile, so it must never be reachable
5
- * from a LAN/phone origin.
6
- */
7
-
8
- import type { IncomingMessage, ServerResponse } from 'node:http'
9
- import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
10
- import type { UpdateRunResult, UpdateStatus } from './update.ts'
11
- import { writeJson } from './http.ts'
12
-
13
- /** Route paths (exact matches under /api). */
14
- export const UPDATE_PATHS = {
15
- status: '/api/update/status',
16
- run: '/api/update/run',
17
- } as const
18
-
19
- /** Route-family dependencies (test seam). */
20
- export interface UpdateRoutesDeps {
21
- /** Loopback-only fence: the control endpoints are host-surface only. */
22
- fence(request: IncomingMessage): boolean
23
- /** Probe registry versions and report the comparison. */
24
- check(): Promise<UpdateStatus>
25
- /** Run the pnpm update (resolves when pnpm exits). */
26
- run(): Promise<UpdateRunResult>
27
- }
28
-
29
- /**
30
- * Build the /api/update route family.
31
- * @param deps - fence + check/run seams.
32
- * @returns the exact routes to register on webServer.
33
- */
34
- export function makeUpdateRoutes(deps: UpdateRoutesDeps): WebRoute[] {
35
- const handleStatus = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
36
- if (req.method !== 'GET') {
37
- res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
38
- res.end('method not allowed')
39
- return
40
- }
41
- if (!deps.fence(req)) {
42
- writeJson(res, 403, { ok: false, code: "forbidden" })
43
- return
44
- }
45
- writeJson(res, 200, await deps.check())
46
- }
47
-
48
- const handleRun = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
49
- if (req.method !== 'POST') {
50
- res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
51
- res.end('method not allowed')
52
- return
53
- }
54
- if (!deps.fence(req)) {
55
- writeJson(res, 403, { ok: false, code: "forbidden" })
56
- return
57
- }
58
- writeJson(res, 200, await deps.run())
59
- }
60
-
61
- return [
62
- { kind: 'exact', path: UPDATE_PATHS.status, handler: handleStatus },
63
- { kind: 'exact', path: UPDATE_PATHS.run, handler: handleRun },
64
- ]
65
- }