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/README.md +4 -0
- package/README.zh.md +4 -0
- package/client/client.js +113 -43
- package/client/client.js.map +1 -1
- package/data/registry-snapshot.json +4512 -975
- package/lib/index.js +4 -1
- package/lib/restart.js +86 -0
- package/lib/routes.js +43 -0
- package/lib/types/restart.d.ts +37 -0
- package/lib/types/routes.d.ts +2 -0
- package/package.json +2 -2
- package/src/client/MarketSection.tsx +59 -0
- package/src/client/locales.ts +8 -0
- package/src/index.ts +4 -1
- package/src/restart.ts +96 -0
- package/src/routes.ts +45 -0
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 = {
|
|
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;
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -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.
|
|
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>
|
package/src/client/locales.ts
CHANGED
|
@@ -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 = {
|
|
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',
|