dsh-plugin 0.1.1 → 1.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.
Files changed (67) hide show
  1. package/README.en.md +56 -28
  2. package/README.md +56 -28
  3. package/client/client.js +2282 -230
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +432 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +66 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/npm-resolve.js +67 -0
  12. package/lib/services/pending-restart.js +34 -0
  13. package/lib/services/preflight.js +94 -0
  14. package/lib/services/profile.js +88 -0
  15. package/lib/services/progress.js +50 -0
  16. package/lib/services/task-queue.js +457 -0
  17. package/lib/types/http/routes.d.ts +23 -0
  18. package/lib/types/index.d.ts +27 -0
  19. package/lib/types/services/install-types.d.ts +69 -0
  20. package/lib/types/services/install.d.ts +18 -0
  21. package/lib/types/services/installed-versions.d.ts +24 -0
  22. package/lib/types/services/loader.d.ts +43 -0
  23. package/lib/types/services/npm-resolve.d.ts +6 -0
  24. package/lib/types/services/pending-restart.d.ts +13 -0
  25. package/lib/types/services/preflight.d.ts +6 -0
  26. package/lib/types/services/profile.d.ts +24 -0
  27. package/lib/types/services/progress.d.ts +17 -0
  28. package/lib/types/services/task-queue.d.ts +82 -0
  29. package/package.json +27 -6
  30. package/src/client/components/CatalogControls.tsx +104 -0
  31. package/src/client/components/CatalogHeader.tsx +78 -0
  32. package/src/client/components/CatalogList.tsx +75 -0
  33. package/src/client/components/CategoryTabs.tsx +35 -0
  34. package/src/client/components/Dropdown.tsx +80 -0
  35. package/src/client/components/NotificationsModal.tsx +243 -0
  36. package/src/client/components/PluginCard.tsx +104 -0
  37. package/src/client/components/PluginHubSection.tsx +342 -0
  38. package/src/client/components/ProgressView.tsx +23 -0
  39. package/src/client/components/icons.tsx +125 -0
  40. package/src/client/components/modals.tsx +368 -0
  41. package/src/client/hooks/useCatalog.ts +202 -0
  42. package/src/client/hooks/useTaskQueue.ts +502 -0
  43. package/src/client/index.tsx +8 -422
  44. package/src/client/lib/catalog.ts +240 -0
  45. package/src/client/lib/env.ts +16 -0
  46. package/src/client/lib/failures.ts +165 -0
  47. package/src/client/lib/format.ts +22 -0
  48. package/src/client/locales.ts +188 -18
  49. package/src/client/styles/Dropdown.module.css +123 -0
  50. package/src/client/styles/Header.module.css +432 -0
  51. package/src/client/styles/List.module.css +324 -0
  52. package/src/client/styles/Modal.module.css +1006 -0
  53. package/src/client/styles/tokens.module.css +91 -0
  54. package/src/client/types.ts +80 -0
  55. package/src/server/http/routes.ts +432 -0
  56. package/src/server/index.ts +35 -0
  57. package/src/server/services/install-types.ts +69 -0
  58. package/src/server/services/install.ts +18 -0
  59. package/src/server/services/installed-versions.ts +84 -0
  60. package/src/server/services/loader.ts +110 -0
  61. package/src/server/services/npm-resolve.ts +67 -0
  62. package/src/server/services/pending-restart.ts +36 -0
  63. package/src/server/services/preflight.ts +97 -0
  64. package/src/server/services/profile.ts +89 -0
  65. package/src/server/services/progress.ts +48 -0
  66. package/src/server/services/task-queue.ts +492 -0
  67. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,67 @@
1
+ /**
2
+ * npm 包反查:git 分发不完整(缺构建产物/子模块)导致安装失败时,用 npm
3
+ * registry 的搜索接口按 repository 地址反查该仓库对应的官方 npm 包。
4
+ *
5
+ * 这是通用机制,不针对任何具体插件:只要作者发布了 repository 指向该
6
+ * GitHub 仓库的 npm 包(scope 名与 GitHub 用户名是否一致无所谓),就能命中,
7
+ * 从而把「git 直装必败」的插件切换到 npm 通道安装。命中结果带内存缓存,
8
+ * 避免同一会话内重复查询 registry。
9
+ */
10
+ import { get } from 'node:https';
11
+ /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包)。 */
12
+ const cache = new Map();
13
+ /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
14
+ const REQUEST_TIMEOUT_MS = 8000;
15
+ /**
16
+ * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
17
+ * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
18
+ * 原有错误路径,反查只是额外的一次尝试。
19
+ */
20
+ export function resolveNpmPackage(repo) {
21
+ const key = repo.toLowerCase();
22
+ if (cache.has(key))
23
+ return Promise.resolve(cache.get(key) ?? null);
24
+ const name = searchRepo(repo);
25
+ name.then((found) => cache.set(key, found)).catch(() => cache.set(key, null));
26
+ return name;
27
+ }
28
+ /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 */
29
+ function searchRepo(repo) {
30
+ const [owner, name] = repo.split('/');
31
+ if (owner === undefined || name === undefined || name === '')
32
+ return Promise.resolve(null);
33
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10`;
34
+ return new Promise((resolve) => {
35
+ const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
36
+ if (res.statusCode !== 200) {
37
+ res.resume();
38
+ resolve(null);
39
+ return;
40
+ }
41
+ let body = '';
42
+ res.on('data', (chunk) => { body += chunk.toString(); });
43
+ res.on('end', () => {
44
+ try {
45
+ const data = JSON.parse(body);
46
+ const needle = `github.com/${owner}/${name}`.toLowerCase();
47
+ for (const obj of data.objects ?? []) {
48
+ const pkg = obj.package;
49
+ if (typeof pkg?.name !== 'string' || pkg.name === '')
50
+ continue;
51
+ const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '');
52
+ if (repoUrl.toLowerCase().includes(needle)) {
53
+ resolve(pkg.name);
54
+ return;
55
+ }
56
+ }
57
+ resolve(null);
58
+ }
59
+ catch {
60
+ resolve(null);
61
+ }
62
+ });
63
+ });
64
+ req.on('timeout', () => req.destroy());
65
+ req.on('error', () => resolve(null));
66
+ });
67
+ }
@@ -0,0 +1,34 @@
1
+ const pendingRestarts = [];
2
+ /**
3
+ * 登记一条待重启记录(同一目标去重)。
4
+ * - 卸载:目标刚装完未重启(已有待重启记录)→ 宿主从未加载过它,卸完磁盘已干净、
5
+ * loader 无残留,无需再提醒重启,直接移除该条;否则(此前已被宿主加载过)登记「卸载后待重启清理」。
6
+ * - 安装:已有记录则刷新为安装语义(如卸载后未重启又重装),否则新登记。
7
+ */
8
+ export function addPendingRestart(target, kind) {
9
+ const existing = pendingRestarts.find((p) => p.target === target);
10
+ if (kind === 'uninstall') {
11
+ if (existing)
12
+ pendingRestarts.splice(pendingRestarts.indexOf(existing), 1);
13
+ else
14
+ pendingRestarts.push({ target, kind, at: Date.now() });
15
+ return;
16
+ }
17
+ if (existing) {
18
+ existing.kind = 'install';
19
+ existing.at = Date.now();
20
+ }
21
+ else {
22
+ pendingRestarts.push({ target, kind, at: Date.now() });
23
+ }
24
+ }
25
+ /** 当前待重启列表快照(副本,消费方不会污染内部数组)。 */
26
+ export function listPendingRestarts() {
27
+ return pendingRestarts.map((p) => ({ ...p }));
28
+ }
29
+ /** 清除某目标的待重启记录(卸载已即时生效、无需重启时调用)。 */
30
+ export function clearPendingRestart(target) {
31
+ const index = pendingRestarts.findIndex((p) => p.target === target);
32
+ if (index >= 0)
33
+ pendingRestarts.splice(index, 1);
34
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 安装前预检:对 `github:` 源的插件,检查其分发(codeload tarball)里是否
3
+ * 真的包含 package.json 声明的入口文件(main / exports["."].default)。
4
+ * git 分发常不提交构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时
5
+ * ERR_MODULE_NOT_FOUND 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
6
+ * npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
7
+ * 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
8
+ */
9
+ import { execFile } from 'node:child_process';
10
+ import { createWriteStream, mkdtempSync, rmSync } from 'node:fs';
11
+ import { get } from 'node:https';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { pipeline } from 'node:stream/promises';
15
+ import { promisify } from 'node:util';
16
+ const execFileAsync = promisify(execFile);
17
+ export async function preflightTarget(target) {
18
+ const m = /^github:([^/]+)\/([^/]+)$/.exec(target);
19
+ if (m === null)
20
+ return { ok: true, missing: null };
21
+ const [, owner, repo] = m;
22
+ let commit = '';
23
+ try {
24
+ const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, 'HEAD']);
25
+ commit = stdout.split(/\s+/)[0] ?? '';
26
+ }
27
+ catch {
28
+ return { ok: true, missing: null };
29
+ }
30
+ if (!commit)
31
+ return { ok: true, missing: null };
32
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'));
33
+ try {
34
+ const tar = join(dir, 'pkg.tar.gz');
35
+ await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar);
36
+ const meta = await readTarJson(tar, 'package/package.json');
37
+ if (meta === null)
38
+ return { ok: true, missing: null };
39
+ const dot = meta.exports?.['.'];
40
+ const resolved = typeof dot === 'string'
41
+ ? dot
42
+ : dot !== null && typeof dot === 'object'
43
+ ? dot.default
44
+ : undefined;
45
+ const entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js';
46
+ const inTar = await hasTarEntry(tar, `package/${entry}`);
47
+ return { ok: inTar, missing: inTar ? null : entry };
48
+ }
49
+ catch {
50
+ return { ok: true, missing: null };
51
+ }
52
+ finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ }
56
+ function download(url, dest) {
57
+ return new Promise((resolve, reject) => {
58
+ get(url, (res) => {
59
+ if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
60
+ res.resume();
61
+ if (res.headers.location) {
62
+ void download(res.headers.location, dest).then(resolve, reject);
63
+ return;
64
+ }
65
+ }
66
+ if (res.statusCode !== 200) {
67
+ res.resume();
68
+ reject(new Error(`download failed: HTTP ${res.statusCode ?? '?'}`));
69
+ return;
70
+ }
71
+ void pipeline(res, createWriteStream(dest)).then(resolve, reject);
72
+ }).on('error', reject);
73
+ });
74
+ }
75
+ /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
76
+ async function readTarJson(tar, entry) {
77
+ try {
78
+ const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 });
79
+ return JSON.parse(stdout);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ /** 检查 tarball 内是否存在指定 entry。 */
86
+ async function hasTarEntry(tar, entry) {
87
+ try {
88
+ const { stdout } = await execFileAsync('tar', ['-tzf', tar, entry], { maxBuffer: 1024 * 1024 });
89
+ return stdout.split(/\r?\n/).some((line) => line === entry);
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 宿主 profile 与目标校验工具:解析当前 profile、目录定位、安装目标语法校验,
3
+ * 以及 pnpm allowBuilds 白名单的写入(失败恢复路径)。
4
+ */
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { PACKAGE_RE, REPO_RE } from './install-types.js';
9
+ /** Resolve the active profile from the booted CLI args, falling back to `web`. */
10
+ export function readProfileArg(fallback = 'web') {
11
+ const index = process.argv.indexOf('--profile');
12
+ const candidate = index >= 0 ? process.argv[index + 1] : undefined;
13
+ return candidate !== undefined && !candidate.startsWith('-') ? candidate : fallback;
14
+ }
15
+ /** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */
16
+ export function profileDirectory(profile) {
17
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile);
18
+ }
19
+ /** Build a safe `github:<owner>/<repo>` target, or null when the repo is unsafe. */
20
+ export function githubTarget(repo) {
21
+ if (typeof repo !== 'string' || !REPO_RE.test(repo))
22
+ return null;
23
+ return `github:${repo}`;
24
+ }
25
+ /** Validate an npm package name (uninstall target grammar). */
26
+ export function validPackageName(name) {
27
+ return typeof name === 'string' && PACKAGE_RE.test(name);
28
+ }
29
+ /**
30
+ * The exact `allowBuilds` key pnpm printed in its
31
+ * `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` hint. For `github:` installs pnpm
32
+ * matches against `name@<resolved fetch URL>` — the URL it actually fetches,
33
+ * e.g. `https://codeload.github.com/<owner>/<repo>/tar.gz/<commit>` — not the
34
+ * `git+ssh://`/branch spec. A bare name or a `git+` spec does not match, so
35
+ * the only reliable key is the one pnpm prints verbatim. Null when absent.
36
+ */
37
+ export function parseAllowBuildsKey(output) {
38
+ const m = /^[ \t]*(\S+@\S+):[ \t]*true[ \t]*$/m.exec(output);
39
+ return m === null ? null : m[1].trim();
40
+ }
41
+ /** Quote a YAML block-mapping key when a plain scalar would be invalid. */
42
+ function quoteYamlKey(key) {
43
+ if (/^[-?:,[\]{}#&*!|>'"%@`]/.test(key) || /:(\s|$)/.test(key)) {
44
+ return `'${key.replace(/'/g, "''")}'`;
45
+ }
46
+ return key;
47
+ }
48
+ /**
49
+ * Add one `allowBuilds` entry to the profile's pnpm-workspace.yaml, merging
50
+ * with existing entries and leaving the rest of the yaml intact. Broken
51
+ * placeholder entries (pnpm's failed-install bug writes a literal
52
+ * `set this to true or false` value) are dropped on rewrite.
53
+ */
54
+ export function addAllowBuildsKey(profile, key) {
55
+ const file = join(profileDirectory(profile), 'pnpm-workspace.yaml');
56
+ let yaml = '';
57
+ try {
58
+ yaml = readFileSync(file, 'utf8');
59
+ }
60
+ catch { /* created below */ }
61
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
62
+ const blockRe = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/g;
63
+ const map = new Map();
64
+ for (const match of Array.from(yaml.matchAll(blockRe))) {
65
+ for (const line of match[1].split(/\r?\n/)) {
66
+ const m = /^[ \t]+(\S.*?)\s*:\s*(true|false)?\s*$/.exec(line);
67
+ if (m === null || m[1] === '')
68
+ continue;
69
+ let k = m[1];
70
+ if (k.length >= 2 && ((k[0] === "'" && k[k.length - 1] === "'") || (k[0] === '"' && k[k.length - 1] === '"'))) {
71
+ k = k.slice(1, -1);
72
+ }
73
+ map.set(k, m[2] ?? 'true');
74
+ }
75
+ }
76
+ map.set(key, 'true');
77
+ const block = Array.from(map).map(([k, v]) => ` ${quoteYamlKey(k)}: ${v}`).join(eol);
78
+ const blockText = `allowBuilds:${eol}${block}${eol}`;
79
+ let next;
80
+ if (/allowBuilds:[ \t]*\r?\n/.test(yaml)) {
81
+ let seen = 0;
82
+ next = yaml.replace(blockRe, () => (seen++ === 0 ? blockText : ''));
83
+ }
84
+ else {
85
+ next = `${yaml.replace(/\r?\n?$/, eol)}${blockText}`;
86
+ }
87
+ writeFileSync(file, next);
88
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pure progress-estimation helpers shared by the install runner.
3
+ *
4
+ * The dsh CLI wraps pnpm, which refreshes its `Progress: …` line in place
5
+ * with carriage returns, so raw output arrives as a stream of partial
6
+ * fragments. These helpers normalize one fragment into a clean terminal
7
+ * line and map it onto a 0-100 estimate; keeping them free of I/O makes the
8
+ * estimation logic directly unit-testable (see tests/progress.test.ts).
9
+ */
10
+ /** Strip the `[out]` / `[err]` source prefix and ANSI escape sequences. */
11
+ export function cleanLine(raw) {
12
+ return raw.replace(/^\[(?:err|out)\]\s*/, '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim();
13
+ }
14
+ /**
15
+ * Estimate 0-100 progress from one CLI output line. pnpm emits
16
+ * `Progress: resolved N, reused X, downloaded Y, added Z` during the fetch
17
+ * phase; resolution/install phase lines bump the estimate towards done.
18
+ */
19
+ export function estimateProgress(line) {
20
+ const progress = line.match(/Progress:\s*(.+)/);
21
+ if (progress) {
22
+ let resolved = 0;
23
+ let handled = 0;
24
+ for (const part of progress[1].split(',')) {
25
+ // pnpm emits `label count` tokens, e.g. `resolved 100, reused 40`
26
+ const [label, raw] = part.trim().split(/\s+/);
27
+ const value = Number(raw);
28
+ if (label === 'resolved')
29
+ resolved = value;
30
+ else if (label === 'reused' || label === 'downloaded' || label === 'added' || label === 'imported') {
31
+ handled += value;
32
+ }
33
+ }
34
+ // pnpm 的 resolved 是"解析出的包数",reused/downloaded/added 是"已处理的包数",
35
+ // 两者几乎总是相等,比值没有进度区分度(一出现就贴满 100%)。
36
+ // fetch 阶段只占安装总时长的一小部分(git 依赖之后还要 clone + 跑 prepare 构建),
37
+ // 因此按已处理量在低区间(8-30)缓步推进,把中段留给真正的安装/构建阶段。
38
+ if (resolved > 0 && handled > 0) {
39
+ return Math.min(30, 8 + Math.round((handled / resolved) * 20));
40
+ }
41
+ return resolved > 0 ? 8 : 0;
42
+ }
43
+ if (/^dependencies:|^Packages:/i.test(line))
44
+ return 85;
45
+ if (/^Running\b|prepare|build scripts/i.test(line))
46
+ return 60;
47
+ if (/^Done in\b/i.test(line))
48
+ return 96;
49
+ return 0;
50
+ }