dsh-m 0.2.9 → 0.2.11
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 +6 -3
- package/README.md +6 -3
- package/docs/DESIGN.md +2 -2
- package/lib/client.js +331 -244
- package/lib/core/dsh-cli.js +0 -29
- package/lib/core/host-api.js +2 -1
- package/lib/core/installed.js +89 -38
- package/lib/core/market.js +2 -1
- package/lib/core/restart.js +149 -27
- package/lib/host.js +7 -2
- package/lib/tools.js +12 -23
- package/package.json +3 -2
package/lib/core/dsh-cli.js
CHANGED
|
@@ -314,19 +314,6 @@ function writeDangerouslyAllowAllBuilds(profileDirectory) {
|
|
|
314
314
|
writeFileSync(file, next);
|
|
315
315
|
return true;
|
|
316
316
|
}
|
|
317
|
-
export function rewritePnpmError(err) {
|
|
318
|
-
const text = err instanceof Error ? err.message : String(err);
|
|
319
|
-
if (/ERR_PNPM_UNUSED_PATCH/.test(text)) {
|
|
320
|
-
return new Error('profile 的补丁配置(patchedDependencies)里存在不再使用的条目,pnpm 拒绝执行。卸载时 dsh-m 会自动摘除目标包自己的补丁条目;仍报此错通常是其他包留有失效补丁,请手工清理 profile 的 pnpm-workspace.yaml。');
|
|
321
|
-
}
|
|
322
|
-
if (isPrepareBlocked(text)) {
|
|
323
|
-
return new Error('该插件需要执行构建脚本(prepare),pnpm 默认拦截。dsh-m 已写入 profile 的 dangerouslyAllowAllBuilds 并重试;若仍失败请检查 web profile 是否可写。');
|
|
324
|
-
}
|
|
325
|
-
if (/ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF/.test(text)) {
|
|
326
|
-
return new Error('当前 profile 的 node_modules 由不同主版本的 pnpm 生成,安装前需要先重建依赖。');
|
|
327
|
-
}
|
|
328
|
-
return err instanceof Error ? err : new Error(text);
|
|
329
|
-
}
|
|
330
317
|
/**
|
|
331
318
|
* 失败摘要:命令失败时从完整输出里提取可诊断的行,而不是盲取末尾。
|
|
332
319
|
* 2026-09-05 实证(dsh-better-sidebar 安装失败):pnpm ndjson 错误行 ~1.2KB,
|
|
@@ -618,22 +605,6 @@ export function makeAddViaLadder(deps) {
|
|
|
618
605
|
function errText(err) {
|
|
619
606
|
return err instanceof Error ? err.message : String(err);
|
|
620
607
|
}
|
|
621
|
-
/**
|
|
622
|
-
* 安装。返回 usedAllowAllBuilds 供 UI 明确报告「该插件执行了构建脚本」。
|
|
623
|
-
* source 形如:`pkg@1.2.3`(npm 精确锁定)或 `github:owner/repo#sha`(锁 SHA)。
|
|
624
|
-
* 薄包装:调 makeAddViaLadder,非 ok 时 throw(对 legacy 调用方保持现行报错形状)。
|
|
625
|
-
*/
|
|
626
|
-
export async function addDshPlugin(source, deps = {}) {
|
|
627
|
-
const ladder = makeAddViaLadder({
|
|
628
|
-
runDshPlugin: deps.runDshPlugin ?? runDshPlugin,
|
|
629
|
-
allowAllBuilds: deps.allowAllBuilds,
|
|
630
|
-
});
|
|
631
|
-
const outcome = await ladder(source, deps.profileDir ?? webProfileDir());
|
|
632
|
-
if (outcome.class === 'ok') {
|
|
633
|
-
return { output: outcome.output, usedAllowAllBuilds: outcome.usedAllowAllBuilds === true };
|
|
634
|
-
}
|
|
635
|
-
throw rewritePnpmError(new Error(outcome.output));
|
|
636
|
-
}
|
|
637
608
|
/** 卸载(转发 pnpm remove;调用方须先做 live-disable)。 */
|
|
638
609
|
export async function removeDshPlugin(pkg, deps = {}) {
|
|
639
610
|
if (!isSafePluginTarget(pkg))
|
package/lib/core/host-api.js
CHANGED
|
@@ -161,6 +161,7 @@ export function createApiDispatcher(ctx) {
|
|
|
161
161
|
checkRegistryEntries: ctx.deps?.checkRegistryEntries ?? checkRegistryEntries,
|
|
162
162
|
npmLatest: ctx.deps?.npmLatest ?? npmLatest,
|
|
163
163
|
runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
|
|
164
|
+
scheduleRestart: ctx.deps?.scheduleRestart ?? scheduleRestart,
|
|
164
165
|
};
|
|
165
166
|
const cfg = () => ctx.controller.config;
|
|
166
167
|
return async function handleApi(req, res) {
|
|
@@ -273,7 +274,7 @@ export function createApiDispatcher(ctx) {
|
|
|
273
274
|
break;
|
|
274
275
|
}
|
|
275
276
|
case 'restart': {
|
|
276
|
-
const result = scheduleRestart(servingPort(req));
|
|
277
|
+
const result = d.scheduleRestart(servingPort(req));
|
|
277
278
|
payload = { ...result };
|
|
278
279
|
break;
|
|
279
280
|
}
|
package/lib/core/installed.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 已装插件识别(DESIGN.md §3):profile 的 package.json 是唯一事实源,
|
|
3
3
|
* 不引入额外状态文件。移植自 skillhub installed-plugins.ts(去 README 暂缓)。
|
|
4
|
+
*
|
|
5
|
+
* 完整性契约(2026-09-09):枚举结果必含 `complete`——只有当顶层 manifest 与每个
|
|
6
|
+
* 依赖的 package.json 都可读、可解析为非数组对象、且每项都能归类(DSH 插件 → items /
|
|
7
|
+
* 确认非 DSH → others)时才为 true;任一项「无法判断」即 complete:false(partial 结果
|
|
8
|
+
* 保留,该依赖不计入 others)。当前 complete 仅被 listMarket → dshm_search 消费;
|
|
9
|
+
* 已装列表(listInstalledWithMeta)/ dshm_list / CLI list·outdated / 升级路径的
|
|
10
|
+
* incomplete 展示与处理为后续独立任务。
|
|
4
11
|
*/
|
|
5
12
|
import { open, readFile } from 'node:fs/promises';
|
|
6
13
|
import { join, resolve } from 'node:path';
|
|
7
|
-
import { isSafePluginTarget, removeDshPlugin } from './dsh-cli.js';
|
|
8
14
|
import { webProfileDir } from './env.js';
|
|
9
15
|
const PKG_NAME_RE = /^(@[A-Za-z0-9-*~][A-Za-z0-9-*._~]*\/)?[A-Za-z0-9-._~]+$/;
|
|
10
16
|
export function isSafePkgName(raw) {
|
|
@@ -58,30 +64,74 @@ export function githubRepoFromRepository(raw) {
|
|
|
58
64
|
const m = /github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i.exec(url.trim());
|
|
59
65
|
return m ? `${m[1]}/${m[2]}` : null;
|
|
60
66
|
}
|
|
61
|
-
|
|
67
|
+
/** JSON 合法根:非 null、非数组的对象(typeof [] === 'object',数组必须显式排除)。 */
|
|
68
|
+
function isRecord(value) {
|
|
69
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
/** 单包 package.json 唯一读取实现:读不到 / 坏 JSON / 根非对象一律 ok:false。 */
|
|
72
|
+
async function readPkgJsonResult(dir) {
|
|
73
|
+
let raw;
|
|
62
74
|
try {
|
|
63
|
-
|
|
64
|
-
return raw && typeof raw === 'object' ? raw : null;
|
|
75
|
+
raw = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
|
|
65
76
|
}
|
|
66
77
|
catch {
|
|
67
|
-
return
|
|
78
|
+
return { ok: false };
|
|
68
79
|
}
|
|
80
|
+
if (!isRecord(raw))
|
|
81
|
+
return { ok: false };
|
|
82
|
+
return { ok: true, value: raw };
|
|
69
83
|
}
|
|
70
|
-
export async function
|
|
84
|
+
export async function readPkgJson(dir) {
|
|
85
|
+
const result = await readPkgJsonResult(dir);
|
|
86
|
+
return result.ok ? result.value ?? null : null;
|
|
87
|
+
}
|
|
88
|
+
/** 空 deps 统一无原型容器:与逐项解析产物保持同一原型语义,继承属性不得伪装成依赖成员。 */
|
|
89
|
+
function emptyDeps() {
|
|
90
|
+
return Object.create(null);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 顶层 profile package.json 唯一读取实现(完整性 + partial 语义):
|
|
94
|
+
* - 读不到 / 坏 JSON / 根非数组对象 → complete:false, deps:{}(manifest 缺失 ≠ 合法空 profile);
|
|
95
|
+
* - 合法但无 dependencies 字段,或 dependencies 为空对象 → complete:true, deps:{}(唯一合法空形态);
|
|
96
|
+
* - dependencies 存在但类型非法(null/数组/标量)→ complete:false, deps:{};
|
|
97
|
+
* - 逐项:key 不安全(isSafePkgName 拒绝 `__proto__`、`_`/`.` 开头分段等)或 spec 非字符串/纯空白
|
|
98
|
+
* → 该项计入 incomplete(complete:false)并跳过,其余合法项保留。
|
|
99
|
+
* 容器为无原型对象(Object.create(null))——第二层防御:特殊属性名不受 Object.prototype
|
|
100
|
+
* setter/继承语义影响,数据结构与早退路径的空容器保持一致。
|
|
101
|
+
*/
|
|
102
|
+
async function readProfileDepsResult(profileDir) {
|
|
103
|
+
let raw;
|
|
71
104
|
try {
|
|
72
|
-
|
|
73
|
-
if (!raw || typeof raw !== 'object' || !raw.dependencies || typeof raw.dependencies !== 'object')
|
|
74
|
-
return {};
|
|
75
|
-
const out = {};
|
|
76
|
-
for (const [name, spec] of Object.entries(raw.dependencies)) {
|
|
77
|
-
if (typeof spec === 'string' && spec !== '')
|
|
78
|
-
out[name] = spec;
|
|
79
|
-
}
|
|
80
|
-
return out;
|
|
105
|
+
raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
81
106
|
}
|
|
82
107
|
catch {
|
|
83
|
-
return {};
|
|
108
|
+
return { complete: false, deps: emptyDeps() };
|
|
109
|
+
}
|
|
110
|
+
if (!isRecord(raw))
|
|
111
|
+
return { complete: false, deps: emptyDeps() };
|
|
112
|
+
if (!Object.prototype.hasOwnProperty.call(raw, 'dependencies'))
|
|
113
|
+
return { complete: true, deps: emptyDeps() };
|
|
114
|
+
const dependencies = raw.dependencies;
|
|
115
|
+
if (!isRecord(dependencies))
|
|
116
|
+
return { complete: false, deps: emptyDeps() };
|
|
117
|
+
let complete = true;
|
|
118
|
+
const deps = emptyDeps();
|
|
119
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
120
|
+
if (!isSafePkgName(name)) {
|
|
121
|
+
complete = false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (typeof spec !== 'string' || spec.trim() === '') {
|
|
125
|
+
complete = false;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
deps[name] = spec;
|
|
84
129
|
}
|
|
130
|
+
return { complete, deps };
|
|
131
|
+
}
|
|
132
|
+
/** 宽松读取(README 路径):只返回可确认的依赖项,个别非法项被跳过而不是整体丢弃。 */
|
|
133
|
+
export async function readProfileDeps(profileDir) {
|
|
134
|
+
return (await readProfileDepsResult(profileDir)).deps;
|
|
85
135
|
}
|
|
86
136
|
function sanitizePkgJson(raw, fallbackName) {
|
|
87
137
|
return {
|
|
@@ -93,17 +143,31 @@ function sanitizePkgJson(raw, fallbackName) {
|
|
|
93
143
|
githubRepo: githubRepoFromRepository(raw.repository),
|
|
94
144
|
};
|
|
95
145
|
}
|
|
96
|
-
/** 枚举 web profile 已安装插件(只读)。 */
|
|
146
|
+
/** 枚举 web profile 已安装插件(只读)。complete:false 时 items/others 为 partial 结果(已确认部分保留)。 */
|
|
97
147
|
export async function listInstalledPlugins(profileDir = webProfileDir()) {
|
|
98
148
|
const root = resolve(profileDir);
|
|
99
|
-
const
|
|
149
|
+
const result = await readProfileDepsResult(root);
|
|
150
|
+
let complete = result.complete;
|
|
151
|
+
const deps = result.deps;
|
|
100
152
|
const items = [];
|
|
101
153
|
let others = 0;
|
|
102
154
|
for (const pkg of Object.keys(deps).sort()) {
|
|
103
155
|
const spec = deps[pkg];
|
|
104
156
|
const dir = resolvePluginDir(root, pkg, spec);
|
|
105
|
-
|
|
106
|
-
|
|
157
|
+
if (!dir) {
|
|
158
|
+
// 依赖键不安全、目录无法解析 → 无法判断(不冒充非 DSH)
|
|
159
|
+
complete = false;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const rawResult = await readPkgJsonResult(dir);
|
|
163
|
+
if (!rawResult.ok) {
|
|
164
|
+
// package.json 缺失/不可读/坏 JSON/根非对象 → 无法判断
|
|
165
|
+
complete = false;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const raw = rawResult.value;
|
|
169
|
+
if (!('dsh' in raw)) {
|
|
170
|
+
// 确认非 DSH 依赖
|
|
107
171
|
others += 1;
|
|
108
172
|
continue;
|
|
109
173
|
}
|
|
@@ -121,23 +185,7 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
|
|
|
121
185
|
githubRepo: githubRepoFromRepository(raw.repository),
|
|
122
186
|
});
|
|
123
187
|
}
|
|
124
|
-
return { items, others, profileDir: root };
|
|
125
|
-
}
|
|
126
|
-
/** 从 web profile 卸载已安装的 dsh 插件。pkg 必须来自 profile 依赖(先 live-disable,见 market.ts)。 */
|
|
127
|
-
export async function removeInstalledPlugin(pkg, profileDir = webProfileDir(), deps = {}) {
|
|
128
|
-
const key = String(pkg || '').trim();
|
|
129
|
-
if (!isSafePkgName(key) || !isSafePluginTarget(key))
|
|
130
|
-
throw new Error(`无效插件包名: ${pkg}`);
|
|
131
|
-
const root = resolve(profileDir);
|
|
132
|
-
const listed = await readProfileDeps(root);
|
|
133
|
-
if (!(key in listed))
|
|
134
|
-
throw new Error(`web profile 未安装该插件: ${key}`);
|
|
135
|
-
const dir = resolvePluginDir(root, key, listed[key]);
|
|
136
|
-
const raw = dir ? await readPkgJson(dir) : null;
|
|
137
|
-
if (!raw || !('dsh' in raw))
|
|
138
|
-
throw new Error(`不是 dsh 插件: ${key}`);
|
|
139
|
-
await removeDshPlugin(key, deps);
|
|
140
|
-
return { pkg: key };
|
|
188
|
+
return { items, others, complete, profileDir: root };
|
|
141
189
|
}
|
|
142
190
|
// ---------- README 预览(借鉴 skillhub,64KB 截断) ----------
|
|
143
191
|
const README_MAX_BYTES = 64 * 1024;
|
|
@@ -170,8 +218,11 @@ export async function readInstalledPluginReadme(pkg, profileDir = webProfileDir(
|
|
|
170
218
|
throw new Error(`无效插件包名: ${pkg}`);
|
|
171
219
|
const root = resolve(profileDir);
|
|
172
220
|
const deps = await readProfileDeps(root);
|
|
173
|
-
|
|
221
|
+
// 授权边界必须用 own-property 判定:`in` 会沿原型链命中继承属性(如 'constructor'),
|
|
222
|
+
// 绕过「pkg 必须来自 profile dependencies」的成员约束
|
|
223
|
+
if (!Object.hasOwn(deps, key)) {
|
|
174
224
|
throw new Error(`web profile 未安装该插件: ${key}`);
|
|
225
|
+
}
|
|
175
226
|
const dir = resolvePluginDir(root, key, deps[key]);
|
|
176
227
|
if (!dir)
|
|
177
228
|
throw new Error(`无法解析插件目录: ${key}`);
|
package/lib/core/market.js
CHANGED
|
@@ -227,7 +227,8 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
227
227
|
};
|
|
228
228
|
}
|
|
229
229
|
const installed = await installedTask;
|
|
230
|
-
|
|
230
|
+
// 完整性两层来源:枚举 throw → null;枚举 partial resolve → complete:false(installed.ts 完整性契约)
|
|
231
|
+
const installedComplete = installed !== null && installed.complete === true;
|
|
231
232
|
const installedItems = installed?.items ?? [];
|
|
232
233
|
// 全量统计 + query/category 过滤 + 分页(同步,极轻)
|
|
233
234
|
const all = loaded.registry.plugins;
|
package/lib/core/restart.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 自重启:优先使用 DSH launcher 提供的 appExit 生命周期钩子,把重启交给
|
|
3
|
+
* systemd 等服务管理器的 Restart 策略;没有受管服务时再退回 detached helper。
|
|
4
|
+
* 只有无法使用 appExit 且能从 cgroup 识别服务时,才通过 manager-owned transient
|
|
5
|
+
* systemd-run 任务调用 systemctl 作为兼容兜底,避免 helper 留在待停止 unit cgroup。
|
|
5
6
|
* 安全:restart 端点必须通过 trustedRestartRequest(Origin 与 Host 同源)。
|
|
6
7
|
*/
|
|
7
|
-
import { spawn } from 'node:child_process';
|
|
8
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
8
9
|
import { readFileSync } from 'node:fs';
|
|
9
10
|
import { tmpdir } from 'node:os';
|
|
10
11
|
import { join } from 'node:path';
|
|
@@ -96,17 +97,32 @@ export function systemdUnitName(cgroupText) {
|
|
|
96
97
|
}
|
|
97
98
|
return null;
|
|
98
99
|
}
|
|
100
|
+
function shellQuote(part) {
|
|
101
|
+
return `'${part.replace(/'/g, "'\\''")}'`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build a transient systemd-run command for callers that do not have appExit.
|
|
105
|
+
* The transient service is owned by the manager, not by the DSH unit that it
|
|
106
|
+
* will restart, so KillMode=control-group cannot kill the restart command.
|
|
107
|
+
*/
|
|
99
108
|
export function systemdRestartArgv(opts) {
|
|
100
109
|
const unit = systemdUnitName(opts.cgroup);
|
|
101
110
|
if (unit === null)
|
|
102
111
|
return null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
const userManager = opts.cgroup.includes('/user.slice/');
|
|
113
|
+
const targetArgs = userManager
|
|
114
|
+
? ['systemctl', '--user', 'restart', '--no-block', unit]
|
|
115
|
+
: ['systemctl', 'restart', '--no-block', unit];
|
|
116
|
+
const script = `sleep 0.2; exec ${targetArgs.map(shellQuote).join(' ')}`;
|
|
117
|
+
const transientArgs = [
|
|
118
|
+
'--unit', `dshm-restart-${opts.pid ?? process.pid}-${Date.now()}.service`,
|
|
119
|
+
'--collect', '--service-type=exec', '/bin/sh', '-c', script,
|
|
120
|
+
];
|
|
121
|
+
if (userManager)
|
|
122
|
+
return { file: 'systemd-run', args: ['--user', ...transientArgs] };
|
|
123
|
+
if (opts.uid === 0)
|
|
124
|
+
return { file: 'systemd-run', args: transientArgs };
|
|
125
|
+
return { file: 'sudo', args: ['-n', 'systemd-run', ...transientArgs] };
|
|
110
126
|
}
|
|
111
127
|
export function restartLaunch() {
|
|
112
128
|
const launch = dshArgv();
|
|
@@ -129,6 +145,79 @@ function respawnInvocation(launch, platform = process.platform) {
|
|
|
129
145
|
detached: false,
|
|
130
146
|
};
|
|
131
147
|
}
|
|
148
|
+
/** DSH's launcher treats a non-zero appExit as a service-manager restart. */
|
|
149
|
+
export const MANAGED_RESTART_EXIT_CODE = 75;
|
|
150
|
+
/**
|
|
151
|
+
* Detect a process started by a service manager without depending on the unit
|
|
152
|
+
* name or on a particular DSH release's cgroup layout.
|
|
153
|
+
*/
|
|
154
|
+
export function serviceManagedEnv(env = process.env) {
|
|
155
|
+
const present = (value) => typeof value === 'string' && value.trim() !== '';
|
|
156
|
+
return typeof env === 'object' && env !== null
|
|
157
|
+
&& (present(env.INVOCATION_ID) || present(env.NOTIFY_SOCKET));
|
|
158
|
+
}
|
|
159
|
+
function statusContainsExitCode(status, code) {
|
|
160
|
+
return String(status ?? '').split(/\s+/u).some((token) => token === String(code) || token === `STATUS=${code}`);
|
|
161
|
+
}
|
|
162
|
+
export function restartPolicyAllowsExit(policy, code = MANAGED_RESTART_EXIT_CODE) {
|
|
163
|
+
if (policy === null || policy === undefined)
|
|
164
|
+
return false;
|
|
165
|
+
if (policy.restart !== 'on-failure' && policy.restart !== 'always')
|
|
166
|
+
return false;
|
|
167
|
+
if (statusContainsExitCode(policy.successExitStatus, code))
|
|
168
|
+
return false;
|
|
169
|
+
if (statusContainsExitCode(policy.restartPreventExitStatus, code))
|
|
170
|
+
return false;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
/** Query policy before voluntarily exiting; failure fails over to transient restart. */
|
|
174
|
+
export function readSystemdRestartPolicy(opts) {
|
|
175
|
+
const unit = systemdUnitName(opts.cgroup);
|
|
176
|
+
if (unit === null)
|
|
177
|
+
return null;
|
|
178
|
+
const userManager = opts.cgroup.includes('/user.slice/');
|
|
179
|
+
const file = userManager || opts.uid === 0 ? 'systemctl' : 'sudo';
|
|
180
|
+
const args = userManager
|
|
181
|
+
? ['--user', 'show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit]
|
|
182
|
+
: opts.uid === 0
|
|
183
|
+
? ['show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit]
|
|
184
|
+
: ['-n', 'systemctl', 'show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit];
|
|
185
|
+
try {
|
|
186
|
+
const output = execFileSync(file, args, {
|
|
187
|
+
encoding: 'utf8',
|
|
188
|
+
timeout: 1_000,
|
|
189
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
190
|
+
env: process.env,
|
|
191
|
+
});
|
|
192
|
+
const [restart = '', successExitStatus = '', restartPreventExitStatus = ''] = String(output).split(/\r?\n/u);
|
|
193
|
+
return { restart: restart.trim(), successExitStatus: successExitStatus.trim(), restartPreventExitStatus: restartPreventExitStatus.trim() };
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function reportRestartError(scope, error) {
|
|
200
|
+
try {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
process.stderr.write(`[dsh-m] ${scope} failed: ${message}\n`);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* stderr may already be closed while the host is shutting down */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Read the optional launcher hook without coupling core code to Cordis types. */
|
|
209
|
+
export function appExitFromContext(context) {
|
|
210
|
+
const getter = context?.get;
|
|
211
|
+
if (typeof getter !== 'function')
|
|
212
|
+
return undefined;
|
|
213
|
+
try {
|
|
214
|
+
const candidate = getter.call(context, 'appExit');
|
|
215
|
+
return typeof candidate === 'function' ? candidate : undefined;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
132
221
|
function restartHelperSource(spawned, launch, logs, port) {
|
|
133
222
|
return [
|
|
134
223
|
"const { spawn } = require('node:child_process')",
|
|
@@ -181,19 +270,35 @@ function restartHelperSource(spawned, launch, logs, port) {
|
|
|
181
270
|
}
|
|
182
271
|
export function scheduleRestart(port = null, deps = {}) {
|
|
183
272
|
const pid = deps.pid ?? process.pid;
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
273
|
+
const cgroup = deps.cgroup ?? readProcCgroup();
|
|
274
|
+
const uid = deps.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 1);
|
|
275
|
+
const systemd = systemdRestartArgv({ cgroup, uid, pid });
|
|
276
|
+
const managed = typeof deps.appExit === 'function' && serviceManagedEnv(deps.env ?? process.env);
|
|
277
|
+
const policy = deps.restartPolicy ?? (managed && systemd !== null ? readSystemdRestartPolicy({ cgroup, uid }) : null);
|
|
278
|
+
if (managed && (systemd === null || restartPolicyAllowsExit(policy))) {
|
|
279
|
+
const timer = (deps.setTimeout ?? setTimeout)(() => deps.appExit(MANAGED_RESTART_EXIT_CODE), 150);
|
|
280
|
+
timer.unref?.();
|
|
281
|
+
return { pid, helperPid: undefined, via: 'app-exit' };
|
|
282
|
+
}
|
|
188
283
|
if (systemd !== null) {
|
|
189
284
|
;
|
|
190
285
|
(deps.setTimeout ?? setTimeout)(() => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
286
|
+
try {
|
|
287
|
+
const helper = (deps.spawn ?? spawn)(systemd.file, systemd.args, {
|
|
288
|
+
detached: true,
|
|
289
|
+
stdio: 'ignore',
|
|
290
|
+
env: process.env,
|
|
291
|
+
});
|
|
292
|
+
helper.once?.('error', (error) => reportRestartError('transient restart helper', error));
|
|
293
|
+
helper.once?.('exit', (code, signal) => {
|
|
294
|
+
if (code !== 0)
|
|
295
|
+
reportRestartError('transient restart helper', `exit=${code ?? 'null'} signal=${signal ?? 'none'}`);
|
|
296
|
+
});
|
|
297
|
+
helper.unref();
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
reportRestartError('transient restart helper', error);
|
|
301
|
+
}
|
|
197
302
|
}, 500);
|
|
198
303
|
return { pid, helperPid: undefined, via: 'systemd' };
|
|
199
304
|
}
|
|
@@ -202,12 +307,29 @@ export function scheduleRestart(port = null, deps = {}) {
|
|
|
202
307
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
203
308
|
const logOut = join(tmpdir(), `dshm-restart-${stamp}.out.log`);
|
|
204
309
|
const logErr = join(tmpdir(), `dshm-restart-${stamp}.err.log`);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
310
|
+
let helper;
|
|
311
|
+
try {
|
|
312
|
+
helper = (deps.spawn ?? spawn)((deps.nodeExecutable ?? nodeExecutable)(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
|
|
313
|
+
detached: true,
|
|
314
|
+
stdio: 'ignore',
|
|
315
|
+
env: process.env,
|
|
316
|
+
});
|
|
317
|
+
helper.once?.('error', (error) => reportRestartError('detached restart helper', error));
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
// Do not terminate the live host if the replacement could not be started.
|
|
321
|
+
reportRestartError('detached restart helper', error);
|
|
322
|
+
return { pid, helperPid: undefined, via: 'helper' };
|
|
323
|
+
}
|
|
210
324
|
helper.unref();
|
|
211
|
-
(deps.setTimeout ?? setTimeout)(() =>
|
|
325
|
+
(deps.setTimeout ?? setTimeout)(() => {
|
|
326
|
+
try {
|
|
327
|
+
;
|
|
328
|
+
(deps.kill ?? process.kill)(pid, 'SIGTERM');
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
reportRestartError('detached host shutdown', error);
|
|
332
|
+
}
|
|
333
|
+
}, 500);
|
|
212
334
|
return { pid, helperPid: helper.pid, via: 'helper' };
|
|
213
335
|
}
|
package/lib/host.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createApiDispatcher } from './core/host-api.js';
|
|
|
4
4
|
import { bindLoaderHost } from './core/live-plugin.js';
|
|
5
5
|
import { createRegistryController } from './core/registry-controller.js';
|
|
6
6
|
import { registerTools } from './tools.js';
|
|
7
|
+
import { appExitFromContext, scheduleRestart } from './core/restart.js';
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const pkg = require('../package.json');
|
|
9
10
|
export const name = 'dshm';
|
|
@@ -20,7 +21,11 @@ export function apply(ctx, config) {
|
|
|
20
21
|
// registry controller:active config / configured / pending / rejected 分离 + generation fence;
|
|
21
22
|
// tools 与 Host API 共用同一 active config object(apply 原地更新字段,live 生效)
|
|
22
23
|
const controller = createRegistryController(config);
|
|
23
|
-
|
|
24
|
+
// DSH 0.1.2-rc.1 and 0.1.5-rc.1 both expose appExit through dsh-cmdline;
|
|
25
|
+
// using it avoids guessing the service unit from release-specific cgroups.
|
|
26
|
+
const appExit = appExitFromContext(ctx);
|
|
27
|
+
const restart = (port = null) => scheduleRestart(port, { appExit });
|
|
28
|
+
registerTools(ctx, controller.config, { restart });
|
|
24
29
|
// 设置页(GUI 设置卡片的宿主命名空间):applies 'live',scope 提供 get/update/watch
|
|
25
30
|
ctx.inject(['settings'], (c) => {
|
|
26
31
|
const settings = c.settings;
|
|
@@ -35,7 +40,7 @@ export function apply(ctx, config) {
|
|
|
35
40
|
// 本地 API:单路由 + method 分发(防护与状态映射在 core/host-api.ts)
|
|
36
41
|
ctx.inject(['webServer'], (c) => {
|
|
37
42
|
const server = c.webServer;
|
|
38
|
-
const handleApi = createApiDispatcher({ controller, pkg });
|
|
43
|
+
const handleApi = createApiDispatcher({ controller, pkg, deps: { scheduleRestart: restart } });
|
|
39
44
|
server.register({
|
|
40
45
|
kind: 'exact',
|
|
41
46
|
path: '/dshm',
|
package/lib/tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { installTimeoutMs } from './core/env.js';
|
|
3
3
|
import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, } from './core/market.js';
|
|
4
|
-
import { scheduleRestart } from './core/restart.js';
|
|
4
|
+
import { appExitFromContext, scheduleRestart } from './core/restart.js';
|
|
5
5
|
export const CATEGORY_LABELS = {
|
|
6
6
|
market: '市场',
|
|
7
7
|
tools: '工具',
|
|
@@ -15,18 +15,6 @@ function cloneJson(value) {
|
|
|
15
15
|
function summaryOf(state) {
|
|
16
16
|
return { isDefault: state.isDefault, status: state.status, stale: state.stale };
|
|
17
17
|
}
|
|
18
|
-
function matchInstalledByEntry(entry, installed) {
|
|
19
|
-
return installed.find((it) => {
|
|
20
|
-
if (entry.npm && (it.pkg === entry.npm || it.name === entry.npm))
|
|
21
|
-
return true;
|
|
22
|
-
if (entry.github && it.source === 'github') {
|
|
23
|
-
const m = /^github:([^#]+)/.exec(it.spec);
|
|
24
|
-
if (m && m[1] === entry.github)
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
return false;
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
18
|
export function registerTools(ctx, cfg, deps = {}) {
|
|
31
19
|
const timeoutMs = cfg.timeoutMs ?? 20_000;
|
|
32
20
|
const m = {
|
|
@@ -36,6 +24,7 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
36
24
|
uninstallPlugin: deps.uninstallPlugin ?? uninstallPlugin,
|
|
37
25
|
upgradePlugin: deps.upgradePlugin ?? upgradePlugin,
|
|
38
26
|
};
|
|
27
|
+
const restart = deps.restart ?? ((port = null) => scheduleRestart(port, { appExit: appExitFromContext(ctx) }));
|
|
39
28
|
ctx.tools.register(defineTool({
|
|
40
29
|
name: 'dshm_search',
|
|
41
30
|
description: 'Search your personal DSH plugin marketplace (dsh-m) and show clickable plugin cards. ALWAYS call this instead of web_search or bash when the user wants to find/recommend/browse their curated DSH plugins (插件). Call EXACTLY ONCE per user message; extract a real keyword (主题, 搜索) rather than pasting the whole sentence. Omit query to browse all listings. After cards appear, reply with AT MOST one short sentence. Do not print install commands.',
|
|
@@ -77,18 +66,18 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
77
66
|
withLatest: false,
|
|
78
67
|
namespace: 'host',
|
|
79
68
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
69
|
+
// 安装标注唯一来源:listMarket 的单次 profile 快照。
|
|
70
|
+
// 状态不完整且存在可被误标的条目时 fail-closed——不把未知安装状态呈现成未安装;
|
|
71
|
+
// 空结果(registry 不可用/超时/无匹配)无可误标条目,维持优雅空结果。
|
|
72
|
+
if (!result.installedComplete && result.items.length > 0) {
|
|
73
|
+
throw new Error('读取 web profile 安装状态失败,安装标注不可用;请稍后重试');
|
|
74
|
+
}
|
|
86
75
|
return cloneJson({
|
|
87
76
|
query: String(args.query || ''),
|
|
88
77
|
category,
|
|
89
78
|
total: result.total,
|
|
90
79
|
registry: summaryOf(result.registryState),
|
|
91
|
-
items:
|
|
80
|
+
items: result.items.map((e) => ({
|
|
92
81
|
id: e.id,
|
|
93
82
|
name: e.name,
|
|
94
83
|
description: e.description,
|
|
@@ -252,13 +241,13 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
252
241
|
}));
|
|
253
242
|
ctx.tools.register(defineTool({
|
|
254
243
|
name: 'dshm_restart',
|
|
255
|
-
description: 'Restart DSH web so newly installed/uninstalled/upgraded plugins take effect. ONLY call after the user agrees (用户同意重启后).
|
|
244
|
+
description: 'Restart DSH web so newly installed/uninstalled/upgraded plugins take effect. ONLY call after the user agrees (用户同意重启后). DSH Web reconnects in the background after the service comes back.',
|
|
256
245
|
parameters: {},
|
|
257
246
|
output: {
|
|
258
247
|
schema: { type: 'object', additionalProperties: true },
|
|
259
248
|
render: (_args, value) => [{
|
|
260
249
|
type: 'text',
|
|
261
|
-
text: `已请求重启 DSH web(via ${value.via}
|
|
250
|
+
text: `已请求重启 DSH web(via ${value.via})。服务恢复后 DSH Web 会在后台自动重连。对用户最多一句短话。`,
|
|
262
251
|
}],
|
|
263
252
|
presentationMeta: (_args, value) => ({ kind: 'dshm-restart', ...value }),
|
|
264
253
|
},
|
|
@@ -270,7 +259,7 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
270
259
|
}),
|
|
271
260
|
timeoutMs: 15_000,
|
|
272
261
|
async execute() {
|
|
273
|
-
return cloneJson(
|
|
262
|
+
return cloneJson(restart(null));
|
|
274
263
|
},
|
|
275
264
|
}));
|
|
276
265
|
ctx.inject(['systemPrompt'], (c) => {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-m",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "DSH Marketplace — 个人自用的 DeepSeek Harness 插件市场:收录、安装、卸载、升级 DSH 插件",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/host.js",
|
|
8
8
|
"bin": {
|
|
9
|
-
"dshm": "
|
|
9
|
+
"dshm": "lib/cli.js"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./lib/host.js",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"build": "node scripts/build.mjs",
|
|
46
46
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
47
|
"test": "node --test tests/*.test.mjs",
|
|
48
|
+
"pretest": "npm run build",
|
|
48
49
|
"prepare": "npm run build"
|
|
49
50
|
},
|
|
50
51
|
"dsh": {
|