@sylphx/flow 1.8.2 → 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,308 @@
1
+ /**
2
+ * Backup Manager
3
+ * Handles backup and restore of project environments
4
+ * Supports multi-project isolation in ~/.sylphx-flow/backups/
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { existsSync } from 'node:fs';
10
+ import ora from 'ora';
11
+ import { ProjectManager } from './project-manager.js';
12
+
13
+ export interface BackupInfo {
14
+ sessionId: string;
15
+ timestamp: string;
16
+ projectPath: string;
17
+ target: 'claude-code' | 'opencode';
18
+ backupPath: string;
19
+ }
20
+
21
+ export interface BackupManifest {
22
+ sessionId: string;
23
+ timestamp: string;
24
+ projectPath: string;
25
+ target: 'claude-code' | 'opencode';
26
+ backup: {
27
+ config?: {
28
+ path: string;
29
+ hash: string;
30
+ mcpServersCount: number;
31
+ };
32
+ agents: {
33
+ user: string[];
34
+ flow: string[];
35
+ };
36
+ commands: {
37
+ user: string[];
38
+ flow: string[];
39
+ };
40
+ rules?: {
41
+ path: string;
42
+ originalSize: number;
43
+ flowContentAdded: boolean;
44
+ };
45
+ singleFiles: Record<
46
+ string,
47
+ {
48
+ existed: boolean;
49
+ originalSize: number;
50
+ flowContentAdded: boolean;
51
+ }
52
+ >;
53
+ };
54
+ secrets: {
55
+ mcpEnvExtracted: boolean;
56
+ storedAt: string;
57
+ };
58
+ }
59
+
60
+ export class BackupManager {
61
+ private projectManager: ProjectManager;
62
+
63
+ constructor(projectManager: ProjectManager) {
64
+ this.projectManager = projectManager;
65
+ }
66
+
67
+ /**
68
+ * Create full backup of project environment
69
+ */
70
+ async createBackup(
71
+ projectPath: string,
72
+ projectHash: string,
73
+ target: 'claude-code' | 'opencode'
74
+ ): Promise<BackupInfo> {
75
+ const sessionId = `session-${Date.now()}`;
76
+ const timestamp = new Date().toISOString();
77
+
78
+ const paths = this.projectManager.getProjectPaths(projectHash);
79
+ const backupPath = path.join(paths.backupsDir, sessionId);
80
+
81
+ // Ensure backup directory exists
82
+ await fs.mkdir(backupPath, { recursive: true });
83
+
84
+ const spinner = ora('Creating backup...').start();
85
+
86
+ try {
87
+ // Get target config directory
88
+ const targetConfigDir = this.projectManager.getTargetConfigDir(projectPath, target);
89
+
90
+ // Backup entire target directory if it exists
91
+ if (existsSync(targetConfigDir)) {
92
+ const backupTargetDir = path.join(
93
+ backupPath,
94
+ target === 'claude-code' ? '.claude' : '.opencode'
95
+ );
96
+ await this.copyDirectory(targetConfigDir, backupTargetDir);
97
+ }
98
+
99
+ // Create manifest
100
+ const manifest: BackupManifest = {
101
+ sessionId,
102
+ timestamp,
103
+ projectPath,
104
+ target,
105
+ backup: {
106
+ agents: { user: [], flow: [] },
107
+ commands: { user: [], flow: [] },
108
+ singleFiles: {},
109
+ },
110
+ secrets: {
111
+ mcpEnvExtracted: false,
112
+ storedAt: '',
113
+ },
114
+ };
115
+
116
+ await fs.writeFile(
117
+ path.join(backupPath, 'manifest.json'),
118
+ JSON.stringify(manifest, null, 2)
119
+ );
120
+
121
+ // Create symlink to latest
122
+ const latestLink = paths.latestBackup;
123
+ if (existsSync(latestLink)) {
124
+ await fs.unlink(latestLink);
125
+ }
126
+ await fs.symlink(sessionId, latestLink);
127
+
128
+ spinner.succeed(`Backup created: ${sessionId}`);
129
+
130
+ return {
131
+ sessionId,
132
+ timestamp,
133
+ projectPath,
134
+ target,
135
+ backupPath,
136
+ };
137
+ } catch (error) {
138
+ spinner.fail('Backup failed');
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Restore backup to project
145
+ */
146
+ async restoreBackup(projectHash: string, sessionId: string): Promise<void> {
147
+ const paths = this.projectManager.getProjectPaths(projectHash);
148
+ const backupPath = path.join(paths.backupsDir, sessionId);
149
+
150
+ if (!existsSync(backupPath)) {
151
+ throw new Error(`Backup not found: ${sessionId}`);
152
+ }
153
+
154
+ const spinner = ora('Restoring backup...').start();
155
+
156
+ try {
157
+ // Read manifest
158
+ const manifestPath = path.join(backupPath, 'manifest.json');
159
+ const manifest: BackupManifest = JSON.parse(
160
+ await fs.readFile(manifestPath, 'utf-8')
161
+ );
162
+
163
+ const projectPath = manifest.projectPath;
164
+ const target = manifest.target;
165
+
166
+ // Get target config directory
167
+ const targetConfigDir = this.projectManager.getTargetConfigDir(projectPath, target);
168
+
169
+ // Remove current target directory
170
+ if (existsSync(targetConfigDir)) {
171
+ await fs.rm(targetConfigDir, { recursive: true, force: true });
172
+ }
173
+
174
+ // Restore from backup
175
+ const backupTargetDir = path.join(
176
+ backupPath,
177
+ target === 'claude-code' ? '.claude' : '.opencode'
178
+ );
179
+
180
+ if (existsSync(backupTargetDir)) {
181
+ await this.copyDirectory(backupTargetDir, targetConfigDir);
182
+ }
183
+
184
+ spinner.succeed('Backup restored');
185
+ } catch (error) {
186
+ spinner.fail('Restore failed');
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Get backup manifest
193
+ */
194
+ async getManifest(projectHash: string, sessionId: string): Promise<BackupManifest | null> {
195
+ const paths = this.projectManager.getProjectPaths(projectHash);
196
+ const manifestPath = path.join(paths.backupsDir, sessionId, 'manifest.json');
197
+
198
+ if (!existsSync(manifestPath)) {
199
+ return null;
200
+ }
201
+
202
+ const data = await fs.readFile(manifestPath, 'utf-8');
203
+ return JSON.parse(data);
204
+ }
205
+
206
+ /**
207
+ * Update backup manifest
208
+ */
209
+ async updateManifest(
210
+ projectHash: string,
211
+ sessionId: string,
212
+ manifest: BackupManifest
213
+ ): Promise<void> {
214
+ const paths = this.projectManager.getProjectPaths(projectHash);
215
+ const manifestPath = path.join(paths.backupsDir, sessionId, 'manifest.json');
216
+
217
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
218
+ }
219
+
220
+ /**
221
+ * Cleanup old backups (keep last N)
222
+ */
223
+ async cleanupOldBackups(projectHash: string, keepLast: number = 3): Promise<void> {
224
+ const paths = this.projectManager.getProjectPaths(projectHash);
225
+
226
+ if (!existsSync(paths.backupsDir)) {
227
+ return;
228
+ }
229
+
230
+ const entries = await fs.readdir(paths.backupsDir, { withFileTypes: true });
231
+ const sessions = entries
232
+ .filter((e) => e.isDirectory() && e.name.startsWith('session-'))
233
+ .map((e) => ({
234
+ name: e.name,
235
+ timestamp: parseInt(e.name.replace('session-', '')),
236
+ }))
237
+ .sort((a, b) => b.timestamp - a.timestamp);
238
+
239
+ // Remove old backups
240
+ const toRemove = sessions.slice(keepLast);
241
+ for (const session of toRemove) {
242
+ const sessionPath = path.join(paths.backupsDir, session.name);
243
+ await fs.rm(sessionPath, { recursive: true, force: true });
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Copy directory recursively
249
+ */
250
+ private async copyDirectory(src: string, dest: string): Promise<void> {
251
+ await fs.mkdir(dest, { recursive: true });
252
+
253
+ const entries = await fs.readdir(src, { withFileTypes: true });
254
+
255
+ for (const entry of entries) {
256
+ const srcPath = path.join(src, entry.name);
257
+ const destPath = path.join(dest, entry.name);
258
+
259
+ if (entry.isDirectory()) {
260
+ await this.copyDirectory(srcPath, destPath);
261
+ } else {
262
+ await fs.copyFile(srcPath, destPath);
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Get list of backups for project
269
+ */
270
+ async listBackups(projectHash: string): Promise<
271
+ Array<{
272
+ sessionId: string;
273
+ timestamp: string;
274
+ target: string;
275
+ }>
276
+ > {
277
+ const paths = this.projectManager.getProjectPaths(projectHash);
278
+
279
+ if (!existsSync(paths.backupsDir)) {
280
+ return [];
281
+ }
282
+
283
+ const entries = await fs.readdir(paths.backupsDir, { withFileTypes: true });
284
+ const backups = [];
285
+
286
+ for (const entry of entries) {
287
+ if (!entry.isDirectory() || !entry.name.startsWith('session-')) {
288
+ continue;
289
+ }
290
+
291
+ const manifestPath = path.join(paths.backupsDir, entry.name, 'manifest.json');
292
+ if (existsSync(manifestPath)) {
293
+ const manifest: BackupManifest = JSON.parse(
294
+ await fs.readFile(manifestPath, 'utf-8')
295
+ );
296
+ backups.push({
297
+ sessionId: manifest.sessionId,
298
+ timestamp: manifest.timestamp,
299
+ target: manifest.target,
300
+ });
301
+ }
302
+ }
303
+
304
+ return backups.sort(
305
+ (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
306
+ );
307
+ }
308
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Cleanup Handler
3
+ * Manages graceful cleanup on exit and crash recovery
4
+ * Handles process signals and ensures backup restoration
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
+
12
+ export class CleanupHandler {
13
+ private projectManager: ProjectManager;
14
+ private sessionManager: SessionManager;
15
+ private backupManager: BackupManager;
16
+ private registered = false;
17
+ private currentProjectHash: string | null = null;
18
+
19
+ constructor(
20
+ projectManager: ProjectManager,
21
+ sessionManager: SessionManager,
22
+ backupManager: BackupManager
23
+ ) {
24
+ this.projectManager = projectManager;
25
+ this.sessionManager = sessionManager;
26
+ this.backupManager = backupManager;
27
+ }
28
+
29
+ /**
30
+ * Register cleanup hooks for current project
31
+ */
32
+ registerCleanupHooks(projectHash: string): void {
33
+ if (this.registered) {
34
+ return;
35
+ }
36
+
37
+ this.currentProjectHash = projectHash;
38
+ this.registered = true;
39
+
40
+ // Normal exit
41
+ process.on('exit', async () => {
42
+ await this.onExit();
43
+ });
44
+
45
+ // SIGINT (Ctrl+C)
46
+ process.on('SIGINT', async () => {
47
+ console.log(chalk.yellow('\n⚠️ Interrupted by user, cleaning up...'));
48
+ await this.onSignal('SIGINT');
49
+ process.exit(0);
50
+ });
51
+
52
+ // SIGTERM
53
+ process.on('SIGTERM', async () => {
54
+ console.log(chalk.yellow('\n⚠️ Terminated, cleaning up...'));
55
+ await this.onSignal('SIGTERM');
56
+ process.exit(0);
57
+ });
58
+
59
+ // Uncaught exceptions
60
+ process.on('uncaughtException', async (error) => {
61
+ console.error(chalk.red('\n✗ Uncaught Exception:'));
62
+ console.error(error);
63
+ await this.onSignal('uncaughtException');
64
+ process.exit(1);
65
+ });
66
+
67
+ // Unhandled rejections
68
+ process.on('unhandledRejection', async (reason) => {
69
+ console.error(chalk.red('\n✗ Unhandled Rejection:'));
70
+ console.error(reason);
71
+ await this.onSignal('unhandledRejection');
72
+ process.exit(1);
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Normal exit cleanup (with multi-session support)
78
+ */
79
+ private async onExit(): Promise<void> {
80
+ if (!this.currentProjectHash) {
81
+ return;
82
+ }
83
+
84
+ try {
85
+ const { shouldRestore, session } = await this.sessionManager.endSession(this.currentProjectHash);
86
+
87
+ if (shouldRestore && session) {
88
+ // Last session - restore backup silently on normal exit
89
+ await this.backupManager.restoreBackup(this.currentProjectHash, session.sessionId);
90
+ await this.backupManager.cleanupOldBackups(this.currentProjectHash, 3);
91
+ }
92
+ } catch (error) {
93
+ // Silent fail on exit
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Signal-based cleanup (SIGINT, SIGTERM, etc.) with multi-session support
99
+ */
100
+ private async onSignal(signal: string): Promise<void> {
101
+ if (!this.currentProjectHash) {
102
+ return;
103
+ }
104
+
105
+ try {
106
+ console.log(chalk.cyan('🧹 Cleaning up...'));
107
+
108
+ const { shouldRestore, session } = await this.sessionManager.endSession(this.currentProjectHash);
109
+
110
+ if (shouldRestore && session) {
111
+ // Last session - restore environment
112
+ console.log(chalk.cyan(' Restoring environment...'));
113
+ await this.backupManager.restoreBackup(this.currentProjectHash, session.sessionId);
114
+ console.log(chalk.green('✓ Environment restored'));
115
+ } else if (!shouldRestore && session) {
116
+ // Other sessions still running
117
+ console.log(chalk.yellow(` ${session.refCount} session(s) still running`));
118
+ }
119
+ } catch (error) {
120
+ console.error(chalk.red('✗ Cleanup failed:'), error);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Recover on startup (for all projects)
126
+ * Checks for orphaned sessions from crashes
127
+ */
128
+ async recoverOnStartup(): Promise<void> {
129
+ const orphanedSessions = await this.sessionManager.detectOrphanedSessions();
130
+
131
+ if (orphanedSessions.size === 0) {
132
+ return;
133
+ }
134
+
135
+ console.log(chalk.cyan(`\n🔧 Recovering ${orphanedSessions.size} crashed session(s)...\n`));
136
+
137
+ for (const [projectHash, session] of orphanedSessions) {
138
+ console.log(chalk.dim(` Project: ${session.projectPath}`));
139
+
140
+ try {
141
+ // Restore backup
142
+ await this.backupManager.restoreBackup(projectHash, session.sessionId);
143
+
144
+ // Clean up session
145
+ await this.sessionManager.recoverSession(projectHash, session);
146
+
147
+ console.log(chalk.green(' ✓ Environment restored\n'));
148
+ } catch (error) {
149
+ console.error(chalk.red(' ✗ Recovery failed:'), error);
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Manually cleanup a specific project session (with multi-session support)
156
+ */
157
+ async cleanup(projectHash: string): Promise<void> {
158
+ const { shouldRestore, session } = await this.sessionManager.endSession(projectHash);
159
+
160
+ if (shouldRestore && session) {
161
+ // Last session - restore environment
162
+ await this.backupManager.restoreBackup(projectHash, session.sessionId);
163
+ await this.backupManager.cleanupOldBackups(projectHash, 3);
164
+ }
165
+ }
166
+ }