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

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/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.