@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.
- package/README.md +38 -13
- package/client.js +688 -140
- package/index.js +161 -14
- 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
|
|
5
|
-
*
|
|
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
|
|
10
|
-
* POST /dsh-powerup/api/restart
|
|
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
|
|
13
|
-
* `pluginManager` Remote
|
|
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
|
|
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({
|
|
113
|
-
|
|
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.
|
|
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
|
|
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",
|