dshmarket 1.2.4 → 1.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/lib/index.js CHANGED
@@ -22,7 +22,10 @@ function argvProfile() {
22
22
  return undefined;
23
23
  }
24
24
  export function apply(ctx, config) {
25
- const resolved = { profile: config?.profile ?? argvProfile() ?? 'web' };
25
+ const resolved = {
26
+ profile: config?.profile ?? argvProfile() ?? 'web',
27
+ allowRestart: config?.allowRestart ?? true,
28
+ };
26
29
  ctx.inject(['webServer', 'loader'], (hostCtx) => {
27
30
  const host = hostCtx;
28
31
  host.effect(() => mountMarketRoutes(host, resolved), 'dsh-market: http routes');
package/lib/restart.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Self-restart: relaunch the exact DSH invocation that booted this host so
3
+ * pending (non-hot) plugin changes take effect without the user leaving the
4
+ * UI. Contributed in #14 by @ysyyhhh; ported onto the layered architecture.
5
+ *
6
+ * Safety model: the endpoint accepts only direct same-origin loopback
7
+ * requests (no forwarding headers), refuses while a plugin operation runs,
8
+ * and deployments under a supervisor (systemd/launchd/pm2) can disable the
9
+ * whole feature with `allowRestart: false` — the supervisor owns restarts.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { dshArgv } from './dsh-cli.js';
15
+ /** Self-restart is enabled by default and disabled only by an explicit false. */
16
+ export function restartAllowed(config) {
17
+ return config.allowRestart !== false;
18
+ }
19
+ /** Whether a process-control request came from this Web host on loopback. */
20
+ export function trustedRestartRequest(request) {
21
+ const address = request.socket.remoteAddress;
22
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
23
+ return false;
24
+ // Any forwarding trace means the loopback peer is a proxy, not the user.
25
+ if (request.headers.forwarded !== undefined
26
+ || request.headers['x-forwarded-for'] !== undefined
27
+ || request.headers['x-real-ip'] !== undefined)
28
+ return false;
29
+ const origin = request.headers.origin;
30
+ const host = request.headers.host;
31
+ if (origin === undefined || host === undefined)
32
+ return false;
33
+ try {
34
+ const parsed = new URL(origin);
35
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /** The exact boot invocation the detached restart helper replays. */
42
+ export function restartLaunch() {
43
+ const launch = dshArgv();
44
+ return {
45
+ ...launch,
46
+ args: [...launch.args, ...process.argv.slice(2)],
47
+ cwd: launch.cwd ?? process.cwd(),
48
+ };
49
+ }
50
+ /**
51
+ * Relaunch this exact DSH entry after a short detached handoff, then stop
52
+ * this process. The helper outlives us (detached + unref), waits for our
53
+ * port to free up, and logs the replacement's output under tmpdir.
54
+ */
55
+ export function scheduleRestart() {
56
+ const launch = restartLaunch();
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
58
+ const logOut = join(tmpdir(), `dsh-market-restart-${stamp}.out.log`);
59
+ const logErr = join(tmpdir(), `dsh-market-restart-${stamp}.err.log`);
60
+ const helperCode = [
61
+ "const { spawn } = require('node:child_process')",
62
+ "const fs = require('node:fs')",
63
+ `const file = ${JSON.stringify(launch.file)}`,
64
+ `const args = ${JSON.stringify(launch.args)}`,
65
+ `const cwd = ${JSON.stringify(launch.cwd)}`,
66
+ `const viaShell = ${JSON.stringify(launch.viaShell)}`,
67
+ `const logOut = ${JSON.stringify(logOut)}`,
68
+ `const logErr = ${JSON.stringify(logErr)}`,
69
+ 'setTimeout(() => {',
70
+ ' try {',
71
+ ' const out = fs.openSync(logOut, "a")',
72
+ ' const err = fs.openSync(logErr, "a")',
73
+ ' const child = spawn(file, args, { cwd, detached: true, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
74
+ ' child.unref()',
75
+ ' } catch {}',
76
+ '}, 1500)',
77
+ ].join('\n');
78
+ const helper = spawn(process.execPath, ['-e', helperCode], {
79
+ detached: true,
80
+ stdio: 'ignore',
81
+ env: process.env,
82
+ });
83
+ helper.unref();
84
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500);
85
+ return { pid: process.pid, helperPid: helper.pid, logOut, logErr };
86
+ }
package/lib/routes.js CHANGED
@@ -19,6 +19,7 @@ import { isStaleUpdate, parseIgnoredBuilds, retargetCollections, validateAddedPl
19
19
  import { checkUpdates, invalidateUpdates } from './updates.js';
20
20
  import { createThemeManager } from './themes.js';
21
21
  import { readJsonBody, sameOrigin, sendJson } from './http.js';
22
+ import { restartAllowed, scheduleRestart, trustedRestartRequest } from './restart.js';
22
23
  const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
23
24
  /**
24
25
  * Register the market's HTTP routes.
@@ -59,6 +60,7 @@ export function mountMarketRoutes(host, config) {
59
60
  void themes.setEntryDisabled(name, true);
60
61
  });
61
62
  let installing = false;
63
+ let restarting = false;
62
64
  /**
63
65
  * Drop live hot mounts whose package was removed outside the market
64
66
  * (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
@@ -161,6 +163,7 @@ export function mountMarketRoutes(host, config) {
161
163
  lastLine: progress.lastLine,
162
164
  pnpm: await probePnpm(),
163
165
  boot: BOOT_ID,
166
+ restart: restartAllowed(config),
164
167
  installed: readInstalled(config.profile),
165
168
  });
166
169
  },
@@ -319,6 +322,46 @@ export function mountMarketRoutes(host, config) {
319
322
  }
320
323
  },
321
324
  }),
325
+ host.webServer.register({
326
+ kind: 'exact',
327
+ path: '/dsh-market/restart',
328
+ handler: (request, response) => {
329
+ if (request.method !== 'POST') {
330
+ response.writeHead(405, { allow: 'POST' });
331
+ response.end();
332
+ return;
333
+ }
334
+ // One-click restart contributed in #14 by @ysyyhhh.
335
+ if (!restartAllowed(config)) {
336
+ sendJson(response, 403, { error: 'self-restart is disabled for this host' });
337
+ return;
338
+ }
339
+ if (!trustedRestartRequest(request)) {
340
+ sendJson(response, 403, { error: 'restart is limited to same-origin loopback requests' });
341
+ return;
342
+ }
343
+ if (installing) {
344
+ sendJson(response, 409, { error: 'cannot restart while a plugin operation is running' });
345
+ return;
346
+ }
347
+ if (restarting) {
348
+ sendJson(response, 409, { error: 'restart already scheduled' });
349
+ return;
350
+ }
351
+ restarting = true;
352
+ try {
353
+ const result = scheduleRestart();
354
+ logEvent('info', 'restart', `scheduled pid=${String(result.pid)} helper=${String(result.helperPid)}`);
355
+ sendJson(response, 202, { ok: true, boot: BOOT_ID, ...result });
356
+ }
357
+ catch (error) {
358
+ restarting = false;
359
+ const message = error instanceof Error ? error.message : String(error);
360
+ logEvent('error', 'restart', message);
361
+ sendJson(response, 500, { error: message });
362
+ }
363
+ },
364
+ }),
322
365
  host.webServer.register({
323
366
  kind: 'exact',
324
367
  path: '/dsh-market/approve-builds',
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Self-restart: relaunch the exact DSH invocation that booted this host so
3
+ * pending (non-hot) plugin changes take effect without the user leaving the
4
+ * UI. Contributed in #14 by @ysyyhhh; ported onto the layered architecture.
5
+ *
6
+ * Safety model: the endpoint accepts only direct same-origin loopback
7
+ * requests (no forwarding headers), refuses while a plugin operation runs,
8
+ * and deployments under a supervisor (systemd/launchd/pm2) can disable the
9
+ * whole feature with `allowRestart: false` — the supervisor owns restarts.
10
+ */
11
+ import type { IncomingMessage } from 'node:http';
12
+ /** Self-restart is enabled by default and disabled only by an explicit false. */
13
+ export declare function restartAllowed(config: {
14
+ allowRestart?: boolean;
15
+ }): boolean;
16
+ /** Whether a process-control request came from this Web host on loopback. */
17
+ export declare function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
18
+ /** The exact boot invocation the detached restart helper replays. */
19
+ export declare function restartLaunch(): {
20
+ file: string;
21
+ args: string[];
22
+ cwd: string;
23
+ viaShell: boolean;
24
+ };
25
+ /** What scheduleRestart reports back to the caller for logging/response. */
26
+ export interface RestartResult {
27
+ pid: number;
28
+ helperPid: number | undefined;
29
+ logOut: string;
30
+ logErr: string;
31
+ }
32
+ /**
33
+ * Relaunch this exact DSH entry after a short detached handoff, then stop
34
+ * this process. The helper outlives us (detached + unref), waits for our
35
+ * port to free up, and logs the replacement's output under tmpdir.
36
+ */
37
+ export declare function scheduleRestart(): RestartResult;
@@ -42,6 +42,8 @@ export interface MarketHost {
42
42
  export interface MarketConfig {
43
43
  /** Profile the market installs into; matches the profile serving this UI. */
44
44
  profile: string;
45
+ /** Detached self-restart is unsafe under systemd/launchd/pm2; operators can disable it (#14). */
46
+ allowRestart?: boolean;
45
47
  }
46
48
  /**
47
49
  * Register the market's HTTP routes.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.2.4",
4
+ "version": "1.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -24,7 +24,7 @@
24
24
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
25
25
  "build": "tsc -p tsconfig.json && npm run build:client",
26
26
  "build:client": "tsdown && node scripts/normalize-client-banner.mjs",
27
- "check": "npm run typecheck && npm run build",
27
+ "check": "npm run typecheck && npm run build && node scripts/restart-smoke.mjs",
28
28
  "test": "vitest run",
29
29
  "test:compat": "vitest run --config vitest.compat.config.ts",
30
30
  "prepack": "npm run build && node scripts/preflight.mjs",
@@ -123,6 +123,9 @@ export function MarketSection(props: MarketSectionProps) {
123
123
  const [envFixing, setEnvFixing] = useState(false)
124
124
  const [envFailed, setEnvFailed] = useState(false)
125
125
  const [bootId, setBootId] = useState<string | null>(null)
126
+ /** One-click restart (#14 by @ysyyhhh): server capability + in-flight state. */
127
+ const [restartEnabled, setRestartEnabled] = useState(false)
128
+ const [restarting, setRestarting] = useState(false)
126
129
  const [showTop, setShowTop] = useState(false)
127
130
  const bodyRef = useRef<HTMLDivElement | null>(null)
128
131
  const [sort, setSort] = useState('hot')
@@ -157,6 +160,7 @@ export function MarketSection(props: MarketSectionProps) {
157
160
  .then(status => {
158
161
  setEnvReady(status.pnpm !== false)
159
162
  if (typeof status.boot === 'string') setBootId(status.boot)
163
+ setRestartEnabled(status.restart === true)
160
164
  })
161
165
  .catch(() => {})
162
166
  refreshInstalled()
@@ -309,6 +313,53 @@ export function MarketSection(props: MarketSectionProps) {
309
313
  .finally(() => setBusyUrl(null))
310
314
  }, [refreshInstalled, t])
311
315
 
316
+ /**
317
+ * Restart the host and reload once the boot id changes (#14 by @ysyyhhh).
318
+ * The 202 races the process's SIGTERM, so network errors on the initial
319
+ * request are expected and treated as "restart under way".
320
+ */
321
+ const doRestart = useCallback(() => {
322
+ if (bootId === null || restarting) return
323
+ const previousBoot = bootId
324
+ setRestarting(true)
325
+ setInstallError(null)
326
+ const awaitNewBoot = () => {
327
+ const deadline = Date.now() + 60000
328
+ const poll = () => {
329
+ fetch('/dsh-market/status', { cache: 'no-store' })
330
+ .then(res => res.json())
331
+ .then((next) => {
332
+ if (typeof next.boot === 'string' && next.boot !== previousBoot) {
333
+ location.reload()
334
+ return
335
+ }
336
+ retry()
337
+ })
338
+ .catch(retry)
339
+ }
340
+ const retry = () => {
341
+ if (Date.now() > deadline) {
342
+ setRestarting(false)
343
+ setInstallError(t('restartTimeout'))
344
+ return
345
+ }
346
+ setTimeout(poll, 1500)
347
+ }
348
+ poll()
349
+ }
350
+ fetch('/dsh-market/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
351
+ .then(res => res.json().then(body => ({ status: res.status, body })))
352
+ .then(({ status, body }) => {
353
+ if (status !== 202 || body.ok !== true) {
354
+ setRestarting(false)
355
+ setInstallError(t('restartFail') + ': ' + String(body.error || ('HTTP ' + String(status))))
356
+ return
357
+ }
358
+ awaitNewBoot()
359
+ })
360
+ .catch(awaitNewBoot) // the host may die mid-response; keep polling
361
+ }, [bootId, restarting, t])
362
+
312
363
  /** Cancel the running plugin command (#6 by @qichuang321). */
313
364
  const doCancel = useCallback(() => {
314
365
  fetch('/dsh-market/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
@@ -638,6 +689,14 @@ export function MarketSection(props: MarketSectionProps) {
638
689
  <span>🔄</span>
639
690
  <span className={css.grow}><b>{pendingRestart}</b> {t('restartBanner')}</span>
640
691
  <span title={t('restartHint')}>ℹ️</span>
692
+ {restartEnabled && (
693
+ <Button
694
+ variant="primary"
695
+ size="sm"
696
+ disabled={restarting || busyUrl !== null || updatingName !== null || removingName !== null}
697
+ onClick={doRestart}
698
+ >{restarting ? t('restarting') : t('restartNow')}</Button>
699
+ )}
641
700
  </div>
642
701
  )}
643
702
  </div>
@@ -34,6 +34,10 @@ export const zh = {
34
34
  busyWait: '已有操作正在进行,请等它结束(同一时间只执行一个安装/更新/卸载)',
35
35
  approveBuilds: '放行构建脚本并重试',
36
36
  buildsSkipped: '该插件需要运行构建脚本才能工作,出于安全默认被拦下。点击下方按钮为它放行并重装:',
37
+ restartNow: '立即重启',
38
+ restarting: '正在重启…',
39
+ restartFail: '重启失败',
40
+ restartTimeout: '等待 DeepSeek Harness 启动超时',
37
41
  updateNow: '立即更新',
38
42
  updateFail: '更新失败',
39
43
  upToDate: '已是最新',
@@ -102,6 +106,10 @@ export const en: Record<MarketKey, string> = {
102
106
  busyWait: 'Another operation is already running — please wait for it to finish (one install/update/uninstall at a time)',
103
107
  approveBuilds: 'Allow build scripts and retry',
104
108
  buildsSkipped: 'This plugin needs its build scripts to run; they are blocked by default for safety. Click below to allow them and reinstall:',
109
+ restartNow: 'Restart now',
110
+ restarting: 'Restarting…',
111
+ restartFail: 'Restart failed',
112
+ restartTimeout: 'Timed out waiting for DeepSeek Harness to start',
105
113
  updateNow: 'Update now',
106
114
  updateFail: 'Update failed',
107
115
  upToDate: 'Up to date',
package/src/index.ts CHANGED
@@ -29,7 +29,10 @@ function argvProfile(): string | undefined {
29
29
  }
30
30
 
31
31
  export function apply(ctx: Context, config?: Config): void {
32
- const resolved: MarketConfig = { profile: config?.profile ?? argvProfile() ?? 'web' }
32
+ const resolved: MarketConfig = {
33
+ profile: config?.profile ?? argvProfile() ?? 'web',
34
+ allowRestart: config?.allowRestart ?? true,
35
+ }
33
36
  ctx.inject(['webServer', 'loader'], (hostCtx: Context) => {
34
37
  const host = hostCtx as unknown as MarketHost & {
35
38
  effect(callback: () => () => void, label: string): void
package/src/restart.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Self-restart: relaunch the exact DSH invocation that booted this host so
3
+ * pending (non-hot) plugin changes take effect without the user leaving the
4
+ * UI. Contributed in #14 by @ysyyhhh; ported onto the layered architecture.
5
+ *
6
+ * Safety model: the endpoint accepts only direct same-origin loopback
7
+ * requests (no forwarding headers), refuses while a plugin operation runs,
8
+ * and deployments under a supervisor (systemd/launchd/pm2) can disable the
9
+ * whole feature with `allowRestart: false` — the supervisor owns restarts.
10
+ */
11
+
12
+ import { spawn } from 'node:child_process'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import type { IncomingMessage } from 'node:http'
16
+ import { dshArgv } from './dsh-cli.ts'
17
+
18
+ /** Self-restart is enabled by default and disabled only by an explicit false. */
19
+ export function restartAllowed(config: { allowRestart?: boolean }): boolean {
20
+ return config.allowRestart !== false
21
+ }
22
+
23
+ /** Whether a process-control request came from this Web host on loopback. */
24
+ export function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
25
+ const address = request.socket.remoteAddress
26
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
27
+ // Any forwarding trace means the loopback peer is a proxy, not the user.
28
+ if (request.headers.forwarded !== undefined
29
+ || request.headers['x-forwarded-for'] !== undefined
30
+ || request.headers['x-real-ip'] !== undefined) return false
31
+ const origin = request.headers.origin
32
+ const host = request.headers.host
33
+ if (origin === undefined || host === undefined) return false
34
+ try {
35
+ const parsed = new URL(origin)
36
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ /** The exact boot invocation the detached restart helper replays. */
43
+ export function restartLaunch(): { file: string; args: string[]; cwd: string; viaShell: boolean } {
44
+ const launch = dshArgv()
45
+ return {
46
+ ...launch,
47
+ args: [...launch.args, ...process.argv.slice(2)],
48
+ cwd: launch.cwd ?? process.cwd(),
49
+ }
50
+ }
51
+
52
+ /** What scheduleRestart reports back to the caller for logging/response. */
53
+ export interface RestartResult {
54
+ pid: number
55
+ helperPid: number | undefined
56
+ logOut: string
57
+ logErr: string
58
+ }
59
+
60
+ /**
61
+ * Relaunch this exact DSH entry after a short detached handoff, then stop
62
+ * this process. The helper outlives us (detached + unref), waits for our
63
+ * port to free up, and logs the replacement's output under tmpdir.
64
+ */
65
+ export function scheduleRestart(): RestartResult {
66
+ const launch = restartLaunch()
67
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
68
+ const logOut = join(tmpdir(), `dsh-market-restart-${stamp}.out.log`)
69
+ const logErr = join(tmpdir(), `dsh-market-restart-${stamp}.err.log`)
70
+ const helperCode = [
71
+ "const { spawn } = require('node:child_process')",
72
+ "const fs = require('node:fs')",
73
+ `const file = ${JSON.stringify(launch.file)}`,
74
+ `const args = ${JSON.stringify(launch.args)}`,
75
+ `const cwd = ${JSON.stringify(launch.cwd)}`,
76
+ `const viaShell = ${JSON.stringify(launch.viaShell)}`,
77
+ `const logOut = ${JSON.stringify(logOut)}`,
78
+ `const logErr = ${JSON.stringify(logErr)}`,
79
+ 'setTimeout(() => {',
80
+ ' try {',
81
+ ' const out = fs.openSync(logOut, "a")',
82
+ ' const err = fs.openSync(logErr, "a")',
83
+ ' const child = spawn(file, args, { cwd, detached: true, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
84
+ ' child.unref()',
85
+ ' } catch {}',
86
+ '}, 1500)',
87
+ ].join('\n')
88
+ const helper = spawn(process.execPath, ['-e', helperCode], {
89
+ detached: true,
90
+ stdio: 'ignore',
91
+ env: process.env,
92
+ })
93
+ helper.unref()
94
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500)
95
+ return { pid: process.pid, helperPid: helper.pid, logOut, logErr }
96
+ }
package/src/routes.ts CHANGED
@@ -24,6 +24,7 @@ import { isStaleUpdate, parseIgnoredBuilds, retargetCollections, validateAddedPl
24
24
  import { checkUpdates, invalidateUpdates } from './updates.ts'
25
25
  import { createThemeManager, type LoaderEntry } from './themes.ts'
26
26
  import { readJsonBody, sameOrigin, sendJson } from './http.ts'
27
+ import { restartAllowed, scheduleRestart, trustedRestartRequest } from './restart.ts'
27
28
 
28
29
  export type { LoaderEntry } from './themes.ts'
29
30
  export type { UpdateStatus } from './updates.ts'
@@ -47,6 +48,8 @@ export interface MarketHost {
47
48
  export interface MarketConfig {
48
49
  /** Profile the market installs into; matches the profile serving this UI. */
49
50
  profile: string
51
+ /** Detached self-restart is unsafe under systemd/launchd/pm2; operators can disable it (#14). */
52
+ allowRestart?: boolean
50
53
  }
51
54
 
52
55
  const PROFILE_RE = /^[A-Za-z0-9_-]+$/
@@ -89,6 +92,7 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
89
92
  if (name !== undefined && disabledThemes.has(name)) void themes.setEntryDisabled(name, true)
90
93
  })
91
94
  let installing = false
95
+ let restarting = false
92
96
 
93
97
  /**
94
98
  * Drop live hot mounts whose package was removed outside the market
@@ -194,6 +198,7 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
194
198
  lastLine: progress.lastLine,
195
199
  pnpm: await probePnpm(),
196
200
  boot: BOOT_ID,
201
+ restart: restartAllowed(config),
197
202
  installed: readInstalled(config.profile),
198
203
  })
199
204
  },
@@ -351,6 +356,46 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
351
356
  },
352
357
  }),
353
358
 
359
+ host.webServer.register({
360
+ kind: 'exact',
361
+ path: '/dsh-market/restart',
362
+ handler: (request, response) => {
363
+ if (request.method !== 'POST') {
364
+ response.writeHead(405, { allow: 'POST' })
365
+ response.end()
366
+ return
367
+ }
368
+ // One-click restart contributed in #14 by @ysyyhhh.
369
+ if (!restartAllowed(config)) {
370
+ sendJson(response, 403, { error: 'self-restart is disabled for this host' })
371
+ return
372
+ }
373
+ if (!trustedRestartRequest(request)) {
374
+ sendJson(response, 403, { error: 'restart is limited to same-origin loopback requests' })
375
+ return
376
+ }
377
+ if (installing) {
378
+ sendJson(response, 409, { error: 'cannot restart while a plugin operation is running' })
379
+ return
380
+ }
381
+ if (restarting) {
382
+ sendJson(response, 409, { error: 'restart already scheduled' })
383
+ return
384
+ }
385
+ restarting = true
386
+ try {
387
+ const result = scheduleRestart()
388
+ logEvent('info', 'restart', `scheduled pid=${String(result.pid)} helper=${String(result.helperPid)}`)
389
+ sendJson(response, 202, { ok: true, boot: BOOT_ID, ...result })
390
+ } catch (error) {
391
+ restarting = false
392
+ const message = error instanceof Error ? error.message : String(error)
393
+ logEvent('error', 'restart', message)
394
+ sendJson(response, 500, { error: message })
395
+ }
396
+ },
397
+ }),
398
+
354
399
  host.webServer.register({
355
400
  kind: 'exact',
356
401
  path: '/dsh-market/approve-builds',