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,165 @@
|
|
|
1
|
+
// @ts-ignore
|
|
2
|
+
import prompts from 'prompts';
|
|
3
|
+
import kleur from 'kleur';
|
|
4
|
+
import type { PreflightPlan, TargetPlan, OptionalServerItem } from './preflight.js';
|
|
5
|
+
|
|
6
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface SelectedFileItem {
|
|
9
|
+
target: string;
|
|
10
|
+
name: string;
|
|
11
|
+
status: 'missing' | 'outdated' | 'drifted';
|
|
12
|
+
category: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SelectedMcpItem {
|
|
16
|
+
target: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SelectedPlan {
|
|
22
|
+
files: SelectedFileItem[];
|
|
23
|
+
mcpCore: SelectedMcpItem[];
|
|
24
|
+
optionalServers: OptionalServerItem[];
|
|
25
|
+
repoRoot: string;
|
|
26
|
+
syncMode: 'copy' | 'symlink';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
32
|
+
missing: kleur.green('[+]'),
|
|
33
|
+
outdated: kleur.yellow('[↑]'), // yellow = actionable warning, not blue
|
|
34
|
+
drifted: kleur.magenta('[≠]'), // magenta = conflict/divergence
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function fileChoices(target: TargetPlan): any[] {
|
|
38
|
+
if (target.files.length === 0) return [];
|
|
39
|
+
const choices: any[] = [
|
|
40
|
+
{ title: kleur.bold().dim(` ── ${target.label} files ──`), disabled: true, value: null },
|
|
41
|
+
];
|
|
42
|
+
for (const f of target.files) {
|
|
43
|
+
const label = STATUS_LABEL[f.status] ?? '[?]';
|
|
44
|
+
const hint = f.status === 'drifted' ? kleur.dim(' local edits — skip recommended') : '';
|
|
45
|
+
choices.push({
|
|
46
|
+
title: ` ${label} ${f.category}/${f.name}${hint}`,
|
|
47
|
+
value: { type: 'file', target: target.target, name: f.name, status: f.status, category: f.category },
|
|
48
|
+
selected: f.status !== 'drifted',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return choices;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mcpCoreChoices(target: TargetPlan): any[] {
|
|
55
|
+
const uninstalled = target.mcpCore.filter(m => !m.installed);
|
|
56
|
+
const installed = target.mcpCore.filter(m => m.installed);
|
|
57
|
+
if (target.mcpCore.length === 0) return [];
|
|
58
|
+
|
|
59
|
+
const choices: any[] = [
|
|
60
|
+
{ title: kleur.bold().dim(` ── ${target.label} MCP servers ──`), disabled: true, value: null },
|
|
61
|
+
];
|
|
62
|
+
for (const m of uninstalled) {
|
|
63
|
+
choices.push({
|
|
64
|
+
title: ` ${kleur.green('[+]')} ${m.name}`,
|
|
65
|
+
value: { type: 'mcp-core', target: target.target, agent: target.agent, name: m.name },
|
|
66
|
+
selected: true,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const m of installed) {
|
|
70
|
+
choices.push({
|
|
71
|
+
title: kleur.dim(` [=] ${m.name} (already installed)`),
|
|
72
|
+
disabled: true,
|
|
73
|
+
value: null,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return choices;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function optionalChoices(optionalServers: OptionalServerItem[]): any[] {
|
|
80
|
+
if (optionalServers.length === 0) return [];
|
|
81
|
+
const choices: any[] = [
|
|
82
|
+
{ title: kleur.bold().dim(' ── optional servers ──'), disabled: true, value: null },
|
|
83
|
+
];
|
|
84
|
+
for (const s of optionalServers) {
|
|
85
|
+
const prereq = s.prerequisite ? kleur.yellow(` ⚠ ${s.prerequisite}`) : '';
|
|
86
|
+
choices.push({
|
|
87
|
+
title: ` ${kleur.yellow('[?]')} ${s.name}${prereq}`,
|
|
88
|
+
value: { type: 'mcp-optional', server: s },
|
|
89
|
+
selected: false,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return choices;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Main export ────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export async function interactivePlan(
|
|
98
|
+
plan: PreflightPlan,
|
|
99
|
+
opts: { dryRun?: boolean; yes?: boolean } = {}
|
|
100
|
+
): Promise<SelectedPlan | null> {
|
|
101
|
+
const allChoices = [
|
|
102
|
+
...plan.targets.flatMap(t => [...fileChoices(t), ...mcpCoreChoices(t)]),
|
|
103
|
+
...optionalChoices(plan.optionalServers),
|
|
104
|
+
].filter(c => c.title); // remove any undefined entries
|
|
105
|
+
|
|
106
|
+
const totalSelectable = allChoices.filter(c => !c.disabled && c.value !== null).length;
|
|
107
|
+
|
|
108
|
+
if (totalSelectable === 0) {
|
|
109
|
+
console.log(kleur.green('\n✓ Everything is up-to-date\n'));
|
|
110
|
+
return { files: [], mcpCore: [], optionalServers: [], repoRoot: plan.repoRoot, syncMode: plan.syncMode };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(kleur.bold('\n📋 Sync Plan') + kleur.dim(' (space to toggle, a = all, enter to confirm)\n'));
|
|
114
|
+
|
|
115
|
+
if (opts.dryRun) {
|
|
116
|
+
// Just display, don't prompt
|
|
117
|
+
for (const c of allChoices) {
|
|
118
|
+
if (c.disabled) { console.log(kleur.dim(c.title)); continue; }
|
|
119
|
+
const bullet = c.selected ? '◉' : '◯';
|
|
120
|
+
console.log(` ${bullet} ${c.title?.trim()}`);
|
|
121
|
+
}
|
|
122
|
+
console.log(kleur.cyan('\n💡 Dry run — no changes written\n'));
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (opts.yes) {
|
|
127
|
+
// Select all pre-selected defaults, skip prompt
|
|
128
|
+
const selected = allChoices.filter(c => !c.disabled && c.selected && c.value).map(c => c.value);
|
|
129
|
+
return buildSelectedPlan(selected, plan);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const response = await prompts({
|
|
133
|
+
type: 'multiselect',
|
|
134
|
+
name: 'selected',
|
|
135
|
+
message: 'Select items to sync:',
|
|
136
|
+
choices: allChoices,
|
|
137
|
+
hint: 'space to toggle · a = all · enter to confirm',
|
|
138
|
+
instructions: false,
|
|
139
|
+
min: 0,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ctrl+c returns undefined
|
|
143
|
+
if (!response || response.selected === undefined) {
|
|
144
|
+
console.log(kleur.gray('\n Cancelled.\n'));
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return buildSelectedPlan(response.selected, plan);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildSelectedPlan(selected: any[], plan: PreflightPlan): SelectedPlan {
|
|
152
|
+
const files: SelectedFileItem[] = selected
|
|
153
|
+
.filter(v => v?.type === 'file')
|
|
154
|
+
.map(v => ({ target: v.target, name: v.name, status: v.status, category: v.category }));
|
|
155
|
+
|
|
156
|
+
const mcpCore: SelectedMcpItem[] = selected
|
|
157
|
+
.filter(v => v?.type === 'mcp-core')
|
|
158
|
+
.map(v => ({ target: v.target, agent: v.agent, name: v.name }));
|
|
159
|
+
|
|
160
|
+
const optionalServers: OptionalServerItem[] = selected
|
|
161
|
+
.filter(v => v?.type === 'mcp-optional')
|
|
162
|
+
.map(v => v.server);
|
|
163
|
+
|
|
164
|
+
return { files, mcpCore, optionalServers, repoRoot: plan.repoRoot, syncMode: plan.syncMode };
|
|
165
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { Manifest, ManifestSchema } from '../types/config.js';
|
|
4
|
+
|
|
5
|
+
const MANIFEST_FILE = '.jaggers-sync-manifest.json';
|
|
6
|
+
|
|
7
|
+
export function getManifestPath(projectDir: string): string {
|
|
8
|
+
return join(projectDir, MANIFEST_FILE);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function loadManifest(projectDir: string): Promise<Manifest | null> {
|
|
12
|
+
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const content = await fs.readJson(manifestPath);
|
|
16
|
+
// Let schema validation gracefully fail or handle legacy
|
|
17
|
+
return ManifestSchema.parse(content) as Manifest;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function saveManifest(projectDir: string, manifest: any): Promise<void> {
|
|
24
|
+
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
25
|
+
await fs.writeJson(manifestPath, manifest, { spaces: 2 });
|
|
26
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { calculateDiff } from './diff.js';
|
|
6
|
+
import {
|
|
7
|
+
loadCanonicalMcpConfig,
|
|
8
|
+
getCurrentServers,
|
|
9
|
+
detectAgent,
|
|
10
|
+
} from '../utils/sync-mcp-cli.js';
|
|
11
|
+
import type { ChangeSet, ChangeSetCategory } from '../types/config.js';
|
|
12
|
+
import type { AgentName } from '../utils/sync-mcp-cli.js';
|
|
13
|
+
|
|
14
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface FileItem {
|
|
17
|
+
name: string;
|
|
18
|
+
status: 'missing' | 'outdated' | 'drifted';
|
|
19
|
+
category: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface McpItem {
|
|
23
|
+
name: string;
|
|
24
|
+
installed: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TargetPlan {
|
|
28
|
+
target: string;
|
|
29
|
+
label: string;
|
|
30
|
+
agent: string | null;
|
|
31
|
+
files: FileItem[];
|
|
32
|
+
mcpCore: McpItem[];
|
|
33
|
+
changeSet: ChangeSet;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OptionalServerItem {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
prerequisite?: string;
|
|
40
|
+
installCmd?: string;
|
|
41
|
+
postInstallMessage?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PreflightPlan {
|
|
45
|
+
targets: TargetPlan[];
|
|
46
|
+
optionalServers: OptionalServerItem[];
|
|
47
|
+
repoRoot: string;
|
|
48
|
+
syncMode: 'copy' | 'symlink' | 'prune';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function getCandidatePaths(): Array<{ label: string; path: string }> {
|
|
54
|
+
const home = os.homedir();
|
|
55
|
+
return [
|
|
56
|
+
{ label: '~/.claude (hooks + skills)', path: path.join(home, '.claude') },
|
|
57
|
+
{ label: '.qwen', path: path.join(home, '.qwen') },
|
|
58
|
+
{ label: '~/.agents/skills', path: path.join(home, '.agents', 'skills') },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isBinaryAvailable(binary: string): boolean {
|
|
63
|
+
// Uses spawnSync (not shell exec) to avoid shell injection; binary is always
|
|
64
|
+
// a hard-coded internal constant, never user-supplied input.
|
|
65
|
+
const result = spawnSync('which', [binary], { stdio: 'pipe' });
|
|
66
|
+
return result.status === 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Main export ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export async function runPreflight(
|
|
72
|
+
repoRoot: string,
|
|
73
|
+
prune = false
|
|
74
|
+
): Promise<PreflightPlan> {
|
|
75
|
+
const candidates = getCandidatePaths();
|
|
76
|
+
|
|
77
|
+
// Fix 4: Hoist canonical MCP config load outside the per-target map (read once, not N times)
|
|
78
|
+
const canonicalMcp = loadCanonicalMcpConfig(repoRoot);
|
|
79
|
+
|
|
80
|
+
// Run all target checks in parallel
|
|
81
|
+
const targetResults = await Promise.all(
|
|
82
|
+
candidates.map(async (c) => {
|
|
83
|
+
// Fix 1: Per-target error isolation — one bad target doesn't abort the whole preflight
|
|
84
|
+
try {
|
|
85
|
+
const exists = await fs.pathExists(c.path);
|
|
86
|
+
if (!exists) return null;
|
|
87
|
+
|
|
88
|
+
const agent = detectAgent(c.path);
|
|
89
|
+
|
|
90
|
+
const changeSet = await calculateDiff(repoRoot, c.path, prune);
|
|
91
|
+
|
|
92
|
+
// Fix 3: Use proper ChangeSetCategory type cast instead of `cat as any`
|
|
93
|
+
const files: FileItem[] = [];
|
|
94
|
+
for (const [category, cat] of Object.entries(changeSet) as [string, ChangeSetCategory][]) {
|
|
95
|
+
for (const name of cat.missing) files.push({ name, status: 'missing', category });
|
|
96
|
+
for (const name of cat.outdated) files.push({ name, status: 'outdated', category });
|
|
97
|
+
for (const name of cat.drifted) files.push({ name, status: 'drifted', category });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const installedMcp = agent ? getCurrentServers(agent) : [];
|
|
101
|
+
const mcpCore: McpItem[] = Object.keys(canonicalMcp.mcpServers || {}).map(name => ({
|
|
102
|
+
name,
|
|
103
|
+
installed: installedMcp.includes(name),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
return { target: c.path, label: c.label, agent, files, mcpCore, changeSet };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.warn(`⚠ Preflight skipped ${c.label}: ${(err as Error).message}`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const targets = targetResults.filter((t): t is TargetPlan => t !== null);
|
|
115
|
+
|
|
116
|
+
// Fix 2: Gather all actually installed MCP servers (across all agents, core + optional)
|
|
117
|
+
// allInstalledMcp previously only contained core server names from mcpCore — optional servers
|
|
118
|
+
// installed via CLI were never excluded from the optionalServers list.
|
|
119
|
+
const allInstalledMcp = new Set<string>();
|
|
120
|
+
for (const t of targets) {
|
|
121
|
+
if (t.agent) {
|
|
122
|
+
const installed = getCurrentServers(t.agent as AgentName);
|
|
123
|
+
for (const name of installed) allInstalledMcp.add(name);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Load optional servers config
|
|
128
|
+
const optionalConfig = loadCanonicalMcpConfig(repoRoot, true);
|
|
129
|
+
|
|
130
|
+
const optionalServers: OptionalServerItem[] = Object.entries(optionalConfig.mcpServers || {})
|
|
131
|
+
.filter(([name]) => !allInstalledMcp.has(name))
|
|
132
|
+
.map(([name, server]: [string, any]) => ({
|
|
133
|
+
name,
|
|
134
|
+
description: server._notes?.description || '',
|
|
135
|
+
prerequisite: server._notes?.prerequisite,
|
|
136
|
+
installCmd: server._notes?.install_cmd,
|
|
137
|
+
postInstallMessage: server._notes?.post_install_message,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const syncMode: 'copy' | 'symlink' | 'prune' = prune ? 'prune' : 'copy';
|
|
141
|
+
return { targets, optionalServers, repoRoot, syncMode };
|
|
142
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
|
|
3
|
+
export interface BackupInfo {
|
|
4
|
+
originalPath: string;
|
|
5
|
+
backupPath: string;
|
|
6
|
+
timestamp: Date;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function createBackup(filePath: string): Promise<BackupInfo> {
|
|
10
|
+
const timestamp = Date.now();
|
|
11
|
+
const backupPath = `${filePath}.backup-${timestamp}`;
|
|
12
|
+
|
|
13
|
+
if (await fs.pathExists(filePath)) {
|
|
14
|
+
await fs.copy(filePath, backupPath);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
originalPath: filePath,
|
|
19
|
+
backupPath,
|
|
20
|
+
timestamp: new Date(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function restoreBackup(backup: BackupInfo): Promise<void> {
|
|
25
|
+
if (await fs.pathExists(backup.backupPath)) {
|
|
26
|
+
await fs.move(backup.backupPath, backup.originalPath, { overwrite: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function cleanupBackup(backup: BackupInfo): Promise<void> {
|
|
31
|
+
await fs.remove(backup.backupPath);
|
|
32
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const SESSION_STATE_FILE = '.xtrm-session-state.json';
|
|
6
|
+
|
|
7
|
+
export const SESSION_PHASES = [
|
|
8
|
+
'claimed',
|
|
9
|
+
'phase1-done',
|
|
10
|
+
'waiting-merge',
|
|
11
|
+
'conflicting',
|
|
12
|
+
'pending-cleanup',
|
|
13
|
+
'merged',
|
|
14
|
+
'cleanup-done',
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type SessionPhase = typeof SESSION_PHASES[number];
|
|
18
|
+
|
|
19
|
+
export interface SessionState {
|
|
20
|
+
issueId: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
worktreePath: string;
|
|
23
|
+
prNumber: number | null;
|
|
24
|
+
prUrl: string | null;
|
|
25
|
+
phase: SessionPhase;
|
|
26
|
+
conflictFiles: string[];
|
|
27
|
+
startedAt: string;
|
|
28
|
+
lastChecked: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ALLOWED_TRANSITIONS: Record<SessionPhase, SessionPhase[]> = {
|
|
32
|
+
claimed: ['phase1-done', 'waiting-merge', 'conflicting', 'pending-cleanup', 'cleanup-done'],
|
|
33
|
+
'phase1-done': ['waiting-merge', 'conflicting', 'pending-cleanup', 'cleanup-done'],
|
|
34
|
+
'waiting-merge': ['conflicting', 'pending-cleanup', 'merged', 'cleanup-done'],
|
|
35
|
+
conflicting: ['waiting-merge', 'pending-cleanup', 'merged', 'cleanup-done'],
|
|
36
|
+
'pending-cleanup': ['waiting-merge', 'conflicting', 'merged', 'cleanup-done'],
|
|
37
|
+
merged: ['cleanup-done'],
|
|
38
|
+
'cleanup-done': [],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const nowIso = () => new Date().toISOString();
|
|
42
|
+
|
|
43
|
+
function isPhase(value: unknown): value is SessionPhase {
|
|
44
|
+
return typeof value === 'string' && (SESSION_PHASES as readonly string[]).includes(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeState(value: unknown): SessionState {
|
|
48
|
+
if (!value || typeof value !== 'object') {
|
|
49
|
+
throw new Error('Invalid session state payload');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const state = value as Partial<SessionState>;
|
|
53
|
+
if (!state.issueId || !state.branch || !state.worktreePath || !state.phase) {
|
|
54
|
+
throw new Error('Session state requires issueId, branch, worktreePath, and phase');
|
|
55
|
+
}
|
|
56
|
+
if (!isPhase(state.phase)) throw new Error(`Invalid session phase: ${String(state.phase)}`);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
issueId: String(state.issueId),
|
|
60
|
+
branch: String(state.branch),
|
|
61
|
+
worktreePath: String(state.worktreePath),
|
|
62
|
+
prNumber: state.prNumber ?? null,
|
|
63
|
+
prUrl: state.prUrl ?? null,
|
|
64
|
+
phase: state.phase,
|
|
65
|
+
conflictFiles: Array.isArray(state.conflictFiles) ? state.conflictFiles.map(String) : [],
|
|
66
|
+
startedAt: state.startedAt || nowIso(),
|
|
67
|
+
lastChecked: nowIso(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findRepoRoot(cwd: string): string | null {
|
|
72
|
+
try {
|
|
73
|
+
return execSync('git rev-parse --show-toplevel', {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
cwd,
|
|
76
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
77
|
+
timeout: 5000,
|
|
78
|
+
}).trim();
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function findSessionStateFile(startCwd: string = process.cwd()): string | null {
|
|
85
|
+
let current = path.resolve(startCwd);
|
|
86
|
+
for (;;) {
|
|
87
|
+
const candidate = path.join(current, SESSION_STATE_FILE);
|
|
88
|
+
if (existsSync(candidate)) return candidate;
|
|
89
|
+
const parent = path.dirname(current);
|
|
90
|
+
if (parent === current) return null;
|
|
91
|
+
current = parent;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readSessionState(startCwd: string = process.cwd()): SessionState | null {
|
|
96
|
+
const filePath = findSessionStateFile(startCwd);
|
|
97
|
+
if (!filePath) return null;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
101
|
+
return normalizeState(parsed);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeSessionState(state: Partial<SessionState>, cwd: string = process.cwd()): string {
|
|
108
|
+
const filePath = findSessionStateFile(cwd)
|
|
109
|
+
?? (findRepoRoot(cwd) ? path.join(findRepoRoot(cwd) as string, SESSION_STATE_FILE) : path.join(cwd, SESSION_STATE_FILE));
|
|
110
|
+
|
|
111
|
+
const normalized = normalizeState(state);
|
|
112
|
+
writeFileSync(filePath, JSON.stringify(normalized, null, 2) + '\n', 'utf8');
|
|
113
|
+
return filePath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function updateSessionPhase(nextPhase: SessionPhase, startCwd: string = process.cwd(), patch: Partial<SessionState> = {}): SessionState {
|
|
117
|
+
const filePath = findSessionStateFile(startCwd);
|
|
118
|
+
if (!filePath) throw new Error('Session state file not found');
|
|
119
|
+
|
|
120
|
+
const current = readSessionState(startCwd);
|
|
121
|
+
if (!current) throw new Error('Session state file invalid');
|
|
122
|
+
|
|
123
|
+
if (!isPhase(nextPhase)) {
|
|
124
|
+
throw new Error(`Invalid session phase: ${String(nextPhase)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (current.phase !== nextPhase && !ALLOWED_TRANSITIONS[current.phase].includes(nextPhase)) {
|
|
128
|
+
throw new Error(`Invalid phase transition: ${current.phase} -> ${nextPhase}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const nextState = normalizeState({
|
|
132
|
+
...current,
|
|
133
|
+
...patch,
|
|
134
|
+
phase: nextPhase,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
writeFileSync(filePath, JSON.stringify(nextState, null, 2) + '\n', 'utf8');
|
|
138
|
+
return nextState;
|
|
139
|
+
}
|