@restregular/dsh-powerup 0.2.2 → 0.3.0

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 (4) hide show
  1. package/README.md +38 -13
  2. package/client.js +688 -140
  3. package/index.js +161 -14
  4. package/package.json +2 -2
package/index.js CHANGED
@@ -1,23 +1,38 @@
1
1
  /**
2
2
  * Host half of dsh-powerup.
3
3
  *
4
- * The plugin is primarily a Client UI upgrade. This Host entry exists for one
5
- * operation the browser cannot perform itself: restarting the whole DSH
6
- * desktop application after a plugin update. It exposes a small loopback-only
7
- * route under `/dsh-powerup/api`:
4
+ * The plugin is primarily a Client UI upgrade. This Host entry owns the two
5
+ * operations the browser cannot perform itself:
8
6
  *
9
- * GET /dsh-powerup/api/restart → { ok, supported, reason? }
10
- * POST /dsh-powerup/api/restart → schedules the restart, then answers
7
+ * GET /dsh-powerup/api/restart → { ok, supported, reason? }
8
+ * POST /dsh-powerup/api/restart → schedules a whole-app restart
9
+ * POST /dsh-powerup/api/self-update → removes and reinstalls this plugin
10
+ * in the background, surviving the
11
+ * plugin's own unload
11
12
  *
12
- * The Client half drives plugin installation through the built-in
13
- * `pluginManager` Remote; only the process restart needs Host code.
13
+ * The Client half drives other plugins' installation through the built-in
14
+ * `pluginManager` Remote. Only the process restart and the self-update handoff
15
+ * need Host code.
14
16
  */
15
17
 
18
+ import { randomUUID } from 'node:crypto';
19
+ import { appendFileSync, mkdirSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { homedir } from 'node:os';
16
22
  import { describeRestart, requestRestart } from './lib/restart.js';
17
23
 
18
24
  /** Prefix owned by this plugin. */
19
25
  const ROUTE = '/dsh-powerup/api';
20
26
 
27
+ /** The bundle this pair of routes may replace while its own UI unloads. */
28
+ const MODULE_ID = '@restregular/dsh-powerup';
29
+
30
+ /** Let the HTTP response reach the browser before the plugin unloads. */
31
+ const SELF_UPDATE_DELAY_MS = 600;
32
+
33
+ /** Request body bound for the small JSON commands. */
34
+ const BODY_LIMIT = 16 * 1024;
35
+
21
36
  /**
22
37
  * @param {import('node:http').ServerResponse} res
23
38
  * @param {unknown} payload
@@ -34,6 +49,23 @@ function sendJson(res, payload, status = 200) {
34
49
  res.end(body);
35
50
  }
36
51
 
52
+ /**
53
+ * Read a bounded JSON request body.
54
+ * @param {import('node:http').IncomingMessage} req
55
+ * @returns {Promise<unknown>}
56
+ */
57
+ async function readJsonBody(req) {
58
+ const chunks = [];
59
+ let size = 0;
60
+ for await (const chunk of req) {
61
+ size += chunk.length;
62
+ if (size > BODY_LIMIT) throw new Error('body-too-large');
63
+ chunks.push(chunk);
64
+ }
65
+ if (size === 0) return {};
66
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
67
+ }
68
+
37
69
  /**
38
70
  * The request came from this machine.
39
71
  * @param {import('node:http').IncomingMessage} req
@@ -64,11 +96,89 @@ function sameOrigin(req) {
64
96
  }
65
97
  }
66
98
 
99
+ /**
100
+ * Append one line to the self-update log; logging never blocks the operation.
101
+ * @param {string} line
102
+ */
103
+ function appendSelfUpdateLog(line) {
104
+ try {
105
+ const path = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'powerup', 'self-update.log');
106
+ mkdirSync(dirname(path), { recursive: true });
107
+ appendFileSync(path, `${new Date().toISOString()} ${line}\n`);
108
+ } catch {
109
+ /* The diagnostic log is optional. */
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The concrete versions the update dialog accepts. The Client applies the same
115
+ * shape before it removes anything; this is the Host-side guard.
116
+ * @param {unknown} value
117
+ * @returns {string}
118
+ */
119
+ function normalizeVersion(value) {
120
+ const text = String(value ?? '').trim().replace(/^v/u, '');
121
+ if (text === '' || /\s/u.test(text) || text.includes('@')) return '';
122
+ return /^\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(text) ? text : '';
123
+ }
124
+
125
+ /**
126
+ * Remove and reinstall this plugin through the built-in manager. The function
127
+ * is started after the HTTP response and keeps running in the Host process
128
+ * even when removing the bundle unloads this plugin's own Host fiber.
129
+ *
130
+ * @param {import('@deepseek-ai/cordis').Context} ctx
131
+ * @param {string} version
132
+ * @param {string | null} registry
133
+ * @param {{ manager?: unknown, log?: (line: string) => void }} [options]
134
+ * @returns {Promise<{ ok: boolean, stage?: string, error?: string }>}
135
+ */
136
+ export async function performSelfUpdate(ctx, version, registry, options = {}) {
137
+ const log = options.log ?? appendSelfUpdateLog;
138
+ const manager = options.manager ?? ctx?.get?.('pluginManager');
139
+ if (manager === undefined || typeof manager.installBundle !== 'function') {
140
+ log('self-update: pluginManager service is unavailable');
141
+ return { ok: false, stage: 'manager', error: 'pluginManager service is unavailable' };
142
+ }
143
+ const spec = `${MODULE_ID}@${version}`;
144
+ log(`self-update: start ${spec} registry=${String(registry)}`);
145
+ let stage = 'remove';
146
+ try {
147
+ const removed = await manager.removeBundle(MODULE_ID);
148
+ if (removed?.error !== undefined) {
149
+ throw new Error(`remove failed: ${removed.error.code ?? 'error'} ${removed.error.diagnostic ?? ''}`.trim());
150
+ }
151
+ log(`self-update: removed (application=${removed?.application ?? 'unknown'})`);
152
+ stage = 'inspect';
153
+ const checked = await manager.inspect(spec, { registry });
154
+ if (checked?.status !== 'accepted') {
155
+ throw new Error(`inspect refused: ${checked?.problem ?? 'unknown'} ${checked?.reason ?? ''}`.trim());
156
+ }
157
+ log(`self-update: inspected (bundle=${String(checked.bundle)})`);
158
+ stage = 'install';
159
+ const installed = await manager.installBundle(spec, {
160
+ enabled: true,
161
+ registry,
162
+ requestId: randomUUID(),
163
+ });
164
+ if (installed?.error !== undefined) {
165
+ throw new Error(`install failed: ${installed.error.code ?? 'error'} ${installed.error.diagnostic ?? ''}`.trim());
166
+ }
167
+ log(`self-update: installed (application=${installed?.application ?? 'unknown'}, version=${installed?.version ?? 'unknown'})`);
168
+ return { ok: true, stage: 'install' };
169
+ } catch (error) {
170
+ const message = String(error?.message ?? error);
171
+ log(`self-update: failed at ${stage}: ${message}${error?.stack === undefined ? '' : `\n${error.stack}`}`);
172
+ return { ok: false, stage, error: message };
173
+ }
174
+ }
175
+
67
176
  /**
68
177
  * @param {import('node:http').IncomingMessage} req
69
178
  * @param {import('node:http').ServerResponse} res
179
+ * @param {import('@deepseek-ai/cordis').Context} ctx
70
180
  */
71
- async function handle(req, res) {
181
+ async function handle(req, res, ctx) {
72
182
  if (!isLoopback(req) || !sameOrigin(req)) {
73
183
  sendJson(res, { ok: false, error: 'forbidden' }, 403);
74
184
  return;
@@ -76,11 +186,44 @@ async function handle(req, res) {
76
186
 
77
187
  const url = new URL(req.url ?? ROUTE, 'http://localhost');
78
188
  const op = url.pathname.slice(ROUTE.length).replace(/^\/+/u, '') || url.searchParams.get('op') || '';
79
- if (op !== '' && op !== 'restart') {
189
+ if (op !== '' && op !== 'restart' && op !== 'self-update') {
80
190
  sendJson(res, { ok: false, error: 'not-found' }, 404);
81
191
  return;
82
192
  }
83
193
 
194
+ if (op === 'self-update') {
195
+ if (req.method !== 'POST') {
196
+ sendJson(res, { ok: false, error: 'method-not-allowed' }, 405);
197
+ return;
198
+ }
199
+ let body;
200
+ try {
201
+ body = await readJsonBody(req);
202
+ } catch (error) {
203
+ sendJson(res, { ok: false, error: String(error?.message ?? error) }, 400);
204
+ return;
205
+ }
206
+ const version = normalizeVersion(body?.version);
207
+ if (version === '') {
208
+ sendJson(res, { ok: false, code: 'invalid-version', error: 'a concrete version is required' }, 400);
209
+ return;
210
+ }
211
+ const registry = typeof body?.registry === 'string' && body.registry.trim() !== '' ? body.registry.trim() : null;
212
+ const manager = ctx?.get?.('pluginManager');
213
+ if (manager === undefined || typeof manager.removeBundle !== 'function' || typeof manager.installBundle !== 'function' || typeof manager.inspect !== 'function') {
214
+ sendJson(res, { ok: false, code: 'manager-unavailable', error: 'pluginManager service is unavailable' }, 503);
215
+ return;
216
+ }
217
+ sendJson(res, { ok: true, started: true, version });
218
+ const timer = setTimeout(() => {
219
+ void performSelfUpdate(ctx, version, registry, { manager }).catch((error) => {
220
+ appendSelfUpdateLog(`self-update: uncaught ${String(error?.stack ?? error)}`);
221
+ });
222
+ }, SELF_UPDATE_DELAY_MS);
223
+ timer.unref?.();
224
+ return;
225
+ }
226
+
84
227
  if (req.method === 'GET') {
85
228
  sendJson(res, { ok: true, action: 'restart', ...describeRestart() });
86
229
  return;
@@ -100,7 +243,7 @@ async function handle(req, res) {
100
243
  }
101
244
 
102
245
  /**
103
- * Mount the restart route once the Web server exists.
246
+ * Mount the restart and self-update routes once the Web server exists.
104
247
  * @param {import('@deepseek-ai/cordis').Context} ctx
105
248
  */
106
249
  export function apply(ctx) {
@@ -109,11 +252,15 @@ export function apply(ctx) {
109
252
  const webServer = scope.get('webServer');
110
253
  if (webServer === undefined || typeof webServer.register !== 'function') return;
111
254
  scope.effect(
112
- () => webServer.register({ kind: 'prefix', path: ROUTE, handler: handle }),
113
- 'dsh-powerup: restart route',
255
+ () => webServer.register({
256
+ kind: 'prefix',
257
+ path: ROUTE,
258
+ handler: (req, res) => handle(req, res, ctx),
259
+ }),
260
+ 'dsh-powerup: restart and self-update routes',
114
261
  );
115
262
  });
116
263
  }
117
264
 
118
265
  /** Exported for tests only; the Host never reads this. */
119
- export const __test = { handle, isLoopback, sameOrigin, ROUTE };
266
+ export const __test = { handle, isLoopback, sameOrigin, performSelfUpdate, normalizeVersion, ROUTE, MODULE_ID };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-powerup",
3
- "version": "0.2.2",
4
- "description": "Power-Up — small, opt-in upgrades for DeepSeek Harness. Adds a right-click action menu to the Trajectory ledger and an update control for third-party plugins installed from the Plugins page, with version lookup, uninstall-and-reinstall through the Add plugin path, and an optional DSH restart. · 为 DeepSeek Harness 提供小型增强:在轨迹页面右键记录即可复制、引用、查看详情或从该记录 Fork;并在插件页为第三方已安装插件提供带版本查询的更新按钮,沿用「添加插件」的安装流程,并可重启 DSH 使更改生效。",
3
+ "version": "0.3.0",
4
+ "description": "Power-Up — small, opt-in upgrades for DeepSeek Harness. Adds a right-click action menu to the Trajectory ledger and an update control for third-party plugins installed from the Plugins page, with version lookup from the configured registries, a starred default source, uninstall-and-reinstall through the Add plugin path, restart-needed markers, a background self-update, and an optional DSH restart. · 为 DeepSeek Harness 提供小型增强:在轨迹页面右键记录即可复制、引用、查看详情或从该记录 Fork;并在插件页为已安装插件提供带版本查询的更新按钮,安装源可设默认,沿用「添加插件」的安装流程,支持待重启标记、后台自更新与一键重启。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",