dsh-config-manager 0.1.67 → 0.1.68

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.
Files changed (99) hide show
  1. package/icon.svg +11 -0
  2. package/lib/adapters/file-collection.d.ts +31 -1
  3. package/lib/adapters/file-collection.js +82 -14
  4. package/lib/adapters/test-helpers.d.ts +7 -0
  5. package/lib/adapters/test-helpers.js +10 -0
  6. package/lib/adapters/units.d.ts +12 -2
  7. package/lib/adapters/units.js +13 -6
  8. package/lib/client.js +392 -426
  9. package/lib/core/analyzer.d.ts +9 -0
  10. package/lib/core/analyzer.js +83 -5
  11. package/lib/core/backup-verify.js +1 -3
  12. package/lib/core/crash-report.js +1 -4
  13. package/lib/core/journal.js +1 -10
  14. package/lib/core/migration-history.js +3 -10
  15. package/lib/core/session-meta.d.ts +6 -1
  16. package/lib/core/session-meta.js +49 -1
  17. package/lib/core/types.d.ts +36 -0
  18. package/lib/index.d.ts +12 -1
  19. package/lib/index.js +47 -17
  20. package/lib/market/git-file-writer.js +1 -4
  21. package/lib/market/types.d.ts +0 -5
  22. package/lib/profiles/dsh-profile-io.d.ts +8 -0
  23. package/lib/profiles/dsh-profile-io.js +31 -0
  24. package/lib/profiles/dsh-profile-launcher.js +6 -19
  25. package/lib/profiles/dsh-profile-manager.js +3 -13
  26. package/lib/profiles/dsh-profile-runtime.js +4 -14
  27. package/lib/schema/config.d.ts +1 -11
  28. package/lib/schema/config.js +0 -10
  29. package/lib/security/integrity.d.ts +0 -1
  30. package/lib/security/integrity.js +0 -1
  31. package/lib/security/redaction.d.ts +2 -0
  32. package/lib/security/redaction.js +15 -0
  33. package/lib/sync/git/git-transport.js +1 -4
  34. package/lib/ui/export-flow.d.ts +0 -7
  35. package/lib/ui/runs-view.d.ts +0 -5
  36. package/lib/ui/runs-view.js +0 -7
  37. package/lib/ui/types.d.ts +1 -14
  38. package/lib/ui/types.js +0 -4
  39. package/lib/utils/atomic-write.js +1 -3
  40. package/lib/utils/env-lock.d.ts +0 -4
  41. package/lib/utils/env-lock.js +11 -16
  42. package/lib/utils/git-quote.d.ts +8 -0
  43. package/lib/utils/git-quote.js +11 -0
  44. package/lib/utils/guards.d.ts +12 -0
  45. package/lib/utils/guards.js +17 -0
  46. package/lib/utils/hashing.d.ts +0 -1
  47. package/lib/utils/hashing.js +0 -5
  48. package/lib/utils/logger.d.ts +0 -3
  49. package/lib/utils/logger.js +1 -6
  50. package/lib/utils/paths.d.ts +0 -4
  51. package/lib/utils/paths.js +0 -10
  52. package/lib/utils/zip.d.ts +15 -0
  53. package/lib/utils/zip.js +69 -14
  54. package/locale/en.json +6 -0
  55. package/locale/zh.json +6 -0
  56. package/package.json +7 -2
  57. package/src/adapters/file-collection.test.ts +97 -1
  58. package/src/adapters/file-collection.ts +100 -16
  59. package/src/adapters/test-helpers.ts +10 -0
  60. package/src/adapters/units.ts +22 -7
  61. package/src/client/common/ErrorBanner.tsx +1 -5
  62. package/src/client/common/Icon.tsx +0 -4
  63. package/src/client/common/morph-icons.ts +0 -2
  64. package/src/client/config-manager.module.css +7 -343
  65. package/src/client/history/history-locales.ts +0 -20
  66. package/src/client/recovery/recovery-view.ts +0 -8
  67. package/src/client/run-store.ts +1 -8
  68. package/src/core/analyzer-cache.test.ts +154 -0
  69. package/src/core/analyzer.ts +85 -5
  70. package/src/core/backup-verify.ts +1 -4
  71. package/src/core/crash-report.ts +1 -5
  72. package/src/core/journal.ts +1 -13
  73. package/src/core/migration-history.ts +3 -13
  74. package/src/core/session-meta.test.ts +88 -2
  75. package/src/core/session-meta.ts +48 -1
  76. package/src/core/types.ts +38 -0
  77. package/src/index.ts +45 -15
  78. package/src/market/git-file-writer.ts +1 -5
  79. package/src/market/types.ts +0 -6
  80. package/src/profiles/dsh-profile-io.ts +31 -0
  81. package/src/profiles/dsh-profile-launcher.ts +6 -23
  82. package/src/profiles/dsh-profile-manager.ts +3 -14
  83. package/src/profiles/dsh-profile-runtime.ts +4 -14
  84. package/src/schema/config.ts +1 -15
  85. package/src/security/integrity.ts +0 -1
  86. package/src/security/redaction.ts +19 -0
  87. package/src/sync/git/git-transport.ts +1 -5
  88. package/src/ui/export-flow.ts +0 -8
  89. package/src/ui/runs-view.ts +0 -7
  90. package/src/ui/types.ts +2 -22
  91. package/src/utils/atomic-write.ts +1 -4
  92. package/src/utils/env-lock.test.ts +32 -4
  93. package/src/utils/env-lock.ts +11 -18
  94. package/src/utils/git-quote.ts +10 -0
  95. package/src/utils/guards.ts +17 -0
  96. package/src/utils/hashing.ts +0 -6
  97. package/src/utils/logger.ts +1 -7
  98. package/src/utils/paths.ts +0 -12
  99. package/src/utils/zip.ts +65 -15
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="DSH Config Manager">
2
+ <defs>
3
+ <linearGradient id="tile" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#5B7CFA"/>
5
+ <stop offset="1" stop-color="#3B3AD1"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="512" height="512" rx="112" fill="url(#tile)"/>
9
+ <path fill="#FFFFFF" d="M196 142 254 226H224V364H168V226H138Z"/>
10
+ <path fill="#FFFFFF" d="M316 370 258 286H288V148H344V286H374Z"/>
11
+ </svg>
@@ -1,6 +1,6 @@
1
1
  import type { MsgFunc } from '../core/messages.ts';
2
2
  import type { FilesSection, SectionId } from '../schema/types.ts';
3
- import type { ApplyResult, ConfigAdapter, ExportOptions, ExportSection, ExportUnit, HostContext, ImportContext, PlanItem, Portability, ValidationResult } from '../core/types.ts';
3
+ import type { ApplyResult, ConfigAdapter, ExportOptions, ExportSection, ExportUnit, HostContext, ImportContext, PlanItem, Portability, SectionPreview, ValidationResult } from '../core/types.ts';
4
4
  /**
5
5
  * 文件类分区导出的**单分区累计字节闸门**:分区内文件内容合计放不下的单元整块剔除(见 export())。
6
6
  *
@@ -66,6 +66,36 @@ export declare abstract class FileCollectionAdapter implements ConfigAdapter<Fil
66
66
  warnings: string[];
67
67
  } | null>;
68
68
  export(ctx: HostContext, options: ExportOptions): Promise<ExportSection<FilesSection>>;
69
+ /**
70
+ * 只读预览(`/export-preview`):与 `export()` **共用同一个内核**(`collect`),
71
+ * 只把「读文件内容 + SHA-256」换成 `ctx.fs.statSize`。
72
+ *
73
+ * 为什么必须共用:预览说的「会带哪些单元、各多大」就是用户勾选的依据,一旦与真实导出
74
+ * 分叉,用户会按预览勾选却拿到不同的包(`file-collection.test.ts` 逐项断言两者相等)。
75
+ */
76
+ preview(ctx: HostContext, options: ExportOptions): Promise<SectionPreview<FilesSection>>;
77
+ /**
78
+ * 分区收集内核(`export` / `preview` 共用)。
79
+ *
80
+ * - `mode === 'content'`:读文件内容 + 算 SHA-256(真实导出);
81
+ * - `mode === 'size'`:只取字节数(`ctx.fs.statSize`),不解码、不哈希(预览)。
82
+ *
83
+ * 两种模式共用**同一套**单元分组、条目级白名单、单分区字节闸门与告警生成 ——
84
+ * 因此「预览的单元 / 体积 / 告警」与「导出的单元 / 体积 / 告警」逐项相等。
85
+ *
86
+ * 返回值里的 `entries` **只含** `relativePath`/`data`/`contentHash` 三个字段:
87
+ * `FilesSection` 是备份格式的一部分,多带内部字段会被序列化出去;体积走旁路 `sizes`。
88
+ */
89
+ private collect;
90
+ /**
91
+ * 读一个成员文件。
92
+ *
93
+ * - `content`:读内容并按需算 SHA-256(真实导出);
94
+ * - `size`:优先 `ctx.fs.statSize`(只 stat,不读内容);门面未实现或返回 null(文件消失 /
95
+ * 读不到)时**退回 readFile** —— 与改造前把「读不到」暴露成同一个错误,绝不把未知体积
96
+ * 当成 0(0 会让字节闸门少算,把本该剔除的单元放进预览,预览与导出就分叉了)。
97
+ */
98
+ private readMember;
69
99
  /** 单元清单(零 I/O:直接由 export 产物归并)。 */
70
100
  listUnits(section: ExportSection<FilesSection>): ExportUnit[];
71
101
  analyzeImport(data: FilesSection, ctx: ImportContext): Promise<PlanItem[]>;
@@ -16,7 +16,7 @@ import { sha256Hex } from '../utils/hashing.js';
16
16
  import { isReservedInternalRel, normalizePath } from '../utils/paths.js';
17
17
  import { msgOf, zhMsg } from '../core/messages.js';
18
18
  import { linkWarnings, listFilesDetailed } from './link-report.js';
19
- import { defaultUnitId, unitAllowed, unitsFromFiles } from './units.js';
19
+ import { defaultUnitId, unitAllowed, unitsFromEntries, unitsFromFiles } from './units.js';
20
20
  import { validateJsonSection } from './json-section.js';
21
21
  /**
22
22
  * 文件类分区导出的**单分区累计字节闸门**:分区内文件内容合计放不下的单元整块剔除(见 export())。
@@ -40,6 +40,14 @@ import { validateJsonSection } from './json-section.js';
40
40
  export const MAX_FILE_SECTION_BYTES = 256 * 1024 * 1024;
41
41
  /** 字节闸门告警里最多列出的单元数(其余折叠成 "(+ N)",避免大分区把报告撑爆;与 link-report 同口径) */
42
42
  const MAX_UNITS_PER_GATE_WARNING = 5;
43
+ /**
44
+ * 只读预览下文件条目的占位内容(长度 0)。
45
+ *
46
+ * 为什么可用空内容:预览产出的 `FilesSection` 只在内存里流转(选择器只需要 `relativePath`
47
+ * 与体积),**绝不**进 ZIP / 快照 / 导入路径 —— 空内容一旦被误当导出产物,写出去的会是空文件。
48
+ * 因此 `preview()` 的返回值在类型注释里明确标注「不是格式的一部分」(见 types.ts 的 SectionPreview)。
49
+ */
50
+ const EMPTY_DATA = new Uint8Array(0);
43
51
  /** 告警文案里的体积(人类可读;纯数值格式化,不含用户可见散文) */
44
52
  function formatBytes(bytes) {
45
53
  if (bytes < 1024)
@@ -95,7 +103,49 @@ export class FileCollectionAdapter {
95
103
  return null;
96
104
  }
97
105
  async export(ctx, options) {
106
+ const collected = await this.collect(ctx, options, 'content');
107
+ return {
108
+ sectionId: this.id,
109
+ data: { version: 1, files: collected.entries },
110
+ counts: { files: collected.entries.length },
111
+ warnings: collected.warnings,
112
+ };
113
+ }
114
+ /**
115
+ * 只读预览(`/export-preview`):与 `export()` **共用同一个内核**(`collect`),
116
+ * 只把「读文件内容 + SHA-256」换成 `ctx.fs.statSize`。
117
+ *
118
+ * 为什么必须共用:预览说的「会带哪些单元、各多大」就是用户勾选的依据,一旦与真实导出
119
+ * 分叉,用户会按预览勾选却拿到不同的包(`file-collection.test.ts` 逐项断言两者相等)。
120
+ */
121
+ async preview(ctx, options) {
122
+ const collected = await this.collect(ctx, options, 'size');
123
+ return {
124
+ section: {
125
+ sectionId: this.id,
126
+ data: { version: 1, files: collected.entries },
127
+ counts: { files: collected.entries.length },
128
+ warnings: collected.warnings,
129
+ },
130
+ sizeBytes: collected.totalBytes,
131
+ items: unitsFromEntries(this.id, collected.sizes, (rel) => this.unitIdOf(rel)),
132
+ };
133
+ }
134
+ /**
135
+ * 分区收集内核(`export` / `preview` 共用)。
136
+ *
137
+ * - `mode === 'content'`:读文件内容 + 算 SHA-256(真实导出);
138
+ * - `mode === 'size'`:只取字节数(`ctx.fs.statSize`),不解码、不哈希(预览)。
139
+ *
140
+ * 两种模式共用**同一套**单元分组、条目级白名单、单分区字节闸门与告警生成 ——
141
+ * 因此「预览的单元 / 体积 / 告警」与「导出的单元 / 体积 / 告警」逐项相等。
142
+ *
143
+ * 返回值里的 `entries` **只含** `relativePath`/`data`/`contentHash` 三个字段:
144
+ * `FilesSection` 是备份格式的一部分,多带内部字段会被序列化出去;体积走旁路 `sizes`。
145
+ */
146
+ async collect(ctx, options, mode) {
98
147
  const files = [];
148
+ const sizes = [];
99
149
  const warnings = [];
100
150
  const allow = options.includeItems?.[this.id];
101
151
  // issue #37:用「跟随 junction/符号链接」的遍历,并把跟随/跳过的链接写进告警——
@@ -148,11 +198,15 @@ export class FileCollectionAdapter {
148
198
  const pending = [];
149
199
  let unitBytes = 0;
150
200
  for (const member of unitEntries.get(unitId) ?? []) {
151
- const data = await ctx.fs.readFile(member.rel);
152
- unitBytes += data.byteLength;
201
+ const read = await this.readMember(ctx, member.rel, mode);
202
+ unitBytes += read.sizeBytes;
153
203
  if (unitBytes > room)
154
204
  break;
155
- pending.push({ rel: member.rel, file: { relativePath: member.relPath, data, contentHash: sha256Hex(data) } });
205
+ pending.push({
206
+ rel: member.rel,
207
+ sizeBytes: read.sizeBytes,
208
+ entry: { relativePath: member.relPath, data: read.data, contentHash: read.contentHash },
209
+ });
156
210
  }
157
211
  if (unitBytes > room) {
158
212
  droppedUnits.push(unitId);
@@ -160,13 +214,15 @@ export class FileCollectionAdapter {
160
214
  }
161
215
  usedBytes += unitBytes;
162
216
  for (const p of pending)
163
- keptByRel.set(p.rel, p.file);
217
+ keptByRel.set(p.rel, { entry: p.entry, sizeBytes: p.sizeBytes });
164
218
  }
165
219
  // ③ 按 ① 的顺序落盘(未触发闸门时与改造前的文件顺序逐项一致)
166
220
  for (const rel of considered) {
167
- const file = keptByRel.get(rel);
168
- if (file !== undefined)
169
- files.push(file);
221
+ const kept = keptByRel.get(rel);
222
+ if (kept === undefined)
223
+ continue;
224
+ files.push(kept.entry);
225
+ sizes.push({ relativePath: kept.entry.relativePath, sizeBytes: kept.sizeBytes });
170
226
  }
171
227
  // 绝不静默:被整块剔除的单元写进告警(导出报告可见),并给出上限与实际保留量
172
228
  if (droppedUnits.length > 0) {
@@ -183,12 +239,24 @@ export class FileCollectionAdapter {
183
239
  if (rels.length === 0)
184
240
  warnings.push(msgOf(ctx)('adapter.dirEmpty', { type: this.displayName }));
185
241
  warnings.push(...linkWarnings(msgOf(ctx), this.displayName, listing));
186
- return {
187
- sectionId: this.id,
188
- data: { version: 1, files },
189
- counts: { files: files.length },
190
- warnings,
191
- };
242
+ return { entries: files, sizes, totalBytes: usedBytes, warnings };
243
+ }
244
+ /**
245
+ * 读一个成员文件。
246
+ *
247
+ * - `content`:读内容并按需算 SHA-256(真实导出);
248
+ * - `size`:优先 `ctx.fs.statSize`(只 stat,不读内容);门面未实现或返回 null(文件消失 /
249
+ * 读不到)时**退回 readFile** —— 与改造前把「读不到」暴露成同一个错误,绝不把未知体积
250
+ * 当成 0(0 会让字节闸门少算,把本该剔除的单元放进预览,预览与导出就分叉了)。
251
+ */
252
+ async readMember(ctx, rel, mode) {
253
+ if (mode === 'size') {
254
+ const size = (await ctx.fs.statSize?.(rel)) ?? null;
255
+ if (size !== null)
256
+ return { data: EMPTY_DATA, sizeBytes: size, contentHash: '' };
257
+ }
258
+ const data = await ctx.fs.readFile(rel);
259
+ return { data, sizeBytes: data.byteLength, contentHash: mode === 'content' ? sha256Hex(data) : '' };
192
260
  }
193
261
  /** 单元清单(零 I/O:直接由 export 产物归并)。 */
194
262
  listUnits(section) {
@@ -26,6 +26,13 @@ export declare class MemFs implements FileSystemFacade {
26
26
  /** 测试助手:登记某文件的 mtime(null = 恢复为「读不到」) */
27
27
  setMtime(relPath: string, ms: number | null): void;
28
28
  mtimeMs(relPath: string): Promise<number | null>;
29
+ /**
30
+ * 文件字节数(只读预览用)。
31
+ *
32
+ * 语义与真实门面一致:**读不到 → null**(不返回 0)—— 0 会让字节闸门少算,
33
+ * 让预览把本该剔除的单元也列出来,与真实导出分叉。
34
+ */
35
+ statSize(relPath: string): Promise<number | null>;
29
36
  listRecursive(dir: string): Promise<string[]>;
30
37
  mkdir(): Promise<void>;
31
38
  }
@@ -57,6 +57,16 @@ export class MemFs {
57
57
  async mtimeMs(relPath) {
58
58
  return this.mtimes.get(this.key(relPath)) ?? null;
59
59
  }
60
+ /**
61
+ * 文件字节数(只读预览用)。
62
+ *
63
+ * 语义与真实门面一致:**读不到 → null**(不返回 0)—— 0 会让字节闸门少算,
64
+ * 让预览把本该剔除的单元也列出来,与真实导出分叉。
65
+ */
66
+ async statSize(relPath) {
67
+ const v = this.files.get(this.key(relPath));
68
+ return v === undefined ? null : v.byteLength;
69
+ }
60
70
  async listRecursive(dir) {
61
71
  const base = normalizePath(this.homeDir);
62
72
  const prefix = normalizePath(dir) === '' ? base : `${base}/${normalizePath(dir)}`;
@@ -23,10 +23,20 @@ export declare function defaultUnitId(rel: string): string;
23
23
  * 空数组 = 什么都不允许(调用方应把该分区整体视为未勾选,见 Exporter.export)。
24
24
  */
25
25
  export declare function unitAllowed(allow: readonly string[] | undefined, id: string): boolean;
26
- /** 白名单是否让该分区整体为空(= 用户取消了整个分区,不是「导出空分区」)。 */
27
- export declare function allowListEmptiesSection(allow: readonly string[] | undefined): boolean;
28
26
  /** 由「成员文件」归并出单元清单(体积 = 成员文件字节合计;零 I/O)。 */
29
27
  export declare function unitsFromFiles(sectionId: string, files: readonly {
30
28
  relativePath: string;
31
29
  data: Uint8Array;
32
30
  }[], unitIdOf: (rel: string) => string): ExportUnit[];
31
+ /**
32
+ * 由「路径 + 已知体积」归并出单元清单(零 I/O)。
33
+ *
34
+ * 为什么要有这一份:只读预览(`/export-preview`)拿不到文件内容(也不该读),体积来自
35
+ * `ctx.fs.statSize`。若预览改用「data 为空的 FilesSection」喂 `unitsFromFiles`,
36
+ * 每个单元的体积会变成 0,选择器显示的体积全错。两份实现必须**同一套聚合规则**,
37
+ * 否则预览与导出会算出不同的单元体积(`file-collection.test.ts` 钉住两者逐项相等)。
38
+ */
39
+ export declare function unitsFromEntries(sectionId: string, entries: readonly {
40
+ relativePath: string;
41
+ sizeBytes: number;
42
+ }[], unitIdOf: (rel: string) => string): ExportUnit[];
@@ -15,18 +15,25 @@ export function defaultUnitId(rel) {
15
15
  export function unitAllowed(allow, id) {
16
16
  return allow === undefined || allow.includes(id);
17
17
  }
18
- /** 白名单是否让该分区整体为空(= 用户取消了整个分区,不是「导出空分区」)。 */
19
- export function allowListEmptiesSection(allow) {
20
- return allow !== undefined && allow.length === 0;
21
- }
22
18
  /** 由「成员文件」归并出单元清单(体积 = 成员文件字节合计;零 I/O)。 */
23
19
  export function unitsFromFiles(sectionId, files, unitIdOf) {
20
+ return unitsFromEntries(sectionId, files.map((f) => ({ relativePath: f.relativePath, sizeBytes: f.data.byteLength })), unitIdOf);
21
+ }
22
+ /**
23
+ * 由「路径 + 已知体积」归并出单元清单(零 I/O)。
24
+ *
25
+ * 为什么要有这一份:只读预览(`/export-preview`)拿不到文件内容(也不该读),体积来自
26
+ * `ctx.fs.statSize`。若预览改用「data 为空的 FilesSection」喂 `unitsFromFiles`,
27
+ * 每个单元的体积会变成 0,选择器显示的体积全错。两份实现必须**同一套聚合规则**,
28
+ * 否则预览与导出会算出不同的单元体积(`file-collection.test.ts` 钉住两者逐项相等)。
29
+ */
30
+ export function unitsFromEntries(sectionId, entries, unitIdOf) {
24
31
  const byUnit = new Map();
25
- for (const f of files) {
32
+ for (const f of entries) {
26
33
  const unit = unitIdOf(f.relativePath);
27
34
  const cur = byUnit.get(unit) ?? { count: 0, size: 0 };
28
35
  cur.count += 1;
29
- cur.size += f.data.length;
36
+ cur.size += f.sizeBytes;
30
37
  byUnit.set(unit, cur);
31
38
  }
32
39
  return [...byUnit.entries()].map(([unit, agg]) => ({