dshmarket 1.2.2 → 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/README.md +8 -0
- package/README.zh.md +8 -0
- package/client/client.js +264 -87
- package/client/client.js.map +1 -1
- package/lib/dsh-cli.js +224 -0
- package/lib/http.js +39 -0
- package/lib/install.js +132 -0
- package/lib/pnpm-compat.js +72 -0
- package/lib/profile.js +166 -0
- package/lib/routes.js +152 -558
- package/lib/sources.js +85 -0
- package/lib/themes.js +102 -0
- package/lib/types/dsh-cli.d.ts +68 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/install.d.ts +55 -0
- package/lib/types/pnpm-compat.d.ts +42 -0
- package/lib/types/profile.d.ts +32 -0
- package/lib/types/routes.d.ts +7 -21
- package/lib/types/sources.d.ts +42 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/updates.d.ts +16 -0
- package/lib/updates.js +61 -0
- package/package.json +5 -2
- package/src/client/Market.module.css +8 -1
- package/src/client/MarketSection.tsx +121 -31
- package/src/client/locales.ts +12 -0
- package/src/client/market-data.ts +60 -4
- package/src/dsh-cli.ts +248 -0
- package/src/http.ts +41 -0
- package/src/install.ts +141 -0
- package/src/pnpm-compat.ts +83 -0
- package/src/profile.ts +162 -0
- package/src/routes.ts +161 -592
- package/src/sources.ts +84 -0
- package/src/themes.ts +120 -0
- package/src/updates.ts +70 -0
package/lib/dsh-cli.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process layer: re-invoking the dsh CLI that launched this host, spawning
|
|
3
|
+
* `dsh plugin` commands with timeouts and live progress, and provisioning
|
|
4
|
+
* pnpm. This is the only module that starts child processes.
|
|
5
|
+
*
|
|
6
|
+
* Installs run through node:child_process, not ctx.shell: the shell service is
|
|
7
|
+
* the agent's sandboxed executor and denies writes to the profile directory.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { dirname, resolve } from 'node:path';
|
|
11
|
+
import { logEvent } from './log.js';
|
|
12
|
+
import { pluginArgsFor } from './pnpm-compat.js';
|
|
13
|
+
import { profileDir } from './profile.js';
|
|
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;
|
|
17
|
+
/**
|
|
18
|
+
* Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
|
|
19
|
+
* cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
|
|
20
|
+
*/
|
|
21
|
+
export const winCmdShim = process.platform === 'win32';
|
|
22
|
+
/**
|
|
23
|
+
* Argv re-invoking the CLI that launched this host process, so installs work
|
|
24
|
+
* whether dsh runs from a global bin, a local install, or repo source
|
|
25
|
+
* (`node --import tsx/esm .../bin.ts`). Falls back to a PATH `dsh`.
|
|
26
|
+
*/
|
|
27
|
+
export function dshArgv() {
|
|
28
|
+
const entry = process.argv[1];
|
|
29
|
+
if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
|
|
30
|
+
// Absolute paths are required: source launches (`pnpm dsh`) pass a
|
|
31
|
+
// relative entry, which the child resolves against its OWN cwd and dies
|
|
32
|
+
// with MODULE_NOT_FOUND (#13). cwd near the entry keeps execArgv imports
|
|
33
|
+
// (tsx/esm) resolvable on source launches.
|
|
34
|
+
const abs = resolve(entry);
|
|
35
|
+
return { file: process.execPath, args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false };
|
|
36
|
+
}
|
|
37
|
+
// Bare `dsh` is a .cmd shim on Windows that only a shell can start (#13).
|
|
38
|
+
return { file: 'dsh', args: [], cwd: undefined, viaShell: winCmdShim };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Kill a spawned child and, on Windows, its whole process tree — `kill()`
|
|
42
|
+
* there only terminates the wrapper, leaving pnpm children running.
|
|
43
|
+
* (Contributed in #7 by @mraing.)
|
|
44
|
+
*/
|
|
45
|
+
export function killChild(child) {
|
|
46
|
+
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
47
|
+
try {
|
|
48
|
+
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
catch { /* fall through */ }
|
|
52
|
+
}
|
|
53
|
+
child.kill('SIGKILL');
|
|
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
|
+
}
|
|
104
|
+
/** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
|
|
105
|
+
let pnpmReady = false;
|
|
106
|
+
/** Probe `pnpm --version` on PATH. */
|
|
107
|
+
export function probePnpm() {
|
|
108
|
+
if (pnpmReady)
|
|
109
|
+
return Promise.resolve(true);
|
|
110
|
+
return new Promise((resolvePromise) => {
|
|
111
|
+
const child = spawn('pnpm', ['--version'], { stdio: 'ignore', shell: winCmdShim });
|
|
112
|
+
child.on('error', () => resolvePromise(false));
|
|
113
|
+
child.on('close', (code) => {
|
|
114
|
+
pnpmReady = code === 0;
|
|
115
|
+
resolvePromise(pnpmReady);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function runQuiet(file, args, timeoutMs) {
|
|
120
|
+
return new Promise((resolvePromise) => {
|
|
121
|
+
const child = spawn(file, args, {
|
|
122
|
+
env: { ...process.env, CI: 'true' },
|
|
123
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
124
|
+
shell: winCmdShim,
|
|
125
|
+
});
|
|
126
|
+
let output = '';
|
|
127
|
+
const timer = setTimeout(() => killChild(child), timeoutMs);
|
|
128
|
+
const collect = (chunk) => { output = (output + chunk.toString()).slice(-8 * 1024); };
|
|
129
|
+
child.stdout.on('data', collect);
|
|
130
|
+
child.stderr.on('data', collect);
|
|
131
|
+
child.on('error', (error) => { clearTimeout(timer); resolvePromise({ code: 127, output: error.message }); });
|
|
132
|
+
child.on('close', (code) => { clearTimeout(timer); resolvePromise({ code, output }); });
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Provision pnpm without user involvement: corepack (ships with Node) first,
|
|
137
|
+
* a global npm install as fallback.
|
|
138
|
+
* @returns true when `pnpm --version` succeeds afterwards.
|
|
139
|
+
*/
|
|
140
|
+
export async function provisionPnpm() {
|
|
141
|
+
const corepack = await runQuiet('corepack', ['enable', 'pnpm'], 60 * 1000);
|
|
142
|
+
logEvent(corepack.code === 0 ? 'info' : 'warn', 'setup-pnpm', `corepack enable: exit=${String(corepack.code)} ${corepack.output.slice(-200)}`);
|
|
143
|
+
if (await probePnpm())
|
|
144
|
+
return true;
|
|
145
|
+
const npm = await runQuiet('npm', ['install', '-g', 'pnpm'], 3 * 60 * 1000);
|
|
146
|
+
logEvent(npm.code === 0 ? 'info' : 'error', 'setup-pnpm', `npm -g: exit=${String(npm.code)} ${npm.output.slice(-200)}`);
|
|
147
|
+
return probePnpm();
|
|
148
|
+
}
|
|
149
|
+
/** Singleton progress state; the status route reads it, runDshPlugin writes it. */
|
|
150
|
+
export const progress = { active: false, target: '', startedAt: 0, lastLine: '' };
|
|
151
|
+
/** Identifies this host process; the client scopes its pending-restart flags to it. */
|
|
152
|
+
export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`;
|
|
153
|
+
function trackProgress(chunk) {
|
|
154
|
+
const lines = chunk.split('\n').map(l => l.trim()).filter(l => l !== '');
|
|
155
|
+
if (lines.length > 0)
|
|
156
|
+
progress.lastLine = lines[lines.length - 1].slice(0, 200);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Central allowlist for every spawn target, regardless of which route built
|
|
160
|
+
* it (defense in depth on top of per-route validation — the win32 bare-dsh
|
|
161
|
+
* fallback runs through a shell). Suggested in #16 by @anupamme.
|
|
162
|
+
*/
|
|
163
|
+
const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/;
|
|
164
|
+
/** Run one `dsh plugin --profile <p> …` command with timeout and progress tracking. */
|
|
165
|
+
export function runDshPlugin(profile, pluginArgs) {
|
|
166
|
+
const { file, args, cwd, viaShell } = dshArgv();
|
|
167
|
+
pluginArgs = pluginArgsFor(profileDir(profile), pluginArgs);
|
|
168
|
+
const target = pluginArgs[pluginArgs.length - 1] ?? '';
|
|
169
|
+
if (!TARGET_RE.test(target)) {
|
|
170
|
+
logEvent('error', 'install', `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 });
|
|
172
|
+
}
|
|
173
|
+
progress.active = true;
|
|
174
|
+
progress.target = target;
|
|
175
|
+
progress.startedAt = Date.now();
|
|
176
|
+
progress.lastLine = '';
|
|
177
|
+
return new Promise((resolvePromise) => {
|
|
178
|
+
const child = spawn(file, [...args, 'plugin', '--profile', profile, ...pluginArgs], {
|
|
179
|
+
cwd,
|
|
180
|
+
// pnpm v10 blocks forever on a silent interactive prompt without a TTY
|
|
181
|
+
// (observed on re-add over a pinned git spec); CI mode forces it to act
|
|
182
|
+
// or fail instead of asking.
|
|
183
|
+
env: { ...process.env, CI: 'true' },
|
|
184
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
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',
|
|
189
|
+
});
|
|
190
|
+
activeChild = child;
|
|
191
|
+
cancelRequested = false;
|
|
192
|
+
let stdout = '';
|
|
193
|
+
let stderr = '';
|
|
194
|
+
let timedOut = false;
|
|
195
|
+
const timer = setTimeout(() => {
|
|
196
|
+
timedOut = true;
|
|
197
|
+
killTree(child);
|
|
198
|
+
}, INSTALL_TIMEOUT_MS);
|
|
199
|
+
child.stdout.on('data', (chunk) => {
|
|
200
|
+
const text = chunk.toString();
|
|
201
|
+
stdout = (stdout + text).slice(-256 * 1024);
|
|
202
|
+
trackProgress(text);
|
|
203
|
+
});
|
|
204
|
+
child.stderr.on('data', (chunk) => {
|
|
205
|
+
const text = chunk.toString();
|
|
206
|
+
stderr = (stderr + text).slice(-64 * 1024);
|
|
207
|
+
trackProgress(text);
|
|
208
|
+
});
|
|
209
|
+
child.on('error', (error) => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
progress.active = false;
|
|
212
|
+
if (activeChild === child)
|
|
213
|
+
activeChild = null;
|
|
214
|
+
resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}`, cancelled: false });
|
|
215
|
+
});
|
|
216
|
+
child.on('close', (code) => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
progress.active = false;
|
|
219
|
+
if (activeChild === child)
|
|
220
|
+
activeChild = null;
|
|
221
|
+
resolvePromise({ exitCode: code, timedOut, stdout, stderr, cancelled: cancelRequested });
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP helpers shared by every market route: JSON serialization,
|
|
3
|
+
* same-origin enforcement for mutating endpoints, and a size-capped JSON
|
|
4
|
+
* body reader.
|
|
5
|
+
*/
|
|
6
|
+
/** Write a JSON payload with no-store caching. */
|
|
7
|
+
export function sendJson(response, status, payload) {
|
|
8
|
+
response.writeHead(status, {
|
|
9
|
+
'cache-control': 'no-store',
|
|
10
|
+
'content-type': 'application/json; charset=utf-8',
|
|
11
|
+
});
|
|
12
|
+
response.end(JSON.stringify(payload));
|
|
13
|
+
}
|
|
14
|
+
/** True when the request's Origin matches its Host — required on every POST route. */
|
|
15
|
+
export function sameOrigin(request) {
|
|
16
|
+
const origin = request.headers.origin;
|
|
17
|
+
const host = request.headers.host;
|
|
18
|
+
if (origin === undefined || host === undefined)
|
|
19
|
+
return false;
|
|
20
|
+
try {
|
|
21
|
+
return new URL(origin).host === host;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Read and parse a JSON request body, rejecting anything over 4 KiB. */
|
|
28
|
+
export async function readJsonBody(request) {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
for await (const chunk of request) {
|
|
32
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
33
|
+
size += buffer.length;
|
|
34
|
+
if (size > 4096)
|
|
35
|
+
throw new Error('request body too large');
|
|
36
|
+
chunks.push(buffer);
|
|
37
|
+
}
|
|
38
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
39
|
+
}
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install orchestration: collection-repo retargeting, post-install
|
|
3
|
+
* validation that keeps broken pieces from bricking the next boot, and
|
|
4
|
+
* update staleness detection. Every function takes the plugin runner as a
|
|
5
|
+
* parameter so tests can substitute a recording fake.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { classifyPnpmFailure } from './pnpm-compat.js';
|
|
10
|
+
import { entryArtifactExists, hasDshManifest, pluginSubdirs, profileDir, readInstalled } from './profile.js';
|
|
11
|
+
import { logEvent } from './log.js';
|
|
12
|
+
/**
|
|
13
|
+
* Run one plugin command with automatic recovery from the pnpm-major drift
|
|
14
|
+
* failure (#20 bug 2): when the modules directory was built by a different
|
|
15
|
+
* pnpm major, pnpm's documented remedy is one `install` to recreate it —
|
|
16
|
+
* do that silently and retry the original command once. Any recognized
|
|
17
|
+
* failure that survives gets its bilingual explanation appended to stderr
|
|
18
|
+
* so the UI shows an actionable message instead of a wall of text (#20 bug 3).
|
|
19
|
+
*/
|
|
20
|
+
export async function withHoistRecovery(run, profile, pluginArgs) {
|
|
21
|
+
let result = await run(profile, pluginArgs);
|
|
22
|
+
const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
|
|
23
|
+
if (!ok(result) && classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)?.code === 'hoist-pattern-diff') {
|
|
24
|
+
logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
|
|
25
|
+
// --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
|
|
26
|
+
// where a lockfile written by the old major would otherwise be refused.
|
|
27
|
+
const rebuild = await run(profile, ['install', '--no-frozen-lockfile']);
|
|
28
|
+
if (ok(rebuild))
|
|
29
|
+
result = await run(profile, pluginArgs);
|
|
30
|
+
}
|
|
31
|
+
if (!ok(result) && !result.cancelled) {
|
|
32
|
+
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`);
|
|
33
|
+
if (failure !== null)
|
|
34
|
+
result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` };
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Some registry entries point at collection repos whose actual plugin lives
|
|
40
|
+
* in a subdirectory — the root has no package.json (or a workspace root with
|
|
41
|
+
* no dsh surface), and pnpm installs the bare fileset with exit 0. Detect
|
|
42
|
+
* that junk install, drop it, and re-add each plugin subdirectory through
|
|
43
|
+
* pnpm's `#path:` selector (#18).
|
|
44
|
+
* @returns overall success (true when nothing needed retargeting).
|
|
45
|
+
*/
|
|
46
|
+
export async function retargetCollections(run, profile, before, target) {
|
|
47
|
+
if (!target.startsWith('github:'))
|
|
48
|
+
return true;
|
|
49
|
+
const junk = Object.keys(readInstalled(profile)).filter((name) => {
|
|
50
|
+
if (before.has(name))
|
|
51
|
+
return false;
|
|
52
|
+
const root = join(profileDir(profile), 'node_modules', name);
|
|
53
|
+
if (!existsSync(join(root, 'package.json')))
|
|
54
|
+
return true;
|
|
55
|
+
return !hasDshManifest(root);
|
|
56
|
+
});
|
|
57
|
+
let allOk = true;
|
|
58
|
+
for (const name of junk) {
|
|
59
|
+
const root = join(profileDir(profile), 'node_modules', name);
|
|
60
|
+
const candidates = pluginSubdirs(root);
|
|
61
|
+
logEvent('info', 'install', `${name}: collection repo (root declares no dsh manifest); plugins inside: ${candidates.join(', ') || 'none'}`);
|
|
62
|
+
await run(profile, ['remove', name]);
|
|
63
|
+
if (candidates.length === 0) {
|
|
64
|
+
allOk = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const sub of candidates) {
|
|
68
|
+
const result = await run(profile, ['add', `${target}#path:/${sub}`]);
|
|
69
|
+
if (result.exitCode !== 0 || result.timedOut) {
|
|
70
|
+
allOk = false;
|
|
71
|
+
logEvent('error', 'install', `${target}#path:/${sub}: exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''} — ${(result.stderr || result.stdout).slice(-220)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return allOk;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Fake-success guard (#18): validate every package the install added. A
|
|
79
|
+
* piece without a dsh manifest or without its declared entry artifact
|
|
80
|
+
* (source-only checkout, build blocked by pnpm allowBuilds) would brick the
|
|
81
|
+
* next boot, so it is removed on the spot.
|
|
82
|
+
* @returns names kept and names removed as broken.
|
|
83
|
+
*/
|
|
84
|
+
export async function validateAddedPlugins(run, profile, before) {
|
|
85
|
+
const addedNow = Object.keys(readInstalled(profile)).filter(n => !before.has(n));
|
|
86
|
+
const keep = [];
|
|
87
|
+
const removedBroken = [];
|
|
88
|
+
for (const n of addedNow) {
|
|
89
|
+
const dir = join(profileDir(profile), 'node_modules', n);
|
|
90
|
+
if (hasDshManifest(dir) && entryArtifactExists(dir)) {
|
|
91
|
+
keep.push(n);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
removedBroken.push(n);
|
|
95
|
+
await run(profile, ['remove', n]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { keep, removedBroken };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether a clean-exit update actually changed nothing — pnpm's
|
|
102
|
+
* minimumReleaseAge silently keeps the old version and exits 0 when the new
|
|
103
|
+
* release is "too young" (#13, #22), so a clean exit alone does not mean the
|
|
104
|
+
* update happened.
|
|
105
|
+
*/
|
|
106
|
+
export function isStaleUpdate(check) {
|
|
107
|
+
return check.isGit
|
|
108
|
+
? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
|
|
109
|
+
: check.beforeVersion !== null && check.afterVersion === check.beforeVersion;
|
|
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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pnpm compatibility layer — everything the market needs to know about how
|
|
3
|
+
* different pnpm majors behave inside a dsh profile directory, kept pure and
|
|
4
|
+
* separately testable (test/unit + test/integration exercise this module
|
|
5
|
+
* against real pnpm 9/10/11).
|
|
6
|
+
*
|
|
7
|
+
* Verified behavior matrix (2026-08, pnpm 9.15.9 / 10.28.2 / 11.21.0):
|
|
8
|
+
* - workspace root, `add` without -w: pnpm 9 fails ERR_PNPM_ADDING_TO_ROOT;
|
|
9
|
+
* pnpm 10/11 succeed.
|
|
10
|
+
* - `add -w` where NO pnpm-workspace.yaml exists: ALL majors fail with
|
|
11
|
+
* "--workspace-root may only be used inside a workspace".
|
|
12
|
+
* - modules dir built by pnpm 9, then pnpm 10/11 mutate it:
|
|
13
|
+
* ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF (defaults drifted between majors).
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
/**
|
|
18
|
+
* Decide the argv for a `dsh plugin <add|remove> …` call in the given profile.
|
|
19
|
+
*
|
|
20
|
+
* pnpm 9 refuses to add at a workspace root without -w (#17, #20); every
|
|
21
|
+
* pnpm major refuses -w when the directory is NOT a workspace. So the flag
|
|
22
|
+
* is injected exactly when the profile has a pnpm-workspace.yaml.
|
|
23
|
+
* @param profileDir - resolved profile directory (owns pnpm-workspace.yaml, or not).
|
|
24
|
+
* @param pluginArgs - the raw args, e.g. ['add', 'dshmarket@latest'].
|
|
25
|
+
* @returns args with -w injected when — and only when — the profile is a workspace root.
|
|
26
|
+
*/
|
|
27
|
+
export function pluginArgsFor(profileDir, pluginArgs) {
|
|
28
|
+
if (pluginArgs[0] !== 'add' && pluginArgs[0] !== 'remove')
|
|
29
|
+
return pluginArgs;
|
|
30
|
+
if (!existsSync(join(profileDir, 'pnpm-workspace.yaml')))
|
|
31
|
+
return pluginArgs;
|
|
32
|
+
return [pluginArgs[0], '-w', ...pluginArgs.slice(1)];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Map a failed pnpm run's combined output to a known failure mode.
|
|
36
|
+
*
|
|
37
|
+
* dsh's own wrapper line ("dsh: pnpm failed in profile directory …") names no
|
|
38
|
+
* cause, so the market must recognize pnpm's real diagnostics itself (#20).
|
|
39
|
+
* @param output - stdout+stderr of the failed run.
|
|
40
|
+
* @returns the classified failure, or null when unrecognized (raw output is then shown as-is).
|
|
41
|
+
*/
|
|
42
|
+
export function classifyPnpmFailure(output) {
|
|
43
|
+
if (output.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
|
|
44
|
+
return {
|
|
45
|
+
code: 'hoist-pattern-diff',
|
|
46
|
+
recoverable: true,
|
|
47
|
+
message: 'profile 的 node_modules 是旧版 pnpm 创建的,与当前 pnpm 的默认配置不兼容,需要重建后重试 / this profile\'s node_modules was created by a different pnpm major; it must be rebuilt (pnpm install) before changes can be applied',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (output.includes('ERR_PNPM_ADDING_TO_ROOT')) {
|
|
51
|
+
return {
|
|
52
|
+
code: 'adding-to-root',
|
|
53
|
+
recoverable: false,
|
|
54
|
+
message: 'pnpm 拒绝在 workspace 根目录安装(缺少 -w)。这是市场的 bug,请升级 dshmarket 到最新版 / pnpm refused to add at a workspace root (missing -w); this is a market bug — please update dshmarket',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (/--workspace-root may only be used inside a workspace/i.test(output)) {
|
|
58
|
+
return {
|
|
59
|
+
code: 'not-a-workspace',
|
|
60
|
+
recoverable: false,
|
|
61
|
+
message: 'profile 目录不是 pnpm workspace,却传入了 -w。这是市场的 bug,请升级 dshmarket 到最新版 / -w was passed but the profile is not a pnpm workspace; this is a market bug — please update dshmarket',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (output.includes('pnpm not found on PATH')) {
|
|
65
|
+
return {
|
|
66
|
+
code: 'pnpm-missing',
|
|
67
|
+
recoverable: false,
|
|
68
|
+
message: '找不到 pnpm,请先在市场页顶部一键安装组件 / pnpm is not on PATH — use the one-click setup at the top of the market page',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
package/lib/profile.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile filesystem reads — everything the market learns from a dsh
|
|
3
|
+
* profile directory (manifest, lockfile, installed package trees). Pure
|
|
4
|
+
* functions of the directory contents; no processes, no network.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
/** Resolve a profile name to its directory under DSH_HOME (default ~/.dsh). */
|
|
10
|
+
export function profileDir(profile) {
|
|
11
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
12
|
+
return join(home, 'profiles', profile);
|
|
13
|
+
}
|
|
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). */
|
|
27
|
+
export function readInstalled(profile) {
|
|
28
|
+
try {
|
|
29
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'package.json'), 'utf8'));
|
|
30
|
+
const installed = {};
|
|
31
|
+
for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
|
|
32
|
+
if (!INBOX_BUNDLES.has(name))
|
|
33
|
+
installed[name] = spec;
|
|
34
|
+
}
|
|
35
|
+
return installed;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The version actually present in the profile's node_modules, or null. */
|
|
42
|
+
export function readInstalledVersion(profile, name) {
|
|
43
|
+
try {
|
|
44
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'node_modules', name, 'package.json'), 'utf8'));
|
|
45
|
+
return manifest.version ?? null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Pinned commit per `owner/repo` from the profile lockfile's codeload tarball URLs. */
|
|
52
|
+
export function readLockCommits(profile) {
|
|
53
|
+
const commits = new Map();
|
|
54
|
+
try {
|
|
55
|
+
const lock = readFileSync(join(profileDir(profile), 'pnpm-lock.yaml'), 'utf8');
|
|
56
|
+
for (const m of lock.matchAll(/codeload\.github\.com\/([^/\s]+\/[^/\s]+)\/tar\.gz\/([0-9a-f]{40})/g)) {
|
|
57
|
+
commits.set(m[1].toLowerCase(), m[2]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { /* no lockfile — no git installs to report */ }
|
|
61
|
+
return commits;
|
|
62
|
+
}
|
|
63
|
+
/** True when the installed package's manifest declares a dsh plugin surface. */
|
|
64
|
+
export function hasDshManifest(dir) {
|
|
65
|
+
try {
|
|
66
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
67
|
+
return manifest.dsh !== undefined;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* True when the package's declared entry artifact actually exists — github
|
|
75
|
+
* source checkouts of build-required plugins ship no lib/, and promoting one
|
|
76
|
+
* into the bundle layer bricks the next boot (ERR_MODULE_NOT_FOUND kills the
|
|
77
|
+
* whole profile, #18).
|
|
78
|
+
*/
|
|
79
|
+
export function entryArtifactExists(dir) {
|
|
80
|
+
try {
|
|
81
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
82
|
+
const candidates = [];
|
|
83
|
+
if (typeof manifest.main === 'string')
|
|
84
|
+
candidates.push(manifest.main);
|
|
85
|
+
const rootExport = typeof manifest.exports === 'string'
|
|
86
|
+
? manifest.exports
|
|
87
|
+
: manifest.exports?.['.'];
|
|
88
|
+
if (typeof rootExport === 'string')
|
|
89
|
+
candidates.push(rootExport);
|
|
90
|
+
else if (rootExport !== null && typeof rootExport === 'object') {
|
|
91
|
+
for (const value of Object.values(rootExport))
|
|
92
|
+
if (typeof value === 'string')
|
|
93
|
+
candidates.push(value);
|
|
94
|
+
}
|
|
95
|
+
if (candidates.length === 0)
|
|
96
|
+
candidates.push('index.js');
|
|
97
|
+
return candidates.some(rel => existsSync(join(dir, rel)));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
|
|
104
|
+
export function pluginSubdirs(root) {
|
|
105
|
+
const found = [];
|
|
106
|
+
let level1 = [];
|
|
107
|
+
try {
|
|
108
|
+
level1 = readdirSync(root, { withFileTypes: true })
|
|
109
|
+
.filter(dirent => dirent.isDirectory() && /^[A-Za-z0-9_.-]+$/.test(dirent.name) && dirent.name !== 'node_modules')
|
|
110
|
+
.map(dirent => dirent.name);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
for (const sub of level1) {
|
|
116
|
+
if (hasDshManifest(join(root, sub))) {
|
|
117
|
+
found.push(sub);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
for (const inner of readdirSync(join(root, sub), { withFileTypes: true })) {
|
|
122
|
+
if (!inner.isDirectory() || !/^[A-Za-z0-9_.-]+$/.test(inner.name) || inner.name === 'node_modules')
|
|
123
|
+
continue;
|
|
124
|
+
if (hasDshManifest(join(root, sub, inner.name)))
|
|
125
|
+
found.push(`${sub}/${inner.name}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { /* unreadable level — skip */ }
|
|
129
|
+
if (found.length >= 8)
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
return found.slice(0, 8);
|
|
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
|
+
}
|