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 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
- │ ├─ OTHERS-PLUGINS.md 同一份的人读版
285
- │ └─ local\ 本地 / Git 来源插件的 .tgz 副本
284
+ │ └─ OTHERS-PLUGINS.md 同一份的人读版
286
285
  └─ <别人的标识>\ ← 别人写,我只读
287
286
  ├─ PLUGINS.json
288
- ├─ FEATURE.json
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`(能联网更新) | 名称 + 版本 | **不打**,对方自己从 registry 拉 |
331
- | `local`(`file:` 本地路径) | 名称 + 版本 + 来源说明 | **打**,`local/<name>-<version>.tgz` |
332
- | `git`(GitHub 等) | 名称 + 版本 | **打**,同上 |
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
- - 副本跟着最高版本走;`features` 里记下它出现在哪几本
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` | 特征文件夹:清单分类(registry 只写名 / 本地打副本 / 宿主自带不写)、tgz 结构、同步幂等与副本复用、他人清单合并去重、安装路径选择、特征名防上跳 |
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
- '插件库是共享文件夹,一个人一格:我的清单、本地插件副本、我自己的汇总全都写在「插件库\\<我的特征>」这一格里面。能联网更新的插件只写名称,本地/Git 来源的打成 .tgz 副本一起放过去。别人的格子只读,汇总也是各机扫一遍自己算,不放在公共位置。',
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} 个插件(副本 ${share.lastResult.bundled},${share.lastResult.totalMb} MB) · 他人 ${share.lastResult.others} 个,可装 ${share.lastResult.installable}`
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} 个插件(副本 ${counts.bundled || 0}),他人 ${others2.othersTotal || 0} 个,可装 ${others2.installable || 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.bundle
852
- ? '含副本,可离线装'
853
- : plugin.kind === 'registry'
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 || (!plugin.bundle && plugin.kind !== 'registry'),
868
- title: plugin.bundle ? `安装对方特征里的副本` : '从 registry 安装该版本',
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: mine.counts.bundled,
329
- skipped: mine.counts.skipped,
330
- totalMb: mine.totalMb,
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: `共享同步:本机 ${mine.counts.total} 个插件 / 副本 ${mine.counts.bundled};他人 ${aggregate.doc.counts.othersTotal} 个,可装 ${aggregate.doc.counts.installable}`,
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
- * ├─ FEATURE.json 本机特征说明(谁、哪台机、什么时候巡检、插件数)
10
- * ├─ PLUGINS.json 本机插件清单(可联网更新的只写名称+版本,不打包)
11
- * └─ local\ 本机插件副本(本地路径 / Git 来源的 .tgz)
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. 可联网更新(registry 来源)的插件**只写名称**,副本一律不打——它们本来就能拉。
23
- * 2. 本地来源(file:/link:/git)的插件打包成 .tgz 同步过来,否则别人装上跑不起来。
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
- /** 单个插件的副本上限(MB):超过就只留条目、不打包,避免一个包拖垮整个同步。 */
48
- export const MAX_BUNDLE_MB = 96;
49
- /** 单个特征文件夹的副本总上限(MB)。 */
50
- export const MAX_FEATURE_MB = 512;
51
- /** 副本保留份数:同名插件只留最新的 N 个版本。 */
52
- export const KEEP_BUNDLES = 2;
53
-
54
- const TAR = process.env.SystemRoot ? path.join(process.env.SystemRoot, 'System32', 'tar.exe') : 'tar';
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
- const VOLATILE_KEYS = ['generatedAt', 'lastSyncAt', 'pruned', 'reused', 'ms'];
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
- * 如果每次都重新压一遍 .tgz,几百 MB 的插件会把每轮巡检拖成几分钟,
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,share:'name-only'|'bundle',sourceDir?,reason?}>}
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(bundleDir, { featuresRoot, machineId: feature });
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
- const packed = await packLocalPlugin({
565
- sourceDir: plugin.sourceDir,
566
- name: plugin.name,
567
- version: plugin.version,
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: 1,
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
- nameOnly: plugins.filter((p) => p.share === 'name-only').length,
633
- bundled: bundled.length,
634
- skipped: skipped.length,
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
- bundlePolicy: {
639
- registry: '只写名称与版本,不打副本(对方能自己从 registry 拉)',
640
- localOrGit: `打包成 .tgz 放在 local/,单包上限 ${MAX_BUNDLE_MB} MB,同名保留 ${KEEP_BUNDLES} 份`,
428
+ /** 共享策略声明:写进清单里,别人一看就知道这份清单不含任何插件文件。 */
429
+ policy: {
430
+ manifestOnly: true,
431
+ storesBundles: false,
432
+ installVia: 'registry',
433
+ note: '特征文件夹只存清单;安装一律按 installSpec 联网拉取,不使用本地副本。',
641
434
  },
642
435
  plugins,
643
- bundles: bundled,
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: 1,
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
- note: '本目录由 ygc-dsh-plugin-autoupdate 的「插件共享」自动生成;目录名即本机标识,与会话同步插件一致;删除后下次巡检会重建。',
454
+ policy: manifest.policy,
455
+ note: '本目录由 ygc-dsh-plugin-autoupdate 的「插件共享」自动生成;目录名即本机标识,与会话同步插件一致;只有清单,没有插件文件;删除后下次巡检会重建。',
666
456
  });
667
457
 
668
458
  log?.(
669
- `共享同步完成:${feature} → ${plugins.length} 个插件(副本 ${bundled.length},跳过 ${skipped.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
- bundles: bundled,
681
- skipped,
682
- pruned,
683
- reused,
684
- totalBytes: bundleBytes,
685
- totalMb: mb(bundleBytes),
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
- bundles: Array.isArray(manifest.bundles) ? manifest.bundles.length : 0,
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
- const bundle = (Array.isArray(manifest.bundles) ? manifest.bundles : []).find(
739
- (item) => item && item.name === plugin.name,
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: typeof plugin.version === 'string' ? plugin.version : '',
744
- kind: typeof plugin.kind === 'string' ? plugin.kind : 'registry',
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.share = record.share;
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
- old.bundle = record.bundle;
775
- } else if (!old.bundle && record.bundle) {
776
- old.bundle = record.bundle;
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
- // 能装的方式:有副本走 file:,没副本且是 registry 来源走 name@version
799
- installKind: plugin.bundle ? 'bundle' : plugin.kind === 'registry' || plugin.kind === 'installation' ? 'registry' : 'none',
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
- withBundle: others.filter((item) => item.bundle).length,
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.bundles} |`,
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.bundle ? `${mb(item.bundle.bytes)} MB` : '-'} |`,
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
- * 优先用对方特征里的 .tgz 副本(不联网也能装);没有副本才走 registry 的 name@version。
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
- let spec = '';
900
- if (plugin.bundle && fs.existsSync(plugin.bundle.path)) {
901
- // file: 路径必须用正斜杠,pnpm 在 Windows 上按反斜杠解析成转义
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: `${target} 是 ${plugin.kind} 来源,对方特征里没有可用副本,无法安装`,
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.bundle || item.kind === 'registry' ? 0 : 1);
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
- bundles: Array.isArray(manifest.bundles) ? manifest.bundles : [],
1004
- skipped: Array.isArray(manifest.skipped) ? manifest.skipped : [],
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
- limits: { maxBundleMb: MAX_BUNDLE_MB, maxFeatureMb: MAX_FEATURE_MB, keepBundles: KEEP_BUNDLES },
898
+ /** 共享策略:只存清单、安装走 registry。面板据此显示口径,不再有副本上限。 */
899
+ policy: SHARE_POLICY,
900
+ limits: { maxBundleMb: 0, maxFeatureMb: 0, keepBundles: 0 },
1019
901
  };
1020
902
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ygc-dsh-plugin-autoupdate",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "DSH 插件自动更新器:定时检查已装插件的新版本并按策略升级;把自己的插件特征(清单+本地副本+本机汇总)同步到共享插件库自己那一格,本机标识与会话同步插件共用,并汇集他人清单供人工选用。",
6
6
  "icon": "icon.svg",