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,427 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import kleur from 'kleur';
|
|
4
|
+
import { safeMergeConfig } from '../utils/atomic-config.js';
|
|
5
|
+
import { ConfigAdapter } from '../utils/config-adapter.js';
|
|
6
|
+
import { syncMcpServersWithCli, loadCanonicalMcpConfig, detectAgent } from '../utils/sync-mcp-cli.js';
|
|
7
|
+
import { hashDirectory } from '../utils/hash.js';
|
|
8
|
+
import { createBackup, restoreBackup, cleanupBackup, type BackupInfo } from './rollback.js';
|
|
9
|
+
import type { ChangeSet } from '../types/config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sync MCP servers for a list of targets, once per unique agent type.
|
|
13
|
+
* Call this explicitly before per-target file sync loops.
|
|
14
|
+
*/
|
|
15
|
+
export async function syncMcpForTargets(
|
|
16
|
+
repoRoot: string,
|
|
17
|
+
targets: string[],
|
|
18
|
+
isDryRun: boolean = false,
|
|
19
|
+
selectedMcpServers?: string[],
|
|
20
|
+
): Promise<number> {
|
|
21
|
+
const synced = new Set<string>();
|
|
22
|
+
let count = 0;
|
|
23
|
+
|
|
24
|
+
for (const target of targets) {
|
|
25
|
+
const agent = detectAgent(target);
|
|
26
|
+
if (!agent || synced.has(agent)) continue;
|
|
27
|
+
|
|
28
|
+
const coreConfig = loadCanonicalMcpConfig(repoRoot);
|
|
29
|
+
const mcpToSync: any = { mcpServers: { ...coreConfig.mcpServers } };
|
|
30
|
+
|
|
31
|
+
if (selectedMcpServers && selectedMcpServers.length > 0) {
|
|
32
|
+
const optionalConfig = loadCanonicalMcpConfig(repoRoot, true);
|
|
33
|
+
for (const name of selectedMcpServers) {
|
|
34
|
+
if (optionalConfig.mcpServers[name]) {
|
|
35
|
+
mcpToSync.mcpServers[name] = optionalConfig.mcpServers[name];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!isDryRun) {
|
|
41
|
+
await syncMcpServersWithCli(agent, mcpToSync, isDryRun, false);
|
|
42
|
+
} else {
|
|
43
|
+
console.log(kleur.cyan(` [DRY RUN] MCP sync for ${agent}`));
|
|
44
|
+
}
|
|
45
|
+
synced.add(agent);
|
|
46
|
+
count++;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return count;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Execute a sync plan based on changeset and mode
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
function extractHookCommandPath(command: string): string | null {
|
|
57
|
+
const quoted = command.match(/"([^"]+)"/);
|
|
58
|
+
if (quoted?.[1]) return quoted[1];
|
|
59
|
+
|
|
60
|
+
const singleQuoted = command.match(/'([^']+)'/);
|
|
61
|
+
if (singleQuoted?.[1]) return singleQuoted[1];
|
|
62
|
+
|
|
63
|
+
const bare = command.trim().split(/\s+/).slice(1).join(' ').trim();
|
|
64
|
+
return bare || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function filterHooksByInstalledScripts(hooksConfig: any): Promise<any> {
|
|
68
|
+
if (!hooksConfig || typeof hooksConfig !== 'object' || !hooksConfig.hooks) {
|
|
69
|
+
return hooksConfig;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [event, wrappers] of Object.entries(hooksConfig.hooks)) {
|
|
73
|
+
if (!Array.isArray(wrappers)) continue;
|
|
74
|
+
|
|
75
|
+
const keptWrappers: any[] = [];
|
|
76
|
+
for (const wrapper of wrappers) {
|
|
77
|
+
if (!wrapper || !Array.isArray(wrapper.hooks)) continue;
|
|
78
|
+
|
|
79
|
+
const keptInner: any[] = [];
|
|
80
|
+
for (const inner of wrapper.hooks) {
|
|
81
|
+
const command = inner?.command;
|
|
82
|
+
if (typeof command !== 'string' || !command.trim()) continue;
|
|
83
|
+
|
|
84
|
+
const scriptPath = extractHookCommandPath(command);
|
|
85
|
+
if (!scriptPath) continue;
|
|
86
|
+
|
|
87
|
+
if (await fs.pathExists(scriptPath)) {
|
|
88
|
+
keptInner.push(inner);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (keptInner.length > 0) {
|
|
93
|
+
keptWrappers.push({ ...wrapper, hooks: keptInner });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
hooksConfig.hooks[event] = keptWrappers;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return hooksConfig;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function executeSync(
|
|
104
|
+
repoRoot: string,
|
|
105
|
+
systemRoot: string,
|
|
106
|
+
changeSet: ChangeSet,
|
|
107
|
+
mode: 'copy' | 'symlink' | 'prune',
|
|
108
|
+
actionType: 'sync' | 'backport',
|
|
109
|
+
isDryRun: boolean = false,
|
|
110
|
+
options?: { force?: boolean },
|
|
111
|
+
): Promise<number> {
|
|
112
|
+
const normalizedRoot = path.normalize(systemRoot).replace(/\\/g, '/');
|
|
113
|
+
const isAgentsSkills = normalizedRoot.includes('.agents/skills');
|
|
114
|
+
const isClaude = systemRoot.includes('.claude') || systemRoot.includes('Claude');
|
|
115
|
+
|
|
116
|
+
// ~/.agents/skills: skills-only, written directly into systemRoot (no subdirectory)
|
|
117
|
+
if (isAgentsSkills) {
|
|
118
|
+
return executeSyncAgentsSkills(repoRoot, systemRoot, changeSet, mode, actionType, isDryRun);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const categories: Array<keyof ChangeSet> = ['skills', 'hooks', 'config'];
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
let count = 0;
|
|
125
|
+
const adapter = new ConfigAdapter(systemRoot);
|
|
126
|
+
const backups: BackupInfo[] = [];
|
|
127
|
+
const newHashes: Record<string, string> = {};
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const agent = detectAgent(systemRoot);
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
for (const category of categories) {
|
|
135
|
+
const itemsToProcess: string[] = [];
|
|
136
|
+
|
|
137
|
+
if (actionType === 'sync') {
|
|
138
|
+
const cat = changeSet[category] as any;
|
|
139
|
+
itemsToProcess.push(...cat.missing);
|
|
140
|
+
itemsToProcess.push(...cat.outdated);
|
|
141
|
+
|
|
142
|
+
if (options?.force) {
|
|
143
|
+
itemsToProcess.push(...cat.drifted);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (mode === 'prune') {
|
|
147
|
+
for (const itemToDelete of cat.drifted || []) {
|
|
148
|
+
const dest = path.join(systemRoot, category, itemToDelete);
|
|
149
|
+
console.log(kleur.red(` [x] PRUNING ${category}/${itemToDelete}`));
|
|
150
|
+
if (!isDryRun) {
|
|
151
|
+
if (await fs.pathExists(dest)) {
|
|
152
|
+
backups.push(await createBackup(dest));
|
|
153
|
+
await fs.remove(dest);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
count++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} else if (actionType === 'backport') {
|
|
160
|
+
const cat = changeSet[category] as any;
|
|
161
|
+
itemsToProcess.push(...cat.drifted);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const item of itemsToProcess) {
|
|
165
|
+
let src: string, dest: string;
|
|
166
|
+
|
|
167
|
+
if (category === 'config' && item === 'settings.json' && actionType === 'sync') {
|
|
168
|
+
src = path.join(repoRoot, 'config', 'settings.json');
|
|
169
|
+
dest = path.join(systemRoot, 'settings.json');
|
|
170
|
+
|
|
171
|
+
const agent = detectAgent(systemRoot);
|
|
172
|
+
|
|
173
|
+
// Claude Code: settings.json managed by xtrm-tools plugin — skip wiring
|
|
174
|
+
if (agent === 'claude') {
|
|
175
|
+
if (!isDryRun) console.log(kleur.dim(` (settings.json skipped — managed by xtrm-tools plugin)`));
|
|
176
|
+
count++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(kleur.gray(` --> config/settings.json`));
|
|
181
|
+
|
|
182
|
+
if (!isDryRun && await fs.pathExists(dest)) {
|
|
183
|
+
backups.push(await createBackup(dest));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const repoConfig = await fs.readJson(src);
|
|
187
|
+
let finalRepoConfig = resolveConfigPaths(repoConfig, systemRoot);
|
|
188
|
+
|
|
189
|
+
// When agent CLI handles MCP servers, strip them from the config merge
|
|
190
|
+
// but still merge hooks, permissions, plugins, skillSuggestions, etc.
|
|
191
|
+
if (agent) {
|
|
192
|
+
delete finalRepoConfig.mcpServers;
|
|
193
|
+
if (!isDryRun) console.log(kleur.dim(` (MCP servers managed by ${agent} CLI — merging non-MCP settings only)`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const hooksSrc = path.join(repoRoot, 'config', 'hooks.json');
|
|
197
|
+
if (await fs.pathExists(hooksSrc)) {
|
|
198
|
+
const hooksRaw = await fs.readJson(hooksSrc);
|
|
199
|
+
const hooksAdapted = await filterHooksByInstalledScripts(adapter.adaptHooksConfig(hooksRaw));
|
|
200
|
+
if (hooksAdapted.hooks) {
|
|
201
|
+
// hooks.json is the canonical source — replace template hooks entirely
|
|
202
|
+
// hooks.json is canonical source — replace template hooks entirely
|
|
203
|
+
finalRepoConfig.hooks = hooksAdapted.hooks;
|
|
204
|
+
if (!isDryRun) console.log(kleur.dim(` (Injected hooks)`));
|
|
205
|
+
}
|
|
206
|
+
if (hooksAdapted.statusLine) {
|
|
207
|
+
finalRepoConfig.statusLine = hooksAdapted.statusLine;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (fs.existsSync(dest)) {
|
|
212
|
+
const localConfig = await fs.readJson(dest);
|
|
213
|
+
const resolvedLocalConfig = resolveConfigPaths(localConfig, systemRoot);
|
|
214
|
+
|
|
215
|
+
if (mode === 'prune') {
|
|
216
|
+
if (localConfig.mcpServers && finalRepoConfig.mcpServers) {
|
|
217
|
+
const canonicalServers = new Set(Object.keys(finalRepoConfig.mcpServers));
|
|
218
|
+
for (const serverName of Object.keys(localConfig.mcpServers)) {
|
|
219
|
+
if (!canonicalServers.has(serverName)) {
|
|
220
|
+
delete localConfig.mcpServers[serverName];
|
|
221
|
+
if (!isDryRun) console.log(kleur.red(` (Pruned local MCP server: ${serverName})`));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (mode === 'prune' && !isDryRun) {
|
|
228
|
+
console.log(kleur.dim(` (--prune: replacing canonical hook events wholesale)`));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const mergeResult = await safeMergeConfig(dest, finalRepoConfig, {
|
|
232
|
+
backupOnSuccess: false,
|
|
233
|
+
preserveComments: true,
|
|
234
|
+
dryRun: isDryRun,
|
|
235
|
+
resolvedLocalConfig: resolvedLocalConfig,
|
|
236
|
+
pruneHooks: mode === 'prune',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (mergeResult.updated) {
|
|
240
|
+
console.log(kleur.blue(` (Configuration safely merged)`));
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
if (!isDryRun) {
|
|
244
|
+
await fs.ensureDir(path.dirname(dest));
|
|
245
|
+
await fs.writeJson(dest, finalRepoConfig, { spaces: 2 });
|
|
246
|
+
}
|
|
247
|
+
console.log(kleur.green(` (Created new configuration)`));
|
|
248
|
+
}
|
|
249
|
+
count++;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const repoPath = path.join(repoRoot, category);
|
|
254
|
+
const systemPath = path.join(systemRoot, category);
|
|
255
|
+
|
|
256
|
+
if (actionType === 'backport') {
|
|
257
|
+
src = path.join(systemPath, item);
|
|
258
|
+
dest = path.join(repoPath, item);
|
|
259
|
+
} else {
|
|
260
|
+
src = path.join(repoPath, item);
|
|
261
|
+
dest = path.join(systemPath, item);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(kleur.gray(` ${actionType === 'backport' ? '<--' : '-->'} ${category}/${item}`));
|
|
265
|
+
|
|
266
|
+
if (!isDryRun && actionType === 'sync' && await fs.pathExists(dest)) {
|
|
267
|
+
backups.push(await createBackup(dest));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (mode === 'symlink' && actionType === 'sync' && category !== 'config') {
|
|
271
|
+
if (!isDryRun) {
|
|
272
|
+
if (process.platform === 'win32') {
|
|
273
|
+
console.log(kleur.yellow(' ⚠ Symlinks require Developer Mode on Windows — falling back to copy.'));
|
|
274
|
+
await fs.remove(dest);
|
|
275
|
+
await fs.copy(src, dest);
|
|
276
|
+
} else {
|
|
277
|
+
await fs.remove(dest);
|
|
278
|
+
await fs.ensureSymlink(src, dest);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
if (!isDryRun) {
|
|
283
|
+
await fs.remove(dest);
|
|
284
|
+
await fs.copy(src, dest);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Record repo hash so future drift checks can distinguish
|
|
289
|
+
// "user modified" from "repo updated" without relying on mtime
|
|
290
|
+
if (!isDryRun && actionType === 'sync') {
|
|
291
|
+
newHashes[`${category}/${item}`] = await hashDirectory(src);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
count++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!isDryRun && actionType === 'sync') {
|
|
299
|
+
const manifestPath = path.join(systemRoot, '.jaggers-sync-manifest.json');
|
|
300
|
+
const existing = await fs.pathExists(manifestPath)
|
|
301
|
+
? await fs.readJson(manifestPath)
|
|
302
|
+
: {};
|
|
303
|
+
await fs.writeJson(manifestPath, {
|
|
304
|
+
...existing,
|
|
305
|
+
lastSync: new Date().toISOString(),
|
|
306
|
+
repoRoot,
|
|
307
|
+
items: count,
|
|
308
|
+
fileHashes: { ...(existing.fileHashes ?? {}), ...newHashes },
|
|
309
|
+
}, { spaces: 2 });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const backup of backups) {
|
|
313
|
+
await cleanupBackup(backup);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return count;
|
|
317
|
+
|
|
318
|
+
} catch (error: any) {
|
|
319
|
+
console.error(kleur.red(`\nSync failed, rolling back ${backups.length} changes...`));
|
|
320
|
+
for (const backup of backups) {
|
|
321
|
+
try {
|
|
322
|
+
await restoreBackup(backup);
|
|
323
|
+
} finally {
|
|
324
|
+
await cleanupBackup(backup);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Sync skills directly into ~/.agents/skills/<skill> (no subdirectory indirection).
|
|
333
|
+
* This target is skills-only — no hooks, config, MCP, or commands.
|
|
334
|
+
*/
|
|
335
|
+
async function executeSyncAgentsSkills(
|
|
336
|
+
repoRoot: string,
|
|
337
|
+
systemRoot: string,
|
|
338
|
+
changeSet: ChangeSet,
|
|
339
|
+
mode: 'copy' | 'symlink' | 'prune',
|
|
340
|
+
actionType: 'sync' | 'backport',
|
|
341
|
+
isDryRun: boolean,
|
|
342
|
+
): Promise<number> {
|
|
343
|
+
let count = 0;
|
|
344
|
+
const backups: BackupInfo[] = [];
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const repoSkillsPath = path.join(repoRoot, 'skills');
|
|
348
|
+
const itemsToProcess: string[] = [];
|
|
349
|
+
|
|
350
|
+
if (actionType === 'sync') {
|
|
351
|
+
itemsToProcess.push(...changeSet.skills.missing, ...changeSet.skills.outdated);
|
|
352
|
+
} else if (actionType === 'backport') {
|
|
353
|
+
itemsToProcess.push(...changeSet.skills.drifted);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const item of itemsToProcess) {
|
|
357
|
+
const src = actionType === 'backport'
|
|
358
|
+
? path.join(systemRoot, item)
|
|
359
|
+
: path.join(repoSkillsPath, item);
|
|
360
|
+
const dest = actionType === 'backport'
|
|
361
|
+
? path.join(repoSkillsPath, item)
|
|
362
|
+
: path.join(systemRoot, item);
|
|
363
|
+
|
|
364
|
+
console.log(kleur.gray(` ${actionType === 'backport' ? '<--' : '-->'} ${item}`));
|
|
365
|
+
|
|
366
|
+
if (!isDryRun) {
|
|
367
|
+
if (await fs.pathExists(dest)) backups.push(await createBackup(dest));
|
|
368
|
+
await fs.ensureDir(path.dirname(dest));
|
|
369
|
+
|
|
370
|
+
if (mode === 'symlink' && actionType === 'sync') {
|
|
371
|
+
if (process.platform === 'win32') {
|
|
372
|
+
console.log(kleur.yellow(' ⚠ Symlinks require Developer Mode on Windows — falling back to copy.'));
|
|
373
|
+
await fs.remove(dest);
|
|
374
|
+
await fs.copy(src, dest);
|
|
375
|
+
} else {
|
|
376
|
+
await fs.remove(dest);
|
|
377
|
+
await fs.ensureSymlink(src, dest);
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
await fs.remove(dest);
|
|
381
|
+
await fs.copy(src, dest);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
count++;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (const backup of backups) await cleanupBackup(backup);
|
|
388
|
+
return count;
|
|
389
|
+
|
|
390
|
+
} catch (error: any) {
|
|
391
|
+
console.error(kleur.red(`\nSync failed, rolling back ${backups.length} changes...`));
|
|
392
|
+
for (const backup of backups) {
|
|
393
|
+
try { await restoreBackup(backup); } finally { await cleanupBackup(backup); }
|
|
394
|
+
}
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function resolveConfigPaths(config: any, targetDir: string): any {
|
|
400
|
+
const newConfig = JSON.parse(JSON.stringify(config));
|
|
401
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
402
|
+
|
|
403
|
+
function recursiveReplace(obj: any) {
|
|
404
|
+
for (const key in obj) {
|
|
405
|
+
if (typeof obj[key] === 'string') {
|
|
406
|
+
let val = obj[key];
|
|
407
|
+
// Resolve $HOME and ~ to actual home directory for comparison
|
|
408
|
+
// but only match absolute paths, not env-var paths like "$HOME/..."
|
|
409
|
+
if (!val.startsWith('$') && !val.startsWith('~')) {
|
|
410
|
+
if (val.match(/\/[^\s"']+\/hooks\//)) {
|
|
411
|
+
const hooksDir = path.join(targetDir, 'hooks');
|
|
412
|
+
let replacementDir = `${hooksDir}/`;
|
|
413
|
+
if (process.platform === 'win32') {
|
|
414
|
+
replacementDir = replacementDir.replace(/\\/g, '/');
|
|
415
|
+
}
|
|
416
|
+
obj[key] = val.replace(/(\/[^\s"']+\/hooks\/)/g, replacementDir);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
|
|
420
|
+
recursiveReplace(obj[key]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
recursiveReplace(newConfig);
|
|
426
|
+
return newConfig;
|
|
427
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import {
|
|
4
|
+
readSessionState,
|
|
5
|
+
updateSessionPhase,
|
|
6
|
+
type SessionState,
|
|
7
|
+
} from './session-state.js';
|
|
8
|
+
|
|
9
|
+
export interface FinishOptions {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
pollIntervalMs?: number;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FinishResult {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CmdResult {
|
|
21
|
+
code: number;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
function run(cmd: string, args: string[], cwd: string): CmdResult {
|
|
30
|
+
const r = spawnSync(cmd, args, {
|
|
31
|
+
cwd,
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
code: r.status ?? 1,
|
|
37
|
+
stdout: (r.stdout ?? '').trim(),
|
|
38
|
+
stderr: (r.stderr ?? '').trim(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getControlRepoRoot(cwd: string): string {
|
|
43
|
+
const commonDir = run('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
|
|
44
|
+
if (commonDir.code === 0 && commonDir.stdout) {
|
|
45
|
+
return commonDir.stdout.replace(/\/.git$/, '');
|
|
46
|
+
}
|
|
47
|
+
return cwd;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveExecutionCwd(controlCwd: string, state: SessionState): string {
|
|
51
|
+
if (state.worktreePath && existsSync(state.worktreePath)) {
|
|
52
|
+
return state.worktreePath;
|
|
53
|
+
}
|
|
54
|
+
return controlCwd;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parsePrCreate(stdout: string): { prNumber: number | null; prUrl: string | null } {
|
|
58
|
+
const urlMatch = stdout.match(/https?:\/\/\S+\/pull\/(\d+)/);
|
|
59
|
+
if (urlMatch) {
|
|
60
|
+
return { prNumber: Number(urlMatch[1]), prUrl: urlMatch[0] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const numberMatch = stdout.match(/#(\d+)/);
|
|
64
|
+
if (numberMatch) {
|
|
65
|
+
return { prNumber: Number(numberMatch[1]), prUrl: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { prNumber: null, prUrl: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getConflictFiles(cwd: string): string[] {
|
|
72
|
+
const out = run('git', ['diff', '--name-only', '--diff-filter=U'], cwd);
|
|
73
|
+
if (out.code !== 0 || !out.stdout) return [];
|
|
74
|
+
return out.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function delay(ms: number): Promise<void> {
|
|
78
|
+
if (ms <= 0) return;
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ensureCleanPhaseTransition(cwd: string, phase: SessionState['phase'], patch: Partial<SessionState> = {}) {
|
|
83
|
+
try {
|
|
84
|
+
updateSessionPhase(phase, cwd, patch);
|
|
85
|
+
} catch {
|
|
86
|
+
// non-fatal for re-entrant paths
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleRebaseAndPush(cwd: string): { ok: boolean; conflicts?: string[]; error?: string } {
|
|
91
|
+
const fetch = run('git', ['fetch', 'origin'], cwd);
|
|
92
|
+
if (fetch.code !== 0) return { ok: false, error: fetch.stderr || fetch.stdout };
|
|
93
|
+
|
|
94
|
+
const rebase = run('git', ['rebase', 'origin/main'], cwd);
|
|
95
|
+
if (rebase.code !== 0) {
|
|
96
|
+
const conflicts = getConflictFiles(cwd);
|
|
97
|
+
return { ok: false, conflicts, error: rebase.stderr || rebase.stdout };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const push = run('git', ['push', '--force-with-lease'], cwd);
|
|
101
|
+
if (push.code !== 0) {
|
|
102
|
+
return { ok: false, error: push.stderr || push.stdout };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cleanupPhase(controlCwd: string, state: SessionState): FinishResult {
|
|
109
|
+
if (existsSync(state.worktreePath)) {
|
|
110
|
+
const rm = run('git', ['worktree', 'remove', state.worktreePath, '--force'], controlCwd);
|
|
111
|
+
if (rm.code !== 0) {
|
|
112
|
+
return { ok: false, message: rm.stderr || rm.stdout || `Failed to remove worktree ${state.worktreePath}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
run('git', ['fetch', '--prune'], controlCwd);
|
|
117
|
+
ensureCleanPhaseTransition(controlCwd, 'cleanup-done');
|
|
118
|
+
|
|
119
|
+
const prLabel = state.prNumber != null ? `#${state.prNumber}` : '(unknown PR)';
|
|
120
|
+
return { ok: true, message: `Done. PR ${prLabel} merged. Worktree removed.` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function pollUntilMerged(controlCwd: string, executionCwd: string, state: SessionState, opts: Required<FinishOptions>): Promise<FinishResult> {
|
|
124
|
+
if (state.prNumber == null) {
|
|
125
|
+
return { ok: false, message: 'Session state missing prNumber. Re-run phase 1 before polling.' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const started = Date.now();
|
|
129
|
+
while ((Date.now() - started) < opts.timeoutMs) {
|
|
130
|
+
const view = run('gh', ['pr', 'view', String(state.prNumber), '--json', 'state,mergeStateStatus,mergeable'], executionCwd);
|
|
131
|
+
|
|
132
|
+
if (view.code !== 0) {
|
|
133
|
+
return { ok: false, message: view.stderr || view.stdout || `Failed to inspect PR #${state.prNumber}` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let payload: any = null;
|
|
137
|
+
try {
|
|
138
|
+
payload = JSON.parse(view.stdout);
|
|
139
|
+
} catch {
|
|
140
|
+
return { ok: false, message: `Unable to parse gh pr view output for #${state.prNumber}` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ensureCleanPhaseTransition(controlCwd, 'waiting-merge');
|
|
144
|
+
|
|
145
|
+
if (payload.state === 'MERGED') {
|
|
146
|
+
ensureCleanPhaseTransition(controlCwd, 'merged');
|
|
147
|
+
const latest = readSessionState(controlCwd) ?? state;
|
|
148
|
+
return cleanupPhase(controlCwd, latest);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (payload.mergeStateStatus === 'BEHIND') {
|
|
152
|
+
run('git', ['fetch', 'origin'], executionCwd);
|
|
153
|
+
run('git', ['push'], executionCwd);
|
|
154
|
+
await delay(opts.pollIntervalMs);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (payload.mergeable === 'CONFLICTING') {
|
|
159
|
+
const rebased = handleRebaseAndPush(executionCwd);
|
|
160
|
+
if (!rebased.ok) {
|
|
161
|
+
const conflictFiles = rebased.conflicts ?? getConflictFiles(executionCwd);
|
|
162
|
+
ensureCleanPhaseTransition(controlCwd, 'conflicting', { conflictFiles });
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
message: `Conflicts in: ${conflictFiles.join(', ') || 'unknown files'}. Resolve, push, then re-run xtrm finish.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
ensureCleanPhaseTransition(controlCwd, 'waiting-merge', { conflictFiles: [] });
|
|
170
|
+
await delay(opts.pollIntervalMs);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await delay(opts.pollIntervalMs);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ensureCleanPhaseTransition(controlCwd, 'pending-cleanup');
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
message: `PR #${state.prNumber} not yet merged. Run xtrm finish when ready.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isWorkingTreeDirty(cwd: string): boolean {
|
|
185
|
+
const st = run('git', ['status', '--porcelain'], cwd);
|
|
186
|
+
return st.code === 0 && st.stdout.length > 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function runPhase1(controlCwd: string, executionCwd: string, state: SessionState): FinishResult {
|
|
190
|
+
if (isWorkingTreeDirty(executionCwd)) {
|
|
191
|
+
const add = run('git', ['add', '-A'], executionCwd);
|
|
192
|
+
if (add.code !== 0) return { ok: false, message: add.stderr || add.stdout };
|
|
193
|
+
|
|
194
|
+
const msg = `feat(${state.issueId}): ${state.branch}`;
|
|
195
|
+
const commit = run('git', ['commit', '-m', msg], executionCwd);
|
|
196
|
+
if (commit.code !== 0) {
|
|
197
|
+
return { ok: false, message: commit.stderr || commit.stdout || 'git commit failed' };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const push = run('git', ['push', '-u', 'origin', state.branch], executionCwd);
|
|
202
|
+
if (push.code !== 0) return { ok: false, message: push.stderr || push.stdout || 'git push failed' };
|
|
203
|
+
|
|
204
|
+
const create = run('gh', ['pr', 'create', '--fill'], executionCwd);
|
|
205
|
+
if (create.code !== 0) return { ok: false, message: create.stderr || create.stdout || 'gh pr create failed' };
|
|
206
|
+
const parsed = parsePrCreate(create.stdout);
|
|
207
|
+
|
|
208
|
+
const merge = run('gh', ['pr', 'merge', '--squash', '--auto'], executionCwd);
|
|
209
|
+
if (merge.code !== 0) return { ok: false, message: merge.stderr || merge.stdout || 'gh pr merge failed' };
|
|
210
|
+
|
|
211
|
+
ensureCleanPhaseTransition(controlCwd, 'phase1-done', {
|
|
212
|
+
prNumber: parsed.prNumber,
|
|
213
|
+
prUrl: parsed.prUrl,
|
|
214
|
+
});
|
|
215
|
+
ensureCleanPhaseTransition(controlCwd, 'waiting-merge', {
|
|
216
|
+
prNumber: parsed.prNumber,
|
|
217
|
+
prUrl: parsed.prUrl,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return { ok: true, message: 'phase1 complete' };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function runXtrmFinish(options: FinishOptions = {}): Promise<FinishResult> {
|
|
224
|
+
const cwd = options.cwd ?? process.cwd();
|
|
225
|
+
const controlCwd = getControlRepoRoot(cwd);
|
|
226
|
+
|
|
227
|
+
const state = readSessionState(controlCwd);
|
|
228
|
+
if (!state) {
|
|
229
|
+
return { ok: false, message: 'No .xtrm-session-state.json found. Claim an issue first (bd update <id> --claim).' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const executionCwd = resolveExecutionCwd(controlCwd, state);
|
|
233
|
+
|
|
234
|
+
const opts: Required<FinishOptions> = {
|
|
235
|
+
cwd: controlCwd,
|
|
236
|
+
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
237
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (state.phase === 'cleanup-done') {
|
|
241
|
+
return { ok: true, message: 'Session is already cleanup-done.' };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (state.phase === 'conflicting') {
|
|
245
|
+
const resolved = handleRebaseAndPush(executionCwd);
|
|
246
|
+
if (!resolved.ok) {
|
|
247
|
+
const files = resolved.conflicts ?? getConflictFiles(executionCwd);
|
|
248
|
+
ensureCleanPhaseTransition(controlCwd, 'conflicting', { conflictFiles: files });
|
|
249
|
+
return { ok: false, message: `Conflicts in: ${files.join(', ') || 'unknown files'}. Resolve, push, then re-run xtrm finish.` };
|
|
250
|
+
}
|
|
251
|
+
ensureCleanPhaseTransition(controlCwd, 'waiting-merge', { conflictFiles: [] });
|
|
252
|
+
const refreshed = readSessionState(controlCwd) ?? state;
|
|
253
|
+
return pollUntilMerged(controlCwd, executionCwd, refreshed, opts);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (state.phase === 'waiting-merge' || state.phase === 'pending-cleanup' || state.phase === 'merged') {
|
|
257
|
+
const refreshed = readSessionState(controlCwd) ?? state;
|
|
258
|
+
if (refreshed.phase === 'merged') return cleanupPhase(controlCwd, refreshed);
|
|
259
|
+
return pollUntilMerged(controlCwd, executionCwd, refreshed, opts);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const phase1 = runPhase1(controlCwd, executionCwd, state);
|
|
263
|
+
if (!phase1.ok) return phase1;
|
|
264
|
+
|
|
265
|
+
const refreshed = readSessionState(controlCwd) ?? state;
|
|
266
|
+
return pollUntilMerged(controlCwd, executionCwd, refreshed, opts);
|
|
267
|
+
}
|