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 +61 -35
- package/index.js +252 -39
- package/lib/api.js +76 -10
- package/lib/core.js +78 -2
- package/lib/gitref.js +209 -0
- package/lib/registry.js +26 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
354
|
-
? h('div', { className: 'pau-row-d' }, plugin.
|
|
355
|
-
:
|
|
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
|
|
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' }, '本地路径 /
|
|
489
|
+
h('div', { className: 'pau-row-d' }, '本地路径 / 随安装提供(不参与版本检查)'),
|
|
441
490
|
otherPlugins.map((plugin) =>
|
|
442
|
-
|
|
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 {
|
|
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
|
|
114
|
+
spec,
|
|
96
115
|
// 自己这个包也要能被管理,否则「自动更新器本身」永远停在旧版
|
|
97
|
-
kind
|
|
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
|
|
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
|
|
259
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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 (
|
|
252
|
-
|
|
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
|
|
310
|
+
const next = loadState();
|
|
267
311
|
let restart = null;
|
|
268
|
-
if (result.ok &&
|
|
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
|
|
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
|
-
|
|
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.
|
|
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/",
|