pi-web-ui 0.86.2 → 0.87.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.
- package/CHANGELOG.md +84 -23
- package/README.md +1 -1
- package/bin/pi-web-ui.mjs +312 -40
- package/dist/server/agent-service.js +377 -25
- package/dist/server/client-state.js +8 -0
- package/dist/server/composer-drafts.js +138 -0
- package/dist/server/dsh/dsh-agent-service.js +535 -54
- package/dist/server/dsh/dsh-client.js +28 -0
- package/dist/server/dsh/dsh-sessions.js +28 -0
- package/dist/server/dsh/dsh-usage.js +82 -0
- package/dist/server/dsh/preset-clones.js +260 -0
- package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
- package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
- package/dist/server/dsh/runtime/launcher.mjs +17 -0
- package/dist/server/dsh/runtime/override.patch.yml +19 -1
- package/dist/server/files-service.js +259 -1
- package/dist/server/index.js +244 -5
- package/dist/server/mcp-bridge.js +126 -22
- package/dist/server/mcp-hot-reload.js +117 -0
- package/dist/server/model-admin.js +93 -3
- package/dist/server/plugin-dom.js +83 -0
- package/dist/server/plugin-facilities.js +15 -1
- package/dist/server/plugin-installer.js +6 -0
- package/dist/server/plugins.js +781 -74
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/provider-oauth-flow.js +157 -0
- package/dist/server/update-check.js +22 -0
- package/package.json +1 -1
- package/plugins/catalog.json +9 -0
- package/themes/dark-teal.css +65 -22
- package/web/dist/assets/index-8xCnPMZP.js +374 -0
- package/web/dist/assets/index-F86qWlJy.css +41 -0
- package/web/dist/index.html +3 -2
- package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
- package/web/dist/assets/index-CKoVyDkP.css +0 -10
- package/web/dist/assets/index-Dmwji4Cr.js +0 -361
package/bin/pi-web-ui.mjs
CHANGED
|
@@ -43,11 +43,12 @@ import {
|
|
|
43
43
|
realpathSync,
|
|
44
44
|
readFileSync,
|
|
45
45
|
readdirSync,
|
|
46
|
+
renameSync,
|
|
46
47
|
rmSync,
|
|
47
48
|
writeFileSync,
|
|
48
49
|
} from "node:fs";
|
|
49
50
|
import { homedir, tmpdir, userInfo } from "node:os";
|
|
50
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
51
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
51
52
|
import { pathToFileURL } from "node:url";
|
|
52
53
|
import { fileURLToPath } from "node:url";
|
|
53
54
|
|
|
@@ -116,6 +117,10 @@ server 选项:
|
|
|
116
117
|
install 选项: --name <id> 自定义插件目录名(默认取仓库名)
|
|
117
118
|
--data-dir <dir> 数据目录(默认 ~/.pi-web)
|
|
118
119
|
--force 目标已存在时覆盖
|
|
120
|
+
--build 强制源码构建(隔离目录编译,见下)
|
|
121
|
+
--no-build 即使只有源码也不构建(产物缺失的插件装上后不加载)
|
|
122
|
+
--catalog <url> 目录同步模式:读目录文档 → 写可安装列表 → 逐条安装
|
|
123
|
+
--replace 配合 --catalog:整体替换列表(默认按 id 合并)
|
|
119
124
|
|
|
120
125
|
环境变量(flag 优先,环境变量后备):
|
|
121
126
|
PI_WEB_PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_WEB_ENGINE / PI_WEB_HOST /
|
|
@@ -163,6 +168,11 @@ UI plugins (installed into <data-dir>/plugins/; refresh browser to activate whil
|
|
|
163
168
|
install options: --name <id> Custom plugin directory name (default: repo name)
|
|
164
169
|
--data-dir <dir> Data directory (default: ~/.pi-web)
|
|
165
170
|
--force Overwrite if target already exists
|
|
171
|
+
--build Force a source build (isolated build, see below)
|
|
172
|
+
--no-build Never build, even for source-only plugins
|
|
173
|
+
--catalog <url> Catalog mode: read a catalog document, write the
|
|
174
|
+
installable list, then install every entry
|
|
175
|
+
--replace With --catalog: replace the whole list (default merges by id)
|
|
166
176
|
|
|
167
177
|
Environment variables (flag takes precedence, env var as fallback):
|
|
168
178
|
PI_WEB_PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_WEB_ENGINE / PI_WEB_HOST /
|
|
@@ -273,6 +283,15 @@ function parseFlags(argv) {
|
|
|
273
283
|
case "--build":
|
|
274
284
|
opts.build = true;
|
|
275
285
|
break;
|
|
286
|
+
case "--no-build":
|
|
287
|
+
opts.noBuild = true;
|
|
288
|
+
break;
|
|
289
|
+
case "--catalog":
|
|
290
|
+
opts.catalog = take("--catalog");
|
|
291
|
+
break;
|
|
292
|
+
case "--replace":
|
|
293
|
+
opts.replace = true;
|
|
294
|
+
break;
|
|
276
295
|
case "--check-updates":
|
|
277
296
|
opts.checkUpdates = true;
|
|
278
297
|
break;
|
|
@@ -1441,6 +1460,7 @@ const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
|
1441
1460
|
|
|
1442
1461
|
const PLUGIN_HELP = `用法:
|
|
1443
1462
|
pi-web-ui install <源> [选项] 安装 GitHub 上的界面插件
|
|
1463
|
+
pi-web-ui install --catalog <目录> [选项] 同步插件市场目录并逐条安装
|
|
1444
1464
|
pi-web-ui uninstall <id> [选项] 卸载已安装的界面插件
|
|
1445
1465
|
pi-web-ui plugins [选项] 列出已安装的界面插件
|
|
1446
1466
|
|
|
@@ -1449,13 +1469,21 @@ const PLUGIN_HELP = `用法:
|
|
|
1449
1469
|
https://github.com/owner/repo 完整 URL(.git 可省)
|
|
1450
1470
|
https://github.com/o/r/tree/dev/sub/dir 指定分支 + 仓库内子目录
|
|
1451
1471
|
以上任意写法末尾加 #分支或tag 指定分支/tag(如 owner/repo#v1.2)
|
|
1452
|
-
/path/to/plugin-dir
|
|
1472
|
+
/path/to/plugin-dir 本地目录(离线开发调试)
|
|
1473
|
+
目录写法(--catalog 用):
|
|
1474
|
+
https://example.com/catalog.json 远端目录文档(数组或 {entries:[...]})
|
|
1475
|
+
/path/to/catalog.json 本地目录文档(绝对路径)
|
|
1476
|
+
install 选项:
|
|
1453
1477
|
--name <id> 插件目录名/id(默认取仓库名或 manifest.id,仅限字母数字-_)
|
|
1454
1478
|
--data-dir <dir> 数据目录(默认 ~/.pi-web 或 $PI_WEB_DATA_DIR)
|
|
1455
1479
|
--force 目标目录已存在时覆盖(覆盖前自动备份旧版本)
|
|
1456
|
-
--build
|
|
1480
|
+
--build 强制源码构建:在隔离临时目录里按插件声明安装构建依赖并编译
|
|
1457
1481
|
(manifest.build 或 package.json 的 scripts.build),
|
|
1458
|
-
成功且产物齐全后才替换目标目录,失败不留半装状态
|
|
1482
|
+
成功且产物齐全后才替换目标目录,失败不留半装状态
|
|
1483
|
+
--no-build 即使插件只有源码也不构建(与 --build 互斥;装出来的插件因缺产物不会被加载)
|
|
1484
|
+
--catalog <目录> 目录同步模式:读目录文档 → 原子写入可安装列表 → 逐条安装/更新
|
|
1485
|
+
(已安装的条目默认跳过,加 --force 则更新;单条失败不中断整批)
|
|
1486
|
+
--replace 配合 --catalog:整体替换可安装列表(默认按 id 合并,保留旧条目)
|
|
1459
1487
|
|
|
1460
1488
|
plugins 选项:
|
|
1461
1489
|
--check-updates 逐个对比最近安装版本与远端 HEAD,列出可更新插件
|
|
@@ -1656,30 +1684,50 @@ function buildPluginSource(pluginRoot, tmpDir, manifest) {
|
|
|
1656
1684
|
return buildDir;
|
|
1657
1685
|
}
|
|
1658
1686
|
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1687
|
+
/**
|
|
1688
|
+
* 构建决策(issue #165:--build 自动推断)。
|
|
1689
|
+
*
|
|
1690
|
+
* 只有源码、没有产物(index.mjs 与 client/entry.mjs 都缺)且存在可解析的构建声明 =
|
|
1691
|
+
* “不构建这次安装必死”,此时默认直接构建( previously 只打印一行提示,等用户滚回去重加
|
|
1692
|
+
* --build)。产物已提交的仓库不受影响(mode=none,什么都不跑)。
|
|
1693
|
+
* --no-build 保留旧的“装个空目录”行为(脚本化镜像/检查用),并明确打印跳过原因。
|
|
1694
|
+
*/
|
|
1695
|
+
function decideBuildAction({ pluginRoot, manifest, build, noBuild }) {
|
|
1696
|
+
if (build && noBuild) throw new Error("--build 与 --no-build 不能同时用(二选一)");
|
|
1697
|
+
const plan = resolveBuildPlan(pluginRoot, manifest);
|
|
1698
|
+
const artifactsMissing =
|
|
1699
|
+
!existsSync(join(pluginRoot, "index.mjs")) && !existsSync(join(pluginRoot, "client", "entry.mjs"));
|
|
1700
|
+
if (build) {
|
|
1701
|
+
if (!plan)
|
|
1702
|
+
throw new Error(
|
|
1703
|
+
"插件没有声明构建方式:请在 manifest.json 里加 build.command(或 package.json 的 scripts.build),或去掉 --build",
|
|
1704
|
+
);
|
|
1705
|
+
return { mode: "explicit", plan };
|
|
1664
1706
|
}
|
|
1665
|
-
if (
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1707
|
+
if (plan && artifactsMissing) {
|
|
1708
|
+
if (noBuild) return { mode: "skipped", plan };
|
|
1709
|
+
return { mode: "auto", plan };
|
|
1710
|
+
}
|
|
1711
|
+
return { mode: "none", plan };
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* 装一个插件(单源模式与目录模式共用):拉取/定位 → 读 manifest → 构建决策 →
|
|
1716
|
+
* 覆盖(备份+保留 config.json)→ 落盘 → 记录来源/sha。
|
|
1717
|
+
* 失败抛 Error(目录模式逐条 try/catch 继续下一条,单源模式由调用方转 fail)。
|
|
1718
|
+
*/
|
|
1719
|
+
async function installOnePlugin({ rawSpec, name, force, build, noBuild, dataDir }) {
|
|
1720
|
+
const pluginsDir = join(dataDir, "plugins");
|
|
1670
1721
|
const localCandidate = resolve(rawSpec.replace(/^file:\/\//, ""));
|
|
1671
1722
|
const isLocal = existsSync(localCandidate);
|
|
1672
1723
|
const src = isLocal ? null : parsePluginSource(rawSpec);
|
|
1673
1724
|
const tmp = mkdtempSync(join(tmpdir(), "pi-web-ui-plugin-"));
|
|
1674
|
-
let backupTs = null;
|
|
1675
1725
|
try {
|
|
1676
1726
|
let checkout;
|
|
1677
1727
|
try {
|
|
1678
1728
|
checkout = isLocal ? localCandidate : await acquireRepo(src, tmp);
|
|
1679
1729
|
} catch (err) {
|
|
1680
|
-
|
|
1681
|
-
process.exitCode = 1;
|
|
1682
|
-
return;
|
|
1730
|
+
throw new Error(`${err?.message ?? err}`);
|
|
1683
1731
|
}
|
|
1684
1732
|
const repoLabel = isLocal ? localCandidate : `${src.owner}/${src.repo}`;
|
|
1685
1733
|
const pluginRoot = locatePluginRoot(checkout, src?.subpath, repoLabel);
|
|
@@ -1687,25 +1735,23 @@ async function pluginInstallCmd(argv) {
|
|
|
1687
1735
|
try {
|
|
1688
1736
|
manifest = JSON.parse(readFileSync(join(pluginRoot, "manifest.json"), "utf8"));
|
|
1689
1737
|
} catch (err) {
|
|
1690
|
-
|
|
1738
|
+
throw new Error(`manifest.json 不是合法 JSON:${err?.message ?? err}`);
|
|
1691
1739
|
}
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
1740
|
+
// 构建决策(issue #150 的 --build + issue #165 的自动推断):构建在临时目录里完成,
|
|
1741
|
+
// 成功后才进入覆盖流程——构建失败 = 目标目录完全没被动过(上一版插件照常可用)。
|
|
1742
|
+
const decision = decideBuildAction({ pluginRoot, manifest, build: build === true, noBuild: noBuild === true });
|
|
1694
1743
|
let installRoot = pluginRoot;
|
|
1695
|
-
if (
|
|
1744
|
+
if (decision.mode === "explicit" || decision.mode === "auto") {
|
|
1745
|
+
console.log(
|
|
1746
|
+
`· 源码构建(${decision.mode === "auto" ? "自动推断:有构建声明但无产物" : "--build"}):先 ${decision.plan.install},再 ${decision.plan.command}`,
|
|
1747
|
+
);
|
|
1696
1748
|
try {
|
|
1697
1749
|
installRoot = buildPluginSource(pluginRoot, tmp, manifest);
|
|
1698
1750
|
} catch (err) {
|
|
1699
|
-
|
|
1700
|
-
console.error(`✖ ${err?.message ?? err}`);
|
|
1701
|
-
return;
|
|
1751
|
+
throw new Error(`${err?.message ?? err}`);
|
|
1702
1752
|
}
|
|
1703
|
-
} else if (
|
|
1704
|
-
|
|
1705
|
-
!existsSync(join(pluginRoot, "index.mjs")) &&
|
|
1706
|
-
!existsSync(join(pluginRoot, "client", "entry.mjs"))
|
|
1707
|
-
) {
|
|
1708
|
-
console.log("· 这个插件声明了构建、但目录里还没有产物:加 --build 可在安装时构建");
|
|
1753
|
+
} else if (decision.mode === "skipped") {
|
|
1754
|
+
console.log("· 跳过构建(--no-build):目录里没有产物,装上后该插件不会被加载");
|
|
1709
1755
|
}
|
|
1710
1756
|
// 默认 id:子目录名 > 仓库名 > 本地目录名
|
|
1711
1757
|
const sourceName = src?.subpath ? src.subpath.split("/").pop() : (src?.repo ?? localCandidate.split(/[\\/]/).pop());
|
|
@@ -1713,16 +1759,17 @@ async function pluginInstallCmd(argv) {
|
|
|
1713
1759
|
String(manifest.id ?? sourceName)
|
|
1714
1760
|
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
1715
1761
|
.replace(/^-+|-+$/g, "") || "plugin";
|
|
1716
|
-
const id =
|
|
1717
|
-
if (!PLUGIN_ID_RE.test(id))
|
|
1762
|
+
const id = name ?? fallbackId;
|
|
1763
|
+
if (!PLUGIN_ID_RE.test(id)) throw new Error(`非法插件 id "${id}"(仅限字母数字-_,可用 --name <id> 自定义)`);
|
|
1718
1764
|
const target = join(pluginsDir, id);
|
|
1765
|
+
let backupTs = null;
|
|
1719
1766
|
let prevConfig = null;
|
|
1720
1767
|
const CONFIG_NAME = "config.json";
|
|
1721
1768
|
if (existsSync(target)) {
|
|
1722
|
-
if (!
|
|
1769
|
+
if (!force) throw new Error(`插件目录已存在:${target}\n 加 --force 覆盖,或用 --name <id> 换个名字。`);
|
|
1723
1770
|
// 更新前备份旧版本(<dataDir>/plugin-backups/<id>-<ts>/,保留最近 3 份),
|
|
1724
1771
|
// 失败时自动回滚。备份与安装同 filter:不带 .git/node_modules。
|
|
1725
|
-
backupTs = ensurePluginBackup(
|
|
1772
|
+
backupTs = ensurePluginBackup(dataDir, id, { source: rawSpec });
|
|
1726
1773
|
// 插件凭据/配置不因升级丢失:先取出旧 config.json,拷完新文件后原样放回
|
|
1727
1774
|
try {
|
|
1728
1775
|
prevConfig = readFileSync(join(target, CONFIG_NAME), "utf8");
|
|
@@ -1736,10 +1783,10 @@ async function pluginInstallCmd(argv) {
|
|
|
1736
1783
|
cpSync(installRoot, target, { recursive: true, filter: PLUGIN_COPY_FILTER });
|
|
1737
1784
|
} catch (err) {
|
|
1738
1785
|
// 拷贝失败 → 有备份则自动回滚,保持旧版本可用
|
|
1739
|
-
if (backupTs && restorePluginBackup(
|
|
1740
|
-
|
|
1786
|
+
if (backupTs && restorePluginBackup(dataDir, id)) {
|
|
1787
|
+
throw new Error(`插件更新失败:${err?.message ?? err}\n 已自动回滚到更新前版本。`);
|
|
1741
1788
|
}
|
|
1742
|
-
|
|
1789
|
+
throw new Error(`插件更新失败:${err?.message ?? err}\n (无可用备份,请重新 install --force)`);
|
|
1743
1790
|
}
|
|
1744
1791
|
if (prevConfig !== null && !existsSync(join(target, CONFIG_NAME))) {
|
|
1745
1792
|
writeFileSync(join(target, CONFIG_NAME), prevConfig);
|
|
@@ -1758,14 +1805,239 @@ async function pluginInstallCmd(argv) {
|
|
|
1758
1805
|
} catch {
|
|
1759
1806
|
/* 尽力而为 */
|
|
1760
1807
|
}
|
|
1808
|
+
return { id, target, manifest, buildMode: decision.mode };
|
|
1809
|
+
} finally {
|
|
1810
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* 目录文档校验(issue #165 的 CLI --catalog)。
|
|
1816
|
+
*
|
|
1817
|
+
* 规则与 server/plugin-catalog.ts 的 toEntry 对齐(id 字符集、source 形状、字段裁剪),
|
|
1818
|
+
* 仅放宽一条:CLI 跑在使用者的本机信任上下文里,允许已存在的本地目录源(离线开发、
|
|
1819
|
+
* 本地目录同步);服务端 toEntry(网络/插件触发)仍只收远端源。两边规则若漂移,
|
|
1820
|
+
* tests/plugin-catalog-cli-test.mjs 的行为断言会先响(离线全链路)。
|
|
1821
|
+
*/
|
|
1822
|
+
function isCatalogEntrySource(s) {
|
|
1823
|
+
if (!s || s.length > 300) return false;
|
|
1824
|
+
// 本地目录:CLI 才放行(存在性检查,file:// 前缀兼容 install 单源写法)
|
|
1825
|
+
try {
|
|
1826
|
+
if (existsSync(resolve(s.replace(/^file:\/\//, "")))) return true;
|
|
1827
|
+
} catch {
|
|
1828
|
+
/* 非法路径字符:走下面的远端规则 */
|
|
1829
|
+
}
|
|
1830
|
+
if (/^https?:\/\//.test(s)) return true;
|
|
1831
|
+
const spec = s.split("#")[0].replace(/\/+$/, "");
|
|
1832
|
+
const segs = spec.split("/").filter(Boolean);
|
|
1833
|
+
if (segs.length < 2) return false;
|
|
1834
|
+
for (const seg of segs) if (seg === "." || seg === "..") return false;
|
|
1835
|
+
return true;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function deriveCatalogEntryId(rawId, source) {
|
|
1839
|
+
if (rawId && PLUGIN_ID_RE.test(rawId)) return rawId;
|
|
1840
|
+
try {
|
|
1841
|
+
const local = resolve(source.replace(/^file:\/\//, ""));
|
|
1842
|
+
if (existsSync(local)) {
|
|
1843
|
+
const base = local.split(/[\\/]/).pop() || "plugin";
|
|
1844
|
+
const cleaned = base.replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
1845
|
+
return cleaned || "plugin";
|
|
1846
|
+
}
|
|
1847
|
+
} catch {
|
|
1848
|
+
/* 走远端规则 */
|
|
1849
|
+
}
|
|
1850
|
+
const spec = source.split("#")[0].replace(/\/+$/, "");
|
|
1851
|
+
const segs = spec.split("/").filter(Boolean);
|
|
1852
|
+
const last = segs.length >= 2 ? segs[segs.length - 1] : (segs[0] ?? "plugin");
|
|
1853
|
+
const cleaned = last.replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
1854
|
+
return cleaned || "plugin";
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function catalogEntryFromRaw(raw) {
|
|
1858
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1859
|
+
const source = typeof raw.source === "string" ? raw.source.trim() : "";
|
|
1860
|
+
if (!isCatalogEntrySource(source)) return null;
|
|
1861
|
+
const id = deriveCatalogEntryId(typeof raw.id === "string" ? raw.id.trim() : undefined, source);
|
|
1862
|
+
if (!PLUGIN_ID_RE.test(id)) return null;
|
|
1863
|
+
const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
1864
|
+
const name = str(raw.name) ?? id;
|
|
1865
|
+
const description = str(raw.description);
|
|
1866
|
+
const descriptionEn = str(raw.descriptionEn);
|
|
1867
|
+
const icon = str(raw.icon);
|
|
1868
|
+
const homepage = str(raw.homepage);
|
|
1869
|
+
return {
|
|
1870
|
+
id,
|
|
1871
|
+
name,
|
|
1872
|
+
source,
|
|
1873
|
+
...(description ? { description } : {}),
|
|
1874
|
+
...(descriptionEn ? { descriptionEn } : {}),
|
|
1875
|
+
...(icon ? { icon } : {}),
|
|
1876
|
+
...(homepage ? { homepage } : {}),
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/** 目录文档形状:JSON 数组或 {entries:[...]};非法条目丢弃并计数(与服务端同语义)。 */
|
|
1881
|
+
function normalizeCatalogEntries(raw) {
|
|
1882
|
+
const list = Array.isArray(raw)
|
|
1883
|
+
? raw
|
|
1884
|
+
: raw && typeof raw === "object" && Array.isArray(raw.entries)
|
|
1885
|
+
? raw.entries
|
|
1886
|
+
: null;
|
|
1887
|
+
if (!list) throw new Error('目录 JSON 需为数组,或 {"entries": [...]} 形状');
|
|
1888
|
+
const entries = [];
|
|
1889
|
+
let skipped = 0;
|
|
1890
|
+
for (const it of list) {
|
|
1891
|
+
const e = catalogEntryFromRaw(it);
|
|
1892
|
+
if (e) entries.push(e);
|
|
1893
|
+
else skipped++;
|
|
1894
|
+
}
|
|
1895
|
+
return { entries, skipped };
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/** 读目录文档:http(s) 拉取(30s 超时),其余当本地绝对路径(与服务端同口径)。 */
|
|
1899
|
+
async function readCatalogDocumentText(source) {
|
|
1900
|
+
const src = String(source ?? "").trim();
|
|
1901
|
+
if (!src) throw new Error("缺少目录来源(--catalog <url 或本地绝对路径>)");
|
|
1902
|
+
if (/^https?:\/\//i.test(src)) {
|
|
1903
|
+
let res;
|
|
1904
|
+
try {
|
|
1905
|
+
res = await fetch(src, { redirect: "follow", signal: AbortSignal.timeout(30_000) });
|
|
1906
|
+
} catch (err) {
|
|
1907
|
+
throw new Error(`拉取目录失败:${err?.message ?? err}`);
|
|
1908
|
+
}
|
|
1909
|
+
if (!res.ok) throw new Error(`拉取目录失败:HTTP ${res.status}`);
|
|
1910
|
+
const text = await res.text();
|
|
1911
|
+
if (text.length > 1024 * 1024) throw new Error("目录文档过大(> 1024 KB)");
|
|
1912
|
+
return text;
|
|
1913
|
+
}
|
|
1914
|
+
if (!isAbsolute(src)) throw new Error("本地目录文档需为绝对路径(远端用 http(s) URL)");
|
|
1915
|
+
try {
|
|
1916
|
+
return readFileSync(src, "utf8");
|
|
1917
|
+
} catch (err) {
|
|
1918
|
+
throw new Error(`读取目录文件失败:${err?.message ?? err}`);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function readCatalogFileEntries(customPath) {
|
|
1923
|
+
try {
|
|
1924
|
+
const raw = JSON.parse(readFileSync(customPath, "utf8"));
|
|
1925
|
+
if (raw && typeof raw === "object" && Array.isArray(raw.entries)) return raw.entries;
|
|
1926
|
+
} catch {
|
|
1927
|
+
/* 无文件/坏文件 = 空列表 */
|
|
1928
|
+
}
|
|
1929
|
+
return [];
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function writeCatalogFileEntries(customPath, entries) {
|
|
1933
|
+
mkdirSync(dirname(customPath), { recursive: true });
|
|
1934
|
+
const tmp = `${customPath}.tmp-${process.pid}`;
|
|
1935
|
+
writeFileSync(tmp, JSON.stringify({ entries }, null, 2) + "\n");
|
|
1936
|
+
renameSync(tmp, customPath);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/** install --catalog <url>:同步可安装列表 + 逐条安装/更新(单条失败不中断整批)。 */
|
|
1940
|
+
async function installCatalogCmd(opts) {
|
|
1941
|
+
const dataDir = pluginDataDir(opts);
|
|
1942
|
+
const customPath = join(dataDir, "plugin-catalog.json");
|
|
1943
|
+
const pluginsDir = join(dataDir, "plugins");
|
|
1944
|
+
const text = await readCatalogDocumentText(opts.catalog).catch((err) => fail(`${err?.message ?? err}`));
|
|
1945
|
+
let raw;
|
|
1946
|
+
try {
|
|
1947
|
+
raw = JSON.parse(text);
|
|
1948
|
+
} catch (err) {
|
|
1949
|
+
fail(`目录 JSON 解析失败:${err?.message ?? err}`);
|
|
1950
|
+
}
|
|
1951
|
+
let entries;
|
|
1952
|
+
let skipped = 0;
|
|
1953
|
+
try {
|
|
1954
|
+
({ entries, skipped } = normalizeCatalogEntries(raw));
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
fail(`${err?.message ?? err}`);
|
|
1957
|
+
}
|
|
1958
|
+
// 到这里才动磁盘:形状不对的文档绝不覆盖有效列表(与服务端同纪律)。
|
|
1959
|
+
if (opts.replace) {
|
|
1960
|
+
writeCatalogFileEntries(customPath, entries);
|
|
1961
|
+
} else {
|
|
1962
|
+
const prev = readCatalogFileEntries(customPath).filter((x) => x && typeof x === "object");
|
|
1963
|
+
const next = [...prev];
|
|
1964
|
+
for (const e of entries) {
|
|
1965
|
+
const idx = next.findIndex((x) => x.id === e.id);
|
|
1966
|
+
if (idx >= 0) next[idx] = e;
|
|
1967
|
+
else next.push(e);
|
|
1968
|
+
}
|
|
1969
|
+
writeCatalogFileEntries(customPath, next);
|
|
1970
|
+
}
|
|
1971
|
+
console.log(
|
|
1972
|
+
`· 目录同步:${entries.length} 条合法${skipped ? `(丢弃 ${skipped} 条非法)` : ""}${opts.replace ? "(整体替换)" : "(按 id 合并)"} → ${customPath}`,
|
|
1973
|
+
);
|
|
1974
|
+
let okCount = 0;
|
|
1975
|
+
let failCount = 0;
|
|
1976
|
+
let skipCount = 0;
|
|
1977
|
+
for (const e of entries) {
|
|
1978
|
+
if (existsSync(join(pluginsDir, e.id)) && !opts.force) {
|
|
1979
|
+
console.log(`· 跳过 ${e.id}(已安装,加 --force 更新)`);
|
|
1980
|
+
skipCount++;
|
|
1981
|
+
continue;
|
|
1982
|
+
}
|
|
1983
|
+
try {
|
|
1984
|
+
await installOnePlugin({
|
|
1985
|
+
rawSpec: e.source,
|
|
1986
|
+
name: e.id,
|
|
1987
|
+
force: true,
|
|
1988
|
+
build: opts.build === true,
|
|
1989
|
+
noBuild: opts.noBuild === true,
|
|
1990
|
+
dataDir,
|
|
1991
|
+
});
|
|
1992
|
+
console.log(`✔ ${e.id} 安装成功`);
|
|
1993
|
+
okCount++;
|
|
1994
|
+
} catch (err) {
|
|
1995
|
+
console.error(`✖ ${e.id} 安装失败:${err?.message ?? err}`);
|
|
1996
|
+
failCount++;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
console.log(`✔ 目录安装完成:${okCount} 成功 / ${failCount} 失败 / ${skipCount} 跳过(已安装)`);
|
|
2000
|
+
if (failCount) process.exitCode = 1;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
async function pluginInstallCmd(argv) {
|
|
2004
|
+
const { opts, positionals } = parseFlags(argv);
|
|
2005
|
+
if (opts.help) {
|
|
2006
|
+
console.log(PLUGIN_HELP);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
// 目录同步模式(issue #165):install --catalog <url> —— 读目录文档 → 校验 →
|
|
2010
|
+
// 原子写盘 → 逐条安装/更新(与服务端 plugin-catalog-sync 同语义)。
|
|
2011
|
+
if (opts.catalog !== undefined) {
|
|
2012
|
+
if (positionals.length !== 0)
|
|
2013
|
+
fail(
|
|
2014
|
+
`用法: pi-web-ui install --catalog <目录> [--data-dir <dir>] [--force] [--build|--no-build] [--replace]\n${PLUGIN_HELP}`,
|
|
2015
|
+
);
|
|
2016
|
+
if (opts.name) fail("--catalog 模式下 --name 无意义(目录名/id 来自目录条目)");
|
|
2017
|
+
await installCatalogCmd(opts);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (positionals.length !== 1)
|
|
2021
|
+
fail(
|
|
2022
|
+
`用法: pi-web-ui install <源> [--name <id>] [--data-dir <dir>] [--force] [--build|--no-build]\n${PLUGIN_HELP}`,
|
|
2023
|
+
);
|
|
2024
|
+
try {
|
|
2025
|
+
const { id, target, manifest } = await installOnePlugin({
|
|
2026
|
+
rawSpec: positionals[0],
|
|
2027
|
+
name: opts.name,
|
|
2028
|
+
force: opts.force === true,
|
|
2029
|
+
build: opts.build === true,
|
|
2030
|
+
noBuild: opts.noBuild === true,
|
|
2031
|
+
dataDir: pluginDataDir(opts),
|
|
2032
|
+
});
|
|
1761
2033
|
console.log(
|
|
1762
2034
|
`✔ 已安装插件 ${id}${manifest.name && manifest.name !== id ? `(${manifest.name})` : ""}${manifest.version ? ` v${manifest.version}` : ""}`,
|
|
1763
2035
|
);
|
|
1764
2036
|
if (manifest.description) console.log(` ${manifest.description}`);
|
|
1765
2037
|
console.log(` 位置: ${target}`);
|
|
1766
2038
|
console.log(` 生效: 服务运行中刷新浏览器即可加载;未运行则下次启动生效。卸载: pi-web-ui uninstall ${id}`);
|
|
1767
|
-
}
|
|
1768
|
-
|
|
2039
|
+
} catch (err) {
|
|
2040
|
+
fail(`${err?.message ?? err}`);
|
|
1769
2041
|
}
|
|
1770
2042
|
}
|
|
1771
2043
|
|