dshmarket 1.2.3 → 1.2.4
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/client/client.js +201 -59
- package/client/client.js.map +1 -1
- package/lib/dsh-cli.js +65 -5
- package/lib/install.js +24 -2
- package/lib/profile.js +48 -3
- package/lib/routes.js +98 -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/package.json +1 -1
- package/src/client/Market.module.css +4 -0
- package/src/client/MarketSection.tsx +98 -4
- package/src/client/locales.ts +10 -0
- package/src/dsh-cli.ts +59 -5
- package/src/install.ts +22 -2
- package/src/profile.ts +45 -3
- package/src/routes.ts +102 -18
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/routes.js
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
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';
|
|
@@ -58,6 +59,20 @@ export function mountMarketRoutes(host, config) {
|
|
|
58
59
|
void themes.setEntryDisabled(name, true);
|
|
59
60
|
});
|
|
60
61
|
let installing = false;
|
|
62
|
+
/**
|
|
63
|
+
* Drop live hot mounts whose package was removed outside the market
|
|
64
|
+
* (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
|
|
65
|
+
* serving a client bundle that 404s after refresh, wedging the page
|
|
66
|
+
* until a restart (#29 by @SunYanbox).
|
|
67
|
+
*/
|
|
68
|
+
async function dropStaleHotMounts() {
|
|
69
|
+
for (const name of listHotMounts()) {
|
|
70
|
+
if (existsSync(join(profileDir(config.profile), 'node_modules', name, 'package.json')))
|
|
71
|
+
continue;
|
|
72
|
+
await hotUnmount(name);
|
|
73
|
+
logEvent('warn', 'hot-sweep', `${name}: package removed outside the market — live mount dropped`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
61
76
|
/** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
|
|
62
77
|
const runPlugin = (profile, args) => withHoistRecovery(runDshPlugin, profile, args);
|
|
63
78
|
const disposers = [
|
|
@@ -82,12 +97,13 @@ export function mountMarketRoutes(host, config) {
|
|
|
82
97
|
host.webServer.register({
|
|
83
98
|
kind: 'exact',
|
|
84
99
|
path: '/dsh-market/installed',
|
|
85
|
-
handler: (request, response) => {
|
|
100
|
+
handler: async (request, response) => {
|
|
86
101
|
if (request.method !== 'GET') {
|
|
87
102
|
response.writeHead(405, { allow: 'GET' });
|
|
88
103
|
response.end();
|
|
89
104
|
return;
|
|
90
105
|
}
|
|
106
|
+
await dropStaleHotMounts();
|
|
91
107
|
sendJson(response, 200, {
|
|
92
108
|
profile: config.profile,
|
|
93
109
|
installed: readInstalled(config.profile),
|
|
@@ -137,6 +153,7 @@ export function mountMarketRoutes(host, config) {
|
|
|
137
153
|
response.end();
|
|
138
154
|
return;
|
|
139
155
|
}
|
|
156
|
+
await dropStaleHotMounts();
|
|
140
157
|
sendJson(response, 200, {
|
|
141
158
|
active: progress.active,
|
|
142
159
|
target: progress.target,
|
|
@@ -236,7 +253,8 @@ export function mountMarketRoutes(host, config) {
|
|
|
236
253
|
// default one-day safety wait; scoped to this single command.
|
|
237
254
|
const addArgs = force ? ['add', '--config.minimumReleaseAge=0', target] : ['add', target];
|
|
238
255
|
const result = await runPlugin(config.profile, addArgs);
|
|
239
|
-
|
|
256
|
+
const cancelled = result.cancelled;
|
|
257
|
+
let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
240
258
|
let stale = false;
|
|
241
259
|
if (ok) {
|
|
242
260
|
stale = isStaleUpdate({
|
|
@@ -254,9 +272,11 @@ export function mountMarketRoutes(host, config) {
|
|
|
254
272
|
const staleError = stale
|
|
255
273
|
? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / 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
274
|
: 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
|
-
|
|
275
|
+
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)}`}`);
|
|
276
|
+
// A user-cancelled run is a quiet outcome, not an error.
|
|
277
|
+
sendJson(response, ok || cancelled ? 200 : 502, {
|
|
259
278
|
ok,
|
|
279
|
+
cancelled: cancelled || undefined,
|
|
260
280
|
stale: stale || undefined,
|
|
261
281
|
error: staleError ?? undefined,
|
|
262
282
|
exitCode: result.exitCode,
|
|
@@ -299,6 +319,64 @@ export function mountMarketRoutes(host, config) {
|
|
|
299
319
|
}
|
|
300
320
|
},
|
|
301
321
|
}),
|
|
322
|
+
host.webServer.register({
|
|
323
|
+
kind: 'exact',
|
|
324
|
+
path: '/dsh-market/approve-builds',
|
|
325
|
+
handler: async (request, response) => {
|
|
326
|
+
if (request.method !== 'POST') {
|
|
327
|
+
response.writeHead(405, { allow: 'POST' });
|
|
328
|
+
response.end();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!sameOrigin(request)) {
|
|
332
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
// One-click build-script approval (#6 by @qichuang321): only
|
|
337
|
+
// packages ALREADY installed in the profile can be allowed — the
|
|
338
|
+
// list is not free input.
|
|
339
|
+
const body = (await readJsonBody(request));
|
|
340
|
+
const installed = readInstalled(config.profile);
|
|
341
|
+
const packages = (Array.isArray(body.packages) ? body.packages.map(String) : [])
|
|
342
|
+
.filter(name => installed[name] !== undefined);
|
|
343
|
+
if (packages.length === 0) {
|
|
344
|
+
sendJson(response, 400, { error: 'no installed packages given' });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const approved = setAllowBuilds(config.profile, packages);
|
|
348
|
+
logEvent('info', 'approve-builds', `allowed build scripts: ${approved.join(', ')}`);
|
|
349
|
+
sendJson(response, 200, { ok: true, approved });
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
353
|
+
logEvent('error', 'approve-builds', `route error: ${message}`);
|
|
354
|
+
sendJson(response, 500, { error: message });
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
}),
|
|
358
|
+
host.webServer.register({
|
|
359
|
+
kind: 'exact',
|
|
360
|
+
path: '/dsh-market/cancel',
|
|
361
|
+
handler: async (request, response) => {
|
|
362
|
+
if (request.method !== 'POST') {
|
|
363
|
+
response.writeHead(405, { allow: 'POST' });
|
|
364
|
+
response.end();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (!sameOrigin(request)) {
|
|
368
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Cancel flow contributed in #6 by @qichuang321.
|
|
372
|
+
if (!cancelActive()) {
|
|
373
|
+
sendJson(response, 400, { error: 'no operation is running' });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
logEvent('info', 'cancel', `cancelled ${progress.target || 'operation'}`);
|
|
377
|
+
sendJson(response, 200, { ok: true, cancelled: true, target: progress.target });
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
302
380
|
host.webServer.register({
|
|
303
381
|
kind: 'exact',
|
|
304
382
|
path: '/dsh-market/uninstall',
|
|
@@ -330,15 +408,17 @@ export function mountMarketRoutes(host, config) {
|
|
|
330
408
|
installing = true;
|
|
331
409
|
try {
|
|
332
410
|
const result = await runPlugin(config.profile, ['remove', name]);
|
|
333
|
-
const
|
|
411
|
+
const cancelled = result.cancelled;
|
|
412
|
+
const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
334
413
|
let hot = false;
|
|
335
414
|
if (ok) {
|
|
336
415
|
invalidateUpdates();
|
|
337
416
|
hot = await hotUnmount(name);
|
|
338
417
|
}
|
|
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, {
|
|
418
|
+
logEvent(ok || cancelled ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${cancelled ? ' CANCELLED' : ''}${ok ? ` live-removed=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
|
|
419
|
+
sendJson(response, ok || cancelled ? 200 : 502, {
|
|
341
420
|
ok,
|
|
421
|
+
cancelled: cancelled || undefined,
|
|
342
422
|
hot,
|
|
343
423
|
exitCode: result.exitCode,
|
|
344
424
|
stdout: result.stdout,
|
|
@@ -405,7 +485,8 @@ export function mountMarketRoutes(host, config) {
|
|
|
405
485
|
try {
|
|
406
486
|
const before = new Set(Object.keys(readInstalled(config.profile)));
|
|
407
487
|
const result = await runPlugin(config.profile, ['add', target]);
|
|
408
|
-
|
|
488
|
+
const cancelled = result.cancelled;
|
|
489
|
+
let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
409
490
|
if (ok)
|
|
410
491
|
invalidateUpdates();
|
|
411
492
|
if (ok) {
|
|
@@ -420,7 +501,7 @@ export function mountMarketRoutes(host, config) {
|
|
|
420
501
|
// must never survive to brick the next boot.
|
|
421
502
|
let notAPlugin = false;
|
|
422
503
|
let removedBroken = [];
|
|
423
|
-
if (result.exitCode === 0 && !result.timedOut) {
|
|
504
|
+
if (result.exitCode === 0 && !result.timedOut && !cancelled) {
|
|
424
505
|
const validated = await validateAddedPlugins(runPlugin, config.profile, before);
|
|
425
506
|
removedBroken = validated.removedBroken;
|
|
426
507
|
if (removedBroken.length > 0) {
|
|
@@ -453,10 +534,12 @@ export function mountMarketRoutes(host, config) {
|
|
|
453
534
|
}
|
|
454
535
|
}
|
|
455
536
|
}
|
|
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, {
|
|
537
|
+
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)}`}`);
|
|
538
|
+
sendJson(response, ok || cancelled ? 200 : 502, {
|
|
458
539
|
ok,
|
|
540
|
+
cancelled: cancelled || undefined,
|
|
459
541
|
hot,
|
|
542
|
+
ignoredBuilds: (() => { const list = parseIgnoredBuilds(result.stdout, result.stderr); return list.length > 0 ? list : undefined; })(),
|
|
460
543
|
error: notAPlugin ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志' : undefined,
|
|
461
544
|
exitCode: result.exitCode,
|
|
462
545
|
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[];
|
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
|
+
"version": "1.2.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -72,3 +72,7 @@
|
|
|
72
72
|
.dangerBtn.dangerBtn,.warnBtn.warnBtn,.dangerArmed.dangerArmed{white-space:nowrap;flex-shrink:0}
|
|
73
73
|
.spec{font-size:11px;color:var(--dsw-alias-label-secondary,#9ca3af);font-family:ui-monospace,Menlo,monospace}
|
|
74
74
|
.staleAction{margin-top:8px}
|
|
75
|
+
.pct{font-weight:600;font-size:11px;flex-shrink:0;color:var(--dsw-alias-label-secondary,#6b7280)}
|
|
76
|
+
.cancelBtn{border:1px solid var(--dsw-alias-border-l2,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-secondary,#6b7280);border-radius:6px;padding:2px 10px;font:inherit;font-size:11px;line-height:16px;cursor:pointer;flex-shrink:0;white-space:nowrap}
|
|
77
|
+
.cancelBtn:hover{color:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626)}
|
|
78
|
+
.sentinel{height:1px}
|
|
@@ -38,6 +38,14 @@ function OwnerAvatar({ name, owner }: { name: string; owner: string }) {
|
|
|
38
38
|
)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Module-scope caches so re-entering the section renders instantly instead
|
|
43
|
+
* of refetching and rebuilding from a spinner (#30 by @StarsTom). Module
|
|
44
|
+
* state survives section switches; a background refetch keeps it current.
|
|
45
|
+
*/
|
|
46
|
+
let cachedRegistry: Registry | null = null
|
|
47
|
+
let cachedInstalled: InstalledMap | null = null
|
|
48
|
+
|
|
41
49
|
export interface MarketSectionProps {
|
|
42
50
|
t: Translate
|
|
43
51
|
locale: {
|
|
@@ -63,9 +71,10 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
63
71
|
props.themeStore.subscribe,
|
|
64
72
|
props.themeStore.getSnapshot,
|
|
65
73
|
)
|
|
66
|
-
const [data, setData] = useState<Registry | null>(
|
|
74
|
+
const [data, setData] = useState<Registry | null>(cachedRegistry)
|
|
67
75
|
const [loadError, setLoadError] = useState(false)
|
|
68
|
-
const [installed,
|
|
76
|
+
const [installed, setInstalledState] = useState<InstalledMap>(cachedInstalled ?? {})
|
|
77
|
+
const setInstalled = useCallback((value: InstalledMap) => { cachedInstalled = value; setInstalledState(value) }, [])
|
|
69
78
|
const [skins, setSkins] = useState<string[]>([])
|
|
70
79
|
const [tab, setTab] = useState(() => {
|
|
71
80
|
const saved = sessionStorage.getItem('dshm-tab')
|
|
@@ -82,6 +91,26 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
82
91
|
const [updatingName, setUpdatingName] = useState<string | null>(null)
|
|
83
92
|
// Plugin blocked by pnpm's fresh-release safety wait; arms the update-now button.
|
|
84
93
|
const [staleName, setStaleName] = useState<string | null>(null)
|
|
94
|
+
/**
|
|
95
|
+
* Cards rendered so far — the discover grid renders incrementally (60,
|
|
96
|
+
* then +120 as the sentinel scrolls into view) instead of all 400+ at
|
|
97
|
+
* once, which built a ~70k px DOM and made section switches lag (#30).
|
|
98
|
+
*/
|
|
99
|
+
const [visibleCount, setVisibleCount] = useState(60)
|
|
100
|
+
const sentinelRef = useCallback((node: HTMLDivElement | null) => {
|
|
101
|
+
if (node === null) return
|
|
102
|
+
const observer = new IntersectionObserver((entries) => {
|
|
103
|
+
if (entries.some(entry => entry.isIntersecting)) setVisibleCount(count => count + 120)
|
|
104
|
+
}, { rootMargin: '600px' })
|
|
105
|
+
observer.observe(node)
|
|
106
|
+
// Cleanup runs when React detaches the node and calls back with null;
|
|
107
|
+
// the observer dies with the node, so a one-shot observe suffices.
|
|
108
|
+
}, [])
|
|
109
|
+
|
|
110
|
+
/** Determinate percent parsed from pnpm's Progress line, when available. */
|
|
111
|
+
const [progressPct, setProgressPct] = useState<number | null>(null)
|
|
112
|
+
/** Blocked build scripts from the last install: enables approve-and-retry (#6). */
|
|
113
|
+
const [buildsSkipped, setBuildsSkipped] = useState<{ plugin: RegistryPlugin; names: string[] } | null>(null)
|
|
85
114
|
const [updatingAll, setUpdatingAll] = useState(false)
|
|
86
115
|
const [updatedNames, setUpdatedNames] = useState<string[]>([])
|
|
87
116
|
const [hotUrls, setHotUrls] = useState<string[]>([])
|
|
@@ -121,7 +150,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
121
150
|
useEffect(() => {
|
|
122
151
|
fetch('/dsh-market/registry', { cache: 'no-store' })
|
|
123
152
|
.then(res => { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json() })
|
|
124
|
-
.then(body => setData(body.registry))
|
|
153
|
+
.then(body => { cachedRegistry = body.registry; setData(body.registry) })
|
|
125
154
|
.catch(() => setLoadError(true))
|
|
126
155
|
fetch('/dsh-market/status', { cache: 'no-store' })
|
|
127
156
|
.then(res => res.json())
|
|
@@ -192,8 +221,14 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
192
221
|
.then(status => {
|
|
193
222
|
if (status.active) {
|
|
194
223
|
setProgressLine((status.lastLine || '…') + ' (' + status.seconds + 's)')
|
|
224
|
+
const m = /resolved (\d+), reused (\d+), downloaded (\d+), added (\d+)/.exec(status.lastLine || '')
|
|
225
|
+
if (m !== null && Number(m[1]) > 0) {
|
|
226
|
+
const done = Number(m[2]) + Number(m[3]) + Number(m[4])
|
|
227
|
+
setProgressPct(Math.max(4, Math.min(96, Math.round(done / Number(m[1]) * 100))))
|
|
228
|
+
}
|
|
195
229
|
} else {
|
|
196
230
|
setProgressLine(null)
|
|
231
|
+
setProgressPct(null)
|
|
197
232
|
setInstalled(status.installed || {})
|
|
198
233
|
const pending = readSession('dshm-pending')
|
|
199
234
|
if (pending !== null && busyUrl !== null) {
|
|
@@ -216,7 +251,10 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
216
251
|
() => (data === null ? [] : visiblePlugins(data.plugins, { category: cat, query: q, lang, sort })),
|
|
217
252
|
[data, q, cat, lang, sort])
|
|
218
253
|
|
|
254
|
+
useEffect(() => { setVisibleCount(60) }, [q, cat, sort])
|
|
255
|
+
|
|
219
256
|
const doInstall = useCallback((plugin: RegistryPlugin) => {
|
|
257
|
+
setBuildsSkipped(null)
|
|
220
258
|
setConfirming(null)
|
|
221
259
|
setInstallError(null)
|
|
222
260
|
setBusyUrl(plugin.url)
|
|
@@ -237,6 +275,11 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
237
275
|
location.reload()
|
|
238
276
|
return
|
|
239
277
|
}
|
|
278
|
+
if (body.cancelled === true) {
|
|
279
|
+
// User-cancelled: quiet reset, nothing to report.
|
|
280
|
+
refreshInstalled()
|
|
281
|
+
return
|
|
282
|
+
}
|
|
240
283
|
if (status === 200 && body.ok) {
|
|
241
284
|
sessionStorage.setItem('dshm-tab', 'installed')
|
|
242
285
|
if (body.hot) {
|
|
@@ -247,6 +290,13 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
247
290
|
}
|
|
248
291
|
refreshInstalled()
|
|
249
292
|
} else {
|
|
293
|
+
if (status === 409) {
|
|
294
|
+
setInstallError(t('busyWait'))
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (Array.isArray(body.ignoredBuilds) && body.ignoredBuilds.length > 0) {
|
|
298
|
+
setBuildsSkipped({ plugin, names: body.ignoredBuilds.map(String) })
|
|
299
|
+
}
|
|
250
300
|
const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v)
|
|
251
301
|
const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
|
|
252
302
|
setInstallError(t('installFail') + ': ' + plugin.name + ' — ' + detail.trim().slice(-600))
|
|
@@ -259,6 +309,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
259
309
|
.finally(() => setBusyUrl(null))
|
|
260
310
|
}, [refreshInstalled, t])
|
|
261
311
|
|
|
312
|
+
/** Cancel the running plugin command (#6 by @qichuang321). */
|
|
313
|
+
const doCancel = useCallback(() => {
|
|
314
|
+
fetch('/dsh-market/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
315
|
+
.catch(() => {})
|
|
316
|
+
}, [])
|
|
317
|
+
|
|
262
318
|
const doUpdate = useCallback((name: string, force = false) => {
|
|
263
319
|
setInstallError(null)
|
|
264
320
|
setStaleName(null)
|
|
@@ -270,10 +326,15 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
270
326
|
})
|
|
271
327
|
.then(res => res.json().then(body => ({ status: res.status, body })))
|
|
272
328
|
.then(({ status, body }) => {
|
|
329
|
+
if (body.cancelled === true) {
|
|
330
|
+
refreshInstalled()
|
|
331
|
+
return
|
|
332
|
+
}
|
|
273
333
|
if (status === 200 && body.ok) {
|
|
274
334
|
setUpdatedNames(names => names.concat(name))
|
|
275
335
|
refreshInstalled()
|
|
276
336
|
} else {
|
|
337
|
+
if (status === 409) { setInstallError(t('busyWait')); return }
|
|
277
338
|
if (body.stale === true) setStaleName(name)
|
|
278
339
|
const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v)
|
|
279
340
|
const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
|
|
@@ -400,6 +461,8 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
400
461
|
<div className={css.progress}>
|
|
401
462
|
<span className={css.spin} />
|
|
402
463
|
<code className={css.grow}>{progressLine || t('progressHint')}</code>
|
|
464
|
+
{progressPct !== null && <span className={css.pct}>{progressPct}%</span>}
|
|
465
|
+
<button type="button" className={css.cancelBtn} onClick={doCancel}>{t('cancelOp')}</button>
|
|
403
466
|
</div>
|
|
404
467
|
)}
|
|
405
468
|
</div>
|
|
@@ -578,6 +641,30 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
578
641
|
</div>
|
|
579
642
|
)}
|
|
580
643
|
</div>
|
|
644
|
+
{buildsSkipped !== null && (
|
|
645
|
+
<div className={css.restart}>
|
|
646
|
+
<span className={css.grow}>{t('buildsSkipped')} {buildsSkipped.names.join(', ')}</span>
|
|
647
|
+
<Button
|
|
648
|
+
size="sm"
|
|
649
|
+
disabled={busyUrl !== null}
|
|
650
|
+
onClick={() => {
|
|
651
|
+
const { plugin, names } = buildsSkipped
|
|
652
|
+
setBuildsSkipped(null)
|
|
653
|
+
fetch('/dsh-market/approve-builds', {
|
|
654
|
+
method: 'POST',
|
|
655
|
+
headers: { 'content-type': 'application/json' },
|
|
656
|
+
body: JSON.stringify({ packages: names }),
|
|
657
|
+
})
|
|
658
|
+
.then(res => res.json())
|
|
659
|
+
.then((body) => {
|
|
660
|
+
if (body.ok) doInstall(plugin)
|
|
661
|
+
else setInstallError(String(body.error || 'approve failed'))
|
|
662
|
+
})
|
|
663
|
+
.catch(error => setInstallError(String(error)))
|
|
664
|
+
}}
|
|
665
|
+
>{t('approveBuilds')}</Button>
|
|
666
|
+
</div>
|
|
667
|
+
)}
|
|
581
668
|
{installError !== null && (
|
|
582
669
|
<div className={css.err}>
|
|
583
670
|
{installError}
|
|
@@ -643,7 +730,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
643
730
|
</div>
|
|
644
731
|
{plugins.length === 0
|
|
645
732
|
? <div className={css.empty}>{t('empty')}</div>
|
|
646
|
-
:
|
|
733
|
+
: (
|
|
734
|
+
<>
|
|
735
|
+
<div className={css.grid}>{plugins.slice(0, visibleCount).map(pluginCard)}</div>
|
|
736
|
+
{plugins.length > visibleCount && <div ref={sentinelRef} className={css.sentinel} />}
|
|
737
|
+
</>
|
|
738
|
+
)}
|
|
647
739
|
</>
|
|
648
740
|
)
|
|
649
741
|
: tab === 'themes' && themeSnap !== null
|
|
@@ -692,6 +784,8 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
692
784
|
<div className={css.progress}>
|
|
693
785
|
<span className={css.spin} />
|
|
694
786
|
<code className={css.grow}>{progressLine || t('progressHint')}</code>
|
|
787
|
+
{progressPct !== null && <span className={css.pct}>{progressPct}%</span>}
|
|
788
|
+
<button type="button" className={css.cancelBtn} onClick={doCancel}>{t('cancelOp')}</button>
|
|
695
789
|
</div>
|
|
696
790
|
)}
|
|
697
791
|
</div>
|
package/src/client/locales.ts
CHANGED
|
@@ -29,6 +29,11 @@ export const zh = {
|
|
|
29
29
|
update: '更新',
|
|
30
30
|
updating: '更新中…',
|
|
31
31
|
updated: '✓ 已更新,重启后生效',
|
|
32
|
+
cancelOp: '取消',
|
|
33
|
+
cancelled: '已取消',
|
|
34
|
+
busyWait: '已有操作正在进行,请等它结束(同一时间只执行一个安装/更新/卸载)',
|
|
35
|
+
approveBuilds: '放行构建脚本并重试',
|
|
36
|
+
buildsSkipped: '该插件需要运行构建脚本才能工作,出于安全默认被拦下。点击下方按钮为它放行并重装:',
|
|
32
37
|
updateNow: '立即更新',
|
|
33
38
|
updateFail: '更新失败',
|
|
34
39
|
upToDate: '已是最新',
|
|
@@ -92,6 +97,11 @@ export const en: Record<MarketKey, string> = {
|
|
|
92
97
|
update: 'Update',
|
|
93
98
|
updating: 'Updating…',
|
|
94
99
|
updated: '✓ Updated — restart to apply',
|
|
100
|
+
cancelOp: 'Cancel',
|
|
101
|
+
cancelled: 'Cancelled',
|
|
102
|
+
busyWait: 'Another operation is already running — please wait for it to finish (one install/update/uninstall at a time)',
|
|
103
|
+
approveBuilds: 'Allow build scripts and retry',
|
|
104
|
+
buildsSkipped: 'This plugin needs its build scripts to run; they are blocked by default for safety. Click below to allow them and reinstall:',
|
|
95
105
|
updateNow: 'Update now',
|
|
96
106
|
updateFail: 'Update failed',
|
|
97
107
|
upToDate: 'Up to date',
|