@huaqiu/dsh-tool-plugin-update 0.6.1 → 0.6.2

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/lib/index.mjs CHANGED
@@ -3,6 +3,22 @@ import { getLogger } from "@huaqiu/dsh-plugin-log";
3
3
  const UPDATE_ROUTE_PREFIX = "/api/v1/huaqiu/plugin-update";
4
4
  /** End-to-end budget for one forwarded request, in milliseconds. */
5
5
  const DEFAULT_FORWARD_TIMEOUT_MS = 3e4;
6
+ /**
7
+ * Budget for `POST /runtime-update/request` — deliberately far larger than
8
+ * {@link DEFAULT_FORWARD_TIMEOUT_MS}.
9
+ *
10
+ * This route is not a quick RPC. Before it answers, hq-edge re-reads the
11
+ * update manifest, downloads the entire runtime bundle (hundreds of MB),
12
+ * verifies its SHA-256, extracts it into the shared staging root and validates
13
+ * the layout + version stamp. Measured end-to-end against a local update
14
+ * server: 56 s (16:49:49 → 16:50:45).
15
+ *
16
+ * Under the generic 30 s budget the forwarder aborts mid-flight and answers
17
+ * `504 hq-edge request timed out after 30000ms`, so the panel reports a failure
18
+ * for an update that is still running perfectly well — and which then goes on
19
+ * to complete, swap the runtime and restart HQ Edge underneath the error toast.
20
+ */
21
+ const RUNTIME_REQUEST_TIMEOUT_MS = 6e5;
6
22
  function sendJson(res, status, body) {
7
23
  const payload = JSON.stringify(body);
8
24
  res.writeHead(status, {
@@ -37,34 +53,85 @@ function readBody(req) {
37
53
  req.on("error", reject);
38
54
  });
39
55
  }
40
- /** Map a sub-path to { method, path } on the hq-edge API. */
41
- function routeFor(sub) {
42
- if (sub === "/report" || sub === "") return {
56
+ /**
57
+ * Map a sub-path + method to { method, path } on the hq-edge API.
58
+ *
59
+ * `timeoutMs` overrides {@link DEFAULT_FORWARD_TIMEOUT_MS} for routes whose
60
+ * upstream work legitimately outlasts it (only `/runtime-request` today).
61
+ *
62
+ * The method is part of the lookup because one sub-path can carry both a read
63
+ * and a write: `/runtime-source` is GET for the current value and POST to
64
+ * change it. A method the route does not declare is `null`, which the
65
+ * forwarder answers 405 rather than forwarding a GET to a POST-only upstream
66
+ * and surfacing hq-edge's 404 as a plugin bug.
67
+ */
68
+ function routeFor(sub, method) {
69
+ if (sub === "/runtime-source") {
70
+ if (method === "GET") return {
71
+ method: "GET",
72
+ path: "/api/v1/runtime-update/source"
73
+ };
74
+ if (method === "POST") return {
75
+ method: "POST",
76
+ path: "/api/v1/runtime-update/source"
77
+ };
78
+ return null;
79
+ }
80
+ if (sub === "/plugin-source") {
81
+ if (method === "GET") return {
82
+ method: "GET",
83
+ path: "/api/v1/plugin-updates/source"
84
+ };
85
+ if (method === "POST") return {
86
+ method: "POST",
87
+ path: "/api/v1/plugin-updates/source"
88
+ };
89
+ return null;
90
+ }
91
+ if (sub === "/report" || sub === "") return method === "GET" ? {
43
92
  method: "GET",
44
93
  path: "/api/v1/plugin-updates"
45
- };
46
- if (sub === "/runtime") return {
94
+ } : null;
95
+ if (sub === "/runtime") return method === "GET" ? {
47
96
  method: "GET",
48
97
  path: "/api/v1/runtime-update"
49
- };
50
- if (sub === "/check") return {
98
+ } : null;
99
+ if (sub === "/runtime-request") return method === "POST" ? {
100
+ method: "POST",
101
+ path: "/api/v1/runtime-update/request",
102
+ timeoutMs: RUNTIME_REQUEST_TIMEOUT_MS
103
+ } : null;
104
+ if (sub === "/check") return method === "POST" ? {
51
105
  method: "POST",
52
106
  path: "/api/v1/plugin-updates/check"
53
- };
54
- if (sub === "/install") return {
107
+ } : null;
108
+ if (sub === "/install") return method === "POST" ? {
55
109
  method: "POST",
56
110
  path: "/api/v1/plugin-updates/install"
57
- };
58
- if (sub === "/ignore") return {
111
+ } : null;
112
+ if (sub === "/ignore") return method === "POST" ? {
59
113
  method: "POST",
60
114
  path: "/api/v1/plugin-updates/ignore"
61
- };
62
- if (sub === "/unignore") return {
115
+ } : null;
116
+ if (sub === "/unignore") return method === "POST" ? {
63
117
  method: "POST",
64
118
  path: "/api/v1/plugin-updates/unignore"
65
- };
119
+ } : null;
66
120
  return null;
67
121
  }
122
+ /** Every sub-path this forwarder serves — used to tell a 404 from a 405. */
123
+ const ROUTE_SUB_PATHS = /* @__PURE__ */ new Set([
124
+ "",
125
+ "/report",
126
+ "/check",
127
+ "/install",
128
+ "/ignore",
129
+ "/unignore",
130
+ "/runtime",
131
+ "/runtime-request",
132
+ "/runtime-source",
133
+ "/plugin-source"
134
+ ]);
68
135
  /** Create the prefix-route handler forwarding to hq-edge. */
69
136
  function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIMEOUT_MS) {
70
137
  return async (req, res) => {
@@ -73,9 +140,9 @@ function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIME
73
140
  sendJson(res, 404, { error: "not found" });
74
141
  return;
75
142
  }
76
- const target = routeFor(sub);
143
+ const target = routeFor(sub, req.method === "HEAD" ? "GET" : req.method ?? "");
77
144
  if (target === null) {
78
- sendJson(res, 404, { error: "not found" });
145
+ sendJson(res, sub === "" || ROUTE_SUB_PATHS.has(sub) ? 405 : 404, { error: sub === "" || ROUTE_SUB_PATHS.has(sub) ? `method ${req.method} not allowed on ${sub || "/"}` : "not found" });
79
146
  return;
80
147
  }
81
148
  if (req.method !== "GET" && req.method !== "POST" && req.method !== "HEAD") {
@@ -94,8 +161,9 @@ function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIME
94
161
  sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
95
162
  return;
96
163
  }
164
+ const budget = target.timeoutMs ?? timeoutMs;
97
165
  const controller = new AbortController();
98
- const timer = setTimeout(() => controller.abort(), timeoutMs);
166
+ const timer = setTimeout(() => controller.abort(), budget);
99
167
  try {
100
168
  const upstream = await fetch(`${base}${target.path}`, {
101
169
  method: target.method,
@@ -117,7 +185,7 @@ function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIME
117
185
  } catch (err) {
118
186
  const message = err instanceof Error ? err.message : String(err);
119
187
  if (err instanceof Error && err.name === "AbortError") {
120
- sendJson(res, 504, { error: `hq-edge request timed out after ${timeoutMs}ms` });
188
+ sendJson(res, 504, { error: `hq-edge request timed out after ${budget}ms` });
121
189
  return;
122
190
  }
123
191
  sendJson(res, 502, { error: `hq-edge request failed: ${message}` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-tool-plugin-update",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -31,8 +31,8 @@
31
31
  "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8"
32
32
  },
33
33
  "dependencies": {
34
- "@huaqiu/dsh-plugin-log": "0.6.1",
35
- "@huaqiu/dsh-sidebar-entry": "0.6.1"
34
+ "@huaqiu/dsh-plugin-log": "0.6.2",
35
+ "@huaqiu/dsh-sidebar-entry": "0.6.2"
36
36
  },
37
37
  "files": [
38
38
  "lib",
package/src/client/api.ts CHANGED
@@ -24,13 +24,83 @@ export interface PluginUpdateItem {
24
24
  notes?: string[]
25
25
  }
26
26
 
27
+ /**
28
+ * One downloadable HQ Edge runtime distribution, as hq-edge reports it
29
+ * (`RuntimeUpdateEntry` in `apps/server/src/dsh/PluginUpdateService.ts`).
30
+ *
31
+ * Note the shape: `runtime.latest` is this ENTRY OBJECT, not a bare version
32
+ * string. Reading it as a string is what once rendered "[object Object]".
33
+ */
34
+ export interface RuntimeUpdateEntry {
35
+ /** Clean SemVer of the available distribution. */
36
+ version: string
37
+ /** SHA-256 of the release archive, lowercase hex (64 chars). */
38
+ sha256: string
39
+ /** Platform the archive targets: win | darwin | linux | all. */
40
+ platform: string
41
+ /** Optional CPU architecture: x64 | arm64 (absent = any). */
42
+ arch?: string
43
+ /** Exact absolute HTTPS URL of the release archive. */
44
+ url: string
45
+ }
46
+
27
47
  export interface RuntimeUpdateInfo {
28
48
  configured: boolean
29
49
  currentVersion?: string | null
30
- latest?: string | null
50
+ /** The latest compatible distribution, or null when the manifest had none. */
51
+ latest?: RuntimeUpdateEntry | null
31
52
  updateAvailable: boolean
32
53
  checkedAt?: string | null
33
54
  lastError?: string | null
55
+ /**
56
+ * Where the runtime-update manifest is read from. Optional: an older hq-edge
57
+ * does not send it, and the panel must degrade to a read-only source row.
58
+ */
59
+ source?: RuntimeUpdateSource
60
+ }
61
+
62
+ /**
63
+ * The runtime-update manifest source, editable from this panel.
64
+ *
65
+ * `origin` says which URL is in effect: `override` is a URL the user set here
66
+ * (persisted by hq-edge per host), `config` is the packaged/env default, and
67
+ * `unset` means runtime updates are not configured at all.
68
+ */
69
+ export interface RuntimeUpdateSource {
70
+ /** The manifest URL currently in effect; empty when none is known. */
71
+ url: string
72
+ origin: 'override' | 'config' | 'unset'
73
+ /** The URL configuration supplies, independent of any user override. */
74
+ configuredUrl: string
75
+ /** True when a user override is shadowing the configured URL. */
76
+ override: boolean
77
+ }
78
+
79
+ /**
80
+ * The plugin release-manifest source (the gitee
81
+ * `plugins-release-manifest.json` by default) — same shape and same edit flow
82
+ * as {@link RuntimeUpdateSource}, because hq-edge serves both from one shared
83
+ * type.
84
+ */
85
+ export type PluginUpdateSource = RuntimeUpdateSource
86
+
87
+ /**
88
+ * The latest runtime version to display, or null when there is none.
89
+ *
90
+ * hq-edge sends the full release entry; an older or future server could send
91
+ * a bare string. Both are accepted here so the facts row can never again fall
92
+ * back to `String(object)` — i.e. "[object Object]" — in the DOM.
93
+ */
94
+ export function runtimeLatestVersion(
95
+ runtime: RuntimeUpdateInfo | null | undefined,
96
+ ): string | null {
97
+ const latest: unknown = runtime?.latest
98
+ if (typeof latest === 'string') return latest.length > 0 ? latest : null
99
+ if (latest !== null && typeof latest === 'object' && 'version' in latest) {
100
+ const version = (latest as { version?: unknown }).version
101
+ if (typeof version === 'string' && version.length > 0) return version
102
+ }
103
+ return null
34
104
  }
35
105
 
36
106
  export interface PluginUpdateReport {
@@ -43,6 +113,11 @@ export interface PluginUpdateReport {
43
113
  fetchedAt?: string
44
114
  lastError?: string | null
45
115
  runtime: RuntimeUpdateInfo
116
+ /**
117
+ * Where the plugin release manifest is read from. Optional: an older
118
+ * hq-edge does not send it, and the panel must degrade to no source row.
119
+ */
120
+ pluginSource?: PluginUpdateSource
46
121
  }
47
122
 
48
123
  async function request<T>(path: string, init?: RequestInit): Promise<T> {
@@ -92,7 +167,87 @@ export function ignoreOffer(setVersion: string): Promise<unknown> {
92
167
  return request<unknown>('/ignore', { method: 'POST', body: JSON.stringify({ setVersion }) })
93
168
  }
94
169
 
170
+ /**
171
+ * The outcome of a user-confirmed HQ Edge runtime update.
172
+ *
173
+ * hq-edge answers `202 { requestId, targetVersion, state: 'accepted' }` and
174
+ * then shuts itself down for the EDA host to swap the runtime, so a success
175
+ * here means "accepted", not "installed". Failures arrive as 409 (already
176
+ * updating → `busy`), 422 (preflight/manifest/download refused) or 503.
177
+ */
178
+ export interface RuntimeUpdateRequestResult {
179
+ requestId?: string
180
+ targetVersion?: string
181
+ state?: string
182
+ ok?: boolean
183
+ error?: string
184
+ busy?: boolean
185
+ }
186
+
187
+ /** POST the user-confirmed HQ Edge runtime update for one target version. */
188
+ export function requestRuntimeUpdate(
189
+ targetVersion: string,
190
+ ): Promise<RuntimeUpdateRequestResult> {
191
+ return request<RuntimeUpdateRequestResult>('/runtime-request', {
192
+ method: 'POST',
193
+ body: JSON.stringify({ targetVersion }),
194
+ })
195
+ }
196
+
95
197
  /** POST unignore for the set version (reverses an ignore). */
96
198
  export function unignoreOffer(setVersion: string): Promise<unknown> {
97
199
  return request<unknown>('/unignore', { method: 'POST', body: JSON.stringify({ setVersion }) })
98
200
  }
201
+
202
+ /**
203
+ * GET the runtime-update manifest source: the URL in effect, its origin
204
+ * (`override` | `config` | `unset`) and the configured URL behind it.
205
+ *
206
+ * Read-only upstream, so this is open like `/report` — only the mutation
207
+ * below is loopback-guarded (and reaches it same-origin anyway).
208
+ */
209
+ export function getRuntimeUpdateSource(): Promise<RuntimeUpdateSource> {
210
+ return request<RuntimeUpdateSource>('/runtime-source')
211
+ }
212
+
213
+ /**
214
+ * POST a new runtime-update manifest URL, or `""` to clear a user override
215
+ * and fall back to the configured URL.
216
+ *
217
+ * hq-edge validates the URL itself (`https` required except for loopback, no
218
+ * embedded credentials) and answers 422 with a message meant for a person —
219
+ * this client surfaces it verbatim rather than pre-validating, so the rule
220
+ * lives in exactly one place. A successful answer is the NEW source; the
221
+ * caller must then re-run discovery, because the cached report still names
222
+ * the old manifest's offer.
223
+ */
224
+ export function setRuntimeUpdateSource(url: string): Promise<RuntimeUpdateSource> {
225
+ return request<RuntimeUpdateSource>('/runtime-source', {
226
+ method: 'POST',
227
+ body: JSON.stringify({ url }),
228
+ })
229
+ }
230
+
231
+ /**
232
+ * GET the plugin release-manifest source: the URL discovery reads, its origin
233
+ * (`override` | `config` | `unset`) and the configured URL behind it. Same
234
+ * open/read-only contract as the runtime source.
235
+ */
236
+ export function getPluginUpdateSource(): Promise<PluginUpdateSource> {
237
+ return request<PluginUpdateSource>('/plugin-source')
238
+ }
239
+
240
+ /**
241
+ * POST a new plugin release-manifest URL, or `""` to clear a user override.
242
+ *
243
+ * hq-edge persists the override per host AND re-runs discovery in the
244
+ * background, so the next report (and the sidebar badge) already reflect the
245
+ * new manifest. This client still re-checks explicitly after a save — the
246
+ * background pass is a safety net for other consumers, not a substitute.
247
+ */
248
+ export function setPluginUpdateSource(url: string): Promise<PluginUpdateSource> {
249
+ return request<PluginUpdateSource>('/plugin-source', {
250
+ method: 'POST',
251
+ body: JSON.stringify({ url }),
252
+ })
253
+ }
@@ -56,10 +56,42 @@ const zh = {
56
56
  unknown: '版本未知',
57
57
  notConfigured: '未配置更新源',
58
58
  note: '运行时更新由 EDA 安装器处理',
59
+ update: '安装运行时更新',
60
+ updateConfirm: (version: string) =>
61
+ `安装 HQ Edge 运行时 ${version}?HQ Edge 会先关闭并完成替换,然后重新启动。`,
62
+ updating: '正在下载并校验运行时包,可能需要一分钟…',
63
+ updateAccepted: '运行时更新已受理,HQ Edge 正在重启…',
64
+ // ── Update-source editor ──
65
+ sourceHint: '运行时更新清单的下载地址,保存后立即生效',
66
+ sourceEdit: '修改',
67
+ sourceSave: '保存',
68
+ sourceReset: '恢复默认',
69
+ saved: '更新源已保存,正在重新检查更新…',
70
+ saveFailed: '保存更新源失败',
71
+ sourceInputLabel: '运行时更新清单地址',
72
+ sourcePlaceholder: 'https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/edge-headless-release-manifest.json',
59
73
  },
74
+ // ── Plugin release-manifest source editor ──
75
+ pluginSource: {
76
+ title: '插件更新源',
77
+ hint: '插件更新清单的下载地址,保存后立即生效',
78
+ edit: '修改',
79
+ save: '保存',
80
+ reset: '恢复默认',
81
+ saved: '更新源已保存,正在重新检查更新…',
82
+ saveFailed: '保存更新源失败',
83
+ inputLabel: '插件更新清单地址',
84
+ placeholder: 'https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/plugins-release-manifest.json',
85
+ },
86
+ // Origin tags, shared by both source editors (hq-edge serves one type).
87
+ originOverride: '自定义',
88
+ originConfig: '默认配置',
89
+ originUnset: '未配置',
60
90
  lastError: '上次检查失败',
61
91
  installError: '安装失败',
62
92
  installSuccess: '更新已安装,正在重启 DSH…',
93
+ confirmInstall: '确认安装',
94
+ cancel: '取消',
63
95
  }
64
96
 
65
97
  export type UpdateCopy = typeof zh
@@ -111,10 +143,43 @@ const en: UpdateCopy = {
111
143
  unknown: 'Version unknown',
112
144
  notConfigured: 'No update source configured',
113
145
  note: 'Runtime updates are handled by the EDA installer',
146
+ update: 'Install runtime update',
147
+ updateConfirm: (version: string) =>
148
+ `Install HQ Edge runtime ${version}? HQ Edge shuts down to swap the runtime, then restarts.`,
149
+ updating: 'Downloading and verifying the runtime bundle — this can take a minute…',
150
+ updateAccepted: 'Runtime update accepted — HQ Edge is restarting…',
151
+ // ── Update-source editor ──
152
+ sourceHint: 'Where the runtime update manifest is fetched from; saved changes take effect immediately',
153
+ sourceEdit: 'Edit',
154
+ sourceSave: 'Save',
155
+ sourceReset: 'Reset to default',
156
+ saved: 'Update source saved — re-checking for updates…',
157
+ saveFailed: 'Failed to save the update source',
158
+ sourceInputLabel: 'Runtime update manifest URL',
159
+ sourcePlaceholder: 'https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/edge-headless-release-manifest.json',
160
+ },
161
+ // ── Plugin release-manifest source editor ──
162
+ pluginSource: {
163
+ title: 'Plugin update source',
164
+ hint: 'Where the plugin update manifest is fetched from; saved changes take effect immediately',
165
+ edit: 'Edit',
166
+ save: 'Save',
167
+ reset: 'Reset to default',
168
+ saved: 'Update source saved — re-checking for updates…',
169
+ saveFailed: 'Failed to save the update source',
170
+ inputLabel: 'Plugin update manifest URL',
171
+ placeholder:
172
+ 'https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/plugins-release-manifest.json',
114
173
  },
174
+ // Origin tags, shared by both source editors (hq-edge serves one type).
175
+ originOverride: 'Custom',
176
+ originConfig: 'Configured',
177
+ originUnset: 'Not configured',
115
178
  lastError: 'Last check failed',
116
179
  installError: 'Install failed',
117
180
  installSuccess: 'Update installed — restarting DSH…',
181
+ confirmInstall: 'Confirm install',
182
+ cancel: 'Cancel',
118
183
  }
119
184
 
120
185
  export const COPY = { zh, en } as const
@@ -694,6 +694,69 @@ const VIEW_CSS_TEXT = `
694
694
  gap: 8px;
695
695
  }
696
696
 
697
+ /* ── Runtime update action row (arm → confirm step) ───────────────────── */
698
+ .hqpu-runtime-actions {
699
+ margin-top: 12px;
700
+ }
701
+ .hqpu-runtime-confirm {
702
+ flex: 1 1 100%;
703
+ margin: 0;
704
+ }
705
+
706
+ /* ── Runtime update source (fact row + editable URL field) ────────────── */
707
+ .hqpu-source-line {
708
+ display: inline-flex;
709
+ align-items: center;
710
+ gap: 8px;
711
+ min-width: 0;
712
+ max-width: 100%;
713
+ }
714
+ .hqpu-source-url {
715
+ min-width: 0;
716
+ overflow: hidden;
717
+ text-overflow: ellipsis;
718
+ white-space: nowrap;
719
+ }
720
+ .hqpu-source-editor {
721
+ display: flex;
722
+ align-items: center;
723
+ flex-wrap: wrap;
724
+ gap: 8px;
725
+ margin-top: 12px;
726
+ }
727
+ /* The plugin-source editor sits inside its own group right under the fact
728
+ * row, so it needs less air than the runtime one under the facts block. */
729
+ .hqpu-source-editor--plugin {
730
+ margin-top: 4px;
731
+ }
732
+ .hqpu-source-hint {
733
+ flex: 1 1 100%;
734
+ margin: 0;
735
+ }
736
+ .hqpu-source-controls {
737
+ flex: 1 1 100%;
738
+ }
739
+ .hqpu-source-input {
740
+ flex: 1 1 240px;
741
+ min-width: 0;
742
+ height: 28px;
743
+ box-sizing: border-box;
744
+ padding: 0 10px;
745
+ border: 0.5px solid var(--dsw-alias-border-l4);
746
+ border-radius: var(--dsw-radius-md);
747
+ background: var(--dsw-alias-bg-layer-3);
748
+ color: var(--dsw-alias-label-primary);
749
+ font: inherit;
750
+ font-size: 12.5px;
751
+ }
752
+ .hqpu-source-input:disabled {
753
+ opacity: 0.5;
754
+ }
755
+ .hqpu-source-input:focus-visible {
756
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
757
+ outline-offset: 1px;
758
+ }
759
+
697
760
  /* ── Footer action row (ui-primitives Modal .footer) ───────────────────── */
698
761
  .hqpu-footer {
699
762
  display: flex;