ygc-dsh-plugin-autoupdate 0.9.5 → 0.9.6
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/README.md +53 -34
- package/client.js +14 -15
- package/index.js +12 -4
- package/lib/api.js +3 -1
- package/lib/share.js +229 -347
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -278,17 +278,19 @@ Electron 的 main / gpu-process / renderer / utility,以及以 `ELECTRON_RUN_A
|
|
|
278
278
|
```
|
|
279
279
|
D:\Ai工作空间\插件库\ ← 共享文件夹,两台机器都能读写
|
|
280
280
|
├─ <本机标识>\ ← 我的格子,例 DSH-7e6b1bb7e052
|
|
281
|
-
│ ├─ PLUGINS.json
|
|
281
|
+
│ ├─ PLUGINS.json 我的插件清单(**只有清单,没有任何插件文件**)
|
|
282
282
|
│ ├─ FEATURE.json 我的身份说明
|
|
283
283
|
│ ├─ OTHERS-PLUGINS.json 我的汇总(本机自用)
|
|
284
|
-
│
|
|
285
|
-
│ └─ local\ 本地 / Git 来源插件的 .tgz 副本
|
|
284
|
+
│ └─ OTHERS-PLUGINS.md 同一份的人读版
|
|
286
285
|
└─ <别人的标识>\ ← 别人写,我只读
|
|
287
286
|
├─ PLUGINS.json
|
|
288
|
-
|
|
289
|
-
└─ local\
|
|
287
|
+
└─ FEATURE.json
|
|
290
288
|
```
|
|
291
289
|
|
|
290
|
+
> **0.9.6 起:特征文件夹只存清单,不存插件副本。** 以前这里还有 `local/` 放 .tgz,
|
|
291
|
+
> 现在没有了,而且会自动清掉老版本留下的 `local/` 与散落 tgz。
|
|
292
|
+
> 安装一律按清单里的「安装坐标」去 registry 联网拉取,不存在本地副本安装这条路。
|
|
293
|
+
|
|
292
294
|
**没有公共分区。** 早先版本在插件库下分出「特征 / 汇集」两级子目录,那是错的:
|
|
293
295
|
插件库是多人共享的盘,公共目录等于公共广场,所有人的东西往一处堆,谁的产出分不清,
|
|
294
296
|
而且「汇集」会成为多方争写的热点。现在改成:
|
|
@@ -323,31 +325,45 @@ D:\Ai工作空间\插件库\ ← 共享文件夹,两台机器都能
|
|
|
323
325
|
`POST /settings { shareResetHash: true }`——这是唯一的重算入口,其它情况一律沿用旧值。
|
|
324
326
|
|
|
325
327
|
|
|
326
|
-
###
|
|
328
|
+
### 清单里写什么(只有清单,没有文件)
|
|
329
|
+
|
|
330
|
+
每一项只有五个字段:`name` / `version` / `kind` / `installSpec` / `installable`。
|
|
327
331
|
|
|
328
|
-
| 插件来源 |
|
|
332
|
+
| 插件来源 | 安装坐标 `installSpec` | 能装吗 |
|
|
329
333
|
|---|---|---|
|
|
330
|
-
| `registry`(能联网更新) |
|
|
331
|
-
| `
|
|
332
|
-
| `
|
|
333
|
-
| `installation`(随 DSH 安装提供) |
|
|
334
|
+
| `registry`(能联网更新) | `<name>@<version>` | 能,registry 拉 |
|
|
335
|
+
| `git`(GitHub 等) | 原 git 写法(`github:o/r#ref`) | 能,联网拉 |
|
|
336
|
+
| `local`(`file:` 本地路径) | 空 | **不能**:未发布的东西没地方拉 |
|
|
337
|
+
| `installation`(随 DSH 安装提供) | — | **不写进清单**:别人本来就有 |
|
|
338
|
+
|
|
339
|
+
`PLUGINS.json` 里的 `policy` 段把这条规矩写死,别人一看就知道这份清单不含文件:
|
|
340
|
+
|
|
341
|
+
```json
|
|
342
|
+
{
|
|
343
|
+
"schemaVersion": 2,
|
|
344
|
+
"policy": {
|
|
345
|
+
"manifestOnly": true,
|
|
346
|
+
"storesBundles": false,
|
|
347
|
+
"installVia": "registry",
|
|
348
|
+
"note": "特征文件夹只存清单;安装一律按 installSpec 联网拉取,不使用本地副本。"
|
|
349
|
+
},
|
|
350
|
+
"plugins": [
|
|
351
|
+
{ "name": "dsh-tavern", "version": "2.8.1", "kind": "registry",
|
|
352
|
+
"installSpec": "dsh-tavern@2.8.1", "installable": true }
|
|
353
|
+
]
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
历史遗留的 `local/` 与根上散落的 `*.tgz` 会在每轮同步时被删掉(删除记录写进
|
|
358
|
+
`PLUGINS.json` 的 `legacyPurged`,便于排查「我的 local/ 怎么没了」)。
|
|
334
359
|
|
|
335
|
-
副本是 npm 标准 tgz(`package/` 前缀),排除 `node_modules`、`.git`、`__pycache__`、`*.bak-*`。
|
|
336
|
-
单包上限 96 MB,单个特征的副本总量上限 512 MB,同名插件只留最近 2 个版本。
|
|
337
|
-
`link:` 装的包在 `node_modules` 下是符号链接,打包时按 `realpath` 追到真实目录。
|
|
338
360
|
|
|
339
|
-
### 什么时候跑
|
|
340
361
|
|
|
341
362
|
**每次巡检跑一次**——巡检 = 自动更新的那一轮定时检查(间隔见「检查频率」)。
|
|
342
363
|
顺序是先查更新、再同步共享;更新失败不影响共享同步。面板上也有「立即同步一次」。
|
|
343
364
|
|
|
344
365
|
每次同步都幂等:
|
|
345
366
|
|
|
346
|
-
- 清单内容(除 `generatedAt` / `lastSyncAt` 这类时间字段)没变 → 不重写文件
|
|
347
|
-
- 源目录指纹(文件数 + 最大 mtime + 总字节)没变 → 不重压 `.tgz`,直接复用
|
|
348
|
-
- 本机标识第一次同步时固定进 state,之后每轮原样传回,不做任何重算
|
|
349
|
-
|
|
350
|
-
所以几百 MB 的本地插件不会把每轮巡检拖慢。
|
|
351
367
|
|
|
352
368
|
### 拉别人的清单
|
|
353
369
|
|
|
@@ -361,28 +377,31 @@ D:\Ai工作空间\插件库\ ← 共享文件夹,两台机器都能
|
|
|
361
377
|
|
|
362
378
|
合并去重规则:
|
|
363
379
|
|
|
364
|
-
-
|
|
365
|
-
-
|
|
366
|
-
- 「本机有没有」按 profile 的 `dsh.profile.bundles` 判断,不看 `node_modules`(那里塞满传递依赖)
|
|
380
|
+
- 去重键是包名;多本特征都有同一插件时,版本与安装坐标都取**更高**的那份(按语义版本比,不看文件时间)
|
|
381
|
+
- `features` 里记下它出现在哪几本;老清单里的 `bundles` 数组一律忽略
|
|
367
382
|
- 没 `PLUGINS.json` 的目录只记一条提示,不报错
|
|
368
383
|
|
|
369
|
-
###
|
|
384
|
+
### 装的时候走哪条路(只有一条:联网)
|
|
385
|
+
|
|
386
|
+
| 清单里的 `installSpec` | 用的 spec | 结果 |
|
|
387
|
+
|---|---|---|
|
|
388
|
+
| `<name>@<version>` | 原样 | 从 registry 拉 |
|
|
389
|
+
| `github:o/r#ref` 这类 git 写法 | 原样 | 联网拉 |
|
|
390
|
+
| 空(`local` 来源) | — | **拒绝**,说明「对方未发布到 registry」 |
|
|
391
|
+
| `file:` / `link:` 开头 | — | **拒绝**:只走 registry 联网拉取 |
|
|
392
|
+
|
|
393
|
+
就算对方是老版本写的清单、里面还带着 `bundles` 数组,也一律忽略:
|
|
394
|
+
拿别人打包过的旧代码装上,版本号写着新的、行为是老的那份,比装不上更难查。
|
|
370
395
|
|
|
371
|
-
| 对方特征里有 | 用的 spec |
|
|
372
|
-
|---|---|
|
|
373
|
-
| `.tgz` 副本 | `file:<对方副本的绝对路径>`(正斜杠) |
|
|
374
|
-
| 只有名称(registry 来源) | `<name>@<version>` |
|
|
375
|
-
| 既没副本又不是 registry | 拒绝,说明原因 |
|
|
376
396
|
|
|
377
|
-
装完自动把包名补进 profile 的 `dsh.profile.bundles`——不补的话插件装下来但不会加载。
|
|
378
397
|
装之前同样会快照 profile 三个清单文件,出问题用 `/restore` 回滚。**装完必须完全退出 DSH 再重开。**
|
|
379
398
|
|
|
380
399
|
### 共享相关接口
|
|
381
400
|
|
|
382
401
|
| 方法 | 路径 | 作用 |
|
|
383
402
|
|---|---|---|
|
|
384
|
-
| GET | `/share` | 我的特征现状 +
|
|
385
|
-
| POST | `/share/sync` | 立即同步一次(我的清单 +
|
|
403
|
+
| GET | `/share` | 我的特征现状 + 他人清单(含每项是否已装、能不能装、安装坐标) |
|
|
404
|
+
| POST | `/share/sync` | 立即同步一次(我的清单 + 他人汇集;不打包任何文件) |
|
|
386
405
|
| POST | `/share/install` | 安装他人清单里的一个插件(body: `{ name }`) |
|
|
387
406
|
|
|
388
407
|
设置项(走 `/settings`):`shareEnabled`、`shareLibraryRoot`、`shareFeatureName`、`shareResetHash`。
|
|
@@ -408,7 +427,7 @@ $node = "C:\DeepSeekHarness\resources\runtime\primary-runtime\dependencies\node\
|
|
|
408
427
|
| `update-guard.test.mjs` | 只向上更新、来源判定、发布年龄门槛 |
|
|
409
428
|
| `git-source.test.mjs` | Git 来源的 commit 比较与安装 spec |
|
|
410
429
|
| `machine.test.mjs` | 本机标识:继承会话同步的 machineId、固定后不漂移、改名不换特征值、卸载会话同步后旧值仍有效、写边界(别人的格 / `../` 逃逸 / 前缀相近) |
|
|
411
|
-
| `share.test.mjs` |
|
|
430
|
+
| `share.test.mjs` | 特征文件夹:清单只写元数据、**目录里不允许出现任何插件文件**、历史 `local/` 被清理、同步幂等、他人清单合并去重(忽略老清单 bundles)、安装只走 registry(有副本也拒绝、`file:` 坐标也拒绝)、特征名防上跳 |
|
|
412
431
|
| `schedule.test.mjs` | 定时排期:更新与共享同步各自独立开关 |
|
|
413
432
|
| `system-plugins.test.mjs` | **本体插件隐藏契约**(源码级):`isBuiltinPlugin` 判据只有一处定义、listPlugins 出口过滤并缓存隐藏名单、runCheck 不查、snapshot 再兜一层并透出 `builtin.count`、策略/更新接口对本体插件一律 400、共享清单同一判据、面板整组隐藏只留一行说明 |
|
|
414
433
|
| `system-plugins.behavior.test.mjs` | **本体插件隐藏行为**(真执行 + 假宿主 + 假 profile):`@deepseek-ai/*` 一行都不出现、第三方插件照常可写策略、`builtin.count` 数对、`pluginManager` 缺失 / 抛错 / 脏数据三种降级都不让 `/state` 失败 |
|
|
@@ -549,7 +568,7 @@ POST /api/local/plugin-autoupdate/plugin-policy {"name":"@deepseek-ai/dsh-base"
|
|
|
549
568
|
版本跟桌面端走,`registry` 上的同名包与它无关;策略/更新接口对它们一律 400
|
|
550
569
|
- 更新失败只记历史,不自动回滚(避免把用户自己手改的清单吞掉);要回滚用 `/restore`
|
|
551
570
|
- DSH 关闭期间不检查(进程内定时器的固有限制);重开后会立刻按上游发布时间重新判定
|
|
552
|
-
-
|
|
571
|
+
- 共享只**同步清单**(不存任何插件文件),不自动安装别人的插件;安装必须人工点选,且只走 registry 联网拉取
|
|
553
572
|
- 只写 `插件库\<本机标识>\` 那一格,别人的格子只读(写入口有守卫)
|
|
554
573
|
- 不在插件库根下留任何公共文件或公共目录
|
|
555
574
|
- **重启只认显式 pid**:任何「按映像名清场」的写法都是回归,`restart-safety.test.mjs` 会红
|
package/client.js
CHANGED
|
@@ -692,7 +692,7 @@ function makePanel(React) {
|
|
|
692
692
|
h(
|
|
693
693
|
'div',
|
|
694
694
|
{ className: 'pau-card-h' },
|
|
695
|
-
h('h3', { className: 'pau-card-t' }, '
|
|
695
|
+
h('h3', { className: 'pau-card-t' }, '插件共享(特征文件夹 · 只存清单)'),
|
|
696
696
|
h(
|
|
697
697
|
'span',
|
|
698
698
|
{ className: share.enabled ? 'pau-tag pau-tag-ok' : 'pau-tag' },
|
|
@@ -702,7 +702,7 @@ function makePanel(React) {
|
|
|
702
702
|
h(
|
|
703
703
|
'p',
|
|
704
704
|
{ className: 'pau-sub' },
|
|
705
|
-
'
|
|
705
|
+
'插件库是共享文件夹,一个人一格:我的插件清单、我自己的汇总都写在「插件库\\<我的特征>」这一格里面,**只存清单,不存任何插件文件**。别人要装,按清单里的安装坐标去 registry 联网拉取,不使用本地副本。别人的格子只读,汇总也是各机扫一遍自己算,不放在公共位置。',
|
|
706
706
|
),
|
|
707
707
|
h(
|
|
708
708
|
'label',
|
|
@@ -766,7 +766,7 @@ function makePanel(React) {
|
|
|
766
766
|
{ className: 'pau-row-d', style: { marginTop: 6 } },
|
|
767
767
|
`上次同步:${fmtAgo(share.lastSyncAt)}${
|
|
768
768
|
share.lastResult
|
|
769
|
-
? ` · 本机 ${share.lastResult.total}
|
|
769
|
+
? ` · 本机 ${share.lastResult.total} 个插件(可联网装 ${share.lastResult.localInstallable ?? '-'},装不了 ${share.lastResult.localUninstallable ?? '-'}) · 他人 ${share.lastResult.others} 个,可装 ${share.lastResult.installable}`
|
|
770
770
|
: ''
|
|
771
771
|
}`,
|
|
772
772
|
),
|
|
@@ -791,7 +791,7 @@ function makePanel(React) {
|
|
|
791
791
|
const counts = (result.mine && result.mine.counts) || {};
|
|
792
792
|
const others2 = (result.aggregate && result.aggregate.counts) || {};
|
|
793
793
|
setNote(
|
|
794
|
-
`同步完成:本机 ${counts.total || 0}
|
|
794
|
+
`同步完成:本机 ${counts.total || 0} 个插件(可联网装 ${counts.installable || 0},装不了 ${counts.uninstallable || 0}),他人 ${others2.othersTotal || 0} 个,可装 ${others2.installable || 0} 个`,
|
|
795
795
|
);
|
|
796
796
|
}
|
|
797
797
|
if (next.share && next.share.detail) setShareDetail(next.share.detail);
|
|
@@ -843,16 +843,13 @@ function makePanel(React) {
|
|
|
843
843
|
h(
|
|
844
844
|
'span',
|
|
845
845
|
{
|
|
846
|
-
className:
|
|
847
|
-
plugin.bundle || plugin.kind === 'registry'
|
|
848
|
-
? 'pau-tag pau-tag-ok'
|
|
849
|
-
: 'pau-tag pau-tag-warn',
|
|
846
|
+
className: plugin.installable ? 'pau-tag pau-tag-ok' : 'pau-tag pau-tag-warn',
|
|
850
847
|
},
|
|
851
|
-
plugin.
|
|
852
|
-
? '
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
848
|
+
plugin.installable
|
|
849
|
+
? plugin.kind === 'git'
|
|
850
|
+
? 'Git 联网拉取'
|
|
851
|
+
: '联网拉取'
|
|
852
|
+
: '无安装坐标,装不了',
|
|
856
853
|
),
|
|
857
854
|
h('span', { className: 'pau-tag pau-tag-mine' }, plugin.features.join(' / ')),
|
|
858
855
|
),
|
|
@@ -864,8 +861,10 @@ function makePanel(React) {
|
|
|
864
861
|
'button',
|
|
865
862
|
{
|
|
866
863
|
className: 'pau-btn pau-btn-sm pau-btn-primary',
|
|
867
|
-
disabled: !!busy ||
|
|
868
|
-
title: plugin.
|
|
864
|
+
disabled: !!busy || !plugin.installable,
|
|
865
|
+
title: plugin.installable
|
|
866
|
+
? `从 registry 联网安装 ${plugin.installSpec || plugin.name}`
|
|
867
|
+
: '这个插件没有可联网的安装坐标(对方未发布到 registry)',
|
|
869
868
|
onClick: () =>
|
|
870
869
|
run(`share-install:${plugin.name}`, async () => {
|
|
871
870
|
const next = await call('/share/install', { name: plugin.name });
|
package/index.js
CHANGED
|
@@ -325,9 +325,14 @@ export function apply(ctx, config = {}) {
|
|
|
325
325
|
feature: mine.feature,
|
|
326
326
|
root: mine.root,
|
|
327
327
|
total: mine.counts.total,
|
|
328
|
-
bundled
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
// 只存清单:bundled / totalMb 恒为 0,保留字段是为了旧面板与旧历史记录不炸
|
|
329
|
+
bundled: 0,
|
|
330
|
+
skipped: 0,
|
|
331
|
+
totalMb: 0,
|
|
332
|
+
// 新口径:本机清单里「能联网装」与「装不了」各多少
|
|
333
|
+
localInstallable: mine.counts.installable,
|
|
334
|
+
localUninstallable: mine.counts.uninstallable,
|
|
335
|
+
legacyPurged: mine.legacyPurged?.removed?.length || 0,
|
|
331
336
|
others: aggregate.doc.counts.othersTotal,
|
|
332
337
|
installable: aggregate.doc.counts.installable,
|
|
333
338
|
sources: aggregate.doc.counts.sources,
|
|
@@ -337,7 +342,10 @@ export function apply(ctx, config = {}) {
|
|
|
337
342
|
pushHistory(next, {
|
|
338
343
|
kind: 'share',
|
|
339
344
|
reason: reason || '定时',
|
|
340
|
-
text:
|
|
345
|
+
text:
|
|
346
|
+
`共享同步:本机 ${mine.counts.total} 个插件(可联网装 ${mine.counts.installable},装不了 ${mine.counts.uninstallable});` +
|
|
347
|
+
`他人 ${aggregate.doc.counts.othersTotal} 个,可装 ${aggregate.doc.counts.installable}` +
|
|
348
|
+
(mine.legacyPurged?.removed?.length ? `;清理历史副本 ${mine.legacyPurged.removed.length} 个` : ''),
|
|
341
349
|
});
|
|
342
350
|
saveState(next);
|
|
343
351
|
return { ok: true, mine, aggregate: { counts: aggregate.doc.counts }, root: libraryRoot(config) };
|
package/lib/api.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from './core.js';
|
|
23
23
|
import { normalizeRegistry, queryLatest } from './registry.js';
|
|
24
24
|
import { restoreBackup, listBackups } from './backup.js';
|
|
25
|
-
import { libraryRoot, myFeatureName } from './share.js';
|
|
25
|
+
import { SHARE_POLICY, libraryRoot, myFeatureName } from './share.js';
|
|
26
26
|
|
|
27
27
|
export const API_PREFIX = '/api/local/plugin-autoupdate';
|
|
28
28
|
|
|
@@ -165,6 +165,8 @@ function snapshot(deps) {
|
|
|
165
165
|
featureName: state.shareFeatureName,
|
|
166
166
|
machineHash: state.shareMachineHash,
|
|
167
167
|
}),
|
|
168
|
+
// 口径声明:特征文件夹只存清单、安装走 registry。面板据此显示,不再提「副本」
|
|
169
|
+
policy: SHARE_POLICY,
|
|
168
170
|
},
|
|
169
171
|
limits: {
|
|
170
172
|
minIntervalMinutes: MIN_INTERVAL_MINUTES,
|
package/lib/share.js
CHANGED
|
@@ -1,32 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 插件共享(特征文件夹)——本机插件清单的对外沉淀与汇集。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 目录约定(一个人一格,直接摆在「插件库」根下,根目录由设置决定):
|
|
5
5
|
*
|
|
6
6
|
* 插件库\
|
|
7
|
-
* └─
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* └─ <name>-<version>.tgz
|
|
7
|
+
* └─ <我的特征名>\
|
|
8
|
+
* ├─ FEATURE.json 本机特征说明(谁、哪台机、什么时候巡检、插件数)
|
|
9
|
+
* ├─ PLUGINS.json 本机插件清单(**只有清单,没有任何插件文件**)
|
|
10
|
+
* ├─ OTHERS-PLUGINS.json 我自己算出来的「他人插件汇总」
|
|
11
|
+
* └─ OTHERS-PLUGINS.md 同一份的人读版本
|
|
13
12
|
*
|
|
14
|
-
*
|
|
13
|
+
* ── 为什么不再存插件副本(0.9.6 起的硬规矩)─────────────────────────
|
|
14
|
+
* 特征文件夹**只放清单**,不放 .tgz。理由三条,都是实打实的:
|
|
15
|
+
* 1. 副本会过期。对方打包时的代码和 registry 上的产物不是一回事,
|
|
16
|
+
* 拿旧副本装出来的插件版本号写着新的、行为是旧的,排查时最坑。
|
|
17
|
+
* 2. 副本会膨胀。共享盘上每台机器各存一份几十 MB 的 tgz,谁也说不清哪份是对的。
|
|
18
|
+
* 3. 安装只该有一个来源:registry。**联网拉取是唯一安装通道**,
|
|
19
|
+
* 不提供、也不接受 file: 本地副本安装。
|
|
15
20
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* ├─ OTHERS-PLUGINS.json 他人插件总清单(合并 + 去重,标记本机是否已有)
|
|
19
|
-
* └─ OTHERS-PLUGINS.md 同一份的人读版本
|
|
21
|
+
* 因此安装他人清单里的插件时:只按「包名@版本」去 registry 拉。
|
|
22
|
+
* 对方的插件没发布到 registry 的,就直接说明「无法安装」,不做任何本地兜底。
|
|
20
23
|
*
|
|
21
|
-
*
|
|
22
|
-
* 1.
|
|
23
|
-
* 2.
|
|
24
|
-
* 3. 每次巡检(自动更新轮询)跑一次同步;同一份内容重复跑不产生新改动(幂等)。
|
|
24
|
+
* 两条硬规则:
|
|
25
|
+
* 1. 清单里只写名称 + 版本 + 来源类型 + 安装坐标,绝不写文件、绝不打包。
|
|
26
|
+
* 2. 每次巡检(自动更新轮询)跑一次同步;同一份内容重复跑不产生新改动(幂等)。
|
|
25
27
|
*/
|
|
26
28
|
import fs from 'node:fs';
|
|
27
29
|
import path from 'node:path';
|
|
28
30
|
import os from 'node:os';
|
|
29
|
-
import { spawn } from 'node:child_process';
|
|
30
31
|
import {
|
|
31
32
|
installedMeta,
|
|
32
33
|
installedVersion,
|
|
@@ -44,14 +45,20 @@ import { assertOwnFeaturePath, resolveIdentity } from './machine.js';
|
|
|
44
45
|
/** 插件库默认根:D 盘 ai 工作空间下的「插件库」。 */
|
|
45
46
|
export const DEFAULT_LIBRARY_ROOT = 'D:\\Ai工作空间\\插件库';
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
/**
|
|
49
|
+
* 共享清单的存放策略(给面板与文档用,也是 0.9.6 的行为声明)。
|
|
50
|
+
*
|
|
51
|
+
* 这里不再有 MAX_BUNDLE_MB / KEEP_BUNDLES 这类「副本上限」:
|
|
52
|
+
* 特征文件夹已经不存副本,没有体积可管,也没有份数可留。
|
|
53
|
+
*/
|
|
54
|
+
export const SHARE_POLICY = Object.freeze({
|
|
55
|
+
/** 清单里允许出现的字段:名称、版本、来源类型、安装坐标。 */
|
|
56
|
+
manifestOnly: true,
|
|
57
|
+
/** 是否允许把插件文件写进特征文件夹。恒为 false。 */
|
|
58
|
+
storesBundles: false,
|
|
59
|
+
/** 安装通道:只有 registry。 */
|
|
60
|
+
installVia: 'registry',
|
|
61
|
+
});
|
|
55
62
|
|
|
56
63
|
export function libraryRoot(config) {
|
|
57
64
|
const value = typeof config?.libraryRoot === 'string' ? config.libraryRoot.trim() : '';
|
|
@@ -71,7 +78,7 @@ export function featuresDir(config) {
|
|
|
71
78
|
|
|
72
79
|
/**
|
|
73
80
|
* 我自己的目录:`插件库/<本机标识>/`。
|
|
74
|
-
*
|
|
81
|
+
* 清单、以及我自己那份「他人插件汇总」全都在这一格里面——**不含任何插件文件**。
|
|
75
82
|
*/
|
|
76
83
|
export function myFeatureDir(config) {
|
|
77
84
|
return path.join(featuresDir(config), myFeatureName(config));
|
|
@@ -141,7 +148,9 @@ function readJson(file) {
|
|
|
141
148
|
* 但 generatedAt 每次都不同,直接比字节会把每轮巡检都变成「有改动」,
|
|
142
149
|
* 白写盘、白触发下游。所以先剔掉易变字段再比。
|
|
143
150
|
*/
|
|
144
|
-
|
|
151
|
+
// legacyPurged 也算易变:历史副本只清一次,清完下一轮就是空数组。
|
|
152
|
+
// 把它算进比较会导致「每次巡检都重写 PLUGINS.json」,而且会把清理记录抹掉。
|
|
153
|
+
const VOLATILE_KEYS = ['generatedAt', 'lastSyncAt', 'pruned', 'reused', 'ms', 'legacyPurged'];
|
|
145
154
|
|
|
146
155
|
function stableValue(value) {
|
|
147
156
|
if (Array.isArray(value)) return value.map(stableValue);
|
|
@@ -210,10 +219,9 @@ const SKIP_DIRS = new Set(['node_modules', '.git', '.pnpm', '__pycache__', '.cac
|
|
|
210
219
|
/**
|
|
211
220
|
* 源目录指纹:文件数 + 最大 mtime + 总字节(排除 node_modules 等)。
|
|
212
221
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* 指纹没变就跳过打包,直接沿用上次的副本。
|
|
222
|
+
* 0.9.6 起不再打包副本,这个函数只作为**诊断用**保留:
|
|
223
|
+
* 需要判断「某个插件目录到底有没有被动过」时(例如排查来源混淆),
|
|
224
|
+
* 它能给一个稳定的短指纹。同步流程不再调用它。
|
|
217
225
|
*/
|
|
218
226
|
export function sourceSignature(sourceDir) {
|
|
219
227
|
let count = 0;
|
|
@@ -304,10 +312,18 @@ function realSource(sourceDir) {
|
|
|
304
312
|
}
|
|
305
313
|
|
|
306
314
|
/**
|
|
307
|
-
* 枚举本机 profile
|
|
315
|
+
* 枚举本机 profile 里的插件,产出一份**纯清单**。
|
|
316
|
+
*
|
|
317
|
+
* 返回的每一项只有「名称 / 版本 / 来源类型 / 安装坐标 / 说明」,没有目录、没有文件。
|
|
318
|
+
* 这是 0.9.6 的硬约束:特征文件夹只存清单,任何插件文件都不往里写。
|
|
319
|
+
*
|
|
320
|
+
* `installSpec` 是对方要装它时的唯一坐标:
|
|
321
|
+
* - registry → `name@version`(联网拉取)
|
|
322
|
+
* - git → 原 git 写法(仍然联网)
|
|
323
|
+
* - local → 空:本机 file: 来源的插件别人装不了,清单里明说
|
|
308
324
|
*
|
|
309
325
|
* @param {{publishedNames?: string[]}} [options]
|
|
310
|
-
* @returns {Array<{name,version,kind,spec,description,homepage,
|
|
326
|
+
* @returns {Array<{name,version,kind,spec,installSpec,installable,description,homepage,reason}>}
|
|
311
327
|
*/
|
|
312
328
|
export function collectLocalPlugins(options) {
|
|
313
329
|
const publishedNames = Array.isArray(options?.publishedNames) ? options.publishedNames : [];
|
|
@@ -319,23 +335,29 @@ export function collectLocalPlugins(options) {
|
|
|
319
335
|
const version = installedVersion(name) || null;
|
|
320
336
|
const spec = typeof specs[name] === 'string' ? specs[name] : null;
|
|
321
337
|
const meta = installedMeta(name);
|
|
322
|
-
const sourceDir = realSource(path.join(profileDir(), 'node_modules', ...String(name).split('/')));
|
|
323
338
|
const registryLike = kind === 'registry' || kind === 'installation';
|
|
339
|
+
// 安装坐标:只有能联网拉到的来源才给;file:/link: 给了也是假的
|
|
340
|
+
const installSpec = registryLike
|
|
341
|
+
? version
|
|
342
|
+
? `${name}@${version}`
|
|
343
|
+
: name
|
|
344
|
+
: kind === 'git'
|
|
345
|
+
? spec || ''
|
|
346
|
+
: '';
|
|
324
347
|
return {
|
|
325
348
|
name,
|
|
326
349
|
version,
|
|
327
350
|
kind,
|
|
328
351
|
spec,
|
|
352
|
+
installSpec,
|
|
353
|
+
installable: Boolean(installSpec),
|
|
329
354
|
description: meta.description || '',
|
|
330
355
|
homepage: meta.homepage || '',
|
|
331
|
-
// registry 来源:只写名称(版本照写,便于别人判断新旧),不打包
|
|
332
|
-
share: registryLike ? 'name-only' : 'bundle',
|
|
333
|
-
sourceDir,
|
|
334
356
|
reason: registryLike
|
|
335
|
-
? '
|
|
357
|
+
? 'registry 来源,按名称+版本联网拉取'
|
|
336
358
|
: kind === 'git'
|
|
337
|
-
? 'Git
|
|
338
|
-
: '
|
|
359
|
+
? 'Git 来源,按仓库地址联网拉取'
|
|
360
|
+
: '本地路径来源,未发布到 registry,别人装不了',
|
|
339
361
|
};
|
|
340
362
|
})
|
|
341
363
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -353,159 +375,15 @@ export function shareablePlugins(plugins) {
|
|
|
353
375
|
return plugins.filter((p) => p && !isBuiltinPlugin(p) && p.kind !== 'installation');
|
|
354
376
|
}
|
|
355
377
|
|
|
356
|
-
/* ------------------------------------------------------------ 打包副本 */
|
|
357
|
-
|
|
358
|
-
/** 读包内 package.json 的 files 字段,决定打包哪些路径;没有就整目录(仍排除 node_modules)。 */
|
|
359
|
-
function packableManifest(sourceDir) {
|
|
360
|
-
const manifest = readJson(path.join(sourceDir, 'package.json'));
|
|
361
|
-
const files = Array.isArray(manifest?.files) ? manifest.files.filter((f) => typeof f === 'string') : [];
|
|
362
|
-
return { manifest, files };
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* 把一个本地插件目录打成 npm 标准 .tgz(package/ 前缀),排除 node_modules 与常见垃圾。
|
|
367
|
-
*
|
|
368
|
-
* 为什么不用 `npm pack`:宿主的 node 旁边不一定有 npm-cli.js,且 npm pack 会跑
|
|
369
|
-
* prepack 脚本、写 packument 日志;这里只要一个能 `pnpm add file:` 的包。
|
|
370
|
-
* Windows 自带 bsdtar 支持 --exclude,够用。
|
|
371
|
-
*
|
|
372
|
-
* @returns {Promise<{ok:boolean, file?:string, bytes?:number, message?:string}>}
|
|
373
|
-
*/
|
|
374
|
-
export async function packLocalPlugin({ sourceDir, name, version, outDir }) {
|
|
375
|
-
const manifestPath = path.join(sourceDir, 'package.json');
|
|
376
|
-
if (!fs.existsSync(manifestPath)) {
|
|
377
|
-
return { ok: false, message: '源目录里没有 package.json' };
|
|
378
|
-
}
|
|
379
|
-
if (!fs.existsSync(TAR)) {
|
|
380
|
-
return { ok: false, message: `找不到 tar.exe(${TAR})` };
|
|
381
|
-
}
|
|
382
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
383
|
-
const file = path.join(outDir, `${fileStem(name)}-${version || '0.0.0'}.tgz`);
|
|
384
|
-
const tmp = `${file}.tmp`;
|
|
385
|
-
try {
|
|
386
|
-
fs.rmSync(tmp, { force: true });
|
|
387
|
-
} catch {
|
|
388
|
-
/* ignore */
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const parent = path.dirname(sourceDir);
|
|
392
|
-
const leaf = path.basename(sourceDir);
|
|
393
|
-
const args = [
|
|
394
|
-
'-czf',
|
|
395
|
-
tmp,
|
|
396
|
-
'--exclude',
|
|
397
|
-
'node_modules',
|
|
398
|
-
'--exclude',
|
|
399
|
-
'.git',
|
|
400
|
-
'--exclude',
|
|
401
|
-
'.pnpm',
|
|
402
|
-
'--exclude',
|
|
403
|
-
'__pycache__',
|
|
404
|
-
'--exclude',
|
|
405
|
-
'*.bak-*',
|
|
406
|
-
'--exclude',
|
|
407
|
-
'.DS_Store',
|
|
408
|
-
'-C',
|
|
409
|
-
parent,
|
|
410
|
-
leaf,
|
|
411
|
-
];
|
|
412
|
-
const result = await run(TAR, args, { timeoutMs: 10 * 60 * 1000 });
|
|
413
|
-
if (result.code !== 0 || !fs.existsSync(tmp)) {
|
|
414
|
-
return { ok: false, message: `tar 打包失败:${result.output || `退出码 ${result.code}`}` };
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// bsdtar 打出来是 <leaf>/... 前缀;npm 标准要的是 package/...
|
|
418
|
-
// 直接按 --strip-components=1 解到一个叫 package 的空目录再压,比「解包后改名」稳:
|
|
419
|
-
// 在 D: 盘上 rename 一个刚被 tar 写过的目录会拿到 EPERM(杀软/索引器短暂持有句柄)。
|
|
420
|
-
const staging = path.join(outDir, `.repack-${process.pid}-${Date.now()}`);
|
|
421
|
-
const inner = path.join(staging, 'package');
|
|
422
|
-
try {
|
|
423
|
-
fs.mkdirSync(inner, { recursive: true });
|
|
424
|
-
const extract = await run(TAR, ['-xzf', tmp, '-C', inner, '--strip-components=1'], {
|
|
425
|
-
timeoutMs: 10 * 60 * 1000,
|
|
426
|
-
});
|
|
427
|
-
if (extract.code !== 0) {
|
|
428
|
-
return { ok: false, message: `tar 解包失败:${extract.output || `退出码 ${extract.code}`}` };
|
|
429
|
-
}
|
|
430
|
-
// 解出来的东西里必须真有 package.json,否则压出来的包装上也不认
|
|
431
|
-
if (!fs.existsSync(path.join(inner, 'package.json'))) {
|
|
432
|
-
return { ok: false, message: '解包后没有 package.json,源目录结构异常' };
|
|
433
|
-
}
|
|
434
|
-
const finalTmp = `${tmp}.final`;
|
|
435
|
-
fs.rmSync(finalTmp, { force: true });
|
|
436
|
-
const repack = await run(TAR, ['-czf', finalTmp, '-C', staging, 'package'], {
|
|
437
|
-
timeoutMs: 10 * 60 * 1000,
|
|
438
|
-
});
|
|
439
|
-
if (repack.code !== 0 || !fs.existsSync(finalTmp)) {
|
|
440
|
-
return { ok: false, message: `tar 重新打包失败:${repack.output || `退出码 ${repack.code}`}` };
|
|
441
|
-
}
|
|
442
|
-
fs.rmSync(file, { force: true });
|
|
443
|
-
fs.renameSync(finalTmp, file);
|
|
444
|
-
} finally {
|
|
445
|
-
try {
|
|
446
|
-
fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3 });
|
|
447
|
-
} catch {
|
|
448
|
-
/* 残留目录不影响结果,下次同名会覆盖 */
|
|
449
|
-
}
|
|
450
|
-
try {
|
|
451
|
-
fs.rmSync(tmp, { force: true });
|
|
452
|
-
} catch {
|
|
453
|
-
/* ignore */
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return { ok: true, file, bytes: bytesOf(file) };
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** 远端特征带来的 .tgz 副本:直接按名字判断是否已在 local/ 里。 */
|
|
460
|
-
function listLocalBundles(dir) {
|
|
461
|
-
const out = [];
|
|
462
|
-
try {
|
|
463
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
464
|
-
if (!entry.isFile() || !/\.tgz$/i.test(entry.name)) continue;
|
|
465
|
-
const file = path.join(dir, entry.name);
|
|
466
|
-
out.push({ file: entry.name, bytes: bytesOf(file) });
|
|
467
|
-
}
|
|
468
|
-
} catch {
|
|
469
|
-
/* 目录不存在就是空 */
|
|
470
|
-
}
|
|
471
|
-
return out.sort((a, b) => a.file.localeCompare(b.file));
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/** 只保留同名插件的最近 KEEP_BUNDLES 份(按版本号排序,不按文件时间)。 */
|
|
475
|
-
function pruneBundles(dir) {
|
|
476
|
-
let removed = [];
|
|
477
|
-
try {
|
|
478
|
-
const groups = new Map();
|
|
479
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
480
|
-
if (!entry.isFile() || !/\.tgz$/i.test(entry.name)) continue;
|
|
481
|
-
const match = /^(.+)-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\.tgz$/i.exec(entry.name);
|
|
482
|
-
const key = match ? match[1] : entry.name;
|
|
483
|
-
const version = match ? match[2] : '0.0.0';
|
|
484
|
-
if (!groups.has(key)) groups.set(key, []);
|
|
485
|
-
groups.get(key).push({ name: entry.name, version });
|
|
486
|
-
}
|
|
487
|
-
for (const list of groups.values()) {
|
|
488
|
-
if (list.length <= KEEP_BUNDLES) continue;
|
|
489
|
-
list.sort((a, b) => (isNewerVersion(a.version, b.version) ? 1 : -1));
|
|
490
|
-
for (const item of list.slice(KEEP_BUNDLES)) {
|
|
491
|
-
try {
|
|
492
|
-
fs.rmSync(path.join(dir, item.name), { force: true });
|
|
493
|
-
removed.push(item.name);
|
|
494
|
-
} catch {
|
|
495
|
-
/* ignore */
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
} catch {
|
|
500
|
-
return removed;
|
|
501
|
-
}
|
|
502
|
-
return removed;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
378
|
/* --------------------------------------------------------- 本机特征同步 */
|
|
506
379
|
|
|
507
380
|
/**
|
|
508
|
-
*
|
|
381
|
+
* 把本机插件清单写进我的特征文件夹。
|
|
382
|
+
*
|
|
383
|
+
* **只写清单,不写插件文件。** 这是 0.9.6 的硬规矩:
|
|
384
|
+
* - 特征文件夹里只有 PLUGINS.json / FEATURE.json / 汇总两份;
|
|
385
|
+
* - 老版本留下的 local/ 目录会被主动清掉(不然共享盘上会一直躺着过期 tgz);
|
|
386
|
+
* - 别人要装,只能按清单里的 installSpec 去 registry 联网拉。
|
|
509
387
|
*
|
|
510
388
|
* @param {{config?: object, publishedNames?: string[], log?: Function}} input
|
|
511
389
|
* @returns {Promise<object>} 同步结果(可直接返回给面板)
|
|
@@ -516,109 +394,21 @@ export async function syncMyFeature({ config, publishedNames, log }) {
|
|
|
516
394
|
const feature = identity.machineId;
|
|
517
395
|
const featuresRoot = featuresDir(config);
|
|
518
396
|
const root = path.join(featuresRoot, feature);
|
|
519
|
-
const bundleDir = path.join(root, 'local');
|
|
520
397
|
|
|
521
398
|
// 写之前先过守门:只允许写 <插件库>/<本机标识>/,别人的特征文件夹只读。
|
|
522
|
-
assertOwnFeaturePath(
|
|
399
|
+
assertOwnFeaturePath(root, { featuresRoot, machineId: feature });
|
|
523
400
|
|
|
524
401
|
const allPlugins = collectLocalPlugins({ publishedNames });
|
|
525
402
|
// 随 DSH 安装提供的官方 bundle 不进清单:别人机器上本来就有,也没版本号可装
|
|
526
403
|
const plugins = shareablePlugins(allPlugins);
|
|
527
|
-
const needBundle = plugins.filter((p) => p.share === 'bundle' && p.version);
|
|
528
|
-
|
|
529
|
-
const bundled = [];
|
|
530
|
-
const skipped = [];
|
|
531
|
-
const reused = [];
|
|
532
|
-
let bundleBytes = 0;
|
|
533
|
-
|
|
534
|
-
// 上一轮留下的副本索引:{ '<name>-<version>.tgz': {sig, bytes} }
|
|
535
|
-
const previous = fs.existsSync(path.join(root, 'PLUGINS.json'))
|
|
536
|
-
? readJson(path.join(root, 'PLUGINS.json'))?.bundleIndex || {}
|
|
537
|
-
: {};
|
|
538
|
-
const bundleIndex = {};
|
|
539
|
-
|
|
540
|
-
for (const plugin of needBundle) {
|
|
541
|
-
if (bundleBytes > MAX_FEATURE_MB * 1048576) {
|
|
542
|
-
skipped.push({ name: plugin.name, reason: `副本总量已超过 ${MAX_FEATURE_MB} MB,本轮未打包` });
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
const expected = path.join(bundleDir, `${fileStem(plugin.name)}-${plugin.version}.tgz`);
|
|
546
|
-
const signature = sourceSignature(plugin.sourceDir);
|
|
547
|
-
const known = previous[path.basename(expected)];
|
|
548
|
-
|
|
549
|
-
// 源没改过、副本还在 → 直接沿用,不重压(几百 MB 的包重压一次要几十秒)
|
|
550
|
-
if (known && known.sig === signature && fs.existsSync(expected)) {
|
|
551
|
-
bundleBytes += bytesOf(expected);
|
|
552
|
-
bundled.push({
|
|
553
|
-
name: plugin.name,
|
|
554
|
-
version: plugin.version,
|
|
555
|
-
kind: plugin.kind,
|
|
556
|
-
file: path.relative(root, expected).replace(/\\/g, '/'),
|
|
557
|
-
bytes: bytesOf(expected),
|
|
558
|
-
});
|
|
559
|
-
bundleIndex[path.basename(expected)] = { sig: signature, bytes: bytesOf(expected) };
|
|
560
|
-
reused.push(plugin.name);
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
404
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
outDir: bundleDir,
|
|
569
|
-
});
|
|
570
|
-
if (!packed.ok) {
|
|
571
|
-
skipped.push({ name: plugin.name, reason: packed.message });
|
|
572
|
-
continue;
|
|
573
|
-
}
|
|
574
|
-
if (packed.bytes > MAX_BUNDLE_MB * 1048576) {
|
|
575
|
-
skipped.push({
|
|
576
|
-
name: plugin.name,
|
|
577
|
-
reason: `副本 ${mb(packed.bytes)} MB 超过单包上限 ${MAX_BUNDLE_MB} MB`,
|
|
578
|
-
});
|
|
579
|
-
try {
|
|
580
|
-
fs.rmSync(packed.file, { force: true });
|
|
581
|
-
} catch {
|
|
582
|
-
/* ignore */
|
|
583
|
-
}
|
|
584
|
-
continue;
|
|
585
|
-
}
|
|
586
|
-
bundleBytes += packed.bytes;
|
|
587
|
-
bundled.push({
|
|
588
|
-
name: plugin.name,
|
|
589
|
-
version: plugin.version,
|
|
590
|
-
kind: plugin.kind,
|
|
591
|
-
file: path.relative(root, packed.file).replace(/\\/g, '/'),
|
|
592
|
-
bytes: packed.bytes,
|
|
593
|
-
});
|
|
594
|
-
bundleIndex[path.basename(packed.file)] = { sig: signature, bytes: packed.bytes };
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// 只清理本次没有打包、且不是本轮产出的旧副本(防止插件被删了副本还留着)
|
|
598
|
-
const produced = new Set(bundled.map((item) => path.basename(item.file)));
|
|
599
|
-
// 清理:本轮没有产出、也不在 KEEP_BUNDLES 保留范围内的旧副本一律删掉。
|
|
600
|
-
//
|
|
601
|
-
// 这里必须按**版本**判断,不能只看「同名的还有没有」——
|
|
602
|
-
// 插件升到 0.13.3 之后,0.13.2 那份副本同名前缀仍然匹配,会被一直留着,
|
|
603
|
-
// 越攒越多(实际踩过:目录里同时躺着 0.3.0 和 0.4.0 两个自己)。
|
|
604
|
-
const keepNames = new Set();
|
|
605
|
-
for (const item of bundled) keepNames.add(path.basename(item.file));
|
|
606
|
-
for (const name of Object.keys(bundleIndex)) keepNames.add(name);
|
|
607
|
-
|
|
608
|
-
for (const entry of listLocalBundles(bundleDir)) {
|
|
609
|
-
if (produced.has(entry.file) || keepNames.has(entry.file)) continue;
|
|
610
|
-
const stillReferenced = needBundle.some((p) => entry.file === `${fileStem(p.name)}-${p.version}.tgz`);
|
|
611
|
-
if (stillReferenced) continue;
|
|
612
|
-
try {
|
|
613
|
-
fs.rmSync(path.join(bundleDir, entry.file), { force: true });
|
|
614
|
-
} catch {
|
|
615
|
-
/* ignore */
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
const pruned = pruneBundles(bundleDir);
|
|
405
|
+
// ---- 清掉历史遗留的副本目录 ----
|
|
406
|
+
// 0.9.5 及以前会把本地/Git 来源的插件打成 .tgz 放在 local/ 下。
|
|
407
|
+
// 现在不存副本了,这个目录必须主动删:留着就是「共享盘上躺着一份别人可能拿去装的旧代码」。
|
|
408
|
+
const purged = purgeLegacyBundles(root);
|
|
619
409
|
|
|
620
410
|
const manifest = {
|
|
621
|
-
schemaVersion:
|
|
411
|
+
schemaVersion: 2,
|
|
622
412
|
// 这份清单属于谁、在哪台机器上、什么时候写的
|
|
623
413
|
feature,
|
|
624
414
|
machine: identity.machineName,
|
|
@@ -629,28 +419,27 @@ export async function syncMyFeature({ config, publishedNames, log }) {
|
|
|
629
419
|
generatedAt: new Date().toISOString(),
|
|
630
420
|
counts: {
|
|
631
421
|
total: plugins.length,
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
422
|
+
// 能装 / 不能装,直接按清单里的 installSpec 算,不再有「有副本」这一档
|
|
423
|
+
installable: plugins.filter((p) => p.installable).length,
|
|
424
|
+
uninstallable: plugins.filter((p) => !p.installable).length,
|
|
635
425
|
// 随宿主安装提供的包不共享(别人本来就有),只报个数便于对照
|
|
636
426
|
hostProvided: allPlugins.length - plugins.length,
|
|
637
427
|
},
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
428
|
+
/** 共享策略声明:写进清单里,别人一看就知道这份清单不含任何插件文件。 */
|
|
429
|
+
policy: {
|
|
430
|
+
manifestOnly: true,
|
|
431
|
+
storesBundles: false,
|
|
432
|
+
installVia: 'registry',
|
|
433
|
+
note: '特征文件夹只存清单;安装一律按 installSpec 联网拉取,不使用本地副本。',
|
|
641
434
|
},
|
|
642
435
|
plugins,
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
bundleIndex,
|
|
646
|
-
skipped,
|
|
647
|
-
pruned,
|
|
648
|
-
reused,
|
|
436
|
+
/** 历史副本的清理记录(便于排查「我的 local/ 怎么没了」)。 */
|
|
437
|
+
legacyPurged: purged,
|
|
649
438
|
};
|
|
650
439
|
|
|
651
440
|
const pluginsChanged = writeJsonIfChanged(path.join(root, 'PLUGINS.json'), manifest);
|
|
652
441
|
const featureChanged = writeJsonIfChanged(path.join(root, 'FEATURE.json'), {
|
|
653
|
-
schemaVersion:
|
|
442
|
+
schemaVersion: 2,
|
|
654
443
|
feature,
|
|
655
444
|
machine: identity.machineName,
|
|
656
445
|
machineId: identity.machineId,
|
|
@@ -662,11 +451,13 @@ export async function syncMyFeature({ config, publishedNames, log }) {
|
|
|
662
451
|
dshProfile: profileDir(),
|
|
663
452
|
lastSyncAt: manifest.generatedAt,
|
|
664
453
|
counts: manifest.counts,
|
|
665
|
-
|
|
454
|
+
policy: manifest.policy,
|
|
455
|
+
note: '本目录由 ygc-dsh-plugin-autoupdate 的「插件共享」自动生成;目录名即本机标识,与会话同步插件一致;只有清单,没有插件文件;删除后下次巡检会重建。',
|
|
666
456
|
});
|
|
667
457
|
|
|
668
458
|
log?.(
|
|
669
|
-
`共享同步完成:${feature} → ${plugins.length}
|
|
459
|
+
`共享同步完成:${feature} → ${plugins.length} 个插件(可联网装 ${manifest.counts.installable},不可装 ${manifest.counts.uninstallable})` +
|
|
460
|
+
(purged.removed.length ? `;清理历史副本 ${purged.removed.length} 个` : ''),
|
|
670
461
|
);
|
|
671
462
|
|
|
672
463
|
return {
|
|
@@ -677,15 +468,80 @@ export async function syncMyFeature({ config, publishedNames, log }) {
|
|
|
677
468
|
pluginsChanged,
|
|
678
469
|
featureChanged,
|
|
679
470
|
counts: manifest.counts,
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
reused,
|
|
684
|
-
totalBytes:
|
|
685
|
-
totalMb:
|
|
471
|
+
// 老字段保留成空数组 / 0:面板与历史记录还在读它们,语义变成「没有副本」
|
|
472
|
+
bundles: [],
|
|
473
|
+
skipped: [],
|
|
474
|
+
reused: [],
|
|
475
|
+
totalBytes: 0,
|
|
476
|
+
totalMb: 0,
|
|
477
|
+
legacyPurged: purged,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* 删掉特征文件夹里历史遗留的插件副本。
|
|
483
|
+
*
|
|
484
|
+
* 只删这两类,别的一律不碰:
|
|
485
|
+
* - `local/` 目录本身(老版本打包进去的 .tgz 全在这里)
|
|
486
|
+
* - 特征根目录下散落的 `*.tgz`(更早的版本直接扔在根上)
|
|
487
|
+
*
|
|
488
|
+
* 删之前先做一次「可删性」自检:只处理自己那一格,别顺着软链接跑到别人家去。
|
|
489
|
+
*
|
|
490
|
+
* @returns {{removed: string[], bytes: number, error: string}}
|
|
491
|
+
*/
|
|
492
|
+
function purgeLegacyBundles(root) {
|
|
493
|
+
const removed = [];
|
|
494
|
+
let bytes = 0;
|
|
495
|
+
let error = '';
|
|
496
|
+
const collect = (dir) => {
|
|
497
|
+
let entries = [];
|
|
498
|
+
try {
|
|
499
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
500
|
+
} catch {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
for (const entry of entries) {
|
|
504
|
+
if (!entry.isFile() || !/\.tgz$/i.test(entry.name)) continue;
|
|
505
|
+
const file = path.join(dir, entry.name);
|
|
506
|
+
bytes += bytesOf(file);
|
|
507
|
+
try {
|
|
508
|
+
fs.rmSync(file, { force: true });
|
|
509
|
+
removed.push(path.relative(root, file).replace(/\\/g, '/'));
|
|
510
|
+
} catch (e) {
|
|
511
|
+
error = String(e?.message || e);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
686
514
|
};
|
|
515
|
+
|
|
516
|
+
const legacyDir = path.join(root, 'local');
|
|
517
|
+
// 只认 root 下面的 local:realpath 之后再比前缀,防止 local 被换成指向别处的链接
|
|
518
|
+
let realRoot = root;
|
|
519
|
+
let realLegacy = legacyDir;
|
|
520
|
+
try {
|
|
521
|
+
realRoot = fs.realpathSync(root);
|
|
522
|
+
} catch {
|
|
523
|
+
/* root 还没建出来,等于没有历史副本 */
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
realLegacy = fs.realpathSync(legacyDir);
|
|
527
|
+
} catch {
|
|
528
|
+
realLegacy = '';
|
|
529
|
+
}
|
|
530
|
+
if (realLegacy && (realLegacy === realRoot || realLegacy.startsWith(realRoot + path.sep))) {
|
|
531
|
+
collect(realLegacy);
|
|
532
|
+
try {
|
|
533
|
+
fs.rmSync(realLegacy, { recursive: true, force: true });
|
|
534
|
+
} catch (e) {
|
|
535
|
+
error = error || String(e?.message || e);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// 根目录上散落的 .tgz 也算历史残留
|
|
539
|
+
collect(root);
|
|
540
|
+
|
|
541
|
+
return { removed, bytes, error };
|
|
687
542
|
}
|
|
688
543
|
|
|
544
|
+
|
|
689
545
|
/* ------------------------------------------------------- 他人特征汇集 */
|
|
690
546
|
|
|
691
547
|
/**
|
|
@@ -730,31 +586,36 @@ export function collectSharedPlugins({ config, publishedNames } = {}) {
|
|
|
730
586
|
machine: manifest.machine || '',
|
|
731
587
|
generatedAt: manifest.generatedAt || '',
|
|
732
588
|
total: Array.isArray(manifest.plugins) ? manifest.plugins.length : 0,
|
|
733
|
-
|
|
589
|
+
// 老清单里可能有 bundles,这里只报个数(提示用),安装不看它
|
|
590
|
+
legacyBundles: Array.isArray(manifest.bundles) ? manifest.bundles.length : 0,
|
|
734
591
|
});
|
|
735
592
|
|
|
736
593
|
for (const plugin of manifest.plugins) {
|
|
737
594
|
if (!plugin || typeof plugin.name !== 'string' || !plugin.name) continue;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
595
|
+
// 安装坐标:清单里带的 installSpec 优先;老清单没这个字段就按「名称@版本」推
|
|
596
|
+
const version = typeof plugin.version === 'string' ? plugin.version : '';
|
|
597
|
+
const kind = typeof plugin.kind === 'string' ? plugin.kind : 'registry';
|
|
598
|
+
const installSpec =
|
|
599
|
+
typeof plugin.installSpec === 'string' && plugin.installSpec
|
|
600
|
+
? plugin.installSpec
|
|
601
|
+
: kind === 'registry' || kind === 'installation'
|
|
602
|
+
? version
|
|
603
|
+
? `${plugin.name}@${version}`
|
|
604
|
+
: plugin.name
|
|
605
|
+
: kind === 'git' && typeof plugin.spec === 'string'
|
|
606
|
+
? plugin.spec
|
|
607
|
+
: '';
|
|
741
608
|
const record = {
|
|
742
609
|
name: plugin.name,
|
|
743
|
-
version
|
|
744
|
-
kind
|
|
610
|
+
version,
|
|
611
|
+
kind,
|
|
612
|
+
spec: typeof plugin.spec === 'string' ? plugin.spec : '',
|
|
613
|
+
installSpec,
|
|
614
|
+
installable: Boolean(installSpec),
|
|
745
615
|
description: typeof plugin.description === 'string' ? plugin.description : '',
|
|
746
616
|
homepage: typeof plugin.homepage === 'string' ? plugin.homepage : '',
|
|
747
|
-
share: plugin.share === 'bundle' ? 'bundle' : 'name-only',
|
|
748
617
|
features: [feature],
|
|
749
618
|
mine: isMine || Boolean(plugin.mine),
|
|
750
|
-
bundle: bundle
|
|
751
|
-
? {
|
|
752
|
-
feature,
|
|
753
|
-
file: bundle.file,
|
|
754
|
-
path: path.join(dir, String(bundle.file).replace(/\//g, path.sep)),
|
|
755
|
-
bytes: Number(bundle.bytes) || 0,
|
|
756
|
-
}
|
|
757
|
-
: null,
|
|
758
619
|
};
|
|
759
620
|
|
|
760
621
|
const old = merged.get(record.name);
|
|
@@ -763,17 +624,20 @@ export function collectSharedPlugins({ config, publishedNames } = {}) {
|
|
|
763
624
|
continue;
|
|
764
625
|
}
|
|
765
626
|
if (!old.features.includes(feature)) old.features.push(feature);
|
|
766
|
-
//
|
|
627
|
+
// 多本都有:取版本更高的那本,安装坐标也跟着它走
|
|
767
628
|
const newer = record.version && (!old.version || isNewerVersion(old.version, record.version));
|
|
768
629
|
if (newer) {
|
|
769
630
|
old.version = record.version;
|
|
770
631
|
old.kind = record.kind;
|
|
771
|
-
old.
|
|
632
|
+
old.spec = record.spec || old.spec;
|
|
633
|
+
old.installSpec = record.installSpec || old.installSpec;
|
|
634
|
+
old.installable = Boolean(old.installSpec);
|
|
772
635
|
old.description = record.description || old.description;
|
|
773
636
|
old.homepage = record.homepage || old.homepage;
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
old.
|
|
637
|
+
} else if (!old.installSpec && record.installSpec) {
|
|
638
|
+
// 老的那本没坐标、新报上来的有,补一个能装的坐标
|
|
639
|
+
old.installSpec = record.installSpec;
|
|
640
|
+
old.installable = true;
|
|
777
641
|
}
|
|
778
642
|
// 别人也有、我也有 → 记成「本机已有」,但不改成 mine
|
|
779
643
|
old.mine = old.mine && record.mine;
|
|
@@ -795,8 +659,9 @@ export function writeAggregate({ config, publishedNames, log } = {}) {
|
|
|
795
659
|
...plugin,
|
|
796
660
|
installed: local.has(plugin.name),
|
|
797
661
|
installedVersion: local.has(plugin.name) ? installedVersion(plugin.name) || '' : '',
|
|
798
|
-
//
|
|
799
|
-
installKind: plugin.
|
|
662
|
+
// 能装的方式只有一种:registry(联网拉)。装不了的明确标 none,不做本地兜底。
|
|
663
|
+
installKind: plugin.installable ? 'registry' : 'none',
|
|
664
|
+
installSpec: plugin.installSpec || '',
|
|
800
665
|
}));
|
|
801
666
|
|
|
802
667
|
const others = items.filter((item) => !item.mine);
|
|
@@ -814,7 +679,8 @@ export function writeAggregate({ config, publishedNames, log } = {}) {
|
|
|
814
679
|
othersTotal: others.length,
|
|
815
680
|
installable: installable.length,
|
|
816
681
|
installedFromOthers: others.filter((item) => item.installed).length,
|
|
817
|
-
|
|
682
|
+
// 老字段保留为 0:清单里不再有副本,面板若还读它能拿到确定的「没有」
|
|
683
|
+
withBundle: 0,
|
|
818
684
|
},
|
|
819
685
|
plugins: items,
|
|
820
686
|
};
|
|
@@ -828,28 +694,29 @@ export function writeAggregate({ config, publishedNames, log } = {}) {
|
|
|
828
694
|
'> 由 ygc-dsh-plugin-autoupdate 的「插件共享」汇集。',
|
|
829
695
|
'> **这份文件只给本机自己看**:每台机器各自扫一遍插件库里所有人的特征文件夹,',
|
|
830
696
|
'> 在自己的格子里生成自己的汇总。插件库是共享盘,不能在里面放一份大家共写的总表。',
|
|
697
|
+
'> 清单里**没有插件文件**;安装一律按「安装坐标」联网拉取,不使用本地副本。',
|
|
831
698
|
'> 扫描范围:' + featuresDir(config),
|
|
832
699
|
`> 生成时间:${doc.generatedAt}`,
|
|
833
700
|
`> 特征本 ${doc.counts.sources} 本 · 他人插件 ${doc.counts.othersTotal} 个 · 本机可装 ${doc.counts.installable} 个`,
|
|
834
701
|
'',
|
|
835
702
|
'## 来源特征',
|
|
836
703
|
'',
|
|
837
|
-
'| 特征 | 机器 | 生成时间 | 插件数
|
|
704
|
+
'| 特征 | 机器 | 生成时间 | 插件数 |(旧副本数)|',
|
|
838
705
|
'|---|---|---|---|---|',
|
|
839
706
|
...collected.sources.map(
|
|
840
707
|
(item) =>
|
|
841
|
-
`| ${item.feature}${item.mine ? '(本机)' : ''} | ${item.machine || '-'} | ${item.generatedAt || '-'} | ${item.total} | ${item.
|
|
708
|
+
`| ${item.feature}${item.mine ? '(本机)' : ''} | ${item.machine || '-'} | ${item.generatedAt || '-'} | ${item.total} | ${item.legacyBundles || 0} |`,
|
|
842
709
|
),
|
|
843
710
|
'',
|
|
844
711
|
'## 他人插件',
|
|
845
712
|
'',
|
|
846
|
-
'| 插件 | 版本 | 来源 | 本机 | 安装方式 |
|
|
713
|
+
'| 插件 | 版本 | 来源 | 本机 | 安装方式 | 安装坐标 |',
|
|
847
714
|
'|---|---|---|---|---|---|',
|
|
848
715
|
...others.map(
|
|
849
716
|
(item) =>
|
|
850
717
|
`| \`${item.name}\` | ${item.version || '-'} | ${item.features.join(', ')} | ${
|
|
851
718
|
item.installed ? `已装 ${item.installedVersion}` : '无'
|
|
852
|
-
} | ${item.installKind} | ${item.
|
|
719
|
+
} | ${item.installKind} | ${item.installSpec ? `\`${item.installSpec}\`` : '-'} |`,
|
|
853
720
|
),
|
|
854
721
|
'',
|
|
855
722
|
];
|
|
@@ -878,7 +745,14 @@ export function writeAggregate({ config, publishedNames, log } = {}) {
|
|
|
878
745
|
/**
|
|
879
746
|
* 安装他人清单里的一个插件。人工点选后调用,不做自动批量装。
|
|
880
747
|
*
|
|
881
|
-
*
|
|
748
|
+
* ── 唯一的安装通道:registry 联网拉取 ──────────────────────────────────
|
|
749
|
+
* 特征文件夹里没有插件文件,所以这里**不存在**「本地副本安装」这条路:
|
|
750
|
+
* - registry 来源 → `name@version` 去 registry 拉
|
|
751
|
+
* - git 来源 → 原 git 写法,仍然联网
|
|
752
|
+
* - local 来源 → 直接拒绝并说明原因(未发布的东西没地方拉)
|
|
753
|
+
*
|
|
754
|
+
* 就算对方的清单是老版本写的、里面还带着 `bundles` 数组,也一律忽略:
|
|
755
|
+
* 拿别人打包的旧代码装上,版本号和行为对不上,比装不上更难查。
|
|
882
756
|
*
|
|
883
757
|
* @param {{ctx?: any, config?: object, publishedNames?: string[], name: string,
|
|
884
758
|
* installSpec?: Function, log?: Function}} input
|
|
@@ -896,20 +770,23 @@ export async function installSharedPlugin({ ctx, config, publishedNames, name, i
|
|
|
896
770
|
return { ok: false, message: `${target} 本机已装(v${installedVersion(target) || '?'}),未重复安装` };
|
|
897
771
|
}
|
|
898
772
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
spec = `file:${plugin.bundle.path.replace(/\\/g, '/')}`;
|
|
903
|
-
} else if (plugin.kind === 'registry' || plugin.kind === 'installation') {
|
|
904
|
-
spec = plugin.version ? `${target}@${plugin.version}` : target;
|
|
905
|
-
} else {
|
|
773
|
+
// 安装坐标必须来自 registry/git 这种能联网拉到的写法,file:/link: 一律不受理
|
|
774
|
+
const spec = typeof plugin.installSpec === 'string' ? plugin.installSpec.trim() : '';
|
|
775
|
+
if (!spec) {
|
|
906
776
|
return {
|
|
907
777
|
ok: false,
|
|
908
|
-
message:
|
|
778
|
+
message:
|
|
779
|
+
`${target} 没有可联网的安装坐标(${plugin.kind} 来源,对方未发布到 registry)。` +
|
|
780
|
+
'特征文件夹只存清单,不提供本地副本安装,所以这个插件装不了——' +
|
|
781
|
+
'要么请对方发布到 npm,要么你手动从源仓库装。',
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
if (/^(file:|link:|workspace:|portal:)/i.test(spec)) {
|
|
785
|
+
return {
|
|
786
|
+
ok: false,
|
|
787
|
+
message: `${target} 的安装坐标是本地路径(${spec}),本插件只走 registry 联网拉取。`,
|
|
909
788
|
};
|
|
910
789
|
}
|
|
911
|
-
|
|
912
|
-
if (typeof installSpec !== 'function') return { ok: false, message: '安装通道不可用' };
|
|
913
790
|
const result = await installSpec({ ctx, spec, log });
|
|
914
791
|
if (!result?.ok) {
|
|
915
792
|
return { ok: false, spec, message: result?.message || '安装失败' };
|
|
@@ -974,7 +851,7 @@ export function shareSnapshot({ config, publishedNames } = {}) {
|
|
|
974
851
|
}))
|
|
975
852
|
.sort((a, b) => {
|
|
976
853
|
// 能装、没装的排前面,方便一眼看到要点什么
|
|
977
|
-
const rank = (item) => (item.installed ? 2 : item.
|
|
854
|
+
const rank = (item) => (item.installed ? 2 : item.installable ? 0 : 1);
|
|
978
855
|
const diff = rank(a) - rank(b);
|
|
979
856
|
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
|
980
857
|
});
|
|
@@ -1000,8 +877,11 @@ export function shareSnapshot({ config, publishedNames } = {}) {
|
|
|
1000
877
|
? {
|
|
1001
878
|
generatedAt: manifest.generatedAt,
|
|
1002
879
|
counts: manifest.counts,
|
|
1003
|
-
|
|
1004
|
-
|
|
880
|
+
// 只存清单:这两个老字段固定为空,面板据此确认「没有副本」
|
|
881
|
+
bundles: [],
|
|
882
|
+
skipped: [],
|
|
883
|
+
policy: manifest.policy || SHARE_POLICY,
|
|
884
|
+
legacyPurged: manifest.legacyPurged || { removed: [], bytes: 0, error: '' },
|
|
1005
885
|
}
|
|
1006
886
|
: null,
|
|
1007
887
|
mine,
|
|
@@ -1015,6 +895,8 @@ export function shareSnapshot({ config, publishedNames } = {}) {
|
|
|
1015
895
|
file: aggregateFile,
|
|
1016
896
|
}
|
|
1017
897
|
: null,
|
|
1018
|
-
|
|
898
|
+
/** 共享策略:只存清单、安装走 registry。面板据此显示口径,不再有副本上限。 */
|
|
899
|
+
policy: SHARE_POLICY,
|
|
900
|
+
limits: { maxBundleMb: 0, maxFeatureMb: 0, keepBundles: 0 },
|
|
1019
901
|
};
|
|
1020
902
|
}
|
package/package.json
CHANGED