dsh-selfupdater 0.4.16 → 0.4.18

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/lib/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * - 升级动作通过锁文件防并发,双端校验。
15
15
  */
16
16
  import { spawn } from 'node:child_process';
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { fetchLatestVersion, installedSelfVersion, isNewer, runPluginUpdateTask } from './plugin-update-task.mjs';
@@ -258,7 +258,7 @@ export function apply(ctx) {
258
258
  const updateAvailable = isNewer(latest, current);
259
259
  // 检查结果落盘(内部自动建目录),UI 刷新后仍能看到上次检查时间。
260
260
  writeStatus(dshStateDir, {
261
- ...(await Promise.resolve(readStatus(dshStateDir))),
261
+ ...readStatus(dshStateDir),
262
262
  currentVersion: current,
263
263
  latestVersion: latest,
264
264
  state: isBusy() ? 'running' : 'idle',
@@ -286,11 +286,22 @@ export function apply(ctx) {
286
286
  return;
287
287
  }
288
288
  // 并发防护:锁文件已存在说明有升级在跑(或上次异常残留未清理)。
289
+ // 残留判定:锁内容损坏,或 startedAt 超过 15 分钟(升级脚本总看门狗
290
+ // 仅 8 分钟,超过必然已死)—— 自动清理放行,避免强杀后永久 409。
289
291
  if (isBusy()) {
290
- sendJson(res, 409, { error: '已有一次升级在进行中' });
291
- return;
292
+ let stale = false;
293
+ try {
294
+ const prev = JSON.parse(readFileSync(lockFile, 'utf8'));
295
+ stale = Date.now() - Number(prev.startedAt ?? 0) > 15 * 60 * 1000;
296
+ } catch { stale = true; }
297
+ if (!stale) {
298
+ sendJson(res, 409, { error: '已有一次升级在进行中' });
299
+ return;
300
+ }
301
+ try { rmSync(lockFile, { force: true }); } catch { /* 清不掉则下次再试 */ }
292
302
  }
293
- // 预置锁文件:updater.mjs 启动后会校验它存在才继续。
303
+ // 预置锁文件:作为并发防护;updater.mjs 见到 by=plugin 的新鲜锁会
304
+ // 视为合法握手接管(见 updater.mjs main 的锁文件握手注释)。
294
305
  // 先确保 .dsh 目录存在(首次安装/手动清理后可能尚无该目录,教训来自插件写状态 ENOENT 问题)。
295
306
  try {
296
307
  mkdirSync(dshStateDir, { recursive: true });
@@ -88,13 +88,17 @@ function registryCandidates() {
88
88
  return [...new Set(list.filter((v) => typeof v === 'string' && v !== ''))];
89
89
  }
90
90
 
91
+ /** 除 latest 外额外追踪的官方发布渠道 tag(官方习惯把新版先挂在 next/alpha 上)。 */
92
+ const EXTRA_DIST_TAGS = ['next', 'alpha'];
93
+
91
94
  /**
92
- * 从单个 registry /latest 版本级端点取文档(含 dist.tarball)。
95
+ * 从单个 registry 的版本级端点(/<pkg>/<tag>)取文档(含 dist.tarball)。
93
96
  * 用版本级端点而非整包 packument:后者会被 Fastly 等 CDN 长时间缓存,
94
97
  * 出现"包已发布但查不到新版"的假象。
98
+ * tag 不存在时(如包从未打过 alpha tag)registry 返回 404,由调用方 allSettled 吞掉。
95
99
  */
96
- async function fetchVersionDoc(base, pkg) {
97
- const res = await fetch(`${base}/${encodeURIComponent(pkg)}/latest`, {
100
+ async function fetchVersionDoc(base, pkg, tag = 'latest') {
101
+ const res = await fetch(`${base}/${encodeURIComponent(pkg)}/${tag}`, {
98
102
  headers: { accept: 'application/json', 'user-agent': 'dsh-selfupdater' },
99
103
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
100
104
  });
@@ -117,14 +121,19 @@ async function fetchVersionDoc(base, pkg) {
117
121
  */
118
122
  async function fetchLatestRelease(pkg) {
119
123
  const candidates = registryCandidates();
120
- const results = await Promise.allSettled(candidates.map((base) => fetchVersionDoc(base, pkg)));
124
+ // 每个 registry × 每个渠道 tag(latest/next/alpha)并行查询:
125
+ // 只查 /latest 会漏掉挂在 next/alpha tag 上的新版(0.1.2-alpha.2 实测踩坑),
126
+ // 与 build.sh 优先取 dist-tags.next 的官方发版习惯保持一致。
127
+ const tags = ['latest', ...EXTRA_DIST_TAGS];
128
+ const targets = candidates.flatMap((base) => tags.map((tag) => ({ base, tag })));
129
+ const results = await Promise.allSettled(targets.map((t) => fetchVersionDoc(t.base, pkg, t.tag)));
121
130
  let best = null;
122
131
  const errors = [];
123
132
  results.forEach((r, i) => {
124
133
  if (r.status === 'fulfilled') {
125
134
  if (best === null || isNewer(r.value.version, best.version)) best = r.value;
126
135
  } else {
127
- errors.push(`${candidates[i]}: ${r.reason.message}`);
136
+ errors.push(`${targets[i].base}/${targets[i].tag}: ${r.reason.message}`);
128
137
  }
129
138
  });
130
139
  if (best === null) throw new Error(errors.join(';'));
package/lib/updater.mjs CHANGED
@@ -241,13 +241,17 @@ function registryCandidates() {
241
241
  return [...new Set(list.filter((v) => typeof v === 'string' && v !== ''))];
242
242
  }
243
243
 
244
+ /** 除 latest 外额外追踪的官方发布渠道 tag(官方习惯把新版先挂在 next/alpha 上)。 */
245
+ const EXTRA_DIST_TAGS = ['next', 'alpha'];
246
+
244
247
  /**
245
- * 从单个 registry /latest 版本级端点取文档(含 dist.tarball)。
248
+ * 从单个 registry 的版本级端点(/<pkg>/<tag>)取文档(含 dist.tarball)。
246
249
  * 用版本级端点而非整包 packument:后者会被 Fastly 等 CDN 长时间缓存,
247
250
  * 出现"包已发布但查不到新版"的假象(插件 0.4.5 踩坑根因)。
251
+ * tag 不存在时(如包从未打过 alpha tag)registry 返回 404,由调用方 allSettled 吞掉。
248
252
  */
249
- async function fetchVersionDoc(base, pkg) {
250
- const res = await fetch(`${base}/${encodeURIComponent(pkg)}/latest`, {
253
+ async function fetchVersionDoc(base, pkg, tag = 'latest') {
254
+ const res = await fetch(`${base}/${encodeURIComponent(pkg)}/${tag}`, {
251
255
  headers: { accept: 'application/json', 'user-agent': 'dsh-selfupdater' },
252
256
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
253
257
  });
@@ -266,14 +270,19 @@ async function fetchVersionDoc(base, pkg) {
266
270
  */
267
271
  async function fetchLatestRelease(pkg) {
268
272
  const candidates = registryCandidates();
269
- const results = await Promise.allSettled(candidates.map((base) => fetchVersionDoc(base, pkg)));
273
+ // 每个 registry × 每个渠道 tag(latest/next/alpha)并行查询:
274
+ // 只查 /latest 会漏掉挂在 next/alpha tag 上的新版(0.1.2-alpha.2 实测踩坑),
275
+ // 与 build.sh 优先取 dist-tags.next 的官方发版习惯保持一致。
276
+ const tags = ['latest', ...EXTRA_DIST_TAGS];
277
+ const targets = candidates.flatMap((base) => tags.map((tag) => ({ base, tag })));
278
+ const results = await Promise.allSettled(targets.map((t) => fetchVersionDoc(t.base, pkg, t.tag)));
270
279
  let best = null;
271
280
  const errors = [];
272
281
  results.forEach((r, i) => {
273
282
  if (r.status === 'fulfilled') {
274
283
  if (best === null || isNewer(r.value.version, best.version)) best = r.value;
275
284
  } else {
276
- errors.push(`${candidates[i]}: ${r.reason.message}`);
285
+ errors.push(`${targets[i].base}/${targets[i].tag}: ${r.reason.message}`);
277
286
  }
278
287
  });
279
288
  if (best === null) throw new Error(errors.join(';'));
@@ -394,9 +403,19 @@ async function main() {
394
403
  mkdirSync(dshStateDir, { recursive: true });
395
404
  const current = currentDshVersion();
396
405
 
397
- // 锁文件双端校验:插件本体触发前会检查;这里再补一道防手动重复执行。
398
- if (existsSync(lockFile)) throw new Error('已有一次升级在进行中(锁文件存在)');
399
- writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
406
+ // 锁文件握手:插件本体 POST /perform 会预写锁文件(by=plugin)作为并发
407
+ // 防护,本脚本见到它应视为合法交接直接接管 —— 旧实现"存在即拒绝"与
408
+ // 插件端的预写约定自相矛盾,导致升级必然秒败(0.4.17 实测踩坑)。
409
+ // 仅当锁是陈旧残留(超过 10 分钟)或来源不明时才判定为重复执行。
410
+ if (existsSync(lockFile)) {
411
+ let handover = false;
412
+ try {
413
+ const prev = JSON.parse(readFileSync(lockFile, 'utf8'));
414
+ handover = prev?.by === 'plugin' && Date.now() - Number(prev.startedAt ?? 0) < 10 * 60 * 1000;
415
+ } catch { handover = false; }
416
+ if (!handover) throw new Error('已有一次升级在进行中(锁文件存在)');
417
+ }
418
+ writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: Date.now(), by: 'updater' }));
400
419
 
401
420
  status = { currentVersion: current, startedAt: new Date().toISOString(), trigger: 'manual' };
402
421
  setState('downloading', '正在查询 npm 最新版本 …');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-selfupdater",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "Self-update plugin for DeepSeek Harness: DSH core upgrades via detached swap script; plugin self-update installs in-place without killing the host and prompts for restart. DSH 主程序与已装插件的一站式在线更新插件。",
5
5
  "license": "MIT",
6
6
  "type": "module",