dsh-config-manager 0.1.72 → 0.1.76
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 +40 -4
- package/README.zh-CN.md +40 -4
- package/lib/adapters/index.d.ts +1 -0
- package/lib/adapters/index.js +5 -0
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-data.d.ts +114 -0
- package/lib/adapters/plugin-data.js +309 -0
- package/lib/adapters/self.d.ts +1 -1
- package/lib/adapters/self.js +2 -1
- package/lib/adapters/test-helpers.d.ts +9 -0
- package/lib/adapters/test-helpers.js +14 -0
- package/lib/client.d.ts +103 -10
- package/lib/client.js +10748 -10119
- package/lib/core/analyzer.d.ts +11 -2
- package/lib/core/analyzer.js +31 -8
- package/lib/core/backup-plan.d.ts +10 -0
- package/lib/core/backup-plan.js +21 -3
- package/lib/core/backup.d.ts +20 -1
- package/lib/core/backup.js +48 -3
- package/lib/core/command-rollback.d.ts +6 -2
- package/lib/core/command-rollback.js +6 -2
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +2 -1
- package/lib/core/messages.js +4 -2
- package/lib/core/plugin-data-registry.d.ts +124 -0
- package/lib/core/plugin-data-registry.js +198 -0
- package/lib/core/rollback-gate.d.ts +33 -0
- package/lib/core/rollback-gate.js +57 -0
- package/lib/core/rollback-ledger.d.ts +274 -0
- package/lib/core/rollback-ledger.js +1400 -0
- package/lib/core/rollback-point.d.ts +406 -5
- package/lib/core/rollback-point.js +1453 -306
- package/lib/core/rollback.js +6 -4
- package/lib/core/types.d.ts +19 -0
- package/lib/foreign/registry.d.ts +1 -1
- package/lib/foreign/registry.js +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +83 -13
- package/lib/routes/messages.d.ts +1 -0
- package/lib/routes/messages.js +2 -0
- package/lib/routes/prefs.js +26 -3
- package/lib/routes/recovery.d.ts +21 -0
- package/lib/routes/recovery.js +127 -7
- package/lib/routes/sync.js +11 -1
- package/lib/schema/section-registry.d.ts +1 -1
- package/lib/schema/section-registry.js +32 -3
- package/lib/schema/types.d.ts +1 -1
- package/lib/sync/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +108 -18
- package/lib/sync/ui-prefs.d.ts +22 -0
- package/lib/sync/ui-prefs.js +28 -0
- package/lib/ui/diff-view.d.ts +10 -0
- package/lib/ui/diff-view.js +17 -0
- package/lib/ui/export-flow.js +2 -0
- package/lib/ui/i18n.d.ts +5 -3
- package/lib/ui/i18n.js +10 -6
- package/lib/ui/rollback-view.d.ts +59 -0
- package/lib/ui/rollback-view.js +14 -0
- package/lib/ui/test-helpers.js +2 -1
- package/lib/utils/bundle-scan.d.ts +5 -3
- package/lib/utils/bundle-scan.js +5 -3
- package/lib/utils/line-diff.d.ts +7 -1
- package/lib/utils/line-diff.js +8 -2
- package/package.json +1 -1
- package/src/adapters/index.ts +5 -0
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-data.test.ts +225 -0
- package/src/adapters/plugin-data.ts +351 -0
- package/src/adapters/self.ts +2 -1
- package/src/adapters/test-helpers.ts +15 -0
- package/src/client/chat/ChatEntryView.tsx +59 -10
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-entry-view.test.ts +164 -1
- package/src/client/chat/chat-entry-view.ts +127 -10
- package/src/client/chat/chat-node-registration.ts +40 -6
- package/src/client/chat/chat-point-window.ts +11 -1
- package/src/client/chat/rollback-diff-tab.ts +13 -3
- package/src/client/chat/rollback-entry-view.ts +5 -0
- package/src/client/chat/sidebar-slots.ts +24 -2
- package/src/client/common/DiffTable.tsx +54 -29
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/host-primitives.ts +78 -0
- package/src/client/common/routes.ts +10 -0
- package/src/client/common/section-labels.test.ts +1 -0
- package/src/client/common/section-labels.ts +2 -0
- package/src/client/config-manager.module.css +138 -14
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +54 -2
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +8 -6
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +26 -8
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +36 -6
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +77 -0
- package/src/client/sync/PushPreviewCard.tsx +57 -0
- package/src/client/sync/SyncPanel.tsx +2 -37
- package/src/client/sync/SyncSettingsView.test.ts +3 -0
- package/src/client/sync/sync-push-preview.test.ts +14 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/client/sync/sync-view.ts +9 -1
- package/src/core/analyzer.ts +31 -9
- package/src/core/backup-plan.test.ts +20 -5
- package/src/core/backup-plan.ts +22 -3
- package/src/core/backup.ts +50 -4
- package/src/core/command-rollback.ts +6 -2
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +4 -2
- package/src/core/plugin-data-registry.test.ts +113 -0
- package/src/core/plugin-data-registry.ts +261 -0
- package/src/core/rollback-gate.test.ts +66 -0
- package/src/core/rollback-gate.ts +61 -0
- package/src/core/rollback-ledger.test.ts +655 -0
- package/src/core/rollback-ledger.ts +1430 -0
- package/src/core/rollback-point.test.ts +866 -41
- package/src/core/rollback-point.ts +1582 -291
- package/src/core/rollback.ts +5 -3
- package/src/core/types.ts +19 -0
- package/src/foreign/registry.test.ts +1 -1
- package/src/foreign/registry.ts +1 -1
- package/src/index.ts +73 -10
- package/src/routes/messages.ts +2 -0
- package/src/routes/prefs.ts +31 -2
- package/src/routes/recovery.ts +122 -7
- package/src/routes/sync.ts +15 -1
- package/src/schema/manifest-schema.test.ts +4 -4
- package/src/schema/registry.test.ts +9 -2
- package/src/schema/section-registry.ts +32 -3
- package/src/schema/types.ts +2 -1
- package/src/sync/messages.ts +3 -0
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +106 -18
- package/src/sync/sync-preview-push.test.ts +158 -0
- package/src/sync/transport.test.ts +1 -0
- package/src/sync/ui-prefs.test.ts +44 -1
- package/src/sync/ui-prefs.ts +39 -0
- package/src/ui/diff-view.test.ts +9 -1
- package/src/ui/diff-view.ts +22 -0
- package/src/ui/export-flow.ts +2 -0
- package/src/ui/i18n.ts +10 -6
- package/src/ui/rollback-view.test.ts +17 -0
- package/src/ui/rollback-view.ts +66 -0
- package/src/ui/test-helpers.ts +2 -1
- package/src/utils/bundle-scan.ts +5 -3
- package/src/utils/line-diff.test.ts +12 -0
- package/src/utils/line-diff.ts +9 -3
package/src/core/backup-plan.ts
CHANGED
|
@@ -69,6 +69,17 @@ export const OFFLINE_BACKUP_SECTIONS: readonly SectionId[] = [
|
|
|
69
69
|
...DEFAULT_BACKUP_SECTIONS, ...OPT_IN_BACKUP_SECTIONS,
|
|
70
70
|
] as const;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 离线 CLI **明确不收集**的分区(第三方插件数据)。
|
|
74
|
+
*
|
|
75
|
+
* 为什么不做:它的收集目标不是静态路径,而是「本机内置来源注册表 + 已安装插件自述声明」推导出来的
|
|
76
|
+
* 区域(见 `core/plugin-data-registry.ts`),还要尊重环境变量覆盖判定 —— 这套判定住在 adapter 里
|
|
77
|
+
* (走 HostContext 门面),而 CLI 的硬约束是**绝不 import DSH 门面**(peer 缺失也要能跑)。
|
|
78
|
+
* 在这里手抄一份区域表会立刻与注册表漂移(正是本文件开头反复说的那类缺陷),所以选择**显式不适用**:
|
|
79
|
+
* 用户显式 `--sections pluginData` 时给一条可执行的提示(改用 GUI 导出/导入),而不是静默不收。
|
|
80
|
+
*/
|
|
81
|
+
export const OFFLINE_EXCLUDED_SECTIONS: readonly SectionId[] = ['pluginData', 'pluginAssets'] as const;
|
|
82
|
+
|
|
72
83
|
/**
|
|
73
84
|
* 离线**无法**收集的分区(需要 DSH Service 门面读值/权威脱敏)。仅用于报告与参数校验,
|
|
74
85
|
* 绝不写进归档:这些分区的权威形态是 GUI 导出(经 DSH `describe({redactSecrets:true})`
|
|
@@ -81,7 +92,10 @@ export const OFFLINE_UNAVAILABLE_SECTIONS: readonly SectionId[] = [
|
|
|
81
92
|
// 加载期自检(注册表派生,t30):三个离线清单里的 id 必须都是**已注册分区**。
|
|
82
93
|
// 「离线可收集性」是注册表未建模的另一条轴(要脱离 DSH 读盘收集),故这三份清单仍是显式声明;
|
|
83
94
|
// 但拼错 id / 分区改名过去会让该分区「静默永不收集、永不报告」,现在模块加载即失败。
|
|
84
|
-
for (const id of [
|
|
95
|
+
for (const id of [
|
|
96
|
+
...DEFAULT_BACKUP_SECTIONS, ...OPT_IN_BACKUP_SECTIONS, ...OFFLINE_UNAVAILABLE_SECTIONS,
|
|
97
|
+
...OFFLINE_EXCLUDED_SECTIONS,
|
|
98
|
+
]) {
|
|
85
99
|
requireSectionMeta(id);
|
|
86
100
|
}
|
|
87
101
|
|
|
@@ -138,7 +152,7 @@ const SECTION_SPECS: Partial<Record<SectionId, SectionCollectSpec>> = {
|
|
|
138
152
|
baseDir: '',
|
|
139
153
|
files: ['dsh-ssh.json', 'pet.json'],
|
|
140
154
|
dirs: ['plugin-config'],
|
|
141
|
-
label: '
|
|
155
|
+
label: "Other Plugins' Loose Files",
|
|
142
156
|
get risk() { return engineMsg('core.backupPlan.pluginFilesRisk') },
|
|
143
157
|
},
|
|
144
158
|
// adapters/self.ts(baseDir='dsh-config-manager',SELF_CONFIG_FILES 白名单)
|
|
@@ -153,7 +167,7 @@ const SECTION_SPECS: Partial<Record<SectionId, SectionCollectSpec>> = {
|
|
|
153
167
|
'dsh-config-manager/market/market-config.json',
|
|
154
168
|
'dsh-config-manager/exports/.backup-notes.json',
|
|
155
169
|
],
|
|
156
|
-
label: '
|
|
170
|
+
label: "This Plugin's Own Config",
|
|
157
171
|
},
|
|
158
172
|
};
|
|
159
173
|
|
|
@@ -250,6 +264,11 @@ export function parseSectionsArg(raw: string): { ok: true; sections: SectionId[]
|
|
|
250
264
|
if (parts.length === 0) {
|
|
251
265
|
return { ok: false, error: engineMsg('core.backupPlan.sectionsMissingValue') };
|
|
252
266
|
}
|
|
267
|
+
// 显式请求「不支持离线」的分区 → 单独一条可执行提示(不得当成「不存在的分区」)
|
|
268
|
+
const notOffline = parts.filter((p) => (OFFLINE_EXCLUDED_SECTIONS as readonly string[]).includes(p));
|
|
269
|
+
if (notOffline.length > 0) {
|
|
270
|
+
return { ok: false, error: engineMsg('core.backupPlan.sectionNotOffline', { section: notOffline.join(', ') }) };
|
|
271
|
+
}
|
|
253
272
|
const unknown = parts.filter((p) => !(OFFLINE_BACKUP_SECTIONS as readonly string[]).includes(p));
|
|
254
273
|
if (unknown.length > 0) {
|
|
255
274
|
return {
|
package/src/core/backup.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { engineMsg } from './messages-core.ts';
|
|
9
9
|
import fs from 'node:fs/promises';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import crypto from 'node:crypto';
|
|
12
13
|
import { parseJsonSafe } from '../utils/json.ts';
|
|
@@ -17,7 +18,7 @@ import { SECTION_IDS, isFileSection } from '../schema/config.ts';
|
|
|
17
18
|
import { isPatchLayerRel, PROFILE_PATCH_FILENAME, USER_PATCH_FILE } from './patch-layers.ts';
|
|
18
19
|
import type { SectionId } from '../schema/types.ts';
|
|
19
20
|
import type {
|
|
20
|
-
ConfigAdapter, HostContext, HostFileBackup, ImportPlan, PlanItem, Snapshot,
|
|
21
|
+
ConfigAdapter, FileSystemFacade, HostContext, HostFileBackup, ImportPlan, PlanItem, Snapshot,
|
|
21
22
|
SnapshotEntry, SnapshotManifest, SnapshotStatus, SnapshotStore, SnapshotTarget,
|
|
22
23
|
} from './types.ts';
|
|
23
24
|
|
|
@@ -139,6 +140,10 @@ const FILE_BASES: Partial<Record<SectionId, string>> = {
|
|
|
139
140
|
agentPresets: '.agent-presets',
|
|
140
141
|
agentInstructions: '', // homeDir 根(~/.dsh/AGENTS.md)
|
|
141
142
|
pluginFiles: '',
|
|
143
|
+
// 第三方插件数据:baseDir '' = home 根,ref 是**完整的 home 相对路径**
|
|
144
|
+
// (.dsh-wallpaper-engine/config.json / .mnemon/data/x.db)
|
|
145
|
+
pluginData: '',
|
|
146
|
+
pluginAssets: '',
|
|
142
147
|
sessions: 'sessions',
|
|
143
148
|
self: 'dsh-config-manager',
|
|
144
149
|
};
|
|
@@ -165,8 +170,43 @@ function profileDirOf(ctx: HostContext): string {
|
|
|
165
170
|
return path.join(ctx.homeDir, 'profiles', profile);
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
/**
|
|
174
|
+
* 以**系统 home 为根**的文件类分区(ref 是系统 home 相对路径)。
|
|
175
|
+
*
|
|
176
|
+
* 为什么单独一组:`FileSystemFacade` 被限制在 $DSH_HOME 内,而第三方插件的数据常在 $DSH_HOME 之外
|
|
177
|
+
* (`~/.dsh-wallpaper-engine/`、`~/.mnemon/` …)。这两个分区的路径解析与 I/O 门面都换根,
|
|
178
|
+
* 其余分区一字不动 —— 单根不变量只在**这两个分区**上被显式放宽,且仍然只允许白名单区域。
|
|
179
|
+
*/
|
|
180
|
+
export const OS_HOME_FILE_SECTIONS: ReadonlySet<SectionId> = new Set<SectionId>(['pluginData', 'pluginAssets']);
|
|
181
|
+
|
|
182
|
+
/** 该分区文件目标的根(`dsh` = $DSH_HOME;`osHome` = 系统 home) */
|
|
183
|
+
export function sectionFileRoot(adapter: SectionId): 'dsh' | 'osHome' {
|
|
184
|
+
return OS_HOME_FILE_SECTIONS.has(adapter) ? 'osHome' : 'dsh';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 系统 home:宿主显式给就用它,否则进程 `homedir()`(`~` 的定义,不是猜) */
|
|
188
|
+
export function osHomeDirOf(ctx: HostContext): string {
|
|
189
|
+
return ctx.osHomeDir !== undefined && ctx.osHomeDir !== '' ? ctx.osHomeDir : homedir();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 该分区文件 I/O 应使用的门面。
|
|
194
|
+
*
|
|
195
|
+
* `osHome` 分区必须用 `homeFs`:缺了它**显式抛错**,绝不退回 `ctx.fs` —— 那会把
|
|
196
|
+
* `~/.mnemon/x.db` 写成 `$DSH_HOME/.mnemon/x.db`(看起来成功、实际写错位置)。
|
|
197
|
+
*/
|
|
198
|
+
export function fsForSection(ctx: HostContext, adapter: SectionId): FileSystemFacade {
|
|
199
|
+
if (sectionFileRoot(adapter) === 'dsh') return ctx.fs;
|
|
200
|
+
if (ctx.homeFs === undefined) throw new Error(engineMsg('core.backup.osHomeUnavailable', { section: adapter }));
|
|
201
|
+
return ctx.homeFs;
|
|
202
|
+
}
|
|
203
|
+
|
|
168
204
|
/** 解析文件类目标的绝对路径(引擎通用快照与回滚共用) */
|
|
169
205
|
export function resolveFileTarget(ctx: HostContext, adapter: SectionId, ref: string): string {
|
|
206
|
+
// 系统 home 根的分区:整体换根(plugins 的两个 profile 特例与它们互不相干)
|
|
207
|
+
if (sectionFileRoot(adapter) === 'osHome') {
|
|
208
|
+
return path.join(osHomeDirOf(ctx), FILE_BASES[adapter] ?? '', ref);
|
|
209
|
+
}
|
|
170
210
|
// plugins 分区的 pnpm-workspace.yaml:位于 profiles/<profile>/ 下(非 FILE_BASES 静态基准)
|
|
171
211
|
if (adapter === 'plugins' && ref === 'pnpm-workspace.yaml') {
|
|
172
212
|
return path.join(profileDirOf(ctx), 'pnpm-workspace.yaml');
|
|
@@ -191,6 +231,8 @@ export function resolveFileTarget(ctx: HostContext, adapter: SectionId, ref: str
|
|
|
191
231
|
* 指纹恒为 null,crash 后 reconcile 无法证明该项是否已应用(保守但可避免的精度损失)。
|
|
192
232
|
*/
|
|
193
233
|
export function resolveFileTargetRel(adapter: SectionId, ref: string, profile?: string): string {
|
|
234
|
+
// 系统 home 根的分区:ref 本身就是「根相对路径」,原样归一即可
|
|
235
|
+
if (sectionFileRoot(adapter) === 'osHome') return normalizePath(ref);
|
|
194
236
|
if (adapter === 'plugins') {
|
|
195
237
|
const profileDir = `profiles/${profile !== undefined && profile !== '' ? profile : 'web'}`;
|
|
196
238
|
if (ref === 'pnpm-workspace.yaml') return normalizePath(`${profileDir}/pnpm-workspace.yaml`);
|
|
@@ -301,13 +343,17 @@ async function engineSnapshotEntry(ctx: HostContext, target: SnapshotTarget): Pr
|
|
|
301
343
|
case 'agentPresets':
|
|
302
344
|
case 'agentInstructions':
|
|
303
345
|
case 'pluginFiles':
|
|
346
|
+
case 'pluginData':
|
|
347
|
+
case 'pluginAssets':
|
|
304
348
|
case 'sessions':
|
|
305
349
|
case 'self': {
|
|
350
|
+
// 门面按分区选:pluginData / pluginAssets 在系统 home 根(见 fsForSection 的说明)
|
|
351
|
+
const fsFacade = fsForSection(ctx, target.adapter);
|
|
306
352
|
const abs = resolveFileTarget(ctx, target.adapter, target.ref);
|
|
307
|
-
if (!(await
|
|
353
|
+
if (!(await fsFacade.exists(abs))) {
|
|
308
354
|
return { kind: 'file', adapter: target.adapter, ref: target.ref, before: null, existed: false };
|
|
309
355
|
}
|
|
310
|
-
const data = await
|
|
356
|
+
const data = await fsFacade.readFile(abs);
|
|
311
357
|
// 文件字节不放进 SnapshotEntry(契约纯净),由 createSnapshot 收集进 blobs Map 统一落盘
|
|
312
358
|
return {
|
|
313
359
|
kind: 'file',
|
|
@@ -415,7 +461,7 @@ export async function createSnapshot(opts: CreateSnapshotOptions): Promise<Snaps
|
|
|
415
461
|
if (entry.kind === 'file' && entry.copiedTo && entry.existed) {
|
|
416
462
|
const abs = resolveFileTarget(ctx, adapterId, target.ref);
|
|
417
463
|
// 与改动前**完全相同的读调用**,只是延后到被消费时执行(路径语义与异常传播不变)
|
|
418
|
-
lazyBlobSources.push({ blobPath: entry.copiedTo, read: () => ctx.
|
|
464
|
+
lazyBlobSources.push({ blobPath: entry.copiedTo, read: () => fsForSection(ctx, adapterId).readFile(abs) });
|
|
419
465
|
}
|
|
420
466
|
entries.push(entry);
|
|
421
467
|
}
|
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
* **绝不绕过**(命令入口没有「确认按钮」,用户敲下 id 就是那次确认;其余门照旧全量生效)。
|
|
11
11
|
*
|
|
12
12
|
* 纪律(与 src/core/model-tools.ts 的 registerModelTools 同口径):
|
|
13
|
-
* · 命令服务**惰取**:一律 ctx.get('commands')
|
|
14
|
-
*
|
|
13
|
+
* · 命令服务**惰取**:一律 ctx.get('commands'),绝不属性访问 ctx.commands、绝不放进 inject ——
|
|
14
|
+
* 未组合该服务的部署必须照常启动(命令是可选能力,不是硬依赖);
|
|
15
|
+
* **读的时机是装配期一次**(见 registerRollbackCommand):Cordis 的 ctx.get 在 apply 期只能拿到
|
|
16
|
+
* **已 apply** 的服务,所以 `commands` 行晚于本插件时 `/rollback` 不会注册(只 warn 一行、无重试入口)。
|
|
17
|
+
* DSH 内置插件与本插件的实际 apply 顺序**未坐实**(asar 不可直接读、本机 profile 无相关行);
|
|
18
|
+
* 要彻底消除这个窗口需要 `ctx.inject(['commands'])` 式的「服务就绪再注册」回调 —— 未做,如实记在这里。
|
|
15
19
|
* · 注册失败只 warn:命令挂不上不得影响其它装配(宿主启动 = blocker);
|
|
16
20
|
* · 用户可见文案一律走宿主字典 engineMsg(key: command.rollback.*),**取文案那一刻**解析语言;
|
|
17
21
|
* · 本模块不 import @deepseek-ai/dsh-commands 的运行时值(只做结构化窄化),core 保持与 DSH 解耦。
|
|
@@ -241,7 +241,9 @@ export const zh = {
|
|
|
241
241
|
'core.backupPlan.depDirSkipped': '运行时依赖目录未进备份(可重建,目标机需重新安装依赖)/ runtime dependency directory NOT in this backup (rebuildable; reinstall on the target): {path}({files})',
|
|
242
242
|
'core.backupPlan.followedLinks': '已跟随 {count} 个链接目录收集内容 / followed {count} linked director(ies)',
|
|
243
243
|
'core.backupPlan.sectionsMissingValue': '`--sections` 缺值 / missing value for `--sections`',
|
|
244
|
+
'core.backup.osHomeUnavailable': '分区 {section} 的数据在系统主目录下,但宿主未提供该目录的文件门面(旧版本宿主);本次不读也不写 / section {section} stores data under the system home directory but the host provides no file facade for it (older host); nothing is read or written this time',
|
|
244
245
|
'core.backupPlan.unknownSections': '未知分区 / unknown section(s): {unknown}(可用 / available: {available})',
|
|
246
|
+
'core.backupPlan.sectionNotOffline': '分区 {section} 不支持离线 CLI 备份(它的收集依赖插件来源注册表与本机插件清单),请用 GUI 的导出/导入迁移它 / section {section} is not supported by the offline CLI backup (its collection depends on the plugin-data source registry and the local plugin manifests); use the GUI export/import instead',
|
|
245
247
|
'core.backupPlan.unsafeEntryName': '跳过不安全条目名 / unsafe entry name skipped: {name}',
|
|
246
248
|
'core.backupPlan.statFailed': '探测失败 / stat failed: {path}({reason})',
|
|
247
249
|
'core.backupPlan.readFailed': '读取失败 / read failed: {path}({reason})',
|
|
@@ -551,7 +553,9 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
551
553
|
'core.backupPlan.depDirSkipped': 'runtime dependency directory NOT in this backup (rebuildable; reinstall dependencies on the target): {path} ({files})',
|
|
552
554
|
'core.backupPlan.followedLinks': 'followed {count} linked director(ies) to collect content',
|
|
553
555
|
'core.backupPlan.sectionsMissingValue': 'missing value for `--sections`',
|
|
556
|
+
'core.backup.osHomeUnavailable': 'section {section} stores data under the system home directory but the host provides no file facade for it (older host); nothing is read or written this time',
|
|
554
557
|
'core.backupPlan.unknownSections': 'unknown section(s): {unknown} (available: {available})',
|
|
558
|
+
'core.backupPlan.sectionNotOffline': 'section {section} is not supported by the offline CLI backup (its collection depends on the plugin-data source registry and the local plugin manifests); use the GUI export/import instead',
|
|
555
559
|
'core.backupPlan.unsafeEntryName': 'unsafe entry name skipped: {name}',
|
|
556
560
|
'core.backupPlan.statFailed': 'stat failed: {path} ({reason})',
|
|
557
561
|
'core.backupPlan.readFailed': 'read failed: {path} ({reason})',
|
package/src/core/messages.ts
CHANGED
|
@@ -357,6 +357,7 @@ export const zh = {
|
|
|
357
357
|
'sync.credentialsReadFailed': '读取凭据文件失败,本次快照不含任何凭据值: {reason}',
|
|
358
358
|
'sync.credentialsEmpty': '本机凭据文件为空或无法解析,本次快照不含任何凭据值',
|
|
359
359
|
'sync.credentialsItemDesc': '凭据 {ref} 随加密快照迁移(确认后写入本机凭据存储)',
|
|
360
|
+
'sync.credentialsItemSame': '凭据 {ref} 与本机一致,跳过写入(保留本机值)',
|
|
360
361
|
'sync.credentialsNeedPassword': '快照 {id} 含加密凭据载荷,需要解密密码才能读取',
|
|
361
362
|
'sync.encryptedNeedsPassword': '快照 {id} 已加密,需要解密密码才能读取(自动同步无法解密,请手动同步)',
|
|
362
363
|
'sync.noPortableSections': '没有可同步的 portable 分区(全部导出失败)',
|
|
@@ -460,7 +461,7 @@ export const zh = {
|
|
|
460
461
|
'command.rollback.restorableYes': '可回退',
|
|
461
462
|
'command.rollback.restorableNo': '不可回退',
|
|
462
463
|
'command.rollback.restorableSummary': '{parts} ⇒ {verdict}',
|
|
463
|
-
'command.rollback.rewindRestored': '已回退到 {id}。{detail}',
|
|
464
|
+
'command.rollback.rewindRestored': '已回退到 {id}。{detail}当前对话不会切换:回退出的新会话在左侧栏(未分组时排在列表末尾),打开它继续聊。',
|
|
464
465
|
'command.rollback.rewindPartial': '回退只完成了一部分(会话段已 fork,工作区段见下)——{id}:{segments}。{detail}',
|
|
465
466
|
'command.rollback.segmentFacts': '段:{facts}',
|
|
466
467
|
'command.rollback.rewindFailed': '回退失败({code}):{id}。{detail}',
|
|
@@ -805,6 +806,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
805
806
|
'sync.credentialsReadFailed': 'Failed to read the credentials file; this snapshot carries no credential value: {reason}',
|
|
806
807
|
'sync.credentialsEmpty': 'The local credentials file is empty or unparseable; this snapshot carries no credential value',
|
|
807
808
|
'sync.credentialsItemDesc': 'Credential {ref} travels with the encrypted snapshot (written to the local credential store once confirmed)',
|
|
809
|
+
'sync.credentialsItemSame': 'Credential {ref} already matches this machine; write skipped (local value kept)',
|
|
808
810
|
'sync.credentialsNeedPassword': 'Snapshot {id} carries an encrypted credentials payload; a decryption password is required',
|
|
809
811
|
'sync.encryptedNeedsPassword': 'Snapshot {id} is encrypted; a decryption password is required (auto sync cannot decrypt — sync manually)',
|
|
810
812
|
'sync.noPortableSections': 'No portable sections to sync (all exports failed)',
|
|
@@ -907,7 +909,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
907
909
|
'command.rollback.restorableYes': 'restorable',
|
|
908
910
|
'command.rollback.restorableNo': 'not restorable',
|
|
909
911
|
'command.rollback.restorableSummary': '{parts} => {verdict}',
|
|
910
|
-
'command.rollback.rewindRestored': 'Rewound to {id}. {detail}',
|
|
912
|
+
'command.rollback.rewindRestored': 'Rewound to {id}. {detail}This conversation is not switched: the new session is in the left sidebar (at the end of the list when ungrouped) — open it to continue.',
|
|
911
913
|
'command.rollback.rewindPartial': 'The rewind only completed partially (session segment forked; see workspace segment below) - {id}: {segments}. {detail}',
|
|
912
914
|
'command.rollback.segmentFacts': 'segments: {facts}',
|
|
913
915
|
'command.rollback.rewindFailed': 'Rewind failed ({code}): {id}. {detail}',
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pluginData / pluginAssets 来源注册表的纯函数单测(零 I/O)。
|
|
3
|
+
*
|
|
4
|
+
* 这里钉的是「收录口径」的四条硬性质:
|
|
5
|
+
* ① 区域归属(角色 → 分区)与内置来源的取证标签;
|
|
6
|
+
* ② 最长前缀归属 —— 大资产不能被短前缀的配置区域吞掉;
|
|
7
|
+
* ③ 排除与安全(cache 不进包;非法 home 相对路径一律拒绝);
|
|
8
|
+
* ④ 自述声明(dsh.dataDirs)的形状解析:不认识就整条丢弃,绝不猜。
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
BUILTIN_PLUGIN_DATA_SOURCES, areaOfPath, areasForSection, isExcludedRel, normalizeHomeRel,
|
|
15
|
+
parseDeclaredDataDirs, relInArea, sectionOfRole,
|
|
16
|
+
} from './plugin-data-registry.ts';
|
|
17
|
+
import type { PluginDataArea, PluginDataSource } from './plugin-data-registry.ts';
|
|
18
|
+
|
|
19
|
+
/** 区域条目(与 adapter 的 PluginDataEntry 同形;core 测试不反向依赖 adapters 层) */
|
|
20
|
+
type AreaEntry = { source: PluginDataSource; area: PluginDataArea };
|
|
21
|
+
|
|
22
|
+
const ALL: AreaEntry[] = [];
|
|
23
|
+
for (const source of BUILTIN_PLUGIN_DATA_SOURCES) {
|
|
24
|
+
for (const area of source.areas) ALL.push({ source, area });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('全局唯一来源:id 不重复,且每个来源都如实标注取证强度', () => {
|
|
28
|
+
const ids = BUILTIN_PLUGIN_DATA_SOURCES.map((s) => s.id);
|
|
29
|
+
assert.equal(new Set(ids).size, ids.length);
|
|
30
|
+
for (const s of BUILTIN_PLUGIN_DATA_SOURCES) {
|
|
31
|
+
assert.ok(['measured', 'fixture', 'documented'].includes(s.evidence), s.id);
|
|
32
|
+
assert.ok(s.areas.length > 0, s.id);
|
|
33
|
+
}
|
|
34
|
+
// 本机实测的两个来源必须真有 measured 证据(其余是文档取证,不得美化)
|
|
35
|
+
assert.equal(BUILTIN_PLUGIN_DATA_SOURCES.find((s) => s.id === 'mnemon')?.evidence, 'measured');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('角色 → 分区:config 进 pluginData,assets 进 pluginAssets,cache 两个都不进', () => {
|
|
39
|
+
assert.equal(sectionOfRole('config'), 'pluginData');
|
|
40
|
+
assert.equal(sectionOfRole('assets'), 'pluginAssets');
|
|
41
|
+
assert.equal(sectionOfRole('cache'), null);
|
|
42
|
+
|
|
43
|
+
const config = areasForSection(BUILTIN_PLUGIN_DATA_SOURCES, 'pluginData').map((e) => e.area.relPath);
|
|
44
|
+
const assets = areasForSection(BUILTIN_PLUGIN_DATA_SOURCES, 'pluginAssets').map((e) => e.area.relPath);
|
|
45
|
+
assert.ok(config.includes('.dsh-wallpaper-engine'));
|
|
46
|
+
assert.ok(config.includes('.mnemon/runtime'));
|
|
47
|
+
assert.ok(!config.includes('.dsh-wallpaper-engine/uploads'), '大资产不得出现在配置分区');
|
|
48
|
+
assert.ok(assets.includes('.dsh-wallpaper-engine/uploads'));
|
|
49
|
+
assert.ok(assets.includes('.mnemon/data'));
|
|
50
|
+
assert.ok(![...config, ...assets].includes('.dsh-wallpaper-engine/cache'), '缓存永不进任何分区');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('最长前缀归属:uploads 下的文件归 uploads 区域,不是它祖先的配置区域', () => {
|
|
54
|
+
const hit = areaOfPath(ALL, '.dsh-wallpaper-engine/uploads/a.mp4');
|
|
55
|
+
assert.equal(hit?.area.relPath, '.dsh-wallpaper-engine/uploads');
|
|
56
|
+
assert.equal(hit?.area.role, 'assets');
|
|
57
|
+
|
|
58
|
+
// 配置分区视角:upload 路径被祖先区域的 excludeRel 挡住 → 判 null(导入侧据此拒绝写)
|
|
59
|
+
const configOnly = areasForSection(BUILTIN_PLUGIN_DATA_SOURCES, 'pluginData');
|
|
60
|
+
assert.equal(areaOfPath(configOnly, '.dsh-wallpaper-engine/uploads/a.mp4'), null);
|
|
61
|
+
assert.equal(areaOfPath(configOnly, '.dsh-wallpaper-engine/config.json')?.area.relPath, '.dsh-wallpaper-engine');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('relInArea / isExcludedRel:文件区域精确匹配,目录区域前缀匹配', () => {
|
|
65
|
+
const fileArea = { relPath: '.x/state.json', role: 'config' as const };
|
|
66
|
+
assert.equal(relInArea(fileArea, '.x/state.json'), true);
|
|
67
|
+
assert.equal(relInArea(fileArea, '.x/state.json.bak'), false);
|
|
68
|
+
|
|
69
|
+
const dirArea = { relPath: '.x', role: 'config' as const, excludeRel: ['cache'] };
|
|
70
|
+
assert.equal(relInArea(dirArea, '.x/a/b'), true);
|
|
71
|
+
assert.equal(isExcludedRel(dirArea, '.x/cache'), true);
|
|
72
|
+
assert.equal(isExcludedRel(dirArea, '.x/cache/deep/b'), true);
|
|
73
|
+
assert.equal(isExcludedRel(dirArea, '.x/cacheX'), false, '前缀必须是完整路径段');
|
|
74
|
+
assert.equal(isExcludedRel(dirArea, '.x/a'), false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('normalizeHomeRel:拒绝一切可能越界的写法,反斜杠归一为 POSIX', () => {
|
|
78
|
+
assert.equal(normalizeHomeRel('.a/b'), '.a/b');
|
|
79
|
+
assert.equal(normalizeHomeRel('.a\\b'), '.a/b');
|
|
80
|
+
assert.equal(normalizeHomeRel(' .a/b '), '.a/b');
|
|
81
|
+
|
|
82
|
+
for (const bad of ['', ' ', '/abs/x', 'C:/x', 'a/../b', '..', './a', 'a/', 'a//b', 'a/\u0000b', 42, null, undefined]) {
|
|
83
|
+
assert.equal(normalizeHomeRel(bad), null, JSON.stringify(bad));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('parseDeclaredDataDirs:合法声明解析成来源;形状不认识整条丢弃(不猜)', () => {
|
|
88
|
+
const source = parseDeclaredDataDirs('dsh-fake', {
|
|
89
|
+
dsh: { dataDirs: [
|
|
90
|
+
{ path: '.fake-plugin', role: 'config', env: ['FAKE_DATA_DIR'] },
|
|
91
|
+
{ path: '.fake-plugin/uploads', role: 'assets', exclude: ['tmp'] },
|
|
92
|
+
] },
|
|
93
|
+
});
|
|
94
|
+
assert.ok(source);
|
|
95
|
+
assert.equal(source?.id, 'declared:dsh-fake');
|
|
96
|
+
assert.deepEqual(source?.packages, ['dsh-fake']);
|
|
97
|
+
assert.equal(source?.areas.length, 2);
|
|
98
|
+
assert.deepEqual(source?.areas[0]?.envOverrides, ['FAKE_DATA_DIR']);
|
|
99
|
+
assert.deepEqual(source?.areas[1]?.excludeRel, ['tmp']);
|
|
100
|
+
assert.equal(source?.areas[1]?.role, 'assets');
|
|
101
|
+
|
|
102
|
+
// 缺省角色 = config
|
|
103
|
+
assert.equal(parseDeclaredDataDirs('p', { dsh: { dataDirs: [{ path: '.p' }] } })?.areas[0]?.role, 'config');
|
|
104
|
+
|
|
105
|
+
// 整条丢弃的形态
|
|
106
|
+
assert.equal(parseDeclaredDataDirs('p', null), null);
|
|
107
|
+
assert.equal(parseDeclaredDataDirs('p', {}), null);
|
|
108
|
+
assert.equal(parseDeclaredDataDirs('p', { dsh: {} }), null);
|
|
109
|
+
assert.equal(parseDeclaredDataDirs('p', { dsh: { dataDirs: [] } }), null);
|
|
110
|
+
assert.equal(parseDeclaredDataDirs('p', { dsh: { dataDirs: 'nope' } }), null);
|
|
111
|
+
assert.equal(parseDeclaredDataDirs('p', { dsh: { dataDirs: [{ path: '/abs' }] } }), null, '绝对路径 → 整条丢弃');
|
|
112
|
+
assert.equal(parseDeclaredDataDirs('p', { dsh: { dataDirs: [{ path: '.ok' }, { path: '../escape' }] } }), null, '任一非法 → 整条丢弃');
|
|
113
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 第三方插件「数据目录」来源注册表(pluginData / pluginAssets 两个分区的唯一知识源)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:DSH 插件的数据目录不由宿主统一提供(2026-10-14 核对 dsh-config-manager /
|
|
5
|
+
* dsh-mnemon / dsh-better-sidebar 的 package.json,均无任何「数据目录」声明字段),而现实里它们
|
|
6
|
+
* 散落在 ~/.dsh-<name>/、~/.<name>/、~/.dsh/<name>/ 三种形态 —— 既有的 pluginFiles 分区只认
|
|
7
|
+
* 「相对 ~/.dsh 根的白名单文件」,够不到其中任何一类。
|
|
8
|
+
*
|
|
9
|
+
* 本模块只做三件事,全部是纯函数 / 静态数据(零 I/O、零 node 内置依赖):
|
|
10
|
+
* ① 冻结来源与「区域(area)」的形状;
|
|
11
|
+
* ② 内置已取证来源(每条如实标注取证强度,与 src/foreign/types.ts 的 ForeignEvidenceKind 同款纪律);
|
|
12
|
+
* ③ 提供「路径 → 区域」的最长前缀匹配与 dsh.dataDirs 自述声明的解析。
|
|
13
|
+
*
|
|
14
|
+
* 三条不变量:
|
|
15
|
+
* - 不猜:路径一律来自插件自己的 README 明示位置或本机实测;不确定的来源宁可不收。
|
|
16
|
+
* - 缓存永不收录:role === 'cache' 的区域只用于报告(「我知道它在那儿,但不带」),绝不进包 ——
|
|
17
|
+
* 缓存是可再生产物,带出去只会撑爆包体。
|
|
18
|
+
* - 环境变量覆盖即跳过且不读值:区域被环境变量覆盖时,绝不去读那个变量指向的目录(目标机的
|
|
19
|
+
* 同名变量可能指向完全不同的盘符,按 home 相对路径写进包,换机后就落到「看起来对」的错误位置)。
|
|
20
|
+
*/
|
|
21
|
+
import type { SectionId } from '../schema/types.ts';
|
|
22
|
+
|
|
23
|
+
/** 第三方插件数据分区(两个:配置类与资产类) */
|
|
24
|
+
export type PluginDataSection = Extract<SectionId, 'pluginData' | 'pluginAssets'>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 区域角色 —— 决定它进哪个分区:
|
|
28
|
+
* - config:配置与轻量状态(进 pluginData);
|
|
29
|
+
* - assets:大体积用户数据(上传的壁纸、本地数据库;进 pluginAssets);
|
|
30
|
+
* - cache:可再生产物,两个分区都不进,只出现在报告里。
|
|
31
|
+
*/
|
|
32
|
+
export type PluginDataRole = 'config' | 'assets' | 'cache';
|
|
33
|
+
|
|
34
|
+
/** 取证强度(字段必填:未取证的来源不可能被悄悄列出来) */
|
|
35
|
+
export type PluginDataEvidence =
|
|
36
|
+
/** 本机实测:目录确实存在,且据此实现 */
|
|
37
|
+
| 'measured'
|
|
38
|
+
/** 有可复现取证方式:夹具 + 单测端到端跑同一份布局 */
|
|
39
|
+
| 'fixture'
|
|
40
|
+
/** 只有插件自己的文档(README 明示的位置/环境变量) */
|
|
41
|
+
| 'documented';
|
|
42
|
+
|
|
43
|
+
/** 一个数据区域:home 相对 POSIX 路径(文件或目录同形,按磁盘实际形态判定) */
|
|
44
|
+
export interface PluginDataArea {
|
|
45
|
+
readonly relPath: string;
|
|
46
|
+
readonly role: PluginDataRole;
|
|
47
|
+
/**
|
|
48
|
+
* 会整体搬走本区域的环境变量名(任一存在即跳过本区域并报码)。
|
|
49
|
+
*
|
|
50
|
+
* 必须把「祖先区域的环境变量」也列进来:wallpaper-engine 的 DSH_WE_DATA_DIR 搬走的是整个
|
|
51
|
+
* ~/.dsh-wallpaper-engine,只列 DSH_WE_UPLOAD_DIR 会让 uploads 在数据目录已被搬走时仍被当成
|
|
52
|
+
* 「默认位置」去读(读到空目录 → 用户以为「没有上传的壁纸」)。
|
|
53
|
+
*/
|
|
54
|
+
readonly envOverrides?: readonly string[];
|
|
55
|
+
/** 仅目录区域:永不收集的**区域相对**子路径(POSIX 前缀),如 cache / bin */
|
|
56
|
+
readonly excludeRel?: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 一个第三方插件来源 */
|
|
60
|
+
export interface PluginDataSource {
|
|
61
|
+
/** 稳定机器 id(进单元 id 与报告;不随文案变化) */
|
|
62
|
+
readonly id: string;
|
|
63
|
+
/** 该来源对应的 npm 包名(自述声明的去重键) */
|
|
64
|
+
readonly packages: readonly string[];
|
|
65
|
+
readonly evidence: PluginDataEvidence;
|
|
66
|
+
readonly areas: readonly PluginDataArea[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 内置来源 —— 只放已取证的。
|
|
71
|
+
*
|
|
72
|
+
* 收录口径(2026-10-14 调研:按 GitHub star + npm 周下载量排序后逐一核对):
|
|
73
|
+
* - 有自有数据目录(不在 DSH 三个核心存储里,也不在 profile 树里);
|
|
74
|
+
* - 目录里不含凭据(~/.commandcode/auth.json、~/.dsh/agy-accounts 这类命中即整来源不收录 ——
|
|
75
|
+
* 文件类分区的内容不进 SecretScanner 的剥离路径,带出去就是明文);
|
|
76
|
+
* - 位置由插件自己的 README 明示,或本机实测。
|
|
77
|
+
*
|
|
78
|
+
* 未收录但确实有数据目录的插件(如实登记,避免「没列出来 = 不存在」的误读):
|
|
79
|
+
* - dsh-better-sidebar(4175 star):状态全在 DSH settings/storages,已被既有分区覆盖;
|
|
80
|
+
* - @mars-sea/dsh-commandcode-provider(389 star):~/.commandcode/ 含 auth.json(凭据);
|
|
81
|
+
* - dsh-agy-link(111 star):~/.dsh/agy-accounts 为账号池(凭据)。
|
|
82
|
+
*/
|
|
83
|
+
export const BUILTIN_PLUGIN_DATA_SOURCES: readonly PluginDataSource[] = [
|
|
84
|
+
{
|
|
85
|
+
// README「配置与数据」表逐项明示:数据目录 + 缓存/上传/官方资源的覆盖变量
|
|
86
|
+
id: 'wallpaper-engine',
|
|
87
|
+
packages: ['dsh-plugin-wallpaper-engine'],
|
|
88
|
+
evidence: 'documented',
|
|
89
|
+
areas: [
|
|
90
|
+
{
|
|
91
|
+
relPath: '.dsh-wallpaper-engine',
|
|
92
|
+
role: 'config',
|
|
93
|
+
envOverrides: ['DSH_WE_DATA_DIR'],
|
|
94
|
+
// cache = 抽帧/转码/缩略图(可再生产物);uploads 进 assets;bin 由插件管理(按需下载的运行时)
|
|
95
|
+
excludeRel: ['cache', 'uploads', 'bin'],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
relPath: '.dsh-wallpaper-engine/uploads',
|
|
99
|
+
role: 'assets',
|
|
100
|
+
envOverrides: ['DSH_WE_DATA_DIR', 'DSH_WE_UPLOAD_DIR'],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
relPath: '.dsh-wallpaper-engine/cache',
|
|
104
|
+
role: 'cache',
|
|
105
|
+
envOverrides: ['DSH_WE_DATA_DIR', 'DSH_WE_CACHE_DIR'],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
// 本机实测(~/.mnemon 下 runtime / data / documents 三个子目录);MNEMON_DATA_DIR 覆盖整个根
|
|
111
|
+
id: 'mnemon',
|
|
112
|
+
packages: ['dsh-mnemon'],
|
|
113
|
+
evidence: 'measured',
|
|
114
|
+
areas: [
|
|
115
|
+
{ relPath: '.mnemon/runtime', role: 'config', envOverrides: ['MNEMON_DATA_DIR'] },
|
|
116
|
+
{ relPath: '.mnemon/data', role: 'assets', envOverrides: ['MNEMON_DATA_DIR'] },
|
|
117
|
+
{ relPath: '.mnemon/documents', role: 'assets', envOverrides: ['MNEMON_DATA_DIR'] },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
// README 明示 ~/.fastctx/ 为持久状态(只有 --purge-fastctx 才删)
|
|
122
|
+
id: 'dsh-ops',
|
|
123
|
+
packages: ['dsh-ops'],
|
|
124
|
+
evidence: 'documented',
|
|
125
|
+
areas: [{ relPath: '.fastctx', role: 'assets' }],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
// README 明示:所有设置落 ~/.dsh/.dsh-any-background-data/(不是 localStorage)
|
|
129
|
+
id: 'any-background',
|
|
130
|
+
packages: ['dsh-any-background'],
|
|
131
|
+
evidence: 'documented',
|
|
132
|
+
areas: [{ relPath: '.dsh/.dsh-any-background-data', role: 'config' }],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
// README 明示 ~/.opencode2dsh/adapter-status.json
|
|
136
|
+
id: 'opencode2dsh',
|
|
137
|
+
packages: ['@opencode2dsh/dsh-plugin'],
|
|
138
|
+
evidence: 'documented',
|
|
139
|
+
areas: [{ relPath: '.opencode2dsh', role: 'config' }],
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** 角色 → 分区(cache 不属于任何分区) */
|
|
144
|
+
export function sectionOfRole(role: PluginDataRole): PluginDataSection | null {
|
|
145
|
+
if (role === 'config') return 'pluginData';
|
|
146
|
+
if (role === 'assets') return 'pluginAssets';
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 该分区要收集的区域清单(保序:来源顺序 → 区域内顺序) */
|
|
151
|
+
export function areasForSection(
|
|
152
|
+
sources: readonly PluginDataSource[],
|
|
153
|
+
section: PluginDataSection,
|
|
154
|
+
): readonly { source: PluginDataSource; area: PluginDataArea }[] {
|
|
155
|
+
const out: { source: PluginDataSource; area: PluginDataArea }[] = [];
|
|
156
|
+
for (const source of sources) {
|
|
157
|
+
for (const area of source.areas) {
|
|
158
|
+
if (sectionOfRole(area.role) === section) out.push({ source, area });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* home 相对路径归一化 + 安全判定。
|
|
166
|
+
*
|
|
167
|
+
* 只接受 POSIX 相对路径:拒绝绝对路径(含 Windows 盘符写法 C:/x)、..、反斜杠、空段、
|
|
168
|
+
* 以 / 结尾、含 NUL 的值。
|
|
169
|
+
* 返回 null = 调用方必须报错/跳过,绝不「尽力而为」地拼一个路径出来。
|
|
170
|
+
*/
|
|
171
|
+
export function normalizeHomeRel(value: unknown): string | null {
|
|
172
|
+
if (typeof value !== 'string') return null;
|
|
173
|
+
const v = value.trim().replace(/\\/g, '/');
|
|
174
|
+
if (v === '' || v.startsWith('/') || /^[A-Za-z]:/.test(v) || v.includes('\u0000')) return null;
|
|
175
|
+
if (v.endsWith('/')) return null;
|
|
176
|
+
for (const part of v.split('/')) {
|
|
177
|
+
if (part === '' || part === '.' || part === '..') return null;
|
|
178
|
+
}
|
|
179
|
+
return v;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 目录区域的排除判定(issue:**excludeRel 是区域相对路径,rel 是 home 相对路径**)。
|
|
184
|
+
*
|
|
185
|
+
* 必须先把 rel 折成区域相对再比:`excludeRel: ['uploads']` 配 `.dsh-wallpaper-engine` 区域时,
|
|
186
|
+
* 真正的判别对象是 `uploads/a.mp4`(区域相对),不是 `.dsh-wallpaper-engine/uploads/a.mp4`(home 相对)——
|
|
187
|
+
* 直接拿 home 相对路径去 startsWith,排除项永远不命中,缓存与大资产会悄悄跟着配置分区一起进包。
|
|
188
|
+
*/
|
|
189
|
+
export function isExcludedRel(area: PluginDataArea, rel: string): boolean {
|
|
190
|
+
const inner = rel === area.relPath
|
|
191
|
+
? ''
|
|
192
|
+
: rel.startsWith(area.relPath + '/') ? rel.slice(area.relPath.length + 1) : null;
|
|
193
|
+
if (inner === null || inner === '') return false;
|
|
194
|
+
for (const ex of area.excludeRel ?? []) {
|
|
195
|
+
if (inner === ex || inner.startsWith(ex + '/')) return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** rel 是否属于该区域(文件区域 = 精确相等;目录区域 = 精确相等或前缀) */
|
|
201
|
+
export function relInArea(area: PluginDataArea, rel: string): boolean {
|
|
202
|
+
if (rel === area.relPath) return true;
|
|
203
|
+
return rel.startsWith(area.relPath + '/');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 路径 → 区域(最长前缀优先)。
|
|
208
|
+
*
|
|
209
|
+
* 为什么必须最长优先:.dsh-wallpaper-engine 与 .dsh-wallpaper-engine/uploads 同时是区域,
|
|
210
|
+
* 短前缀先匹配会把上传的壁纸算进「配置」分区(用户取消勾选大资产也照样带走几百 MB)。
|
|
211
|
+
*/
|
|
212
|
+
export function areaOfPath(
|
|
213
|
+
entries: readonly { source: PluginDataSource; area: PluginDataArea }[],
|
|
214
|
+
rel: string,
|
|
215
|
+
): { source: PluginDataSource; area: PluginDataArea } | null {
|
|
216
|
+
let best: { source: PluginDataSource; area: PluginDataArea } | null = null;
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (!relInArea(entry.area, rel)) continue;
|
|
219
|
+
if (isExcludedRel(entry.area, rel)) continue;
|
|
220
|
+
if (best === null || entry.area.relPath.length > best.area.relPath.length) best = entry;
|
|
221
|
+
}
|
|
222
|
+
return best;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 解析一个已安装插件 package.json 里的自述声明(dsh.dataDirs)—— 「让别的插件自己适配」的钩子。
|
|
227
|
+
*
|
|
228
|
+
* 契约(对外文档见 docs/spec/plugin-data-declaration.md):
|
|
229
|
+
* { "dsh": { "dataDirs": [ { "path": ".my-plugin", "role": "config", "env": ["MY_PLUGIN_DIR"] } ] } }
|
|
230
|
+
*
|
|
231
|
+
* 形状不认识就整条丢弃(返回 null),绝不猜;path 非法(绝对路径 / .. / 空段)同样丢弃。
|
|
232
|
+
*/
|
|
233
|
+
export function parseDeclaredDataDirs(packageName: string, manifest: unknown): PluginDataSource | null {
|
|
234
|
+
if (manifest === null || typeof manifest !== 'object') return null;
|
|
235
|
+
const dsh = (manifest as { dsh?: unknown }).dsh;
|
|
236
|
+
if (dsh === null || typeof dsh !== 'object') return null;
|
|
237
|
+
const raw = (dsh as { dataDirs?: unknown }).dataDirs;
|
|
238
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
239
|
+
const areas: PluginDataArea[] = [];
|
|
240
|
+
for (const item of raw) {
|
|
241
|
+
if (item === null || typeof item !== 'object') return null;
|
|
242
|
+
const rec = item as { path?: unknown; role?: unknown; env?: unknown; exclude?: unknown };
|
|
243
|
+
const relPath = normalizeHomeRel(rec.path);
|
|
244
|
+
if (relPath === null) return null;
|
|
245
|
+
const role: PluginDataRole =
|
|
246
|
+
rec.role === 'assets' ? 'assets' : rec.role === 'cache' ? 'cache' : 'config';
|
|
247
|
+
const envOverrides = Array.isArray(rec.env)
|
|
248
|
+
? rec.env.filter((e): e is string => typeof e === 'string' && e !== '')
|
|
249
|
+
: [];
|
|
250
|
+
const excludes = Array.isArray(rec.exclude)
|
|
251
|
+
? rec.exclude.map((e) => normalizeHomeRel(e)).filter((e): e is string => e !== null)
|
|
252
|
+
: [];
|
|
253
|
+
areas.push({
|
|
254
|
+
relPath,
|
|
255
|
+
role,
|
|
256
|
+
...(envOverrides.length > 0 ? { envOverrides } : {}),
|
|
257
|
+
...(excludes.length > 0 ? { excludeRel: excludes } : {}),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return { id: 'declared:' + packageName, packages: [packageName], evidence: 'documented', areas };
|
|
261
|
+
}
|