@sylphx/flow 1.8.1 → 2.0.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.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Flow Executor
3
+ * Orchestrates the complete attach-mode flow execution
4
+ * Handles backup → attach → run → restore lifecycle
5
+ */
6
+
7
+ import chalk from 'chalk';
8
+ import { ProjectManager } from './project-manager.js';
9
+ import { SessionManager } from './session-manager.js';
10
+ import { BackupManager } from './backup-manager.js';
11
+ import { AttachManager } from './attach-manager.js';
12
+ import { SecretsManager } from './secrets-manager.js';
13
+ import { CleanupHandler } from './cleanup-handler.js';
14
+ import { TemplateLoader } from './template-loader.js';
15
+ import { GitStashManager } from './git-stash-manager.js';
16
+
17
+ export interface FlowExecutorOptions {
18
+ verbose?: boolean;
19
+ skipBackup?: boolean;
20
+ skipSecrets?: boolean;
21
+ merge?: boolean; // Merge mode: keep user files (default: replace all)
22
+ }
23
+
24
+ export class FlowExecutor {
25
+ private projectManager: ProjectManager;
26
+ private sessionManager: SessionManager;
27
+ private backupManager: BackupManager;
28
+ private attachManager: AttachManager;
29
+ private secretsManager: SecretsManager;
30
+ private cleanupHandler: CleanupHandler;
31
+ private templateLoader: TemplateLoader;
32
+ private gitStashManager: GitStashManager;
33
+
34
+ constructor() {
35
+ this.projectManager = new ProjectManager();
36
+ this.sessionManager = new SessionManager(this.projectManager);
37
+ this.backupManager = new BackupManager(this.projectManager);
38
+ this.attachManager = new AttachManager(this.projectManager);
39
+ this.secretsManager = new SecretsManager(this.projectManager);
40
+ this.cleanupHandler = new CleanupHandler(
41
+ this.projectManager,
42
+ this.sessionManager,
43
+ this.backupManager
44
+ );
45
+ this.templateLoader = new TemplateLoader();
46
+ this.gitStashManager = new GitStashManager();
47
+ }
48
+
49
+ /**
50
+ * Execute complete flow with attach mode (with multi-session support)
51
+ */
52
+ async execute(
53
+ projectPath: string,
54
+ options: FlowExecutorOptions = {}
55
+ ): Promise<void> {
56
+ // Initialize Flow directories
57
+ await this.projectManager.initialize();
58
+
59
+ // Step 1: Crash recovery on startup
60
+ await this.cleanupHandler.recoverOnStartup();
61
+
62
+ // Step 2: Get project hash and paths
63
+ const projectHash = this.projectManager.getProjectHash(projectPath);
64
+ const target = await this.projectManager.detectTarget(projectPath);
65
+
66
+ if (options.verbose) {
67
+ console.log(chalk.dim(`Project: ${projectPath}`));
68
+ console.log(chalk.dim(`Hash: ${projectHash}`));
69
+ console.log(chalk.dim(`Target: ${target}\n`));
70
+ }
71
+
72
+ // Check for existing session
73
+ const existingSession = await this.sessionManager.getActiveSession(projectHash);
74
+
75
+ if (existingSession) {
76
+ // Joining existing session
77
+ console.log(chalk.cyan('🔗 Joining existing session...'));
78
+
79
+ const { session, isFirstSession } = await this.sessionManager.startSession(
80
+ projectPath,
81
+ projectHash,
82
+ target,
83
+ existingSession.backupPath
84
+ );
85
+
86
+ // Register cleanup hooks
87
+ this.cleanupHandler.registerCleanupHooks(projectHash);
88
+
89
+ console.log(chalk.green(` ✓ Joined session (${session.refCount} active session(s))\n`));
90
+ console.log(chalk.green('✓ Flow environment ready!\n'));
91
+ return;
92
+ }
93
+
94
+ // First session - stash settings changes, then create backup and attach
95
+ // Step 3: Stash git changes to hide Flow's modifications from git status
96
+ console.log(chalk.cyan('🔍 Checking git status...'));
97
+ await this.gitStashManager.stashSettingsChanges(projectPath);
98
+
99
+ console.log(chalk.cyan('💾 Creating backup...'));
100
+ const backup = await this.backupManager.createBackup(
101
+ projectPath,
102
+ projectHash,
103
+ target
104
+ );
105
+
106
+ // Step 4: Extract and save secrets
107
+ if (!options.skipSecrets) {
108
+ console.log(chalk.cyan('🔐 Extracting secrets...'));
109
+ const secrets = await this.secretsManager.extractMCPSecrets(
110
+ projectPath,
111
+ projectHash,
112
+ target
113
+ );
114
+
115
+ if (Object.keys(secrets.servers).length > 0) {
116
+ await this.secretsManager.saveSecrets(projectHash, secrets);
117
+ console.log(
118
+ chalk.green(` ✓ Saved ${Object.keys(secrets.servers).length} MCP secret(s)`)
119
+ );
120
+ }
121
+ }
122
+
123
+ // Step 5: Start session (use backup's sessionId to ensure consistency)
124
+ const { session, isFirstSession } = await this.sessionManager.startSession(
125
+ projectPath,
126
+ projectHash,
127
+ target,
128
+ backup.backupPath,
129
+ backup.sessionId
130
+ );
131
+
132
+ // Step 6: Register cleanup hooks
133
+ this.cleanupHandler.registerCleanupHooks(projectHash);
134
+
135
+ // Step 7: Default replace mode - clear user files before attaching (unless merge flag is set)
136
+ if (!options.merge) {
137
+ console.log(chalk.cyan('🔄 Clearing existing settings...'));
138
+ await this.clearUserSettings(projectPath, target);
139
+ }
140
+
141
+ // Step 8: Load templates
142
+ console.log(chalk.cyan('📦 Loading Flow templates...'));
143
+ const templates = await this.templateLoader.loadTemplates(target);
144
+
145
+ // Step 9: Attach Flow environment
146
+ console.log(chalk.cyan('🚀 Attaching Flow environment...'));
147
+ const manifest = await this.backupManager.getManifest(projectHash, session.sessionId);
148
+
149
+ if (!manifest) {
150
+ throw new Error('Backup manifest not found');
151
+ }
152
+
153
+ const attachResult = await this.attachManager.attach(
154
+ projectPath,
155
+ projectHash,
156
+ target,
157
+ templates,
158
+ manifest
159
+ );
160
+
161
+ // Update manifest with attach results
162
+ await this.backupManager.updateManifest(projectHash, session.sessionId, manifest);
163
+
164
+ // Show summary
165
+ this.showAttachSummary(attachResult);
166
+
167
+ console.log(chalk.green('\n✓ Flow environment ready!\n'));
168
+ }
169
+
170
+ /**
171
+ * Clear user settings in replace mode
172
+ */
173
+ private async clearUserSettings(
174
+ projectPath: string,
175
+ target: 'claude-code' | 'opencode'
176
+ ): Promise<void> {
177
+ const targetDir = this.projectManager.getTargetConfigDir(projectPath, target);
178
+ const fs = await import('node:fs/promises');
179
+ const path = await import('node:path');
180
+ const { existsSync } = await import('node:fs');
181
+
182
+ if (!existsSync(targetDir)) {
183
+ return;
184
+ }
185
+
186
+ // Get directory names for this target
187
+ const dirs = target === 'claude-code'
188
+ ? { agents: 'agents', commands: 'commands' }
189
+ : { agents: 'agent', commands: 'command' };
190
+
191
+ // Clear agents directory
192
+ const agentsDir = path.join(targetDir, dirs.agents);
193
+ if (existsSync(agentsDir)) {
194
+ const files = await fs.readdir(agentsDir);
195
+ for (const file of files) {
196
+ await fs.unlink(path.join(agentsDir, file));
197
+ }
198
+ }
199
+
200
+ // Clear commands directory
201
+ const commandsDir = path.join(targetDir, dirs.commands);
202
+ if (existsSync(commandsDir)) {
203
+ const files = await fs.readdir(commandsDir);
204
+ for (const file of files) {
205
+ await fs.unlink(path.join(commandsDir, file));
206
+ }
207
+ }
208
+
209
+ // Clear MCP configuration
210
+ const configPath = target === 'claude-code'
211
+ ? path.join(targetDir, 'settings.json')
212
+ : path.join(targetDir, '.mcp.json');
213
+
214
+ if (existsSync(configPath)) {
215
+ // Clear MCP servers section only, keep other settings
216
+ const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
217
+ if (target === 'claude-code') {
218
+ if (config.mcp?.servers) {
219
+ config.mcp.servers = {};
220
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
221
+ }
222
+ } else {
223
+ // For opencode, clear the entire .mcp.json
224
+ await fs.writeFile(configPath, JSON.stringify({ servers: {} }, null, 2));
225
+ }
226
+ }
227
+
228
+ // Clear hooks directory if exists
229
+ const hooksDir = path.join(targetDir, 'hooks');
230
+ if (existsSync(hooksDir)) {
231
+ const files = await fs.readdir(hooksDir);
232
+ for (const file of files) {
233
+ await fs.unlink(path.join(hooksDir, file));
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Cleanup after execution
240
+ */
241
+ async cleanup(projectPath: string): Promise<void> {
242
+ const projectHash = this.projectManager.getProjectHash(projectPath);
243
+
244
+ console.log(chalk.cyan('\n🧹 Cleaning up...'));
245
+
246
+ await this.cleanupHandler.cleanup(projectHash);
247
+
248
+ // Restore stashed git changes
249
+ await this.gitStashManager.popSettingsChanges(projectPath);
250
+
251
+ console.log(chalk.green(' ✓ Environment restored'));
252
+ console.log(chalk.green(' ✓ Secrets preserved for next run\n'));
253
+ }
254
+
255
+ /**
256
+ * Show attach summary
257
+ */
258
+ private showAttachSummary(result: any): void {
259
+ const items = [];
260
+
261
+ if (result.agentsAdded.length > 0) {
262
+ items.push(
263
+ `${result.agentsAdded.length} agent${result.agentsAdded.length > 1 ? 's' : ''}`
264
+ );
265
+ }
266
+
267
+ if (result.commandsAdded.length > 0) {
268
+ items.push(
269
+ `${result.commandsAdded.length} command${result.commandsAdded.length > 1 ? 's' : ''}`
270
+ );
271
+ }
272
+
273
+ if (result.mcpServersAdded.length > 0) {
274
+ items.push(
275
+ `${result.mcpServersAdded.length} MCP server${result.mcpServersAdded.length > 1 ? 's' : ''}`
276
+ );
277
+ }
278
+
279
+ if (result.hooksAdded.length > 0) {
280
+ items.push(
281
+ `${result.hooksAdded.length} hook${result.hooksAdded.length > 1 ? 's' : ''}`
282
+ );
283
+ }
284
+
285
+ if (result.rulesAppended) {
286
+ items.push('rules');
287
+ }
288
+
289
+ if (items.length > 0) {
290
+ console.log(chalk.green(` ✓ Added: ${items.join(', ')}`));
291
+ }
292
+
293
+ const overridden = result.agentsOverridden.length +
294
+ result.commandsOverridden.length +
295
+ result.mcpServersOverridden.length +
296
+ result.hooksOverridden.length;
297
+
298
+ if (overridden > 0) {
299
+ console.log(chalk.yellow(` ⚠ Overridden: ${overridden} item${overridden > 1 ? 's' : ''}`));
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Get project manager (for external use)
305
+ */
306
+ getProjectManager(): ProjectManager {
307
+ return this.projectManager;
308
+ }
309
+
310
+ /**
311
+ * Get session manager (for external use)
312
+ */
313
+ getSessionManager(): SessionManager {
314
+ return this.sessionManager;
315
+ }
316
+
317
+ /**
318
+ * Get cleanup handler (for external use)
319
+ */
320
+ getCleanupHandler(): CleanupHandler {
321
+ return this.cleanupHandler;
322
+ }
323
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Git Worktree Manager
3
+ * Uses git update-index --skip-worktree to hide Flow's settings changes from git status
4
+ * Prevents LLM from accidentally committing Flow's temporary changes
5
+ */
6
+
7
+ import { exec } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import chalk from 'chalk';
13
+
14
+ const execAsync = promisify(exec);
15
+
16
+ export class GitStashManager {
17
+ private skipWorktreeFiles: string[] = [];
18
+
19
+ /**
20
+ * Check if project is in a git repository
21
+ */
22
+ async isGitRepo(projectPath: string): Promise<boolean> {
23
+ try {
24
+ await execAsync('git rev-parse --git-dir', { cwd: projectPath });
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Get all tracked files in settings directories
33
+ */
34
+ async getTrackedSettingsFiles(projectPath: string): Promise<string[]> {
35
+ const files: string[] = [];
36
+
37
+ // Check .claude directory
38
+ const claudeDir = path.join(projectPath, '.claude');
39
+ if (existsSync(claudeDir)) {
40
+ try {
41
+ const { stdout } = await execAsync('git ls-files .claude', { cwd: projectPath });
42
+ const claudeFiles = stdout.trim().split('\n').filter(f => f);
43
+ files.push(...claudeFiles);
44
+ } catch {
45
+ // Directory not tracked in git
46
+ }
47
+ }
48
+
49
+ // Check .opencode directory
50
+ const opencodeDir = path.join(projectPath, '.opencode');
51
+ if (existsSync(opencodeDir)) {
52
+ try {
53
+ const { stdout } = await execAsync('git ls-files .opencode', { cwd: projectPath });
54
+ const opencodeFiles = stdout.trim().split('\n').filter(f => f);
55
+ files.push(...opencodeFiles);
56
+ } catch {
57
+ // Directory not tracked in git
58
+ }
59
+ }
60
+
61
+ return files;
62
+ }
63
+
64
+ /**
65
+ * Mark settings files as skip-worktree before attach
66
+ * This hides Flow's settings modifications from git status
67
+ */
68
+ async stashSettingsChanges(projectPath: string): Promise<void> {
69
+ // Check if in git repo
70
+ const inGitRepo = await this.isGitRepo(projectPath);
71
+ if (!inGitRepo) {
72
+ return;
73
+ }
74
+
75
+ // Get all tracked settings files
76
+ const files = await this.getTrackedSettingsFiles(projectPath);
77
+ if (files.length === 0) {
78
+ return;
79
+ }
80
+
81
+ try {
82
+ // Mark each file as skip-worktree
83
+ for (const file of files) {
84
+ try {
85
+ await execAsync(`git update-index --skip-worktree "${file}"`, { cwd: projectPath });
86
+ this.skipWorktreeFiles.push(file);
87
+ } catch {
88
+ // File might not exist or not tracked, skip it
89
+ }
90
+ }
91
+
92
+ if (this.skipWorktreeFiles.length > 0) {
93
+ console.log(chalk.dim(` ✓ Hiding ${this.skipWorktreeFiles.length} settings file(s) from git\n`));
94
+ }
95
+ } catch (error) {
96
+ console.log(chalk.yellow(' ⚠️ Could not hide settings from git\n'));
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Unmark settings files as skip-worktree after restore
102
+ * This restores normal git tracking
103
+ */
104
+ async popSettingsChanges(projectPath: string): Promise<void> {
105
+ if (this.skipWorktreeFiles.length === 0) {
106
+ return;
107
+ }
108
+
109
+ try {
110
+ // Unmark each file
111
+ for (const file of this.skipWorktreeFiles) {
112
+ try {
113
+ await execAsync(`git update-index --no-skip-worktree "${file}"`, { cwd: projectPath });
114
+ } catch {
115
+ // File might have been deleted, skip it
116
+ }
117
+ }
118
+
119
+ console.log(chalk.dim(` ✓ Restored git tracking for ${this.skipWorktreeFiles.length} file(s)\n`));
120
+ this.skipWorktreeFiles = [];
121
+ } catch (error: any) {
122
+ console.log(chalk.yellow(' ⚠️ Could not restore git tracking'));
123
+ console.log(chalk.yellow(' Run manually: git update-index --no-skip-worktree .claude/* .opencode/*\n'));
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Reset state (for cleanup)
129
+ */
130
+ reset(): void {
131
+ this.skipWorktreeFiles = [];
132
+ }
133
+ }