dsh-plugin 0.1.0 → 1.0.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.
- package/README.en.md +52 -27
- package/README.md +52 -27
- package/client/client.js +2148 -229
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +396 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +51 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +417 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +63 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +14 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +76 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +230 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +339 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +365 -0
- package/src/client/hooks/useCatalog.ts +192 -0
- package/src/client/hooks/useTaskQueue.ts +405 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +204 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +153 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +186 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +965 -0
- package/src/client/styles/tokens.module.css +86 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +397 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +63 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +65 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +450 -0
- package/src/client/Section.module.css +0 -569
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后台安装/卸载任务队列:内存任务注册表 + FIFO 串行 worker + 子进程生命周期管理。
|
|
3
|
+
* 每个任务一次 `dsh plugin --profile <profile> <action> <target>` 子进程,
|
|
4
|
+
* 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
|
|
10
|
+
import { cleanLine, estimateProgress } from './progress.js';
|
|
11
|
+
import { removeLoadedEntry } from './loader.js';
|
|
12
|
+
import { addPendingRestart, clearPendingRestart } from './pending-restart.js';
|
|
13
|
+
import { addAllowBuildsKey, parseAllowBuildsKey, profileDirectory } from './profile.js';
|
|
14
|
+
/** In-memory task registry, keyed by task id. */
|
|
15
|
+
const tasks = new Map();
|
|
16
|
+
let nextTaskId = 1;
|
|
17
|
+
/** Pending tasks awaiting their turn (FIFO). The queue worker runs at most one mutation at a time. */
|
|
18
|
+
const queue = [];
|
|
19
|
+
/** Child processes of currently running tasks (for cancel). */
|
|
20
|
+
const runningChildren = new Map();
|
|
21
|
+
/** Snapshot of a task (keeps the live object untouched by consumers). */
|
|
22
|
+
export function getTask(id) {
|
|
23
|
+
const task = tasks.get(id);
|
|
24
|
+
if (!task)
|
|
25
|
+
return undefined;
|
|
26
|
+
return { ...task, lines: task.lines.slice(0, MAX_TASK_LINES) };
|
|
27
|
+
}
|
|
28
|
+
/** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
|
|
29
|
+
export function activeTask() {
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const item of queue) {
|
|
32
|
+
const task = item.task;
|
|
33
|
+
if (task.status === 'pending') {
|
|
34
|
+
found.push({ id: task.id, target: task.target, action: task.action, status: 'pending', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES) });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
tasks.forEach((task) => {
|
|
38
|
+
if (task.status === 'running') {
|
|
39
|
+
found.unshift({ id: task.id, target: task.target, action: task.action, status: 'running', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES) });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* True while a mutation child process is still alive. The concurrent slot is
|
|
46
|
+
* keyed off the live child set rather than task.status: cancelling a running
|
|
47
|
+
* task flips its status immediately, but the child may still be shutting down
|
|
48
|
+
* (the close event lands later) — the slot must stay held until then, otherwise
|
|
49
|
+
* a new task would start while the old pnpm still runs in the same profile dir.
|
|
50
|
+
*/
|
|
51
|
+
export function hasRunningTask() {
|
|
52
|
+
return runningChildren.size > 0;
|
|
53
|
+
}
|
|
54
|
+
/** Whether the target already has a non-terminal task in the queue (install/uninstall dedupe). */
|
|
55
|
+
export function hasQueuedTarget(target) {
|
|
56
|
+
return queue.some((it) => it.task.target === target && (it.task.status === 'pending' || it.task.status === 'running'));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Cancel a queued or running task.
|
|
60
|
+
* - pending: removed from the queue immediately (never spawned).
|
|
61
|
+
* - running: kills the child process; the queue worker then picks the next one.
|
|
62
|
+
* Returns false when the task is unknown or already finished.
|
|
63
|
+
*/
|
|
64
|
+
export function cancelTask(id) {
|
|
65
|
+
const task = tasks.get(id);
|
|
66
|
+
if (!task)
|
|
67
|
+
return false;
|
|
68
|
+
if (task.status === 'pending') {
|
|
69
|
+
const index = queue.findIndex((it) => it.task === task);
|
|
70
|
+
if (index >= 0)
|
|
71
|
+
queue.splice(index, 1);
|
|
72
|
+
task.status = 'cancelled';
|
|
73
|
+
pushLine(task, '[cancelled]');
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (task.status === 'running') {
|
|
77
|
+
task.status = 'cancelled';
|
|
78
|
+
pushLine(task, '[cancelled]');
|
|
79
|
+
const child = runningChildren.get(id);
|
|
80
|
+
if (child)
|
|
81
|
+
stopChild(child);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
function pushLine(task, line) {
|
|
87
|
+
const text = cleanLine(line);
|
|
88
|
+
// pnpm 的 `Progress:` 行用 \r 原地刷新同一行:内容一变化就追加,
|
|
89
|
+
// 日志像终端一样实时滚动(downloaded 0→1→2…);内容与最新一条完全相同则跳过,
|
|
90
|
+
// 避免同一进度反复刷新时无意义的重复堆积
|
|
91
|
+
if (text !== task.lines[0]) {
|
|
92
|
+
task.lines.unshift(text);
|
|
93
|
+
if (task.lines.length > MAX_TASK_LINES)
|
|
94
|
+
task.lines.length = MAX_TASK_LINES;
|
|
95
|
+
}
|
|
96
|
+
const estimate = estimateProgress(text);
|
|
97
|
+
if (estimate > 0) {
|
|
98
|
+
task.progress = Math.max(task.progress, estimate);
|
|
99
|
+
}
|
|
100
|
+
else if (task.status === 'running' && task.progress < 85) {
|
|
101
|
+
// 兜底:行无解析格式也随输出量推进,保证进度条始终在动(不会卡在 0)
|
|
102
|
+
task.progress = Math.max(task.progress, 6 + Math.min(79, task.lines.length * 2));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function cliInvocation() {
|
|
106
|
+
const entry = process.argv[1];
|
|
107
|
+
if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
|
|
108
|
+
const absoluteEntry = resolve(entry);
|
|
109
|
+
return {
|
|
110
|
+
file: process.execPath,
|
|
111
|
+
prefixArgs: [...process.execArgv, absoluteEntry],
|
|
112
|
+
cwd: dirname(absoluteEntry),
|
|
113
|
+
useShell: false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { file: 'dsh', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' };
|
|
117
|
+
}
|
|
118
|
+
function stopChild(child) {
|
|
119
|
+
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
120
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
|
121
|
+
killer.once('error', () => child.kill('SIGKILL'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (child.pid !== undefined) {
|
|
125
|
+
// POSIX:dsh 内部会 fork pnpm,只杀 dsh 会让 pnpm 变成孤儿继续攥着管道,
|
|
126
|
+
// close 事件永不到来、队列卡死。detached 让子进程成为进程组组长,
|
|
127
|
+
// 对整组 SIGKILL 才能把 dsh + pnpm(及后代)一起清掉,close 随即触发。
|
|
128
|
+
try {
|
|
129
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
catch { /* 进程已退出:忽略 */ }
|
|
133
|
+
}
|
|
134
|
+
child.kill('SIGKILL');
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Enqueue a plugin mutation and return its task. Tasks run strictly serially:
|
|
138
|
+
* the queue worker starts the next one only after the previous finishes.
|
|
139
|
+
* Progress is visible through `getTask(id)` / `activeTask()` until done.
|
|
140
|
+
*/
|
|
141
|
+
export function startPluginMutation(options) {
|
|
142
|
+
const task = { id: nextTaskId, target: options.target, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [] };
|
|
143
|
+
nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1;
|
|
144
|
+
tasks.set(task.id, task);
|
|
145
|
+
if (tasks.size > MAX_TASKS) {
|
|
146
|
+
let removed = false;
|
|
147
|
+
tasks.forEach((candidate, id) => {
|
|
148
|
+
if (!removed && candidate.status !== 'running' && candidate.status !== 'pending') {
|
|
149
|
+
tasks.delete(id);
|
|
150
|
+
removed = true;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
queue.push({ task, options: { ...options } });
|
|
155
|
+
pumpQueue();
|
|
156
|
+
return task;
|
|
157
|
+
}
|
|
158
|
+
/** Queue worker: at most one mutation runs at a time; pick the next pending task when the slot frees up. */
|
|
159
|
+
function pumpQueue() {
|
|
160
|
+
if (hasRunningTask())
|
|
161
|
+
return;
|
|
162
|
+
const item = queue.find((it) => it.task.status === 'pending');
|
|
163
|
+
if (!item)
|
|
164
|
+
return;
|
|
165
|
+
const { task, options } = item;
|
|
166
|
+
task.status = 'running';
|
|
167
|
+
void runPluginMutation({ ...options, task }).finally(() => {
|
|
168
|
+
runningChildren.delete(task.id);
|
|
169
|
+
// 已终态的任务移出队列:既防 queue 数组无限增长(内存泄漏),也让下一轮 pump 干净
|
|
170
|
+
const idx = queue.findIndex((it) => it.task === task);
|
|
171
|
+
if (idx >= 0)
|
|
172
|
+
queue.splice(idx, 1);
|
|
173
|
+
pumpQueue();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Run one `dsh plugin --profile <profile> <action> <target>` spawn, stream
|
|
178
|
+
* its output onto the tracked task and resolve with the captured result.
|
|
179
|
+
* Never rejects; failures surface through the result / task state.
|
|
180
|
+
*/
|
|
181
|
+
function spawnMutation(options) {
|
|
182
|
+
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader } = options;
|
|
183
|
+
const invocation = cliInvocation();
|
|
184
|
+
const args = [...invocation.prefixArgs, 'plugin', '--profile', profile, action, target];
|
|
185
|
+
return new Promise((resolvePromise) => {
|
|
186
|
+
let stdout = '';
|
|
187
|
+
let stderr = '';
|
|
188
|
+
let timedOut = false;
|
|
189
|
+
let settled = false;
|
|
190
|
+
let child;
|
|
191
|
+
try {
|
|
192
|
+
child = spawn(invocation.file, args, {
|
|
193
|
+
cwd: invocation.cwd,
|
|
194
|
+
env,
|
|
195
|
+
shell: invocation.useShell,
|
|
196
|
+
// detached 使子进程成为独立进程组组长:取消时能整组清理(见 stopChild),
|
|
197
|
+
// 且宿主进程崩溃后安装任务不会被连带误杀
|
|
198
|
+
detached: true,
|
|
199
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (task) {
|
|
204
|
+
task.status = 'failed';
|
|
205
|
+
task.exitCode = null;
|
|
206
|
+
pushLine(task, `[error] ${error instanceof Error ? error.message : String(error)}`);
|
|
207
|
+
}
|
|
208
|
+
resolvePromise({
|
|
209
|
+
exitCode: null,
|
|
210
|
+
timedOut: false,
|
|
211
|
+
error: error instanceof Error ? error.message : String(error),
|
|
212
|
+
stdout,
|
|
213
|
+
stderr,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (task)
|
|
218
|
+
runningChildren.set(task.id, child);
|
|
219
|
+
// 兜底定时器句柄:整段超时 / exit 后 close 宽限 / 强制收尾
|
|
220
|
+
let timer;
|
|
221
|
+
let progressTimer;
|
|
222
|
+
let exitWatch;
|
|
223
|
+
let forceSettle;
|
|
224
|
+
// 唯一收尾入口:close / error / 兜底超时 三者只放行一次,
|
|
225
|
+
// 保证 runPluginMutation 必然 resolve、队列 worker 必然继续推进
|
|
226
|
+
const settle = (result) => {
|
|
227
|
+
if (settled)
|
|
228
|
+
return;
|
|
229
|
+
settled = true;
|
|
230
|
+
if (timer !== undefined)
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
if (progressTimer !== undefined)
|
|
233
|
+
clearInterval(progressTimer);
|
|
234
|
+
if (exitWatch !== undefined)
|
|
235
|
+
clearTimeout(exitWatch);
|
|
236
|
+
if (forceSettle !== undefined)
|
|
237
|
+
clearTimeout(forceSettle);
|
|
238
|
+
if (task)
|
|
239
|
+
runningChildren.delete(task.id);
|
|
240
|
+
resolvePromise(result);
|
|
241
|
+
};
|
|
242
|
+
timer = setTimeout(() => {
|
|
243
|
+
timedOut = true;
|
|
244
|
+
stopChild(child);
|
|
245
|
+
}, timeoutMs);
|
|
246
|
+
// 时间兜底:即使 CLI 静默不吐进度行,进度条也单调推进,不会卡在 0
|
|
247
|
+
progressTimer = setInterval(() => {
|
|
248
|
+
if (task && task.status === 'running' && task.progress < 85)
|
|
249
|
+
task.progress += 1;
|
|
250
|
+
}, 500);
|
|
251
|
+
const collect = (kind, chunk) => {
|
|
252
|
+
const text = chunk.toString();
|
|
253
|
+
if (kind === 'stdout')
|
|
254
|
+
stdout = (stdout + text).slice(-CAPTURE_LIMIT_BYTES);
|
|
255
|
+
else
|
|
256
|
+
stderr = (stderr + text).slice(-CAPTURE_LIMIT_BYTES);
|
|
257
|
+
if (task) {
|
|
258
|
+
// 原生透传 pnpm 输出:stdout/stderr 原样入日志,不做格式化,保证报错信息完整可见
|
|
259
|
+
for (const line of text.split(/\r?\n|\r/)) {
|
|
260
|
+
if (line.trim() !== '')
|
|
261
|
+
pushLine(task, line.trimEnd());
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
child.stdout?.on('data', (chunk) => collect('stdout', chunk));
|
|
266
|
+
child.stderr?.on('data', (chunk) => collect('stderr', chunk));
|
|
267
|
+
child.once('exit', () => {
|
|
268
|
+
// dsh 内部会 fork pnpm:pnpm 退出后仍攥着 stdout/stderr 管道时,
|
|
269
|
+
// close 事件永不到来(历史队列卡死根因——取消路径曾因此卡死,用整组 SIGKILL 解决;
|
|
270
|
+
// 自然失败路径没有兜底)。exit 后给 close 一段宽限,未按时触发则整组清理强制结束,
|
|
271
|
+
// 保证任务必然进入终态、后续排队任务自动启动。
|
|
272
|
+
exitWatch = setTimeout(() => {
|
|
273
|
+
if (settled)
|
|
274
|
+
return;
|
|
275
|
+
if (task && task.status !== 'cancelled')
|
|
276
|
+
pushLine(task, '[stale pipe] force closing child');
|
|
277
|
+
stopChild(child);
|
|
278
|
+
// 清理后极端情况下 close 仍未触发(kill 失败):再兜底强制收尾
|
|
279
|
+
forceSettle = setTimeout(() => {
|
|
280
|
+
if (settled)
|
|
281
|
+
return;
|
|
282
|
+
if (task && task.status !== 'cancelled') {
|
|
283
|
+
task.status = 'failed';
|
|
284
|
+
task.exitCode = null;
|
|
285
|
+
task.timedOut = true;
|
|
286
|
+
pushLine(task, '[timed out]');
|
|
287
|
+
}
|
|
288
|
+
settle({ exitCode: null, timedOut: true, error: 'child failed to close', stdout, stderr });
|
|
289
|
+
}, 5_000);
|
|
290
|
+
}, 3_000);
|
|
291
|
+
});
|
|
292
|
+
child.once('error', (error) => {
|
|
293
|
+
if (task && task.status !== 'cancelled') {
|
|
294
|
+
task.status = 'failed';
|
|
295
|
+
task.exitCode = null;
|
|
296
|
+
pushLine(task, `[error] ${error.message}`);
|
|
297
|
+
}
|
|
298
|
+
settle({ exitCode: null, timedOut: false, error: error.message, stdout, stderr });
|
|
299
|
+
});
|
|
300
|
+
child.once('close', async (code) => {
|
|
301
|
+
try {
|
|
302
|
+
if (task) {
|
|
303
|
+
task.exitCode = code;
|
|
304
|
+
task.timedOut = timedOut;
|
|
305
|
+
if (task.status !== 'cancelled') {
|
|
306
|
+
task.status = timedOut || code !== 0 ? 'failed' : 'done';
|
|
307
|
+
if (task.status === 'done') {
|
|
308
|
+
task.progress = 100;
|
|
309
|
+
if (action === 'remove' && uninstallLoader) {
|
|
310
|
+
// 卸载成功 → 主动从运行中 loader 移除该包条目,立即生效、无需重启;
|
|
311
|
+
// 移除失败(或从未加载过但 loader 不可用)才登记「待重启清理」兜底
|
|
312
|
+
const removed = await removeLoadedEntry(uninstallLoader, target);
|
|
313
|
+
if (removed)
|
|
314
|
+
clearPendingRestart(displayTarget ?? target);
|
|
315
|
+
else
|
|
316
|
+
addPendingRestart(displayTarget ?? target, 'uninstall');
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
// 安装成功(或卸载但无 loader 引用)→ 登记待重启:
|
|
320
|
+
// 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
|
|
321
|
+
addPendingRestart(displayTarget ?? target, action === 'add' ? 'install' : 'uninstall');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
pushLine(task, timedOut ? '[timed out]' : `[exit ${code ?? '?'}]`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
settle({ exitCode: code, timedOut, error: null, stdout, stderr });
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
|
|
336
|
+
* git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会
|
|
337
|
+
* ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。
|
|
338
|
+
* 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。
|
|
339
|
+
*/
|
|
340
|
+
export function verifyInstalledEntry(profile, target) {
|
|
341
|
+
let profilePkg = null;
|
|
342
|
+
try {
|
|
343
|
+
profilePkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return { name: null, missing: null };
|
|
347
|
+
}
|
|
348
|
+
const deps = (profilePkg?.dependencies ?? {});
|
|
349
|
+
// target 自身是依赖名(npm 包),或通过 github: specifier 反查依赖名
|
|
350
|
+
const name = deps[target] !== undefined ? target : Object.keys(deps).find((k) => deps[k] === target);
|
|
351
|
+
if (!name)
|
|
352
|
+
return { name: null, missing: null };
|
|
353
|
+
const pkgDir = join(profileDirectory(profile), 'node_modules', name);
|
|
354
|
+
let entry = null;
|
|
355
|
+
try {
|
|
356
|
+
const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
357
|
+
const dot = meta.exports?.['.'];
|
|
358
|
+
const resolved = typeof dot === 'string' ? dot
|
|
359
|
+
: dot !== null && typeof dot === 'object'
|
|
360
|
+
? dot.default
|
|
361
|
+
: undefined;
|
|
362
|
+
entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js';
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return { name, missing: null };
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
return { name, missing: statSync(join(pkgDir, entry)).isFile() ? null : entry };
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return { name, missing: entry };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Run a plugin mutation with one recovery path: when `add` fails because a
|
|
376
|
+
* git-hosted package's `prepare` script is blocked by pnpm's allowBuilds
|
|
377
|
+
* gate (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`), read the exact allowlist
|
|
378
|
+
* key pnpm printed, write it into the profile's pnpm-workspace.yaml, and
|
|
379
|
+
* retry once. Other failures fall through to the single-spawn result.
|
|
380
|
+
*/
|
|
381
|
+
export async function runPluginMutation(options) {
|
|
382
|
+
const result = await spawnMutation(options);
|
|
383
|
+
if (options.action === 'add' && result.exitCode !== 0 && !result.timedOut) {
|
|
384
|
+
const output = `${result.stderr}\n${result.stdout}`;
|
|
385
|
+
if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
|
|
386
|
+
const key = parseAllowBuildsKey(output);
|
|
387
|
+
if (key !== null) {
|
|
388
|
+
if (options.task) {
|
|
389
|
+
// First attempt already marked the task failed; reset it so the
|
|
390
|
+
// retry streams progress as a running task again.
|
|
391
|
+
options.task.status = 'running';
|
|
392
|
+
options.task.exitCode = null;
|
|
393
|
+
pushLine(options.task, `[allow build] ${key}`);
|
|
394
|
+
}
|
|
395
|
+
addAllowBuildsKey(options.profile, key);
|
|
396
|
+
return spawnMutation(options);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
|
|
401
|
+
// 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃
|
|
402
|
+
if (options.action === 'add' && result.exitCode === 0 && !result.timedOut) {
|
|
403
|
+
const verified = verifyInstalledEntry(options.profile, options.target);
|
|
404
|
+
if (verified.name !== null && verified.missing !== null) {
|
|
405
|
+
clearPendingRestart(options.displayTarget ?? options.target);
|
|
406
|
+
const detail = `[packaging] ${verified.name}: entry file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`;
|
|
407
|
+
if (options.task && options.task.status !== 'cancelled') {
|
|
408
|
+
options.task.status = 'failed';
|
|
409
|
+
options.task.exitCode = null;
|
|
410
|
+
options.task.progress = 0;
|
|
411
|
+
pushLine(options.task, detail);
|
|
412
|
+
}
|
|
413
|
+
return { exitCode: 1, timedOut: false, error: `entry file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { readProfileArg, type LoaderHandle } from '../services/install.ts';
|
|
3
|
+
export interface WebRoute {
|
|
4
|
+
kind: 'exact';
|
|
5
|
+
path: string;
|
|
6
|
+
handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/** The subset of the host web-server service this plugin touches. */
|
|
9
|
+
export interface WebServerService {
|
|
10
|
+
register(route: WebRoute): () => void;
|
|
11
|
+
}
|
|
12
|
+
/** Read non-official dependencies installed into one profile. */
|
|
13
|
+
export declare function readInstalled(profile: string): Record<string, string>;
|
|
14
|
+
/**
|
|
15
|
+
* Register the Plugin Hub API on the host web server and return a disposer.
|
|
16
|
+
* @param webServer - DSH web server service.
|
|
17
|
+
* @param profile - profile that owns plugin mutations.
|
|
18
|
+
* @param loader - running loader (optional): lets uninstall remove the entry
|
|
19
|
+
* immediately so the page survives a refresh without a host restart.
|
|
20
|
+
*/
|
|
21
|
+
export declare function mountPluginHubRoutes(webServer: WebServerService, profile: string, loader?: LoaderHandle): () => void;
|
|
22
|
+
/** Profile resolution shared with the client route docs. */
|
|
23
|
+
export { readProfileArg };
|