xtrm-cli 2.1.4
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 +55937 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/index.js +151 -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/lib/transform-gemini.js +119 -0
- package/package.json +43 -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/help.ts +171 -0
- package/src/commands/install-project.ts +566 -0
- package/src/commands/install-service-skills.ts +251 -0
- package/src/commands/install.ts +534 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +143 -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/sync-executor.ts +399 -0
- package/src/index.ts +69 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +222 -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 +467 -0
- package/src/utils/theme.ts +37 -0
- package/test/context.test.ts +33 -0
- package/test/hooks.test.ts +277 -0
- package/test/install-project.test.ts +235 -0
- package/test/install-service-skills.test.ts +111 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import prompts from 'prompts';
|
|
5
|
+
import { getCandidatePaths } from '../core/context.js';
|
|
6
|
+
import { calculateDiff } from '../core/diff.js';
|
|
7
|
+
import { executeSync } from '../core/sync-executor.js';
|
|
8
|
+
import { findRepoRoot } from '../utils/repo-root.js';
|
|
9
|
+
import { getManifestPath } from '../core/manifest.js';
|
|
10
|
+
import fs from 'fs-extra';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
import Conf from 'conf';
|
|
14
|
+
|
|
15
|
+
function formatTargetLabel(target: string): string {
|
|
16
|
+
const normalized = target.replace(/\\/g, '/').toLowerCase();
|
|
17
|
+
if (normalized.endsWith('/.agents/skills') || normalized.includes('/.agents/skills/')) return '~/.agents/skills';
|
|
18
|
+
if (normalized.endsWith('/.claude') || normalized.includes('/.claude/')) return '~/.claude';
|
|
19
|
+
return path.basename(target);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatRelativeTime(timestamp: number): string {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const diff = now - timestamp;
|
|
25
|
+
|
|
26
|
+
const minutes = Math.floor(diff / 60000);
|
|
27
|
+
const hours = Math.floor(diff / 3600000);
|
|
28
|
+
const days = Math.floor(diff / 86400000);
|
|
29
|
+
|
|
30
|
+
if (minutes < 1) return 'just now';
|
|
31
|
+
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
|
32
|
+
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
33
|
+
return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createStatusCommand(): Command {
|
|
37
|
+
return new Command('status')
|
|
38
|
+
.description('Show status and optionally sync target environments')
|
|
39
|
+
.option('--json', 'Output machine-readable JSON', false)
|
|
40
|
+
.action(async (opts) => {
|
|
41
|
+
const { json } = opts;
|
|
42
|
+
|
|
43
|
+
const repoRoot = await findRepoRoot();
|
|
44
|
+
|
|
45
|
+
// Auto-detect all existing environments (no prompt needed for read-only view)
|
|
46
|
+
const candidates = getCandidatePaths();
|
|
47
|
+
const targets: string[] = [];
|
|
48
|
+
for (const c of candidates) {
|
|
49
|
+
if (await fs.pathExists(c.path)) targets.push(c.path);
|
|
50
|
+
}
|
|
51
|
+
if (targets.length === 0) {
|
|
52
|
+
console.log(kleur.yellow('\n No agent environments found (~/.claude, ~/.gemini, ~/.qwen)\n'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TargetStatus {
|
|
57
|
+
path: string;
|
|
58
|
+
name: string;
|
|
59
|
+
lastSync: string | null;
|
|
60
|
+
changes: Record<string, { missing: string[]; outdated: string[]; drifted: string[] }>;
|
|
61
|
+
totalChanges: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const results: TargetStatus[] = [];
|
|
65
|
+
|
|
66
|
+
for (const target of targets) {
|
|
67
|
+
const manifestPath = getManifestPath(target);
|
|
68
|
+
let lastSync: string | null = null;
|
|
69
|
+
try {
|
|
70
|
+
if (await fs.pathExists(manifestPath)) {
|
|
71
|
+
const manifest = await fs.readJson(manifestPath);
|
|
72
|
+
if (manifest.lastSync) lastSync = manifest.lastSync;
|
|
73
|
+
}
|
|
74
|
+
} catch { /* ignore */ }
|
|
75
|
+
|
|
76
|
+
const changeSet = await calculateDiff(repoRoot, target);
|
|
77
|
+
const totalChanges = Object.values(changeSet).reduce(
|
|
78
|
+
(sum: number, c: any) => sum + c.missing.length + c.outdated.length + c.drifted.length, 0,
|
|
79
|
+
) as number;
|
|
80
|
+
|
|
81
|
+
results.push({ path: target, name: formatTargetLabel(target), lastSync, changes: changeSet as any, totalChanges });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── JSON output ──────────────────────────────────────────────────
|
|
85
|
+
if (json) {
|
|
86
|
+
console.log(JSON.stringify({ targets: results }, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Table output ─────────────────────────────────────────────────
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
92
|
+
const Table = require('cli-table3');
|
|
93
|
+
|
|
94
|
+
const table = new Table({
|
|
95
|
+
head: [
|
|
96
|
+
kleur.bold('Target'),
|
|
97
|
+
kleur.bold(kleur.green('+ New')),
|
|
98
|
+
kleur.bold(kleur.yellow('↑ Update')),
|
|
99
|
+
kleur.bold(kleur.red('! Drift')),
|
|
100
|
+
kleur.bold('Last Sync'),
|
|
101
|
+
],
|
|
102
|
+
style: { head: [], border: [] },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
for (const r of results) {
|
|
106
|
+
const missing = Object.values(r.changes).reduce((s: number, c: any) => s + c.missing.length, 0) as number;
|
|
107
|
+
const outdated = Object.values(r.changes).reduce((s: number, c: any) => s + c.outdated.length, 0) as number;
|
|
108
|
+
const drifted = Object.values(r.changes).reduce((s: number, c: any) => s + c.drifted.length, 0) as number;
|
|
109
|
+
|
|
110
|
+
const lastSyncStr = r.lastSync
|
|
111
|
+
? kleur.gray(formatRelativeTime(new Date(r.lastSync).getTime()))
|
|
112
|
+
: kleur.gray('never');
|
|
113
|
+
|
|
114
|
+
table.push([
|
|
115
|
+
r.totalChanges > 0 ? kleur.bold(r.name) : r.name,
|
|
116
|
+
missing > 0 ? kleur.green(String(missing)) : kleur.gray('—'),
|
|
117
|
+
outdated > 0 ? kleur.yellow(String(outdated)) : kleur.gray('—'),
|
|
118
|
+
drifted > 0 ? kleur.red(String(drifted)) : kleur.gray('—'),
|
|
119
|
+
lastSyncStr,
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log('\n' + table.toString());
|
|
124
|
+
|
|
125
|
+
const totalPending = results.reduce((s, r) => s + r.totalChanges, 0);
|
|
126
|
+
|
|
127
|
+
if (totalPending === 0) {
|
|
128
|
+
console.log(kleur.green('\n ✓ All environments up-to-date\n'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const pending = results.filter(r => r.totalChanges > 0);
|
|
133
|
+
console.log(kleur.yellow(`\n ⚠ ${totalPending} pending change${totalPending !== 1 ? 's' : ''} across ${pending.length} environment${pending.length !== 1 ? 's' : ''}\n`));
|
|
134
|
+
|
|
135
|
+
// ── Inline sync offer ────────────────────────────────────────────
|
|
136
|
+
const { selected } = await prompts({
|
|
137
|
+
type: 'multiselect',
|
|
138
|
+
name: 'selected',
|
|
139
|
+
message: 'Select environments to sync:',
|
|
140
|
+
choices: pending.map(r => ({
|
|
141
|
+
title: `${r.name} ${kleur.gray(`(${r.totalChanges} change${r.totalChanges !== 1 ? 's' : ''})`)}`,
|
|
142
|
+
value: r.path,
|
|
143
|
+
selected: true,
|
|
144
|
+
})),
|
|
145
|
+
hint: '- Space to toggle. Enter to confirm. Esc to skip.',
|
|
146
|
+
instructions: false,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!selected || selected.length === 0) {
|
|
150
|
+
console.log(kleur.gray(' Skipped. Run xtrm sync anytime to apply.\n'));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const toSync = pending.filter(r => selected.includes(r.path));
|
|
155
|
+
|
|
156
|
+
// Reuse the already-computed changeSets — no second diff needed
|
|
157
|
+
const store = new Conf({ projectName: 'xtrm-manager' });
|
|
158
|
+
const syncMode = (store.get('syncMode') as string) || 'copy';
|
|
159
|
+
|
|
160
|
+
let totalSynced = 0;
|
|
161
|
+
for (const r of toSync) {
|
|
162
|
+
console.log(kleur.bold(`\n → ${r.name}`));
|
|
163
|
+
const count = await executeSync(repoRoot, r.path, r.changes as any, syncMode as any, 'sync', false);
|
|
164
|
+
totalSynced += count;
|
|
165
|
+
console.log(kleur.green(` ✓ ${count} item${count !== 1 ? 's' : ''} synced`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(kleur.bold().green(`\n✓ Done — ${totalSynced} item${totalSynced !== 1 ? 's' : ''} synced\n`));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
// @ts-ignore
|
|
5
|
+
import Conf from 'conf';
|
|
6
|
+
// @ts-ignore
|
|
7
|
+
import prompts from 'prompts';
|
|
8
|
+
import kleur from 'kleur';
|
|
9
|
+
import type { SyncMode } from '../types/config.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export interface Context {
|
|
13
|
+
targets: string[];
|
|
14
|
+
syncMode: 'copy' | 'symlink' | 'prune';
|
|
15
|
+
config: any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GetContextOptions {
|
|
19
|
+
selector?: string;
|
|
20
|
+
createMissingDirs?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let config: Conf | null = null;
|
|
24
|
+
|
|
25
|
+
function getConfig(): Conf {
|
|
26
|
+
if (!config) {
|
|
27
|
+
config = new Conf({
|
|
28
|
+
projectName: 'xtrm-cli',
|
|
29
|
+
defaults: {
|
|
30
|
+
syncMode: 'copy',
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return config;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getCandidatePaths(): Array<{ label: string; path: string }> {
|
|
39
|
+
const home = os.homedir();
|
|
40
|
+
const appData = process.env.APPDATA;
|
|
41
|
+
const isWindows = process.platform === 'win32';
|
|
42
|
+
|
|
43
|
+
const paths = [
|
|
44
|
+
{ label: '~/.claude (hooks + skills)', path: path.join(home, '.claude') },
|
|
45
|
+
{ label: '~/.agents/skills', path: path.join(home, '.agents', 'skills') },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
if (isWindows && appData) {
|
|
49
|
+
paths.push({ label: 'Claude (AppData)', path: path.join(appData, 'Claude') });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return paths;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveTargets(
|
|
56
|
+
selector: string | undefined,
|
|
57
|
+
candidates: Array<{ label: string; path: string }>,
|
|
58
|
+
): string[] | null {
|
|
59
|
+
if (!selector) return null;
|
|
60
|
+
|
|
61
|
+
const normalized = selector.trim().toLowerCase();
|
|
62
|
+
if (normalized === '*' || normalized === 'all') {
|
|
63
|
+
return candidates.map(candidate => candidate.path);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error(`Unknown install target selector '${selector}'. Use '*' or 'all'.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getContext(options: GetContextOptions = {}): Promise<Context> {
|
|
70
|
+
const { selector, createMissingDirs = true } = options;
|
|
71
|
+
const choices = [];
|
|
72
|
+
const candidates = getCandidatePaths();
|
|
73
|
+
const directTargets = resolveTargets(selector, candidates);
|
|
74
|
+
|
|
75
|
+
if (directTargets) {
|
|
76
|
+
const activeConfig = getConfig();
|
|
77
|
+
if (createMissingDirs) {
|
|
78
|
+
for (const target of directTargets) {
|
|
79
|
+
await fs.ensureDir(target);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
targets: directTargets,
|
|
85
|
+
syncMode: activeConfig.get('syncMode') as SyncMode,
|
|
86
|
+
config: activeConfig,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const activeConfig = getConfig();
|
|
91
|
+
|
|
92
|
+
for (const c of candidates) {
|
|
93
|
+
const exists = await fs.pathExists(c.path);
|
|
94
|
+
const icon = exists ? kleur.green('●') : kleur.gray('○');
|
|
95
|
+
const desc = exists ? 'Found' : 'Not found (will create)';
|
|
96
|
+
|
|
97
|
+
choices.push({
|
|
98
|
+
title: `${icon} ${c.label} (${c.path})`,
|
|
99
|
+
description: desc,
|
|
100
|
+
value: c.path,
|
|
101
|
+
selected: exists, // Pre-select existing environments
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const response = await prompts({
|
|
106
|
+
type: 'multiselect',
|
|
107
|
+
name: 'targets',
|
|
108
|
+
message: 'Select target environment(s):',
|
|
109
|
+
choices: choices,
|
|
110
|
+
hint: '- Space to select. Return to submit',
|
|
111
|
+
instructions: false,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (response.targets === undefined) {
|
|
115
|
+
console.log(kleur.gray('\nCancelled.'));
|
|
116
|
+
process.exit(130);
|
|
117
|
+
}
|
|
118
|
+
if (response.targets.length === 0) {
|
|
119
|
+
console.log(kleur.gray('No targets selected.'));
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Ensure directories exist for selected targets
|
|
124
|
+
if (createMissingDirs) {
|
|
125
|
+
for (const target of response.targets) {
|
|
126
|
+
await fs.ensureDir(target);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
targets: response.targets,
|
|
132
|
+
syncMode: activeConfig.get('syncMode') as SyncMode,
|
|
133
|
+
config: activeConfig,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function resetContext(): void {
|
|
139
|
+
getConfig().clear();
|
|
140
|
+
console.log(kleur.yellow('Configuration cleared.'));
|
|
141
|
+
}
|
package/src/core/diff.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { join, normalize } from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { hashDirectory, getNewestMtime } from '../utils/hash.js';
|
|
4
|
+
import type { ChangeSet } from '../types/config.js';
|
|
5
|
+
import { getAdapter } from '../adapters/registry.js';
|
|
6
|
+
import { detectAdapter } from '../adapters/registry.js';
|
|
7
|
+
|
|
8
|
+
// Items to ignore from diff scanning (similar to .gitignore)
|
|
9
|
+
const IGNORED_ITEMS = new Set(['__pycache__', '.DS_Store', 'Thumbs.db', '.gitkeep', 'node_modules']);
|
|
10
|
+
|
|
11
|
+
export class PruneModeReadError extends Error {
|
|
12
|
+
constructor(path: string) {
|
|
13
|
+
super(`Cannot read ${path} in prune mode — aborting to prevent accidental deletion`);
|
|
14
|
+
this.name = 'PruneModeReadError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function calculateDiff(repoRoot: string, systemRoot: string, pruneMode: boolean = false): Promise<ChangeSet> {
|
|
19
|
+
const adapter = detectAdapter(systemRoot);
|
|
20
|
+
const isClaude = adapter?.toolName === 'claude-code';
|
|
21
|
+
const isQwen = adapter?.toolName === 'qwen';
|
|
22
|
+
const normalizedRoot = normalize(systemRoot).replace(/\\/g, '/');
|
|
23
|
+
const isAgentsSkills = normalizedRoot.includes('.agents/skills');
|
|
24
|
+
|
|
25
|
+
const changeSet: ChangeSet = {
|
|
26
|
+
skills: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
27
|
+
hooks: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
28
|
+
config: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
29
|
+
commands: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
30
|
+
'qwen-commands': { missing: [], outdated: [], drifted: [], total: 0 },
|
|
31
|
+
'antigravity-workflows': { missing: [], outdated: [], drifted: [], total: 0 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ~/.agents/skills: skills-only, mapped directly (repoRoot/skills/* → systemRoot/*)
|
|
35
|
+
if (isAgentsSkills) {
|
|
36
|
+
const repoPath = join(repoRoot, 'skills');
|
|
37
|
+
if (!(await fs.pathExists(repoPath))) return changeSet;
|
|
38
|
+
|
|
39
|
+
const items = (await fs.readdir(repoPath)).filter(i => !IGNORED_ITEMS.has(i));
|
|
40
|
+
changeSet.skills.total = items.length;
|
|
41
|
+
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
await compareItem('skills', item, join(repoPath, item), join(systemRoot, item), changeSet, pruneMode);
|
|
44
|
+
}
|
|
45
|
+
return changeSet;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 1. Folders: Skills & Hooks & Commands
|
|
49
|
+
const folders = ['skills', 'hooks'];
|
|
50
|
+
if (isQwen) folders.push('qwen-commands');
|
|
51
|
+
else if (!isClaude) folders.push('commands');
|
|
52
|
+
|
|
53
|
+
for (const category of folders) {
|
|
54
|
+
let repoPath: string;
|
|
55
|
+
let systemPath: string;
|
|
56
|
+
|
|
57
|
+
if (category === 'qwen-commands') {
|
|
58
|
+
repoPath = join(repoRoot, '.qwen', 'commands');
|
|
59
|
+
systemPath = join(systemRoot, 'commands');
|
|
60
|
+
} else {
|
|
61
|
+
repoPath = join(repoRoot, category);
|
|
62
|
+
systemPath = join(systemRoot, category);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!(await fs.pathExists(repoPath))) continue;
|
|
66
|
+
|
|
67
|
+
const items = (await fs.readdir(repoPath)).filter(i => !IGNORED_ITEMS.has(i));
|
|
68
|
+
(changeSet[category as keyof ChangeSet] as any).total = items.length;
|
|
69
|
+
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
await compareItem(
|
|
72
|
+
category as keyof ChangeSet,
|
|
73
|
+
item,
|
|
74
|
+
join(repoPath, item),
|
|
75
|
+
join(systemPath, item),
|
|
76
|
+
changeSet,
|
|
77
|
+
pruneMode
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. Config Files (Explicit Mapping)
|
|
83
|
+
const configMapping = {
|
|
84
|
+
'settings.json': { repo: 'config/settings.json', sys: 'settings.json' },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (const [name, paths] of Object.entries(configMapping)) {
|
|
88
|
+
// settings.json is path-transformed on install for all agent environments,
|
|
89
|
+
// so the hash always differs — skip to prevent perpetual false "1 update"
|
|
90
|
+
if (name === 'settings.json' && adapter !== null) continue;
|
|
91
|
+
|
|
92
|
+
const itemRepoPath = join(repoRoot, paths.repo);
|
|
93
|
+
const itemSystemPath = join(systemRoot, paths.sys);
|
|
94
|
+
|
|
95
|
+
if (await fs.pathExists(itemRepoPath)) {
|
|
96
|
+
await compareItem('config', name, itemRepoPath, itemSystemPath, changeSet);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return changeSet;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function compareItem(
|
|
104
|
+
category: keyof ChangeSet,
|
|
105
|
+
item: string,
|
|
106
|
+
repoPath: string,
|
|
107
|
+
systemPath: string,
|
|
108
|
+
changeSet: ChangeSet,
|
|
109
|
+
pruneMode: boolean = false
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const cat = changeSet[category] as any;
|
|
112
|
+
|
|
113
|
+
if (!(await fs.pathExists(systemPath))) {
|
|
114
|
+
cat.missing.push(item);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const repoHash = await hashDirectory(repoPath);
|
|
119
|
+
|
|
120
|
+
// Wrap system-side hash read in try/catch for prune mode safety
|
|
121
|
+
let systemHash: string;
|
|
122
|
+
try {
|
|
123
|
+
systemHash = await hashDirectory(systemPath);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (pruneMode) {
|
|
126
|
+
throw new PruneModeReadError(systemPath);
|
|
127
|
+
}
|
|
128
|
+
// If not in prune mode, treat as missing
|
|
129
|
+
cat.missing.push(item);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (repoHash !== systemHash) {
|
|
134
|
+
const repoMtime = await getNewestMtime(repoPath);
|
|
135
|
+
const systemMtime = await getNewestMtime(systemPath);
|
|
136
|
+
|
|
137
|
+
if (systemMtime > repoMtime + 2000) {
|
|
138
|
+
cat.drifted.push(item);
|
|
139
|
+
} else {
|
|
140
|
+
cat.outdated.push(item);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -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
|
+
}
|