@huaqiu/dsh-tool-plugin-update 0.0.0-stage → 0.5.21

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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Plugin-update review modal (browser half).
3
+ *
4
+ * Plain-DOM modal (no React tree) so it can never disturb the DSH shell's
5
+ * reconciliation — same rule as the sidebar entry rows. Rendered content:
6
+ *
7
+ * - header: title + refresh
8
+ * - plugin list (name, current → available, state badge, blocked reason)
9
+ * - ignored-offer banner + reversible "show again"
10
+ * - install action (disabled while installing / nothing actionable / offer
11
+ * ignored); after install the UI keeps polling the backend state instead
12
+ * of trusting the HTTP response alone, because activating a set restarts
13
+ * DSH (spec §7.1: never report success on the 200/202 alone).
14
+ * - independent HQ Edge runtime section (read-only report; the actual
15
+ * upgrade is owned by the EDA installer).
16
+ *
17
+ * @module
18
+ */
19
+ import {
20
+ checkUpdates,
21
+ ignoreOffer,
22
+ installSet,
23
+ unignoreOffer,
24
+ type PluginUpdateItem,
25
+ type PluginUpdateReport,
26
+ } from './api.js'
27
+ import type { UpdateCopy } from './copy.js'
28
+
29
+ export interface UpdateStoreLike {
30
+ report(): PluginUpdateReport | null
31
+ installing(): boolean
32
+ refresh(): Promise<PluginUpdateReport | null>
33
+ subscribe(listener: () => void): () => void
34
+ }
35
+
36
+ function el<K extends keyof HTMLElementTagNameMap>(
37
+ tag: K,
38
+ className: string,
39
+ text?: string,
40
+ ): HTMLElementTagNameMap[K] {
41
+ const node = document.createElement(tag)
42
+ node.className = className
43
+ if (text !== undefined) node.textContent = text
44
+ return node
45
+ }
46
+
47
+ function stateLabel(item: PluginUpdateItem, copy: UpdateCopy): string {
48
+ switch (item.state) {
49
+ case 'current':
50
+ return copy.state.current
51
+ case 'update':
52
+ return copy.state.update
53
+ case 'install':
54
+ return copy.state.install
55
+ case 'ignored':
56
+ return copy.state.ignored
57
+ case 'blocked':
58
+ return copy.state.blocked
59
+ }
60
+ }
61
+
62
+ function blockedReason(item: PluginUpdateItem, copy: UpdateCopy): string | undefined {
63
+ const by = item.blockedBy ?? []
64
+ if (by.includes('host')) return copy.blockedReason.host
65
+ if (by.includes('hq-edge-version')) return copy.blockedReason['hq-edge-version']
66
+ if (by.includes('runtime-unknown')) return copy.blockedReason['runtime-unknown']
67
+ return by.length > 0 ? by.join(', ') : undefined
68
+ }
69
+
70
+ /**
71
+ * Open (or focus) the update modal. Re-renders from the store; when the store
72
+ * is stale the modal first refreshes the report.
73
+ */
74
+ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
75
+ const existing = document.querySelector<HTMLElement>('[data-hq-plugin-update-view]')
76
+ if (existing !== null) {
77
+ existing.style.display = ''
78
+ return
79
+ }
80
+
81
+ const overlay = el('div', 'hqpu-overlay')
82
+ overlay.setAttribute('data-hq-plugin-update-view', '')
83
+ const panel = el('div', 'hqpu-panel')
84
+
85
+ // Header
86
+ const header = el('div', 'hqpu-header')
87
+ header.append(el('div', 'hqpu-title', copy.viewTitle))
88
+ const refreshBtn = el('button', 'hqpu-btn', copy.refresh)
89
+ header.append(refreshBtn)
90
+ const closeBtn = el('button', 'hqpu-btn hqpu-btn-ghost', copy.close)
91
+ header.append(closeBtn)
92
+
93
+ // Body: plugin list + ignored banner + install action + runtime section.
94
+ const body = el('div', 'hqpu-body')
95
+ const listSection = el('div', 'hqpu-section')
96
+ const runtimeSection = el('div', 'hqpu-section hqpu-runtime')
97
+ const actionBar = el('div', 'hqpu-actions')
98
+
99
+ panel.append(header, body)
100
+ body.append(listSection, runtimeSection, actionBar)
101
+ overlay.append(panel)
102
+ document.body.appendChild(overlay)
103
+
104
+ const disposers: Array<() => void> = []
105
+
106
+ const render = (): void => {
107
+ const report = store.report()
108
+ const installing = store.installing()
109
+ renderList(listSection, report, copy, installing, store)
110
+ renderRuntime(runtimeSection, report, copy)
111
+ renderActions(actionBar, report, copy, store, installing)
112
+ }
113
+
114
+ const unsubscribe = store.subscribe(render)
115
+ disposers.push(unsubscribe)
116
+
117
+ // Refresh on open (bounded; never block the shell).
118
+ void store.refresh().then(() => {
119
+ // Store notification re-renders already; nothing extra to do.
120
+ })
121
+
122
+ const onClose = (): void => {
123
+ for (const dispose of disposers) {
124
+ try {
125
+ dispose()
126
+ } catch {
127
+ /* already disposed */
128
+ }
129
+ }
130
+ overlay.remove()
131
+ }
132
+ closeBtn.addEventListener('click', onClose)
133
+ overlay.addEventListener('click', (ev) => {
134
+ if (ev.target === overlay) onClose()
135
+ })
136
+ refreshBtn.addEventListener('click', () => {
137
+ void store.refresh()
138
+ })
139
+ disposers.push(() => {
140
+ closeBtn.removeEventListener('click', onClose)
141
+ overlay.removeEventListener('click', onClose)
142
+ refreshBtn.removeEventListener('click', () => {})
143
+ })
144
+
145
+ render()
146
+ }
147
+
148
+ function renderList(
149
+ section: HTMLElement,
150
+ report: PluginUpdateReport | null,
151
+ copy: UpdateCopy,
152
+ installing: boolean,
153
+ store: UpdateStoreLike,
154
+ ): void {
155
+ section.textContent = ''
156
+ if (report === null) {
157
+ section.append(el('div', 'hqpu-empty', copy.checkFailed))
158
+ return
159
+ }
160
+ if (report.items.length === 0) {
161
+ section.append(el('div', 'hqpu-empty', copy.empty))
162
+ return
163
+ }
164
+
165
+ const manifestRow = el('div', 'hqpu-manifest')
166
+ manifestRow.append(
167
+ el('span', 'hqpu-manifest-label', `${copy.manifestVersion}`),
168
+ el('span', 'hqpu-manifest-value', report.manifestVersion),
169
+ )
170
+ section.append(manifestRow)
171
+
172
+ if (report.ignoredSet) {
173
+ const banner = el('div', 'hqpu-ignored-banner', copy.ignoredOffer)
174
+ const unignore = el('button', 'hqpu-btn hqpu-btn-small', copy.unignore)
175
+ unignore.addEventListener('click', () => {
176
+ void unignoreOffer(report.manifestVersion)
177
+ .then(() => store.refresh())
178
+ .catch(() => undefined)
179
+ })
180
+ banner.append(unignore)
181
+ section.append(banner)
182
+ }
183
+
184
+ const list = el('ul', 'hqpu-list')
185
+ for (const item of report.items) {
186
+ const row = el('li', `hqpu-item hqpu-item-${item.state}`)
187
+ const name = el('span', 'hqpu-item-name', item.name)
188
+ const version = el(
189
+ 'span',
190
+ 'hqpu-item-version',
191
+ item.state === 'current'
192
+ ? item.currentVersion
193
+ : `${item.currentVersion} → ${item.availableVersion}`,
194
+ )
195
+ const state = el('span', `hqpu-item-state hqpu-state-${item.state}`, stateLabel(item, copy))
196
+ row.append(name, version, state)
197
+ const reason = blockedReason(item, copy)
198
+ if (reason !== undefined) {
199
+ row.append(el('span', 'hqpu-item-reason', reason))
200
+ }
201
+ list.append(row)
202
+ }
203
+ section.append(list)
204
+ }
205
+
206
+ function renderRuntime(
207
+ section: HTMLElement,
208
+ report: PluginUpdateReport | null,
209
+ copy: UpdateCopy,
210
+ ): void {
211
+ section.textContent = ''
212
+ const title = el('div', 'hqpu-runtime-title', copy.runtime.title)
213
+ section.append(title)
214
+ const runtime = report?.runtime
215
+ if (runtime === undefined || runtime.configured === false) {
216
+ section.append(el('div', 'hqpu-runtime-line', copy.runtime.notConfigured))
217
+ return
218
+ }
219
+ if (runtime.updateAvailable && runtime.latest !== undefined) {
220
+ section.append(el('div', 'hqpu-runtime-line', copy.runtime.updateAvailable(runtime.latest)))
221
+ } else if (runtime.currentVersion !== undefined) {
222
+ section.append(el('div', 'hqpu-runtime-line', copy.runtime.current(runtime.currentVersion)))
223
+ } else {
224
+ section.append(el('div', 'hqpu-runtime-line', copy.runtime.unknown))
225
+ }
226
+ section.append(el('div', 'hqpu-runtime-note', copy.runtime.note))
227
+ if (runtime.lastError !== undefined && runtime.lastError.length > 0) {
228
+ section.append(el('div', 'hqpu-runtime-error', `${copy.lastError}: ${runtime.lastError}`))
229
+ }
230
+ }
231
+
232
+ function renderActions(
233
+ bar: HTMLElement,
234
+ report: PluginUpdateReport | null,
235
+ copy: UpdateCopy,
236
+ store: UpdateStoreLike,
237
+ installing: boolean,
238
+ ): void {
239
+ bar.textContent = ''
240
+ if (report === null) return
241
+
242
+ if (installing) {
243
+ bar.append(el('span', 'hqpu-installing', copy.installing))
244
+ return
245
+ }
246
+
247
+ const actionable = report.items.filter(
248
+ (item) => item.state === 'update' || item.state === 'install',
249
+ ).length
250
+ const installBtn = el(
251
+ 'button',
252
+ 'hqpu-btn hqpu-btn-primary',
253
+ actionable > 0 ? copy.installAll(actionable) : copy.state.current,
254
+ )
255
+ installBtn.disabled = actionable === 0 || report.ignoredSet
256
+ installBtn.addEventListener('click', () => {
257
+ void installSet(report.manifestVersion)
258
+ .then((result) => {
259
+ if (result.error !== undefined) {
260
+ bar.textContent = ''
261
+ bar.append(el('div', 'hqpu-error', `${copy.installError}: ${result.error}`))
262
+ return
263
+ }
264
+ // The set is activated atomically; DSH restarts. Keep polling the
265
+ // backend state rather than trusting the response alone (spec §7.1).
266
+ bar.textContent = ''
267
+ bar.append(el('span', 'hqpu-installing', copy.installSuccess))
268
+ void pollUntilSettled(store, report.manifestVersion)
269
+ })
270
+ .catch((err: unknown) => {
271
+ bar.textContent = ''
272
+ bar.append(
273
+ el('div', 'hqpu-error', `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`),
274
+ )
275
+ })
276
+ })
277
+ bar.append(installBtn)
278
+ }
279
+
280
+ /** After an install, poll the backend until the update report settles
281
+ * (installing flag clears or DSH restarts under us — the next load re-reads
282
+ * the fresh state). */
283
+ function pollUntilSettled(store: UpdateStoreLike, setVersion: string): void {
284
+ let attempts = 0
285
+ const tick = (): void => {
286
+ void store
287
+ .refresh()
288
+ .then((report) => {
289
+ if (store.installing()) return
290
+ if (report !== null && report.ignoredSet === false && attempts >= 2) return
291
+ })
292
+ .catch(() => undefined)
293
+ .finally(() => {
294
+ attempts += 1
295
+ if (attempts < 10) {
296
+ window.setTimeout(tick, 2000)
297
+ }
298
+ })
299
+ }
300
+ tick()
301
+ }
package/src/index.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Huaqiu EDA plugin-update DSH plugin (node half) — `@huaqiu/dsh-tool-plugin-update`.
3
+ *
4
+ * Thin adapter between the DSH Web update UI and the hq-edge plugin-update
5
+ * API (task dsh-plugin-discovery-and-update-with-hq-edge-update-reporting.md
6
+ * §7). The browser half mounts the sidebar toolbutton (badge = actionable
7
+ * count) and the review/confirm/ignore view; every request it makes lands on
8
+ * this package's same-origin prefix routes, which forward to hq-edge.
9
+ *
10
+ * ── Architectural boundary ─────────────────────────────────────────────────
11
+ * All update logic is owned ONCE by hq-edge (discovery, compatibility,
12
+ * ignore persistence, the install transaction with cross-process lock and
13
+ * atomic activation). This plugin is a thin adapter: no manifest fetching, no
14
+ * registry calls, no download/verify code, no `@hqedge/*` dependency. It only
15
+ * talks to hq-edge through the edge-bridge (`ctx.hqEdge.baseUrl`), matching
16
+ * the `@huaqiu/dsh-tool-quote` / `@huaqiu/dsh-eda-host` convention.
17
+ *
18
+ * `hqEdge` is a REQUIRED inject: without the bridge the update UI reports
19
+ * "hq-edge is not reachable" rather than showing stale data.
20
+ *
21
+ * @module @huaqiu/dsh-tool-plugin-update
22
+ */
23
+
24
+ import type { Context } from '@deepseek-ai/cordis'
25
+ import type {} from '@deepseek-ai/dsh-host-webserver'
26
+ import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
27
+ import { createUpdateForwarder, UPDATE_ROUTE_PREFIX } from './routes.js'
28
+
29
+ /** Plugin id — matches package.json. */
30
+ export const name = '@huaqiu/dsh-tool-plugin-update'
31
+
32
+ /** Cordis services this half depends on: hq-edge bridge (REQUIRED) + the DSH
33
+ * webServer surface that hosts the same-origin forwarding routes. */
34
+ export const inject = ['hqEdge', 'webServer'] as const
35
+
36
+ declare module '@deepseek-ai/cordis' {
37
+ interface Context {
38
+ /**
39
+ * Provided by the hq-edge `edge-bridge` plugin (the HOST). `baseUrl` is
40
+ * the loopback HQ Edge endpoint, e.g. "http://localhost:18080". Read
41
+ * lazily on every request.
42
+ */
43
+ hqEdge: { baseUrl?: string }
44
+ }
45
+ }
46
+
47
+ /** Shared component name for the unified DSH-plugin log. */
48
+ const COMPONENT = 'dsh-tool-plugin-update'
49
+
50
+ let _log: PluginLogger | null = null
51
+ function log(): PluginLogger {
52
+ if (_log === null) _log = getLogger(COMPONENT)
53
+ return _log
54
+ }
55
+
56
+ /**
57
+ * Host plugin body — register the forwarding routes.
58
+ *
59
+ * The HQ Edge endpoint is resolved lazily per request from `ctx.hqEdge`
60
+ * (edge-bridge). When the bridge is absent the plugin refuses to load: an
61
+ * update UI without a host would only mislead.
62
+ *
63
+ * @param ctx - real cordis context (node side).
64
+ * @returns disposer — unregisters the routes on plugin dispose.
65
+ */
66
+ export function apply(ctx: Context): () => void {
67
+ const hq = ctx.hqEdge
68
+ if (!hq || typeof hq.baseUrl !== 'string' || hq.baseUrl.trim().length === 0) {
69
+ throw new Error(
70
+ '@huaqiu/dsh-tool-plugin-update requires a usable hq-edge context: the ' +
71
+ 'edge-bridge plugin did not provide ctx.hqEdge.baseUrl. The update UI ' +
72
+ 'cannot work without hq-edge — check that the bridge started.',
73
+ )
74
+ }
75
+
76
+ const getHqEdgeBaseUrl = (): string | undefined => {
77
+ const current = ctx.hqEdge
78
+ return current?.baseUrl && current.baseUrl.trim().length > 0 ? current.baseUrl : undefined
79
+ }
80
+
81
+ const disposers: Array<() => void> = []
82
+ if (ctx.webServer && typeof ctx.webServer.register === 'function') {
83
+ ctx.effect(() =>
84
+ ctx.webServer.register({
85
+ kind: 'prefix',
86
+ path: UPDATE_ROUTE_PREFIX,
87
+ handler: createUpdateForwarder(getHqEdgeBaseUrl),
88
+ }),
89
+ )
90
+ } else {
91
+ log().warn('webServer unavailable — plugin-update routes are not exposed; the sidebar UI cannot load data')
92
+ }
93
+
94
+ log().info('applying dsh-tool-plugin-update node half', {
95
+ routePrefix: UPDATE_ROUTE_PREFIX,
96
+ hqEdgeBaseUrl: getHqEdgeBaseUrl() ?? null,
97
+ })
98
+
99
+ return () => {
100
+ for (const dispose of disposers) {
101
+ try {
102
+ dispose()
103
+ } catch {
104
+ // best-effort teardown
105
+ }
106
+ }
107
+ }
108
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Same-origin forwarding routes for the plugin-update UI (browser half).
3
+ *
4
+ * Browser → DSH webServer prefix route `/api/v1/huaqiu/plugin-update/…` →
5
+ * this handler → hq-edge loopback HTTP API `/api/v1/plugin-updates/…`.
6
+ *
7
+ * The forwarding is a THIN adapter: hq-edge owns discovery, compatibility,
8
+ * ignore persistence and the install transaction (with its loopback-only
9
+ * protection — every request below originates from this node process on the
10
+ * same host, so the loopback guard is satisfied naturally). This plugin
11
+ * carries no update logic and no `@hqedge/*` dependency.
12
+ *
13
+ * Routes (mirroring hq-edge apps/server/src/routes/plugin-updates.ts):
14
+ * GET /plugin-update/report → GET /api/v1/plugin-updates
15
+ * POST /plugin-update/check → POST /api/v1/plugin-updates/check
16
+ * POST /plugin-update/install → POST /api/v1/plugin-updates/install {setVersion}
17
+ * POST /plugin-update/ignore → POST /api/v1/plugin-updates/ignore {setVersion}
18
+ * POST /plugin-update/unignore → POST /api/v1/plugin-updates/unignore {setVersion}
19
+ * GET /plugin-update/runtime → GET /api/v1/runtime-update
20
+ *
21
+ * @module
22
+ */
23
+ import type { IncomingMessage, ServerResponse } from 'node:http'
24
+
25
+ export const UPDATE_ROUTE_PREFIX = '/api/v1/huaqiu/plugin-update'
26
+
27
+ /** End-to-end budget for one forwarded request, in milliseconds. */
28
+ export const DEFAULT_FORWARD_TIMEOUT_MS = 30_000
29
+
30
+ export type BaseUrlResolver = () => string | undefined
31
+
32
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
33
+ const payload = JSON.stringify(body)
34
+ res.writeHead(status, {
35
+ 'content-type': 'application/json; charset=utf-8',
36
+ 'cache-control': 'no-store',
37
+ })
38
+ res.end(payload)
39
+ }
40
+
41
+ /** Split the sub-path off the prefix. Returns null on a bad shape. */
42
+ function parsePath(req: IncomingMessage): string | null {
43
+ const url = req.url ?? ''
44
+ const q = url.indexOf('?')
45
+ const pathname = q >= 0 ? url.slice(0, q) : url
46
+ if (!pathname.startsWith(UPDATE_ROUTE_PREFIX)) return null
47
+ return pathname.slice(UPDATE_ROUTE_PREFIX.length)
48
+ }
49
+
50
+ /** Read the request body (bounded) as UTF-8 text. */
51
+ function readBody(req: IncomingMessage): Promise<string> {
52
+ return new Promise((resolve, reject) => {
53
+ let size = 0
54
+ const chunks: Buffer[] = []
55
+ req.on('data', (chunk: Buffer) => {
56
+ size += chunk.length
57
+ if (size > 64 * 1024) {
58
+ reject(new Error('request body too large'))
59
+ req.destroy()
60
+ return
61
+ }
62
+ chunks.push(chunk)
63
+ })
64
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
65
+ req.on('error', reject)
66
+ })
67
+ }
68
+
69
+ /** Map a sub-path to { method, path } on the hq-edge API. */
70
+ function routeFor(sub: string): { method: string; path: string } | null {
71
+ if (sub === '/report' || sub === '') {
72
+ return { method: 'GET', path: '/api/v1/plugin-updates' }
73
+ }
74
+ if (sub === '/runtime') {
75
+ return { method: 'GET', path: '/api/v1/runtime-update' }
76
+ }
77
+ if (sub === '/check') return { method: 'POST', path: '/api/v1/plugin-updates/check' }
78
+ if (sub === '/install') return { method: 'POST', path: '/api/v1/plugin-updates/install' }
79
+ if (sub === '/ignore') return { method: 'POST', path: '/api/v1/plugin-updates/ignore' }
80
+ if (sub === '/unignore') return { method: 'POST', path: '/api/v1/plugin-updates/unignore' }
81
+ return null
82
+ }
83
+
84
+ /** Create the prefix-route handler forwarding to hq-edge. */
85
+ export function createUpdateForwarder(
86
+ baseUrlResolver: BaseUrlResolver,
87
+ timeoutMs: number = DEFAULT_FORWARD_TIMEOUT_MS,
88
+ ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
89
+ return async (req: IncomingMessage, res: ServerResponse) => {
90
+ const sub = parsePath(req)
91
+ if (sub === null) {
92
+ sendJson(res, 404, { error: 'not found' })
93
+ return
94
+ }
95
+ const target = routeFor(sub)
96
+ if (target === null) {
97
+ sendJson(res, 404, { error: 'not found' })
98
+ return
99
+ }
100
+ if (req.method !== 'GET' && req.method !== 'POST' && req.method !== 'HEAD') {
101
+ sendJson(res, 405, { error: 'method not allowed' })
102
+ return
103
+ }
104
+ const base = (baseUrlResolver() ?? '').replace(/\/+$/, '')
105
+ if (base.length === 0) {
106
+ sendJson(res, 503, { error: 'hq-edge is not reachable from this plugin (no base URL)' })
107
+ return
108
+ }
109
+ let body = ''
110
+ if (req.method === 'POST') {
111
+ try {
112
+ body = await readBody(req)
113
+ } catch (err) {
114
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
115
+ return
116
+ }
117
+ }
118
+ const controller = new AbortController()
119
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
120
+ try {
121
+ const upstream = await fetch(`${base}${target.path}`, {
122
+ method: target.method,
123
+ headers: {
124
+ 'content-type': 'application/json; charset=utf-8',
125
+ 'user-agent': 'hq-edge-dsh-tool-plugin-update/0.5.21',
126
+ },
127
+ ...(body.length > 0 ? { body } : {}),
128
+ signal: controller.signal,
129
+ })
130
+ const upstreamBody = await upstream.text()
131
+ let json: unknown = null
132
+ try {
133
+ json = JSON.parse(upstreamBody)
134
+ } catch {
135
+ json = { raw: upstreamBody.slice(0, 512) }
136
+ }
137
+ sendJson(res, upstream.status, json)
138
+ } catch (err) {
139
+ const message = err instanceof Error ? err.message : String(err)
140
+ if (err instanceof Error && err.name === 'AbortError') {
141
+ sendJson(res, 504, { error: `hq-edge request timed out after ${timeoutMs}ms` })
142
+ return
143
+ }
144
+ sendJson(res, 502, { error: `hq-edge request failed: ${message}` })
145
+ } finally {
146
+ clearTimeout(timer)
147
+ }
148
+ }
149
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.