@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.
Files changed (3) hide show
  1. package/README.md +6 -1
  2. package/main.js +83 -26
  3. 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.2`。
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 = process.env.DSH_HOME || join(homedir(), '.dsh');
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 优先;否则在 $DSH_HOME/profiles 下找含 node_modules 的目录,
267
- // 多个时取 node_modules 最近修改的。找不到 = 磁盘回读退化为仅信安装结果。
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
- const base = join(home, 'profiles');
271
- const candidates = readdirSync(base)
272
- .map((name) => join(base, name))
273
- .filter((dir) => existsSync(join(dir, 'node_modules')));
274
- if (candidates.length === 1) {
275
- profileDir = candidates[0];
276
- } else if (candidates.length > 1) {
277
- profileDir = candidates
278
- .map((dir) => ({ dir, m: statSync(join(dir, 'node_modules')).mtimeMs }))
279
- .sort((a, b) => b.m - a.m)[0].dir;
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 = '找不到可用的 dsh CLI(桌面 App 自带的 dsh-desktop-host 与 PATH 上的 dsh 都不可用)';
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
- if (scheduleTimer) clearTimeout(scheduleTimer);
1135
- if (graceTimer) clearInterval(graceTimer);
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.2",
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.",