dsh-m 0.1.0 → 0.2.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.
@@ -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
+ }