@fffangchenghao/dsh-autoupdater 0.6.2 → 0.6.4
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.md +6 -1
- package/main.js +83 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,14 +97,19 @@ dsh plugin --profile web add github:fangchenghao/dsh-autoupdater
|
|
|
97
97
|
- **`dsh.cmd` 调用**:PATH / sibling 找到的 `.cmd` 经 `cmd.exe` 执行并正确加引号;超时用 `taskkill /T /F` 结束整棵进程树,避免留下 pnpm 子进程。
|
|
98
98
|
- **环境变量**:没有 `HOME` 时用 `homedir()`(Windows 的 `USERPROFILE`);PATH 兼容 `Path` 键,并额外查找 `%APPDATA%\npm` / `%LOCALAPPDATA%\pnpm`。
|
|
99
99
|
- Windows 上不执行 DSH 本体更新(交给安装器/App 自带更新器),插件换版后同样只提示重启。
|
|
100
|
+
- **多 profile 选择**:优先使用桌面 App 注入的 `DSH_PROFILE_DIR` / `DSH_PROFILE`;桌面版在两者都缺失时默认 `desktop`,不会因为某个测试 profile 的 `node_modules` 更新时间更近而误选。
|
|
100
101
|
|
|
101
102
|
开发机是 macOS;Windows 分支是按 Node/Electron 的跨平台语义改的,并做了逻辑测试,但**尚未在真实 Windows 机器上端到端跑过**。如果 Windows 上仍失败,请把面板日志和 `%USERPROFILE%\.dsh\autoupdater\history.jsonl` 发到 GitHub issue。
|
|
102
103
|
|
|
104
|
+
### 上下文销毁修复(0.6.4)
|
|
105
|
+
|
|
106
|
+
Cordis 在插件热重载/卸载时会销毁插件上下文。0.6.1 的启动 Promise 可能在销毁后才执行 `reschedule()`,留下一个已失效上下文的定时器;次日 04:30 它会报 `cannot get required service "pluginManager" in inactive context`,而同一时间新实例的插件更新其实已经成功。0.6.4 用 `disposed` 标志在销毁后中止定时器和在途检查,并忽略这种过期上下文错误。
|
|
107
|
+
|
|
103
108
|
## 维护说明
|
|
104
109
|
|
|
105
110
|
- 上游项目:[niubi6668889595/dsh-autoupdater](https://github.com/niubi6668889595/dsh-autoupdater)(MIT)。
|
|
106
111
|
- 本分支新增:桌面版 CLI 换版、npmmirror 优先、跳过 `link:`/git 来源、只有真正升级成功才算“已应用”、Windows 空闲判定/.cmd 调用适配;完整差异见 git log。
|
|
107
|
-
- 发布渠道:npm `@fffangchenghao/dsh-autoupdater`,GitHub `fangchenghao/dsh-autoupdater`,tag `v0.6.
|
|
112
|
+
- 发布渠道:npm `@fffangchenghao/dsh-autoupdater`,GitHub `fangchenghao/dsh-autoupdater`,tag `v0.6.4`。
|
|
108
113
|
- 与上游同步:把上游加为 remote,rebase/merge 到本分支后重新发版即可。
|
|
109
114
|
|
|
110
115
|
## 安全模型
|
package/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
|
|
2
2
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
3
3
|
import { execFileSync, spawn } from 'node:child_process';
|
|
4
|
-
import { basename, delimiter, dirname, join } from 'node:path';
|
|
4
|
+
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
|
|
7
7
|
/** 本插件的真实包名(上游名或发布用的 scoped 名),用于把自己排除在自动更新之外。 */
|
|
@@ -140,6 +140,20 @@ async function readJson(path) {
|
|
|
140
140
|
try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; }
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* 与 @deepseek-ai/dsh-home-paths 的 resolveDshHome 对齐:
|
|
145
|
+
* $DSH_HOME 优先(支持 ~ / ~\ 前缀,空/全空白视为未设置),否则 ~/.dsh。
|
|
146
|
+
* 这样 Windows 上没有 HOME、只设置 DSH_HOME 或 USERPROFILE 时都能落到同一目录。
|
|
147
|
+
*/
|
|
148
|
+
function resolveDshHome(env = process.env) {
|
|
149
|
+
const raw = typeof env.DSH_HOME === 'string' ? env.DSH_HOME : '';
|
|
150
|
+
if (raw.trim().length === 0) return join(homedir(), '.dsh');
|
|
151
|
+
let expanded = raw;
|
|
152
|
+
if (raw === '~') expanded = homedir();
|
|
153
|
+
else if (raw.startsWith('~/') || raw.startsWith('~\\')) expanded = join(homedir(), raw.slice(2));
|
|
154
|
+
return resolve(expanded);
|
|
155
|
+
}
|
|
156
|
+
|
|
143
157
|
/**
|
|
144
158
|
* 递归找出目录里最新的文件 mtime(毫秒)。
|
|
145
159
|
* 目录不存在 → 0(没有会话 = 空闲);根目录读不了 → 抛错(调用方保守当忙)。
|
|
@@ -244,7 +258,7 @@ export const inject = ['agents', 'pluginManager', 'webServer'];
|
|
|
244
258
|
|
|
245
259
|
export function apply(ctx, rawConfig = {}) {
|
|
246
260
|
// Windows 上通常没有 HOME,只有 USERPROFILE;homedir() 会选对。
|
|
247
|
-
const home =
|
|
261
|
+
const home = resolveDshHome();
|
|
248
262
|
const stateDir = join(home, 'autoupdater');
|
|
249
263
|
const statePath = join(stateDir, 'status.json');
|
|
250
264
|
const historyPath = join(stateDir, 'history.jsonl');
|
|
@@ -257,33 +271,49 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
257
271
|
// 初始配置 = YAML 注入值;运行时覆盖来自 config.json(每次调度/检查前重读)
|
|
258
272
|
let config = normalizeConfig(rawConfig);
|
|
259
273
|
|
|
274
|
+
// Cordis 销毁插件上下文后,未完成的异步检查不能再访问 ctx(会抛
|
|
275
|
+
// "cannot get required service ... in inactive context"),也不能再排下一轮定时器。
|
|
276
|
+
// 0.6.1 的启动 Promise 在 dispose 后仍会 reschedule(),导致次日 04:30 留下一个
|
|
277
|
+
// 已销毁上下文的定时器;0.6.4 起用 disposed 统一拦截。
|
|
278
|
+
let disposed = false;
|
|
279
|
+
|
|
260
280
|
async function reloadHotConfig() {
|
|
261
281
|
const hot = await readJson(hotConfigPath);
|
|
262
282
|
if (hot) config = normalizeConfig({ ...rawConfig, ...hot });
|
|
263
283
|
}
|
|
264
284
|
|
|
285
|
+
/** 是否运行在 Electron 桌面 App 的进程里(macOS/Windows 安装包)。 */
|
|
286
|
+
const isDesktopApp = () => typeof process.versions?.electron === 'string';
|
|
287
|
+
|
|
265
288
|
// ── profile 目录探测(磁盘版本回读用)──
|
|
266
|
-
// DSH_PROFILE_DIR
|
|
267
|
-
//
|
|
289
|
+
// 优先级与 DSH 自身一致:DSH_PROFILE_DIR → DSH_PROFILE → 扫描 profiles。
|
|
290
|
+
// 桌面版在 DSH_PROFILE 缺失时默认 desktop,避免多 profile 时误选最近修改的测试 profile。
|
|
268
291
|
let profileDir = process.env.DSH_PROFILE_DIR || null;
|
|
292
|
+
if (profileDir && !existsSync(profileDir)) profileDir = null;
|
|
293
|
+
if (!profileDir) {
|
|
294
|
+
const configuredProfile = process.env.DSH_PROFILE || (isDesktopApp() ? 'desktop' : '');
|
|
295
|
+
if (configuredProfile) {
|
|
296
|
+
const candidate = join(home, 'profiles', configuredProfile);
|
|
297
|
+
if (existsSync(candidate)) profileDir = candidate;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
269
300
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
301
|
+
if (!profileDir) {
|
|
302
|
+
const base = join(home, 'profiles');
|
|
303
|
+
const candidates = readdirSync(base)
|
|
304
|
+
.map((name) => join(base, name))
|
|
305
|
+
.filter((dir) => existsSync(join(dir, 'node_modules')));
|
|
306
|
+
if (candidates.length === 1) {
|
|
307
|
+
profileDir = candidates[0];
|
|
308
|
+
} else if (candidates.length > 1) {
|
|
309
|
+
profileDir = candidates
|
|
310
|
+
.map((dir) => ({ dir, m: statSync(join(dir, 'node_modules')).mtimeMs }))
|
|
311
|
+
.sort((a, b) => b.m - a.m)[0].dir;
|
|
312
|
+
}
|
|
280
313
|
}
|
|
281
314
|
if (profileDir && config.verbose) log('profileDir detected:', profileDir);
|
|
282
315
|
} catch {}
|
|
283
316
|
|
|
284
|
-
/** 是否运行在 Electron 桌面 App 的进程里(macOS/Windows 安装包)。 */
|
|
285
|
-
const isDesktopApp = () => typeof process.versions?.electron === 'string';
|
|
286
|
-
|
|
287
317
|
/**
|
|
288
318
|
* 找一条能真正写 profile 的 `dsh plugin` 入口。
|
|
289
319
|
*
|
|
@@ -369,15 +399,17 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
369
399
|
/** 等到空闲;true=达成,false=超时。双重采样防瞬时空档误判。 */
|
|
370
400
|
async function waitIdle(maxWaitSec) {
|
|
371
401
|
const deadline = nowSec() + maxWaitSec;
|
|
372
|
-
while (nowSec() < deadline) {
|
|
402
|
+
while (!disposed && nowSec() < deadline) {
|
|
403
|
+
if (disposed) return false;
|
|
373
404
|
if (isIdle()) {
|
|
374
405
|
await sleep(15000);
|
|
406
|
+
if (disposed) return false;
|
|
375
407
|
if (isIdle()) return true;
|
|
376
408
|
continue;
|
|
377
409
|
}
|
|
378
410
|
await sleep(30000);
|
|
379
411
|
}
|
|
380
|
-
return isIdle();
|
|
412
|
+
return !disposed && isIdle();
|
|
381
413
|
}
|
|
382
414
|
|
|
383
415
|
async function saveState() {
|
|
@@ -476,7 +508,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
476
508
|
async function runPluginCliAdd(pkg, version) {
|
|
477
509
|
const cli = resolvePluginCli();
|
|
478
510
|
if (!cli) {
|
|
479
|
-
const reason =
|
|
511
|
+
const reason = `找不到可用的 dsh CLI(platform=${process.platform}, resourcesPath=${process.resourcesPath ?? 'n/a'}, execPath=${process.execPath})`;
|
|
480
512
|
log('CLI add:', reason);
|
|
481
513
|
return { ok: false, code: -1, output: '', reason };
|
|
482
514
|
}
|
|
@@ -765,11 +797,13 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
765
797
|
const ATTEMPT_TTL = 24 * 3600 * 1000;
|
|
766
798
|
|
|
767
799
|
async function checkAndApplyPlugins(report, { exemptQuiet = false, successes = [] } = {}) {
|
|
800
|
+
if (disposed) return;
|
|
768
801
|
const pm = ctx.pluginManager;
|
|
769
802
|
if (!pm?.listBundles || !pm?.installBundle) return;
|
|
770
803
|
const now = Date.now();
|
|
771
804
|
for (const [k, ts] of recentAttempts) if (now - ts > ATTEMPT_TTL) recentAttempts.delete(k);
|
|
772
805
|
const bundles = await pm.listBundles();
|
|
806
|
+
if (disposed) return;
|
|
773
807
|
const items = (bundles ?? []).filter((b) => {
|
|
774
808
|
if (!b?.installed || !b?.removable) return false;
|
|
775
809
|
if (b.name === SELF_NAME || b.name === 'dsh-autoupdater') return false;
|
|
@@ -789,6 +823,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
789
823
|
let consecutiveNetFails = 0;
|
|
790
824
|
const NET_FAIL_LIMIT = 4;
|
|
791
825
|
for (const b of items) {
|
|
826
|
+
if (disposed) return;
|
|
792
827
|
if (consecutiveNetFails >= NET_FAIL_LIMIT) {
|
|
793
828
|
report.push(`⚠ 连续 ${NET_FAIL_LIMIT} 个包网络超时,中止本轮(网络恢复后下轮自动重试)`);
|
|
794
829
|
break;
|
|
@@ -797,6 +832,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
797
832
|
if (decision === 'skip') continue;
|
|
798
833
|
try {
|
|
799
834
|
const latest = await npmLatestVersion(b.name);
|
|
835
|
+
if (disposed) return;
|
|
800
836
|
consecutiveNetFails = 0;
|
|
801
837
|
if (!latest || !semverGt(latest, b.version)) continue;
|
|
802
838
|
const key = `${b.name}@${latest}`;
|
|
@@ -834,6 +870,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
834
870
|
: { application: 'failed', error: { code: 'cli-failed', diagnostic: cli.reason } };
|
|
835
871
|
}
|
|
836
872
|
}
|
|
873
|
+
if (disposed) return;
|
|
837
874
|
// 装完回读磁盘真实版本验证,不再信返回值
|
|
838
875
|
let diskVer = null;
|
|
839
876
|
if (profileDir) {
|
|
@@ -873,6 +910,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
873
910
|
report.push(` ⚠ ${b.name} 已写到 ${diskVer},但 CLI 收尾报错(退出码 ${result?.error?.code === 'cli-failed' ? '非 0' : '?'})——重启后确认`);
|
|
874
911
|
}
|
|
875
912
|
} catch (e) {
|
|
913
|
+
if (/inactive context/i.test(String(e?.message ?? e))) { disposed = true; return; }
|
|
876
914
|
log(`plugin check ${b.name} failed:`, e?.message);
|
|
877
915
|
consecutiveNetFails++;
|
|
878
916
|
}
|
|
@@ -884,10 +922,11 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
884
922
|
let running = false;
|
|
885
923
|
|
|
886
924
|
async function runCheck(trigger) {
|
|
887
|
-
if (running) return;
|
|
925
|
+
if (disposed || running) return;
|
|
888
926
|
running = true;
|
|
889
927
|
try {
|
|
890
928
|
await reloadHotConfig();
|
|
929
|
+
if (disposed) return;
|
|
891
930
|
log(`check start (${trigger})`);
|
|
892
931
|
state.lastRun = new Date().toISOString();
|
|
893
932
|
|
|
@@ -897,13 +936,14 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
897
936
|
// 有会话运行 → 轮询等待其结束,最多 idleTimeoutMinutes。
|
|
898
937
|
const deadline = nowSec() + config.idleTimeoutMinutes * 60;
|
|
899
938
|
got = false;
|
|
900
|
-
while (nowSec() < deadline) {
|
|
939
|
+
while (!disposed && nowSec() < deadline) {
|
|
901
940
|
if (!anyAgentRunning()) { got = true; break; }
|
|
902
941
|
await sleep(10000);
|
|
903
942
|
}
|
|
904
943
|
} else {
|
|
905
944
|
got = await waitIdle(config.idleTimeoutMinutes * 60);
|
|
906
945
|
}
|
|
946
|
+
if (disposed) return;
|
|
907
947
|
if (!got) {
|
|
908
948
|
log('idle window not reached, skip this round');
|
|
909
949
|
state.lastRun += ' (skipped: busy)';
|
|
@@ -914,6 +954,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
914
954
|
const report = [];
|
|
915
955
|
const successes = [];
|
|
916
956
|
await checkAndApplyPlugins(report, { exemptQuiet: trigger === 'manual', successes });
|
|
957
|
+
if (disposed) return;
|
|
917
958
|
try {
|
|
918
959
|
await checkHarness(report);
|
|
919
960
|
} catch (e) {
|
|
@@ -922,15 +963,18 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
922
963
|
log('harness check failed (network):', e?.message);
|
|
923
964
|
}
|
|
924
965
|
|
|
966
|
+
if (disposed) return;
|
|
925
967
|
// 桌面 App 不能从进程内部重启:把“需要重启才生效”写进本轮结果,面板里直接可见。
|
|
926
968
|
// 只统计真正成功的 upgrade(successes),失败/跳过/仅通知的版本行不算。
|
|
927
969
|
if (successes.length > 0 && isDesktopApp()) {
|
|
928
970
|
report.push(' ⏻ 插件已更新到磁盘,重启 DeepSeek Harness 后生效');
|
|
929
971
|
}
|
|
930
972
|
|
|
973
|
+
if (disposed) return;
|
|
931
974
|
state.updates = report;
|
|
932
975
|
state.lastError = null;
|
|
933
976
|
await saveState();
|
|
977
|
+
if (disposed) return;
|
|
934
978
|
await appendHistory({ kind: 'check', trigger, findings: report });
|
|
935
979
|
log('check done:', report.length ? report : 'no updates');
|
|
936
980
|
|
|
@@ -961,6 +1005,11 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
961
1005
|
return;
|
|
962
1006
|
}
|
|
963
1007
|
} catch (e) {
|
|
1008
|
+
if (/inactive context/i.test(String(e?.message ?? e))) disposed = true;
|
|
1009
|
+
if (disposed) {
|
|
1010
|
+
log('check aborted (plugin context disposed)');
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
964
1013
|
log('check error:', e?.message ?? e);
|
|
965
1014
|
state.lastError = String(e?.message ?? e);
|
|
966
1015
|
await appendHistory({ kind: 'error', trigger, message: String(e?.message ?? e) });
|
|
@@ -972,6 +1021,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
972
1021
|
// ---- 宽限期 tick ----
|
|
973
1022
|
let applyingHarness = false;
|
|
974
1023
|
async function tick() {
|
|
1024
|
+
if (disposed) return;
|
|
975
1025
|
try {
|
|
976
1026
|
if (!harnessNotice || config.harnessMode !== 'auto') return;
|
|
977
1027
|
if (applyingHarness) return;
|
|
@@ -990,13 +1040,15 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
990
1040
|
let graceTimer = null;
|
|
991
1041
|
|
|
992
1042
|
function reschedule() {
|
|
1043
|
+
if (disposed) return;
|
|
993
1044
|
if (scheduleTimer) { clearTimeout(scheduleTimer); scheduleTimer = null; }
|
|
994
1045
|
const at = nextRunAt(config);
|
|
995
1046
|
if (!(at > 0)) return;
|
|
996
1047
|
const delay = Math.max(1000, at - Date.now());
|
|
997
1048
|
scheduleTimer = setTimeout(() => {
|
|
1049
|
+
if (disposed) return;
|
|
998
1050
|
runCheck(config.scheduleMode).catch(() => {});
|
|
999
|
-
reschedule();
|
|
1051
|
+
if (!disposed) reschedule();
|
|
1000
1052
|
}, delay);
|
|
1001
1053
|
scheduleTimer.unref?.();
|
|
1002
1054
|
const when = new Date(at).toLocaleString('zh-CN', { hour12: false });
|
|
@@ -1021,6 +1073,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
1021
1073
|
}
|
|
1022
1074
|
|
|
1023
1075
|
async function installedPluginNames() {
|
|
1076
|
+
if (disposed) return [];
|
|
1024
1077
|
try {
|
|
1025
1078
|
const bundles = await ctx.pluginManager.listBundles();
|
|
1026
1079
|
return (bundles ?? [])
|
|
@@ -1113,6 +1166,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
1113
1166
|
kind: 'exact',
|
|
1114
1167
|
path: '/api/autoupdater/run-now',
|
|
1115
1168
|
handler: async (_req, res) => {
|
|
1169
|
+
if (disposed) { sendJson(res, 410, { ok: false, error: 'plugin disposed' }); return; }
|
|
1116
1170
|
if (running) { sendJson(res, 200, { ok: true, queued: false, reason: 'already-running' }); return; }
|
|
1117
1171
|
runCheck('manual').catch(() => {});
|
|
1118
1172
|
sendJson(res, 200, { ok: true, queued: true });
|
|
@@ -1124,15 +1178,18 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
1124
1178
|
|
|
1125
1179
|
// ---- 启动 ----
|
|
1126
1180
|
reloadHotConfig().then(() => {
|
|
1181
|
+
if (disposed) return;
|
|
1127
1182
|
reschedule();
|
|
1183
|
+
if (disposed) return;
|
|
1128
1184
|
graceTimer = setInterval(tick, 30000);
|
|
1129
1185
|
graceTimer.unref?.();
|
|
1130
|
-
}).catch((e) => log('startup reload failed:', e?.message));
|
|
1186
|
+
}).catch((e) => { if (!disposed) log('startup reload failed:', e?.message); });
|
|
1131
1187
|
|
|
1132
1188
|
// ---- 清理 ----
|
|
1133
1189
|
return () => {
|
|
1134
|
-
|
|
1135
|
-
if (
|
|
1190
|
+
disposed = true;
|
|
1191
|
+
if (scheduleTimer) { clearTimeout(scheduleTimer); scheduleTimer = null; }
|
|
1192
|
+
if (graceTimer) { clearInterval(graceTimer); graceTimer = null; }
|
|
1136
1193
|
for (const d of disposers) { try { d?.(); } catch {} }
|
|
1137
1194
|
};
|
|
1138
1195
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fffangchenghao/dsh-autoupdater",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Maintained fork of dsh-autoupdater with macOS/Windows desktop fixes. Scheduled update checks for DSH itself and installed plugins, applied only in idle windows.",
|