pi-shepherd 0.1.0

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 (55) hide show
  1. package/README.en.md +136 -0
  2. package/README.md +136 -0
  3. package/index.ts +229 -0
  4. package/node_modules/@pi-atelier/shared-utils/README.en.md +182 -0
  5. package/node_modules/@pi-atelier/shared-utils/README.md +182 -0
  6. package/node_modules/@pi-atelier/shared-utils/package.json +51 -0
  7. package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +120 -0
  8. package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +100 -0
  9. package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +152 -0
  10. package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +187 -0
  11. package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +170 -0
  12. package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +126 -0
  13. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +138 -0
  14. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +257 -0
  15. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +189 -0
  16. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +204 -0
  17. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +269 -0
  18. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +267 -0
  19. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +520 -0
  20. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +116 -0
  21. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +119 -0
  22. package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +145 -0
  23. package/node_modules/@pi-atelier/shared-utils/src/agents.ts +39 -0
  24. package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +42 -0
  25. package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +62 -0
  26. package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +100 -0
  27. package/node_modules/@pi-atelier/shared-utils/src/index.ts +71 -0
  28. package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +96 -0
  29. package/node_modules/@pi-atelier/shared-utils/src/paths.ts +23 -0
  30. package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +241 -0
  31. package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +191 -0
  32. package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +73 -0
  33. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +104 -0
  34. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +75 -0
  35. package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +172 -0
  36. package/node_modules/@pi-atelier/shared-utils/src/settings.ts +104 -0
  37. package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +149 -0
  38. package/node_modules/@pi-atelier/shared-utils/tsconfig.json +9 -0
  39. package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +24 -0
  40. package/package.json +49 -0
  41. package/rules.json +516 -0
  42. package/shepherd/ephemeral-shared.ts +14 -0
  43. package/shepherd/ephemeral.ts +52 -0
  44. package/shepherd/index.ts +39 -0
  45. package/shepherd/line-count.ts +86 -0
  46. package/shepherd/rules-editor.ts +135 -0
  47. package/shepherd/rules-tool.ts +99 -0
  48. package/shepherd/rules-validate.ts +44 -0
  49. package/shepherd/rules.ts +283 -0
  50. package/shepherd/state-tracker.ts +119 -0
  51. package/shepherd/tool-event-types.ts +31 -0
  52. package/shepherd/tool-hooks.ts +176 -0
  53. package/shepherd/worktree-check.ts +130 -0
  54. package/tsconfig.json +14 -0
  55. package/vitest.config.ts +13 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Project-level Configuration — 全局 + 项目级配置合并与冲突检测
3
+ *
4
+ * 配置解析优先级:defaults → 全局 settings.json → 项目 .pi/settings.json
5
+ * 支持按项目启用/禁用工具、MCP 服务器,以及覆盖任意扩展配置。
6
+ *
7
+ * 用法:
8
+ * import { getEffectiveConfig, detectConfigConflicts, getEnabledTools } from "@pi-atelier/shared-utils";
9
+ *
10
+ * const cfg = getEffectiveConfig("context", defaults, cwd);
11
+ * const conflicts = detectConfigConflicts(cwd);
12
+ * const tools = getEnabledTools(allTools, cwd);
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { SETTINGS_PATH } from "./paths";
18
+ import { getSettingsSection } from "./settings";
19
+
20
+ // ── 类型 ─────────────────────────────────────────────────
21
+
22
+ export interface ConfigConflict {
23
+ section: string;
24
+ key: string;
25
+ globalValue: unknown;
26
+ projectValue: unknown;
27
+ description: string;
28
+ }
29
+
30
+ export interface EffectiveConfigResult<T extends Record<string, any>> {
31
+ /** 合并后的配置 */
32
+ config: T;
33
+ /** 配置来源:'default' | 'global' | 'project' */
34
+ sources: Record<string, "default" | "global" | "project">;
35
+ }
36
+
37
+ export interface MergeOptions {
38
+ /** 数组合并策略:replace(项目级替换全局,默认)、concat(项目级追加到全局) */
39
+ arrayMerge?: "replace" | "concat";
40
+ }
41
+
42
+ export interface SchemaError {
43
+ /** 配置段名 */
44
+ section: string;
45
+ /** 字段名 */
46
+ key: string;
47
+ /** 期望类型 */
48
+ expectedType: string;
49
+ /** 实际类型 */
50
+ actualType: string;
51
+ /** 人类可读描述 */
52
+ description: string;
53
+ }
54
+
55
+ export interface ToolFilter {
56
+ /** 额外启用的工具 ID 列表 */
57
+ enabled?: string[];
58
+ /** 禁用的工具 ID 列表 */
59
+ disabled?: string[];
60
+ }
61
+
62
+ // ── 内部工具 ─────────────────────────────────────────────
63
+
64
+ /** 深度合并两个对象,source 覆盖 target */
65
+ function deepMerge<T extends Record<string, any>>(target: T, source: Record<string, any>, options?: MergeOptions): T {
66
+ const result = { ...target } as Record<string, any>;
67
+ for (const key of Object.keys(source)) {
68
+ if (
69
+ source[key] !== null &&
70
+ typeof source[key] === "object" &&
71
+ !Array.isArray(source[key]) &&
72
+ result[key] !== null &&
73
+ typeof result[key] === "object" &&
74
+ !Array.isArray(result[key])
75
+ ) {
76
+ result[key] = deepMerge(result[key], source[key], options);
77
+ } else if (Array.isArray(source[key]) && options?.arrayMerge === "concat" && Array.isArray(result[key])) {
78
+ // concat 策略:项目级数组追加到全局数组末尾
79
+ result[key] = [...result[key], ...source[key]];
80
+ } else {
81
+ result[key] = source[key];
82
+ }
83
+ }
84
+ return result as T;
85
+ }
86
+
87
+ /** 读取项目级 settings.json */
88
+ export function readProjectSettings(cwd: string): Record<string, any> {
89
+ const projectSettingsPath = join(cwd, ".pi", "settings.json");
90
+ if (!existsSync(projectSettingsPath)) return {};
91
+ try {
92
+ return JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
93
+ } catch {
94
+ return {};
95
+ }
96
+ }
97
+
98
+ /** 清除项目级配置缓存(保留接口,当前为 no-op) */
99
+ export function clearProjectSettingsCache(): void {
100
+ // 预留,当前无缓存
101
+ }
102
+
103
+ /**
104
+ * 获取合并后的有效配置(defaults → 全局 → 项目级 deep merge)
105
+ *
106
+ * @param section - settings.json 中的顶层 key,如 "context"、"shepherd"
107
+ * @param defaults - 该段的默认值(完整对象)
108
+ * @param cwd - 当前项目目录(用于查找 .pi/settings.json)
109
+ * @param options - 合并选项(如 arrayMerge 策略)
110
+ * @returns 合并后的配置 + 各字段来源
111
+ */
112
+ export function getEffectiveConfig<T extends Record<string, any>>(
113
+ section: string,
114
+ defaults: T,
115
+ cwd: string,
116
+ options?: MergeOptions,
117
+ ): EffectiveConfigResult<T> {
118
+ // 1. 全局配置
119
+ const globalConfig = getSettingsSection(section, defaults);
120
+
121
+ // 2. 项目级配置
122
+ const projectSettings = readProjectSettings(cwd);
123
+ const projectSection = projectSettings?.[section] ?? {};
124
+
125
+ // 3. Deep merge: defaults → global → project
126
+ const merged = deepMerge(globalConfig, projectSection, options);
127
+
128
+ // 4. 追踪来源
129
+ const sources: Record<string, "default" | "global" | "project"> = {};
130
+ for (const key of Object.keys(defaults)) {
131
+ if (projectSection && key in projectSection) {
132
+ sources[key] = "project";
133
+ } else {
134
+ const globalSec = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"))?.[section] ?? {};
135
+ sources[key] = key in globalSec ? "global" : "default";
136
+ }
137
+ }
138
+
139
+ return { config: merged, sources };
140
+ }
141
+
142
+ /**
143
+ * 检测全局配置与项目级配置之间的冲突
144
+ *
145
+ * 冲突定义:项目级配置覆盖了全局配置中可能导致行为不一致的值。
146
+ * 例如:全局 distillThreshold=5000,项目设为 500,可能过于激进。
147
+ *
148
+ * @param cwd - 当前项目目录
149
+ * @param sensitiveKeys - 需要检测冲突的 key 列表(可选,默认检查所有有差异的 key)
150
+ * @returns 冲突列表
151
+ */
152
+ export function detectConfigConflicts(cwd: string, sensitiveKeys?: string[]): ConfigConflict[] {
153
+ const conflicts: ConfigConflict[] = [];
154
+ const projectSettings = readProjectSettings(cwd);
155
+ if (Object.keys(projectSettings).length === 0) return conflicts;
156
+
157
+ // 读取全局 settings
158
+ let globalSettings: Record<string, any> = {};
159
+ try {
160
+ globalSettings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
161
+ } catch {
162
+ return conflicts;
163
+ }
164
+
165
+ for (const section of Object.keys(projectSettings)) {
166
+ // 跳过非配置段(如 prompts、skills 等 pi 核心管理的字段)
167
+ if (section === "prompts" || section === "skills") continue;
168
+
169
+ const projectSection = projectSettings[section];
170
+ const globalSection = globalSettings[section] ?? {};
171
+ if (typeof projectSection !== "object" || projectSection === null) continue;
172
+
173
+ for (const key of Object.keys(projectSection)) {
174
+ if (sensitiveKeys && !sensitiveKeys.includes(key)) continue;
175
+
176
+ const globalVal = globalSection[key];
177
+ const projectVal = projectSection[key];
178
+
179
+ // 只报告项目级与全局值不同的 key
180
+ if (globalVal !== undefined && JSON.stringify(globalVal) !== JSON.stringify(projectVal)) {
181
+ conflicts.push({
182
+ section,
183
+ key,
184
+ globalValue: globalVal,
185
+ projectValue: projectVal,
186
+ description: `项目配置 "${section}.${key}" 覆盖了全局值`,
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ return conflicts;
193
+ }
194
+
195
+ /**
196
+ * 校验项目级配置与默认值的类型一致性
197
+ *
198
+ * 对比项目 .pi/settings.json 中指定 section 的字段类型与 defaults 定义的类型,
199
+ * 类型不一致时返回错误列表。
200
+ *
201
+ * @param section - settings.json 中的顶层 key
202
+ * @param defaults - 该段的默认值(定义了期望的类型结构)
203
+ * @param cwd - 当前项目目录
204
+ * @returns 格式错误列表
205
+ */
206
+ export function validateConfigSchema(section: string, defaults: Record<string, any>, cwd: string): SchemaError[] {
207
+ const errors: SchemaError[] = [];
208
+ const projectSettings = readProjectSettings(cwd);
209
+ const projectSection = projectSettings?.[section];
210
+ if (!projectSection || typeof projectSection !== "object") return errors;
211
+
212
+ function checkTypes(defaultObj: Record<string, any>, projectObj: Record<string, any>, prefix: string) {
213
+ for (const key of Object.keys(projectObj)) {
214
+ const fullKey = prefix ? `${prefix}.${key}` : key;
215
+ const defaultVal = defaultObj[key];
216
+ const projectVal = projectObj[key];
217
+
218
+ // 只校验 defaults 中定义的字段(未知字段跳过)
219
+ if (defaultVal === undefined) continue;
220
+
221
+ const expectedType = Array.isArray(defaultVal) ? "array" : defaultVal === null ? "null" : typeof defaultVal;
222
+ const actualType = Array.isArray(projectVal) ? "array" : projectVal === null ? "null" : typeof projectVal;
223
+
224
+ if (expectedType === "object" && actualType === "object") {
225
+ // 递归校验嵌套对象
226
+ checkTypes(defaultVal, projectVal, fullKey);
227
+ } else if (expectedType !== actualType) {
228
+ errors.push({
229
+ section,
230
+ key: fullKey,
231
+ expectedType,
232
+ actualType,
233
+ description: `配置 "${section}.${fullKey}" 类型错误:期望 ${expectedType},实际 ${actualType}`,
234
+ });
235
+ }
236
+ }
237
+ }
238
+
239
+ checkTypes(defaults, projectSection, "");
240
+ return errors;
241
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Tool/MCP Filtering — 三层合并的工具和 MCP 服务器过滤
3
+ *
4
+ * 配置优先级:defaults → 全局 settings.json → 项目 .pi/settings.json
5
+ * 数组策略:concat(项目级追加到全局,不是替换)
6
+ *
7
+ * 用法:
8
+ * import { getEnabledTools, getDisabledMcpServers } from "@pi-atelier/shared-utils";
9
+ *
10
+ * const disabledServers = getDisabledMcpServers(cwd); // 全局 + 项目 concat
11
+ * const tools = getEnabledTools(allTools, cwd); // 三层合并后过滤
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { SETTINGS_PATH } from "./paths";
16
+ import { readProjectSettings } from "./project-config";
17
+
18
+ // ── 类型 ─────────────────────────────────────────────────
19
+
20
+ export interface ToolFilter {
21
+ /** 额外启用的工具 ID 列表 */
22
+ enabled?: string[];
23
+ /** 禁用的工具 ID 列表 */
24
+ disabled?: string[];
25
+ }
26
+
27
+ // ── 内部工具 ─────────────────────────────────────────────
28
+
29
+ /** 读取全局 settings.json 中的指定 section(直接读文件,不走 schema merge) */
30
+ function readGlobalSection(section: string): Record<string, any> {
31
+ try {
32
+ if (!existsSync(SETTINGS_PATH)) return {};
33
+ const raw = readFileSync(SETTINGS_PATH, "utf-8");
34
+ const settings = JSON.parse(raw);
35
+ return settings?.[section] ?? {};
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 获取有效工具过滤规则(三层合并:全局 → 项目,concat 策略)
43
+ */
44
+ export function getEffectiveToolFilter(cwd: string): ToolFilter {
45
+ const globalFilter: ToolFilter = readGlobalSection("tools");
46
+ const projectSettings = readProjectSettings(cwd);
47
+ const projectFilter: ToolFilter = projectSettings?.tools ?? {};
48
+
49
+ // concat 策略:项目级追加到全局
50
+ const disabled = [...(globalFilter.disabled ?? []), ...(projectFilter.disabled ?? [])];
51
+ const enabled = [...(globalFilter.enabled ?? []), ...(projectFilter.enabled ?? [])];
52
+
53
+ return {
54
+ disabled: disabled.length > 0 ? disabled : undefined,
55
+ enabled: enabled.length > 0 ? enabled : undefined,
56
+ };
57
+ }
58
+
59
+ // ── API ─────────────────────────────────────────────────
60
+
61
+ /**
62
+ * 获取过滤后的工具列表
63
+ *
64
+ * 三层合并全局 + 项目的 enabled/disabled 规则,对 allTools 进行过滤。
65
+ *
66
+ * @param allTools - 全部可用工具 ID 列表
67
+ * @param cwd - 当前项目目录
68
+ * @returns 过滤后的工具 ID 列表
69
+ */
70
+ export function getEnabledTools(allTools: string[], cwd: string): string[] {
71
+ const filter = getEffectiveToolFilter(cwd);
72
+
73
+ let tools = [...allTools];
74
+
75
+ // 禁用优先
76
+ if (filter.disabled?.length) {
77
+ const disabledSet = new Set(filter.disabled);
78
+ tools = tools.filter((t) => !disabledSet.has(t));
79
+ }
80
+
81
+ // 额外启用(可能不在 allTools 里,给调用方自行处理)
82
+ if (filter.enabled?.length) {
83
+ const existingSet = new Set(tools);
84
+ for (const t of filter.enabled) {
85
+ if (!existingSet.has(t)) tools.push(t);
86
+ }
87
+ }
88
+
89
+ return tools;
90
+ }
91
+
92
+ /**
93
+ * 获取禁用的 MCP 服务器列表(三层合并:全局 + 项目 concat,enabled 白名单覆盖)
94
+ *
95
+ * 配置优先级:
96
+ * 1. 全局 settings.json 的 mcp.disabled — 全局禁用
97
+ * 2. 项目 .pi/settings.json 的 mcp.disabled — 项目级追加禁用
98
+ * 3. 项目 .pi/settings.json 的 mcp.enabled — 白名单,从 disabled 中移除
99
+ *
100
+ * 场景:全局禁用 godot/code-graph 等项目专用工具,
101
+ * 需要的项目在 .pi/settings.json 的 mcp.enabled 里显式启用。
102
+ *
103
+ * @param cwd - 当前项目目录
104
+ * @returns 去重后的禁用 MCP 服务器名称列表
105
+ */
106
+ /**
107
+ * 将 glob 模式转为正则(只支持 * 通配符)
108
+ * "godot_export_*" → /^godot_export_.*$/
109
+ */
110
+ function globToRegex(pattern: string): RegExp {
111
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); // 转义特殊字符
112
+ const withStar = escaped.replace(/\*/g, ".*"); // * → .*
113
+ return new RegExp(`^${withStar}$`);
114
+ }
115
+
116
+ /**
117
+ * 检查工具名是否匹配某个模式(精确匹配或 glob)
118
+ */
119
+ function matchesPattern(toolName: string, patterns: string[]): boolean {
120
+ for (const pattern of patterns) {
121
+ if (pattern.includes("*")) {
122
+ if (globToRegex(pattern).test(toolName)) return true;
123
+ } else if (toolName === pattern) {
124
+ return true;
125
+ }
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * 获取禁用的 MCP 工具列表(三层合并:全局 + 项目 concat,enabled 白名单覆盖)
132
+ *
133
+ * 支持精确匹配和 glob 模式(* 通配符):
134
+ * - "godot_launch_editor" — 精确匹配
135
+ * - "godot_export_*" — 匹配所有 godot_export_ 开头的工具
136
+ *
137
+ * @param cwd - 当前项目目录
138
+ * @returns 去重后的禁用工具名称模式列表
139
+ */
140
+ export function getDisabledTools(cwd: string): string[] {
141
+ const globalMcp = readGlobalSection("mcp");
142
+ const globalTools: string[] = globalMcp?.tools?.disabled ?? [];
143
+
144
+ const projectSettings = readProjectSettings(cwd);
145
+ const projectMcp = projectSettings?.mcp ?? {};
146
+ const projectTools: string[] = projectMcp?.tools?.disabled ?? [];
147
+ const projectEnabled: string[] = projectMcp?.tools?.enabled ?? [];
148
+
149
+ // concat 全局 + 项目 disabled
150
+ const merged = [...globalTools, ...projectTools];
151
+
152
+ // 项目级 enabled 白名单覆盖
153
+ if (projectEnabled.length > 0) {
154
+ return [...new Set(merged.filter((p) => !projectEnabled.includes(p)))];
155
+ }
156
+
157
+ return [...new Set(merged)];
158
+ }
159
+
160
+ /**
161
+ * 检查工具是否应该被禁用
162
+ *
163
+ * @param toolName - 工具全名(如 "godot_launch_editor")
164
+ * @param cwd - 当前项目目录
165
+ * @returns true 表示该工具应该被禁用
166
+ */
167
+ export function isToolDisabled(toolName: string, cwd: string): boolean {
168
+ const patterns = getDisabledTools(cwd);
169
+ return matchesPattern(toolName, patterns);
170
+ }
171
+
172
+ export function getDisabledMcpServers(cwd: string): string[] {
173
+ const globalMcp = readGlobalSection("mcp");
174
+ const globalDisabled: string[] = globalMcp.disabled ?? [];
175
+
176
+ const projectSettings = readProjectSettings(cwd);
177
+ const projectMcp = projectSettings?.mcp ?? {};
178
+ const projectDisabled: string[] = projectMcp.disabled ?? [];
179
+ const projectEnabled: string[] = projectMcp.enabled ?? [];
180
+
181
+ // concat 全局 + 项目 disabled
182
+ const merged = [...globalDisabled, ...projectDisabled];
183
+
184
+ // 项目级 enabled 白名单覆盖:从 disabled 中移除
185
+ if (projectEnabled.length > 0) {
186
+ const enabledSet = new Set(projectEnabled);
187
+ return [...new Set(merged.filter((s) => !enabledSet.has(s)))];
188
+ }
189
+
190
+ return [...new Set(merged)];
191
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * settings.json 数组 section 的 patch 操作
3
+ *
4
+ * 支持 packages / extensions / skills 等数组类型 section 的增删改。
5
+ * 匹配规则:对象按 source 字段匹配,字符串按精确匹配。
6
+ */
7
+
8
+ // ── 类型 ─────────────────────────────────────────────────
9
+
10
+ /** 数组元素匹配条件:对象按 source 字段匹配,字符串按精确匹配 */
11
+ export type ArrayItemMatch = { source: string } | string;
12
+
13
+ /** 数组替换操作 */
14
+ export interface ArrayReplaceItem {
15
+ /** 匹配条件 */
16
+ match: ArrayItemMatch;
17
+ /** 替换后的值 */
18
+ replacement: any;
19
+ }
20
+
21
+ /** 数组 patch 操作(任选其一) */
22
+ export interface ArrayPatch {
23
+ /** 添加元素(对象按 source 去重,字符串按精确去重) */
24
+ addItem?: any;
25
+ /** 删除元素 */
26
+ removeItem?: ArrayItemMatch;
27
+ /** 替换元素 */
28
+ replaceItem?: ArrayReplaceItem;
29
+ }
30
+
31
+ // ── 辅助函数 ─────────────────────────────────────────────
32
+
33
+ /** 判断 patch 是否包含数组操作 */
34
+ export function isArrayPatch(patch: any): patch is ArrayPatch {
35
+ return (
36
+ patch &&
37
+ typeof patch === "object" &&
38
+ ("addItem" in patch || "removeItem" in patch || "replaceItem" in patch)
39
+ );
40
+ }
41
+
42
+ /** 判断两个数组元素是否匹配 */
43
+ export function itemsMatch(a: any, b: ArrayItemMatch): boolean {
44
+ if (typeof b === "string") return a === b;
45
+ // 对象按 source 字段匹配
46
+ if (a && typeof a === "object" && "source" in a && "source" in b) return a.source === b.source;
47
+ // fallback:深度比较
48
+ return JSON.stringify(a) === JSON.stringify(b);
49
+ }
50
+
51
+ /** 对数组执行 patch 操作,返回新数组 */
52
+ export function applyArrayPatch(arr: any[], patch: ArrayPatch): any[] {
53
+ const result = [...arr];
54
+
55
+ if (patch.removeItem !== undefined) {
56
+ const idx = result.findIndex((item) => itemsMatch(item, patch.removeItem!));
57
+ if (idx !== -1) result.splice(idx, 1);
58
+ }
59
+
60
+ if (patch.addItem !== undefined) {
61
+ // 去重检查
62
+ const exists = result.some((item) => itemsMatch(item, patch.addItem!));
63
+ if (!exists) result.push(patch.addItem);
64
+ }
65
+
66
+ if (patch.replaceItem !== undefined) {
67
+ const { match, replacement } = patch.replaceItem;
68
+ const idx = result.findIndex((item) => itemsMatch(item, match));
69
+ if (idx !== -1) result[idx] = replacement;
70
+ }
71
+
72
+ return result;
73
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Settings 备份管理 — 回滚与列表
3
+ *
4
+ * 从 settings-backup.ts 拆分出的备份管理 API:
5
+ * - rollbackSettings: 回滚到最近备份
6
+ * - listBackups: 列出所有备份
7
+ * - rotateBackups: 清理旧备份(内部函数)
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { withFileLock } from "./file-lock";
13
+ import { parseTimestamp } from "./settings-backup-utils";
14
+
15
+ export interface BackupEntry {
16
+ filename: string;
17
+ path: string;
18
+ timestamp: Date;
19
+ size: number;
20
+ }
21
+
22
+ export interface RollbackOptions {
23
+ settingsPath?: string;
24
+ backupDir?: string;
25
+ }
26
+
27
+ /** 清理超出 maxBackups 的旧备份 */
28
+ export function rotateBackups(backupDir: string, maxBackups: number): void {
29
+ if (!existsSync(backupDir)) return;
30
+ const backups = listBackups({ backupDir });
31
+ while (backups.length > maxBackups) {
32
+ const oldest = backups.pop();
33
+ if (oldest) rmSync(oldest.path);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 回滚 settings.json 到最近一次备份
39
+ *
40
+ * @param options - 路径选项
41
+ * @throws 无备份时抛错
42
+ */
43
+ export function rollbackSettings(options?: RollbackOptions): void {
44
+ const settingsPath = options?.settingsPath ?? "";
45
+ const backupDir = options?.backupDir ?? "";
46
+
47
+ const backups = listBackups({ backupDir });
48
+ if (backups.length === 0) {
49
+ throw new Error(`无可用备份:${backupDir}`);
50
+ }
51
+
52
+ const latestBackup = backups[0];
53
+ const content = readFileSync(latestBackup.path, "utf-8");
54
+
55
+ if (settingsPath) {
56
+ withFileLock(settingsPath, () => {
57
+ writeFileSync(settingsPath, content);
58
+ });
59
+ } else {
60
+ writeFileSync(settingsPath, content);
61
+ }
62
+
63
+ // 删除已恢复的备份
64
+ rmSync(latestBackup.path);
65
+ }
66
+
67
+ /**
68
+ * 列出所有备份(按时间倒序,最新的在前)
69
+ */
70
+ export function listBackups(options?: RollbackOptions): BackupEntry[] {
71
+ const backupDir = options?.backupDir ?? "";
72
+
73
+ if (!existsSync(backupDir)) return [];
74
+
75
+ try {
76
+ const files = readdirSync(backupDir)
77
+ .filter((f) => f.startsWith("settings.") && f.endsWith(".json"))
78
+ .map((f) => {
79
+ const fullPath = join(backupDir, f);
80
+ try {
81
+ const { statSync } = require("node:fs");
82
+ const s = statSync(fullPath);
83
+ return {
84
+ filename: f,
85
+ path: fullPath,
86
+ timestamp: parseTimestamp(f) ?? new Date(0),
87
+ size: s.size,
88
+ };
89
+ } catch {
90
+ return {
91
+ filename: f,
92
+ path: fullPath,
93
+ timestamp: parseTimestamp(f) ?? new Date(0),
94
+ size: 0,
95
+ };
96
+ }
97
+ })
98
+ .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
99
+
100
+ return files;
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * settings-backup 内部工具函数
3
+ *
4
+ * 备份目录管理、时间戳格式化、文件读写等。
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ // ── 常量 ─────────────────────────────────────────────────
11
+
12
+ /** 默认备份子目录名 */
13
+ export const DEFAULT_BACKUP_DIR_SUFFIX = "settings-backup";
14
+
15
+ // ── 内部工具 ─────────────────────────────────────────────
16
+
17
+ /** 格式化时间戳为文件名安全字符串(与原版行为一致) */
18
+ export function formatTimestamp(date: Date): string {
19
+ return date.toISOString().replace(/[.:]/g, "-");
20
+ }
21
+
22
+ /** 从备份文件名解析时间戳 */
23
+ export function parseTimestamp(filename: string): Date | null {
24
+ const match = filename.match(/^settings\.(.+)\.json$/);
25
+ if (!match) return null;
26
+ const isoStr = match[1].replace(/-/g, (m, offset) => {
27
+ const chars = match[1];
28
+ if (offset === 4 || offset === 7) return "-";
29
+ if (offset === 10) return "T";
30
+ if (offset === 13 || offset === 16) return ":";
31
+ if (offset === 19) return ".";
32
+ return "-";
33
+ });
34
+ const d = new Date(isoStr);
35
+ return Number.isNaN(d.getTime()) ? null : d;
36
+ }
37
+
38
+ /** 获取默认备份目录 */
39
+ export function getDefaultBackupDir(settingsPath: string): string {
40
+ return settingsPath ? join(settingsPath, "..", DEFAULT_BACKUP_DIR_SUFFIX) : "";
41
+ }
42
+
43
+ /** 创建备份 */
44
+ export function createBackup(settingsPath: string, backupDir: string): string {
45
+ mkdirSync(backupDir, { recursive: true });
46
+ const content = readFileSync(settingsPath, "utf-8");
47
+ const backupPath = join(backupDir, `settings.${formatTimestamp(new Date())}.json`);
48
+ writeFileSync(backupPath, content);
49
+ return backupPath;
50
+ }
51
+
52
+ /** 清理超出 maxBackups 的旧备份 */
53
+ export function rotateBackups(backupDir: string, maxBackups: number, listBackupsFn: (opts: any) => any[]): void {
54
+ if (!existsSync(backupDir)) return;
55
+ const backups = listBackupsFn({ backupDir });
56
+ while (backups.length > maxBackups) {
57
+ const oldest = backups.pop();
58
+ if (oldest) rmSync(oldest.path);
59
+ }
60
+ }
61
+
62
+ /** 读取完整 settings.json */
63
+ export function readFull(settingsPath: string): Record<string, any> {
64
+ try {
65
+ if (!existsSync(settingsPath)) return {};
66
+ return JSON.parse(readFileSync(settingsPath, "utf-8"));
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ /** 写入完整 settings.json */
73
+ export function writeFull(settingsPath: string, settings: Record<string, any>): void {
74
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, "\t")}\n`);
75
+ }