xtrm-cli 0.5.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.
- package/.gemini/settings.json +39 -0
- package/dist/index.cjs +57378 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/extensions/beads.ts +109 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/custom-footer.ts +160 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/minimal-mode.ts +201 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +150 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/lib/atomic-config.js +236 -0
- package/lib/config-adapter.js +231 -0
- package/lib/config-injector.js +80 -0
- package/lib/context.js +73 -0
- package/lib/diff.js +142 -0
- package/lib/env-manager.js +160 -0
- package/lib/sync-mcp-cli.js +345 -0
- package/lib/sync.js +227 -0
- package/package.json +47 -0
- package/src/adapters/base.ts +29 -0
- package/src/adapters/claude.ts +38 -0
- package/src/adapters/registry.ts +21 -0
- package/src/commands/claude.ts +122 -0
- package/src/commands/clean.ts +371 -0
- package/src/commands/end.ts +239 -0
- package/src/commands/finish.ts +25 -0
- package/src/commands/help.ts +180 -0
- package/src/commands/init.ts +959 -0
- package/src/commands/install-pi.ts +276 -0
- package/src/commands/install-service-skills.ts +281 -0
- package/src/commands/install.ts +427 -0
- package/src/commands/pi-install.ts +119 -0
- package/src/commands/pi.ts +128 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/commands/worktree.ts +193 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +174 -0
- package/src/core/interactive-plan.ts +165 -0
- package/src/core/manifest.ts +26 -0
- package/src/core/preflight.ts +142 -0
- package/src/core/rollback.ts +32 -0
- package/src/core/session-state.ts +139 -0
- package/src/core/sync-executor.ts +427 -0
- package/src/core/xtrm-finish.ts +267 -0
- package/src/index.ts +87 -0
- package/src/tests/policy-parity.test.ts +204 -0
- package/src/tests/session-flow-parity.test.ts +118 -0
- package/src/tests/session-state.test.ts +124 -0
- package/src/tests/xtrm-finish.test.ts +148 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +467 -0
- package/src/utils/banner.ts +194 -0
- package/src/utils/config-adapter.ts +90 -0
- package/src/utils/config-injector.ts +81 -0
- package/src/utils/env-manager.ts +193 -0
- package/src/utils/hash.ts +42 -0
- package/src/utils/repo-root.ts +39 -0
- package/src/utils/sync-mcp-cli.ts +395 -0
- package/src/utils/theme.ts +37 -0
- package/src/utils/worktree-session.ts +93 -0
- package/test/atomic-config-prune.test.ts +101 -0
- package/test/atomic-config.test.ts +138 -0
- package/test/clean.test.ts +172 -0
- package/test/config-schema.test.ts +52 -0
- package/test/context.test.ts +33 -0
- package/test/end-worktree.test.ts +168 -0
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/minimal-mode.test.ts +107 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/service-skills.test.ts +84 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/hooks/quality-check-hooks.test.ts +45 -0
- package/test/hooks.test.ts +1075 -0
- package/test/install-pi.test.ts +185 -0
- package/test/install-project.test.ts +378 -0
- package/test/install-service-skills.test.ts +131 -0
- package/test/install-surface.test.ts +72 -0
- package/test/runtime-subcommands.test.ts +121 -0
- package/test/session-launcher.test.ts +139 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const SyncModeSchema = z.enum(['copy', 'symlink', 'prune']);
|
|
4
|
+
export type SyncMode = z.infer<typeof SyncModeSchema>;
|
|
5
|
+
|
|
6
|
+
export const TargetConfigSchema = z.object({
|
|
7
|
+
label: z.string(),
|
|
8
|
+
path: z.string(),
|
|
9
|
+
exists: z.boolean(),
|
|
10
|
+
});
|
|
11
|
+
export type TargetConfig = z.infer<typeof TargetConfigSchema>;
|
|
12
|
+
|
|
13
|
+
export const ChangeSetCategorySchema = z.object({
|
|
14
|
+
missing: z.array(z.string()),
|
|
15
|
+
outdated: z.array(z.string()),
|
|
16
|
+
drifted: z.array(z.string()),
|
|
17
|
+
total: z.number(),
|
|
18
|
+
});
|
|
19
|
+
export type ChangeSetCategory = z.infer<typeof ChangeSetCategorySchema>;
|
|
20
|
+
|
|
21
|
+
export const ChangeSetSchema = z.object({
|
|
22
|
+
skills: ChangeSetCategorySchema,
|
|
23
|
+
hooks: ChangeSetCategorySchema,
|
|
24
|
+
config: ChangeSetCategorySchema,
|
|
25
|
+
commands: ChangeSetCategorySchema,
|
|
26
|
+
'qwen-commands': ChangeSetCategorySchema,
|
|
27
|
+
'antigravity-workflows': ChangeSetCategorySchema,
|
|
28
|
+
});
|
|
29
|
+
export type ChangeSet = z.infer<typeof ChangeSetSchema>;
|
|
30
|
+
|
|
31
|
+
export const SyncPlanSchema = z.object({
|
|
32
|
+
mode: SyncModeSchema,
|
|
33
|
+
targets: z.array(z.string()),
|
|
34
|
+
});
|
|
35
|
+
export type SyncPlan = z.infer<typeof SyncPlanSchema>;
|
|
36
|
+
|
|
37
|
+
export const ManifestItemSchema = z.object({
|
|
38
|
+
type: z.enum(['skill', 'hook', 'config', 'command']),
|
|
39
|
+
name: z.string(),
|
|
40
|
+
hash: z.string(),
|
|
41
|
+
lastSync: z.string(),
|
|
42
|
+
source: z.string(),
|
|
43
|
+
});
|
|
44
|
+
export type ManifestItem = z.infer<typeof ManifestItemSchema>;
|
|
45
|
+
|
|
46
|
+
export const ManifestSchema = z.object({
|
|
47
|
+
version: z.string().optional().default('1'),
|
|
48
|
+
lastSync: z.string(),
|
|
49
|
+
items: z.number().optional().default(0),
|
|
50
|
+
});
|
|
51
|
+
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface Skill {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
content: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Command {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
prompt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Hook {
|
|
14
|
+
name?: string;
|
|
15
|
+
type?: string;
|
|
16
|
+
command?: string;
|
|
17
|
+
script?: string;
|
|
18
|
+
timeout?: number;
|
|
19
|
+
events?: string[];
|
|
20
|
+
matcher?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MCPServer {
|
|
24
|
+
type?: 'stdio' | 'http' | 'sse';
|
|
25
|
+
command?: string;
|
|
26
|
+
args?: string[];
|
|
27
|
+
env?: Record<string, string>;
|
|
28
|
+
url?: string;
|
|
29
|
+
serverUrl?: string;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SyncOptions {
|
|
35
|
+
dryRun?: boolean;
|
|
36
|
+
yes?: boolean;
|
|
37
|
+
prune?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ManifestItem {
|
|
41
|
+
type: 'skill' | 'hook' | 'config' | 'command';
|
|
42
|
+
name: string;
|
|
43
|
+
hash: string;
|
|
44
|
+
lastSync: string;
|
|
45
|
+
source: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Manifest {
|
|
49
|
+
version: string;
|
|
50
|
+
lastSync: string;
|
|
51
|
+
items: Record<string, ManifestItem>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import { parse, stringify } from 'comment-json';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Atomic Configuration Handler with Vault Pattern
|
|
8
|
+
* Ensures safe read/write operations with protection against corruption during crashes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Protected keys that should never be overwritten if they exist locally
|
|
12
|
+
const PROTECTED_KEYS = [
|
|
13
|
+
'permissions.allow', // User-defined permissions
|
|
14
|
+
'hooks.UserPromptSubmit', // Claude hooks
|
|
15
|
+
'hooks.SessionStart',
|
|
16
|
+
'hooks.PreToolUse',
|
|
17
|
+
'hooks.BeforeAgent', // Gemini hooks
|
|
18
|
+
'hooks.BeforeTool', // Gemini hooks
|
|
19
|
+
'security', // Auth secrets/OAuth data
|
|
20
|
+
'general', // Personal preferences
|
|
21
|
+
'enabledPlugins', // User-enabled/disabled plugins
|
|
22
|
+
'model', // User's preferred model
|
|
23
|
+
'skillSuggestions.enabled' // User preferences
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a key path is exactly protected or a parent of a protected key
|
|
28
|
+
*/
|
|
29
|
+
export function isProtectedPath(keyPath: string): boolean {
|
|
30
|
+
return PROTECTED_KEYS.some(protectedPath =>
|
|
31
|
+
keyPath === protectedPath || protectedPath.startsWith(keyPath + '.')
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a key path is a protected key or a child of a protected key
|
|
37
|
+
*/
|
|
38
|
+
export function isValueProtected(keyPath: string): boolean {
|
|
39
|
+
return PROTECTED_KEYS.some(protectedPath =>
|
|
40
|
+
keyPath === protectedPath || keyPath.startsWith(protectedPath + '.')
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractHookCommands(wrapper: any): string[] {
|
|
45
|
+
if (!wrapper || !Array.isArray(wrapper.hooks)) return [];
|
|
46
|
+
return wrapper.hooks
|
|
47
|
+
.map((h: any) => h?.command)
|
|
48
|
+
.filter((c: any): c is string => typeof c === 'string' && c.trim().length > 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function commandKey(command: string): string {
|
|
52
|
+
const m = command.match(/([A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
|
|
53
|
+
return m?.[1] || command.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract script filename from a hook command for pruning purposes.
|
|
58
|
+
*/
|
|
59
|
+
function scriptKey(command: string): string | null {
|
|
60
|
+
// Match the script path relative to hooks directory
|
|
61
|
+
// Pattern: /hooks/<optional-subdir>/<filename>.<ext>
|
|
62
|
+
const m = command.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
|
|
63
|
+
if (m) return m[1];
|
|
64
|
+
|
|
65
|
+
// Fallback: match just the filename if no /hooks/ path
|
|
66
|
+
const m2 = command.match(/([A-Za-z0-9_-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
|
|
67
|
+
return m2?.[1] || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collect all canonical script filenames from incoming hooks.
|
|
72
|
+
*/
|
|
73
|
+
function collectCanonicalScripts(incomingHooks: any): Set<string> {
|
|
74
|
+
const scripts = new Set<string>();
|
|
75
|
+
if (!incomingHooks || typeof incomingHooks !== 'object') return scripts;
|
|
76
|
+
|
|
77
|
+
for (const wrappers of Object.values(incomingHooks)) {
|
|
78
|
+
if (!Array.isArray(wrappers)) continue;
|
|
79
|
+
for (const wrapper of wrappers) {
|
|
80
|
+
const commands = extractHookCommands(wrapper);
|
|
81
|
+
for (const cmd of commands) {
|
|
82
|
+
const script = scriptKey(cmd);
|
|
83
|
+
if (script) scripts.add(script);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return scripts;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prune existing hook wrappers that reference scripts NOT in canonical set.
|
|
92
|
+
* Returns { pruned: wrappers[], removed: string[] }
|
|
93
|
+
*/
|
|
94
|
+
function pruneStaleWrappers(existing: any[], canonicalScripts: Set<string>): { pruned: any[]; removed: string[] } {
|
|
95
|
+
if (canonicalScripts.size === 0) {
|
|
96
|
+
return { pruned: existing, removed: [] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const removed: string[] = [];
|
|
100
|
+
const pruned: any[] = [];
|
|
101
|
+
|
|
102
|
+
for (const wrapper of existing) {
|
|
103
|
+
if (!Array.isArray(wrapper.hooks)) {
|
|
104
|
+
pruned.push(wrapper);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const keptHooks: any[] = [];
|
|
109
|
+
for (const hook of wrapper.hooks) {
|
|
110
|
+
const cmd = hook?.command;
|
|
111
|
+
if (typeof cmd !== 'string') {
|
|
112
|
+
keptHooks.push(hook);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Only prune hooks that are clearly xtrm-managed (have /hooks/ in their path)
|
|
116
|
+
// User-local hooks from other directories are always preserved
|
|
117
|
+
const isXtrmManaged = /\/hooks\//.test(cmd);
|
|
118
|
+
if (!isXtrmManaged) {
|
|
119
|
+
keptHooks.push(hook);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const script = scriptKey(cmd);
|
|
123
|
+
// Keep if: no script (not a file-based hook) OR script is in canonical set
|
|
124
|
+
if (!script || canonicalScripts.has(script)) {
|
|
125
|
+
keptHooks.push(hook);
|
|
126
|
+
} else {
|
|
127
|
+
removed.push(script);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (keptHooks.length > 0) {
|
|
132
|
+
pruned.push({ ...wrapper, hooks: keptHooks });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { pruned, removed };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mergeMatcher(existingMatcher: string, incomingMatcher: string): string {
|
|
140
|
+
const parts = [
|
|
141
|
+
...existingMatcher.split('|').map((s: string) => s.trim()),
|
|
142
|
+
...incomingMatcher.split('|').map((s: string) => s.trim()),
|
|
143
|
+
].filter(Boolean);
|
|
144
|
+
return Array.from(new Set(parts)).join('|');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
|
|
148
|
+
const merged = existing.map((w: any) => ({ ...w }));
|
|
149
|
+
|
|
150
|
+
for (const incomingWrapper of incoming) {
|
|
151
|
+
const incomingCommands = extractHookCommands(incomingWrapper);
|
|
152
|
+
if (incomingCommands.length === 0) {
|
|
153
|
+
merged.push(incomingWrapper);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const incomingKeys = new Set(incomingCommands.map(commandKey));
|
|
158
|
+
const incomingTokens = new Set(
|
|
159
|
+
typeof incomingWrapper.matcher === 'string'
|
|
160
|
+
? incomingWrapper.matcher.split('|').map((s: string) => s.trim()).filter(Boolean)
|
|
161
|
+
: [],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const existingIndex = merged.findIndex((existingWrapper: any) => {
|
|
165
|
+
const existingCommands = extractHookCommands(existingWrapper);
|
|
166
|
+
if (!existingCommands.some((c: string) => incomingKeys.has(commandKey(c)))) return false;
|
|
167
|
+
|
|
168
|
+
// Only merge with entries whose matchers overlap (share at least one token).
|
|
169
|
+
// Disjoint matchers (e.g. "Write|Edit" vs "Bash") intentionally serve
|
|
170
|
+
// different purposes and must remain as separate entries.
|
|
171
|
+
if (
|
|
172
|
+
typeof existingWrapper.matcher === 'string' &&
|
|
173
|
+
typeof incomingWrapper.matcher === 'string' &&
|
|
174
|
+
incomingTokens.size > 0
|
|
175
|
+
) {
|
|
176
|
+
const existingTokens = existingWrapper.matcher
|
|
177
|
+
.split('|').map((s: string) => s.trim()).filter(Boolean);
|
|
178
|
+
const hasOverlap = existingTokens.some((t: string) => incomingTokens.has(t));
|
|
179
|
+
if (!hasOverlap) return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return true;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (existingIndex === -1) {
|
|
186
|
+
merged.push(incomingWrapper);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const existingWrapper = merged[existingIndex];
|
|
191
|
+
if (
|
|
192
|
+
typeof existingWrapper.matcher === 'string' &&
|
|
193
|
+
typeof incomingWrapper.matcher === 'string'
|
|
194
|
+
) {
|
|
195
|
+
existingWrapper.matcher = mergeMatcher(existingWrapper.matcher, incomingWrapper.matcher);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (Array.isArray(existingWrapper.hooks) && Array.isArray(incomingWrapper.hooks)) {
|
|
199
|
+
const existingByKey = new Set(existingWrapper.hooks
|
|
200
|
+
.map((h: any) => h?.command)
|
|
201
|
+
.filter((c: any): c is string => typeof c === 'string')
|
|
202
|
+
.map(commandKey));
|
|
203
|
+
for (const hook of incomingWrapper.hooks) {
|
|
204
|
+
const cmd = hook?.command;
|
|
205
|
+
if (typeof cmd !== 'string' || !existingByKey.has(commandKey(cmd))) {
|
|
206
|
+
existingWrapper.hooks.push(hook);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return merged;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Prune mode: replace xtrm-managed wrappers wholesale with canonical ones.
|
|
217
|
+
* Wrappers whose commands do NOT reference ~/.claude/hooks/ (user-local) are always preserved.
|
|
218
|
+
*/
|
|
219
|
+
function replaceHooksObject(existingHooks: any, incomingHooks: any): any {
|
|
220
|
+
const result: any = {};
|
|
221
|
+
const canonicalScripts = collectCanonicalScripts(incomingHooks);
|
|
222
|
+
|
|
223
|
+
// Events only in existing: prune xtrm-managed entries, keep user-local
|
|
224
|
+
for (const [event, existingWrappers] of Object.entries(existingHooks || {})) {
|
|
225
|
+
if (!Array.isArray(existingWrappers)) { result[event] = existingWrappers; continue; }
|
|
226
|
+
if (event in (incomingHooks || {})) continue; // handled below
|
|
227
|
+
const { pruned } = pruneStaleWrappers(existingWrappers, canonicalScripts);
|
|
228
|
+
if (pruned.length > 0) result[event] = pruned;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Events in incoming: use canonical wrappers + preserve user-local wrappers
|
|
232
|
+
for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
|
|
233
|
+
const existingWrappers: any[] = Array.isArray(existingHooks?.[event]) ? existingHooks[event] : [];
|
|
234
|
+
const userLocal = existingWrappers.filter((wrapper: any) => {
|
|
235
|
+
if (!Array.isArray(wrapper.hooks)) return true;
|
|
236
|
+
return wrapper.hooks.every((h: any) => {
|
|
237
|
+
const cmd = h?.command;
|
|
238
|
+
return typeof cmd !== 'string' || !/\/hooks\//.test(cmd);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
result[event] = [...(Array.isArray(incomingWrappers) ? incomingWrappers : []), ...userLocal];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function mergeHooksObject(existingHooks: any, incomingHooks: any): any {
|
|
248
|
+
// Step 1: Collect canonical script filenames from incoming hooks
|
|
249
|
+
const canonicalScripts = collectCanonicalScripts(incomingHooks);
|
|
250
|
+
|
|
251
|
+
// Step 2: Prune existing hooks that reference non-canonical scripts
|
|
252
|
+
const result: any = {};
|
|
253
|
+
for (const [event, existingWrappers] of Object.entries(existingHooks || {})) {
|
|
254
|
+
if (!Array.isArray(existingWrappers)) {
|
|
255
|
+
result[event] = existingWrappers;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const { pruned } = pruneStaleWrappers(existingWrappers, canonicalScripts);
|
|
259
|
+
if (pruned.length > 0) {
|
|
260
|
+
result[event] = pruned;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Step 3: Merge incoming hooks with pruned existing hooks
|
|
265
|
+
for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
|
|
266
|
+
const existingWrappers = Array.isArray(result[event]) ? result[event] : [];
|
|
267
|
+
const incomingArray = Array.isArray(incomingWrappers) ? incomingWrappers : [];
|
|
268
|
+
result[event] = mergeHookWrappers(existingWrappers, incomingArray);
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Deep merge two objects, preserving protected values from the original
|
|
275
|
+
*/
|
|
276
|
+
export function deepMergeWithProtection(original: any, updates: any, currentPath: string = '', opts: { pruneHooks?: boolean } = {}): any {
|
|
277
|
+
const result = { ...original };
|
|
278
|
+
|
|
279
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
280
|
+
const keyPath = currentPath ? `${currentPath}.${key}` : key;
|
|
281
|
+
|
|
282
|
+
// Hooks: merge by command identity normally; in prune mode replace xtrm-managed wrappers wholesale.
|
|
283
|
+
if (
|
|
284
|
+
key === 'hooks' &&
|
|
285
|
+
typeof value === 'object' &&
|
|
286
|
+
value !== null &&
|
|
287
|
+
typeof original[key] === 'object' &&
|
|
288
|
+
original[key] !== null
|
|
289
|
+
) {
|
|
290
|
+
result[key] = opts.pruneHooks
|
|
291
|
+
? replaceHooksObject(original[key], value)
|
|
292
|
+
: mergeHooksObject(original[key], value);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// If this specific value is protected and exists locally, skip it
|
|
297
|
+
if (isValueProtected(keyPath) && original.hasOwnProperty(key)) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Special handling for mcpServers: merge individual server entries
|
|
302
|
+
if (key === 'mcpServers' && typeof value === 'object' && value !== null &&
|
|
303
|
+
typeof original[key] === 'object' && original[key] !== null) {
|
|
304
|
+
|
|
305
|
+
result[key] = { ...original[key] }; // Start with original servers
|
|
306
|
+
|
|
307
|
+
// Add servers from updates that don't exist in original
|
|
308
|
+
for (const [serverName, serverConfig] of Object.entries(value)) {
|
|
309
|
+
if (!result[key].hasOwnProperty(serverName)) {
|
|
310
|
+
result[key][serverName] = serverConfig;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} else if (
|
|
314
|
+
typeof value === 'object' &&
|
|
315
|
+
value !== null &&
|
|
316
|
+
!Array.isArray(value) &&
|
|
317
|
+
typeof original[key] === 'object' &&
|
|
318
|
+
original[key] !== null &&
|
|
319
|
+
!Array.isArray(original[key])
|
|
320
|
+
) {
|
|
321
|
+
// Recursively merge nested objects
|
|
322
|
+
result[key] = deepMergeWithProtection(original[key], value, keyPath, opts);
|
|
323
|
+
} else {
|
|
324
|
+
// Overwrite with new value for non-protected keys
|
|
325
|
+
result[key] = value;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface AtomicWriteOptions {
|
|
333
|
+
preserveComments?: boolean;
|
|
334
|
+
backupOnSuccess?: boolean;
|
|
335
|
+
backupSuffix?: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Atomically write data to a file using a temporary file
|
|
340
|
+
*/
|
|
341
|
+
export async function atomicWrite(filePath: string, data: any, options: AtomicWriteOptions = {}): Promise<void> {
|
|
342
|
+
const {
|
|
343
|
+
preserveComments = false,
|
|
344
|
+
backupOnSuccess = false,
|
|
345
|
+
backupSuffix = '.bak'
|
|
346
|
+
} = options;
|
|
347
|
+
|
|
348
|
+
const tempFilePath = `${filePath}.tmp.${randomUUID()}`;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
let content: string;
|
|
352
|
+
if (preserveComments) {
|
|
353
|
+
content = stringify(data, null, 2);
|
|
354
|
+
} else {
|
|
355
|
+
content = JSON.stringify(data, null, 2);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
await fs.writeFile(tempFilePath, content, 'utf8');
|
|
359
|
+
|
|
360
|
+
const tempStats = await fs.stat(tempFilePath);
|
|
361
|
+
if (tempStats.size === 0) {
|
|
362
|
+
throw new Error('Temporary file is empty - write failed');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (backupOnSuccess && await fs.pathExists(filePath)) {
|
|
366
|
+
const backupPath = `${filePath}${backupSuffix}`;
|
|
367
|
+
await fs.copy(filePath, backupPath);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
await fs.rename(tempFilePath, filePath);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
try {
|
|
373
|
+
if (await fs.pathExists(tempFilePath)) {
|
|
374
|
+
await fs.unlink(tempFilePath);
|
|
375
|
+
}
|
|
376
|
+
} catch (cleanupError) { }
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Safely read a JSON configuration file with error handling
|
|
383
|
+
*/
|
|
384
|
+
export async function safeReadConfig(filePath: string): Promise<any> {
|
|
385
|
+
try {
|
|
386
|
+
if (!(await fs.pathExists(filePath))) {
|
|
387
|
+
return {};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
return parse(content);
|
|
394
|
+
} catch (parseError) {
|
|
395
|
+
return JSON.parse(content);
|
|
396
|
+
}
|
|
397
|
+
} catch (error: any) {
|
|
398
|
+
if (error.code === 'ENOENT') return {};
|
|
399
|
+
throw new Error(`Failed to read config file: ${error.message}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
interface MergeOptions {
|
|
404
|
+
preserveComments?: boolean;
|
|
405
|
+
backupOnSuccess?: boolean;
|
|
406
|
+
dryRun?: boolean;
|
|
407
|
+
resolvedLocalConfig?: any;
|
|
408
|
+
pruneHooks?: boolean;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export interface MergeResult {
|
|
412
|
+
updated: boolean;
|
|
413
|
+
changes: string[];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Perform a safe merge of repository config with local config
|
|
418
|
+
*/
|
|
419
|
+
export async function safeMergeConfig(localConfigPath: string, repoConfig: any, options: MergeOptions = {}): Promise<MergeResult> {
|
|
420
|
+
const {
|
|
421
|
+
preserveComments = true,
|
|
422
|
+
backupOnSuccess = true,
|
|
423
|
+
dryRun = false,
|
|
424
|
+
resolvedLocalConfig = null,
|
|
425
|
+
pruneHooks = false,
|
|
426
|
+
} = options;
|
|
427
|
+
|
|
428
|
+
const localConfig = resolvedLocalConfig || await safeReadConfig(localConfigPath);
|
|
429
|
+
const changes: string[] = [];
|
|
430
|
+
|
|
431
|
+
if (localConfig.mcpServers && typeof localConfig.mcpServers === 'object') {
|
|
432
|
+
const localServerNames = Object.keys(localConfig.mcpServers);
|
|
433
|
+
if (localServerNames.length > 0) {
|
|
434
|
+
changes.push(`Preserved ${localServerNames.length} local mcpServers: ${localServerNames.join(', ')}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (repoConfig.mcpServers && typeof repoConfig.mcpServers === 'object') {
|
|
439
|
+
const repoServerNames = Object.keys(repoConfig.mcpServers);
|
|
440
|
+
const newServerNames = repoServerNames.filter(name =>
|
|
441
|
+
!localConfig.mcpServers || !localConfig.mcpServers.hasOwnProperty(name)
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
if (newServerNames.length > 0) {
|
|
445
|
+
changes.push(`Added ${newServerNames.length} new non-conflicting mcpServers from repository: ${newServerNames.join(', ')}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const mergedConfig = deepMergeWithProtection(localConfig, repoConfig, '', { pruneHooks });
|
|
450
|
+
const configsAreEqual = JSON.stringify(localConfig) === JSON.stringify(mergedConfig);
|
|
451
|
+
|
|
452
|
+
if (!configsAreEqual && !dryRun) {
|
|
453
|
+
await atomicWrite(localConfigPath, mergedConfig, {
|
|
454
|
+
preserveComments,
|
|
455
|
+
backupOnSuccess
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
updated: !configsAreEqual,
|
|
461
|
+
changes
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function getProtectedKeys(): string[] {
|
|
466
|
+
return [...PROTECTED_KEYS];
|
|
467
|
+
}
|