dsh-plugin-pack 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -204,6 +204,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
204
204
  'backup.rollback': '回滚',
205
205
  'backup.rolling': '正在回滚…',
206
206
  'backup.done': '回滚完成',
207
+ 'backup.surgicalHint': '只撤销了这次导入加的东西,你之后改的其它配置都保留着。',
208
+ 'backup.fullHint': '这次的备份是老格式,只能整份还原 —— 导入之后改的配置可能被覆盖。',
209
+ 'backup.preSnapshot': '回滚前的配置已另存(想反悔可从这儿取回):',
207
210
  'backup.confirm': '确定要回滚到这份备份吗?本次导入新增的插件会被删掉。',
208
211
  'backup.createdAt': '时间',
209
212
  'backup.items': '项',
@@ -400,6 +403,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
400
403
  'backup.rollback': 'Roll back',
401
404
  'backup.rolling': 'Rolling back…',
402
405
  'backup.done': 'Rollback finished',
406
+ 'backup.surgicalHint': 'Only what this import added was reverted; your later config changes are untouched.',
407
+ 'backup.fullHint': 'This backup is an old format and can only be restored wholesale — config you changed after the import may be overwritten.',
408
+ 'backup.preSnapshot': 'Your config before rollback was saved here (recover from it if needed): ',
403
409
  'backup.confirm': 'Roll back to this snapshot? Plugins added by that import will be removed.',
404
410
  'backup.createdAt': 'Time',
405
411
  'backup.items': 'items',
@@ -2982,9 +2988,25 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2982
2988
  'div',
2983
2989
  null,
2984
2990
  h(Alert, { kind: 'ok' }, `${t('backup.done')} · ${t('import.restart')}`),
2991
+ h(
2992
+ 'div',
2993
+ { className: 'pp-hint', style: { marginTop: 6 } },
2994
+ rollbackResult.mode === 'surgical'
2995
+ ? t('backup.surgicalHint')
2996
+ : t('backup.fullHint'),
2997
+ ),
2985
2998
  rollbackResult.restored?.length
2986
2999
  ? h('div', { className: 'pp-note' }, `恢复: ${rollbackResult.restored.join(', ')}`)
2987
3000
  : null,
3001
+ // 回滚前把当时配置另存了一份,路径给出来方便反悔
3002
+ rollbackResult.preRollbackDir
3003
+ ? h(
3004
+ 'div',
3005
+ { className: 'pp-hint', style: { marginTop: 6 } },
3006
+ t('backup.preSnapshot'),
3007
+ h('code', { className: 'pp-mono', style: { fontSize: 11, wordBreak: 'break-all' } }, rollbackResult.preRollbackDir),
3008
+ )
3009
+ : null,
2988
3010
  )
2989
3011
  : null,
2990
3012
  ),
package/lib/import.js CHANGED
@@ -17,6 +17,7 @@ import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName
17
17
  import { readJson, readText, writeJson, writeText, ensureDir, isDir, isFile, realOrSelf, copyTree, removeTree } from './util.js';
18
18
  import { existingIds, rowId, appendInsertBlock, splitTopLevel, yamlScalar } from './patch-text.js';
19
19
  import { isAbsoluteSpec, clearScanCache } from './scan.js';
20
+ import { repairProfile } from './repair.js';
20
21
 
21
22
  export const PACK_FORMAT = 'dsh-plugin-pack@2';
22
23
  /** 兼容旧的散文件布局。 */
@@ -447,6 +448,11 @@ export function importBundle(options = {}) {
447
448
  pkg.dsh = pkg.dsh && typeof pkg.dsh === 'object' ? pkg.dsh : {};
448
449
  pkg.dsh.profile = pkg.dsh.profile && typeof pkg.dsh.profile === 'object' ? pkg.dsh.profile : {};
449
450
  pkg.dsh.profile.bundles = Array.isArray(pkg.dsh.profile.bundles) ? pkg.dsh.profile.bundles : [];
451
+ /* 改写前先记下"每个被碰过的字段原来是什么"。
452
+ * 回滚时只把这几个字段还原,不动用户后来改的其它配置 ——
453
+ * 早先回滚是整份覆盖,会把导入之后设的 API 密钥之类一起抹掉。 */
454
+ const depsBefore = { ...pkg.dependencies };
455
+ const bundlesBefore = [...pkg.dsh.profile.bundles];
450
456
  /* enable=false:只把文件装上、依赖写好,但不进 dsh.profile.bundles
451
457
  * —— 也就是"装好了但先不启用"。用户之后可以在界面上再打开。
452
458
  * files 与依赖照常写,所以不启用不等于没装。 */
@@ -460,13 +466,30 @@ export function importBundle(options = {}) {
460
466
  }
461
467
  writeJson(pkgFile, pkg);
462
468
 
469
+ // 记录:哪些依赖被改(含改前值)、哪些 bundle 是新加的
470
+ record.pkgDepsBefore = {};
471
+ for (const item of plan) {
472
+ if (item.action === 'skip' || item.pathPlugin) continue;
473
+ const had = Object.prototype.hasOwnProperty.call(depsBefore, item.name);
474
+ record.pkgDepsBefore[item.name] = {
475
+ before: had ? depsBefore[item.name] : null,
476
+ after: `link:./local-plugins/${item.segment}`,
477
+ };
478
+ }
479
+ record.pkgBundlesAdded = pkg.dsh.profile.bundles.filter((name) => !bundlesBefore.includes(name));
480
+
463
481
  // cordis.patch.yml
464
482
  let nextPatch = patchText;
465
483
  if (patchPlan.topRows.length) nextPatch = appendTopRows(nextPatch, patchPlan.topRows, `plugin-pack ${stampText}`);
466
484
  if (patchPlan.insertRows.length) {
467
485
  nextPatch = appendInsertBlock(nextPatch, dedentRows(patchPlan.insertRows), `plugin-pack ${stampText}`).text;
468
486
  }
469
- if (nextPatch !== patchText) writeText(patchFile, nextPatch);
487
+ if (nextPatch !== patchText) {
488
+ writeText(patchFile, nextPatch);
489
+ /* 记下这次"追加的那一段原文"。回滚时只把这几个字串摘掉,
490
+ * 保留用户后来在同一个文件里做的其它修改。 */
491
+ record.patchAppended = nextPatch.startsWith(patchText) ? nextPatch.slice(patchText.length) : null;
492
+ }
470
493
 
471
494
  // node_modules 链接
472
495
  onProgress('建立 node_modules 链接', 92);
@@ -701,15 +724,77 @@ export function rollbackImport(options = {}) {
701
724
  const removed = [];
702
725
  const relinked = [];
703
726
 
704
- const backupPkg = path.join(backupRoot, 'package.json');
705
- const backupPatch = path.join(backupRoot, 'cordis.patch.yml');
706
- if (isFile(backupPkg)) {
707
- fs.copyFileSync(backupPkg, path.join(profile, 'package.json'));
708
- restored.push('package.json');
727
+ const pkgFile = path.join(profile, 'package.json');
728
+ const patchFile = path.join(profile, 'cordis.patch.yml');
729
+
730
+ /* 第一步:先把**当前**的配置文件另存一份。
731
+ *
732
+ * 回滚会改 package.json / cordis.patch.yml,早先直接整份覆盖,
733
+ * 用户在这之后设的 API 密钥、改过的 provider 全没了且找不回来。
734
+ * 现在无论走精准回滚还是整份还原,都先留一份可反悔的副本。 */
735
+ const preRollbackDir = path.join(backupRoot, `pre-rollback-${stamp()}`);
736
+ let preRollbackFiles = [];
737
+ try {
738
+ ensureDir(preRollbackDir);
739
+ for (const [src, name] of [
740
+ [pkgFile, 'package.json'],
741
+ [patchFile, 'cordis.patch.yml'],
742
+ ]) {
743
+ if (isFile(src)) {
744
+ fs.copyFileSync(src, path.join(preRollbackDir, name));
745
+ preRollbackFiles.push(name);
746
+ }
747
+ }
748
+ } catch {
749
+ /* 存不了也继续,不阻断回滚 */
750
+ }
751
+
752
+ /* 第二步:优先精准回滚 —— 只撤销这次导入加的东西,保留用户后续改动。
753
+ * 老备份没有这些字段,才退回整份还原(那种情况确实是快照替换)。 */
754
+ let mode = 'full';
755
+
756
+ if (record.pkgDepsBefore || Array.isArray(record.pkgBundlesAdded)) {
757
+ mode = 'surgical';
758
+ const pkg = readJson(pkgFile, null);
759
+ if (pkg && typeof pkg === 'object') {
760
+ pkg.dependencies = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
761
+ // 依赖:只在"当前值还是我们写进去的那个"时才还原,避免覆盖用户后来的改动
762
+ for (const [name, info] of Object.entries(record.pkgDepsBefore ?? {})) {
763
+ if (pkg.dependencies[name] !== info.after) continue;
764
+ if (info.before === null) delete pkg.dependencies[name];
765
+ else pkg.dependencies[name] = info.before;
766
+ }
767
+ // bundles:只移除这次加的,且现在还在的
768
+ const addedSet = new Set(record.pkgBundlesAdded ?? []);
769
+ if (Array.isArray(pkg.dsh?.profile?.bundles)) {
770
+ pkg.dsh.profile.bundles = pkg.dsh.profile.bundles.filter((name) => !addedSet.has(name));
771
+ }
772
+ writeJson(pkgFile, pkg);
773
+ restored.push('package.json(仅撤销本次导入的依赖与 bundles)');
774
+ }
775
+ }
776
+
777
+ if (typeof record.patchAppended === 'string') {
778
+ mode = mode === 'surgical' ? 'surgical' : 'surgical';
779
+ const currentPatch = readText(patchFile, null);
780
+ if (typeof currentPatch === 'string' && record.patchAppended && currentPatch.includes(record.patchAppended)) {
781
+ // 只摘掉当初追加的那一段,其它内容(含用户后加的)原样保留
782
+ const nextText = currentPatch.replace(record.patchAppended, '');
783
+ writeText(patchFile, nextText);
784
+ restored.push('cordis.patch.yml(仅移除本次导入追加的行)');
785
+ }
709
786
  }
710
- if (isFile(backupPatch)) {
711
- fs.copyFileSync(backupPatch, path.join(profile, 'cordis.patch.yml'));
712
- restored.push('cordis.patch.yml');
787
+
788
+ // 精准回滚没覆盖到的老备份,才退回整份还原
789
+ const pkgHandled = restored.some((item) => item.startsWith('package.json'));
790
+ const patchHandled = restored.some((item) => item.startsWith('cordis.patch.yml'));
791
+ if (!pkgHandled && isFile(path.join(backupRoot, 'package.json'))) {
792
+ fs.copyFileSync(path.join(backupRoot, 'package.json'), pkgFile);
793
+ restored.push('package.json(整份还原:老备份没有精准记录)');
794
+ }
795
+ if (!patchHandled && isFile(path.join(backupRoot, 'cordis.patch.yml'))) {
796
+ fs.copyFileSync(path.join(backupRoot, 'cordis.patch.yml'), patchFile);
797
+ restored.push('cordis.patch.yml(整份还原:老备份没有精准记录)');
713
798
  }
714
799
 
715
800
  for (const dest of record.addedDirs ?? []) {
@@ -748,32 +833,47 @@ export function rollbackImport(options = {}) {
748
833
  removed.push(file);
749
834
  }
750
835
 
751
- // 标记该备份已消费:重命名加 .done,之后「最新备份」指向更早的、还没回滚过的那次。
836
+ // 回滚改了配置和插件目录,扫描缓存必须失效
837
+ // (早先这行写在 return 之后,成功路径根本执行不到)
838
+ clearScanCache();
839
+
840
+ /* 回滚后对账:恢复回来的 package.json 可能声明了某个插件,
841
+ * 而它的 node_modules 链接/目录已经被更早的回滚删了。
842
+ * 不修的话 dsh 启动会报
843
+ * cannot resolve profile bundle "xxx" ... run 'dsh plugin install'
844
+ * 而点「卸载」又会触发 pnpm(可能 store 版本不匹配直接失败)。 */
845
+ let repair = null;
846
+ try {
847
+ repair = repairProfile({ profileDir: profile });
848
+ } catch (error) {
849
+ repair = { ok: false, error: String(error?.message ?? error) };
850
+ }
851
+
852
+ /* 标记该备份已消费:重命名加 .done,之后「最新备份」指向更早的、还没回滚过的那次。
853
+ * 注意:pre-rollback 快照在备份目录里,重命名后它跟着一起搬到 .done 里,
854
+ * 仍然可以取出来反悔。 */
855
+ let backupArchived = null;
752
856
  try {
753
857
  const done = `${backupRoot}.done`;
754
858
  fs.renameSync(backupRoot, done);
755
- return {
756
- ok: true,
757
- backup: backupRoot,
758
- backupArchived: done,
759
- restored,
760
- removed,
761
- unlinked: relinked,
762
- restartRequired: true,
763
- };
859
+ backupArchived = done;
764
860
  } catch {
765
861
  /* 重命名失败不影响回滚结果 */
766
862
  }
767
863
 
768
- // 回滚改了配置和插件目录,扫描缓存失效
769
- clearScanCache();
770
-
771
864
  return {
772
865
  ok: true,
773
866
  backup: backupRoot,
867
+ backupArchived,
868
+ mode,
774
869
  restored,
775
870
  removed,
776
871
  unlinked: relinked,
872
+ // 回滚前把当时的配置另存了一份,路径给出来方便反悔
873
+ preRollbackDir: preRollbackFiles.length ? preRollbackDir : null,
874
+ preRollbackFiles,
875
+ // 回滚后自动对账结果(补了哪些链接、清了哪些僵尸声明)
876
+ repair,
777
877
  restartRequired: true,
778
878
  };
779
879
  }
package/lib/repair.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 修复 profile 里「配置说有、磁盘上没有」的插件。
3
+ *
4
+ * 为什么需要:回滚会把 package.json 恢复成某份快照,那份快照可能声明了
5
+ * 某个插件,而它的 node_modules 链接(甚至目录)已经在更早的回滚里被删掉。
6
+ * 结果 dsh 启动时报:
7
+ *
8
+ * cannot resolve profile bundle "dsh-pet" ... run 'dsh plugin install'
9
+ *
10
+ * 而点「卸载」又会触发 pnpm 安装(可能因为 store 版本不匹配直接失败)。
11
+ *
12
+ * 这里做的是把两边对齐:
13
+ * 目录在、链接缺 → 补链接(插件能正常用)
14
+ * 目录和链接都缺 → 从 dependencies / bundles 里移除(不留僵尸条目)
15
+ * 官方包(@deepseek-ai/*)→ 从 dsh 安装目录解析,永远跳过
16
+ *
17
+ * @module dsh-plugin-pack/lib/repair
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ import { readJson, writeJson, ensureDir, isDir } from './util.js';
23
+ import { clearScanCache } from './scan.js';
24
+
25
+ /** 这个包是不是宿主自带(从 dsh 安装目录解析)。 */
26
+ function isOfficial(name) {
27
+ return String(name).startsWith('@deepseek-ai/');
28
+ }
29
+
30
+ /** 把 `link:./local-plugins/xxx` 里的目录段取出来;不是这种写法返回 null。 */
31
+ function localSegment(spec) {
32
+ const text = String(spec ?? '');
33
+ if (!text.startsWith('link:')) return null;
34
+ const rel = text.slice('link:'.length).replace(/\\/g, '/');
35
+ const match = /(?:^|\/)local-plugins\/([^/]+)\/?$/.exec(rel);
36
+ return match ? match[1] : null;
37
+ }
38
+
39
+ /** node_modules 里这个包名的路径(scoped 要分两级)。 */
40
+ function linkPathOf(profileDir, name) {
41
+ return path.join(profileDir, 'node_modules', ...String(name).split('/'));
42
+ }
43
+
44
+ /** 指向目标目录建一个 junction / symlink。 */
45
+ function makeLink(linkPath, target) {
46
+ ensureDir(path.dirname(linkPath));
47
+ try {
48
+ fs.symlinkSync(target, linkPath, 'junction');
49
+ return true;
50
+ } catch {
51
+ try {
52
+ fs.symlinkSync(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 对齐 profile 的声明与实际磁盘。
62
+ *
63
+ * @param {object} options
64
+ * @param {string} options.profileDir
65
+ * @param {boolean} [options.dryRun] 只算不改
66
+ * @returns {{ ok: boolean, linked: string[], removed: string[], unlinked: string[], warnings: string[] }}
67
+ */
68
+ export function repairProfile({ profileDir, dryRun = false }) {
69
+ const profile = path.resolve(profileDir);
70
+ const pkgFile = path.join(profile, 'package.json');
71
+ const pkg = readJson(pkgFile, null);
72
+ if (!pkg || typeof pkg !== 'object') {
73
+ const error = new Error(`profile 里没有 package.json: ${pkgFile}`);
74
+ error.code = 'BAD_PROFILE';
75
+ throw error;
76
+ }
77
+
78
+ pkg.dependencies = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
79
+ pkg.dsh = pkg.dsh && typeof pkg.dsh === 'object' ? pkg.dsh : {};
80
+ pkg.dsh.profile = pkg.dsh.profile && typeof pkg.dsh.profile === 'object' ? pkg.dsh.profile : {};
81
+ const bundles = Array.isArray(pkg.dsh.profile.bundles) ? pkg.dsh.profile.bundles : [];
82
+
83
+ const linked = [];
84
+ const removed = [];
85
+ const unlinked = [];
86
+ const warnings = [];
87
+
88
+ const dropNames = new Set();
89
+
90
+ for (const [name, spec] of Object.entries(pkg.dependencies)) {
91
+ if (isOfficial(name)) continue; // 宿主自带,不碰
92
+ const seg = localSegment(spec);
93
+ if (!seg) continue; // 只处理本工具自己写的那种 link
94
+
95
+ const dir = path.join(profile, 'local-plugins', seg);
96
+ const linkPath = linkPathOf(profile, name);
97
+ const dirOk = isDir(dir);
98
+ const linkOk = fs.existsSync(linkPath);
99
+
100
+ if (dirOk && !linkOk) {
101
+ // 目录在、链接缺 → 补上,插件恢复可用
102
+ if (!dryRun && makeLink(linkPath, dir)) linked.push(name);
103
+ else if (dryRun) linked.push(name);
104
+ else warnings.push(`补链接失败: ${name}`);
105
+ continue;
106
+ }
107
+
108
+ if (!dirOk) {
109
+ // 目录没了 → 不论链接在不在,这条声明都是僵尸
110
+ if (linkOk) {
111
+ if (!dryRun) {
112
+ try {
113
+ fs.unlinkSync(linkPath);
114
+ unlinked.push(name);
115
+ } catch {
116
+ try {
117
+ fs.rmSync(linkPath, { recursive: true, force: true });
118
+ unlinked.push(name);
119
+ } catch {
120
+ /* 删不掉就留着,至少别写进删名单 */
121
+ }
122
+ }
123
+ } else {
124
+ unlinked.push(name);
125
+ }
126
+ }
127
+ dropNames.add(name);
128
+ }
129
+ }
130
+
131
+ if (dropNames.size) {
132
+ for (const name of dropNames) {
133
+ delete pkg.dependencies[name];
134
+ removed.push(name);
135
+ }
136
+ pkg.dsh.profile.bundles = bundles.filter((name) => !dropNames.has(name));
137
+ }
138
+
139
+ if (!dryRun && (linked.length || removed.length)) {
140
+ writeJson(pkgFile, pkg);
141
+ // 配置和磁盘刚对齐,扫描缓存必须失效
142
+ clearScanCache();
143
+ }
144
+
145
+ return { ok: true, linked, removed, unlinked: unlinked, warnings };
146
+ }
package/lib/server.js CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  import { runJob, listJobs, getJob, runningJobs, clearFinished, openWaiting, getWaiting, listWaiting, cancelWaiting } from './jobs.js';
41
41
  import { fetchBundleFrom, parseSource } from './fetch-bundle.js';
42
42
  import { setPluginsEnabled, getEnabledState } from './enable.js';
43
+ import { repairProfile } from './repair.js';
43
44
  import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, humanBytes } from './home.js';
44
45
  import { readJson, writeJson, isDir, isFile } from './util.js';
45
46
 
@@ -737,6 +738,35 @@ export function mountRoutes(ctx, config = {}) {
737
738
  'plugin-pack: enable',
738
739
  );
739
740
 
741
+ /* 对账修复:把 package.json 与实际磁盘对齐。
742
+ * POST /plugin-pack/repair { dryRun }
743
+ *
744
+ * 解决"配置里有、磁盘上没有"导致 dsh 报
745
+ * cannot resolve profile bundle "xxx" 的情况:
746
+ * - 目录在、链接缺 → 补链接(插件恢复可用)
747
+ * - 目录和链接都缺 → 从 dependencies/bundles 移除(清僵尸条目)
748
+ */
749
+ register(
750
+ {
751
+ kind: 'exact',
752
+ path: `${PREFIX}/repair`,
753
+ handler: async (request, response) => {
754
+ if (request.method !== 'POST') {
755
+ sendJson(response, 405, { ok: false, error: '只支持 POST' });
756
+ return;
757
+ }
758
+ const body = await readJsonBody(request).catch(() => ({}));
759
+ const local = contextOf(config);
760
+ const result = repairProfile({
761
+ profileDir: local.profileDir,
762
+ dryRun: Boolean(body.dryRun),
763
+ });
764
+ sendJson(response, 200, result);
765
+ },
766
+ },
767
+ 'plugin-pack: repair',
768
+ );
769
+
740
770
  /* 待决策任务:导入遇到重名时登记,界面切走再回来靠它捡回弹窗。
741
771
  * GET /plugin-pack/waiting 列出待决策任务(含冲突列表)
742
772
  * POST /plugin-pack/waiting/cancel 取消(用户切走/关页面/点取消)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-pack",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "DeepSeek Harness 插件整合包:一键把当前 profile 的第三方插件连依赖和配置导出成 zip(内部每个插件是标准 npm tgz),换机/换 profile 拖进去就能还原。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",