dsh-m 0.1.0 → 0.1.1

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.
@@ -9,7 +9,85 @@ import { spawn } from 'node:child_process';
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
10
  import { dirname, isAbsolute, join, resolve } from 'node:path';
11
11
  import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
12
+ import { createProgressTracker } from './progress.js';
12
13
  const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/;
14
+ const NDJSON_COMMANDS = new Set(['add', 'remove', 'install']);
15
+ export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`;
16
+ export const progress = {
17
+ active: false,
18
+ target: '',
19
+ startedAt: 0,
20
+ lastLine: '',
21
+ phase: null,
22
+ done: 0,
23
+ total: null,
24
+ currentPackage: null,
25
+ downloaded: null,
26
+ size: null,
27
+ ndjson: false,
28
+ error: null,
29
+ };
30
+ export function publicInstallStatus() {
31
+ return {
32
+ active: progress.active,
33
+ target: progress.target,
34
+ seconds: progress.active ? Math.round((Date.now() - progress.startedAt) / 1000) : 0,
35
+ lastLine: progress.lastLine,
36
+ phase: progress.phase,
37
+ done: progress.done,
38
+ total: progress.total,
39
+ currentPackage: progress.currentPackage,
40
+ downloaded: progress.downloaded,
41
+ size: progress.size,
42
+ ndjson: progress.ndjson,
43
+ error: progress.error,
44
+ boot: BOOT_ID,
45
+ };
46
+ }
47
+ function beginProgress(target) {
48
+ progress.active = true;
49
+ progress.target = target;
50
+ progress.startedAt = Date.now();
51
+ progress.lastLine = '';
52
+ progress.phase = null;
53
+ progress.done = 0;
54
+ progress.total = null;
55
+ progress.currentPackage = null;
56
+ progress.downloaded = null;
57
+ progress.size = null;
58
+ progress.ndjson = false;
59
+ progress.error = null;
60
+ return createProgressTracker();
61
+ }
62
+ function makeProgressFeeder(tracker) {
63
+ let lineBuffer = '';
64
+ return (chunk) => {
65
+ lineBuffer += chunk;
66
+ let nl;
67
+ while ((nl = lineBuffer.indexOf('\n')) !== -1) {
68
+ const line = lineBuffer.slice(0, nl);
69
+ lineBuffer = lineBuffer.slice(nl + 1);
70
+ const trimmed = line.trim();
71
+ if (trimmed === '')
72
+ continue;
73
+ tracker.feed(trimmed);
74
+ if (!trimmed.startsWith('{'))
75
+ progress.lastLine = trimmed.slice(0, 200);
76
+ }
77
+ };
78
+ }
79
+ function syncProgress(tracker) {
80
+ const snap = tracker.snapshot;
81
+ progress.phase = snap.phase;
82
+ progress.done = snap.done;
83
+ progress.total = snap.total;
84
+ progress.currentPackage = snap.currentPackage;
85
+ progress.downloaded = snap.downloaded;
86
+ progress.size = snap.size;
87
+ progress.ndjson = snap.seen;
88
+ if (snap.error !== null)
89
+ progress.error = snap.error;
90
+ }
13
91
  export function webProfileName() {
14
92
  return WEB_PROFILE;
15
93
  }
@@ -45,6 +123,14 @@ export function pluginArgsFor(profileDirectory, pluginArgs) {
45
123
  return args;
46
124
  return [args[0], '-w', ...args.slice(1)];
47
125
  }
126
+ /** add/remove/install 追加 ndjson reporter,供进度解析(人类回退行由 feeder 记录 lastLine)。 */
127
+ export function preparePluginArgs(profileDirectory, pluginArgs) {
128
+ const args = pluginArgsFor(profileDirectory, pluginArgs);
129
+ const command = args[0];
130
+ if (command !== undefined && NDJSON_COMMANDS.has(command))
131
+ return [...args, '--reporter=ndjson'];
132
+ return args;
133
+ }
48
134
  export function isPrepareBlocked(text) {
49
135
  return /needs to execute build scripts|allowBuilds|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED|ERR_PNPM_IGNORED_BUILDS/i.test(text);
50
136
  }
@@ -134,10 +220,14 @@ export async function runCommand(command, args, options) {
134
220
  };
135
221
  options.signal?.addEventListener('abort', onAbort, { once: true });
136
222
  child.stdout?.on('data', (chunk) => {
137
- out = (out + chunk.toString()).slice(-256 * 1024);
223
+ const text = chunk.toString();
224
+ out = (out + text).slice(-256 * 1024);
225
+ options.onChunk?.(text);
138
226
  });
139
227
  child.stderr?.on('data', (chunk) => {
140
- out = (out + chunk.toString()).slice(-256 * 1024);
228
+ const text = chunk.toString();
229
+ out = (out + text).slice(-256 * 1024);
230
+ options.onChunk?.(text);
141
231
  });
142
232
  child.on('error', (err) => finish(err));
143
233
  child.on('close', (code) => {
@@ -155,15 +245,33 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
155
245
  if (!isSafePluginTarget(target))
156
246
  throw new Error(`拒绝不安全的安装目标: ${target}`);
157
247
  const argv = (deps.dshArgv ?? dshArgv)();
158
- const prepared = pluginArgsFor(deps.profileDir ?? webProfileDir(), pluginArgs);
248
+ const prepared = preparePluginArgs(deps.profileDir ?? webProfileDir(), pluginArgs);
249
+ const tracker = beginProgress(target);
250
+ const feed = makeProgressFeeder(tracker);
159
251
  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
- });
252
+ try {
253
+ return await run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
254
+ cwd: argv.cwd,
255
+ timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
256
+ env: { CI: 'true' },
257
+ viaShell: argv.viaShell,
258
+ detached: process.platform !== 'win32',
259
+ onChunk: (text) => {
260
+ feed(text);
261
+ syncProgress(tracker);
262
+ },
263
+ });
264
+ }
265
+ catch (err) {
266
+ // 失败/超时时也把原因写入状态端点,轮询侧能看到错误终态
267
+ const text = err instanceof Error ? err.message : String(err);
268
+ if (progress.error === null)
269
+ progress.error = text.slice(0, 800);
270
+ throw err;
271
+ }
272
+ finally {
273
+ progress.active = false;
274
+ }
167
275
  }
168
276
  /**
169
277
  * 安装。返回 usedAllowAllBuilds 供 UI 明确报告「该插件执行了构建脚本」。
@@ -2,7 +2,7 @@
2
2
  * 已装插件识别(DESIGN.md §3):profile 的 package.json 是唯一事实源,
3
3
  * 不引入额外状态文件。移植自 skillhub installed-plugins.ts(去 README 暂缓)。
4
4
  */
5
- import { readFile } from 'node:fs/promises';
5
+ import { open, readFile } from 'node:fs/promises';
6
6
  import { join, resolve } from 'node:path';
7
7
  import { isSafePluginTarget, removeDshPlugin } from './dsh-cli.js';
8
8
  import { webProfileDir } from './env.js';
@@ -27,19 +27,37 @@ export function parseSpecSource(spec) {
27
27
  return 'npm';
28
28
  return 'unknown';
29
29
  }
30
- /** 解析依赖的包目录:普通依赖限制在 profile node_modules 内;link:/file: 仅接受绝对路径。 */
30
+ /**
31
+ * 解析依赖的包目录:
32
+ * - `link:` 是活的开发目录符号链接,解析真实目标(已装页展示本地路径有价值);
33
+ * - 其余(npm / github / file-tarball / file-dir)pnpm 都会把内容物化到 node_modules/<pkg>,
34
+ * 统一从那里读。skillhub 同款语义——file: 特判回 tarball 路径是错的(读不到 package.json)。
35
+ */
31
36
  export function resolvePluginDir(profileDir, pkg, spec) {
32
37
  if (!isSafePkgName(pkg))
33
38
  return null;
34
39
  const source = parseSpecSource(spec);
35
- if (source === 'link' || source === 'file') {
36
- const target = String(spec).slice(spec.indexOf(':') + 1).trim();
40
+ if (source === 'link') {
41
+ const target = String(spec).slice('link:'.length).trim();
37
42
  if (!target.startsWith('/') || target.includes('\0'))
38
43
  return null;
39
44
  return resolve(target);
40
45
  }
41
46
  return join(resolve(profileDir), 'node_modules', pkg);
42
47
  }
48
+ /** 从 repository 字段(字符串或 {url},git+/ssh/https 形态)提取 github owner/repo。 */
49
+ export function githubRepoFromRepository(raw) {
50
+ let url = '';
51
+ if (typeof raw === 'string')
52
+ url = raw;
53
+ else if (raw && typeof raw === 'object') {
54
+ const u = raw.url;
55
+ if (typeof u === 'string')
56
+ url = u;
57
+ }
58
+ const m = /github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i.exec(url.trim());
59
+ return m ? `${m[1]}/${m[2]}` : null;
60
+ }
43
61
  export async function readPkgJson(dir) {
44
62
  try {
45
63
  const raw = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
@@ -72,6 +90,7 @@ function sanitizePkgJson(raw, fallbackName) {
72
90
  description: typeof raw.description === 'string' ? raw.description.trim().slice(0, 500) : '',
73
91
  homepage: typeof raw.homepage === 'string' && /^https?:\/\//i.test(raw.homepage) ? raw.homepage.slice(0, 300) : '',
74
92
  path: '',
93
+ githubRepo: githubRepoFromRepository(raw.repository),
75
94
  };
76
95
  }
77
96
  /** 枚举 web profile 已安装插件(只读)。 */
@@ -99,6 +118,7 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
99
118
  source: parseSpecSource(spec),
100
119
  dsh: true,
101
120
  path: dir,
121
+ githubRepo: githubRepoFromRepository(raw.repository),
102
122
  });
103
123
  }
104
124
  return { items, others, profileDir: root };
@@ -119,3 +139,48 @@ export async function removeInstalledPlugin(pkg, profileDir = webProfileDir(), d
119
139
  await removeDshPlugin(key, deps);
120
140
  return { pkg: key };
121
141
  }
142
+ // ---------- README 预览(借鉴 skillhub,64KB 截断) ----------
143
+ const README_MAX_BYTES = 64 * 1024;
144
+ const README_FILES = ['README.md', 'README.markdown', 'README'];
145
+ /** 限量读取文本文件:只读前 limit 字节,超限标记 truncated。 */
146
+ async function readTextLimited(path, limit) {
147
+ let fh;
148
+ try {
149
+ fh = await open(path, 'r');
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ try {
155
+ const buf = Buffer.alloc(limit + 1);
156
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
157
+ return {
158
+ text: buf.subarray(0, Math.min(bytesRead, limit)).toString('utf8'),
159
+ truncated: bytesRead > limit,
160
+ };
161
+ }
162
+ finally {
163
+ await fh.close().catch(() => undefined);
164
+ }
165
+ }
166
+ /** 读取单个已安装插件的 README(UTF-8,≤64KB,超限截断)。pkg 必须来自 profile 依赖。 */
167
+ export async function readInstalledPluginReadme(pkg, profileDir = webProfileDir()) {
168
+ const key = String(pkg || '').trim();
169
+ if (!isSafePkgName(key))
170
+ throw new Error(`无效插件包名: ${pkg}`);
171
+ const root = resolve(profileDir);
172
+ const deps = await readProfileDeps(root);
173
+ if (!(key in deps))
174
+ throw new Error(`web profile 未安装该插件: ${key}`);
175
+ const dir = resolvePluginDir(root, key, deps[key]);
176
+ if (!dir)
177
+ throw new Error(`无法解析插件目录: ${key}`);
178
+ const raw = await readPkgJson(dir);
179
+ const name = raw && typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : key;
180
+ for (const file of README_FILES) {
181
+ const text = await readTextLimited(join(dir, file), README_MAX_BYTES);
182
+ if (text)
183
+ return { pkg: key, name, readme: text.text, truncated: text.truncated };
184
+ }
185
+ return { pkg: key, name, readme: '', truncated: false };
186
+ }
@@ -9,7 +9,7 @@ import { dshHome, webProfileDir } from './env.js';
9
9
  import { listInstalledPlugins, readProfileDeps, removeInstalledPlugin, } from './installed.js';
10
10
  import { setLivePluginDisabled } from './live-plugin.js';
11
11
  import { loadRegistry } from './registry.js';
12
- import { githubHeadSha, isNewerVersion, npmLatest } from './versions.js';
12
+ import { githubLatestTag, isNewerVersion, npmLatest } from './versions.js';
13
13
  function matchInstalledByEntry(entry, installed) {
14
14
  return installed.find((it) => {
15
15
  if (entry.npm && it.pkg === entry.npm)
@@ -50,7 +50,8 @@ export async function listMarket(cfg = {}, opts = {}) {
50
50
  item.outdated = true;
51
51
  }
52
52
  else if (entry.github) {
53
- const sha = await githubHeadSha(entry.github, timeoutMs);
53
+ const { tag, sha } = await githubLatestTag(entry.github, timeoutMs);
54
+ item.latestTag = tag;
54
55
  item.latestSha = sha;
55
56
  if (inst && !inst.spec.includes(sha))
56
57
  item.outdated = true;
@@ -78,8 +79,11 @@ export async function listInstalledWithMeta(cfg = {}) {
78
79
  for (const it of installed) {
79
80
  const entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [it]));
80
81
  const item = { ...it, outdated: false };
81
- if (entry)
82
+ if (entry) {
82
83
  item.registryId = entry.id;
84
+ item.registryGithub = entry.github ?? null;
85
+ item.registryIcon = entry.icon ?? null;
86
+ }
83
87
  try {
84
88
  if (!entry && it.source === 'npm') {
85
89
  const latest = await npmLatest(it.pkg, timeoutMs);
@@ -94,7 +98,8 @@ export async function listInstalledWithMeta(cfg = {}) {
94
98
  else if (it.source === 'github') {
95
99
  const m = /^github:([^#]+)/.exec(it.spec);
96
100
  if (m) {
97
- const sha = await githubHeadSha(m[1], timeoutMs);
101
+ const { tag, sha } = await githubLatestTag(m[1], timeoutMs);
102
+ item.latestTag = tag;
98
103
  item.outdated = !it.spec.includes(sha);
99
104
  }
100
105
  }
@@ -136,7 +141,7 @@ export async function installEntry(entry, cfg = {}, opts = {}) {
136
141
  };
137
142
  }
138
143
  if (entry.github) {
139
- const sha = await githubHeadSha(entry.github, timeoutMs);
144
+ const { tag, sha } = await githubLatestTag(entry.github, timeoutMs);
140
145
  const spec = `github:${entry.github}#${sha}`;
141
146
  const res = await addDshPlugin(spec);
142
147
  const deps = await readProfileDeps(webProfileDir());
@@ -0,0 +1,113 @@
1
+ /**
2
+ * pnpm `--reporter=ndjson` 进度解析器。移植自 skillhub ndjson.ts(零依赖)。
3
+ * stdout 每行一个 JSON 对象;人类可读的回退行由 spawn 层处理。
4
+ */
5
+ export function emptyProgress() {
6
+ return {
7
+ phase: null,
8
+ done: 0,
9
+ total: null,
10
+ currentPackage: null,
11
+ downloaded: null,
12
+ size: null,
13
+ seen: false,
14
+ error: null,
15
+ };
16
+ }
17
+ export function createProgressTracker() {
18
+ const snap = emptyProgress();
19
+ const seenPackages = new Set();
20
+ function dedupe(packageId) {
21
+ if (typeof packageId !== 'string' || packageId === '')
22
+ return;
23
+ if (!seenPackages.has(packageId)) {
24
+ seenPackages.add(packageId);
25
+ snap.done += 1;
26
+ }
27
+ }
28
+ function feed(line) {
29
+ let event;
30
+ try {
31
+ event = JSON.parse(line);
32
+ }
33
+ catch {
34
+ return;
35
+ }
36
+ if (typeof event !== 'object' || event === null)
37
+ return;
38
+ const msg = event;
39
+ const name = msg.name;
40
+ if (typeof name !== 'string')
41
+ return;
42
+ if (name === 'pnpm:stage') {
43
+ const stage = msg.stage;
44
+ if (stage === 'resolution_started')
45
+ snap.phase = 'resolving';
46
+ else if (stage === 'resolution_done')
47
+ snap.phase = 'downloading';
48
+ else if (stage === 'importing_started' || stage === 'importing_done')
49
+ snap.phase = 'linking';
50
+ snap.seen = true;
51
+ return;
52
+ }
53
+ if (name === 'pnpm:progress') {
54
+ snap.seen = true;
55
+ const status = msg.status;
56
+ if (status === 'resolved') {
57
+ if (snap.phase === null)
58
+ snap.phase = 'resolving';
59
+ dedupe(msg.packageId);
60
+ }
61
+ else if (status === 'fetched' || status === 'found_in_store') {
62
+ snap.phase = 'downloading';
63
+ snap.currentPackage = typeof msg.packageId === 'string' ? msg.packageId : snap.currentPackage;
64
+ dedupe(msg.packageId);
65
+ }
66
+ return;
67
+ }
68
+ if (name === 'pnpm:fetching-progress') {
69
+ snap.seen = true;
70
+ snap.phase = 'downloading';
71
+ if (typeof msg.packageId === 'string')
72
+ snap.currentPackage = msg.packageId;
73
+ if (typeof msg.size === 'number')
74
+ snap.size = msg.size;
75
+ if (typeof msg.downloaded === 'number')
76
+ snap.downloaded = msg.downloaded;
77
+ dedupe(msg.packageId);
78
+ return;
79
+ }
80
+ if (name === 'pnpm:lifecycle') {
81
+ snap.seen = true;
82
+ snap.phase = 'building';
83
+ const wd = typeof msg.wd === 'string' ? msg.wd : '';
84
+ const dep = typeof msg.depPath === 'string' ? msg.depPath : '';
85
+ const base = wd.split(/[\\/]/).filter(Boolean).pop();
86
+ snap.currentPackage = base ?? (dep !== '' ? dep : snap.currentPackage);
87
+ return;
88
+ }
89
+ if (name === 'pnpm:stats') {
90
+ if (msg.added !== undefined || msg.removed !== undefined)
91
+ snap.phase = 'linking';
92
+ snap.seen = true;
93
+ return;
94
+ }
95
+ if (name === 'pnpm' && msg.level === 'error') {
96
+ const err = (msg.err ?? {});
97
+ const message = typeof err.message === 'string' ? err.message : '';
98
+ if (message !== '')
99
+ snap.error = message.slice(0, 400);
100
+ }
101
+ }
102
+ function reset() {
103
+ seenPackages.clear();
104
+ Object.assign(snap, emptyProgress());
105
+ }
106
+ return {
107
+ get snapshot() {
108
+ return { ...snap };
109
+ },
110
+ feed,
111
+ reset,
112
+ };
113
+ }
@@ -9,9 +9,11 @@ import { cacheDir } from './env.js';
9
9
  import { fetchJsonLimited } from './httpx.js';
10
10
  export const CATEGORIES = ['market', 'tools', 'ui', 'search', 'media', 'other'];
11
11
  const REPO = 'iasiv5/dsh-m';
12
+ // raw 优先:jsDelivr 对 @main 有 CDN 缓存(可 stale 数小时),raw 始终反映 main 最新内容;
13
+ // jsDelivr 降为备用线路(可通过 purge.jsdelivr.net 手动清缓存)。
12
14
  const DEFAULT_URLS = [
13
- { source: 'jsdelivr', url: `https://cdn.jsdelivr.net/gh/${REPO}@main/registry.json` },
14
15
  { source: 'raw', url: `https://raw.githubusercontent.com/${REPO}/main/registry.json` },
16
+ { source: 'jsdelivr', url: `https://cdn.jsdelivr.net/gh/${REPO}@main/registry.json` },
15
17
  ];
16
18
  // ---------- 校验 ----------
17
19
  const GITHUB_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 最新版本解析(DESIGN.md §3:版本不写死,运行时实查)。
3
3
  * npm:registry /latest(pnpm 安装本身会按 lock integrity 校验 tarball)。
4
- * GitHub:api.github.com 解析 HEAD commit SHA(未认证限额 60 次/小时,自用足够)。
4
+ * GitHub:优先最新 release/tag(更新提示只跟稳定版走,不跟 main HEAD——中间提交可能不稳定);
5
+ * 未认证限额 60 次/小时,自用足够。
5
6
  */
6
7
  import { fetchJsonLimited } from './httpx.js';
7
8
  export async function npmLatest(pkg, timeoutMs = 20_000) {
@@ -18,18 +19,45 @@ export async function npmLatest(pkg, timeoutMs = 20_000) {
18
19
  tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
19
20
  };
20
21
  }
21
- export async function githubHeadSha(repo, timeoutMs = 20_000) {
22
- if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(repo))
23
- throw new Error(`无效 GitHub 仓库: ${repo}`);
24
- const data = await fetchJsonLimited(`https://api.github.com/repos/${repo}/commits/HEAD`, {
22
+ export async function githubTagSha(repo, tag, timeoutMs = 20_000) {
23
+ // commits/{ref} 会自动解引用 annotated tag,返回的才是可用于 #sha 锁定的 commit
24
+ const data = await fetchJsonLimited(`https://api.github.com/repos/${repo}/commits/${encodeURIComponent(tag)}`, {
25
25
  timeoutMs,
26
26
  headers: { accept: 'application/vnd.github+json' },
27
27
  });
28
28
  const sha = typeof data.sha === 'string' ? data.sha : '';
29
29
  if (!/^[0-9a-f]{40}$/.test(sha))
30
- throw new Error(`GitHub 未返回有效 SHA: ${repo}`);
30
+ throw new Error(`GitHub 未返回有效 SHA: ${repo}@${tag}`);
31
31
  return sha;
32
32
  }
33
+ /** GitHub 来源的“最新稳定点”:优先最新 release(排除 draft/prerelease),无则回退 tags 列表首项。 */
34
+ export async function githubLatestTag(repo, timeoutMs = 20_000) {
35
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(repo))
36
+ throw new Error(`无效 GitHub 仓库: ${repo}`);
37
+ const headers = { accept: 'application/vnd.github+json' };
38
+ // 1) 最新 release(404 = 仓库从未发过 release → 回退 tags)
39
+ try {
40
+ const rel = await fetchJsonLimited(`https://api.github.com/repos/${repo}/releases/latest`, { timeoutMs, headers });
41
+ const tag = typeof rel.tag_name === 'string' ? rel.tag_name.trim() : '';
42
+ if (tag)
43
+ return { tag, sha: await githubTagSha(repo, tag, timeoutMs) };
44
+ }
45
+ catch (err) {
46
+ const status = err.status;
47
+ if (status !== 404)
48
+ throw err;
49
+ }
50
+ // 2) 回退:tags 列表(GitHub 按创建时间倒序,首项即最新)
51
+ const tags = await fetchJsonLimited(`https://api.github.com/repos/${repo}/tags`, { timeoutMs, headers });
52
+ if (!Array.isArray(tags) || !tags.length)
53
+ throw new Error(`仓库没有任何 tag: ${repo}`);
54
+ const first = tags[0];
55
+ const name = typeof first.name === 'string' ? first.name : '';
56
+ const sha = first.commit && typeof first.commit.sha === 'string' ? first.commit.sha : '';
57
+ if (!name || !/^[0-9a-f]{40}$/.test(sha))
58
+ throw new Error(`tag 信息无效: ${repo}`);
59
+ return { tag: name, sha };
60
+ }
33
61
  export function isNewerVersion(candidate, current) {
34
62
  const parse = (v) => String(v || '')
35
63
  .replace(/^v/i, '')
package/lib/host.js CHANGED
@@ -106,6 +106,18 @@ async function handleApi(req, res, cfg) {
106
106
  const result = await listInstalledWithMeta(cfg);
107
107
  return sendJson(res, 200, { ok: true, ...result });
108
108
  }
109
+ case 'readme': {
110
+ const target = String(body.pkg || '').trim();
111
+ if (!target)
112
+ return sendJson(res, 400, { ok: false, error: '缺少 pkg' });
113
+ const { readInstalledPluginReadme } = await import('./core/installed.js');
114
+ const result = await readInstalledPluginReadme(target);
115
+ return sendJson(res, 200, { ok: true, ...result });
116
+ }
117
+ case 'status': {
118
+ const { publicInstallStatus } = await import('./core/dsh-cli.js');
119
+ return sendJson(res, 200, { ok: true, ...publicInstallStatus() });
120
+ }
109
121
  case 'install': {
110
122
  const id = String(body.id || '').trim();
111
123
  if (!id)
package/lib/tools.js CHANGED
@@ -206,6 +206,7 @@ export function registerTools(ctx, cfg) {
206
206
  version: it.version,
207
207
  source: it.source,
208
208
  latestVersion: it.latestVersion ?? null,
209
+ latestTag: it.latestTag ?? null,
209
210
  outdated: it.outdated,
210
211
  }));
211
212
  return cloneJson({ items, outdatedCount: items.filter((it) => it.outdated).length });
@@ -322,7 +323,7 @@ function renderOutdated(out) {
322
323
  return 'web profile 没有已装插件。';
323
324
  if (!outdated.length)
324
325
  return `全部 ${out.items.length} 个插件均已是最新版本。对用户一句短话。`;
325
- const lines = outdated.map((it) => `${it.name} (${it.pkg}):v${it.version} → ${it.latestVersion || '最新'}`);
326
+ const lines = outdated.map((it) => `${it.name} (${it.pkg}):v${it.version} → ${it.latestTag || (it.latestVersion ? `v${it.latestVersion}` : '最新')}`);
326
327
  return [
327
328
  `${outdated.length}/${out.items.length} 个插件可升级:`,
328
329
  lines.join('\n'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DSH Marketplace — 个人自用的 DeepSeek Harness 插件市场:收录、安装、卸载、升级 DSH 插件",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "cordis.patch.yml",
20
20
  "registry.json",
21
21
  "README.md",
22
+ "README.en.md",
22
23
  "DESIGN.md",
23
24
  "LICENSE"
24
25
  ],
package/registry.json CHANGED
@@ -21,6 +21,72 @@
21
21
  "npm": "dsh-web-search",
22
22
  "github": "haibinwang9/dsh-web-search",
23
23
  "homepage": "https://github.com/haibinwang9/dsh-web-search"
24
+ },
25
+ {
26
+ "id": "deepseek-harness-lark",
27
+ "name": "DeepSeek Harness Lark",
28
+ "description": "飞书 / Lark 文本与图片通道插件:把飞书消息桥接进 DeepSeek Harness 对话。",
29
+ "category": "other",
30
+ "tags": ["飞书", "Lark", "通道"],
31
+ "source": "npm",
32
+ "npm": "deepseek-harness-lark",
33
+ "github": "sliverp/DeepSeek-harness-lark",
34
+ "homepage": "https://github.com/sliverp/DeepSeek-harness-lark"
35
+ },
36
+ {
37
+ "id": "deepseek-harness-qqbot",
38
+ "name": "DeepSeek Harness QQBot",
39
+ "description": "QQ Bot 文本与图片通道插件:把 QQ 机器人消息桥接进 DeepSeek Harness 对话。",
40
+ "category": "other",
41
+ "tags": ["QQ", "通道"],
42
+ "source": "npm",
43
+ "npm": "deepseek-harness-qqbot",
44
+ "github": "sliverp/DeepSeek-harness-qqbot",
45
+ "homepage": "https://github.com/sliverp/DeepSeek-harness-qqbot"
46
+ },
47
+ {
48
+ "id": "deepseek-harness-weixin",
49
+ "name": "DeepSeek Harness Weixin",
50
+ "description": "微信通道插件:扫码登录,收发文本与图片消息,桥接进 DeepSeek Harness 对话。",
51
+ "category": "other",
52
+ "tags": ["微信", "通道"],
53
+ "source": "npm",
54
+ "npm": "deepseek-harness-weixin",
55
+ "github": "sliverp/DeepSeek-harness-weixin",
56
+ "homepage": "https://github.com/sliverp/DeepSeek-harness-weixin"
57
+ },
58
+ {
59
+ "id": "deepseek-harness-wecom",
60
+ "name": "DeepSeek Harness WeCom",
61
+ "description": "企业微信 AI Bot 文本与图片通道桥,桥接进 DeepSeek Harness 对话。",
62
+ "category": "other",
63
+ "tags": ["企业微信", "通道"],
64
+ "source": "npm",
65
+ "npm": "deepseek-harness-wecom",
66
+ "github": "sliverp/DeepSeek-harness-wecom",
67
+ "homepage": "https://github.com/sliverp/DeepSeek-harness-wecom"
68
+ },
69
+ {
70
+ "id": "deepseek-harness-dingtalk",
71
+ "name": "DeepSeek Harness DingTalk",
72
+ "description": "钉钉 Stream 文本与图片通道插件,桥接进 DeepSeek Harness 对话。",
73
+ "category": "other",
74
+ "tags": ["钉钉", "通道"],
75
+ "source": "npm",
76
+ "npm": "deepseek-harness-dingtalk",
77
+ "github": "sliverp/DeepSeek-harness-dingtalk",
78
+ "homepage": "https://github.com/sliverp/DeepSeek-harness-dingtalk"
79
+ },
80
+ {
81
+ "id": "dsh-m",
82
+ "name": "DSH Marketplace",
83
+ "description": "个人自用的 DeepSeek Harness 插件市场:收录、安装、卸载、升级 DSH 插件,含 Web 界面、agent 工具与 CLI。",
84
+ "category": "market",
85
+ "tags": ["市场", "插件管理"],
86
+ "source": "npm",
87
+ "npm": "dsh-m",
88
+ "github": "iasiv5/dsh-m",
89
+ "homepage": "https://github.com/iasiv5/dsh-m"
24
90
  }
25
91
  ]
26
92
  }