@fffangchenghao/dsh-autoupdater 0.6.1 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +18 -2
  2. package/main.js +119 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,6 +31,8 @@ dsh plugin --profile web add github:fangchenghao/dsh-autoupdater
31
31
  ```
32
32
 
33
33
  > 用 scoped 包名是为了不和上游 `dsh-autoupdater` 抢注同名包;bundle id 仍是 `dsh-autoupdater`,配置与状态路径不变。
34
+ >
35
+ > Windows 桌面版更推荐直接在 **插件管理器 → 安装插件** 里填包名安装;装完重启 App。
34
36
 
35
37
  安装后重启 DSH(systemd 环境:`sudo systemctl restart <你的dsh服务名>`),然后在 **插件管理器 → 自动更新** 卡片配置。
36
38
 
@@ -87,11 +89,22 @@ dsh plugin --profile web add github:fangchenghao/dsh-autoupdater
87
89
 
88
90
  因此桌面版的工作方式是:**静默把插件换版到磁盘 → 面板提示重启 → 你下次打开 App 时生效**。
89
91
 
92
+ ### Windows 支持(0.6.2+)
93
+
94
+ 0.6.2 起 Windows 桌面版与 macOS 使用同一条换版路径,并修掉了 0.6.1 里的几处 Unix-only 假设:
95
+
96
+ - **空闲判定**不再调用 `find -newermt`(Windows 的 `find.exe` 是文本搜索,不支持这些参数,会让插件永远认为“忙”),改为纯 Node 递归扫描 `%DSH_HOME%\sessions`。目录不存在 = 没有会话 = 空闲;只有根目录不可读才保守当忙。
97
+ - **`dsh.cmd` 调用**:PATH / sibling 找到的 `.cmd` 经 `cmd.exe` 执行并正确加引号;超时用 `taskkill /T /F` 结束整棵进程树,避免留下 pnpm 子进程。
98
+ - **环境变量**:没有 `HOME` 时用 `homedir()`(Windows 的 `USERPROFILE`);PATH 兼容 `Path` 键,并额外查找 `%APPDATA%\npm` / `%LOCALAPPDATA%\pnpm`。
99
+ - Windows 上不执行 DSH 本体更新(交给安装器/App 自带更新器),插件换版后同样只提示重启。
100
+
101
+ 开发机是 macOS;Windows 分支是按 Node/Electron 的跨平台语义改的,并做了逻辑测试,但**尚未在真实 Windows 机器上端到端跑过**。如果 Windows 上仍失败,请把面板日志和 `%USERPROFILE%\.dsh\autoupdater\history.jsonl` 发到 GitHub issue。
102
+
90
103
  ## 维护说明
91
104
 
92
105
  - 上游项目:[niubi6668889595/dsh-autoupdater](https://github.com/niubi6668889595/dsh-autoupdater)(MIT)。
93
- - 本分支新增:桌面版 CLI 换版、npmmirror 优先、跳过 `link:`/git 来源、只有真正升级成功才算“已应用”;完整差异见 git log。
94
- - 发布渠道:npm `@fffangchenghao/dsh-autoupdater`,GitHub `fangchenghao/dsh-autoupdater`,tag `v0.6.1`。
106
+ - 本分支新增:桌面版 CLI 换版、npmmirror 优先、跳过 `link:`/git 来源、只有真正升级成功才算“已应用”、Windows 空闲判定/.cmd 调用适配;完整差异见 git log。
107
+ - 发布渠道:npm `@fffangchenghao/dsh-autoupdater`,GitHub `fangchenghao/dsh-autoupdater`,tag `v0.6.2`。
95
108
  - 与上游同步:把上游加为 remote,rebase/merge 到本分支后重新发版即可。
96
109
 
97
110
  ## 安全模型
@@ -106,6 +119,7 @@ dsh plugin --profile web add github:fangchenghao/dsh-autoupdater
106
119
  - 插件更新只覆盖声明 `dsh.bundle` 的 npm 形态插件;GitHub 源码安装的插件版本比对不适用
107
120
  - 本体更新依赖 npm 全局安装形态(git checkout 形态请用 [dsh-updater](https://github.com/GodCC6/dsh-updater);桌面 App 不参与本体更新,由 App 自带更新器负责)
108
121
  - 空闲判定基于本机 `$DSH_HOME/sessions` 的文件 mtime,远程会话(SSH 场景)不计入
122
+ - Windows 原生环境尚未实机验证(0.6.2 已移除 Unix-only 依赖,仍建议先看面板日志确认 desktop CLI 路径)
109
123
 
110
124
  ## 致谢
111
125
 
@@ -133,4 +147,6 @@ MIT
133
147
 
134
148
  Install: `dsh plugin --profile web add @fffangchenghao/dsh-autoupdater` (or `github:fangchenghao/dsh-autoupdater`), then restart DSH and configure under **Plugin Manager → dsh-autoupdater**.
135
149
 
150
+ **Windows (0.6.2+)**: the desktop path is shared with macOS. 0.6.2 removes the Unix `find` idle check, invokes `dsh.cmd` through `cmd.exe`, falls back to `homedir()` when `HOME` is absent, and leaves DSH core updates to the installer/app. Windows has not been tested end-to-end on a real machine yet; logs are welcome in GitHub issues.
151
+
136
152
  See the Chinese sections above for full details — the configuration table and idle-detection notes apply to both languages.
package/main.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
2
2
  import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
3
- import { execSync, spawn } from 'node:child_process';
3
+ import { execFileSync, spawn } from 'node:child_process';
4
4
  import { basename, delimiter, dirname, join } from 'node:path';
5
+ import { homedir } from 'node:os';
5
6
 
6
7
  /** 本插件的真实包名(上游名或发布用的 scoped 名),用于把自己排除在自动更新之外。 */
7
8
  const SELF_NAME = (() => {
@@ -139,6 +140,48 @@ async function readJson(path) {
139
140
  try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; }
140
141
  }
141
142
 
143
+ /**
144
+ * 递归找出目录里最新的文件 mtime(毫秒)。
145
+ * 目录不存在 → 0(没有会话 = 空闲);根目录读不了 → 抛错(调用方保守当忙)。
146
+ * 用纯 Node 实现,避免 Windows 上没有 Unix `find` 导致空闲判定永远为忙。
147
+ */
148
+ function newestFileMtimeMs(dir) {
149
+ if (!existsSync(dir)) return 0;
150
+ let newest = 0;
151
+ const stack = [dir];
152
+ while (stack.length > 0) {
153
+ const current = stack.pop();
154
+ let entries;
155
+ try {
156
+ entries = readdirSync(current, { withFileTypes: true });
157
+ } catch (e) {
158
+ if (current === dir) throw e;
159
+ continue; // 子目录权限不足不应让整个探测失败
160
+ }
161
+ for (const entry of entries) {
162
+ const full = join(current, entry.name);
163
+ if (entry.isDirectory()) { stack.push(full); continue; }
164
+ if (!entry.isFile()) continue;
165
+ try {
166
+ const m = statSync(full).mtimeMs;
167
+ if (m > newest) newest = m;
168
+ } catch {}
169
+ }
170
+ }
171
+ return newest;
172
+ }
173
+
174
+ /**
175
+ * Windows 的 npm 全局命令是 dsh.cmd,不能直接 spawn(必须经 cmd.exe)。
176
+ * 返回已加引号的命令行;其他平台或 .exe 返回 null。
177
+ * 平台参数只用于测试,生产调用走默认值。
178
+ */
179
+ function windowsShellCommand(command, args, platform = process.platform) {
180
+ if (platform !== 'win32' || !/\.(cmd|bat)$/i.test(command)) return null;
181
+ const quote = (value) => `"${String(value).replace(/"/g, '""')}"`;
182
+ return [quote(command), ...args.map(quote)].join(' ');
183
+ }
184
+
142
185
  /** 运行子进程并收集 stdout+stderr;超时或无法启动 → code -1。 */
143
186
  function spawnCapture(command, args, { env = process.env, timeoutMs = 300000 } = {}) {
144
187
  return new Promise((resolve) => {
@@ -150,15 +193,30 @@ function spawnCapture(command, args, { env = process.env, timeoutMs = 300000 } =
150
193
  settled = true;
151
194
  resolve({ code, output });
152
195
  };
196
+ const shellCommand = windowsShellCommand(command, args);
153
197
  try {
154
- child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
198
+ child = shellCommand
199
+ ? spawn(shellCommand, [], { stdio: ['ignore', 'pipe', 'pipe'], env, shell: true, windowsHide: true })
200
+ : spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], env, windowsHide: true });
155
201
  } catch (e) {
156
202
  output += String(e?.message ?? e);
157
203
  finish(-1);
158
204
  return;
159
205
  }
206
+ const killTree = () => {
207
+ if (!child?.pid) return;
208
+ if (process.platform === 'win32') {
209
+ try {
210
+ execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
211
+ stdio: 'ignore', windowsHide: true,
212
+ });
213
+ } catch {}
214
+ } else {
215
+ try { child.kill('SIGKILL'); } catch {}
216
+ }
217
+ };
160
218
  const timer = setTimeout(() => {
161
- try { child.kill(); } catch {}
219
+ killTree();
162
220
  output += `\n[dsh-autoupdater] timeout after ${timeoutMs}ms`;
163
221
  finish(-1);
164
222
  }, timeoutMs);
@@ -185,12 +243,13 @@ function lastMeaningfulLine(text, limit = 220) {
185
243
  export const inject = ['agents', 'pluginManager', 'webServer'];
186
244
 
187
245
  export function apply(ctx, rawConfig = {}) {
188
- const home = process.env.DSH_HOME || `${process.env.HOME}/.dsh`;
189
- const stateDir = `${home}/autoupdater`;
190
- const statePath = `${stateDir}/status.json`;
191
- const historyPath = `${stateDir}/history.jsonl`;
192
- const pendingPath = `${stateDir}/pending-update.json`;
193
- const hotConfigPath = `${stateDir}/config.json`;
246
+ // Windows 上通常没有 HOME,只有 USERPROFILE;homedir() 会选对。
247
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh');
248
+ const stateDir = join(home, 'autoupdater');
249
+ const statePath = join(stateDir, 'status.json');
250
+ const historyPath = join(stateDir, 'history.jsonl');
251
+ const pendingPath = join(stateDir, 'pending-update.json');
252
+ const hotConfigPath = join(stateDir, 'config.json');
194
253
 
195
254
  let state = { lastRun: null, lastNotice: null, updates: [], lastError: null };
196
255
  readJson(statePath).then((s) => { if (s) state = { ...state, ...s }; }).catch(() => {});
@@ -208,15 +267,15 @@ export function apply(ctx, rawConfig = {}) {
208
267
  // 多个时取 node_modules 最近修改的。找不到 = 磁盘回读退化为仅信安装结果。
209
268
  let profileDir = process.env.DSH_PROFILE_DIR || null;
210
269
  try {
211
- const base = `${home}/profiles`;
270
+ const base = join(home, 'profiles');
212
271
  const candidates = readdirSync(base)
213
- .map((name) => `${base}/${name}`)
214
- .filter((dir) => existsSync(`${dir}/node_modules`));
272
+ .map((name) => join(base, name))
273
+ .filter((dir) => existsSync(join(dir, 'node_modules')));
215
274
  if (candidates.length === 1) {
216
275
  profileDir = candidates[0];
217
276
  } else if (candidates.length > 1) {
218
277
  profileDir = candidates
219
- .map((dir) => ({ dir, m: statSync(`${dir}/node_modules`).mtimeMs }))
278
+ .map((dir) => ({ dir, m: statSync(join(dir, 'node_modules')).mtimeMs }))
220
279
  .sort((a, b) => b.m - a.m)[0].dir;
221
280
  }
222
281
  if (profileDir && config.verbose) log('profileDir detected:', profileDir);
@@ -237,9 +296,15 @@ export function apply(ctx, rawConfig = {}) {
237
296
  */
238
297
  function resolvePluginCli() {
239
298
  if (isDesktopApp() && process.resourcesPath) {
240
- const desktopCli = join(process.resourcesPath, 'app.asar', 'dsh', 'node_modules',
241
- '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js');
242
- if (existsSync(desktopCli)) {
299
+ const desktopCliCandidates = [
300
+ join(process.resourcesPath, 'app.asar', 'dsh', 'node_modules',
301
+ '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js'),
302
+ // 未打包/开发模式:resources/app/dsh/...
303
+ join(process.resourcesPath, 'app', 'dsh', 'node_modules',
304
+ '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js'),
305
+ ];
306
+ const desktopCli = desktopCliCandidates.find((candidate) => existsSync(candidate));
307
+ if (desktopCli) {
243
308
  return {
244
309
  command: process.execPath,
245
310
  prefixArgs: [desktopCli],
@@ -247,15 +312,21 @@ export function apply(ctx, rawConfig = {}) {
247
312
  label: 'desktop-cli',
248
313
  };
249
314
  }
250
- log('desktop CLI not found at', desktopCli);
315
+ log('desktop CLI not found at', desktopCliCandidates[0]);
251
316
  }
252
317
  // 普通 npm 全局安装:dsh 与 node 同目录(Linux 常见布局)
253
- const sibling = join(dirname(process.execPath), process.platform === 'win32' ? 'dsh.cmd' : 'dsh');
318
+ const binName = process.platform === 'win32' ? 'dsh.cmd' : 'dsh';
319
+ const sibling = join(dirname(process.execPath), binName);
254
320
  if (existsSync(sibling)) return { command: sibling, prefixArgs: [], env: {}, label: 'sibling-cli' };
255
321
  // 最后退回 PATH(macOS 上 npm 全局安装常见于 ~/.local/bin,而非 node 同目录)
256
- const binName = process.platform === 'win32' ? 'dsh.cmd' : 'dsh';
257
- for (const dir of String(process.env.PATH ?? '').split(delimiter)) {
258
- if (!dir) continue;
322
+ // Windows 环境变量大小写不敏感,但键名通常是 Path 而不是 PATH。
323
+ const pathEnv = process.env.Path ?? process.env.PATH ?? '';
324
+ const pathDirs = String(pathEnv).split(delimiter).filter(Boolean);
325
+ if (process.platform === 'win32') {
326
+ if (process.env.APPDATA) pathDirs.push(join(process.env.APPDATA, 'npm'));
327
+ if (process.env.LOCALAPPDATA) pathDirs.push(join(process.env.LOCALAPPDATA, 'pnpm'));
328
+ }
329
+ for (const dir of pathDirs) {
259
330
  const candidate = join(dir, binName);
260
331
  if (existsSync(candidate)) return { command: candidate, prefixArgs: [], env: {}, label: 'path-cli' };
261
332
  }
@@ -277,20 +348,18 @@ export function apply(ctx, rawConfig = {}) {
277
348
  // 借鉴 dsh-self-upgrade 的判定(实测比 agent.status 可靠):
278
349
  // 会话日志 2 分钟内无任何写入 = 无进行中的工作。agent.status 存在 turn 结束
279
350
  // 后仍停留 'running' 的情况(本机实测),会永久阻塞空闲判定,故弃用。
280
- const sessionsDir = `${home}/sessions`;
351
+ const sessionsDir = join(home, 'sessions');
281
352
  let sessionsBusyCache = { at: 0, busy: true };
282
353
  const anyAgentRunning = () => {
283
354
  const now = Date.now();
284
355
  if (now - sessionsBusyCache.at < 8000) return sessionsBusyCache.busy; // 8s 缓存
285
356
  try {
286
- // mtime 探测:-newermt 用固定秒窗避免 -mmin 的分钟取整误差
287
- const cutoff = new Date(Date.now() - 120_000).toISOString();
288
- const out = execSync(
289
- `find ${JSON.stringify(sessionsDir)} -type f -newermt ${JSON.stringify(cutoff)} 2>/dev/null | head -1`,
290
- { timeout: 8000 },
291
- ).toString().trim();
292
- sessionsBusyCache = { at: now, busy: out !== '' };
293
- } catch { sessionsBusyCache = { at: now, busy: true }; } // 探测失败保守当忙
357
+ // 2 分钟内有文件写入 = 有进行中的工作;目录不存在 = 没有会话 = 空闲。
358
+ const newest = newestFileMtimeMs(sessionsDir);
359
+ sessionsBusyCache = { at: now, busy: newest > now - 120_000 };
360
+ } catch {
361
+ sessionsBusyCache = { at: now, busy: true }; // 根目录探测失败保守当忙
362
+ }
294
363
  return sessionsBusyCache.busy;
295
364
  };
296
365
 
@@ -411,7 +480,9 @@ export function apply(ctx, rawConfig = {}) {
411
480
  log('CLI add:', reason);
412
481
  return { ok: false, code: -1, output: '', reason };
413
482
  }
414
- const profileName = profileDir ? basename(profileDir) : (process.env.DSH_PROFILE || 'web');
483
+ const profileName = profileDir
484
+ ? basename(profileDir)
485
+ : (process.env.DSH_PROFILE || (isDesktopApp() ? 'desktop' : 'web'));
415
486
  // pnpm 自身不会在 registry 之间回退。国内链路 registry.npmjs.org 实测会连续
416
487
  // CONNECT_TIMEOUT(一次 130 个包安装用了 3 分 14 秒),而版本查询用的 npmmirror
417
488
  // 同一版本有同步副本、1~3 秒装完。先问 npmmirror,失败再退回 pnpm 默认源。
@@ -487,6 +558,7 @@ export function apply(ctx, rawConfig = {}) {
487
558
 
488
559
  /** 自动探测管理本进程的 systemd 服务名(无则返回空 = 裸进程模式) */
489
560
  function detectSystemdUnit() {
561
+ if (process.platform !== 'linux') return '';
490
562
  try {
491
563
  const cgroup = readFileSync('/proc/self/cgroup', 'utf8');
492
564
  const m = cgroup.match(/\/system\.slice\/([a-z0-9@._-]+\.service)/i);
@@ -524,7 +596,6 @@ export function apply(ctx, rawConfig = {}) {
524
596
  */
525
597
  async function applyHarnessUpdateNow() {
526
598
  const pendingExists = existsSync(pendingPath);
527
- const unit = config.systemdUnit || detectSystemdUnit();
528
599
 
529
600
  // 桌面 App:DSH 本体由 electron-updater 负责,npm -g 不能也不该覆盖 .app 内的运行时;
530
601
  // 插件更新也不能从进程内安全 relaunch(会连带杀掉正在跑的会话)。统一标记“待重启”。
@@ -534,6 +605,15 @@ export function apply(ctx, rawConfig = {}) {
534
605
  return;
535
606
  }
536
607
 
608
+ // Windows 非桌面模式:没有 systemd,也不支持 apply-update.mjs 的 POSIX 重启方式。
609
+ if (process.platform === 'win32') {
610
+ log('Windows: in-process restart/apply unsupported — restart required');
611
+ await appendHistory({ kind: 'restart-required', reason: 'win32', pending: pendingExists });
612
+ return;
613
+ }
614
+
615
+ const unit = config.systemdUnit || detectSystemdUnit();
616
+
537
617
  // 路径 A:仅插件更新(无 pending 文件)→ 无需 npm install,直接重启激活新插件
538
618
  if (!pendingExists) {
539
619
  log('no pending harness update — plugin-only restart');
@@ -616,6 +696,11 @@ export function apply(ctx, rawConfig = {}) {
616
696
  if (config.verbose) log('desktop build: DSH core updates are owned by the app updater — skip harness check');
617
697
  return;
618
698
  }
699
+ if (process.platform === 'win32') {
700
+ // Windows 的 npm 全局布局与 POSIX 不同,且没有 systemd 重启路径;本体交给安装器/App 更新。
701
+ if (config.verbose) log('Windows: DSH core updates are handled by the installer/app updater — skip harness check');
702
+ return;
703
+ }
619
704
  if (existsSync(pendingPath)) {
620
705
  if (config.verbose) log('pending update already staged, skip harness check');
621
706
  return;
@@ -654,7 +739,7 @@ export function apply(ctx, rawConfig = {}) {
654
739
  /** 扫描 profile 的 cordis.patch.yml,列出 !!js ctx.<service> 这类跨版本易失效的引用 */
655
740
  function scanPatchRisks() {
656
741
  try {
657
- const patchPath = `${profileDir || `${home}/profiles/web`}/cordis.patch.yml`;
742
+ const patchPath = join(profileDir || join(home, 'profiles', 'web'), 'cordis.patch.yml');
658
743
  if (!existsSync(patchPath)) return [];
659
744
  const text = readFileSync(patchPath, 'utf8');
660
745
  const found = new Set();
@@ -752,7 +837,7 @@ export function apply(ctx, rawConfig = {}) {
752
837
  // 装完回读磁盘真实版本验证,不再信返回值
753
838
  let diskVer = null;
754
839
  if (profileDir) {
755
- const diskPkg = await readJson(`${profileDir}/node_modules/${b.name}/package.json`).catch(() => null);
840
+ const diskPkg = await readJson(join(profileDir, 'node_modules', b.name, 'package.json')).catch(() => null);
756
841
  diskVer = diskPkg?.version ?? null;
757
842
  }
758
843
  const reallyUpdated = diskVer == null ? true : diskVer !== b.version; // 无 profileDir 时退化为信安装结果
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fffangchenghao/dsh-autoupdater",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
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.",