dshmarket 1.2.3 → 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 +270 -58
- package/client/client.js.map +1 -1
- package/data/registry-snapshot.json +4512 -975
- package/lib/dsh-cli.js +65 -5
- package/lib/index.js +4 -1
- package/lib/install.js +24 -2
- package/lib/profile.js +48 -3
- package/lib/restart.js +86 -0
- package/lib/routes.js +141 -15
- package/lib/types/dsh-cli.d.ts +7 -0
- package/lib/types/install.d.ts +6 -0
- package/lib/types/profile.d.ts +9 -1
- package/lib/types/restart.d.ts +37 -0
- package/lib/types/routes.d.ts +2 -0
- package/package.json +2 -2
- package/src/client/Market.module.css +4 -0
- package/src/client/MarketSection.tsx +157 -4
- package/src/client/locales.ts +18 -0
- package/src/dsh-cli.ts +59 -5
- package/src/index.ts +4 -1
- package/src/install.ts +22 -2
- package/src/profile.ts +45 -3
- package/src/restart.ts +96 -0
- package/src/routes.ts +147 -18
package/lib/dsh-cli.js
CHANGED
|
@@ -11,7 +11,9 @@ import { dirname, resolve } from 'node:path';
|
|
|
11
11
|
import { logEvent } from './log.js';
|
|
12
12
|
import { pluginArgsFor } from './pnpm-compat.js';
|
|
13
13
|
import { profileDir } from './profile.js';
|
|
14
|
-
|
|
14
|
+
// 15 min default (slow networks + git installs), overridable for CI/tests.
|
|
15
|
+
// (#6 by @qichuang321.)
|
|
16
|
+
const INSTALL_TIMEOUT_MS = Number(process.env.DSH_MARKET_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
|
|
15
17
|
/**
|
|
16
18
|
* Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
|
|
17
19
|
* cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
|
|
@@ -50,6 +52,55 @@ export function killChild(child) {
|
|
|
50
52
|
}
|
|
51
53
|
child.kill('SIGKILL');
|
|
52
54
|
}
|
|
55
|
+
/** The child of the operation currently running, for /dsh-market/cancel. */
|
|
56
|
+
let activeChild = null;
|
|
57
|
+
let cancelRequested = false;
|
|
58
|
+
/**
|
|
59
|
+
* Kill a child and its whole tree, gracefully where the platform allows:
|
|
60
|
+
* taskkill /T /F on Windows (plain kill() leaves pnpm children running),
|
|
61
|
+
* SIGTERM with a 5s SIGKILL escalation elsewhere so pnpm can clean up.
|
|
62
|
+
* (Cancel flow contributed in #6 by @qichuang321.)
|
|
63
|
+
*/
|
|
64
|
+
function killTree(child) {
|
|
65
|
+
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
66
|
+
try {
|
|
67
|
+
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
catch { /* fall through */ }
|
|
71
|
+
}
|
|
72
|
+
// POSIX: the dsh wrapper runs pnpm as a grandchild (spawnSync), which a
|
|
73
|
+
// plain child.kill() leaves running — it keeps our stdio pipes open, so
|
|
74
|
+
// the close event never fires and the market looks stuck "installing".
|
|
75
|
+
// The child is spawned detached as its own process GROUP; kill the group.
|
|
76
|
+
const signalTree = (signal) => {
|
|
77
|
+
if (child.pid === undefined)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
process.kill(-child.pid, signal);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
try {
|
|
84
|
+
child.kill(signal);
|
|
85
|
+
}
|
|
86
|
+
catch { /* already gone */ }
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
signalTree('SIGTERM');
|
|
90
|
+
const escalate = setTimeout(() => signalTree('SIGKILL'), 5000);
|
|
91
|
+
escalate.unref?.();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Cancel the plugin command currently running.
|
|
95
|
+
* @returns true when there was one to cancel.
|
|
96
|
+
*/
|
|
97
|
+
export function cancelActive() {
|
|
98
|
+
if (activeChild === null)
|
|
99
|
+
return false;
|
|
100
|
+
cancelRequested = true;
|
|
101
|
+
killTree(activeChild);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
53
104
|
/** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
|
|
54
105
|
let pnpmReady = false;
|
|
55
106
|
/** Probe `pnpm --version` on PATH. */
|
|
@@ -117,7 +168,7 @@ export function runDshPlugin(profile, pluginArgs) {
|
|
|
117
168
|
const target = pluginArgs[pluginArgs.length - 1] ?? '';
|
|
118
169
|
if (!TARGET_RE.test(target)) {
|
|
119
170
|
logEvent('error', 'install', `unsafe plugin target rejected: ${JSON.stringify(target)}`);
|
|
120
|
-
return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}
|
|
171
|
+
return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}`, cancelled: false });
|
|
121
172
|
}
|
|
122
173
|
progress.active = true;
|
|
123
174
|
progress.target = target;
|
|
@@ -132,13 +183,18 @@ export function runDshPlugin(profile, pluginArgs) {
|
|
|
132
183
|
env: { ...process.env, CI: 'true' },
|
|
133
184
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
134
185
|
shell: viaShell,
|
|
186
|
+
// Own process group on POSIX so cancel/timeout can kill the whole
|
|
187
|
+
// tree (dsh wrapper + pnpm grandchild) with one group signal.
|
|
188
|
+
detached: process.platform !== 'win32',
|
|
135
189
|
});
|
|
190
|
+
activeChild = child;
|
|
191
|
+
cancelRequested = false;
|
|
136
192
|
let stdout = '';
|
|
137
193
|
let stderr = '';
|
|
138
194
|
let timedOut = false;
|
|
139
195
|
const timer = setTimeout(() => {
|
|
140
196
|
timedOut = true;
|
|
141
|
-
|
|
197
|
+
killTree(child);
|
|
142
198
|
}, INSTALL_TIMEOUT_MS);
|
|
143
199
|
child.stdout.on('data', (chunk) => {
|
|
144
200
|
const text = chunk.toString();
|
|
@@ -153,12 +209,16 @@ export function runDshPlugin(profile, pluginArgs) {
|
|
|
153
209
|
child.on('error', (error) => {
|
|
154
210
|
clearTimeout(timer);
|
|
155
211
|
progress.active = false;
|
|
156
|
-
|
|
212
|
+
if (activeChild === child)
|
|
213
|
+
activeChild = null;
|
|
214
|
+
resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}`, cancelled: false });
|
|
157
215
|
});
|
|
158
216
|
child.on('close', (code) => {
|
|
159
217
|
clearTimeout(timer);
|
|
160
218
|
progress.active = false;
|
|
161
|
-
|
|
219
|
+
if (activeChild === child)
|
|
220
|
+
activeChild = null;
|
|
221
|
+
resolvePromise({ exitCode: code, timedOut, stdout, stderr, cancelled: cancelRequested });
|
|
162
222
|
});
|
|
163
223
|
});
|
|
164
224
|
}
|
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/install.js
CHANGED
|
@@ -19,7 +19,7 @@ import { logEvent } from './log.js';
|
|
|
19
19
|
*/
|
|
20
20
|
export async function withHoistRecovery(run, profile, pluginArgs) {
|
|
21
21
|
let result = await run(profile, pluginArgs);
|
|
22
|
-
const ok = (r) => r.exitCode === 0 && !r.timedOut;
|
|
22
|
+
const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
|
|
23
23
|
if (!ok(result) && classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)?.code === 'hoist-pattern-diff') {
|
|
24
24
|
logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
|
|
25
25
|
// --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
|
|
@@ -28,7 +28,7 @@ export async function withHoistRecovery(run, profile, pluginArgs) {
|
|
|
28
28
|
if (ok(rebuild))
|
|
29
29
|
result = await run(profile, pluginArgs);
|
|
30
30
|
}
|
|
31
|
-
if (!ok(result)) {
|
|
31
|
+
if (!ok(result) && !result.cancelled) {
|
|
32
32
|
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`);
|
|
33
33
|
if (failure !== null)
|
|
34
34
|
result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` };
|
|
@@ -108,3 +108,25 @@ export function isStaleUpdate(check) {
|
|
|
108
108
|
? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
|
|
109
109
|
: check.beforeVersion !== null && check.afterVersion === check.beforeVersion;
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Package names pnpm reported as having their build scripts ignored
|
|
113
|
+
* ("Ignored build scripts: esbuild, koffi."). Empty when none.
|
|
114
|
+
* (#6 by @qichuang321.)
|
|
115
|
+
*/
|
|
116
|
+
export function parseIgnoredBuilds(stdout, stderr) {
|
|
117
|
+
const m = /Ignored build scripts:?\s*([^\n]+)/i.exec(`${stdout}\n${stderr}`);
|
|
118
|
+
if (m === null)
|
|
119
|
+
return [];
|
|
120
|
+
const found = [];
|
|
121
|
+
for (const chunk of m[1].split(',')) {
|
|
122
|
+
// Entries may carry a version suffix and the sentence's final period.
|
|
123
|
+
const trimmed = chunk.trim().replace(/\.$/, '');
|
|
124
|
+
if (trimmed === '')
|
|
125
|
+
continue;
|
|
126
|
+
const at = trimmed.lastIndexOf('@');
|
|
127
|
+
const name = at > 0 ? trimmed.slice(0, at) : trimmed;
|
|
128
|
+
if (name !== '' && !found.includes(name))
|
|
129
|
+
found.push(name);
|
|
130
|
+
}
|
|
131
|
+
return found;
|
|
132
|
+
}
|
package/lib/profile.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* profile directory (manifest, lockfile, installed package trees). Pure
|
|
4
4
|
* functions of the directory contents; no processes, no network.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
/** Resolve a profile name to its directory under DSH_HOME (default ~/.dsh). */
|
|
@@ -11,13 +11,25 @@ export function profileDir(profile) {
|
|
|
11
11
|
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
12
12
|
return join(home, 'profiles', profile);
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The in-box bundles dsh's profile templates install themselves — the ONLY
|
|
16
|
+
* names the market hides from the installed list. Community plugins may
|
|
17
|
+
* legitimately publish under the official scope (#28), so a whole-scope
|
|
18
|
+
* filter would make them invisible and fail install validation.
|
|
19
|
+
* (Diagnosis and fix proposed in #28 by @Lograthmic.)
|
|
20
|
+
*/
|
|
21
|
+
const INBOX_BUNDLES = new Set([
|
|
22
|
+
'@deepseek-ai/dsh-base',
|
|
23
|
+
'@deepseek-ai/dsh-web-app',
|
|
24
|
+
'@deepseek-ai/dsh-headless',
|
|
25
|
+
]);
|
|
26
|
+
/** Community dependencies of the profile (in-box bundles filtered out). */
|
|
15
27
|
export function readInstalled(profile) {
|
|
16
28
|
try {
|
|
17
29
|
const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'package.json'), 'utf8'));
|
|
18
30
|
const installed = {};
|
|
19
31
|
for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
|
|
20
|
-
if (!
|
|
32
|
+
if (!INBOX_BUNDLES.has(name))
|
|
21
33
|
installed[name] = spec;
|
|
22
34
|
}
|
|
23
35
|
return installed;
|
|
@@ -119,3 +131,36 @@ export function pluginSubdirs(root) {
|
|
|
119
131
|
}
|
|
120
132
|
return found.slice(0, 8);
|
|
121
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Allow the given packages' build scripts in the profile's
|
|
136
|
+
* pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
|
|
137
|
+
* merging with existing entries and leaving the rest of the yaml intact.
|
|
138
|
+
* (#6 by @qichuang321.)
|
|
139
|
+
* @returns every package now allowed.
|
|
140
|
+
*/
|
|
141
|
+
export function setAllowBuilds(profile, packages) {
|
|
142
|
+
const file = join(profileDir(profile), 'pnpm-workspace.yaml');
|
|
143
|
+
let yaml = '';
|
|
144
|
+
try {
|
|
145
|
+
yaml = readFileSync(file, 'utf8');
|
|
146
|
+
}
|
|
147
|
+
catch { /* created below */ }
|
|
148
|
+
const blockRe = /allowBuilds:\n((?:[ \t]+[^\n]*\n?)*)/;
|
|
149
|
+
const map = {};
|
|
150
|
+
const blockMatch = blockRe.exec(yaml);
|
|
151
|
+
if (blockMatch !== null) {
|
|
152
|
+
for (const line of blockMatch[1].split('\n')) {
|
|
153
|
+
const m = /^[ \t]+([^:\s]+(?:\/[^:\s]+)?)\s*:\s*(\S.*)?$/.exec(line);
|
|
154
|
+
if (m !== null)
|
|
155
|
+
map[m[1]] = m[2] ?? 'true';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const pkg of packages) {
|
|
159
|
+
if (/^[A-Za-z0-9@/_.-]+$/.test(pkg))
|
|
160
|
+
map[pkg] = 'true';
|
|
161
|
+
}
|
|
162
|
+
const block = Object.entries(map).map(([k, v]) => ` ${k}: ${v}`).join('\n');
|
|
163
|
+
const blockText = `allowBuilds:\n${block}\n`;
|
|
164
|
+
writeFileSync(file, blockMatch !== null ? yaml.replace(blockRe, blockText) : `${yaml.replace(/\n?$/, '\n')}${blockText}`);
|
|
165
|
+
return Object.keys(map);
|
|
166
|
+
}
|
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
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
* Security: the install route executes a shell command, so it accepts only
|
|
8
8
|
* same-origin POSTs and only sources present in the curated registry.
|
|
9
9
|
*/
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
11
12
|
import { loadRegistry } from './registry.js';
|
|
12
13
|
import { cleanHotDir, hotMount, hotUnmount, listHotMounts, mountClientOnlyDeps, readDisabledThemes, } from './hot.js';
|
|
13
14
|
import { exportLogs, logEvent } from './log.js';
|
|
14
|
-
import { BOOT_ID, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.js';
|
|
15
|
-
import { profileDir, readInstalled, readInstalledVersion, readLockCommits } from './profile.js';
|
|
15
|
+
import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.js';
|
|
16
|
+
import { profileDir, readInstalled, readInstalledVersion, readLockCommits, setAllowBuilds } from './profile.js';
|
|
16
17
|
import { findInstalledAlias, installTargetFor } from './sources.js';
|
|
17
|
-
import { isStaleUpdate, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
18
|
+
import { isStaleUpdate, parseIgnoredBuilds, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
18
19
|
import { checkUpdates, invalidateUpdates } from './updates.js';
|
|
19
20
|
import { createThemeManager } from './themes.js';
|
|
20
21
|
import { readJsonBody, sameOrigin, sendJson } from './http.js';
|
|
22
|
+
import { restartAllowed, scheduleRestart, trustedRestartRequest } from './restart.js';
|
|
21
23
|
const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
22
24
|
/**
|
|
23
25
|
* Register the market's HTTP routes.
|
|
@@ -58,6 +60,21 @@ export function mountMarketRoutes(host, config) {
|
|
|
58
60
|
void themes.setEntryDisabled(name, true);
|
|
59
61
|
});
|
|
60
62
|
let installing = false;
|
|
63
|
+
let restarting = false;
|
|
64
|
+
/**
|
|
65
|
+
* Drop live hot mounts whose package was removed outside the market
|
|
66
|
+
* (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
|
|
67
|
+
* serving a client bundle that 404s after refresh, wedging the page
|
|
68
|
+
* until a restart (#29 by @SunYanbox).
|
|
69
|
+
*/
|
|
70
|
+
async function dropStaleHotMounts() {
|
|
71
|
+
for (const name of listHotMounts()) {
|
|
72
|
+
if (existsSync(join(profileDir(config.profile), 'node_modules', name, 'package.json')))
|
|
73
|
+
continue;
|
|
74
|
+
await hotUnmount(name);
|
|
75
|
+
logEvent('warn', 'hot-sweep', `${name}: package removed outside the market — live mount dropped`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
61
78
|
/** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
|
|
62
79
|
const runPlugin = (profile, args) => withHoistRecovery(runDshPlugin, profile, args);
|
|
63
80
|
const disposers = [
|
|
@@ -82,12 +99,13 @@ export function mountMarketRoutes(host, config) {
|
|
|
82
99
|
host.webServer.register({
|
|
83
100
|
kind: 'exact',
|
|
84
101
|
path: '/dsh-market/installed',
|
|
85
|
-
handler: (request, response) => {
|
|
102
|
+
handler: async (request, response) => {
|
|
86
103
|
if (request.method !== 'GET') {
|
|
87
104
|
response.writeHead(405, { allow: 'GET' });
|
|
88
105
|
response.end();
|
|
89
106
|
return;
|
|
90
107
|
}
|
|
108
|
+
await dropStaleHotMounts();
|
|
91
109
|
sendJson(response, 200, {
|
|
92
110
|
profile: config.profile,
|
|
93
111
|
installed: readInstalled(config.profile),
|
|
@@ -137,6 +155,7 @@ export function mountMarketRoutes(host, config) {
|
|
|
137
155
|
response.end();
|
|
138
156
|
return;
|
|
139
157
|
}
|
|
158
|
+
await dropStaleHotMounts();
|
|
140
159
|
sendJson(response, 200, {
|
|
141
160
|
active: progress.active,
|
|
142
161
|
target: progress.target,
|
|
@@ -144,6 +163,7 @@ export function mountMarketRoutes(host, config) {
|
|
|
144
163
|
lastLine: progress.lastLine,
|
|
145
164
|
pnpm: await probePnpm(),
|
|
146
165
|
boot: BOOT_ID,
|
|
166
|
+
restart: restartAllowed(config),
|
|
147
167
|
installed: readInstalled(config.profile),
|
|
148
168
|
});
|
|
149
169
|
},
|
|
@@ -236,7 +256,8 @@ export function mountMarketRoutes(host, config) {
|
|
|
236
256
|
// default one-day safety wait; scoped to this single command.
|
|
237
257
|
const addArgs = force ? ['add', '--config.minimumReleaseAge=0', target] : ['add', target];
|
|
238
258
|
const result = await runPlugin(config.profile, addArgs);
|
|
239
|
-
|
|
259
|
+
const cancelled = result.cancelled;
|
|
260
|
+
let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
240
261
|
let stale = false;
|
|
241
262
|
if (ok) {
|
|
242
263
|
stale = isStaleUpdate({
|
|
@@ -254,9 +275,11 @@ export function mountMarketRoutes(host, config) {
|
|
|
254
275
|
const staleError = stale
|
|
255
276
|
? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / This version was just released; for safety, installs normally wait about a day after a release. Try again tomorrow, or click "Update now" to install it right away.'
|
|
256
277
|
: null;
|
|
257
|
-
logEvent(ok ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
|
|
258
|
-
|
|
278
|
+
logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok || cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
|
|
279
|
+
// A user-cancelled run is a quiet outcome, not an error.
|
|
280
|
+
sendJson(response, ok || cancelled ? 200 : 502, {
|
|
259
281
|
ok,
|
|
282
|
+
cancelled: cancelled || undefined,
|
|
260
283
|
stale: stale || undefined,
|
|
261
284
|
error: staleError ?? undefined,
|
|
262
285
|
exitCode: result.exitCode,
|
|
@@ -299,6 +322,104 @@ export function mountMarketRoutes(host, config) {
|
|
|
299
322
|
}
|
|
300
323
|
},
|
|
301
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
|
+
}),
|
|
365
|
+
host.webServer.register({
|
|
366
|
+
kind: 'exact',
|
|
367
|
+
path: '/dsh-market/approve-builds',
|
|
368
|
+
handler: async (request, response) => {
|
|
369
|
+
if (request.method !== 'POST') {
|
|
370
|
+
response.writeHead(405, { allow: 'POST' });
|
|
371
|
+
response.end();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!sameOrigin(request)) {
|
|
375
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
// One-click build-script approval (#6 by @qichuang321): only
|
|
380
|
+
// packages ALREADY installed in the profile can be allowed — the
|
|
381
|
+
// list is not free input.
|
|
382
|
+
const body = (await readJsonBody(request));
|
|
383
|
+
const installed = readInstalled(config.profile);
|
|
384
|
+
const packages = (Array.isArray(body.packages) ? body.packages.map(String) : [])
|
|
385
|
+
.filter(name => installed[name] !== undefined);
|
|
386
|
+
if (packages.length === 0) {
|
|
387
|
+
sendJson(response, 400, { error: 'no installed packages given' });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const approved = setAllowBuilds(config.profile, packages);
|
|
391
|
+
logEvent('info', 'approve-builds', `allowed build scripts: ${approved.join(', ')}`);
|
|
392
|
+
sendJson(response, 200, { ok: true, approved });
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
396
|
+
logEvent('error', 'approve-builds', `route error: ${message}`);
|
|
397
|
+
sendJson(response, 500, { error: message });
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
}),
|
|
401
|
+
host.webServer.register({
|
|
402
|
+
kind: 'exact',
|
|
403
|
+
path: '/dsh-market/cancel',
|
|
404
|
+
handler: async (request, response) => {
|
|
405
|
+
if (request.method !== 'POST') {
|
|
406
|
+
response.writeHead(405, { allow: 'POST' });
|
|
407
|
+
response.end();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (!sameOrigin(request)) {
|
|
411
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// Cancel flow contributed in #6 by @qichuang321.
|
|
415
|
+
if (!cancelActive()) {
|
|
416
|
+
sendJson(response, 400, { error: 'no operation is running' });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
logEvent('info', 'cancel', `cancelled ${progress.target || 'operation'}`);
|
|
420
|
+
sendJson(response, 200, { ok: true, cancelled: true, target: progress.target });
|
|
421
|
+
},
|
|
422
|
+
}),
|
|
302
423
|
host.webServer.register({
|
|
303
424
|
kind: 'exact',
|
|
304
425
|
path: '/dsh-market/uninstall',
|
|
@@ -330,15 +451,17 @@ export function mountMarketRoutes(host, config) {
|
|
|
330
451
|
installing = true;
|
|
331
452
|
try {
|
|
332
453
|
const result = await runPlugin(config.profile, ['remove', name]);
|
|
333
|
-
const
|
|
454
|
+
const cancelled = result.cancelled;
|
|
455
|
+
const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
334
456
|
let hot = false;
|
|
335
457
|
if (ok) {
|
|
336
458
|
invalidateUpdates();
|
|
337
459
|
hot = await hotUnmount(name);
|
|
338
460
|
}
|
|
339
|
-
logEvent(ok ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${ok ? ` live-removed=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`);
|
|
340
|
-
sendJson(response, ok ? 200 : 502, {
|
|
461
|
+
logEvent(ok || cancelled ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${cancelled ? ' CANCELLED' : ''}${ok ? ` live-removed=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
|
|
462
|
+
sendJson(response, ok || cancelled ? 200 : 502, {
|
|
341
463
|
ok,
|
|
464
|
+
cancelled: cancelled || undefined,
|
|
342
465
|
hot,
|
|
343
466
|
exitCode: result.exitCode,
|
|
344
467
|
stdout: result.stdout,
|
|
@@ -405,7 +528,8 @@ export function mountMarketRoutes(host, config) {
|
|
|
405
528
|
try {
|
|
406
529
|
const before = new Set(Object.keys(readInstalled(config.profile)));
|
|
407
530
|
const result = await runPlugin(config.profile, ['add', target]);
|
|
408
|
-
|
|
531
|
+
const cancelled = result.cancelled;
|
|
532
|
+
let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
409
533
|
if (ok)
|
|
410
534
|
invalidateUpdates();
|
|
411
535
|
if (ok) {
|
|
@@ -420,7 +544,7 @@ export function mountMarketRoutes(host, config) {
|
|
|
420
544
|
// must never survive to brick the next boot.
|
|
421
545
|
let notAPlugin = false;
|
|
422
546
|
let removedBroken = [];
|
|
423
|
-
if (result.exitCode === 0 && !result.timedOut) {
|
|
547
|
+
if (result.exitCode === 0 && !result.timedOut && !cancelled) {
|
|
424
548
|
const validated = await validateAddedPlugins(runPlugin, config.profile, before);
|
|
425
549
|
removedBroken = validated.removedBroken;
|
|
426
550
|
if (removedBroken.length > 0) {
|
|
@@ -453,10 +577,12 @@ export function mountMarketRoutes(host, config) {
|
|
|
453
577
|
}
|
|
454
578
|
}
|
|
455
579
|
}
|
|
456
|
-
logEvent(ok ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${ok ? ` hot=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`);
|
|
457
|
-
sendJson(response, ok ? 200 : 502, {
|
|
580
|
+
logEvent(ok || cancelled ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
|
|
581
|
+
sendJson(response, ok || cancelled ? 200 : 502, {
|
|
458
582
|
ok,
|
|
583
|
+
cancelled: cancelled || undefined,
|
|
459
584
|
hot,
|
|
585
|
+
ignoredBuilds: (() => { const list = parseIgnoredBuilds(result.stdout, result.stderr); return list.length > 0 ? list : undefined; })(),
|
|
460
586
|
error: notAPlugin ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志' : undefined,
|
|
461
587
|
exitCode: result.exitCode,
|
|
462
588
|
timedOut: result.timedOut,
|
package/lib/types/dsh-cli.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface InstallResult {
|
|
|
29
29
|
timedOut: boolean;
|
|
30
30
|
stdout: string;
|
|
31
31
|
stderr: string;
|
|
32
|
+
/** True when the run ended because the user cancelled it. */
|
|
33
|
+
cancelled: boolean;
|
|
32
34
|
}
|
|
33
35
|
/** The shape every orchestration function takes to run plugin commands (injectable in tests). */
|
|
34
36
|
export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<InstallResult>;
|
|
@@ -38,6 +40,11 @@ export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<In
|
|
|
38
40
|
* (Contributed in #7 by @mraing.)
|
|
39
41
|
*/
|
|
40
42
|
export declare function killChild(child: ChildProcess): void;
|
|
43
|
+
/**
|
|
44
|
+
* Cancel the plugin command currently running.
|
|
45
|
+
* @returns true when there was one to cancel.
|
|
46
|
+
*/
|
|
47
|
+
export declare function cancelActive(): boolean;
|
|
41
48
|
/** Probe `pnpm --version` on PATH. */
|
|
42
49
|
export declare function probePnpm(): Promise<boolean>;
|
|
43
50
|
/**
|
package/lib/types/install.d.ts
CHANGED
|
@@ -47,3 +47,9 @@ export declare function isStaleUpdate(check: {
|
|
|
47
47
|
beforeCommit: string | null;
|
|
48
48
|
afterCommit: string | null;
|
|
49
49
|
}): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Package names pnpm reported as having their build scripts ignored
|
|
52
|
+
* ("Ignored build scripts: esbuild, koffi."). Empty when none.
|
|
53
|
+
* (#6 by @qichuang321.)
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseIgnoredBuilds(stdout: string, stderr: string): string[];
|
package/lib/types/profile.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Resolve a profile name to its directory under DSH_HOME (default ~/.dsh). */
|
|
7
7
|
export declare function profileDir(profile: string): string;
|
|
8
|
-
/** Community dependencies of the profile (
|
|
8
|
+
/** Community dependencies of the profile (in-box bundles filtered out). */
|
|
9
9
|
export declare function readInstalled(profile: string): Record<string, string>;
|
|
10
10
|
/** The version actually present in the profile's node_modules, or null. */
|
|
11
11
|
export declare function readInstalledVersion(profile: string, name: string): string | null;
|
|
@@ -22,3 +22,11 @@ export declare function hasDshManifest(dir: string): boolean;
|
|
|
22
22
|
export declare function entryArtifactExists(dir: string): boolean;
|
|
23
23
|
/** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
|
|
24
24
|
export declare function pluginSubdirs(root: string): string[];
|
|
25
|
+
/**
|
|
26
|
+
* Allow the given packages' build scripts in the profile's
|
|
27
|
+
* pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
|
|
28
|
+
* merging with existing entries and leaving the rest of the yaml intact.
|
|
29
|
+
* (#6 by @qichuang321.)
|
|
30
|
+
* @returns every package now allowed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function setAllowBuilds(profile: string, packages: string[]): string[];
|