dsh-m 0.0.2 → 0.1.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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Spawn `dsh plugin`。与 skillhub 同款关键约束:
3
+ * - 不经 agent 的沙箱 shell(它写不了 profile 目录);
4
+ * - 在 dsh web 宿主进程内时,用 `node <自身 entry> plugin ...` 重入自身;
5
+ * - 目标串白名单校验;超时对进程组发 SIGTERM;只保留末 256KB 输出;
6
+ * - prepare 被拦 → 写 dangerouslyAllowAllBuilds 重试(明确报告,DESIGN.md §3 基线 4)。
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
11
+ import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
12
+ const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/;
13
+ export function webProfileName() {
14
+ return WEB_PROFILE;
15
+ }
16
+ export function isSafePluginTarget(target) {
17
+ return TARGET_RE.test(target);
18
+ }
19
+ export function nodeExecutable(argv0 = process.argv0, execPath = process.execPath) {
20
+ if (argv0 !== undefined && argv0 !== '' && isAbsolute(argv0) && existsSync(argv0))
21
+ return argv0;
22
+ return execPath;
23
+ }
24
+ /** 宿主进程内时重入自身 entry(skillhub 同款);否则退回 PATH 上的 dsh。 */
25
+ export function dshArgv(input = {}) {
26
+ const argv = input.argv ?? process.argv;
27
+ const execArgv = input.execArgv ?? process.execArgv;
28
+ const execPath = input.execPath ?? process.execPath;
29
+ const argv0 = input.argv0 ?? process.argv0;
30
+ const platform = input.platform ?? process.platform;
31
+ const node = nodeExecutable(argv0, execPath);
32
+ const entry = argv[1];
33
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
34
+ const abs = resolve(entry);
35
+ return { file: node, args: [...execArgv, abs], cwd: dirname(abs), viaShell: false };
36
+ }
37
+ return { file: 'dsh', args: [], cwd: undefined, viaShell: platform === 'win32' };
38
+ }
39
+ /** pnpm 9 需要 -w 于 workspace 根;其他主版本在非 workspace 下拒绝 -w。 */
40
+ export function pluginArgsFor(profileDirectory, pluginArgs) {
41
+ const args = [...pluginArgs];
42
+ if (args[0] !== 'add' && args[0] !== 'remove')
43
+ return args;
44
+ if (!existsSync(join(profileDirectory, 'pnpm-workspace.yaml')))
45
+ return args;
46
+ return [args[0], '-w', ...args.slice(1)];
47
+ }
48
+ export function isPrepareBlocked(text) {
49
+ return /needs to execute build scripts|allowBuilds|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED|ERR_PNPM_IGNORED_BUILDS/i.test(text);
50
+ }
51
+ export function withDangerouslyAllowAllBuilds(yaml) {
52
+ if (/(?:^|\n)dangerouslyAllowAllBuilds:\s*true\s*(?:\n|$)/.test(yaml))
53
+ return yaml;
54
+ if (/(?:^|\n)dangerouslyAllowAllBuilds:\s*/m.test(yaml)) {
55
+ return yaml.replace(/^dangerouslyAllowAllBuilds:\s*.*$/m, 'dangerouslyAllowAllBuilds: true');
56
+ }
57
+ if (yaml.trim() === '')
58
+ return 'dangerouslyAllowAllBuilds: true\n';
59
+ return `${yaml.replace(/\s*$/u, '\n')}\ndangerouslyAllowAllBuilds: true\n`;
60
+ }
61
+ /** 基线 §17.4:放行构建脚本前必须能被明确报告(返回值带 usedAllowAllBuilds)。 */
62
+ function writeDangerouslyAllowAllBuilds(profileDirectory) {
63
+ const file = join(profileDirectory, 'pnpm-workspace.yaml');
64
+ let yaml = '';
65
+ try {
66
+ yaml = readFileSync(file, 'utf8');
67
+ }
68
+ catch {
69
+ /* created below */
70
+ }
71
+ const next = withDangerouslyAllowAllBuilds(yaml);
72
+ if (next === yaml)
73
+ return false;
74
+ mkdirSync(profileDirectory, { recursive: true });
75
+ writeFileSync(file, next);
76
+ return true;
77
+ }
78
+ export function rewritePnpmError(err) {
79
+ const text = err instanceof Error ? err.message : String(err);
80
+ if (isPrepareBlocked(text)) {
81
+ return new Error('该插件需要执行构建脚本(prepare),pnpm 默认拦截。dsh-m 已写入 profile 的 dangerouslyAllowAllBuilds 并重试;若仍失败请检查 web profile 是否可写。');
82
+ }
83
+ if (/ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF/.test(text)) {
84
+ return new Error('当前 profile 的 node_modules 由不同主版本的 pnpm 生成,安装前需要先重建依赖。');
85
+ }
86
+ return err instanceof Error ? err : new Error(text);
87
+ }
88
+ export async function runCommand(command, args, options) {
89
+ return new Promise((resolvePromise, reject) => {
90
+ const child = spawn(command, args, {
91
+ cwd: options.cwd,
92
+ env: { ...process.env, ...options.env, CI: 'true' },
93
+ stdio: ['ignore', 'pipe', 'pipe'],
94
+ shell: options.viaShell === true,
95
+ detached: options.detached === true && process.platform !== 'win32',
96
+ });
97
+ let out = '';
98
+ let settled = false;
99
+ const finish = (err) => {
100
+ if (settled)
101
+ return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ options.signal?.removeEventListener('abort', onAbort);
105
+ if (err)
106
+ reject(err);
107
+ else
108
+ resolvePromise(out);
109
+ };
110
+ const killChild = () => {
111
+ if (process.platform !== 'win32' && child.pid !== undefined) {
112
+ try {
113
+ process.kill(-child.pid, 'SIGTERM');
114
+ return;
115
+ }
116
+ catch {
117
+ /* fall through */
118
+ }
119
+ }
120
+ try {
121
+ child.kill('SIGTERM');
122
+ }
123
+ catch {
124
+ /* already gone */
125
+ }
126
+ };
127
+ const timer = setTimeout(() => {
128
+ killChild();
129
+ finish(new Error(`命令超时 ${options.timeoutMs}ms`));
130
+ }, options.timeoutMs);
131
+ const onAbort = () => {
132
+ killChild();
133
+ finish(new Error('命令已取消'));
134
+ };
135
+ options.signal?.addEventListener('abort', onAbort, { once: true });
136
+ child.stdout?.on('data', (chunk) => {
137
+ out = (out + chunk.toString()).slice(-256 * 1024);
138
+ });
139
+ child.stderr?.on('data', (chunk) => {
140
+ out = (out + chunk.toString()).slice(-256 * 1024);
141
+ });
142
+ child.on('error', (err) => finish(err));
143
+ child.on('close', (code) => {
144
+ if (code === 0)
145
+ finish();
146
+ else
147
+ finish(new Error(`命令失败 (exit ${code}): ${out.trim().slice(-800) || 'no output'}`));
148
+ });
149
+ });
150
+ }
151
+ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
152
+ if (profile !== WEB_PROFILE)
153
+ throw new Error('仅支持 web profile');
154
+ const target = pluginArgs[pluginArgs.length - 1] ?? '';
155
+ if (!isSafePluginTarget(target))
156
+ throw new Error(`拒绝不安全的安装目标: ${target}`);
157
+ const argv = (deps.dshArgv ?? dshArgv)();
158
+ const prepared = pluginArgsFor(deps.profileDir ?? webProfileDir(), pluginArgs);
159
+ const run = deps.runCommand ?? runCommand;
160
+ return run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
161
+ cwd: argv.cwd,
162
+ timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
163
+ env: { CI: 'true' },
164
+ viaShell: argv.viaShell,
165
+ detached: process.platform !== 'win32',
166
+ });
167
+ }
168
+ /**
169
+ * 安装。返回 usedAllowAllBuilds 供 UI 明确报告「该插件执行了构建脚本」。
170
+ * source 形如:`pkg@1.2.3`(npm 精确锁定)或 `github:owner/repo#sha`(锁 SHA)。
171
+ */
172
+ export async function addDshPlugin(source, deps = {}) {
173
+ const run = deps.runDshPlugin ?? runDshPlugin;
174
+ const allowAllBuilds = deps.allowAllBuilds ?? writeDangerouslyAllowAllBuilds;
175
+ const retryAfterPrepare = async () => {
176
+ const changed = allowAllBuilds(deps.profileDir ?? webProfileDir());
177
+ try {
178
+ return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: true };
179
+ }
180
+ catch (retryErr) {
181
+ throw rewritePnpmError(retryErr);
182
+ }
183
+ };
184
+ try {
185
+ return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
186
+ }
187
+ catch (err) {
188
+ const text = err instanceof Error ? err.message : String(err);
189
+ if (text.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
190
+ await run(WEB_PROFILE, ['install', '--no-frozen-lockfile']);
191
+ try {
192
+ return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
193
+ }
194
+ catch (retryErr) {
195
+ if (!isPrepareBlocked(retryErr instanceof Error ? retryErr.message : String(retryErr))) {
196
+ throw rewritePnpmError(retryErr);
197
+ }
198
+ return retryAfterPrepare();
199
+ }
200
+ }
201
+ if (!isPrepareBlocked(text))
202
+ throw rewritePnpmError(err);
203
+ return retryAfterPrepare();
204
+ }
205
+ }
206
+ /** 卸载(转发 pnpm remove;调用方须先做 live-disable)。 */
207
+ export async function removeDshPlugin(pkg, deps = {}) {
208
+ if (!isSafePluginTarget(pkg))
209
+ throw new Error(`无效插件包名: ${pkg}`);
210
+ const run = deps.runDshPlugin ?? runDshPlugin;
211
+ return run(WEB_PROFILE, ['remove', pkg]);
212
+ }
@@ -0,0 +1,18 @@
1
+ /** 路径与环境约定(DESIGN.md §11:profile 是唯一事实源) */
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ export const WEB_PROFILE = 'web';
5
+ export function dshHome() {
6
+ return process.env.DSH_HOME || join(homedir(), '.dsh');
7
+ }
8
+ export function webProfileDir() {
9
+ return join(dshHome(), 'profiles', WEB_PROFILE);
10
+ }
11
+ /** registry 缓存目录(可被 DSHM_CACHE_DIR 覆盖,便于测试) */
12
+ export function cacheDir() {
13
+ return process.env.DSHM_CACHE_DIR || join(dshHome(), 'dshm', 'cache');
14
+ }
15
+ /** 安装类操作的超时(毫秒) */
16
+ export function installTimeoutMs() {
17
+ return Number(process.env.DSHM_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
18
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 安全基线 §17.1:仅 HTTPS(loopback http 例外)+ 响应大小上限 + 超时。
3
+ * skillhub 的缺口(无统一体积上限)在这里补齐。
4
+ */
5
+ export class HttpError extends Error {
6
+ status;
7
+ constructor(status, message) {
8
+ super(message);
9
+ this.status = status;
10
+ }
11
+ }
12
+ const MAX_DEFAULT = 2 * 1024 * 1024; // 2MB:registry/npm metadata 足够
13
+ export function assertSafeUrl(raw) {
14
+ let url;
15
+ try {
16
+ url = new URL(raw);
17
+ }
18
+ catch {
19
+ throw new HttpError(400, `无效 URL: ${raw}`);
20
+ }
21
+ if (url.protocol === 'https:')
22
+ return url;
23
+ if (url.protocol === 'http:' && ['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) {
24
+ return url; // 本地 registry 覆盖调试用(DESIGN.md §2.1)
25
+ }
26
+ throw new HttpError(400, `仅允许 HTTPS: ${raw}`);
27
+ }
28
+ async function readCapped(res, maxBytes) {
29
+ const declared = Number(res.headers.get('content-length') || 0);
30
+ if (declared > maxBytes)
31
+ throw new HttpError(502, `响应过大: ${declared} > ${maxBytes}`);
32
+ const reader = res.body?.getReader();
33
+ if (!reader)
34
+ return Buffer.alloc(0);
35
+ const chunks = [];
36
+ let total = 0;
37
+ for (;;) {
38
+ const { done, value } = await reader.read();
39
+ if (done)
40
+ break;
41
+ total += value.byteLength;
42
+ if (total > maxBytes) {
43
+ await reader.cancel().catch(() => undefined);
44
+ throw new HttpError(502, `响应超过上限 ${maxBytes} 字节`);
45
+ }
46
+ chunks.push(Buffer.from(value));
47
+ }
48
+ return Buffer.concat(chunks);
49
+ }
50
+ export async function fetchJsonLimited(url, opts = {}) {
51
+ assertSafeUrl(url);
52
+ const res = await fetch(url, {
53
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000),
54
+ headers: { accept: 'application/json', 'user-agent': 'dsh-m (personal marketplace)', ...opts.headers },
55
+ redirect: 'follow',
56
+ });
57
+ if (!res.ok)
58
+ throw new HttpError(res.status, `HTTP ${res.status}: ${url}`);
59
+ const buf = await readCapped(res, opts.maxBytes ?? MAX_DEFAULT);
60
+ try {
61
+ return JSON.parse(buf.toString('utf8'));
62
+ }
63
+ catch (err) {
64
+ throw new HttpError(502, `响应不是合法 JSON: ${url}`);
65
+ }
66
+ }
67
+ export async function fetchTextLimited(url, opts = {}) {
68
+ assertSafeUrl(url);
69
+ const res = await fetch(url, {
70
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000),
71
+ headers: { 'user-agent': 'dsh-m (personal marketplace)', ...opts.headers },
72
+ redirect: 'follow',
73
+ });
74
+ if (!res.ok)
75
+ throw new HttpError(res.status, `HTTP ${res.status}: ${url}`);
76
+ const buf = await readCapped(res, opts.maxBytes ?? MAX_DEFAULT);
77
+ return buf.toString('utf8');
78
+ }
79
+ /** 可达性探测(icon/homepage 校验用):2xx 即可达。 */
80
+ export async function isReachable(url, timeoutMs = 8000) {
81
+ try {
82
+ assertSafeUrl(url);
83
+ const res = await fetch(url, {
84
+ method: 'HEAD',
85
+ signal: AbortSignal.timeout(timeoutMs),
86
+ headers: { 'user-agent': 'dsh-m (personal marketplace)' },
87
+ redirect: 'follow',
88
+ });
89
+ return res.ok || res.status === 405; // 有的站点拒绝 HEAD,视为可达
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 已装插件识别(DESIGN.md §3):profile 的 package.json 是唯一事实源,
3
+ * 不引入额外状态文件。移植自 skillhub installed-plugins.ts(去 README 暂缓)。
4
+ */
5
+ import { readFile } from 'node:fs/promises';
6
+ import { join, resolve } from 'node:path';
7
+ import { isSafePluginTarget, removeDshPlugin } from './dsh-cli.js';
8
+ import { webProfileDir } from './env.js';
9
+ const PKG_NAME_RE = /^(@[A-Za-z0-9-*~][A-Za-z0-9-*._~]*\/)?[A-Za-z0-9-._~]+$/;
10
+ export function isSafePkgName(raw) {
11
+ const name = String(raw || '').trim();
12
+ if (!name || name.length > 214)
13
+ return false;
14
+ if (!PKG_NAME_RE.test(name))
15
+ return false;
16
+ return !name.split('/').some((part) => part === '' || part === '.' || part.startsWith('.') || part.startsWith('_') || part.includes('..'));
17
+ }
18
+ export function parseSpecSource(spec) {
19
+ const raw = String(spec || '').trim();
20
+ if (raw.startsWith('link:'))
21
+ return 'link';
22
+ if (raw.startsWith('file:'))
23
+ return 'file';
24
+ if (raw.startsWith('github:') || /^https:\/\/github\.com\//i.test(raw))
25
+ return 'github';
26
+ if (raw)
27
+ return 'npm';
28
+ return 'unknown';
29
+ }
30
+ /** 解析依赖的包目录:普通依赖限制在 profile node_modules 内;link:/file: 仅接受绝对路径。 */
31
+ export function resolvePluginDir(profileDir, pkg, spec) {
32
+ if (!isSafePkgName(pkg))
33
+ return null;
34
+ const source = parseSpecSource(spec);
35
+ if (source === 'link' || source === 'file') {
36
+ const target = String(spec).slice(spec.indexOf(':') + 1).trim();
37
+ if (!target.startsWith('/') || target.includes('\0'))
38
+ return null;
39
+ return resolve(target);
40
+ }
41
+ return join(resolve(profileDir), 'node_modules', pkg);
42
+ }
43
+ export async function readPkgJson(dir) {
44
+ try {
45
+ const raw = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
46
+ return raw && typeof raw === 'object' ? raw : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ export async function readProfileDeps(profileDir) {
53
+ try {
54
+ const raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
55
+ if (!raw || typeof raw !== 'object' || !raw.dependencies || typeof raw.dependencies !== 'object')
56
+ return {};
57
+ const out = {};
58
+ for (const [name, spec] of Object.entries(raw.dependencies)) {
59
+ if (typeof spec === 'string' && spec !== '')
60
+ out[name] = spec;
61
+ }
62
+ return out;
63
+ }
64
+ catch {
65
+ return {};
66
+ }
67
+ }
68
+ function sanitizePkgJson(raw, fallbackName) {
69
+ return {
70
+ name: typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim().slice(0, 200) : fallbackName,
71
+ version: typeof raw.version === 'string' ? raw.version.trim().slice(0, 64) : '',
72
+ description: typeof raw.description === 'string' ? raw.description.trim().slice(0, 500) : '',
73
+ homepage: typeof raw.homepage === 'string' && /^https?:\/\//i.test(raw.homepage) ? raw.homepage.slice(0, 300) : '',
74
+ path: '',
75
+ };
76
+ }
77
+ /** 枚举 web profile 已安装插件(只读)。 */
78
+ export async function listInstalledPlugins(profileDir = webProfileDir()) {
79
+ const root = resolve(profileDir);
80
+ const deps = await readProfileDeps(root);
81
+ const items = [];
82
+ let others = 0;
83
+ for (const pkg of Object.keys(deps).sort()) {
84
+ const spec = deps[pkg];
85
+ const dir = resolvePluginDir(root, pkg, spec);
86
+ const raw = dir ? await readPkgJson(dir) : null;
87
+ if (!raw || !('dsh' in raw)) {
88
+ others += 1;
89
+ continue;
90
+ }
91
+ const info = sanitizePkgJson(raw, pkg);
92
+ items.push({
93
+ pkg,
94
+ name: info.name,
95
+ version: info.version,
96
+ description: info.description,
97
+ homepage: info.homepage,
98
+ spec,
99
+ source: parseSpecSource(spec),
100
+ dsh: true,
101
+ path: dir,
102
+ });
103
+ }
104
+ return { items, others, profileDir: root };
105
+ }
106
+ /** 从 web profile 卸载已安装的 dsh 插件。pkg 必须来自 profile 依赖(先 live-disable,见 market.ts)。 */
107
+ export async function removeInstalledPlugin(pkg, profileDir = webProfileDir(), deps = {}) {
108
+ const key = String(pkg || '').trim();
109
+ if (!isSafePkgName(key) || !isSafePluginTarget(key))
110
+ throw new Error(`无效插件包名: ${pkg}`);
111
+ const root = resolve(profileDir);
112
+ const listed = await readProfileDeps(root);
113
+ if (!(key in listed))
114
+ throw new Error(`web profile 未安装该插件: ${key}`);
115
+ const dir = resolvePluginDir(root, key, listed[key]);
116
+ const raw = dir ? await readPkgJson(dir) : null;
117
+ if (!raw || !('dsh' in raw))
118
+ throw new Error(`不是 dsh 插件: ${key}`);
119
+ await removeDshPlugin(key, deps);
120
+ return { pkg: key };
121
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 卸载前先让 loader 里的 live fiber 下线,否则 client-modules 仍列出该包,
3
+ * 浏览器请求 /plugins/<name>/client.js 会 404。移植自 skillhub live-plugin.ts。
4
+ */
5
+ let boundHost;
6
+ export function bindLoaderHost(host) {
7
+ boundHost = host;
8
+ }
9
+ export function loaderHost() {
10
+ return boundHost;
11
+ }
12
+ export async function setLivePluginDisabled(pkg, disabled, host = boundHost) {
13
+ const name = String(pkg || '').trim();
14
+ const entries = host?.loader?.entries;
15
+ if (!name || typeof entries !== 'function')
16
+ return false;
17
+ let found = false;
18
+ for (const entry of entries.call(host.loader)) {
19
+ if (!entry || entry.options?.name !== name)
20
+ continue;
21
+ if (typeof entry.update !== 'function')
22
+ continue;
23
+ found = (await flipEntry(entry, disabled)) || found;
24
+ }
25
+ return found;
26
+ }
27
+ async function flipEntry(entry, disabled) {
28
+ const flag = disabled ? true : null;
29
+ for (let attempt = 0; attempt < 3; attempt++) {
30
+ try {
31
+ await entry.update({ disabled: flag }, false, true);
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ const live = entry.fiber !== undefined;
37
+ if (live !== disabled)
38
+ return true;
39
+ await new Promise((resolve) => setTimeout(resolve, 200));
40
+ }
41
+ return true;
42
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * 市场编排层:registry × profile × 最新版本 → 市场列表 / 已装列表 / outdated /
3
+ * 安装 / 卸载 / 升级。安装语义见 DESIGN.md §3(npm 精确锁定、GitHub 锁 SHA)。
4
+ */
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { addDshPlugin } from './dsh-cli.js';
8
+ import { dshHome, webProfileDir } from './env.js';
9
+ import { listInstalledPlugins, readProfileDeps, removeInstalledPlugin, } from './installed.js';
10
+ import { setLivePluginDisabled } from './live-plugin.js';
11
+ import { loadRegistry } from './registry.js';
12
+ import { githubHeadSha, isNewerVersion, npmLatest } from './versions.js';
13
+ function matchInstalledByEntry(entry, installed) {
14
+ return installed.find((it) => {
15
+ if (entry.npm && it.pkg === entry.npm)
16
+ return true;
17
+ if (entry.npm && it.name === entry.npm)
18
+ return true;
19
+ if (entry.github && it.source === 'github') {
20
+ const m = /^github:([^#]+)/.exec(it.spec);
21
+ if (m && m[1] === entry.github)
22
+ return true;
23
+ }
24
+ return false;
25
+ });
26
+ }
27
+ export async function listMarket(cfg = {}, opts = {}) {
28
+ const [loaded, installed] = await Promise.all([
29
+ loadRegistry(cfg, opts),
30
+ listInstalledPlugins(),
31
+ ]);
32
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
33
+ const items = [];
34
+ for (const entry of loaded.registry.plugins) {
35
+ const inst = matchInstalledByEntry(entry, installed.items);
36
+ const item = {
37
+ ...entry,
38
+ installed: Boolean(inst),
39
+ outdated: false,
40
+ };
41
+ if (inst) {
42
+ item.installedPkg = inst.pkg;
43
+ item.installedVersion = inst.version;
44
+ }
45
+ try {
46
+ if (entry.source === 'npm' && entry.npm) {
47
+ const latest = await npmLatest(entry.npm, timeoutMs);
48
+ item.latestVersion = latest.version;
49
+ if (inst?.version && isNewerVersion(latest.version, inst.version))
50
+ item.outdated = true;
51
+ }
52
+ else if (entry.github) {
53
+ const sha = await githubHeadSha(entry.github, timeoutMs);
54
+ item.latestSha = sha;
55
+ if (inst && !inst.spec.includes(sha))
56
+ item.outdated = true;
57
+ }
58
+ }
59
+ catch (err) {
60
+ item.latestError = err instanceof Error ? err.message : String(err);
61
+ }
62
+ items.push(item);
63
+ }
64
+ return {
65
+ items,
66
+ registrySource: loaded.source,
67
+ registryFetchedAt: loaded.fetchedAt,
68
+ registryErrors: loaded.errors,
69
+ };
70
+ }
71
+ export async function listInstalledWithMeta(cfg = {}) {
72
+ const [{ items: installed, others, profileDir }, loaded] = await Promise.all([
73
+ listInstalledPlugins(),
74
+ loadRegistry(cfg),
75
+ ]);
76
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
77
+ const items = [];
78
+ for (const it of installed) {
79
+ const entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [it]));
80
+ const item = { ...it, outdated: false };
81
+ if (entry)
82
+ item.registryId = entry.id;
83
+ try {
84
+ if (!entry && it.source === 'npm') {
85
+ const latest = await npmLatest(it.pkg, timeoutMs);
86
+ item.latestVersion = latest.version;
87
+ item.outdated = isNewerVersion(latest.version, it.version);
88
+ }
89
+ else if (entry?.source === 'npm' && entry.npm) {
90
+ const latest = await npmLatest(entry.npm, timeoutMs);
91
+ item.latestVersion = latest.version;
92
+ item.outdated = isNewerVersion(latest.version, it.version);
93
+ }
94
+ else if (it.source === 'github') {
95
+ const m = /^github:([^#]+)/.exec(it.spec);
96
+ if (m) {
97
+ const sha = await githubHeadSha(m[1], timeoutMs);
98
+ item.outdated = !it.spec.includes(sha);
99
+ }
100
+ }
101
+ }
102
+ catch {
103
+ /* 查询失败不阻塞列表,outdated 维持 false */
104
+ }
105
+ items.push(item);
106
+ }
107
+ return { items, others, profileDir };
108
+ }
109
+ /** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。 */
110
+ export async function installFromRegistry(id, cfg = {}, opts = {}) {
111
+ const { registry } = await loadRegistry(cfg);
112
+ const entry = registry.plugins.find((e) => e.id === id);
113
+ if (!entry)
114
+ throw new Error(`registry 中没有该条目: ${id}`);
115
+ return installEntry(entry, cfg, opts);
116
+ }
117
+ export async function installEntry(entry, cfg = {}, opts = {}) {
118
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
119
+ if (entry.source === 'npm' && entry.npm) {
120
+ const version = opts.version && /^\d+\.\d+\.\d+/.test(opts.version) ? opts.version : (await npmLatest(entry.npm, timeoutMs)).version;
121
+ const spec = `${entry.npm}@${version}`;
122
+ const res = await addDshPlugin(spec);
123
+ // 安装后校验:落盘版本必须与意图一致(tarball 完整性由 pnpm 按 lock integrity 保证)
124
+ const deps = await readProfileDeps(webProfileDir());
125
+ const specInProfile = deps[entry.npm];
126
+ if (specInProfile === undefined)
127
+ throw new Error(`安装后未在 profile 依赖中找到 ${entry.npm}`);
128
+ return {
129
+ id: entry.id,
130
+ pkg: entry.npm,
131
+ spec,
132
+ version,
133
+ usedAllowAllBuilds: res.usedAllowAllBuilds,
134
+ needsRestart: true,
135
+ output: res.output.slice(-800),
136
+ };
137
+ }
138
+ if (entry.github) {
139
+ const sha = await githubHeadSha(entry.github, timeoutMs);
140
+ const spec = `github:${entry.github}#${sha}`;
141
+ const res = await addDshPlugin(spec);
142
+ const deps = await readProfileDeps(webProfileDir());
143
+ const pkgKey = Object.keys(deps).find((k) => deps[k] === spec || deps[k].startsWith(`github:${entry.github}#`));
144
+ if (!pkgKey)
145
+ throw new Error(`安装后未在 profile 依赖中找到 ${entry.github}`);
146
+ return {
147
+ id: entry.id,
148
+ pkg: pkgKey,
149
+ spec,
150
+ sha,
151
+ usedAllowAllBuilds: res.usedAllowAllBuilds,
152
+ needsRestart: true,
153
+ output: res.output.slice(-800),
154
+ };
155
+ }
156
+ throw new Error(`条目 ${entry.id} 缺少可安装来源`);
157
+ }
158
+ /** 卸载:live-disable → pnpm remove → 报告疑似残留(DESIGN.md §3:删包不删数据)。 */
159
+ export async function uninstallPlugin(pkg, _cfg = {}) {
160
+ const liveDisabled = await setLivePluginDisabled(pkg, true);
161
+ await removeInstalledPlugin(pkg);
162
+ return { pkg, liveDisabled, needsRestart: true, leftovers: leftoverCandidates(pkg) };
163
+ }
164
+ /** 升级 = 按最新重新安装(npm 拉最新精确版;github 重新锁 HEAD)。 */
165
+ export async function upgradePlugin(pkg, cfg = {}) {
166
+ const { registry } = await loadRegistry(cfg);
167
+ const { items: installed } = await listInstalledPlugins();
168
+ const target = installed.find((it) => it.pkg === pkg);
169
+ if (!target)
170
+ throw new Error(`web profile 未安装该插件: ${pkg}`);
171
+ const entry = registry.plugins.find((e) => matchInstalledByEntry(e, [target]));
172
+ if (!entry)
173
+ throw new Error(`「${pkg}」不是经 dsh-m 收录的插件;直接升级请用 dsh plugin update 或先在 registry 收录它`);
174
+ const result = await installEntry(entry, cfg);
175
+ return { ...result, fromVersion: target.version };
176
+ }
177
+ /** 变更互斥:安装/卸载/升级串行执行(skillhub install-lock 同款思路)。 */
178
+ let mutationTail = Promise.resolve();
179
+ export function withMutationLock(task) {
180
+ const next = mutationTail.then(task, task);
181
+ mutationTail = next.catch(() => undefined);
182
+ return next;
183
+ }
184
+ /** 疑似残留路径(存在才列出):删包不删数据,只报告。 */
185
+ export function leftoverCandidates(pkg) {
186
+ const home = dshHome();
187
+ const candidates = [
188
+ join(home, `${pkg}.json`),
189
+ join(home, pkg),
190
+ join(home, `${pkg.replace(/^@[^/]+\//, '')}.json`),
191
+ ];
192
+ return candidates.filter((p) => existsSync(p));
193
+ }