dsh-config-manager 0.1.59 → 0.1.61
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 +6 -4
- package/README.zh-CN.md +4 -3
- package/lib/adapters/file-collection.js +7 -2
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/plugin-files.js +7 -4
- package/lib/adapters/plugins.d.ts +10 -0
- package/lib/adapters/plugins.js +191 -4
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/test-helpers.d.ts +2 -0
- package/lib/adapters/test-helpers.js +2 -0
- package/lib/client.d.ts +218 -2
- package/lib/client.js +1704 -744
- package/lib/core/analyzer.d.ts +3 -1
- package/lib/core/analyzer.js +15 -9
- package/lib/core/backup-plan.js +26 -32
- package/lib/core/backup.d.ts +13 -2
- package/lib/core/backup.js +34 -6
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/journal.d.ts +3 -0
- package/lib/core/messages.d.ts +22 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +1 -0
- package/lib/core/plugin-cli.d.ts +1 -1
- package/lib/core/plugin-cli.js +35 -0
- package/lib/core/recovery-orchestrator.d.ts +23 -0
- package/lib/core/recovery-orchestrator.js +47 -2
- package/lib/core/types.d.ts +8 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.js +408 -32
- package/lib/market/prepare.js +5 -0
- package/lib/market/security.js +10 -0
- package/lib/schema/config.js +25 -0
- package/lib/schema/types.d.ts +16 -0
- package/lib/sync/autosync-scheduler.js +22 -0
- package/lib/sync/backup-scheduler.d.ts +3 -1
- package/lib/sync/backup-scheduler.js +19 -7
- package/lib/sync/layout.js +5 -0
- package/lib/sync/snapshot-crypto.d.ts +14 -1
- package/lib/sync/snapshot-crypto.js +43 -1
- package/lib/sync/snapshot-json.js +7 -1
- package/lib/sync/sync-engine.d.ts +37 -2
- package/lib/sync/sync-engine.js +102 -7
- package/lib/sync/sync-session.d.ts +8 -0
- package/lib/sync/transport.d.ts +18 -0
- package/lib/sync/transport.js +7 -0
- package/lib/ui/i18n.d.ts +2 -0
- package/lib/ui/i18n.js +4 -0
- package/lib/ui/types.d.ts +26 -0
- package/lib/utils/env-lock.d.ts +17 -0
- package/lib/utils/env-lock.js +60 -3
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/package.json +1 -1
- package/src/adapters/file-collection.ts +8 -2
- package/src/adapters/files.test.ts +19 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/plugin-files.ts +8 -4
- package/src/adapters/plugins.test.ts +129 -0
- package/src/adapters/plugins.ts +185 -5
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/test-helpers.ts +2 -0
- package/src/client/ConfigManagerSection.tsx +37 -2
- package/src/client/client-types.ts +2 -0
- package/src/client/index.ts +4 -1
- package/src/client/lifecycle/LifecyclePanel.tsx +524 -0
- package/src/client/lifecycle/lifecycle-api.ts +249 -0
- package/src/client/locales.ts +106 -0
- package/src/client/recovery/RecoveryPanel.tsx +53 -1
- package/src/client/recovery/recovery-api.ts +12 -1
- package/src/client/recovery/recovery-locales.ts +19 -0
- package/src/client/recovery/recovery-view.test.ts +23 -0
- package/src/client/recovery/recovery-view.ts +19 -3
- package/src/client/run-store.ts +8 -1
- package/src/client/snapshots/SnapshotsPanel.tsx +4 -1
- package/src/client/sync/SyncSettingsView.tsx +1 -0
- package/src/client/sync/history-model.test.ts +10 -0
- package/src/client/sync/history-model.ts +4 -0
- package/src/client/sync/sync-locales.ts +4 -4
- package/src/client/sync/sync-push-preview.test.ts +10 -1
- package/src/client/sync/sync-view-v2.test.ts +30 -2
- package/src/client/sync/sync-view.ts +23 -4
- package/src/core/analyzer.ts +15 -9
- package/src/core/backup-plan.ts +30 -27
- package/src/core/backup.ts +35 -6
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/journal.ts +3 -0
- package/src/core/messages.ts +44 -0
- package/src/core/phase1-wiring.test.ts +139 -0
- package/src/core/phase3-host.ts +1 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +8 -0
- package/src/core/plugin-cli.fs.test.ts +31 -0
- package/src/core/plugin-cli.test.ts +11 -0
- package/src/core/plugin-cli.ts +34 -0
- package/src/core/recovery-orchestrator.test.ts +180 -0
- package/src/core/recovery-orchestrator.ts +60 -2
- package/src/core/types.ts +8 -0
- package/src/core/undo.ts +116 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.ts +355 -30
- package/src/market/market.test.ts +44 -0
- package/src/market/prepare.test.ts +47 -0
- package/src/market/prepare.ts +8 -1
- package/src/market/security.ts +10 -0
- package/src/schema/config.ts +24 -0
- package/src/schema/types.ts +17 -0
- package/src/sync/autosync-scheduler.test.ts +70 -3
- package/src/sync/autosync-scheduler.ts +21 -0
- package/src/sync/backup-scheduler.test.ts +88 -1
- package/src/sync/backup-scheduler.ts +19 -7
- package/src/sync/layout.ts +5 -0
- package/src/sync/snapshot-crypto.ts +49 -2
- package/src/sync/snapshot-json.ts +9 -2
- package/src/sync/sync-credentials.test.ts +292 -0
- package/src/sync/sync-engine.ts +131 -10
- package/src/sync/sync-session.ts +8 -0
- package/src/sync/transport.ts +24 -0
- package/src/ui/i18n.ts +4 -0
- package/src/ui/types.ts +28 -0
- package/src/utils/env-lock.test.ts +53 -0
- package/src/utils/env-lock.ts +61 -2
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ Move your complete DeepSeek Harness setup without manually reinstalling plugins,
|
|
|
52
52
|
|
|
53
53
|
### Sync DSH configuration across machines
|
|
54
54
|
|
|
55
|
-
Keep portable configuration synchronized between machines through a private Git repository or WebDAV — secrets
|
|
55
|
+
Keep portable configuration synchronized between machines through a private Git repository or WebDAV — secrets do not sync by default (the payload is run through the SecretScanner); check "Export secrets" with an encryption password and `~/.dsh/.credentials.yaml` travels as scrypt + AES-256-GCM ciphertext inside the encrypted snapshot, so another machine can restore the credentials while the remote (Git host / WebDAV provider) only ever sees ciphertext.
|
|
56
56
|
|
|
57
57
|
### Schedule automatic full backups
|
|
58
58
|
|
|
@@ -76,7 +76,7 @@ Browse the built-in official market for ready-made configurations (model provide
|
|
|
76
76
|
| 🔒 | **Secret safety** | API Keys are not exported by default — non-encrypted imports ask you to re-enter; encrypted backups restore them with the password |
|
|
77
77
|
| ↩️ | **Automatic rollback** | Failed import restores everything automatically |
|
|
78
78
|
| 📸 | **Snapshot restore** | Undo an import: whole-file restore + uninstall added plugins (CLI & GUI) |
|
|
79
|
-
| 🔄 | **Remote Sync** | Push/pull portable config via **Git private repo or WebDAV** (secrets
|
|
79
|
+
| 🔄 | **Remote Sync** | Push/pull portable config via **Git private repo or WebDAV** (secrets do not sync by default; encrypted snapshots can optionally carry encrypted credentials) |
|
|
80
80
|
| ⏰ | **Scheduled backups** | Full backup on a fixed cadence (6h / 12h / 24h / 7d) — set-and-forget, secrets never included |
|
|
81
81
|
| 🛒 | **Config Marketplace** | Browse & one-click install community configs — supply-chain warnings + per-section approval |
|
|
82
82
|
| 🗂️ | **Profiles** | Save multiple setups (Work / Personal) and switch anytime — preview + auto-backup + rollback |
|
|
@@ -248,6 +248,7 @@ Push / pull your portable config between machines through **either of two channe
|
|
|
248
248
|
- **WebDAV auth** uses HTTP Basic: the `username` is stored in the config and may be echoed back into the UI, while the `password` is read live from the DSH credentials slot `DSH_CONFIG_MANAGER_SYNC_WEBDAV_PASSWORD` — it never appears in any sync file or log.
|
|
249
249
|
- **Plugins auto-install**: when pulling diffs, plugins that are new in the backup are **installed automatically** on confirm — no manual per-item ticking in the diff list. Only **version-conflict** plugins still ask you to pick "Keep Current / Use Imported".
|
|
250
250
|
- **Push preview before uploading** — the Push button first shows a read-only preview of what will be sent (sections + per-section counts + changed-vs-baseline markers, first-baseline notice) and only writes the remote after you confirm.
|
|
251
|
+
- **Secrets do not sync by default**: every section goes through the `SecretScanner` (sensitive field values stripped) and the credential section is structurally excluded. With "Export secrets" checked and an encryption password set, `~/.dsh/.credentials.yaml` travels as scrypt + AES-256-GCM ciphertext in a **separate credentials payload** of the encrypted snapshot (never inside any section); the receiving side decrypts it into per-ref "credential migration" items that are written back to the local credential store (`credentials.set`) only after you confirm. The password is memory-only and never persisted or logged; **auto sync never carries credentials** (it has no password and skips encrypted snapshots).
|
|
251
252
|
|
|
252
253
|
### 🛒 Configuration Marketplace
|
|
253
254
|
|
|
@@ -395,14 +396,15 @@ Symptoms and the fix:
|
|
|
395
396
|
| Symptom | Meaning | Fix |
|
|
396
397
|
|---|---|---|
|
|
397
398
|
| 「另一个任务正在运行,请稍后重试。」 / "Another task is running, please retry." | A live operation holds the lock | Just wait — it clears itself |
|
|
398
|
-
| 「检测到上次异常退出残留的配置锁…重试或重启 DSH 均无效」 / relayed in the log as `自动同步已跳过` | The owner process is **proven dead** (leftover lock) | Run the command below, or use GUI **Recovery →
|
|
399
|
+
| 「检测到上次异常退出残留的配置锁…重试或重启 DSH 均无效」 / relayed in the log as `自动同步已跳过` | The owner process is **proven dead** (leftover lock) | Run the command below, or use GUI **Recovery → 事故恢复 → 回收残留锁** |
|
|
400
|
+
| Same message, but the owner PID was **reused** by an unrelated process (common on Windows) | The heartbeat has been stale for a very long time, so the lock is still classified as a leftover one | Same fix — a heartbeat that has not been refreshed for far longer than the stale window is now reclaimable |
|
|
399
401
|
|
|
400
402
|
```bash
|
|
401
403
|
# safe: it inspects first and refuses unless the owner is proven dead (a live lock is never touched)
|
|
402
404
|
dsh-config-manager recover-stale-lock
|
|
403
405
|
```
|
|
404
406
|
|
|
405
|
-
**Plugins installed but the backup doesn't see them?** Check **Settings → DSH Config Manager → About**: it now shows which directory / profile the plugin list was read from, and how many plugins were detected. The list comes from `$DSH_HOME/profiles/<profile>/package.json` → `dependencies
|
|
407
|
+
**Plugins installed but the backup doesn't see them?** Check **Settings → DSH Config Manager → About**: it now shows which directory / profile the plugin list was read from, and how many plugins were detected. The list comes from `$DSH_HOME/profiles/<profile>/package.json` → `dependencies` (plus anything declared in `dsh.profile.bundles` that is not a dependency), where `<profile>` is resolved as `config.profile` → `--profile` → `web`. If the shown path is not the profile you installed into (Desktop builds may use a different profile or a different `DSH_HOME`), that is the cause — align `--profile` / `DSH_HOME` with it.
|
|
406
408
|
|
|
407
409
|
### 🌐 Behind a proxy? (GitHub login / sync)
|
|
408
410
|
|
package/README.zh-CN.md
CHANGED
|
@@ -13,7 +13,7 @@ DSH Config Manager 是一个 DeepSeek Harness 配置备份与迁移插件:一
|
|
|
13
13
|
- Profiles(配置档案)
|
|
14
14
|
- Workspace / AGENTS.md
|
|
15
15
|
|
|
16
|
-
把当前 DeepSeek Harness 环境导出为可移植备份,在另一台电脑上一键恢复;也支持通过 Git / WebDAV
|
|
16
|
+
把当前 DeepSeek Harness 环境导出为可移植备份,在另一台电脑上一键恢复;也支持通过 Git / WebDAV 跨机同步(密钥默认不同步,勾选「导出密钥」并加密后可随加密快照迁移);还能通过内置**配置市场**浏览、一键安装社区分享的现成配置。
|
|
17
17
|
|
|
18
18
|
[English](README.md) · [简体中文](README.zh-CN.md)
|
|
19
19
|
|
|
@@ -52,7 +52,7 @@ DSH 是你的 AI 助手工作台,里面存着你的各种设置:模型配置
|
|
|
52
52
|
|
|
53
53
|
### 在多台电脑间同步 DSH 配置
|
|
54
54
|
|
|
55
|
-
通过私有 Git 仓库或 WebDAV
|
|
55
|
+
通过私有 Git 仓库或 WebDAV 在多台电脑间保持可移植配置同步——密钥默认不参与同步(同步载荷会被 SecretScanner 剥离);勾选「导出密钥」并设置加密密码后,`~/.dsh/.credentials.yaml` 会以 scrypt + AES-256-GCM 密文随加密快照上行,换机后可随同步写回本机凭据,远端(Git 仓库 / WebDAV 服务商)全程只见密文。
|
|
56
56
|
|
|
57
57
|
### 定时自动全量备份
|
|
58
58
|
|
|
@@ -76,7 +76,7 @@ DSH 是你的 AI 助手工作台,里面存着你的各种设置:模型配置
|
|
|
76
76
|
| 🔒 | **密钥安全** | API Key 默认不导出;非加密导入后提醒重新填写,加密备份用密码解锁恢复 |
|
|
77
77
|
| ↩️ | **自动回滚** | 导入失败自动恢复原样,不会弄坏现有配置 |
|
|
78
78
|
| 📸 | **快照恢复** | 撤销一次导入:整文件还原 + 卸载新增插件(CLI 与 GUI 均支持) |
|
|
79
|
-
| 🔄 | **远程同步** | 通过 **Git 私有仓库或 WebDAV** 推送 /
|
|
79
|
+
| 🔄 | **远程同步** | 通过 **Git 私有仓库或 WebDAV** 推送 / 拉取可移植配置(密钥默认不参与同步;加密快照可选携带密文凭据) |
|
|
80
80
|
| ⏰ | **定时全量备份** | 按固定周期(6h / 12h / 24h / 7d)自动全量备份,一劳永逸,密钥永不包含 |
|
|
81
81
|
| 🛒 | **配置市场** | 浏览并一键安装社区分享的配置——供应链警示 + 逐分区批准 |
|
|
82
82
|
| 🗂️ | **配置档案 Profiles** | 保存多套配置(工作 / 个人),随时切换 |
|
|
@@ -243,6 +243,7 @@ dsh plugin --profile web add dsh-config-manager@latest
|
|
|
243
243
|
- **切换通道重新开始**:Git 与 WebDAV 的快照 / 共同祖先**互不共享**。切换通道后,同步从新远端的空基线重新开始——请先推送一个新快照。
|
|
244
244
|
- **WebDAV 认证**采用 HTTP Basic:`username` 存配置、可在界面回显;`password` 则实时从 DSH credentials 槽位 `DSH_CONFIG_MANAGER_SYNC_WEBDAV_PASSWORD` 读取——绝不出现于任何同步文件或日志。
|
|
245
245
|
- **插件自动安装**:拉取差异时,备份里新增的插件会在确认导入时**自动安装**,无需在差异列表里逐项手动勾选;只有**版本冲突**的插件仍需要你决定「保留当前 / 采用备份」。
|
|
246
|
+
- **密钥默认不同步**:同步载荷逐分区过 `SecretScanner`(敏感字段值剥离),凭据分区结构性排除。勾选「导出密钥」并设置加密密码后,`~/.dsh/.credentials.yaml` 以 scrypt + AES-256-GCM 密文作为**独立凭据载荷**随加密快照上行(不放进任何分区);拉取侧解密后生成逐条「凭据迁移」项,你确认采纳才写回本机凭据(`credentials.set`)。加密密码仅存内存,绝不落盘 / 落日志;**自动同步恒不含凭据**(无密码可用,遇到加密快照会跳过)。
|
|
246
247
|
|
|
247
248
|
### 🛒 配置市场(Marketplace)
|
|
248
249
|
|
|
@@ -11,17 +11,21 @@ import path from 'node:path';
|
|
|
11
11
|
import { sha256Hex } from '../utils/hashing.js';
|
|
12
12
|
import { isReservedInternalRel, normalizePath } from '../utils/paths.js';
|
|
13
13
|
import { msgOf, zhMsg } from '../core/messages.js';
|
|
14
|
+
import { linkWarnings, listFilesDetailed } from './link-report.js';
|
|
14
15
|
export class FileCollectionAdapter {
|
|
15
16
|
async export(ctx, _options) {
|
|
16
17
|
const files = [];
|
|
17
18
|
const warnings = [];
|
|
18
|
-
|
|
19
|
+
// issue #37:用「跟随 junction/符号链接」的遍历,并把跟随/跳过的链接写进告警——
|
|
20
|
+
// 此前链接目录及其全部内容被静默排除,备份仍报成功。
|
|
21
|
+
let listing = { paths: [], skippedLinks: [], followedLinks: 0, unreadableDirs: [] };
|
|
19
22
|
try {
|
|
20
|
-
|
|
23
|
+
listing = await listFilesDetailed(ctx.fs, this.baseDir);
|
|
21
24
|
}
|
|
22
25
|
catch {
|
|
23
26
|
// 目录不存在视为空
|
|
24
27
|
}
|
|
28
|
+
const rels = listing.paths;
|
|
25
29
|
for (const rel of rels) {
|
|
26
30
|
const data = await ctx.fs.readFile(rel);
|
|
27
31
|
const relPath = this.baseDir === '' ? rel : rel.replace(new RegExp(`^${escapeRegExp(this.baseDir)}[\\/]`), '');
|
|
@@ -29,6 +33,7 @@ export class FileCollectionAdapter {
|
|
|
29
33
|
}
|
|
30
34
|
if (rels.length === 0)
|
|
31
35
|
warnings.push(msgOf(ctx)('adapter.dirEmpty', { type: this.displayName }));
|
|
36
|
+
warnings.push(...linkWarnings(msgOf(ctx), this.displayName, listing));
|
|
32
37
|
return {
|
|
33
38
|
sectionId: this.id,
|
|
34
39
|
data: { version: 1, files },
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件类分区的「链接目录」报告(issue #37)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:备份静默跳过 junction/符号链接,用户拿到的仍是「成功」,却少了整块内容。
|
|
5
|
+
* 修复分两步:① `utils/recursive-walk.ts` 跟随链接收集内容;② 本模块把「跟随了什么、
|
|
6
|
+
* 跳过了什么、为什么跳过」变成备份报告里可见的告警 —— 缺了 ②,用户依然无从察觉缺失。
|
|
7
|
+
*
|
|
8
|
+
* 纯函数(除 listFilesDetailed 的 IO 委托外),两个 adapter 共用,避免文案再次漂移。
|
|
9
|
+
*/
|
|
10
|
+
import type { FileSystemFacade } from '../core/types.ts';
|
|
11
|
+
import type { MsgFunc } from '../core/messages.ts';
|
|
12
|
+
import type { RecursiveListing } from '../utils/recursive-walk.ts';
|
|
13
|
+
/** 取遍历结果:宿主未实现 listRecursiveDetailed(旧版/测试 mock)时回退,行为与旧版一致。 */
|
|
14
|
+
export declare function listFilesDetailed(fs: FileSystemFacade, dir: string): Promise<RecursiveListing>;
|
|
15
|
+
/** 把遍历结果转成告警行(无链接时返回空数组 —— 不制造噪音)。 */
|
|
16
|
+
export declare function linkWarnings(msg: MsgFunc, type: string, listing: RecursiveListing): string[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** 跳过原因 → 消息 key(与 SkippedLink.reason 一一对应,穷尽映射) */
|
|
2
|
+
const REASON_KEY = {
|
|
3
|
+
loop: 'adapter.linkReason.loop',
|
|
4
|
+
'outside-home': 'adapter.linkReason.outsideHome',
|
|
5
|
+
broken: 'adapter.linkReason.broken',
|
|
6
|
+
unreadable: 'adapter.linkReason.unreadable',
|
|
7
|
+
'too-deep': 'adapter.linkReason.tooDeep',
|
|
8
|
+
};
|
|
9
|
+
/** 单条原因下最多列出的路径数(其余折叠为计数,避免把报告撑爆) */
|
|
10
|
+
const MAX_PATHS_PER_REASON = 5;
|
|
11
|
+
/** 取遍历结果:宿主未实现 listRecursiveDetailed(旧版/测试 mock)时回退,行为与旧版一致。 */
|
|
12
|
+
export async function listFilesDetailed(fs, dir) {
|
|
13
|
+
if (fs.listRecursiveDetailed !== undefined)
|
|
14
|
+
return fs.listRecursiveDetailed(dir);
|
|
15
|
+
return { paths: await fs.listRecursive(dir), skippedLinks: [], followedLinks: 0, unreadableDirs: [] };
|
|
16
|
+
}
|
|
17
|
+
/** 把遍历结果转成告警行(无链接时返回空数组 —— 不制造噪音)。 */
|
|
18
|
+
export function linkWarnings(msg, type, listing) {
|
|
19
|
+
const out = [];
|
|
20
|
+
if (listing.followedLinks > 0) {
|
|
21
|
+
out.push(msg('adapter.linksFollowed', { type, count: String(listing.followedLinks) }));
|
|
22
|
+
}
|
|
23
|
+
if (listing.skippedLinks.length > 0) {
|
|
24
|
+
const byReason = new Map();
|
|
25
|
+
for (const s of listing.skippedLinks) {
|
|
26
|
+
const arr = byReason.get(s.reason) ?? [];
|
|
27
|
+
arr.push(s.path);
|
|
28
|
+
byReason.set(s.reason, arr);
|
|
29
|
+
}
|
|
30
|
+
const detail = [...byReason.entries()].map(([reason, paths]) => {
|
|
31
|
+
const shown = paths.slice(0, MAX_PATHS_PER_REASON).join(', ');
|
|
32
|
+
const more = paths.length > MAX_PATHS_PER_REASON ? ` (+ ${paths.length - MAX_PATHS_PER_REASON})` : '';
|
|
33
|
+
return `${msg(REASON_KEY[reason])} ${paths.length} 个: ${shown}${more}`;
|
|
34
|
+
}).join(';');
|
|
35
|
+
out.push(msg('adapter.linksSkipped', { type, count: String(listing.skippedLinks.length), detail }));
|
|
36
|
+
}
|
|
37
|
+
if (listing.unreadableDirs.length > 0) {
|
|
38
|
+
const shown = listing.unreadableDirs.slice(0, MAX_PATHS_PER_REASON).join(', ');
|
|
39
|
+
const more = listing.unreadableDirs.length > MAX_PATHS_PER_REASON
|
|
40
|
+
? ` (+ ${listing.unreadableDirs.length - MAX_PATHS_PER_REASON})` : '';
|
|
41
|
+
out.push(msg('adapter.dirsUnreadable', {
|
|
42
|
+
type, count: String(listing.unreadableDirs.length), detail: `${shown}${more}`,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=link-report.js.map
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* 导入时按同一相对路径写回原位置。默认关闭(defaultIncluded=false,用户显式勾选才导出)。
|
|
8
8
|
*/
|
|
9
9
|
import { sha256Hex } from '../utils/hashing.js';
|
|
10
|
-
import { zhMsg } from '../core/messages.js';
|
|
10
|
+
import { msgOf, zhMsg } from '../core/messages.js';
|
|
11
|
+
import { linkWarnings, listFilesDetailed } from './link-report.js';
|
|
11
12
|
import { isPathSafe, isReservedInternalRel } from '../utils/paths.js';
|
|
12
13
|
export const DEFAULT_PLUGIN_FILE_WHITELIST = ['dsh-ssh.json', 'pet.json'];
|
|
13
14
|
export class PluginFilesAdapter {
|
|
@@ -40,14 +41,16 @@ export class PluginFilesAdapter {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
// 2) 约定配置目录递归收集(相对 ~/.dsh 根的完整路径;与白名单文件去重)
|
|
44
|
+
// issue #37:与 skills 等同一条遍历(跟随 junction/符号链接 + 跳过留痕)
|
|
45
|
+
let listing = { paths: [], skippedLinks: [], followedLinks: 0, unreadableDirs: [] };
|
|
43
46
|
if (this.collectDir !== undefined) {
|
|
44
|
-
let rels = [];
|
|
45
47
|
try {
|
|
46
|
-
|
|
48
|
+
listing = await listFilesDetailed(ctx.fs, this.collectDir);
|
|
47
49
|
}
|
|
48
50
|
catch {
|
|
49
51
|
// 目录不存在视为空
|
|
50
52
|
}
|
|
53
|
+
const rels = listing.paths;
|
|
51
54
|
for (const rel of rels) {
|
|
52
55
|
if (seen.has(rel))
|
|
53
56
|
continue;
|
|
@@ -64,7 +67,7 @@ export class PluginFilesAdapter {
|
|
|
64
67
|
sectionId: 'pluginFiles',
|
|
65
68
|
data: { version: 1, files },
|
|
66
69
|
counts: { files: files.length },
|
|
67
|
-
warnings:
|
|
70
|
+
warnings: linkWarnings(msgOf(ctx), this.displayName, listing),
|
|
68
71
|
};
|
|
69
72
|
}
|
|
70
73
|
async analyzeImport(data, ctx) {
|
|
@@ -12,8 +12,12 @@ export type LocalPluginPackHook = (plugins: PluginEntry[], ctx: HostContext) =>
|
|
|
12
12
|
export declare const LOCAL_TARBALL_CACHE_REL = "dsh-config-manager/local-plugins";
|
|
13
13
|
/** 归档内相对路径 → 缓存目录下的安全文件名(拒绝任何路径穿越)。 */
|
|
14
14
|
export declare function localTarballCacheName(relativePath: string): string;
|
|
15
|
+
/** profile 目录相对 $DSH_HOME 的路径(patches/ 与 pnpm-workspace.yaml 都挂在这里)。 */
|
|
16
|
+
export declare const PNPM_PROFILE_DIR: (profile: string | undefined) => string;
|
|
15
17
|
/** pnpm-workspace.yaml 相对 $DSH_HOME 的路径(plugins 分区内按「插件安装配置」管理)。 */
|
|
16
18
|
export declare const PNPM_WORKSPACE_REL: (profile: string | undefined) => string;
|
|
19
|
+
/** 单个 patch 文件随备份迁移的体积上限(issue #35;patch 是纯文本,超过说明放错了东西)。 */
|
|
20
|
+
export declare const MAX_PATCH_FILE_BYTES: number;
|
|
17
21
|
export declare class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
18
22
|
readonly id: 'plugins';
|
|
19
23
|
readonly displayName = "Plugins";
|
|
@@ -25,6 +29,12 @@ export declare class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
25
29
|
private readonly localPack;
|
|
26
30
|
constructor(selfName?: string, localPack?: LocalPluginPackHook);
|
|
27
31
|
export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<PluginsSection>>;
|
|
32
|
+
/**
|
|
33
|
+
* 导入后目标机**可能**存在的 patch 文件集合(相对 profile 目录)——issue #35。
|
|
34
|
+
* = 备份携带的 patchFiles ∪ 目标机本来就有的文件。只用于判定「声明能否被满足」,
|
|
35
|
+
* 不做任何写入(写入在 applyItem 内、且先于 pnpm-workspace.yaml)。
|
|
36
|
+
*/
|
|
37
|
+
private patchAvailability;
|
|
28
38
|
analyzeImport(data: PluginsSection, ctx: ImportContext): Promise<PlanItem[]>;
|
|
29
39
|
applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult>;
|
|
30
40
|
validate(data: PluginsSection, msg?: MsgFunc): Promise<ValidationResult>;
|
package/lib/adapters/plugins.js
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
* **不新增分区 id**);导入时把 spec 重写为 `file:<解包后的绝对路径>` 再交给官方安装通道。
|
|
12
12
|
*/
|
|
13
13
|
import { isDeepStrictEqual } from 'node:util';
|
|
14
|
+
import { sha256Hex } from '../utils/hashing.js';
|
|
14
15
|
import { installSpecFor, resolveProfileNameFromArgv } from '../core/plugin-cli.js';
|
|
15
16
|
import { isLocalPluginSpec, isPackedLocalSpec, LOCAL_PLUGIN_DIR } from '../core/local-plugin-pack.js';
|
|
16
17
|
import { msgOf, zhMsg } from '../core/messages.js';
|
|
18
|
+
import { isPathSafe, normalizePath } from '../utils/paths.js';
|
|
19
|
+
import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.js';
|
|
20
|
+
import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.js';
|
|
17
21
|
export const USER_PATCH_FILE = 'cordis.patch.yml';
|
|
18
22
|
/** tarball 字节 → base64(零依赖,避免 Buffer 在浏览器侧类型问题) */
|
|
19
23
|
function bytesToBase64(data) {
|
|
@@ -64,8 +68,12 @@ async function writeLocalTarball(ctx, tarball) {
|
|
|
64
68
|
const abs = `${ctx.target.homeDir.replace(/[\\/]+$/, '')}/${rel}`;
|
|
65
69
|
return abs.replace(/\\/g, '/');
|
|
66
70
|
}
|
|
71
|
+
/** profile 目录相对 $DSH_HOME 的路径(patches/ 与 pnpm-workspace.yaml 都挂在这里)。 */
|
|
72
|
+
export const PNPM_PROFILE_DIR = (profile) => `profiles/${profile !== undefined && profile !== '' ? profile : 'web'}`;
|
|
67
73
|
/** pnpm-workspace.yaml 相对 $DSH_HOME 的路径(plugins 分区内按「插件安装配置」管理)。 */
|
|
68
|
-
export const PNPM_WORKSPACE_REL = (profile) =>
|
|
74
|
+
export const PNPM_WORKSPACE_REL = (profile) => `${PNPM_PROFILE_DIR(profile)}/pnpm-workspace.yaml`;
|
|
75
|
+
/** 单个 patch 文件随备份迁移的体积上限(issue #35;patch 是纯文本,超过说明放错了东西)。 */
|
|
76
|
+
export const MAX_PATCH_FILE_BYTES = 2 * 1024 * 1024;
|
|
69
77
|
/** patch 行 raw 是否由其他 adapter 管理(mcp-client 行 / systemPrompt / planMode 行)。
|
|
70
78
|
* 这些行的导入归 mcp.ts / prompts.ts,plugins 分区只负责普通用户行(启用/禁用/插入插件等)。 */
|
|
71
79
|
function isManagedElsewhere(raw) {
|
|
@@ -155,6 +163,46 @@ export class PluginsAdapter {
|
|
|
155
163
|
catch (err) {
|
|
156
164
|
warnings.push(msgOf(ctx)('adapter.pnpmReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
|
|
157
165
|
}
|
|
166
|
+
// issue #35:`patchedDependencies` 引用的 patches/** 必须与声明同进同出。
|
|
167
|
+
// 只搬 pnpm-workspace.yaml 文本会让目标机拿到「声明在、文件不在」的组合,
|
|
168
|
+
// 此后 pnpm 拒绝**一切** add(含插件安装)——实测 13/13 插件安装全灭。
|
|
169
|
+
let patchFiles;
|
|
170
|
+
if (pnpmWorkspace !== null && pnpmWorkspace !== '') {
|
|
171
|
+
const parsed = parsePnpmPatchedDependencies(pnpmWorkspace);
|
|
172
|
+
if (parsed.unsupported !== null) {
|
|
173
|
+
warnings.push(msgOf(ctx)('adapter.pwPatchesUnsupported', { line: parsed.unsupported }));
|
|
174
|
+
}
|
|
175
|
+
const collected = [];
|
|
176
|
+
for (const decl of parsed.declared) {
|
|
177
|
+
const rel = normalizePath(decl.path);
|
|
178
|
+
if (rel === '' || !isPathSafe(rel)) {
|
|
179
|
+
warnings.push(msgOf(ctx)('adapter.patchFileUnsafe', { name: decl.name, path: decl.path }));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const homeRel = `${PNPM_PROFILE_DIR(ctx.profile)}/${rel}`;
|
|
183
|
+
try {
|
|
184
|
+
if (!(await ctx.fs.exists(homeRel))) {
|
|
185
|
+
warnings.push(msgOf(ctx)('adapter.patchFileMissing', { name: decl.name, path: rel }));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const data = await ctx.fs.readFile(homeRel);
|
|
189
|
+
if (data.byteLength > MAX_PATCH_FILE_BYTES) {
|
|
190
|
+
warnings.push(msgOf(ctx)('adapter.patchFileTooLarge', {
|
|
191
|
+
name: decl.name, path: rel, limit: `${Math.floor(MAX_PATCH_FILE_BYTES / 1024)} KiB`,
|
|
192
|
+
}));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
collected.push({ relativePath: rel, base64: bytesToBase64(data) });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
warnings.push(msgOf(ctx)('adapter.patchFileReadFailed', {
|
|
199
|
+
name: decl.name, path: rel, reason: err instanceof Error ? err.message : String(err),
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (collected.length > 0)
|
|
204
|
+
patchFiles = collected;
|
|
205
|
+
}
|
|
158
206
|
// T1:本地源(link:/file:)插件打包。这些 spec 指向本机路径,换机后必然不可达
|
|
159
207
|
// (曾导致插件被静默丢失)。钩子由宿主注入;未注入 / 无本地源 / 单个失败一律不中断导出。
|
|
160
208
|
let localTarballs;
|
|
@@ -188,21 +236,88 @@ export class PluginsAdapter {
|
|
|
188
236
|
patch,
|
|
189
237
|
pnpmWorkspace,
|
|
190
238
|
...(localTarballs !== undefined ? { localTarballs } : {}),
|
|
239
|
+
...(patchFiles !== undefined ? { patchFiles } : {}),
|
|
191
240
|
},
|
|
192
241
|
counts: {
|
|
193
242
|
plugins: effectivePlugins.length,
|
|
194
243
|
patchLines: patch.length,
|
|
195
244
|
...(localTarballs !== undefined ? { localTarballs: localTarballs.length } : {}),
|
|
245
|
+
...(patchFiles !== undefined ? { patchFiles: patchFiles.length } : {}),
|
|
196
246
|
},
|
|
197
247
|
warnings,
|
|
198
248
|
};
|
|
199
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* 导入后目标机**可能**存在的 patch 文件集合(相对 profile 目录)——issue #35。
|
|
252
|
+
* = 备份携带的 patchFiles ∪ 目标机本来就有的文件。只用于判定「声明能否被满足」,
|
|
253
|
+
* 不做任何写入(写入在 applyItem 内、且先于 pnpm-workspace.yaml)。
|
|
254
|
+
*/
|
|
255
|
+
async patchAvailability(data, ctx) {
|
|
256
|
+
const set = new Set();
|
|
257
|
+
for (const pf of data.patchFiles ?? []) {
|
|
258
|
+
const rel = normalizePath(pf.relativePath);
|
|
259
|
+
if (rel !== '' && isPathSafe(rel))
|
|
260
|
+
set.add(rel);
|
|
261
|
+
}
|
|
262
|
+
const profileDir = PNPM_PROFILE_DIR(ctx.target.profile);
|
|
263
|
+
for (const decl of parsePnpmPatchedDependencies(data.pnpmWorkspace ?? '').declared) {
|
|
264
|
+
const rel = normalizePath(decl.path);
|
|
265
|
+
if (rel === '' || !isPathSafe(rel) || set.has(rel))
|
|
266
|
+
continue;
|
|
267
|
+
try {
|
|
268
|
+
if (await ctx.target.fs.exists(`${profileDir}/${rel}`))
|
|
269
|
+
set.add(rel);
|
|
270
|
+
}
|
|
271
|
+
catch { /* 读不到 = 视为不存在 */ }
|
|
272
|
+
}
|
|
273
|
+
return set;
|
|
274
|
+
}
|
|
200
275
|
async analyzeImport(data, ctx) {
|
|
201
276
|
const msg = ctx.msg;
|
|
202
277
|
const items = [];
|
|
203
278
|
// pnpm-workspace.yaml:先于插件安装写入(allowBuilds / minimumReleaseAgeExclude 需在
|
|
204
279
|
// pnpm add 时生效)。与目标不同 → Create/Update;无文件/内容一致 → Skip。
|
|
205
280
|
if (data.pnpmWorkspace !== undefined && data.pnpmWorkspace !== null && data.pnpmWorkspace !== '') {
|
|
281
|
+
// issue #35:patch 文件项必须**先于** pnpm-workspace.yaml。
|
|
282
|
+
// 原因有二:① 配置里的 patchedDependencies 只有文件已就位才可用,先写配置会留下
|
|
283
|
+
// 「声明在、文件未到」的窗口(中途中断 → 目标机 pnpm 从此拒绝一切 add);
|
|
284
|
+
// ② 它们是独立计划项(可被用户 keepCurrent 否决),配置项的写入不得越权替它们落盘。
|
|
285
|
+
// 作为计划项还有第三个作用:进入导入前快照(回滚可还原被覆盖的原文件)。
|
|
286
|
+
for (const pf of data.patchFiles ?? []) {
|
|
287
|
+
const rel = normalizePath(pf.relativePath);
|
|
288
|
+
if (rel === '' || !isPathSafe(rel))
|
|
289
|
+
continue;
|
|
290
|
+
const id = `plugins:patch:${rel}`;
|
|
291
|
+
const abs = `${PNPM_PROFILE_DIR(ctx.target.profile)}/${rel}`;
|
|
292
|
+
let current = null;
|
|
293
|
+
try {
|
|
294
|
+
current = await ctx.target.fs.exists(abs) ? await ctx.target.fs.readFile(abs) : null;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
current = null;
|
|
298
|
+
}
|
|
299
|
+
const target = { adapter: 'plugins', ref: `${PLUGIN_PATCH_REF_PREFIX}${rel}` };
|
|
300
|
+
if (current === null) {
|
|
301
|
+
items.push({
|
|
302
|
+
id, kind: 'Create', adapter: 'plugins',
|
|
303
|
+
description: msg('adapter.patchFileCreate', { path: rel }), severity: 'info', target,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
else if (sha256Hex(current) === sha256Hex(base64ToBytes(pf.base64))) {
|
|
307
|
+
items.push({ id, kind: 'Skip', adapter: 'plugins', description: msg('adapter.fileSame', { path: rel }), severity: 'info' });
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
items.push({
|
|
311
|
+
id, kind: 'Conflict', adapter: 'plugins',
|
|
312
|
+
description: msg('adapter.fileDiff', { path: rel }), severity: 'warning', target,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// issue #35:目标机满足不了的 patchedDependencies 声明必须剔除——否则写进去之后,
|
|
317
|
+
// 目标机 pnpm 会因为「patch 文件读不到」拒绝**一切** add(含本次要装的插件)。
|
|
318
|
+
const available = await this.patchAvailability(data, ctx);
|
|
319
|
+
const clean = sanitizePnpmWorkspacePatches(data.pnpmWorkspace, (rel) => available.has(rel));
|
|
320
|
+
const droppedNames = clean.dropped.map((d) => d.name).join(', ');
|
|
206
321
|
let current = null;
|
|
207
322
|
try {
|
|
208
323
|
const rel = PNPM_WORKSPACE_REL(ctx.target.profile);
|
|
@@ -213,16 +328,38 @@ export class PluginsAdapter {
|
|
|
213
328
|
catch {
|
|
214
329
|
current = null;
|
|
215
330
|
}
|
|
216
|
-
if (current !==
|
|
331
|
+
if (current !== clean.text) {
|
|
217
332
|
items.push({
|
|
218
333
|
id: 'plugins:pnpm-workspace',
|
|
219
334
|
kind: current === null ? 'Create' : 'Update',
|
|
220
335
|
adapter: 'plugins',
|
|
221
336
|
description: current === null ? msg('adapter.pwCreate') : msg('adapter.pwUpdate'),
|
|
337
|
+
...(clean.dropped.length > 0
|
|
338
|
+
? { detail: msg('adapter.pwPatchesDroppedDetail', { count: String(clean.dropped.length), names: droppedNames }) }
|
|
339
|
+
: {}),
|
|
222
340
|
severity: 'info',
|
|
223
341
|
target: { adapter: 'plugins', ref: 'pnpm-workspace.yaml' },
|
|
224
342
|
});
|
|
225
343
|
}
|
|
344
|
+
// 剔除是**可见**的信息项(不静默改变用户配置语义)
|
|
345
|
+
if (clean.dropped.length > 0) {
|
|
346
|
+
items.push({
|
|
347
|
+
id: 'plugins:pnpm-workspace-dropped',
|
|
348
|
+
kind: 'Warning',
|
|
349
|
+
adapter: 'plugins',
|
|
350
|
+
description: msg('adapter.pwPatchesDropped', { count: String(clean.dropped.length), names: droppedNames }),
|
|
351
|
+
severity: 'warning',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (clean.unsupported !== null) {
|
|
355
|
+
items.push({
|
|
356
|
+
id: 'plugins:pnpm-workspace-flow',
|
|
357
|
+
kind: 'Warning',
|
|
358
|
+
adapter: 'plugins',
|
|
359
|
+
description: msg('adapter.pwPatchesUnsupported', { line: clean.unsupported }),
|
|
360
|
+
severity: 'warning',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
226
363
|
}
|
|
227
364
|
// 插件:包名唯一键;同版本 Skip / 未装 Install / 版本不同 Conflict
|
|
228
365
|
const targetInstalled = await ctx.target.plugins.listInstalled();
|
|
@@ -284,15 +421,65 @@ export class PluginsAdapter {
|
|
|
284
421
|
const msg = ctx.msg;
|
|
285
422
|
// pnpm-workspace.yaml:插件安装的 pnpm 配置(allowBuilds / minimumReleaseAgeExclude),
|
|
286
423
|
// 必须先于任何插件安装写入,pnpm add 时才能生效。失败为非致命 warning。
|
|
424
|
+
// issue #35:patch 文件项(写入 profile 的 patches/ 目录;同时进入导入前快照,可回滚)
|
|
425
|
+
if (item.id.startsWith('plugins:patch:')) {
|
|
426
|
+
const ref = item.target?.ref;
|
|
427
|
+
if (ref === undefined || !ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
|
|
428
|
+
return { ok: false, message: msg('adapter.missingTargetRef') };
|
|
429
|
+
}
|
|
430
|
+
const rel = normalizePath(ref.slice(PLUGIN_PATCH_REF_PREFIX.length));
|
|
431
|
+
if (rel === '' || !isPathSafe(rel)) {
|
|
432
|
+
return { ok: false, message: msg('adapter.patchFileUnsafe', { name: item.id, path: rel }) };
|
|
433
|
+
}
|
|
434
|
+
const data = ctx.sections.get('plugins');
|
|
435
|
+
const pf = data?.patchFiles?.find((p) => normalizePath(p.relativePath) === rel);
|
|
436
|
+
if (pf === undefined)
|
|
437
|
+
return { ok: false, message: msg('adapter.dataMissingFile', { ref: rel }) };
|
|
438
|
+
try {
|
|
439
|
+
await ctx.target.fs.writeFile(`${PNPM_PROFILE_DIR(ctx.target.profile)}/${rel}`, base64ToBytes(pf.base64));
|
|
440
|
+
return { ok: true };
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
warning: true,
|
|
446
|
+
message: msg('adapter.patchFileWriteFailed', { path: rel, reason: err instanceof Error ? err.message : String(err) }),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
287
450
|
if (item.id === 'plugins:pnpm-workspace') {
|
|
288
451
|
const data = ctx.sections.get('plugins');
|
|
289
452
|
const text = data?.pnpmWorkspace;
|
|
290
453
|
if (text === undefined || text === null || text === '') {
|
|
291
454
|
return { ok: false, message: msg('adapter.pwMissing') };
|
|
292
455
|
}
|
|
456
|
+
// issue #35:判定「声明能否被满足」只看**此刻磁盘上的真实状态**——patch 文件项
|
|
457
|
+
// (`plugins:patch:*`)在计划里排在本项之前,已按用户决策落盘或保留原样。
|
|
458
|
+
// 这里**绝不**替它们写文件:那样会绕过用户在冲突项上的 keepCurrent 选择,
|
|
459
|
+
// 正是 issue #35 要消除的「静默覆盖」。
|
|
460
|
+
const profileDir = PNPM_PROFILE_DIR(ctx.target.profile);
|
|
461
|
+
const available = new Set();
|
|
462
|
+
for (const decl of parsePnpmPatchedDependencies(text).declared) {
|
|
463
|
+
const rel = normalizePath(decl.path);
|
|
464
|
+
if (rel === '' || !isPathSafe(rel) || available.has(rel))
|
|
465
|
+
continue;
|
|
466
|
+
try {
|
|
467
|
+
if (await ctx.target.fs.exists(`${profileDir}/${rel}`))
|
|
468
|
+
available.add(rel);
|
|
469
|
+
}
|
|
470
|
+
catch { /* 读不到 = 视为不存在 */ }
|
|
471
|
+
}
|
|
472
|
+
const clean = sanitizePnpmWorkspacePatches(text, (rel) => available.has(rel));
|
|
293
473
|
try {
|
|
294
|
-
await ctx.target.fs.writeFile(PNPM_WORKSPACE_REL(ctx.target.profile), new TextEncoder().encode(text));
|
|
295
|
-
|
|
474
|
+
await ctx.target.fs.writeFile(PNPM_WORKSPACE_REL(ctx.target.profile), new TextEncoder().encode(clean.text));
|
|
475
|
+
const notes = [msg('adapter.pwWritten')];
|
|
476
|
+
if (clean.dropped.length > 0) {
|
|
477
|
+
notes.push(msg('adapter.pwWrittenPatchesDropped', {
|
|
478
|
+
count: String(clean.dropped.length),
|
|
479
|
+
names: clean.dropped.map((d) => d.name).join(', '),
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
return { ok: true, needsRestart: true, message: notes.join(';') };
|
|
296
483
|
}
|
|
297
484
|
catch (err) {
|
|
298
485
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface PatchedDependency {
|
|
2
|
+
/** 依赖名(patchedDependencies 的键) */
|
|
3
|
+
name: string;
|
|
4
|
+
/** 声明的 patch 路径(相对 profile 目录,已去引号;原始写法保留) */
|
|
5
|
+
path: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ParsePatchedResult {
|
|
8
|
+
declared: PatchedDependency[];
|
|
9
|
+
/** 无法安全改写的形态(如单行 flow 映射)→ 调用方据此告警,绝不猜着改 */
|
|
10
|
+
unsupported: string | null;
|
|
11
|
+
}
|
|
12
|
+
/** 解析 patchedDependencies 的声明(纯函数;不校验文件是否存在)。 */
|
|
13
|
+
export declare function parsePnpmPatchedDependencies(text: string): ParsePatchedResult;
|
|
14
|
+
export interface SanitizeResult {
|
|
15
|
+
/** 清理后的文本(无需修改时与入参逐字节相同) */
|
|
16
|
+
text: string;
|
|
17
|
+
declared: PatchedDependency[];
|
|
18
|
+
/** 被移除的条目(目标机没有对应 patch 文件) */
|
|
19
|
+
dropped: PatchedDependency[];
|
|
20
|
+
unsupported: string | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 删除 patchedDependencies 中「目标机不存在对应 patch 文件」的条目。
|
|
24
|
+
* @param available 判断某相对路径(相对 profile 目录)在**导入后**是否存在
|
|
25
|
+
*/
|
|
26
|
+
export declare function sanitizePnpmWorkspacePatches(text: string, available: (relPath: string) => boolean): SanitizeResult;
|