dsh-selfupdater 0.4.19 → 0.4.22

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.
@@ -238,6 +238,8 @@ const CARD_CSS = `
238
238
  .dshsu-step-active{background:var(--dshsu-accent);animation:dshsu-pulse 1.1s ease-in-out infinite}
239
239
  @keyframes dshsu-pulse{50%{opacity:.35}}
240
240
  .dshsu-actions{display:flex;align-items:center;gap:8px;margin-top:2px}
241
+ .dshsu-channel{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:12px;opacity:.75;cursor:pointer;user-select:none}
242
+ .dshsu-channel input{accent-color:var(--dshsu-accent,#c9a227)}
241
243
  .dshsu-spacer{flex:1}
242
244
  .dshsu-btn{appearance:none;border:1px solid var(--dshsu-border);border-radius:8px;cursor:pointer;
243
245
  padding:6px 14px;font-size:13px;background:transparent;color:inherit;display:inline-flex;
@@ -389,7 +391,7 @@ function headBadge(text, isNew) {
389
391
  * 上半部分 = DSH 更新小节;下半部分 = 插件更新小节。
390
392
  * 两组状态相互独立、互不阻塞;样式共享同一套 CSS 类与主题变量。
391
393
  */
392
- function UpdateCard({ t, status, busy, checking, onCheck, onUpgrade,
394
+ function UpdateCard({ t, status, busy, checking, onCheck, onUpgrade, onChannelChange,
393
395
  plugin, pluginBusy, pluginChecking, pluginMsg, onPluginCheck, onPluginUpgrade }) {
394
396
 
395
397
  // 与插件小节保持一致:以后端 semver 权威判定为准,避免仅靠字符串 !== 误判预发布版本号
@@ -456,6 +458,17 @@ function UpdateCard({ t, status, busy, checking, onCheck, onUpgrade,
456
458
  : ['error', 'done_failed'].includes(status?.state) ? h('span', { className: 'dshsu-pill dshsu-pill-bad' }, t.failed)
457
459
  : null,
458
460
  ),
461
+ // alpha 预发布渠道开关:默认关闭(alpha 可能与旧插件 client bundle 不兼容,
462
+ // 0.1.2-alpha.3 实测升级后 web 端进不去),开启后检测/升级都会纳入 alpha 渠道。
463
+ h('label', { className: 'dshsu-channel' },
464
+ h('input', {
465
+ type: 'checkbox',
466
+ checked: status?.channel === 'alpha',
467
+ disabled: busy,
468
+ onChange: (e) => onChannelChange(e.target.checked === true),
469
+ }),
470
+ t.alphaChannel,
471
+ ),
459
472
 
460
473
  /* ---- 分隔线:视觉上把 DSH 更新与插件更新分成两区 ---- */
461
474
  h('div', { className: 'dshsu-divider' }),
@@ -580,6 +593,7 @@ const FALLBACK_DICT = {
580
593
  currentVersion: '当前版本', latestVersion: '最新版本',
581
594
  lastCheck: '上次检查', never: '从未', processing: '处理中…',
582
595
  updateAvailable: '可更新', upgraded: '已升级', failed: '失败',
596
+ alphaChannel: '追踪 alpha 预发布渠道(可能不稳定)',
583
597
  pluginNav: '插件更新',
584
598
  pluginUpdating: '插件更新进行中…', checkingLabel: '正在检查更新…',
585
599
  checkFailed: '检查更新失败',
@@ -592,6 +606,7 @@ const FALLBACK_DICT = {
592
606
  currentVersion: 'Current', latestVersion: 'Latest',
593
607
  lastCheck: 'Last check', never: 'never', processing: 'Working…',
594
608
  updateAvailable: 'Update', upgraded: 'Upgraded', failed: 'Failed',
609
+ alphaChannel: 'Track alpha prereleases (may be unstable)',
595
610
  pluginNav: 'Plugin Updates',
596
611
  pluginUpdating: 'Plugin update in progress…', checkingLabel: 'Checking…',
597
612
  checkFailed: 'Check failed',
@@ -648,7 +663,10 @@ function apply(ctx) {
648
663
  ...data,
649
664
  message: agedMessage('dsh', data.message, FINAL_MSG_STATES.includes(data.state)),
650
665
  };
651
- upgradeStarting = false; // 服务端状态已接管视觉
666
+ // 仅当服务端给出"进行中"或终态时才撤销乐观视觉:perform 受理初期
667
+ // 状态可能仍是旧的 idle,若此时撤销会退回"发现新版本"且按钮恢复
668
+ // 可点,造成重复点击(0.4.19 实测踩坑)。
669
+ if (isBusyState(latestStatus) || FINAL_MSG_STATES.includes(data.state)) upgradeStarting = false;
652
670
  } catch { /* 服务重启期间拉不到状态属正常:保持 starting 视觉 */ }
653
671
  refresh();
654
672
  // 升级中高频轮询,空闲低频保活。
@@ -739,6 +757,17 @@ function apply(ctx) {
739
757
  }
740
758
  }
741
759
 
760
+ /** 切换 DSH 检测渠道(alpha 预发布开关):保存到后端并刷新回显。 */
761
+ async function handleChannelChange(enabled) {
762
+ try {
763
+ await api('/channel', { method: 'POST', body: JSON.stringify({ channel: enabled ? 'alpha' : 'stable' }) });
764
+ } catch (err) {
765
+ console.warn(`[${NS}] 保存渠道设置失败:`, err);
766
+ }
767
+ // 无论成败都刷新:开关以服务端落盘的真实渠道为准(保存失败会弹回)
768
+ await pollStatus();
769
+ }
770
+
742
771
  async function handleCheck() {
743
772
  checking = true;
744
773
  refresh();
@@ -787,6 +816,7 @@ function apply(ctx) {
787
816
  currentVersion: dict('currentVersion'), latestVersion: dict('latestVersion'),
788
817
  lastCheck: dict('lastCheck'), never: dict('never'), processing: dict('processing'),
789
818
  updateAvailable: dict('updateAvailable'), upgraded: dict('upgraded'), failed: dict('failed'),
819
+ alphaChannel: dict('alphaChannel'),
790
820
  pluginNav: dict('pluginNav'),
791
821
  pluginUpdating: dict('pluginUpdating'), checkingLabel: dict('checkingLabel'),
792
822
  pendingRestart: dict('pendingRestart'),
@@ -796,6 +826,7 @@ function apply(ctx) {
796
826
  checking,
797
827
  onCheck: handleCheck,
798
828
  onUpgrade: handleUpgrade,
829
+ onChannelChange: handleChannelChange,
799
830
  plugin: pluginData,
800
831
  pluginBusy: pluginBusy || pluginStarting,
801
832
  pluginChecking,
package/lib/index.js CHANGED
@@ -230,6 +230,20 @@ export function apply(ctx) {
230
230
  return { ...raw, state: baseState };
231
231
  };
232
232
 
233
+ /* ---------- DSH 检测渠道设置(alpha 预发布开关) ---------- */
234
+ // 开关持久化在 workspace/.dsh/selfupdate-config.json,插件更新/重启都不丢。
235
+ // alpha 是官方预发布通道,可能与旧插件 client bundle 不兼容(0.1.2-alpha.3
236
+ // 实测升级后 web 端 boot 报错),因此默认关闭,由用户在设置卡片显式开启。
237
+ const channelFile = join(dshStateDir, 'selfupdate-config.json');
238
+ const readChannel = () => {
239
+ try {
240
+ return JSON.parse(readFileSync(channelFile, 'utf8'))?.channel === 'alpha' ? 'alpha' : 'stable';
241
+ } catch { return 'stable'; }
242
+ };
243
+ // 检测用渠道 tag 列表:stable 只看 latest+next(与 build.sh 取版策略一致);
244
+ // alpha 开启后纳入 alpha 渠道(显式尝鲜)。
245
+ const dshTags = () => (readChannel() === 'alpha' ? ['latest', 'next', 'alpha'] : ['latest', 'next']);
246
+
233
247
  /* ---------- GET /dsh-selfupdater/status ---------- */
234
248
  registerRoute('GET', '/dsh-selfupdater/status', (_req, res) => {
235
249
  const status = dshStatusView();
@@ -243,6 +257,8 @@ export function apply(ctx) {
243
257
  state: status.state ?? 'idle',
244
258
  message: status.message ?? null,
245
259
  lastCheck: status.updatedAt ?? null,
260
+ // 当前检测渠道(前端 alpha 开关的回显依据)
261
+ channel: readChannel(),
246
262
  });
247
263
  });
248
264
 
@@ -253,7 +269,8 @@ export function apply(ctx) {
253
269
  return;
254
270
  }
255
271
  try {
256
- const latest = await fetchLatestVersion(PKG);
272
+ // 渠道 tag 按 UI 开关传入:stable 只看 latest+next,alpha 开启后多查 alpha
273
+ const latest = await fetchLatestVersion(PKG, { tags: dshTags() });
257
274
  const current = currentDshVersion(appDir);
258
275
  const updateAvailable = isNewer(latest, current);
259
276
  // 检查结果落盘(内部自动建目录),UI 刷新后仍能看到上次检查时间。
@@ -310,6 +327,15 @@ export function apply(ctx) {
310
327
  sendJson(res, 500, { error: `写入锁文件失败:${err.message}` });
311
328
  return;
312
329
  }
330
+ // 受理即落盘 running 状态:给前端乐观视觉一个服务端依据(转圈 + 按钮禁用),
331
+ // 避免宿主退出前轮询仍读到旧的 idle/"发现新版本" 而误判升级没发生、
332
+ // 允许重复点击。latestVersion 等历史字段原样保留,供归位判定使用。
333
+ writeStatus(dshStateDir, {
334
+ ...readStatus(dshStateDir),
335
+ state: 'running',
336
+ message: '升级已受理,服务即将切换到升级脚本…',
337
+ updatedAt: new Date().toISOString(),
338
+ });
313
339
  const child = spawn(process.execPath, [
314
340
  join(PLUGIN_ROOT, 'lib', 'updater.mjs'),
315
341
  '--pid', String(process.pid),
@@ -317,7 +343,12 @@ export function apply(ctx) {
317
343
  '--workspace', workspace,
318
344
  '--port', String(port),
319
345
  '--pkg', PKG,
320
- ], { detached: true, stdio: 'ignore', env: process.env });
346
+ ], {
347
+ detached: true,
348
+ stdio: 'ignore',
349
+ // alpha 开关透传给升级脚本(updater.mjs 的 distTags 读该环境变量)
350
+ env: readChannel() === 'alpha' ? { ...process.env, DSHSU_DSH_CHANNEL: 'alpha' } : process.env,
351
+ });
321
352
  child.unref();
322
353
 
323
354
  host.logger?.info?.(`[dsh-selfupdater] 升级进程已启动 pid=${child.pid},主程序即将退出`);
@@ -327,6 +358,36 @@ export function apply(ctx) {
327
358
  sendJson(res, 202, { accepted: true, note: '服务将自动退出并由升级脚本接管' });
328
359
  });
329
360
 
361
+ /* ---------- POST /dsh-selfupdater/channel ---------- */
362
+ /** 读取并解析 JSON 请求体(仅一个开关字段,无需引入框架式解析)。 */
363
+ const readJsonBody = (req) => new Promise((resolve) => {
364
+ let raw = '';
365
+ req.on('data', (chunk) => {
366
+ raw += chunk;
367
+ if (raw.length > 4096) { resolve({}); req.destroy(); }
368
+ });
369
+ req.on('end', () => { try { resolve(JSON.parse(raw || '{}')); } catch { resolve({}); } });
370
+ req.on('error', () => resolve({}));
371
+ });
372
+ registerRoute('POST', '/dsh-selfupdater/channel', async (req, res) => {
373
+ if (!sameOrigin(req)) {
374
+ sendJson(res, 403, { error: 'untrusted request' });
375
+ return;
376
+ }
377
+ const body = await readJsonBody(req);
378
+ // 只允许两个合法值,其余一律归为 stable(含 undefined/乱传)
379
+ const channel = body?.channel === 'alpha' ? 'alpha' : 'stable';
380
+ try {
381
+ mkdirSync(dshStateDir, { recursive: true });
382
+ writeFileSync(channelFile, JSON.stringify({ channel, updatedAt: new Date().toISOString() }, null, 2));
383
+ } catch (err) {
384
+ sendJson(res, 500, { error: `保存渠道设置失败:${err.message}` });
385
+ return;
386
+ }
387
+ host.logger?.info?.(`[dsh-selfupdater] DSH 检测渠道已切换为 ${channel}`);
388
+ sendJson(res, 200, { channel });
389
+ });
390
+
330
391
  /* ---------- GET /dsh-selfupdater/plugins ---------- */
331
392
  /**
332
393
  * 插件更新小节只针对本插件自身(dsh-selfupdater):
@@ -88,8 +88,20 @@ 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'];
91
+ /**
92
+ * 追踪的发布渠道 tag 列表。
93
+ * 默认 latest + next,与 build.sh 的取版策略保持一致(构建 FPK 时优先
94
+ * dist-tags.next):官方把 alpha 当预发布通道,可能与旧插件 client bundle
95
+ * 不兼容(0.1.2-alpha.3 实测升级后 web 端报 boot manifest batches must be
96
+ * an array 进不去),因此默认不追;仅当环境变量 DSHSU_DSH_CHANNEL 含
97
+ * alpha(逗号分隔)时才纳入,显式尝鲜才开启。
98
+ */
99
+ function distTags() {
100
+ const tags = ['latest', 'next'];
101
+ const channels = (process.env.DSHSU_DSH_CHANNEL ?? '').split(',').map((s) => s.trim());
102
+ if (channels.includes('alpha')) tags.push('alpha');
103
+ return tags;
104
+ }
93
105
 
94
106
  /**
95
107
  * 从单个 registry 的版本级端点(/<pkg>/<tag>)取文档(含 dist.tarball)。
@@ -119,12 +131,13 @@ async function fetchVersionDoc(base, pkg, tag = 'latest') {
119
131
  *
120
132
  * @returns {{version: string, tarball: string|null}} 全部失败时抛聚合错误。
121
133
  */
122
- async function fetchLatestRelease(pkg) {
134
+ async function fetchLatestRelease(pkg, opts = {}) {
123
135
  const candidates = registryCandidates();
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];
136
+ // 每个 registry × 每个渠道 tag 并行查询:
137
+ // 只查 /latest 会漏掉挂在 next/alpha tag 上的新版(0.1.2-alpha.2 实测踩坑);
138
+ // 渠道范围默认由 distTags() 决定(latest+next,alpha 需环境变量显式开启),
139
+ // 也允许调用方显式传入 tags(DSH 检测路由按 UI 开关传入)。
140
+ const tags = opts.tags ?? distTags();
128
141
  const targets = candidates.flatMap((base) => tags.map((tag) => ({ base, tag })));
129
142
  const results = await Promise.allSettled(targets.map((t) => fetchVersionDoc(t.base, pkg, t.tag)));
130
143
  let best = null;
@@ -140,9 +153,9 @@ async function fetchLatestRelease(pkg) {
140
153
  return best;
141
154
  }
142
155
 
143
- /** 只取最新版本号(检查更新路由使用)。 */
144
- export async function fetchLatestVersion(pkg) {
145
- return (await fetchLatestRelease(pkg)).version;
156
+ /** 只取最新版本号(检查更新路由使用);opts.tags 可覆盖默认渠道。 */
157
+ export async function fetchLatestVersion(pkg, opts = {}) {
158
+ return (await fetchLatestRelease(pkg, opts)).version;
146
159
  }
147
160
 
148
161
  /* ------------------------------------------------------------------ *
package/lib/updater.mjs CHANGED
@@ -241,8 +241,19 @@ 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'];
244
+ /**
245
+ * 追踪的发布渠道 tag 列表(与 plugin-update-task.mjs 的 distTags 一致)。
246
+ * 默认 latest + next,与 build.sh 取版策略保持一致;alpha 为官方预发布
247
+ * 通道,可能与旧插件 client bundle 不兼容(0.1.2-alpha.3 实测升级后 web
248
+ * 端 boot manifest 报错进不去),默认不追,仅当 DSHSU_DSH_CHANNEL 含
249
+ * alpha 时显式纳入。
250
+ */
251
+ function distTags() {
252
+ const tags = ['latest', 'next'];
253
+ const channels = (process.env.DSHSU_DSH_CHANNEL ?? '').split(',').map((s) => s.trim());
254
+ if (channels.includes('alpha')) tags.push('alpha');
255
+ return tags;
256
+ }
246
257
 
247
258
  /**
248
259
  * 从单个 registry 的版本级端点(/<pkg>/<tag>)取文档(含 dist.tarball)。
@@ -270,10 +281,10 @@ async function fetchVersionDoc(base, pkg, tag = 'latest') {
270
281
  */
271
282
  async function fetchLatestRelease(pkg) {
272
283
  const candidates = registryCandidates();
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];
284
+ // 每个 registry × 每个渠道 tag 并行查询:
285
+ // 只查 /latest 会漏掉挂在 next/alpha tag 上的新版(0.1.2-alpha.2 实测踩坑);
286
+ // 渠道范围由 distTags() 决定(默认 latest+next,alpha 需环境变量显式开启)。
287
+ const tags = distTags();
277
288
  const targets = candidates.flatMap((base) => tags.map((tag) => ({ base, tag })));
278
289
  const results = await Promise.allSettled(targets.map((t) => fetchVersionDoc(t.base, pkg, t.tag)));
279
290
  let best = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-selfupdater",
3
- "version": "0.4.19",
3
+ "version": "0.4.22",
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",