@restregular/dsh-powerup 0.0.0-stage → 0.2.1

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,7 @@
1
+ # dsh-powerup bundle patch: inserts the Host row. The Client half is resolved
2
+ # from the same package through `dsh.client` in package.json; `name` must be
3
+ # byte-for-byte the package name because the client module graph locates the
4
+ # package by the Loader row name.
5
+ - insert:
6
+ - id: dsh-powerup
7
+ name: '@restregular/dsh-powerup'
package/icon.svg ADDED
@@ -0,0 +1,22 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
2
+ <defs>
3
+ <linearGradient id="dsh-powerup-bg" x1="32" y1="24" x2="224" y2="232" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#8B5CF6"/>
5
+ <stop offset="0.55" stop-color="#6D3BE0"/>
6
+ <stop offset="1" stop-color="#4C1D95"/>
7
+ </linearGradient>
8
+ <radialGradient id="dsh-powerup-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(80 56) rotate(45) scale(190)">
9
+ <stop stop-color="#FFFFFF" stop-opacity="0.26"/>
10
+ <stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
11
+ </radialGradient>
12
+ <linearGradient id="dsh-powerup-chevron" x1="78" y1="56" x2="178" y2="178" gradientUnits="userSpaceOnUse">
13
+ <stop stop-color="#FFFFFF"/>
14
+ <stop offset="1" stop-color="#D8FFF4"/>
15
+ </linearGradient>
16
+ </defs>
17
+ <rect x="16" y="16" width="224" height="224" rx="56" fill="url(#dsh-powerup-bg)"/>
18
+ <rect x="16" y="16" width="224" height="224" rx="56" fill="url(#dsh-powerup-glow)"/>
19
+ <path d="M78 102 128 56l50 46" stroke="url(#dsh-powerup-chevron)" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
20
+ <path d="M78 178 128 132l50 46" stroke="#9DF2DB" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
21
+ <path d="M96 212h64" stroke="#FFC94D" stroke-width="10" stroke-linecap="round"/>
22
+ </svg>
package/index.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Host half of dsh-powerup.
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`:
8
+ *
9
+ * GET /dsh-powerup/api/restart → { ok, supported, reason? }
10
+ * POST /dsh-powerup/api/restart → schedules the restart, then answers
11
+ *
12
+ * The Client half drives plugin installation through the built-in
13
+ * `pluginManager` Remote; only the process restart needs Host code.
14
+ */
15
+
16
+ import { describeRestart, requestRestart } from './lib/restart.js';
17
+
18
+ /** Prefix owned by this plugin. */
19
+ const ROUTE = '/dsh-powerup/api';
20
+
21
+ /**
22
+ * @param {import('node:http').ServerResponse} res
23
+ * @param {unknown} payload
24
+ * @param {number} status
25
+ */
26
+ function sendJson(res, payload, status = 200) {
27
+ if (res.headersSent) return;
28
+ const body = JSON.stringify(payload);
29
+ res.writeHead(status, {
30
+ 'content-type': 'application/json; charset=utf-8',
31
+ 'cache-control': 'no-store',
32
+ 'content-length': String(Buffer.byteLength(body)),
33
+ });
34
+ res.end(body);
35
+ }
36
+
37
+ /**
38
+ * The request came from this machine.
39
+ * @param {import('node:http').IncomingMessage} req
40
+ * @returns {boolean}
41
+ */
42
+ function isLoopback(req) {
43
+ const address = req.socket?.remoteAddress ?? '';
44
+ return address === '127.0.0.1'
45
+ || address === '::1'
46
+ || address.startsWith('::ffff:127.')
47
+ || address === '';
48
+ }
49
+
50
+ /**
51
+ * A browser request carries an Origin matching Host. The desktop document
52
+ * proxy strips both headers, which is accepted here — a browser cannot produce
53
+ * that shape, and a loopback peer without an Origin is the desktop shell.
54
+ * @param {import('node:http').IncomingMessage} req
55
+ * @returns {boolean}
56
+ */
57
+ function sameOrigin(req) {
58
+ const origin = req.headers.origin;
59
+ if (typeof origin !== 'string' || origin === '') return true;
60
+ try {
61
+ return new URL(origin).host === req.headers.host;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * @param {import('node:http').IncomingMessage} req
69
+ * @param {import('node:http').ServerResponse} res
70
+ */
71
+ async function handle(req, res) {
72
+ if (!isLoopback(req) || !sameOrigin(req)) {
73
+ sendJson(res, { ok: false, error: 'forbidden' }, 403);
74
+ return;
75
+ }
76
+
77
+ const url = new URL(req.url ?? ROUTE, 'http://localhost');
78
+ const op = url.pathname.slice(ROUTE.length).replace(/^\/+/u, '') || url.searchParams.get('op') || '';
79
+ if (op !== '' && op !== 'restart') {
80
+ sendJson(res, { ok: false, error: 'not-found' }, 404);
81
+ return;
82
+ }
83
+
84
+ if (req.method === 'GET') {
85
+ sendJson(res, { ok: true, action: 'restart', ...describeRestart() });
86
+ return;
87
+ }
88
+
89
+ if (req.method === 'POST') {
90
+ try {
91
+ const scheduled = requestRestart();
92
+ sendJson(res, { ok: true, restarting: true, ...scheduled });
93
+ } catch (error) {
94
+ sendJson(res, { ok: false, code: error?.code, error: String(error?.message ?? error) }, 409);
95
+ }
96
+ return;
97
+ }
98
+
99
+ sendJson(res, { ok: false, error: 'method-not-allowed' }, 405);
100
+ }
101
+
102
+ /**
103
+ * Mount the restart route once the Web server exists.
104
+ * @param {import('@deepseek-ai/cordis').Context} ctx
105
+ */
106
+ export function apply(ctx) {
107
+ if (ctx === undefined || typeof ctx.inject !== 'function') return;
108
+ ctx.inject(['webServer'], (scope) => {
109
+ const webServer = scope.get('webServer');
110
+ if (webServer === undefined || typeof webServer.register !== 'function') return;
111
+ scope.effect(
112
+ () => webServer.register({ kind: 'prefix', path: ROUTE, handler: handle }),
113
+ 'dsh-powerup: restart route',
114
+ );
115
+ });
116
+ }
117
+
118
+ /** Exported for tests only; the Host never reads this. */
119
+ export const __test = { handle, isLoopback, sameOrigin, ROUTE };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * dsh-powerup — detached restart helper (runs in Electron's Node mode).
3
+ *
4
+ * Started by `lib/restart.js` with `detached: true`. It waits for the UI
5
+ * notice and the HTTP response to settle, ends the Electron main process, waits
6
+ * for the old Host to exit, then relaunches the same executable.
7
+ *
8
+ * Arguments:
9
+ * 1. exe desktop executable (`process.execPath`)
10
+ * 2. mainPid Electron main process pid (the Host's parent)
11
+ * 3. hostPid this Host process pid
12
+ * 4. appDir application directory, used as the new process cwd
13
+ * 5. argsJson optional JSON array of launch arguments (normally empty)
14
+ * 6. mode `restart` (default); `shutdown` is reserved for future use
15
+ * 7. delayMs grace period before acting (default 1500 ms)
16
+ *
17
+ * Environment:
18
+ * DSH_POWERUP_RESTART_LOG optional path for a one-line run log.
19
+ *
20
+ * Adapted from dsh-pocket's restart helper.
21
+ *
22
+ * @module dsh-powerup/restart-helper
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const { spawn } = require('node:child_process');
28
+ const fs = require('node:fs');
29
+
30
+ /** Wait for the UI notice and the HTTP response before ending the main process. */
31
+ const RESPONSE_GRACE_MS = 1500;
32
+ /** Upper bound for the old Host's graceful shutdown. */
33
+ const HOST_EXIT_TIMEOUT_MS = 15000;
34
+ /** Let the OS release the single-instance lock and file handles. */
35
+ const RELAUNCH_DELAY_MS = 1200;
36
+
37
+ const sleep = (ms) => new Promise((resolve) => {
38
+ setTimeout(resolve, ms);
39
+ });
40
+
41
+ /**
42
+ * Whether a process is alive; an access-denied answer counts as alive.
43
+ * @param {number} pid
44
+ * @returns {boolean}
45
+ */
46
+ function alive(pid) {
47
+ if (!Number.isInteger(pid) || pid <= 0) return false;
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ } catch (error) {
52
+ return error !== null && typeof error === 'object' && error.code === 'EPERM';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Poll until the process exits or the deadline passes.
58
+ * @param {number} pid
59
+ * @param {number} timeout
60
+ * @returns {Promise<boolean>}
61
+ */
62
+ async function waitGone(pid, timeout) {
63
+ const deadline = Date.now() + timeout;
64
+ while (Date.now() < deadline) {
65
+ if (!alive(pid)) return true;
66
+ await sleep(120);
67
+ }
68
+ return !alive(pid);
69
+ }
70
+
71
+ /**
72
+ * Terminate a process; failures are logged, not thrown.
73
+ * @param {number} pid
74
+ * @param {(message: string) => void} log
75
+ */
76
+ function terminate(pid, log) {
77
+ if (!alive(pid)) return;
78
+ try {
79
+ process.kill(pid);
80
+ log(`terminated pid=${String(pid)}`);
81
+ } catch (error) {
82
+ log(`terminate pid=${String(pid)} failed: ${String(error && error.message ? error.message : error)}`);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Parse the optional launch arguments.
88
+ * @param {string | undefined} raw
89
+ * @returns {string[]}
90
+ */
91
+ function parseArgs(raw) {
92
+ if (typeof raw !== 'string' || raw.length === 0) return [];
93
+ try {
94
+ const value = JSON.parse(raw);
95
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Parse the grace period, clamped to 0–10 seconds.
103
+ * @param {string | undefined} raw
104
+ * @returns {number}
105
+ */
106
+ function parseDelay(raw) {
107
+ if (typeof raw !== 'string' || raw.length === 0) return RESPONSE_GRACE_MS;
108
+ const value = Number(raw);
109
+ if (!Number.isFinite(value) || value < 0) return RESPONSE_GRACE_MS;
110
+ return Math.min(value, 10000);
111
+ }
112
+
113
+ async function main() {
114
+ const [exe, mainPidRaw, hostPidRaw, appDir, argsRaw, modeRaw, delayRaw] = process.argv.slice(2);
115
+ const mode = modeRaw === 'shutdown' ? 'shutdown' : 'restart';
116
+ const delayMs = parseDelay(delayRaw);
117
+ const mainPid = Number(mainPidRaw);
118
+ const hostPid = Number(hostPidRaw);
119
+ const logPath = process.env.DSH_POWERUP_RESTART_LOG;
120
+ /** @param {string} message */
121
+ const log = (message) => {
122
+ if (typeof logPath !== 'string' || logPath.length === 0) return;
123
+ try {
124
+ fs.appendFileSync(logPath, `${new Date().toISOString()} ${message}\n`);
125
+ } catch {
126
+ /* A missing log never blocks the action. */
127
+ }
128
+ };
129
+
130
+ if (typeof exe !== 'string' || exe.length === 0) {
131
+ log('missing executable');
132
+ process.exit(2);
133
+ }
134
+
135
+ log(`scheduled mode=${mode} exe=${exe} main=${String(mainPid)} host=${String(hostPid)} delay=${String(delayMs)}`);
136
+ await sleep(delayMs);
137
+
138
+ // The Host exits on its own when the main process goes away; only the main
139
+ // process is terminated outright.
140
+ terminate(mainPid, log);
141
+ if (!(await waitGone(mainPid, 5000))) {
142
+ log('main still alive; retrying');
143
+ terminate(mainPid, log);
144
+ if (!(await waitGone(mainPid, 3000))) {
145
+ log('main did not exit; aborting');
146
+ process.exit(1);
147
+ }
148
+ }
149
+ if (!(await waitGone(hostPid, HOST_EXIT_TIMEOUT_MS))) {
150
+ log('host did not exit in time; terminating it');
151
+ terminate(hostPid, log);
152
+ await waitGone(hostPid, 3000);
153
+ }
154
+
155
+ if (mode === 'shutdown') {
156
+ log('shutdown complete; not relaunching');
157
+ process.exit(0);
158
+ }
159
+
160
+ await sleep(RELAUNCH_DELAY_MS);
161
+
162
+ const env = { ...process.env };
163
+ // These keep a new process in Node mode; the relaunched app must not inherit them.
164
+ delete env.ELECTRON_RUN_AS_NODE;
165
+ delete env.ELECTRON_NO_ATTACH_CONSOLE;
166
+ delete env.ELECTRON_ENABLE_LOGGING;
167
+ delete env.DSH_DESKTOP_NODE_EXECUTABLE;
168
+ delete env.DSH_POWERUP_RESTART_LOG;
169
+
170
+ const child = spawn(exe, parseArgs(argsRaw), {
171
+ cwd: typeof appDir === 'string' && appDir.length > 0 ? appDir : undefined,
172
+ env,
173
+ detached: true,
174
+ windowsHide: false,
175
+ stdio: 'ignore',
176
+ });
177
+ child.on('error', (error) => {
178
+ log(`spawn failed: ${String(error && error.message ? error.message : error)}`);
179
+ process.exit(1);
180
+ });
181
+ child.unref();
182
+ log(`launched pid=${String(child.pid)}`);
183
+
184
+ // Give spawn a moment to report a pid or an error, then exit.
185
+ await sleep(400);
186
+ process.exit(0);
187
+ }
188
+
189
+ main().catch((error) => {
190
+ try {
191
+ console.error(error);
192
+ } catch {
193
+ /* ignore */
194
+ }
195
+ process.exit(1);
196
+ });
package/lib/restart.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * dsh-powerup — restart the whole DSH desktop app.
3
+ *
4
+ * The Host half runs inside the `dsh-desktop-host` child process, whose parent
5
+ * is the Electron main process. The desktop shell exposes shutdown and update
6
+ * controls over IPC but no "restart application" request, so this module
7
+ * starts a detached helper (`restart-helper.cjs`), lets the HTTP response
8
+ * settle, then ends the Electron main process. The helper waits for this Host
9
+ * to exit and relaunches the same executable.
10
+ *
11
+ * Only the Electron desktop build supports this. A plain `dsh web` process
12
+ * reports the reason instead, and the UI hides the action.
13
+ *
14
+ * The mechanism is adapted from dsh-pocket's restart module (same profile,
15
+ * verified on this machine).
16
+ *
17
+ * @module dsh-powerup/restart
18
+ */
19
+
20
+ import { spawn } from 'node:child_process';
21
+ import { existsSync, mkdirSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ /** Helper shipped beside this module. */
27
+ const HELPER_PATH = fileURLToPath(new URL('./restart-helper.cjs', import.meta.url));
28
+
29
+ /** Let the UI paint its "restarting" state and the HTTP response settle. */
30
+ const ACTION_DELAY_MS = 1500;
31
+
32
+ /** One helper per process, even if the button is clicked twice. */
33
+ let scheduled = false;
34
+
35
+ /**
36
+ * The DSH home directory, matching the launcher's `DSH_HOME` handling.
37
+ * @returns {string}
38
+ */
39
+ function dshHome() {
40
+ const configured = process.env.DSH_HOME;
41
+ if (typeof configured === 'string' && configured.trim().length > 0) return configured.trim();
42
+ return join(homedir(), '.dsh');
43
+ }
44
+
45
+ /**
46
+ * Whether this runtime can restart the whole application.
47
+ * @returns {{ supported: boolean, reason?: string }}
48
+ */
49
+ export function describeRestart() {
50
+ if (process.versions.electron === undefined) {
51
+ return {
52
+ supported: false,
53
+ reason: 'DSH is running as a command-line web process (dsh web); restart it from the terminal.',
54
+ };
55
+ }
56
+ if (typeof process.execPath !== 'string' || process.execPath.length === 0 || !existsSync(process.execPath)) {
57
+ return { supported: false, reason: 'The DSH executable could not be located.' };
58
+ }
59
+ const mainPid = process.ppid;
60
+ if (!Number.isInteger(mainPid) || mainPid <= 1) {
61
+ return { supported: false, reason: 'The DSH main process could not be located.' };
62
+ }
63
+ return { supported: true };
64
+ }
65
+
66
+ /**
67
+ * Schedule a full application restart. The caller must finish its HTTP
68
+ * response; the helper waits {@link ACTION_DELAY_MS} before acting.
69
+ *
70
+ * @returns {{ supported: true, action: 'restart', pid: number | undefined, pending: boolean }}
71
+ * @throws {Error} with `code = 'restart-unsupported'` outside the desktop app.
72
+ */
73
+ export function requestRestart() {
74
+ const capability = describeRestart();
75
+ if (!capability.supported) {
76
+ const error = new Error(capability.reason ?? 'This runtime cannot restart DSH.');
77
+ error.code = 'restart-unsupported';
78
+ throw error;
79
+ }
80
+ if (scheduled) return { supported: true, action: 'restart', pid: undefined, pending: true };
81
+ scheduled = true;
82
+
83
+ const executable = process.execPath;
84
+ const logPath = join(dshHome(), 'powerup', 'restart.log');
85
+ try {
86
+ mkdirSync(dirname(logPath), { recursive: true });
87
+ } catch {
88
+ /* The log directory is optional; the helper tolerates a missing path. */
89
+ }
90
+
91
+ let child;
92
+ try {
93
+ child = spawn(process.execPath, [
94
+ HELPER_PATH,
95
+ executable,
96
+ String(process.ppid),
97
+ String(process.pid),
98
+ dirname(executable),
99
+ '[]',
100
+ 'restart',
101
+ String(ACTION_DELAY_MS),
102
+ ], {
103
+ cwd: dirname(executable),
104
+ detached: true,
105
+ windowsHide: true,
106
+ stdio: 'ignore',
107
+ env: {
108
+ ...process.env,
109
+ ELECTRON_RUN_AS_NODE: '1',
110
+ DSH_POWERUP_RESTART_LOG: logPath,
111
+ },
112
+ });
113
+ } catch (error) {
114
+ scheduled = false;
115
+ throw error;
116
+ }
117
+ if (child.pid === undefined) {
118
+ scheduled = false;
119
+ const error = new Error('The restart helper could not be started.');
120
+ error.code = 'restart-helper-failed';
121
+ throw error;
122
+ }
123
+ child.unref();
124
+ child.on('error', () => {
125
+ scheduled = false;
126
+ });
127
+ return { supported: true, action: 'restart', pid: child.pid, pending: false };
128
+ }
129
+
130
+ /**
131
+ * Reset the one-shot latch; exported for tests only.
132
+ * @internal
133
+ */
134
+ export function __resetScheduled() {
135
+ scheduled = false;
136
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Power-Up",
4
+ "description": "Adds a right-click action menu to the Trajectory ledger: copy a record, copy a Markdown quote, quote it into the composer, open the record inspector, fork the session at that record, export the trajectory snapshot, and copy the session ID. The Plugins page gains an update control on every third-party installed plugin: choose a version from the registry, uninstall the old copy, reinstall through the Add plugin path, then enable and optionally restart DSH."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Power-Up",
4
+ "description": "在轨迹页面为记录增加右键操作菜单:复制内容、复制 Markdown 引用、引用到输入框、查看记录详情、从该记录 Fork 会话、导出轨迹快照、复制会话 ID。插件页为每个第三方已安装插件增加更新按钮:从安装源选择版本,先卸载旧版再按「添加插件」流程安装,完成后可立即启用并选择是否重启 DSH。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "@restregular/dsh-powerup",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
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 使更改生效。",
5
+ "icon": "./icon.svg",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg",
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "client.js",
19
+ "cordis.patch.yml",
20
+ "lib/restart.js",
21
+ "lib/restart-helper.cjs",
22
+ "locale/*.json",
23
+ "icon.svg",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "platform": "web",
33
+ "immediately": true,
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-layout",
37
+ "@deepseek-ai/dsh-client-ui-conversation",
38
+ "@deepseek-ai/dsh-client-ui-session",
39
+ "@deepseek-ai/dsh-api-session-controller",
40
+ "@deepseek-ai/dsh-client-ui-workspace"
41
+ ]
42
+ }
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/RestRegular/dsh-powerup.git"
47
+ },
48
+ "homepage": "https://github.com/RestRegular/dsh-powerup#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/RestRegular/dsh-powerup/issues"
51
+ },
52
+ "keywords": [
53
+ "dsh-plugin",
54
+ "deepseek-harness",
55
+ "deepseek",
56
+ "trajectory",
57
+ "context-menu",
58
+ "power-up"
59
+ ],
60
+ "publishConfig": {
61
+ "access": "public",
62
+ "registry": "https://registry.npmjs.org/"
63
+ },
64
+ "engines": {
65
+ "node": ">=22.13.0"
66
+ },
67
+ "scripts": {
68
+ "test": "node test/smoke.mjs"
69
+ },
70
+ "license": "MIT",
71
+ "author": "RestRegular"
72
+ }