@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.
- package/CHANGELOG.md +59 -0
- package/UPGRADE.md +140 -0
- package/package.json +2 -1
- package/src/commands/flow/execute-v2.ts +278 -0
- package/src/commands/flow/execute.ts +1 -18
- package/src/commands/flow/types.ts +3 -2
- package/src/commands/flow-command.ts +32 -69
- package/src/commands/flow-orchestrator.ts +18 -55
- package/src/commands/run-command.ts +12 -6
- package/src/commands/settings-command.ts +529 -0
- package/src/core/attach-manager.ts +482 -0
- package/src/core/backup-manager.ts +308 -0
- package/src/core/cleanup-handler.ts +166 -0
- package/src/core/flow-executor.ts +323 -0
- package/src/core/git-stash-manager.ts +133 -0
- package/src/core/project-manager.ts +274 -0
- package/src/core/secrets-manager.ts +229 -0
- package/src/core/session-manager.ts +268 -0
- package/src/core/template-loader.ts +189 -0
- package/src/core/upgrade-manager.ts +79 -47
- package/src/index.ts +13 -27
- package/src/services/first-run-setup.ts +220 -0
- package/src/services/global-config.ts +337 -0
- package/src/utils/__tests__/package-manager-detector.test.ts +163 -0
- package/src/utils/agent-enhancer.ts +40 -22
- package/src/utils/errors.ts +9 -0
- package/src/utils/package-manager-detector.ts +139 -0
|
@@ -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
|
+
}
|