@mrrisega/dsh-remote 0.6.7-beta.1 → 0.6.7-beta.2
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/dsh-setup.mjs
CHANGED
|
@@ -161,6 +161,7 @@ function printHelp() {
|
|
|
161
161
|
dsh-remote run 前台运行 bridge(调试)
|
|
162
162
|
dsh-remote status 查看配置与服务状态
|
|
163
163
|
dsh-remote plugin 手动安装 dsh web 远程控制插件(--uninstall 卸载)
|
|
164
|
+
dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
|
|
164
165
|
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
165
166
|
|
|
166
167
|
自建模式(可选):
|
|
@@ -1473,34 +1474,143 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
1473
1474
|
}
|
|
1474
1475
|
|
|
1475
1476
|
/** 把插件挂到 <profile>/node_modules/<PLUGIN_ID>(裸包名解析需要),指向拷贝目录;缺失/指错时重建。 */
|
|
1477
|
+
/**
|
|
1478
|
+
* 判定 `node_modules/<PLUGIN_ID>` 是否**真的可用**。
|
|
1479
|
+
*
|
|
1480
|
+
* 判据绝不是"函数有没有抛错" —— 这是 0.6.7-beta.1 在 Windows 上翻车的根因:
|
|
1481
|
+
* 非特权进程 + 开发者模式关闭时,`fs.symlinkSync(target, path, "dir")` 会**既不抛错也产不出可用链接**
|
|
1482
|
+
* (实测:用户目录下留下一个空目录;%TEMP% 下什么都不留;同一位置两次运行一次报成功一次抛 UNKNOWN)。
|
|
1483
|
+
* 于是"用 try/catch 探测能力"这个前提在 Windows 上根本不成立,写在其 catch 里的 junction 兜底永不执行。
|
|
1484
|
+
*
|
|
1485
|
+
* 这里只认 dsh 自己那条解析路径能不能走通:
|
|
1486
|
+
* ① 读得到 package.json 且 name 正确(cordis 加载器 + @deepseek-ai/dsh-client-modules 靠它读 dsh.client);
|
|
1487
|
+
* ② 读得到 lib/index.js(节点半入口);
|
|
1488
|
+
* ③ 要么 realpath 指回本地拷贝目录(链接形态),要么入口内容与本地拷贝逐字节一致(整目录拷贝形态)。
|
|
1489
|
+
*/
|
|
1490
|
+
function pluginLinkUsable(nmPlugin, pluginLocalDir) {
|
|
1491
|
+
try {
|
|
1492
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(nmPlugin, "package.json"), "utf8"));
|
|
1493
|
+
if (pkg.name !== PLUGIN_ID) return false;
|
|
1494
|
+
if (!fs.existsSync(path.join(nmPlugin, "lib", "index.js"))) return false;
|
|
1495
|
+
} catch { return false; }
|
|
1496
|
+
|
|
1497
|
+
let real = "";
|
|
1498
|
+
try { real = fs.realpathSync(nmPlugin); } catch { /* 悬空链接 */ }
|
|
1499
|
+
if (real) {
|
|
1500
|
+
try { if (real === fs.realpathSync(pluginLocalDir)) return true; } catch { /* 本地目录不在 → 看内容 */ }
|
|
1501
|
+
}
|
|
1502
|
+
try {
|
|
1503
|
+
return fs.readFileSync(path.join(nmPlugin, "lib", "index.js"), "utf8")
|
|
1504
|
+
=== fs.readFileSync(path.join(pluginLocalDir, "lib", "index.js"), "utf8");
|
|
1505
|
+
} catch { return false; }
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* 把插件挂到 `<profile>/node_modules/<PLUGIN_ID>`。
|
|
1510
|
+
*
|
|
1511
|
+
* 规格:已就绪返回 false(幂等)、重建成功返回 true、**任何情况下都没能挂好就抛错**。
|
|
1512
|
+
* 抛错是刻意的 —— 调用方必须据此中止安装:写进 patch 的激活行会让 dsh web 按裸包名解析这个目录,
|
|
1513
|
+
* 解析不到就 `Cannot find package '...\node_modules\dsh-remote-web\index.js'`,
|
|
1514
|
+
* 而 cordis 的插件树加载失败 = **整个 dsh web 起不来**(用户连面板都打不开,没有自助修复入口)。
|
|
1515
|
+
* 宁可不装,也不能写出一个起不来的 profile。
|
|
1516
|
+
*/
|
|
1476
1517
|
function ensurePluginLinked(profileDir, pluginLocalDir) {
|
|
1477
1518
|
const nmDir = path.join(profileDir, "node_modules");
|
|
1478
1519
|
const nmPlugin = path.join(nmDir, PLUGIN_ID);
|
|
1520
|
+
if (!fs.existsSync(path.join(pluginLocalDir, "package.json"))) {
|
|
1521
|
+
throw new Error(`本地插件拷贝缺失或不完整:${pluginLocalDir}(应含 package.json)`);
|
|
1522
|
+
}
|
|
1479
1523
|
fs.mkdirSync(nmDir, { recursive: true });
|
|
1480
1524
|
// 清理历史名链接(node_modules/dsh-remote-ui),避免解析到旧拷贝
|
|
1481
1525
|
for (const id of PLUGIN_LEGACY_IDS) {
|
|
1482
1526
|
try { fs.rmSync(path.join(nmDir, id), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1483
1527
|
}
|
|
1528
|
+
if (pluginLinkUsable(nmPlugin, pluginLocalDir)) return false; // 已就绪(拷贝形态也算就绪)
|
|
1529
|
+
|
|
1530
|
+
// ⚠️ Windows 上**不要用 symlinkSync(...,'dir')**:非特权进程下它产不出可用链接,
|
|
1531
|
+
// 而且抛不抛错不确定、失败时还会留下空目录残留 —— 兜底链会因此整条失效(实测)。
|
|
1532
|
+
// junction(目录联接)免特权、realpath 正确、require.resolve 与 ESM 裸包名解析都正常。
|
|
1533
|
+
const attempts = process.platform === "win32"
|
|
1534
|
+
? [
|
|
1535
|
+
["junction", () => fs.symlinkSync(path.resolve(pluginLocalDir), nmPlugin, "junction")], // junction 只接受绝对路径
|
|
1536
|
+
["copy", () => fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true })],
|
|
1537
|
+
]
|
|
1538
|
+
: [
|
|
1539
|
+
["dir", () => fs.symlinkSync(pluginLocalDir, nmPlugin, "dir")],
|
|
1540
|
+
["copy", () => fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true })],
|
|
1541
|
+
];
|
|
1542
|
+
|
|
1543
|
+
const failures = [];
|
|
1544
|
+
for (const [how, run] of attempts) {
|
|
1545
|
+
// 每支开始前先清残留:上一支"没抛错但留了个空目录"时,不清就会让下一支撞 EEXIST 而失败
|
|
1546
|
+
//(这正是原实现三级兜底退化成一级的原因)。
|
|
1547
|
+
try { fs.rmSync(nmPlugin, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1548
|
+
let err = null;
|
|
1549
|
+
try { run(); } catch (e) { err = e; }
|
|
1550
|
+
if (pluginLinkUsable(nmPlugin, pluginLocalDir)) return true;
|
|
1551
|
+
failures.push(`${how}: ${err ? err.message : "未抛错但落盘结果不可用(读不到 package.json / lib/index.js)"}`);
|
|
1552
|
+
}
|
|
1553
|
+
throw new Error(`插件挂载失败:${nmPlugin}\n ${failures.join("\n ")}`);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* 独立验收:复刻 dsh 客户端模块系统的真实动作 —— 从 profile 目录按**裸包名**解析到本插件。
|
|
1559
|
+
*
|
|
1560
|
+
* 与 pluginLinkUsable 是两道独立的闸门:前者看文件在不在、是不是真指向目标;
|
|
1561
|
+
* 这条走 Node 的解析器(exports 字段、symlink 跟随、pnpm 布局),是"面板能不能被注入"的前提
|
|
1562
|
+
* (@deepseek-ai/dsh-client-modules 就是 require.resolve('<name>/package.json') 读 dsh.client)。
|
|
1563
|
+
*/
|
|
1564
|
+
function probePluginResolve(profileDir) {
|
|
1565
|
+
const base = path.join(profileDir, "__dsh_resolve_probe.cjs");
|
|
1566
|
+
// 必须**另起一个 node 进程**做这次解析,不能在本进程里 require.resolve:
|
|
1567
|
+
// 实测(Node 25 / macOS):本进程里只要先失败过一次裸包名解析(例如"修复前诊断"那次),
|
|
1568
|
+
// 链接随后修好了,同一个进程里的裸包名解析**仍会持续失败**(子路径却正常)——
|
|
1569
|
+
// 进程内的解析缓存会把假阴性一直带下去,让"修复后验收"误判成没修好、拒绝恢复激活。
|
|
1570
|
+
// 另起进程同时也更忠实:dsh 启动时就是在一个全新进程里解析这些裸包名的。
|
|
1571
|
+
const script = [
|
|
1572
|
+
'const { createRequire } = require("node:module");',
|
|
1573
|
+
`const req = createRequire(${JSON.stringify(base)});`,
|
|
1574
|
+
"process.stdout.write(JSON.stringify({",
|
|
1575
|
+
` pkgPath: req.resolve(${JSON.stringify(`${PLUGIN_ID}/package.json`)}),`,
|
|
1576
|
+
` entry: req.resolve(${JSON.stringify(PLUGIN_ID)})`,
|
|
1577
|
+
"}));",
|
|
1578
|
+
].join("\n");
|
|
1484
1579
|
try {
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
} catch {
|
|
1491
|
-
// Windows:普通目录符号链接需要管理员或"开发者模式",非管理员时直接 EPERM。
|
|
1492
|
-
// junction(目录联接)**不需要任何特权**,且 realpath 同样解析到目标,
|
|
1493
|
-
// 所以放在符号链接与整目录拷贝之间——能让 Windows 用户也拿到"链接"语义(改一处即生效)。
|
|
1494
|
-
let linked = false;
|
|
1495
|
-
if (process.platform === "win32") {
|
|
1496
|
-
try {
|
|
1497
|
-
fs.symlinkSync(path.resolve(pluginLocalDir), nmPlugin, "junction"); // junction 只接受绝对路径
|
|
1498
|
-
linked = true;
|
|
1499
|
-
} catch { /* 退化到拷贝 */ }
|
|
1580
|
+
const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 30_000, windowsHide: true });
|
|
1581
|
+
if (r.error) return { ok: false, reason: `无法启动解析探测进程:${r.error.message}` };
|
|
1582
|
+
if (r.status !== 0) {
|
|
1583
|
+
const line = String(r.stderr || "").split("\n").find((l) => /MODULE_NOT_FOUND|ERR_|Error/.test(l)) || "解析失败";
|
|
1584
|
+
return { ok: false, reason: line.trim().slice(0, 200) };
|
|
1500
1585
|
}
|
|
1501
|
-
|
|
1586
|
+
const out = JSON.parse(String(r.stdout || "{}"));
|
|
1587
|
+
const pkg = JSON.parse(fs.readFileSync(out.pkgPath, "utf8"));
|
|
1588
|
+
return {
|
|
1589
|
+
ok: true,
|
|
1590
|
+
pkgPath: out.pkgPath,
|
|
1591
|
+
entry: out.entry,
|
|
1592
|
+
name: pkg.name,
|
|
1593
|
+
clientPlatform: (pkg.dsh && pkg.dsh.client && pkg.dsh.client.platform) || "",
|
|
1594
|
+
};
|
|
1595
|
+
} catch (e) {
|
|
1596
|
+
return { ok: false, reason: `${e.code || e.name}: ${e.message}` };
|
|
1502
1597
|
}
|
|
1503
|
-
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* 描述插件在 profile 里的挂载形态(供 status / repair 输出)。
|
|
1602
|
+
* 用户报障时这三行就能区分"链接坏了"与"别的毛病"——不必再靠猜。
|
|
1603
|
+
* @returns {{localExists:boolean, exists:boolean, usable:boolean, kind:"missing"|"broken"|"linked"|"copy"|"no-local-copy"}}
|
|
1604
|
+
*/
|
|
1605
|
+
function describePluginLink(profileDir) {
|
|
1606
|
+
const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
|
|
1607
|
+
const localDir = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1608
|
+
const localExists = fs.existsSync(path.join(localDir, "package.json"));
|
|
1609
|
+
if (!fs.existsSync(nmPlugin)) return { localExists, exists: false, usable: false, kind: localExists ? "missing" : "no-local-copy" };
|
|
1610
|
+
const usable = pluginLinkUsable(nmPlugin, localDir);
|
|
1611
|
+
let isLink = false;
|
|
1612
|
+
try { isLink = fs.lstatSync(nmPlugin).isSymbolicLink(); } catch { /* ignore */ }
|
|
1613
|
+
return { localExists, exists: true, usable, kind: usable ? (isLink ? "linked" : "copy") : "broken" };
|
|
1504
1614
|
}
|
|
1505
1615
|
|
|
1506
1616
|
/** package.json 写入 file: 依赖(让后续任何 pnpm/npm install 也认可该插件,不会删链接)。 */
|
|
@@ -1564,8 +1674,26 @@ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg
|
|
|
1564
1674
|
console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
|
|
1565
1675
|
process.exit(1);
|
|
1566
1676
|
}
|
|
1677
|
+
// ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测事故):
|
|
1678
|
+
// 老代码把 ensurePluginLinked 的返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,
|
|
1679
|
+
// 于是用户拿到的是**起不来的 dsh web**(cordis 按裸包名解析到空目录 → plugin tree failed to load,
|
|
1680
|
+
// 整个 dsh web 启动失败,而面板/插件市场都在那个进程里 → 用户没有任何自助修复入口)。
|
|
1681
|
+
// 现在:先挂载 → 独立验收 → 才写依赖与激活行;任何一步不过就中止,绝不留下坏 profile。
|
|
1682
|
+
try {
|
|
1683
|
+
ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
|
|
1684
|
+
} catch (e) {
|
|
1685
|
+
console.error(`❌ ${e.message}`);
|
|
1686
|
+
console.error(" 已中止安装:继续写 patch 会让 dsh web 因插件树加载失败而完全无法启动。");
|
|
1687
|
+
console.error(" 请把上面的错误整段反馈给作者(含平台 / 是否管理员 / 开发者模式是否开启)。");
|
|
1688
|
+
process.exit(1);
|
|
1689
|
+
}
|
|
1690
|
+
const probe = probePluginResolve(profileDir);
|
|
1691
|
+
if (!probe.ok) {
|
|
1692
|
+
console.error(`❌ 验收失败:dsh 无法从 profile 按裸包名解析 ${PLUGIN_ID}(${probe.reason})`);
|
|
1693
|
+
console.error(" 已中止安装(未写 patch):否则 dsh web 会因插件树加载失败而完全无法启动。");
|
|
1694
|
+
process.exit(1);
|
|
1695
|
+
}
|
|
1567
1696
|
declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
|
|
1568
|
-
ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
|
|
1569
1697
|
const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, { forcePatch: !!opts.forcePatch });
|
|
1570
1698
|
if (r.ok) {
|
|
1571
1699
|
console.log(`✅ 插件已就绪: ${pluginLocalDir}${opts.forcePatch ? "(激活点已转为 patch 行,装完即热加载)" : ""}`);
|
|
@@ -1602,6 +1730,34 @@ function readInstalledPluginVersion(profileDir, name) {
|
|
|
1602
1730
|
} catch { return null; }
|
|
1603
1731
|
}
|
|
1604
1732
|
|
|
1733
|
+
/**
|
|
1734
|
+
* 识别 profile 用哪个包管理器(市场装法「一键更新」要用它改源码)。
|
|
1735
|
+
*
|
|
1736
|
+
* ⚠️ 不能用 `sh("command -v pnpm && echo pnpm || echo npm")`:`sh()` 走 execSync,
|
|
1737
|
+
* Windows 下是 cmd.exe,而 `command` 是 POSIX 内建 → 永远命中 `||` 分支,
|
|
1738
|
+
* **Windows 上永远选 npm**。而 dsh profile 是 pnpm 管理的(pnpm-workspace.yaml + 虚拟 store),
|
|
1739
|
+
* 用 npm 去改同一个 node_modules 会写出与 pnpm 不同的布局(污染/冲突风险)。
|
|
1740
|
+
* 现在:先用**声明文件**判断(最可靠、零子进程),再用 where/which 探测可执行文件;
|
|
1741
|
+
* 兜底选 pnpm 而不是 npm —— dsh profile 默认就是 pnpm,选错方向的代价不对称。
|
|
1742
|
+
*/
|
|
1743
|
+
function detectPackageManager(profileDir) {
|
|
1744
|
+
try {
|
|
1745
|
+
if (fs.existsSync(path.join(profileDir, "pnpm-lock.yaml"))
|
|
1746
|
+
|| fs.existsSync(path.join(profileDir, "pnpm-workspace.yaml"))) return "pnpm";
|
|
1747
|
+
if (fs.existsSync(path.join(profileDir, "package-lock.json"))) return "npm";
|
|
1748
|
+
} catch { /* 读不到就往下探测 */ }
|
|
1749
|
+
const has = (bin) => {
|
|
1750
|
+
try {
|
|
1751
|
+
return process.platform === "win32"
|
|
1752
|
+
? spawnSync("where", [bin], { stdio: "ignore", windowsHide: true }).status === 0
|
|
1753
|
+
: spawnSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0;
|
|
1754
|
+
} catch { return false; }
|
|
1755
|
+
};
|
|
1756
|
+
if (has("pnpm")) return "pnpm";
|
|
1757
|
+
if (has("npm")) return "npm";
|
|
1758
|
+
return "pnpm";
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1605
1761
|
/**
|
|
1606
1762
|
* 市场装法下把插件包升到最新。
|
|
1607
1763
|
*
|
|
@@ -1615,7 +1771,7 @@ function readInstalledPluginVersion(profileDir, name) {
|
|
|
1615
1771
|
function upgradeMarketManagedPlugin(profileDir, name, dep) {
|
|
1616
1772
|
const spec = String(dep ?? "").trim();
|
|
1617
1773
|
const isRegistryRange = /^[\^~><=v\d]/.test(spec);
|
|
1618
|
-
const pm =
|
|
1774
|
+
const pm = detectPackageManager(profileDir);
|
|
1619
1775
|
const args = isRegistryRange ? `add ${name}@latest` : `update ${name}`;
|
|
1620
1776
|
const cmd = `${pm} ${args}`;
|
|
1621
1777
|
const r = sh(cmd, 180000, profileDir);
|
|
@@ -1734,6 +1890,83 @@ async function pluginCmd(argv) {
|
|
|
1734
1890
|
// 引导语只在 setup 汇总里打印一次(这里不再重复;单独跑 `dsh-remote plugin` 也无需引导)
|
|
1735
1891
|
}
|
|
1736
1892
|
|
|
1893
|
+
/**
|
|
1894
|
+
* 打印插件挂载诊断三行(供 status / repair 共用)。
|
|
1895
|
+
* 用户报障时这三行就能区分「链接坏了」与「别的毛病」,不必靠猜:
|
|
1896
|
+
* link = 有没有真挂上(junction / 符号链接 / 整目录拷贝 / BROKEN)
|
|
1897
|
+
* resolve = dsh 的裸包名解析能不能走通(浏览器半注入面板就靠它)
|
|
1898
|
+
* client = dsh.client.platform 声明在不在
|
|
1899
|
+
*/
|
|
1900
|
+
function printPluginDiagnostics(profileDir) {
|
|
1901
|
+
const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
|
|
1902
|
+
const link = describePluginLink(profileDir);
|
|
1903
|
+
const probe = probePluginResolve(profileDir);
|
|
1904
|
+
const kindText = { linked: "已挂载(链接)", copy: "已挂载(整目录拷贝)", broken: "BROKEN(存在但读不到内容)", missing: "缺失", "no-local-copy": "缺失(本地插件目录也没有)" }[link.kind] || link.kind;
|
|
1905
|
+
console.log(` [link] ${nmPlugin}\n → ${kindText}`);
|
|
1906
|
+
console.log(` [resolve] ${PLUGIN_ID}/package.json → ${probe.ok ? probe.pkgPath : `失败(${probe.reason})`}`);
|
|
1907
|
+
console.log(` [client] dsh.client.platform = ${probe.ok ? (probe.clientPlatform || "缺失") : "(解析失败,无法确认)"}`);
|
|
1908
|
+
return { link, probe };
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* `dsh-remote repair` —— 只修 profile 里的插件挂载,不碰运行环境 / 不联网 / 不碰自启动。
|
|
1913
|
+
*
|
|
1914
|
+
* 为什么需要这个独立入口:插件挂载坏掉时 dsh web **整个起不来**(cordis 插件树加载失败),
|
|
1915
|
+
* 而设置面板与插件市场都在那个进程里 —— 用户没有任何自助修复入口,只剩命令行。
|
|
1916
|
+
* 执行顺序刻意是"先保命、再修复、最后恢复激活":
|
|
1917
|
+
* ① patch 里引用了本插件但挂载不可用 → **先把 include 摘掉**(profile 立刻恢复可启动);
|
|
1918
|
+
* ② 再重建挂载(失败会抛错,但①的结果保留 → 用户至少能打开 dsh web);
|
|
1919
|
+
* ③ 只有①②都成功,才把激活行写回去。
|
|
1920
|
+
*/
|
|
1921
|
+
function repairProfile(argv = []) {
|
|
1922
|
+
const profileDir = resolveProfileDir(argv);
|
|
1923
|
+
const pkgFile = path.join(profileDir, "package.json");
|
|
1924
|
+
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
1925
|
+
const localDir = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1926
|
+
if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
|
|
1927
|
+
console.error(`❌ 未找到 dsh web profile(${profileDir})。`);
|
|
1928
|
+
console.error(" 先确认 dsh web 装好并初始化过 profile,或用 --profile <目录> 指定。");
|
|
1929
|
+
process.exit(1);
|
|
1930
|
+
}
|
|
1931
|
+
console.log(`dsh-remote repair — ${profileDir}`);
|
|
1932
|
+
const before = printPluginDiagnostics(profileDir);
|
|
1933
|
+
if (!before.link.localExists) {
|
|
1934
|
+
console.error(`❌ 本地插件目录缺失(${localDir}):无法就地修复。`);
|
|
1935
|
+
console.error(" 请重新安装:npx @mrrisega/dsh-remote@latest");
|
|
1936
|
+
process.exit(1);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const patch = fs.readFileSync(patchFile, "utf8");
|
|
1940
|
+
const referenced = parsePatchInsertIds(patch).ids.some((id) => PLUGIN_ALL_IDS.includes(id));
|
|
1941
|
+
if (referenced && !before.link.usable) {
|
|
1942
|
+
// 保命动作:先摘掉激活行,让 dsh web 至少能启动(此时插件不可用,但用户进得去、能重装)
|
|
1943
|
+
stripIncludeEntries(patchFile, patch, "挂载不可用,先摘掉 include 保命");
|
|
1944
|
+
try { removeBundleEntry(pkgFile); } catch { /* ignore */ }
|
|
1945
|
+
console.log("🛟 挂载不可用 → 已先摘掉插件激活行(dsh web 现在应该能启动了)。");
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
try {
|
|
1949
|
+
ensurePluginLinked(profileDir, localDir);
|
|
1950
|
+
} catch (e) {
|
|
1951
|
+
console.error(`❌ 重建挂载失败:${e.message}`);
|
|
1952
|
+
console.error(" profile 已处于「能启动、插件未激活」状态;请把上面错误反馈给作者。");
|
|
1953
|
+
process.exit(1);
|
|
1954
|
+
}
|
|
1955
|
+
const after = printPluginDiagnostics(profileDir);
|
|
1956
|
+
if (!after.probe.ok) {
|
|
1957
|
+
console.error("❌ 挂载已重建,但 dsh 仍解析不到本插件 —— 请把上面的输出反馈给作者。");
|
|
1958
|
+
process.exit(1);
|
|
1959
|
+
}
|
|
1960
|
+
const srcPluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
1961
|
+
if (!fs.existsSync(srcPluginDir)) {
|
|
1962
|
+
console.error(`❌ 本包缺少 packages/${PLUGIN_ID},无法就地恢复激活行。`);
|
|
1963
|
+
console.error(" 请重新安装:npx @mrrisega/dsh-remote@latest");
|
|
1964
|
+
process.exit(1);
|
|
1965
|
+
}
|
|
1966
|
+
convergePluginActivation(profileDir, pkgFile, patchFile, srcPluginDir, fs.readFileSync(patchFile, "utf8"));
|
|
1967
|
+
console.log("✅ 挂载与激活已修复 —— 重启一次 dsh web 生效。");
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1737
1970
|
// ---------- main ----------
|
|
1738
1971
|
// 无命令名(或首个参数以 - 开头)时默认执行 setup —— 这样 `dsh-remote --no-autostart`
|
|
1739
1972
|
// 这类「只给 flag」的用法才能装上去。
|
|
@@ -1748,6 +1981,7 @@ if (cmd === "setup" || cmd === "install") await setup(args);
|
|
|
1748
1981
|
else if (cmd === "settings") settingsHint();
|
|
1749
1982
|
else if (cmd === "run") await runBridge();
|
|
1750
1983
|
else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
|
|
1984
|
+
else if (cmd === "repair") repairProfile(process.argv.slice(3));
|
|
1751
1985
|
else if (cmd === "status") {
|
|
1752
1986
|
const cfg = loadConfig();
|
|
1753
1987
|
const local = Boolean(cfg.local_key);
|
|
@@ -1755,6 +1989,14 @@ else if (cmd === "status") {
|
|
|
1755
1989
|
console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
|
|
1756
1990
|
console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
|
|
1757
1991
|
console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程控制」查看与操作");
|
|
1992
|
+
// 插件挂载诊断:报障时一眼看出是"链接坏了"还是"别的毛病"
|
|
1993
|
+
const profileDir = resolveProfileDir([]);
|
|
1994
|
+
if (fs.existsSync(path.join(profileDir, "package.json"))) {
|
|
1995
|
+
console.log("插件挂载:");
|
|
1996
|
+
printPluginDiagnostics(profileDir);
|
|
1997
|
+
} else {
|
|
1998
|
+
console.log(`插件挂载: 未找到 profile(${profileDir})`);
|
|
1999
|
+
}
|
|
1758
2000
|
} else {
|
|
1759
2001
|
printHelp();
|
|
1760
2002
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.7-beta.
|
|
3
|
+
"version": "0.6.7-beta.2",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -2302,7 +2302,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
2302
2302
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
2303
2303
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
2304
2304
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
2305
|
-
const PLUGIN_VERSION = "0.6.7-beta.
|
|
2305
|
+
const PLUGIN_VERSION = "0.6.7-beta.2";
|
|
2306
2306
|
const UPDATE_LOG = ".dsh-update.log";
|
|
2307
2307
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
2308
2308
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.7-beta.
|
|
3
|
+
"version": "0.6.7-beta.2",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件挂载(node_modules/<id> 链接)回归 —— 0.6.7-beta.2 新增。
|
|
3
|
+
*
|
|
4
|
+
* 背景(Windows 用户实测,2026-09-18):0.6.7-beta.1 的 `ensurePluginLinked` 用
|
|
5
|
+
* `fs.symlinkSync(target, path, "dir")` 建链接,并把 junction / 整目录拷贝两级兜底写在它的 `catch` 里。
|
|
6
|
+
* 在 **非特权进程 + 开发者模式关闭**(= 普通用户默认状态)下,`'dir'` 在 Windows 上
|
|
7
|
+
* **既不抛错也产不出可用链接**(实测:用户目录下留一个空目录,%TEMP% 下什么都不留;
|
|
8
|
+
* 同一位置两次运行一次报成功一次抛 UNKNOWN)。于是:
|
|
9
|
+
* · 报成功 → catch 不进入 → 两级兜底永不执行 → 留下空壳/什么都没有;
|
|
10
|
+
* · 报错 → catch 进入 → 但上一步已留空目录 → junction 撞 EEXIST → 只剩 cpSync 一支。
|
|
11
|
+
* 而调用方把返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,最终
|
|
12
|
+
* dsh web 按裸包名解析到空目录 → `plugin tree failed to load` → **整个 dsh web 起不来**。
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 本文件存在的意义就是"上一版为什么漏测":老用例只断言"函数没抛错 / 返回 true",
|
|
15
|
+
* 而这里的断言对象是**通过 node_modules/<id> 这条路径到底能不能读到东西**,
|
|
16
|
+
* 并且用注入式 fs 代理**复现**了 Windows 那种"不抛错但落盘是空壳"的形态。
|
|
17
|
+
*/
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import test from "node:test";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
|
|
29
|
+
const PLUGIN_SRC = path.join(HERE, "..");
|
|
30
|
+
const PLUGIN_ID = "dsh-remote-web";
|
|
31
|
+
const SETUP_SRC = fs.readFileSync(SETUP, "utf8");
|
|
32
|
+
|
|
33
|
+
// ─────────────── 源码内取函数(安装器是 CLI 脚本,import 会直接执行 main) ───────────────
|
|
34
|
+
|
|
35
|
+
/** 取出 dsh-setup.mjs 里某个函数的源码文本。 */
|
|
36
|
+
function fnSrc(name, nextMarker) {
|
|
37
|
+
const start = SETUP_SRC.indexOf(`function ${name}(`);
|
|
38
|
+
const end = SETUP_SRC.indexOf(nextMarker, start);
|
|
39
|
+
assert.ok(start > -1 && end > start, `应能从安装器源码里取出 ${name}`);
|
|
40
|
+
return SETUP_SRC.slice(start, end);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 用真实 fs/path 组装出被测函数(纯函数,可安全求值)。 */
|
|
44
|
+
function loadLinkFns() {
|
|
45
|
+
const src = fnSrc("pluginLinkUsable", "/**\n * 把插件挂到")
|
|
46
|
+
+ fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
|
|
47
|
+
return new Function("fs", "path", "PLUGIN_ID", "PLUGIN_LEGACY_IDS",
|
|
48
|
+
`${src}; return { pluginLinkUsable, ensurePluginLinked };`)(fs, path, PLUGIN_ID, ["dsh-remote-ui"]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 造一份"像真的"插件目录(package.json + lib/index.js 已是可用性判据的最低要求)。 */
|
|
52
|
+
function makePluginDir(root, { name = PLUGIN_ID, withEntry = true } = {}) {
|
|
53
|
+
fs.mkdirSync(path.join(root, "lib"), { recursive: true });
|
|
54
|
+
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name, version: "0.0.0-test" }));
|
|
55
|
+
if (withEntry) fs.writeFileSync(path.join(root, "lib", "index.js"), "export const x = 1;\n");
|
|
56
|
+
return root;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tmpDir(prefix = "dsh-link-") {
|
|
60
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─────────────── ① 可用性判据必须否定所有畸形形态 ───────────────
|
|
64
|
+
|
|
65
|
+
test("pluginLinkUsable:空目录/畸形链接一律判为不可用(不能只看'没抛错')", () => {
|
|
66
|
+
const { pluginLinkUsable } = loadLinkFns();
|
|
67
|
+
const root = tmpDir();
|
|
68
|
+
try {
|
|
69
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
70
|
+
const nm = path.join(root, "node_modules", PLUGIN_ID);
|
|
71
|
+
fs.mkdirSync(path.dirname(nm), { recursive: true });
|
|
72
|
+
|
|
73
|
+
assert.equal(pluginLinkUsable(nm, local), false, "不存在 → 不可用");
|
|
74
|
+
|
|
75
|
+
// 这就是本 bug 的落盘形态:一个同名空目录
|
|
76
|
+
fs.mkdirSync(nm, { recursive: true });
|
|
77
|
+
assert.equal(pluginLinkUsable(nm, local), false, "★空目录必须判为不可用(老实现正是在这里当作成功)");
|
|
78
|
+
|
|
79
|
+
// 有 package.json 但 name 不对(旧的别名包残留 / 别的包)
|
|
80
|
+
fs.writeFileSync(path.join(nm, "package.json"), JSON.stringify({ name: "dsh-remote-ui" }));
|
|
81
|
+
fs.mkdirSync(path.join(nm, "lib"), { recursive: true });
|
|
82
|
+
fs.writeFileSync(path.join(nm, "lib", "index.js"), "x");
|
|
83
|
+
assert.equal(pluginLinkUsable(nm, local), false, "name 不匹配 → 不可用(面板注入靠裸包名)");
|
|
84
|
+
|
|
85
|
+
// name 对了但没有节点半入口
|
|
86
|
+
fs.writeFileSync(path.join(nm, "package.json"), JSON.stringify({ name: PLUGIN_ID }));
|
|
87
|
+
fs.rmSync(path.join(nm, "lib", "index.js"));
|
|
88
|
+
assert.equal(pluginLinkUsable(nm, local), false, "缺 lib/index.js → 不可用");
|
|
89
|
+
|
|
90
|
+
// 内容完整 + realpath 指回本地目录 → 可用
|
|
91
|
+
fs.writeFileSync(path.join(nm, "lib", "index.js"), fs.readFileSync(path.join(local, "lib", "index.js")));
|
|
92
|
+
fs.rmSync(nm, { recursive: true, force: true });
|
|
93
|
+
// 正向对照必须按平台取链接类型:Windows 非特权进程下 'dir' 本来就建不出来(正是本 bug),
|
|
94
|
+
// 拿它当"正确答案"会让用例在真 Windows 上自己失败。
|
|
95
|
+
if (process.platform === "win32") fs.symlinkSync(local, nm, "junction");
|
|
96
|
+
else fs.symlinkSync(local, nm, "dir");
|
|
97
|
+
assert.equal(pluginLinkUsable(nm, local), true, "真链接 → 可用");
|
|
98
|
+
|
|
99
|
+
// 整目录拷贝(内容一致)→ 也算可用
|
|
100
|
+
fs.rmSync(nm, { recursive: true, force: true });
|
|
101
|
+
fs.cpSync(local, nm, { recursive: true });
|
|
102
|
+
assert.equal(pluginLinkUsable(nm, local), true, "整目录拷贝(内容一致)→ 可用");
|
|
103
|
+
} finally {
|
|
104
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─────────────── ② 注入 Windows 的失败形态:不抛错但落盘是空壳 ───────────────
|
|
109
|
+
|
|
110
|
+
/** 造一个 fs 代理:可按需让某个策略"静默失败"(不抛错、也不产出任何东西)。 */
|
|
111
|
+
function fsProxy({ silentFail = [], emptyDirInstead = [] } = {}) {
|
|
112
|
+
return {
|
|
113
|
+
...fs,
|
|
114
|
+
symlinkSync(target, p, type) {
|
|
115
|
+
if (emptyDirInstead.includes(type)) { fs.mkdirSync(p, { recursive: true }); return; } // ← 本 bug 的落盘形态
|
|
116
|
+
if (silentFail.includes(type)) return; // ← 本 bug 的"报成功"
|
|
117
|
+
return fs.symlinkSync(target, p, type);
|
|
118
|
+
},
|
|
119
|
+
cpSync(...args) {
|
|
120
|
+
if (silentFail.includes("copy")) return;
|
|
121
|
+
return fs.cpSync(...args);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function loadLinkFnsWithFs(fakeFs) {
|
|
127
|
+
const src = fnSrc("pluginLinkUsable", "/**\n * 把插件挂到")
|
|
128
|
+
+ fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
|
|
129
|
+
return new Function("fs", "path", "PLUGIN_ID", "PLUGIN_LEGACY_IDS",
|
|
130
|
+
`${src}; return { pluginLinkUsable, ensurePluginLinked };`)(fakeFs, path, PLUGIN_ID, ["dsh-remote-ui"]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test("ensurePluginLinked:'不抛错但什么都没建' 时,兜底链必须继续生效(本 bug 的核心)", () => {
|
|
134
|
+
const root = tmpDir();
|
|
135
|
+
try {
|
|
136
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
137
|
+
const profile = path.join(root, "profile");
|
|
138
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
139
|
+
// 模拟 Windows:junction 报告成功但落盘什么都没有(老实现据此当作成功、直接 return)
|
|
140
|
+
const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ silentFail: ["junction"] }));
|
|
141
|
+
const changed = ensurePluginLinked(profile, local);
|
|
142
|
+
assert.equal(changed, true, "应当重建并报告发生了变更");
|
|
143
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
144
|
+
assert.ok(fs.existsSync(path.join(nm, "package.json")), "★兜底必须真的把内容落盘(老实现在这里留下空壳)");
|
|
145
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★节点半入口必须可读");
|
|
146
|
+
assert.ok(fs.readdirSync(nm).length > 0, "node_modules/<id> 是空目录 = 本 bug 复现");
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("ensurePluginLinked:上一支留下空目录残留时,下一支不得被 EEXIST 顶掉", () => {
|
|
153
|
+
const root = tmpDir();
|
|
154
|
+
try {
|
|
155
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
156
|
+
const profile = path.join(root, "profile");
|
|
157
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
158
|
+
const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ emptyDirInstead: ["junction"] }));
|
|
159
|
+
ensurePluginLinked(profile, local);
|
|
160
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
161
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")),
|
|
162
|
+
"★先留空目录再走兜底也必须成功(老实现这里撞 EEXIST,三级兜底退化成一级)");
|
|
163
|
+
} finally {
|
|
164
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("ensurePluginLinked:所有策略都失败时必须抛错,绝不返回成功", () => {
|
|
169
|
+
const root = tmpDir();
|
|
170
|
+
try {
|
|
171
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
172
|
+
const profile = path.join(root, "profile");
|
|
173
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
174
|
+
const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ silentFail: ["junction", "dir", "copy"] }));
|
|
175
|
+
assert.throws(() => ensurePluginLinked(profile, local), /插件挂载失败/,
|
|
176
|
+
"全失败必须抛错 —— 否则调用方会写出一个起不来的 profile");
|
|
177
|
+
} finally {
|
|
178
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("ensurePluginLinked:已经挂好时幂等返回 false(不重复重建)", () => {
|
|
183
|
+
const root = tmpDir();
|
|
184
|
+
try {
|
|
185
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
186
|
+
const profile = path.join(root, "profile");
|
|
187
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
188
|
+
const { ensurePluginLinked } = loadLinkFns();
|
|
189
|
+
assert.equal(ensurePluginLinked(profile, local), true, "首次应重建");
|
|
190
|
+
assert.equal(ensurePluginLinked(profile, local), false, "已就绪应幂等返回 false");
|
|
191
|
+
} finally {
|
|
192
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ─────────────── ③ 真起进程:装完必须真的能被裸包名解析到 ───────────────
|
|
197
|
+
|
|
198
|
+
/** 准备一个最小可用 profile(package.json + 空的 cordis.patch.yml)。 */
|
|
199
|
+
function makeProfile(root) {
|
|
200
|
+
const profile = path.join(root, "prof");
|
|
201
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
202
|
+
fs.writeFileSync(path.join(profile, "package.json"), JSON.stringify({ name: "dsh-profile-web", version: "0.0.0" }, null, 2));
|
|
203
|
+
fs.writeFileSync(path.join(profile, "cordis.patch.yml"), "[]\n");
|
|
204
|
+
return profile;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 在隔离 HOME 下跑安装器命令。 */
|
|
208
|
+
function runInstaller(args, env = {}) {
|
|
209
|
+
const home = env.HOME || fs.mkdtempSync(path.join(os.tmpdir(), "dsh-link-home-"));
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
execFile(process.execPath, [SETUP, ...args], {
|
|
212
|
+
env: { ...process.env, HOME: home, DSH_RELAY_SKIP_SERVICE: "1", DSH_RELAY_DIR: path.join(home, ".dsh-remote"), ...env },
|
|
213
|
+
timeout: 60_000,
|
|
214
|
+
}, (err, stdout, stderr) => resolve({ code: err ? (err.code ?? 1) : 0, stdout, stderr }));
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 复刻 dsh 的真实解析动作(浏览器半注入设置面板靠它)。 */
|
|
219
|
+
function resolveFromProfile(profile, spec) {
|
|
220
|
+
const req = createRequire(path.join(profile, "__probe.cjs"));
|
|
221
|
+
return req.resolve(spec);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
test("端到端:安装器装完插件后,node_modules/<id> 必须真的可读、可解析(不能只看退出码)", async () => {
|
|
225
|
+
const root = tmpDir();
|
|
226
|
+
try {
|
|
227
|
+
const profile = makeProfile(root);
|
|
228
|
+
const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
|
|
229
|
+
assert.equal(r.code, 0, `安装应成功:${r.stdout}\n${r.stderr}`);
|
|
230
|
+
|
|
231
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
232
|
+
// ① 落盘可读 —— Windows 非特权下老代码在这里就挂了
|
|
233
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(nm, "package.json"), "utf8"));
|
|
234
|
+
assert.equal(pkg.name, PLUGIN_ID);
|
|
235
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "节点半入口必须存在");
|
|
236
|
+
assert.ok(fs.readdirSync(nm).length > 0, "★空目录 = 本 bug 复现");
|
|
237
|
+
|
|
238
|
+
// ② 复刻 dsh 的解析动作
|
|
239
|
+
assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`).endsWith("package.json"));
|
|
240
|
+
assert.ok(resolveFromProfile(profile, PLUGIN_ID), "裸包名必须能解析到入口");
|
|
241
|
+
assert.equal(pkg.dsh.client.platform, "web", "浏览器半声明必须在(面板注入的前提)");
|
|
242
|
+
|
|
243
|
+
// ③ 激活行必须写进 patch
|
|
244
|
+
const patch = fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8");
|
|
245
|
+
assert.match(patch, /dsh-remote-web/, "patch 里应有插件激活行");
|
|
246
|
+
} finally {
|
|
247
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("端到端:node_modules/<id> 已是空壳时,重新安装必须自愈", async () => {
|
|
252
|
+
const root = tmpDir();
|
|
253
|
+
try {
|
|
254
|
+
const profile = makeProfile(root);
|
|
255
|
+
// 复现中招现场:同名空目录
|
|
256
|
+
fs.mkdirSync(path.join(profile, "node_modules", PLUGIN_ID), { recursive: true });
|
|
257
|
+
const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
|
|
258
|
+
assert.equal(r.code, 0, `重装应自愈:${r.stdout}\n${r.stderr}`);
|
|
259
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
260
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★空壳必须被重建(空目录不许当作已挂载)");
|
|
261
|
+
assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`));
|
|
262
|
+
} finally {
|
|
263
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("端到端:挂载失败必须中止安装,且**不写** patch(宁可不装,也不能写出起不来的 profile)", async (t) => {
|
|
268
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return; // root 不受权限位约束
|
|
269
|
+
// Windows 的 chmod 只改只读属性、**不限制目录写入**,制造不出"必然失败"的落盘环境。
|
|
270
|
+
// 该场景由上面三条注入式 fs 代理用例覆盖(含"全失败必须抛错")。
|
|
271
|
+
if (process.platform === "win32") { t.skip("Windows 无 POSIX 权限位语义"); return; }
|
|
272
|
+
const root = tmpDir();
|
|
273
|
+
try {
|
|
274
|
+
const profile = makeProfile(root);
|
|
275
|
+
const nmDir = path.join(profile, "node_modules");
|
|
276
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
277
|
+
fs.chmodSync(nmDir, 0o500); // 只读:symlink 与 cpSync 都必然失败
|
|
278
|
+
try {
|
|
279
|
+
const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
|
|
280
|
+
assert.equal(r.code, 1, `应中止安装(非 0 退出):${r.stdout}\n${r.stderr}`);
|
|
281
|
+
assert.match(r.stderr, /已中止安装/, "必须明确告知已中止");
|
|
282
|
+
const patch = fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8");
|
|
283
|
+
assert.ok(!/dsh-remote-web/.test(patch), "★绝不能写激活行:那会让 dsh web 起不来");
|
|
284
|
+
assert.ok(!/插件已就绪/.test(r.stdout), "不得谎报成功");
|
|
285
|
+
} finally {
|
|
286
|
+
fs.chmodSync(nmDir, 0o700);
|
|
287
|
+
}
|
|
288
|
+
} finally {
|
|
289
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ─────────────── ④ 自救入口:repair ───────────────
|
|
294
|
+
|
|
295
|
+
test("repair:空壳现场能被修好,并打印挂载诊断三行", async () => {
|
|
296
|
+
const root = tmpDir();
|
|
297
|
+
try {
|
|
298
|
+
const profile = makeProfile(root);
|
|
299
|
+
await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
|
|
300
|
+
// 破坏:把链接换成空壳(= 用户中招后的现场)
|
|
301
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
302
|
+
fs.rmSync(nm, { recursive: true, force: true });
|
|
303
|
+
fs.mkdirSync(nm, { recursive: true });
|
|
304
|
+
|
|
305
|
+
const r = await runInstaller(["repair"], { DSH_PROFILE_DIR: profile });
|
|
306
|
+
assert.equal(r.code, 0, `repair 应成功:${r.stdout}\n${r.stderr}`);
|
|
307
|
+
assert.match(r.stdout, /\[link\]/, "应打印链接诊断");
|
|
308
|
+
assert.match(r.stdout, /\[resolve\]/, "应打印解析诊断");
|
|
309
|
+
assert.match(r.stdout, /\[client\]/, "应打印客户端半声明诊断");
|
|
310
|
+
assert.match(r.stdout, /🛟/, "挂载不可用时应先摘掉激活行保命");
|
|
311
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★应修好挂载");
|
|
312
|
+
assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`), "★应可解析");
|
|
313
|
+
assert.match(fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8"), /dsh-remote-web/, "应恢复激活行");
|
|
314
|
+
} finally {
|
|
315
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("repair:本地插件目录缺失时明确报错并给重装命令(不静默成功)", async () => {
|
|
320
|
+
const root = tmpDir();
|
|
321
|
+
try {
|
|
322
|
+
const profile = makeProfile(root);
|
|
323
|
+
const r = await runInstaller(["repair"], { DSH_PROFILE_DIR: profile });
|
|
324
|
+
assert.equal(r.code, 1, `无本地拷贝应失败:${r.stdout}\n${r.stderr}`);
|
|
325
|
+
assert.match(r.stderr, /请重新安装/, "必须给出可照抄的重装命令");
|
|
326
|
+
} finally {
|
|
327
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ─────────────── ⑤ 包管理器识别(Windows 上 `command -v` 恒失效) ───────────────
|
|
332
|
+
|
|
333
|
+
test("detectPackageManager:按声明文件识别,且代码里不得再用 command -v pnpm", () => {
|
|
334
|
+
const src = fnSrc("detectPackageManager", "/**\n * 市场装法下把插件包升到最新");
|
|
335
|
+
const detect = new Function("fs", "path", "spawnSync", "process", `${src}; return detectPackageManager;`)(
|
|
336
|
+
fs, path, () => ({ status: 1 }), process);
|
|
337
|
+
|
|
338
|
+
const root = tmpDir();
|
|
339
|
+
try {
|
|
340
|
+
const a = path.join(root, "a"); fs.mkdirSync(a);
|
|
341
|
+
fs.writeFileSync(path.join(a, "pnpm-workspace.yaml"), "packages: []\n");
|
|
342
|
+
assert.equal(detect(a), "pnpm", "有 pnpm-workspace.yaml → pnpm");
|
|
343
|
+
|
|
344
|
+
const b = path.join(root, "b"); fs.mkdirSync(b);
|
|
345
|
+
fs.writeFileSync(path.join(b, "package-lock.json"), "{}");
|
|
346
|
+
assert.equal(detect(b), "npm", "有 package-lock.json → npm");
|
|
347
|
+
|
|
348
|
+
const c = path.join(root, "c"); fs.mkdirSync(c);
|
|
349
|
+
assert.equal(detect(c), "pnpm", "都探测不到时兜底 pnpm(dsh profile 默认就是 pnpm)");
|
|
350
|
+
} finally {
|
|
351
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 源码契约:不得再用 POSIX 内建 `command -v`(Windows 走 cmd.exe,永远命中 || 分支)
|
|
355
|
+
const codeLines = SETUP_SRC.split("\n").filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l));
|
|
356
|
+
assert.ok(!codeLines.some((l) => /command -v pnpm/.test(l)), "不得再用 command -v pnpm 探测");
|
|
357
|
+
assert.ok(/const pm = detectPackageManager\(profileDir\);/.test(SETUP_SRC), "升级路径必须用 detectPackageManager");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ─────────────── ⑥ 源码契约:策略顺序与失败即中止 ───────────────
|
|
361
|
+
|
|
362
|
+
test("源码契约:Windows 首选 junction(不用 'dir'),且每支之间清理残留", () => {
|
|
363
|
+
const fn = fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
|
|
364
|
+
const winBranch = fn.slice(fn.indexOf('process.platform === "win32"'), fn.indexOf('["copy"'));
|
|
365
|
+
assert.ok(winBranch.includes('"junction"'), "Windows 首选必须是 junction");
|
|
366
|
+
assert.ok(!/"dir"/.test(winBranch), "★Windows 分支不得再尝试 symlinkSync(...,'dir')");
|
|
367
|
+
assert.ok(/fs\.rmSync\(nmPlugin, \{ recursive: true, force: true \}\);\s*\}\s*catch/.test(fn) || /rmSync\(nmPlugin[^\n]*\n/.test(fn),
|
|
368
|
+
"每次尝试前必须清残留(否则下一支撞 EEXIST)");
|
|
369
|
+
assert.ok(/for \(const \[how, run\] of attempts\)/.test(fn), "必须是「逐支验证」的循环,而不是 try 嵌套 catch");
|
|
370
|
+
assert.ok(/throw new Error\(`插件挂载失败/.test(fn), "全部失败必须抛错");
|
|
371
|
+
// POSIX 行为保持不变(原生 symlink 无需特权)
|
|
372
|
+
const posixBranch = fn.slice(fn.indexOf(':\n [') >= 0 ? fn.indexOf(':\n [') : fn.indexOf('['), fn.indexOf("const failures"));
|
|
373
|
+
assert.ok(/"dir"/.test(posixBranch), "POSIX 仍用原生 symlink");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("源码契约:调用方必须检查结果并在失败时中止(老代码丢返回值还报成功)", () => {
|
|
377
|
+
// 调用必须被 try/catch 包住(老代码是裸调用 + 丢返回值)
|
|
378
|
+
assert.ok(/try \{\s*\n\s*ensurePluginLinked\(profileDir, pluginLocalDir\);/.test(SETUP_SRC),
|
|
379
|
+
"ensurePluginLinked 必须在 try 里调用(否则失败会变成未捕获异常)");
|
|
380
|
+
const call = SETUP_SRC.slice(SETUP_SRC.indexOf("try {\n ensurePluginLinked(profileDir, pluginLocalDir);"), SETUP_SRC.indexOf("const r = ensurePatchActivation"));
|
|
381
|
+
assert.ok(/catch \(e\) \{[\s\S]*?process\.exit\(1\);/.test(call), "挂载失败必须 process.exit(1)");
|
|
382
|
+
// 顺序(只在 activateLocalCopy 函数体内看):挂载 → 独立验收 → 才 declarePluginDep / 写 patch
|
|
383
|
+
const body = SETUP_SRC.slice(
|
|
384
|
+
SETUP_SRC.indexOf("function activateLocalCopy("),
|
|
385
|
+
SETUP_SRC.indexOf("function convergePluginActivation("));
|
|
386
|
+
assert.ok(body.length > 0, "应能定位 activateLocalCopy");
|
|
387
|
+
const order = [
|
|
388
|
+
"ensurePluginLinked(profileDir, pluginLocalDir)",
|
|
389
|
+
"const probe = probePluginResolve(profileDir);",
|
|
390
|
+
"declarePluginDep(pkgFile);",
|
|
391
|
+
"ensurePatchActivation(patchFile",
|
|
392
|
+
];
|
|
393
|
+
let last = -1;
|
|
394
|
+
for (const marker of order) {
|
|
395
|
+
const i = body.indexOf(marker);
|
|
396
|
+
assert.ok(i > last, `activateLocalCopy 内的顺序必须是:挂载 → 验收 → 依赖 → 激活(${marker})`);
|
|
397
|
+
last = i;
|
|
398
|
+
}
|
|
399
|
+
assert.ok(/function probePluginResolve\(/.test(SETUP_SRC), "应有独立验收(复刻 dsh 的裸包名解析)");
|
|
400
|
+
});
|