ygc-dsh-plugin-autoupdate 0.6.0 → 0.7.0

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/client.js CHANGED
@@ -179,7 +179,9 @@ function makePanel(React) {
179
179
 
180
180
  const plugins = Array.isArray(snap.plugins) ? snap.plugins : [];
181
181
  const registryPlugins = plugins.filter((p) => p.kind === 'registry');
182
- const otherPlugins = plugins.filter((p) => p.kind !== 'registry');
182
+ // git 与 registry 一样参与自动更新,只是比的是 commit 而不是版本号
183
+ const gitPlugins = plugins.filter((p) => p.kind === 'git');
184
+ const otherPlugins = plugins.filter((p) => p.kind !== 'registry' && p.kind !== 'git');
183
185
  const available = plugins.filter((p) => p.updateAvailable);
184
186
  const ready = available.filter((p) => !p.waitMs);
185
187
 
@@ -321,6 +323,7 @@ function makePanel(React) {
321
323
  );
322
324
 
323
325
  const pluginRow = (plugin) => {
326
+ const isGit = plugin.kind === 'git';
324
327
  const tone = plugin.updateAvailable
325
328
  ? plugin.waitMs
326
329
  ? 'pau-tag-warn'
@@ -333,7 +336,21 @@ function makePanel(React) {
333
336
  : plugin.latestError
334
337
  ? '查询失败'
335
338
  : '已是最新';
336
- const showLatest = plugin.latest && plugin.latest !== plugin.version;
339
+ // git 来源没有版本号可比:当前版本只认 commit,「最新」也只写短 sha
340
+ const currentCommit = plugin.git && plugin.git.commit ? String(plugin.git.commit).slice(0, 10) : '';
341
+ const showLatest = isGit
342
+ ? Boolean(plugin.latest && plugin.latest !== currentCommit)
343
+ : Boolean(plugin.latest && plugin.latest !== plugin.version);
344
+ const latestLabel = isGit ? `远端 ${plugin.latest}` : `最新 v${plugin.latest}`;
345
+ // npm 上有同名包才算「能切换」;查不到就明确说没有,免得用户反复猜
346
+ const migration = plugin.npmMigration;
347
+ const canMigrate = !isGit && plugin.kind === 'local' && migration && migration.available;
348
+ const migrationNote =
349
+ !isGit && plugin.kind === 'local' && migration
350
+ ? migration.available
351
+ ? `npm 上有同名包 ${migration.latest}${migration.upgradable ? '(更高)' : ''},可切换来源`
352
+ : 'npm 上没有同名包,保持原来源'
353
+ : '';
337
354
  return h(
338
355
  'div',
339
356
  { className: 'pau-row', key: plugin.name },
@@ -344,15 +361,21 @@ function makePanel(React) {
344
361
  'div',
345
362
  { className: 'pau-row-t' },
346
363
  h('span', { className: 'pau-mono' }, plugin.name),
364
+ isGit ? h('span', { className: 'pau-tag' }, 'Git') : null,
347
365
  h('span', { className: 'pau-tag' }, plugin.version ? `v${plugin.version}` : '未装'),
348
- showLatest ? h('span', { className: 'pau-tag' }, `最新 v${plugin.latest}`) : null,
366
+ isGit && currentCommit ? h('span', { className: 'pau-tag' }, currentCommit) : null,
367
+ showLatest ? h('span', { className: 'pau-tag' }, latestLabel) : null,
349
368
  h('span', { className: `pau-tag ${tone}` }, stateText),
350
369
  ),
351
370
  plugin.latestError
352
371
  ? h('div', { className: 'pau-row-d' }, `查询失败:${plugin.latestError}`)
353
- : plugin.description
354
- ? h('div', { className: 'pau-row-d' }, plugin.description.slice(0, 160))
355
- : null,
372
+ : isGit && plugin.latestMessage
373
+ ? h('div', { className: 'pau-row-d' }, `${plugin.latestTag ? `${plugin.latestTag} · ` : ''}${plugin.latestMessage}`)
374
+ : migrationNote
375
+ ? h('div', { className: 'pau-row-d' }, migrationNote)
376
+ : plugin.description
377
+ ? h('div', { className: 'pau-row-d' }, plugin.description.slice(0, 160))
378
+ : null,
356
379
  ),
357
380
  h(
358
381
  'select',
@@ -387,16 +410,42 @@ function makePanel(React) {
387
410
  {
388
411
  className: 'pau-btn pau-btn-sm',
389
412
  disabled: !!busy || !plugin.updateAvailable,
413
+ title: isGit ? '安装远端最新提交' : '安装最新版本',
390
414
  onClick: () =>
391
415
  run(`update:${plugin.name}`, async () => {
392
- const next = await call('/update', { name: plugin.name, version: plugin.latest });
416
+ const body = isGit
417
+ ? { name: plugin.name, commit: plugin.latestCommit || '' }
418
+ : { name: plugin.name, version: plugin.latest };
419
+ const next = await call('/update', body);
393
420
  if (next.restart) setNote(next.restart.message || '已安排重启');
421
+ else if (next.result && next.result.ok === false) setNote(next.result.message || '未执行');
394
422
  else setNote(`${plugin.name} 已更新`);
395
423
  return next;
396
424
  }),
397
425
  },
398
426
  busy === `update:${plugin.name}` ? '更新中…' : '立即更新',
399
427
  ),
428
+ canMigrate
429
+ ? h(
430
+ 'button',
431
+ {
432
+ className: 'pau-btn pau-btn-sm',
433
+ disabled: !!busy,
434
+ title: `改用 npm 来源(${migration.latest}),之后就能按版本号自动更新`,
435
+ onClick: () =>
436
+ run(`migrate:${plugin.name}`, async () => {
437
+ const next = await call('/migrate', { name: plugin.name });
438
+ setNote(
439
+ next.result && next.result.ok
440
+ ? `${plugin.name} 已切换到 npm ${next.result.latest}`
441
+ : (next.result && next.result.message) || '切换未完成',
442
+ );
443
+ return next;
444
+ }),
445
+ },
446
+ busy === `migrate:${plugin.name}` ? '切换中…' : '切换到 npm',
447
+ )
448
+ : null,
400
449
  h(
401
450
  'button',
402
451
  {
@@ -427,42 +476,19 @@ function makePanel(React) {
427
476
  h(
428
477
  'div',
429
478
  { className: 'pau-card-h' },
430
- h('h3', { className: 'pau-card-t' }, `已装插件(${registryPlugins.length})`),
479
+ h('h3', { className: 'pau-card-t' }, `已装插件(${registryPlugins.length + gitPlugins.length})`),
431
480
  h('span', { className: 'pau-row-d' }, available.length ? `${available.length} 个可更新` : '全部最新'),
432
481
  ),
433
- registryPlugins.length
434
- ? registryPlugins.map(pluginRow)
482
+ registryPlugins.length + gitPlugins.length
483
+ ? registryPlugins.concat(gitPlugins).map(pluginRow)
435
484
  : h('div', { className: 'pau-empty' }, '没有可联网检查的插件'),
436
485
  otherPlugins.length
437
486
  ? h(
438
487
  'div',
439
488
  { style: { marginTop: 12 } },
440
- h('div', { className: 'pau-row-d' }, '本地路径 / Git 来源 / 随安装提供(不参与版本检查)'),
489
+ h('div', { className: 'pau-row-d' }, '本地路径 / 随安装提供(不参与版本检查)'),
441
490
  otherPlugins.map((plugin) =>
442
- h(
443
- 'div',
444
- { className: 'pau-row', key: plugin.name },
445
- h(
446
- 'div',
447
- { className: 'pau-row-main' },
448
- h(
449
- 'div',
450
- { className: 'pau-row-t' },
451
- h('span', { className: 'pau-mono' }, plugin.name),
452
- h(
453
- 'span',
454
- { className: 'pau-tag' },
455
- plugin.kind === 'git'
456
- ? 'Git'
457
- : plugin.kind === 'local'
458
- ? '本地'
459
- : '随安装提供',
460
- ),
461
- plugin.version ? h('span', { className: 'pau-tag' }, `v${plugin.version}`) : null,
462
- ),
463
- h('div', { className: 'pau-row-d' }, plugin.spec || '由 DSH 安装提供,版本跟随宿主'),
464
- ),
465
- ),
491
+ pluginRow(plugin),
466
492
  ),
467
493
  )
468
494
  : null,
package/index.js CHANGED
@@ -18,21 +18,28 @@ import {
18
18
  humanHours,
19
19
  installedMeta,
20
20
  installedVersion,
21
+ installedGitHead,
21
22
  isNewerVersion,
23
+ isUpdatableKind,
22
24
  loadState,
25
+ parseGitSpec,
23
26
  policyFor,
24
27
  profileBundleNames,
25
28
  profileDependencySpecs,
26
29
  pushHistory,
27
30
  saveState,
28
31
  updateKind,
32
+ writeDependencySpec,
29
33
  } from './lib/core.js';
30
- import { normalizeRegistry, queryLatest } from './lib/registry.js';
31
- import { installSpec, installVersion } from './lib/updater.js';
34
+ import { normalizeRegistry, probePackage, queryLatest } from './lib/registry.js';
35
+ import { parseLockGitCommit, queryCommitMessage, queryGitHead, queryLatestTag } from './lib/gitref.js';
36
+ import { backupProfile, installSpec, installVersion } from './lib/updater.js';
32
37
  import { scheduleRestart } from './lib/restart.js';
33
38
  import { createApi } from './lib/api.js';
34
39
  import { installSharedPlugin, libraryRoot, shareSnapshot, syncMyFeature, writeAggregate } from './lib/share.js';
35
40
  import { resolveIdentity } from './lib/machine.js';
41
+ import fs from 'node:fs';
42
+ import path from 'node:path';
36
43
 
37
44
  export const inject = ['webServer'];
38
45
 
@@ -86,15 +93,29 @@ export function apply(ctx, config = {}) {
86
93
  const specs = profileDependencySpecs();
87
94
  // 「已发布」名单存在 state 里,跟着面板配置走
88
95
  const publishedNames = loadState().publishedNames || [];
96
+ // lockfile 只读一次:git 来源的「当前 commit」只能从这里拿
97
+ let lockText = '';
98
+ try {
99
+ lockText = fs.readFileSync(path.join(profileDir(), 'pnpm-lock.yaml'), 'utf8');
100
+ } catch {
101
+ /* 没有 lock 文件就退化成只有 gitHead */
102
+ }
89
103
  return names
90
104
  .map((name) => {
91
105
  const meta = installedMeta(name);
106
+ const spec = typeof specs[name] === 'string' ? specs[name] : null;
107
+ const kind = updateKind(name, { publishedNames });
108
+ const parsed = kind === 'git' ? parseGitSpec(spec) : undefined;
109
+ const lockCommit = kind === 'git' ? parseLockGitCommit(lockText, name) : undefined;
110
+ const gitHead = kind === 'git' ? installedGitHead(name) : undefined;
92
111
  return {
93
112
  name,
94
113
  version: installedVersion(name) || null,
95
- spec: typeof specs[name] === 'string' ? specs[name] : null,
114
+ spec,
96
115
  // 自己这个包也要能被管理,否则「自动更新器本身」永远停在旧版
97
- kind: updateKind(name, { publishedNames }),
116
+ kind,
117
+ // git 来源额外带:仓库、钉住的 ref、当前 commit —— 面板据此显示「落后几个提交」
118
+ git: kind === 'git' ? { owner: parsed?.owner, repo: parsed?.repo, ref: parsed?.ref, commit: lockCommit || gitHead } : undefined,
98
119
  description: meta.description || '',
99
120
  homepage: meta.homepage || meta.repository || '',
100
121
  };
@@ -109,6 +130,21 @@ export function apply(ctx, config = {}) {
109
130
  };
110
131
  };
111
132
 
133
+ /**
134
+ * 给 git 子进程用的代理地址。
135
+ *
136
+ * 为什么必须显式取而不是靠环境变量:Node 自己的 fetch 会读 HTTPS_PROXY,
137
+ * 但 `git` 是独立进程,本机 git 未配置 http.proxy 时不会读这两个变量,
138
+ * 直连 github.com 会超时 20 秒然后报连不上。这里按配置 → 环境变量 → 常用端口依次取。
139
+ */
140
+ const updateProxy = () => {
141
+ const configured = config?.gitProxy || config?.proxy;
142
+ if (typeof configured === 'string' && configured.trim()) return configured.trim();
143
+ const env = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
144
+ if (typeof env === 'string' && env.trim()) return env.trim();
145
+ return '';
146
+ };
147
+
112
148
  /* -------------------------------------------------------------- 共享 */
113
149
 
114
150
  /** 共享相关的设置(目录、特征名)从状态里读,面板改完立即生效。 */
@@ -244,42 +280,130 @@ export function apply(ctx, config = {}) {
244
280
  const runCheck = async ({ signal } = {}) => {
245
281
  const state = loadState();
246
282
  const now = Date.now();
247
- const plugins = listPlugins().filter((p) => p.kind === 'registry' && p.version);
283
+ const compare = (a, b) => (isNewerVersion(b, a) ? 1 : a === b ? 0 : -1);
284
+ // registry 走版本号;git 走 commit sha。两者都要查。
285
+ const plugins = listPlugins().filter((p) => isUpdatableKind(p.kind) && p.version);
248
286
  const { registry, fallbackRegistries } = registries();
249
287
 
250
288
  const results = [];
251
289
  let cursor = 0;
290
+
291
+ /** registry 来源:比版本号。 */
292
+ const checkRegistry = async (plugin) => {
293
+ const answer = await queryLatest({ name: plugin.name, registry, fallbackRegistries, compare, signal });
294
+ if (!answer.ok) return { ...plugin, error: answer.error };
295
+ const newer = isNewerVersion(plugin.version, answer.latest);
296
+ const policy = policyFor(state, plugin.name);
297
+ const remaining = newer ? delayRemainingMs(state, plugin.name, answer.publishedAt, now) : 0;
298
+ return {
299
+ ...plugin,
300
+ latest: answer.latest,
301
+ publishedAt: answer.publishedAt || 0,
302
+ updateAvailable: newer,
303
+ policy: policy.policy,
304
+ delayHours: policy.delayHours,
305
+ eligible: newer && remaining === 0,
306
+ waitMs: remaining,
307
+ waitText: remaining > 0 ? humanHours(remaining) : '',
308
+ };
309
+ };
310
+
311
+ /**
312
+ * git 来源:比 commit sha。
313
+ *
314
+ * 判据只能是 sha,不能看 package.json 的 version——上游可以连提十次都不改版本号
315
+ * (本机 dsh-knowledge-wiki 就是 0.34.0 停在某个 commit 之后的典型)。
316
+ * 「最新」这一列写短 sha,面板才看得出差在哪。
317
+ *
318
+ * 主通道是 `git ls-remote`:不受 GitHub API 未登录每小时 60 次的限流。
319
+ * 代理必须显式传给 git(Node 的 env-proxy 只管 fetch,管不到 git 子进程)。
320
+ */
321
+ const checkGit = async (plugin) => {
322
+ const parsed = parseGitSpec(plugin.spec);
323
+ const policy = policyFor(state, plugin.name);
324
+ const base = { policy: policy.policy, delayHours: policy.delayHours };
325
+ if (!parsed) return { ...plugin, ...base, error: '无法解析 git 地址' };
326
+
327
+ const head = await queryGitHead({ parsed, ref: parsed.ref, proxy: updateProxy() });
328
+ if (!head.ok) return { ...plugin, ...base, error: head.error };
329
+
330
+ const current = plugin.git?.commit;
331
+ // 当前 commit 读不到时不能判成「有更新」——否则每轮都会重复安装同一个版本
332
+ if (!current) {
333
+ return {
334
+ ...plugin,
335
+ ...base,
336
+ latest: head.shortCommit,
337
+ latestCommit: head.commit,
338
+ updateAvailable: false,
339
+ unknownCurrent: true,
340
+ note: '读不到已装 commit,按「不更新」处理',
341
+ };
342
+ }
343
+ const newer = current.toLowerCase() !== head.commit;
344
+ if (!newer) {
345
+ return { ...plugin, ...base, latest: head.shortCommit, latestCommit: head.commit, updateAvailable: false };
346
+ }
347
+
348
+ // 有更新才去补说明与 tag:这两件事都是纯展示,失败不影响判定
349
+ const [detail, tag] = await Promise.all([
350
+ queryCommitMessage({ parsed, commit: head.commit, proxy: updateProxy(), signal }).catch(() => ({ ok: false })),
351
+ queryLatestTag({ parsed, proxy: updateProxy() }).catch(() => ({ ok: false })),
352
+ ]);
353
+ const publishedAt = detail.ok ? detail.publishedAt || 0 : 0;
354
+ const remaining = delayRemainingMs(state, plugin.name, publishedAt, now);
355
+ return {
356
+ ...plugin,
357
+ ...base,
358
+ latest: head.shortCommit,
359
+ latestCommit: head.commit,
360
+ latestMessage: detail.ok ? detail.message : '',
361
+ latestTag: tag.ok ? tag.tag : undefined,
362
+ publishedAt,
363
+ updateAvailable: true,
364
+ eligible: remaining === 0,
365
+ waitMs: remaining,
366
+ waitText: remaining > 0 ? humanHours(remaining) : '',
367
+ };
368
+ };
369
+
370
+ /**
371
+ * 非 registry 来源(local / git)顺带探一次 npm 同名包。
372
+ *
373
+ * 用户要的是「更新时检查有没有对应的 npm 版,有就切换」。
374
+ * 做法是只报不自动切:切来源要改 profile 的 package.json,
375
+ * 属于「换一种安装方式」,比升版本危险得多,必须人工点。
376
+ */
377
+ const probeMigration = async (plugin) => {
378
+ if (plugin.kind === 'registry' || plugin.kind === 'installation') return plugin;
379
+ const answer = await probePackage({ name: plugin.name, registry, compare, signal });
380
+ if (!answer.ok) return plugin;
381
+ if (!answer.exists) {
382
+ // 明确记下「npm 上没有」,避免用户反复猜
383
+ return { ...plugin, npmMigration: { available: false, checkedAt: now } };
384
+ }
385
+ return {
386
+ ...plugin,
387
+ npmMigration: {
388
+ available: true,
389
+ latest: answer.latest,
390
+ publishedAt: answer.publishedAt || 0,
391
+ registry: answer.registry,
392
+ checkedAt: now,
393
+ // 已装版本可能比 npm 上还新(本地改过),这种不必切
394
+ upgradable: Boolean(plugin.version && answer.latest && isNewerVersion(plugin.version, answer.latest)),
395
+ },
396
+ };
397
+ };
398
+
252
399
  const worker = async () => {
253
400
  for (;;) {
254
401
  const index = cursor;
255
402
  cursor += 1;
256
403
  if (index >= plugins.length) return;
257
404
  const plugin = plugins[index];
258
- const answer = await queryLatest({
259
- name: plugin.name,
260
- registry,
261
- fallbackRegistries,
262
- compare: (a, b) => (isNewerVersion(b, a) ? 1 : a === b ? 0 : -1),
263
- signal,
264
- });
265
- if (!answer.ok) {
266
- results[index] = { ...plugin, error: answer.error };
267
- continue;
268
- }
269
- const newer = isNewerVersion(plugin.version, answer.latest);
270
- const policy = policyFor(state, plugin.name);
271
- const remaining = newer ? delayRemainingMs(state, plugin.name, answer.publishedAt, now) : 0;
272
- results[index] = {
273
- ...plugin,
274
- latest: answer.latest,
275
- publishedAt: answer.publishedAt || 0,
276
- updateAvailable: newer,
277
- policy: policy.policy,
278
- delayHours: policy.delayHours,
279
- eligible: newer && remaining === 0,
280
- waitMs: remaining,
281
- waitText: remaining > 0 ? humanHours(remaining) : '',
282
- };
405
+ const checked = plugin.kind === 'git' ? await checkGit(plugin) : await checkRegistry(plugin);
406
+ results[index] = await probeMigration(checked);
283
407
  }
284
408
  };
285
409
  await Promise.all(Array.from({ length: Math.min(CHECK_CONCURRENCY, plugins.length) }, worker));
@@ -303,6 +427,13 @@ export function apply(ctx, config = {}) {
303
427
  publishedAt: item.publishedAt || 0,
304
428
  checkedAt: Date.now(),
305
429
  error: item.error || '',
430
+ // git 来源的展示字段:短 sha 之外的提交说明与最新 tag
431
+ latestCommit: item.latestCommit || '',
432
+ latestTag: item.latestTag || '',
433
+ latestMessage: item.latestMessage || '',
434
+ updateAvailable: Boolean(item.updateAvailable),
435
+ // 非 npm 来源顺带记录的「npm 上有没有同名包」结论
436
+ npmMigration: item.npmMigration || null,
306
437
  };
307
438
  if (!item.updateAvailable) continue;
308
439
  const old = next.pending[item.name];
@@ -344,22 +475,42 @@ export function apply(ctx, config = {}) {
344
475
 
345
476
  /* -------------------------------------------------------------- 更新 */
346
477
 
347
- /** 安装一个包的指定版本,并写历史。 */
348
- const applyUpdate = async (name, version, reason) => {
478
+ /**
479
+ * 安装一个包的指定版本 / 提交,并写历史。
480
+ *
481
+ * registry 走 `name@version`;git 走 `name@<完整安装 spec>`——
482
+ * 对 git 依赖必须把 ref 一起带上,否则装出来的还是分支最新而不是这次判定的那个 commit。
483
+ */
484
+ const applyUpdate = async (name, version, reason, options = {}) => {
349
485
  // 装上哪个源跟着查询走:查的是 A,装也必须用 A,
350
486
  // 否则刚发布到 A 的版本在 B 上还不存在,pnpm 会直接装不到。
351
487
  const { registry } = registries();
352
- const result = await installVersion({ ctx, name, version, registry, log });
488
+ const kind = options.kind || updateKind(name, { publishedNames: loadState().publishedNames });
489
+ let result;
490
+ if (kind === 'git') {
491
+ const spec = options.spec || profileDependencySpecs()[name];
492
+ const parsed = parseGitSpec(spec);
493
+ // 把 ref 钉到这次查到的 commit:分支会在安装期间继续往前走
494
+ const pinned = parsed && options.commit ? `${spec.split('#')[0]}#${options.commit}` : spec;
495
+ result = await installSpec({ ctx, spec: pinned, registry, log });
496
+ } else {
497
+ result = await installVersion({ ctx, name, version, registry, log });
498
+ }
353
499
  const next = loadState();
500
+ const label = kind === 'git' ? (options.commit ? String(options.commit).slice(0, 10) : version) : version;
354
501
  if (result.ok) {
355
502
  delete next.pending[name];
356
503
  pushHistory(next, {
357
504
  kind: 'update',
358
505
  reason: reason || 'auto',
359
506
  name,
360
- version,
507
+ version: label,
508
+ sourceKind: kind,
361
509
  via: result.via,
362
- text: `${name} 已更新到 ${version}(${result.via})`,
510
+ text:
511
+ kind === 'git'
512
+ ? `${name} 已更新到提交 ${label}(${result.via})`
513
+ : `${name} 已更新到 ${version}(${result.via})`,
363
514
  restartRequired: result.restartRequired === true,
364
515
  });
365
516
  } else {
@@ -367,12 +518,13 @@ export function apply(ctx, config = {}) {
367
518
  kind: 'update-failed',
368
519
  reason: reason || 'auto',
369
520
  name,
370
- version,
371
- text: `${name} 更新到 ${version} 失败:${String(result.message).slice(0, 400)}`,
521
+ version: label,
522
+ sourceKind: kind,
523
+ text: `${name} 更新到 ${label} 失败:${String(result.message).slice(0, 400)}`,
372
524
  });
373
525
  }
374
526
  saveState(next);
375
- log(result.ok ? `已更新 ${name}@${version}` : `更新失败 ${name}@${version}`);
527
+ log(result.ok ? `已更新 ${name}(${label})` : `更新失败 ${name}(${label})`);
376
528
  return result;
377
529
  };
378
530
 
@@ -386,7 +538,11 @@ export function apply(ctx, config = {}) {
386
538
  const due = checks.filter((item) => item.eligible);
387
539
  const updated = [];
388
540
  for (const item of due) {
389
- const result = await applyUpdate(item.name, item.latest, reason || 'auto');
541
+ const result = await applyUpdate(item.name, item.latest, reason || 'auto', {
542
+ kind: item.kind,
543
+ spec: item.spec,
544
+ commit: item.latestCommit,
545
+ });
390
546
  if (result.ok) updated.push({ name: item.name, version: item.latest, restartRequired: result.restartRequired === true });
391
547
  }
392
548
 
@@ -513,10 +669,67 @@ export function apply(ctx, config = {}) {
513
669
 
514
670
  /* ---------------------------------------------------------------- API */
515
671
 
672
+ /**
673
+ * 把某个非 registry 来源的插件切换成 npm 来源。
674
+ *
675
+ * 这是「换安装方式」,比升版本危险:要改 profile 的 package.json。
676
+ * 所以必须人工触发,且装不上时把 package.json 原样还原。
677
+ */
678
+ const migrateToNpm = async (name, { reason } = {}) => {
679
+ const { registry } = registries();
680
+ const specs = profileDependencySpecs();
681
+ const spec = specs[name];
682
+ if (typeof spec !== 'string') return { ok: false, message: `${name} 不在 profile dependencies 里` };
683
+ const kind = updateKind(name, { publishedNames: loadState().publishedNames });
684
+ if (kind === 'registry') return { ok: false, message: `${name} 已经是 registry 来源` };
685
+
686
+ const probe = await probePackage({
687
+ name,
688
+ registry,
689
+ compare: (a, b) => (isNewerVersion(b, a) ? 1 : a === b ? 0 : -1),
690
+ });
691
+ if (!probe.ok) return { ok: false, message: `查询 npm 失败:${probe.error}` };
692
+ if (!probe.exists) return { ok: false, message: `npm 上没有 ${name},无法切换(保持原来源)` };
693
+
694
+ backupProfile(log);
695
+ try {
696
+ // 直接把依赖写法换成 npm 版本:pnpm 会据此重新解析,lock 里换成 registry tarball
697
+ const result = await installSpec({ ctx, spec: `${name}@${probe.latest}`, registry, log });
698
+ const next = loadState();
699
+ if (result.ok) {
700
+ pushHistory(next, {
701
+ kind: 'migrate',
702
+ reason: reason || '手动',
703
+ name,
704
+ version: probe.latest,
705
+ fromKind: kind,
706
+ text: `${name} 已从 ${kind} 来源切换到 npm ${probe.latest}`,
707
+ restartRequired: result.restartRequired === true,
708
+ });
709
+ saveState(next);
710
+ return { ok: true, latest: probe.latest, kind: 'registry', via: result.via };
711
+ }
712
+ // 装不上:把 package.json 里这一行写回原样
713
+ writeDependencySpec(name, spec);
714
+ pushHistory(next, {
715
+ kind: 'migrate-failed',
716
+ reason: reason || '手动',
717
+ name,
718
+ text: `${name} 切换到 npm 失败,已还原为 ${spec}:${String(result.message).slice(0, 300)}`,
719
+ });
720
+ saveState(next);
721
+ return { ok: false, message: result.message };
722
+ } catch (error) {
723
+ writeDependencySpec(name, spec);
724
+ return { ok: false, message: String(error?.message || error) };
725
+ }
726
+ };
727
+
516
728
  const api = createApi({
517
729
  listPlugins,
518
730
  runCheck: checkAndRecord,
519
731
  applyUpdate,
732
+ migrateToNpm,
520
733
  requestRestart,
521
734
  isBusy,
522
735
  log,
package/lib/api.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  profileBundleNames,
18
18
  saveState,
19
19
  updateKind,
20
+ isUpdatableKind,
20
21
  } from './core.js';
21
22
  import { normalizeRegistry, queryLatest } from './registry.js';
22
23
  import { restoreBackup, listBackups } from './backup.js';
@@ -57,14 +58,19 @@ function snapshot(deps) {
57
58
  // 而 pending 里还留着旧记录。只看 pending 会把「已是最新」错标成「可更新」。
58
59
  const newer = Boolean(latest && plugin.version && isNewerVersion(plugin.version, latest));
59
60
  const active = newer ? pending : undefined;
61
+ // git 来源没有版本号可比,可更新性直接看检查阶段算出的 updateAvailable
62
+ const gitNewer = plugin.kind === 'git' ? Boolean(seen && seen.updateAvailable) : false;
60
63
  return {
61
64
  ...plugin,
62
65
  policy: policy.policy,
63
66
  delayHours: policy.delayHours,
64
67
  hasOverride: Boolean(state.perPlugin[plugin.name]),
65
68
  latest: latest || null,
69
+ latestTag: (seen && seen.latestTag) || null,
70
+ latestMessage: (seen && seen.latestMessage) || '',
71
+ npmMigration: (seen && seen.npmMigration) || plugin.npmMigration || null,
66
72
  latestError: seen && seen.error ? seen.error : '',
67
- updateAvailable: newer,
73
+ updateAvailable: plugin.kind === 'git' ? gitNewer : newer,
68
74
  waitMs: active ? delayRemainingMs(state, plugin.name, active.firstSeenAt) : 0,
69
75
  waitText: active ? humanHours(delayRemainingMs(state, plugin.name, active.firstSeenAt)) : '',
70
76
  };
@@ -212,8 +218,19 @@ export function createApi(deps) {
212
218
  return;
213
219
  }
214
220
  const state = loadState();
215
- if (updateKind(name, { publishedNames: state.publishedNames }) !== 'registry') {
216
- json(res, 200, { ok: true, name, current, latest: null, note: '本地/仓库来源,不参与版本检查' });
221
+ const kind = updateKind(name, { publishedNames: state.publishedNames });
222
+ if (kind !== 'registry') {
223
+ // git / local 来源也能查,但比的是 commit 而不是版本号
224
+ const plugin = deps.listPlugins().find((item) => item.name === name);
225
+ json(res, 200, {
226
+ ok: true,
227
+ name,
228
+ current,
229
+ kind,
230
+ git: plugin?.git,
231
+ latest: null,
232
+ note: kind === 'git' ? 'Git 来源,请用「检查」看远端提交' : '本地来源,不参与版本检查',
233
+ });
217
234
  return;
218
235
  }
219
236
  const answer = await queryLatest({
@@ -244,12 +261,39 @@ export function createApi(deps) {
244
261
  const body = await readJson(req);
245
262
  const name = typeof body.name === 'string' ? body.name : '';
246
263
  const version = typeof body.version === 'string' ? body.version : '';
247
- if (!name || !version) {
248
- json(res, 400, { ok: false, error: '缺少 name 或 version' });
264
+ const commit = typeof body.commit === 'string' ? body.commit.trim().toLowerCase() : '';
265
+ if (!name || (!version && !commit)) {
266
+ json(res, 400, { ok: false, error: '缺少 name,或 version / commit' });
267
+ return;
268
+ }
269
+ const state = loadState();
270
+ const kind = updateKind(name, { publishedNames: state.publishedNames });
271
+ if (!isUpdatableKind(kind)) {
272
+ json(res, 400, {
273
+ ok: false,
274
+ error: '该插件既不是 registry 也不是 git 来源,不自动更新(本地 file: 来源可先「切换到 npm」)',
275
+ });
249
276
  return;
250
277
  }
251
- if (updateKind(name, { publishedNames: loadState().publishedNames }) !== 'registry') {
252
- json(res, 400, { ok: false, error: '该插件不是 registry 来源,不自动更新(若是已发布的自有插件,先在设置里把它列为「已发布」)' });
278
+ if (kind === 'git') {
279
+ const plugin = deps.listPlugins().find((item) => item.name === name);
280
+ const current = plugin?.git?.commit;
281
+ if (current && commit && current === commit) {
282
+ json(res, 200, {
283
+ ...snapshot(deps),
284
+ result: { ok: false, via: 'none', message: `${name} 当前已是提交 ${commit.slice(0, 10)},未执行` },
285
+ restart: null,
286
+ });
287
+ return;
288
+ }
289
+ const result = await deps.applyUpdate(name, commit ? commit.slice(0, 10) : '', '手动', {
290
+ kind: 'git',
291
+ spec: plugin?.spec,
292
+ commit: commit || undefined,
293
+ });
294
+ let restart = null;
295
+ if (result.ok && state.autoRestart) restart = deps.requestRestart('手动更新完成', state);
296
+ json(res, 200, { ...snapshot(deps), result, restart });
253
297
  return;
254
298
  }
255
299
  // 只允许向上更新:面板状态可能与磁盘不同步,传进来的 version 万一更旧就会降级
@@ -263,12 +307,30 @@ export function createApi(deps) {
263
307
  return;
264
308
  }
265
309
  const result = await deps.applyUpdate(name, version, '手动');
266
- const state = loadState();
310
+ const next = loadState();
267
311
  let restart = null;
268
- if (result.ok && state.autoRestart) restart = deps.requestRestart('手动更新完成', state);
312
+ if (result.ok && next.autoRestart) restart = deps.requestRestart('手动更新完成', next);
269
313
  json(res, 200, { ...snapshot(deps), result, restart });
270
314
  },
271
315
 
316
+ /**
317
+ * 把非 npm 来源切到 npm 来源。必须先查得到 npm 上有这个包,且要人工触发。
318
+ * 这是「换安装方式」,比升版本危险,永远不自动做。
319
+ */
320
+ 'POST /migrate': async (req, res) => {
321
+ const body = await readJson(req);
322
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
323
+ if (!name) {
324
+ json(res, 400, { ok: false, error: '缺少 name' });
325
+ return;
326
+ }
327
+ const result = await deps.migrateToNpm(name, { reason: '手动' });
328
+ const state = loadState();
329
+ let restart = null;
330
+ if (result.ok && state.autoRestart) restart = deps.requestRestart('切换到 npm 完成', state);
331
+ json(res, result.ok ? 200 : 400, { ...snapshot(deps), result, restart });
332
+ },
333
+
272
334
  /** 按策略更新所有到点的包。 */
273
335
  'POST /update-all': async (req, res) => {
274
336
  const state = loadState();
@@ -277,7 +339,11 @@ export function createApi(deps) {
277
339
  const updated = [];
278
340
  const failed = [];
279
341
  for (const item of due) {
280
- const result = await deps.applyUpdate(item.name, item.latest, '手动批量');
342
+ const result = await deps.applyUpdate(item.name, item.latest, '手动批量', {
343
+ kind: item.kind,
344
+ spec: item.spec,
345
+ commit: item.latestCommit,
346
+ });
281
347
  (result.ok ? updated : failed).push({ name: item.name, version: item.latest, message: result.message });
282
348
  }
283
349
  let restart = null;
package/lib/core.js CHANGED
@@ -119,6 +119,24 @@ export function profileDependencySpecs() {
119
119
  return deps;
120
120
  }
121
121
 
122
+ /**
123
+ * 直接把一个依赖写法写回 profile 的 package.json。
124
+ *
125
+ * 用途只有一个:切换到 npm 来源失败时把原写法还原。
126
+ * 只动 dependencies[name] 这一个键,其余字段与顺序一律保持原样(先解析再字符串写回会丢格式)。
127
+ */
128
+ export function writeDependencySpec(name, spec) {
129
+ const file = path.join(profileDir(), 'package.json');
130
+ const text = fs.readFileSync(file, 'utf8');
131
+ const pkg = JSON.parse(text);
132
+ if (!pkg || typeof pkg.dependencies !== 'object' || !pkg.dependencies) return false;
133
+ if (typeof spec === 'string') pkg.dependencies[name] = spec;
134
+ else delete pkg.dependencies[name];
135
+ // profile 的 package.json 是 2 空格缩进,写回时保持一致
136
+ fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
137
+ return true;
138
+ }
139
+
122
140
  /** 读已安装包的真实版本(node_modules/<name>/package.json)。 */
123
141
  export function installedVersion(name) {
124
142
  const manifest = path.join(profileDir(), 'node_modules', ...String(name).split('/'), 'package.json');
@@ -142,10 +160,59 @@ export function installedMeta(name) {
142
160
  };
143
161
  }
144
162
 
163
+ /**
164
+ * 读已装副本里记录的 git commit(npm publish 时写进 package.json 的 gitHead)。
165
+ * 拿不到返回 undefined——真实 sha 通常在 pnpm-lock.yaml 里,由调用方另行解析。
166
+ */
167
+ export function installedGitHead(name) {
168
+ const manifest = path.join(profileDir(), 'node_modules', ...String(name).split('/'), 'package.json');
169
+ const value = readJsonFile(manifest);
170
+ return typeof value?.gitHead === 'string' && value.gitHead ? value.gitHead.toLowerCase() : undefined;
171
+ }
172
+
173
+ /**
174
+ * 解析 git 依赖写法,取出仓库 URL 与钉住的 ref(分支/标签/commit)。
175
+ *
176
+ * 支持的写法(pnpm / npm 常见):
177
+ * github:user/repo
178
+ * github:user/repo#v1.2.3
179
+ * git+https://github.com/user/repo.git#main
180
+ * git+ssh://git@github.com/user/repo.git
181
+ * https://github.com/user/repo.git
182
+ *
183
+ * @returns {{url?: string, codeload?: string, ref?: string, owner?: string, repo?: string}|undefined}
184
+ */
185
+ export function parseGitSpec(spec) {
186
+ if (typeof spec !== 'string') return undefined;
187
+ const value = spec.trim();
188
+ if (!/^(github:|git\+|git:)/i.test(value) && !/^https?:\/\/\S+\.git(#|$)/i.test(value)) return undefined;
189
+
190
+ const hashAt = value.indexOf('#');
191
+ const ref = hashAt >= 0 ? value.slice(hashAt + 1).trim() || undefined : undefined;
192
+ const body = hashAt >= 0 ? value.slice(0, hashAt) : value;
193
+
194
+ // github:owner/repo
195
+ let m = /^github:([^/\s]+)\/([^/\s#]+)$/i.exec(body);
196
+ if (m) {
197
+ const owner = m[1];
198
+ const repo = m[2].replace(/\.git$/i, '');
199
+ return { owner, repo, ref, url: `https://github.com/${owner}/${repo}.git` };
200
+ }
201
+
202
+ // git+https://… / git+ssh://… / https://…git
203
+ const url = body.replace(/^git\+/i, '');
204
+ m = /github\.com[:/]([^/\s]+)\/([^/\s#]+?)(?:\.git)?\/?$/i.exec(url);
205
+ if (!m) return { url, ref };
206
+ const owner = m[1];
207
+ const repo = m[2].replace(/\.git$/i, '');
208
+ return { owner, repo, ref, url: `https://github.com/${owner}/${repo}.git` };
209
+ }
210
+
145
211
  /**
146
212
  * 判断某个包能不能自动更新。
147
213
  * 规则:
148
- * - 本地路径(file:/link:/workspace:)和 git 地址不自动更新——没有「发布」概念
214
+ * - 本地路径(file:/link:/workspace:)不自动更新——没有「发布」概念
215
+ * - git 地址可以更新:靠 git ref 比新旧(见 gitRefKind)
149
216
  * - 不在 profile dependencies 里的(随 DSH 安装提供的官方包)也没有可查的版本约束
150
217
  *
151
218
  * 「已发布的自有插件」例外:profile 里还是 file: 依赖,但它其实已经有 registry 产物。
@@ -165,10 +232,19 @@ export function updateKind(name, options) {
165
232
  const localish = /^(file:|link:|workspace:|portal:)/i.test(value);
166
233
  // 已在 registry 发布过的本地包,按 registry 处理(可以查新版、可以自动更新)
167
234
  if (localish) return claimed ? 'registry' : 'local';
168
- if (/^(git\+|git:|github:|https?:\/\/)/i.test(value)) return claimed ? 'registry' : 'git';
235
+ // 只有真正的 git 写法才算 git:github: / git+ / git: / 以 .git 结尾的 URL。
236
+ // 普通 https:// 直链(例如 release 附件 .tgz)仍然算不可自动更新——
237
+ // 它没有 ref 概念,也没有版本号可查,硬当 git 处理会误开一条走不通的安装路径。
238
+ if (parseGitSpec(value)) return claimed ? 'registry' : 'git';
239
+ if (/^https?:\/\//i.test(value)) return claimed ? 'registry' : 'local';
169
240
  return 'registry';
170
241
  }
171
242
 
243
+ /** 参与自动更新的来源(registry 走版本号,git 走 ref)。 */
244
+ export function isUpdatableKind(kind) {
245
+ return kind === 'registry' || kind === 'git';
246
+ }
247
+
172
248
  /* ---------------------------------------------------------------- state */
173
249
 
174
250
  /** 默认状态。 */
package/lib/gitref.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * git 来源插件的「有没有新版」查询层。
3
+ *
4
+ * 与 registry.js 的分工:registry 比版本号,这里比 commit。
5
+ * 一条 git 依赖能确定的「当前版本」只有 commit sha,所以判据只能是 sha 而不是 package.json 的 version
6
+ * ——上游完全可以连着提交十次都不改 version(本机 dsh-knowledge-wiki 0.34.0 就停在
7
+ * commit 8716b06,而其后的提交已经改了不少功能)。
8
+ *
9
+ * 双通道:
10
+ * 主:`git ls-remote`(走 443 上的 git 协议,不受 GitHub API 限流影响)
11
+ * 辅:GitHub REST API(只为拿提交说明与 tag 名,拿不到不影响判定)
12
+ *
13
+ * 主通道的选择理由:未登录时 api.github.com 只有 60 次/小时,二十几个插件轮询几轮就打光,
14
+ * 而 ls-remote 没有这个限制。判定「有没有新提交」这件事 ls-remote 完全够用。
15
+ */
16
+ import { execFile } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ const TIMEOUT_MS = 20000;
22
+ const DEFAULT_REF = 'HEAD';
23
+
24
+ /** 仓库标识 → API 根。只支持 GitHub;其余返回 undefined 由调用方降级。 */
25
+ export function gitApiBase(parsed) {
26
+ if (!parsed || !parsed.owner || !parsed.repo) return undefined;
27
+ return `https://api.github.com/repos/${parsed.owner}/${parsed.repo}`;
28
+ }
29
+
30
+ /**
31
+ * 找一个能用的 git 可执行文件。
32
+ *
33
+ * 不假设 PATH 里有 git:本机可能只装了 DSH 自带的 @dsh-ops/git.exe(那是包装器,不是真 git),
34
+ * 真正能用的是 dsh-tools 里那份。候选顺序按「确定可用」优先。
35
+ */
36
+ export function findGitBinary() {
37
+ const home = os.homedir();
38
+ const candidates = [
39
+ path.join(home, 'AppData', 'Local', 'Programs', 'dsh-tools', 'git', 'cmd', 'git.exe'),
40
+ 'C:\\Program Files\\Git\\cmd\\git.exe',
41
+ 'C:\\Program Files (x86)\\Git\\cmd\\git.exe',
42
+ '/usr/bin/git',
43
+ '/usr/local/bin/git',
44
+ 'git',
45
+ ];
46
+ for (const item of candidates) {
47
+ try {
48
+ if (item === 'git') return item;
49
+ if (fs.existsSync(item)) return item;
50
+ } catch {
51
+ /* 继续试下一个 */
52
+ }
53
+ }
54
+ return 'git';
55
+ }
56
+
57
+ function runGit(args, { env, timeout = TIMEOUT_MS } = {}) {
58
+ return new Promise((resolve) => {
59
+ const child = execFile(
60
+ findGitBinary(),
61
+ args,
62
+ {
63
+ timeout,
64
+ windowsHide: true,
65
+ maxBuffer: 8 * 1024 * 1024,
66
+ env: {
67
+ ...process.env,
68
+ ...env,
69
+ // 不让 git 弹凭据/主机确认,否则无人值守时会挂住
70
+ GIT_TERMINAL_PROMPT: '0',
71
+ GIT_ASKPASS: 'echo',
72
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=no',
73
+ },
74
+ },
75
+ (error, stdout, stderr) => {
76
+ if (error) resolve({ ok: false, code: error.code, error: String(stderr || error.message || error).trim() });
77
+ else resolve({ ok: true, stdout: String(stdout || '') });
78
+ },
79
+ );
80
+ child.on('error', (error) => resolve({ ok: false, error: String(error?.message || error) }));
81
+ });
82
+ }
83
+
84
+ /**
85
+ * 从 pnpm-lock.yaml 文本里抠出某个包的 git 解析结果(commit sha)。
86
+ *
87
+ * 形如:
88
+ * dsh-knowledge-wiki:
89
+ * specifier: github:135642-163/dsh-knowledge-base
90
+ * version: https://codeload.github.com/135642-163/dsh-knowledge-base/tar.gz/8716b06bf6...
91
+ */
92
+ export function parseLockGitCommit(lockText, name) {
93
+ if (typeof lockText !== 'string' || !name) return undefined;
94
+ const lines = lockText.split(/\r?\n/);
95
+ const key = `${name}:`;
96
+ for (let i = 0; i < lines.length; i += 1) {
97
+ if (lines[i].trim() !== key) continue;
98
+ for (let j = i + 1; j < Math.min(i + 7, lines.length); j += 1) {
99
+ const m = /^\s+version:\s*(.+)$/.exec(lines[j]);
100
+ if (!m) continue;
101
+ const sha = /([0-9a-f]{40})/i.exec(m[1]);
102
+ if (sha) return sha[1].toLowerCase();
103
+ break;
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /**
110
+ * 查远端某个 ref 的 commit(走 ls-remote)。
111
+ *
112
+ * @param {{parsed: object, ref?: string, proxy?: string}} input
113
+ * proxy —— 形如 http://127.0.0.1:7897。给了就通过 git 的 http.proxy 传下去;
114
+ * 不给则沿用环境变量(Node 的 env-proxy 对 git 无效,必须显式传 -c)。
115
+ */
116
+ export async function queryGitHead({ parsed, ref, proxy }) {
117
+ if (!parsed || !parsed.url) return { ok: false, error: '不是可识别的 git 仓库地址' };
118
+ const target = ref || parsed.ref || DEFAULT_REF;
119
+ const args = [];
120
+ if (proxy) args.push('-c', `http.proxy=${proxy}`);
121
+ args.push('ls-remote', parsed.url, target === 'HEAD' ? 'HEAD' : target);
122
+
123
+ const result = await runGit(args);
124
+ if (!result.ok) {
125
+ // ref 写错(例如分支名打错)与网络失败要分开说
126
+ const text = String(result.error || '');
127
+ if (/not found|couldn't find remote ref/i.test(text)) {
128
+ return { ok: false, error: `远端没有 ${target} 这个 ref`, ref: target };
129
+ }
130
+ return { ok: false, error: text || 'ls-remote 失败', ref: target };
131
+ }
132
+ const line = result.stdout.split(/\r?\n/).find((l) => l.trim());
133
+ if (!line) return { ok: false, error: `远端没有 ${target}`, ref: target };
134
+ const [sha] = line.trim().split(/\s+/);
135
+ if (!/^[0-9a-f]{40}$/i.test(sha || '')) return { ok: false, error: 'ls-remote 返回的 sha 不合法', ref: target };
136
+ return { ok: true, ref: target, commit: sha.toLowerCase(), shortCommit: sha.slice(0, 10) };
137
+ }
138
+
139
+ /**
140
+ * 查远端最新 tag(走 ls-remote,按版本倒序)。
141
+ * 拿不到不算错——很多仓库不打 tag。
142
+ */
143
+ export async function queryLatestTag({ parsed, proxy }) {
144
+ if (!parsed || !parsed.url) return { ok: false, error: '不是可识别的 git 仓库地址' };
145
+ const args = [];
146
+ if (proxy) args.push('-c', `http.proxy=${proxy}`);
147
+ args.push('ls-remote', '--tags', '--sort=-v:refname', parsed.url);
148
+ const result = await runGit(args);
149
+ if (!result.ok) return { ok: false, error: result.error };
150
+ for (const line of result.stdout.split(/\r?\n/)) {
151
+ const m = /^([0-9a-f]{40})\s+refs\/tags\/(.+?)(\^\{\})?$/.exec(line.trim());
152
+ if (!m) continue;
153
+ if (m[3]) continue; // 只要未解引用的那一行,避免重复
154
+ return { ok: true, tag: m[2], commit: m[1].toLowerCase() };
155
+ }
156
+ return { ok: true, tag: undefined };
157
+ }
158
+
159
+ /**
160
+ * 用 GitHub API 补一条提交说明(纯展示用,失败无影响)。
161
+ * 单独放这里是为了让主判定完全不依赖 API。
162
+ *
163
+ * 注意 API 与 ls-remote 走的不是同一条网络:ls-remote 用显式 -c http.proxy,
164
+ * 这里用 fetch,而 Node 的 env-proxy 只在进程启动时读环境变量。
165
+ * 所以显式构造代理 dispatcher,保证「代理开了就一定能过」。
166
+ */
167
+ export async function queryCommitMessage({ parsed, commit, proxy, signal }) {
168
+ const base = gitApiBase(parsed);
169
+ if (!base || !commit) return { ok: false };
170
+ const controller = new AbortController();
171
+ const timer = setTimeout(() => controller.abort(), 8000);
172
+ const onAbort = () => controller.abort();
173
+ if (signal) {
174
+ if (signal.aborted) controller.abort();
175
+ else signal.addEventListener('abort', onAbort, { once: true });
176
+ }
177
+ let dispatcher;
178
+ try {
179
+ if (proxy) {
180
+ const { ProxyAgent } = await import('undici');
181
+ dispatcher = new ProxyAgent(proxy);
182
+ }
183
+ } catch {
184
+ dispatcher = undefined; // 拿不到 undici 就退回普通 fetch
185
+ }
186
+ try {
187
+ const response = await fetch(`${base}/commits/${commit}`, {
188
+ signal: controller.signal,
189
+ dispatcher,
190
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-plugin-autoupdate/0.7' },
191
+ });
192
+ if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
193
+ const value = await response.json();
194
+ const message = String(value?.commit?.message || '').split('\n')[0].slice(0, 120);
195
+ const date = value?.commit?.author?.date || value?.commit?.committer?.date;
196
+ const ms = typeof date === 'string' ? Date.parse(date) : NaN;
197
+ return { ok: true, message, publishedAt: Number.isFinite(ms) ? ms : 0 };
198
+ } catch (error) {
199
+ return { ok: false, error: String(error?.message || error) };
200
+ } finally {
201
+ clearTimeout(timer);
202
+ if (signal) signal.removeEventListener('abort', onAbort);
203
+ try {
204
+ await dispatcher?.close?.();
205
+ } catch {
206
+ /* ignore */
207
+ }
208
+ }
209
+ }
package/lib/registry.js CHANGED
@@ -118,3 +118,29 @@ export async function queryLatest({ name, registry, fallbackRegistries, compare,
118
118
  }
119
119
  return { ok: false, name, error: lastError || 'registry unreachable', registries: tried };
120
120
  }
121
+
122
+ /**
123
+ * 探测一个包在 registry 上是否存在(用于「本地/git 来源能否改成 npm 来源」)。
124
+ *
125
+ * 与 queryLatest 分开:这里不关心最新版是多少,只回答「npm 上有没有这个包」。
126
+ * 404 / Not found 明确返回 exists:false;网络错误返回 ok:false 而不是 exists:false——
127
+ * 把「查不到」与「查不了」混为一谈会诱导用户在断网时误判。
128
+ *
129
+ * @returns {Promise<{ok: boolean, exists: boolean, latest?: string, publishedAt?: number, error?: string, registry?: string}>}
130
+ */
131
+ export async function probePackage({ name, registry, compare, signal }) {
132
+ const base = normalizeRegistry(registry);
133
+ const result = await fetchJson(packageUrl(base, name), signal);
134
+ if (!result.ok) {
135
+ if (result.status === 404) return { ok: true, exists: false, registry: base };
136
+ return { ok: false, exists: false, error: result.error, registry: base };
137
+ }
138
+ const doc = result.value;
139
+ // npm 对未收录的包有时回 200 + {"error":"Not found"},必须看内容再说
140
+ if (doc && typeof doc.error === 'string' && !doc.versions) {
141
+ return { ok: true, exists: false, registry: base, note: doc.error };
142
+ }
143
+ const latest = pickLatest(doc, compare);
144
+ if (!latest) return { ok: true, exists: false, registry: base, note: 'package has no versions' };
145
+ return { ok: true, exists: true, latest, publishedAt: publishedAt(doc, latest), registry: base };
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ygc-dsh-plugin-autoupdate",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "DSH 插件自动更新器:定时检查已装插件的新版本并按策略升级;把自己的插件特征(清单+本地副本+本机汇总)同步到共享插件库自己那一格,本机标识与会话同步插件共用,并汇集他人清单供人工选用。",
6
6
  "icon": "icon.svg",
@@ -33,7 +33,8 @@
33
33
  "lib/restart.js",
34
34
  "lib/share.js",
35
35
  "lib/updater.js",
36
- "locale/*.json"
36
+ "locale/*.json",
37
+ "lib/gitref.js"
37
38
  ],
38
39
  "publishConfig": {
39
40
  "registry": "https://registry.npmjs.org/",