@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,274 @@
1
+ /**
2
+ * Project Manager
3
+ * Manages project identification and paths for multi-project support
4
+ * All projects store data in ~/.sylphx-flow/ isolated by project hash
5
+ */
6
+
7
+ import crypto from 'node:crypto';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { existsSync } from 'node:fs';
11
+ import fs from 'node:fs/promises';
12
+
13
+ export interface ProjectPaths {
14
+ sessionFile: string;
15
+ backupsDir: string;
16
+ secretsDir: string;
17
+ latestBackup: string;
18
+ }
19
+
20
+ export class ProjectManager {
21
+ private readonly flowHomeDir: string;
22
+
23
+ constructor() {
24
+ // All Flow data stored in ~/.sylphx-flow/
25
+ this.flowHomeDir = path.join(os.homedir(), '.sylphx-flow');
26
+ }
27
+
28
+ /**
29
+ * Get unique hash for project path
30
+ * Uses first 16 chars of SHA256 hash of absolute path
31
+ */
32
+ getProjectHash(projectPath: string): string {
33
+ const absolutePath = path.resolve(projectPath);
34
+ return crypto
35
+ .createHash('sha256')
36
+ .update(absolutePath)
37
+ .digest('hex')
38
+ .substring(0, 16);
39
+ }
40
+
41
+ /**
42
+ * Get all paths for a project
43
+ */
44
+ getProjectPaths(projectHash: string): ProjectPaths {
45
+ const sessionsDir = path.join(this.flowHomeDir, 'sessions');
46
+ const backupsDir = path.join(this.flowHomeDir, 'backups', projectHash);
47
+ const secretsDir = path.join(this.flowHomeDir, 'secrets', projectHash);
48
+
49
+ return {
50
+ sessionFile: path.join(sessionsDir, `${projectHash}.json`),
51
+ backupsDir,
52
+ secretsDir,
53
+ latestBackup: path.join(backupsDir, 'latest'),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Get Flow home directory
59
+ */
60
+ getFlowHomeDir(): string {
61
+ return this.flowHomeDir;
62
+ }
63
+
64
+ /**
65
+ * Initialize Flow directories
66
+ */
67
+ async initialize(): Promise<void> {
68
+ const dirs = [
69
+ this.flowHomeDir,
70
+ path.join(this.flowHomeDir, 'sessions'),
71
+ path.join(this.flowHomeDir, 'backups'),
72
+ path.join(this.flowHomeDir, 'secrets'),
73
+ path.join(this.flowHomeDir, 'templates'),
74
+ ];
75
+
76
+ for (const dir of dirs) {
77
+ await fs.mkdir(dir, { recursive: true });
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Check if a command is available on the system
83
+ */
84
+ private async isCommandAvailable(command: string): Promise<boolean> {
85
+ try {
86
+ const { execSync } = await import('node:child_process');
87
+ execSync(`which ${command}`, { stdio: 'ignore' });
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Detect which commands are installed on this machine
96
+ */
97
+ private async detectInstalledCommands(): Promise<{
98
+ claudeCode: boolean;
99
+ opencode: boolean;
100
+ }> {
101
+ const [claudeCode, opencode] = await Promise.all([
102
+ this.isCommandAvailable('claude'),
103
+ this.isCommandAvailable('opencode'),
104
+ ]);
105
+
106
+ return { claudeCode, opencode };
107
+ }
108
+
109
+ /**
110
+ * Get project-specific target preference from global config
111
+ */
112
+ private async getProjectTargetPreference(
113
+ projectHash: string
114
+ ): Promise<'claude-code' | 'opencode' | undefined> {
115
+ const prefsPath = path.join(this.flowHomeDir, 'project-preferences.json');
116
+ if (!existsSync(prefsPath)) {
117
+ return undefined;
118
+ }
119
+
120
+ try {
121
+ const prefs = JSON.parse(await fs.readFile(prefsPath, 'utf-8'));
122
+ return prefs.projects?.[projectHash]?.target;
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Save project-specific target preference to global config
130
+ */
131
+ async saveProjectTargetPreference(
132
+ projectHash: string,
133
+ target: 'claude-code' | 'opencode'
134
+ ): Promise<void> {
135
+ const prefsPath = path.join(this.flowHomeDir, 'project-preferences.json');
136
+ let prefs: any = { projects: {} };
137
+
138
+ if (existsSync(prefsPath)) {
139
+ try {
140
+ prefs = JSON.parse(await fs.readFile(prefsPath, 'utf-8'));
141
+ } catch {
142
+ // Use default
143
+ }
144
+ }
145
+
146
+ if (!prefs.projects) {
147
+ prefs.projects = {};
148
+ }
149
+
150
+ prefs.projects[projectHash] = {
151
+ target,
152
+ lastUsed: new Date().toISOString(),
153
+ };
154
+
155
+ await fs.writeFile(prefsPath, JSON.stringify(prefs, null, 2));
156
+ }
157
+
158
+ /**
159
+ * Detect target platform (claude-code or opencode)
160
+ * New strategy: Detect based on installed commands, not folders
161
+ * Priority: saved preference > installed commands > global default
162
+ */
163
+ async detectTarget(projectPath: string): Promise<'claude-code' | 'opencode'> {
164
+ const projectHash = this.getProjectHash(projectPath);
165
+
166
+ // 1. Check if we already have a saved preference for this project
167
+ const savedPreference = await this.getProjectTargetPreference(projectHash);
168
+ if (savedPreference) {
169
+ // Verify the command is still available
170
+ const isAvailable =
171
+ savedPreference === 'claude-code'
172
+ ? await this.isCommandAvailable('claude')
173
+ : await this.isCommandAvailable('opencode');
174
+
175
+ if (isAvailable) {
176
+ return savedPreference;
177
+ }
178
+ // Command no longer available, fall through to re-detect
179
+ }
180
+
181
+ // 2. Detect which commands are installed
182
+ const installed = await this.detectInstalledCommands();
183
+
184
+ // If only one is installed, use that
185
+ if (installed.claudeCode && !installed.opencode) {
186
+ await this.saveProjectTargetPreference(projectHash, 'claude-code');
187
+ return 'claude-code';
188
+ }
189
+ if (installed.opencode && !installed.claudeCode) {
190
+ await this.saveProjectTargetPreference(projectHash, 'opencode');
191
+ return 'opencode';
192
+ }
193
+
194
+ // If both are installed, use global default
195
+ if (installed.claudeCode && installed.opencode) {
196
+ const globalSettingsPath = path.join(this.flowHomeDir, 'settings.json');
197
+ if (existsSync(globalSettingsPath)) {
198
+ try {
199
+ const settings = JSON.parse(await fs.readFile(globalSettingsPath, 'utf-8'));
200
+ if (settings.defaultTarget) {
201
+ await this.saveProjectTargetPreference(projectHash, settings.defaultTarget);
202
+ return settings.defaultTarget;
203
+ }
204
+ } catch {
205
+ // Fall through
206
+ }
207
+ }
208
+
209
+ // Both installed, no global default, use claude-code
210
+ await this.saveProjectTargetPreference(projectHash, 'claude-code');
211
+ return 'claude-code';
212
+ }
213
+
214
+ // Neither installed - this will fail later, but return default
215
+ return 'claude-code';
216
+ }
217
+
218
+ /**
219
+ * Get target config directory for project
220
+ */
221
+ getTargetConfigDir(projectPath: string, target: 'claude-code' | 'opencode'): string {
222
+ return target === 'claude-code'
223
+ ? path.join(projectPath, '.claude')
224
+ : path.join(projectPath, '.opencode');
225
+ }
226
+
227
+ /**
228
+ * Get all active projects
229
+ */
230
+ async getActiveProjects(): Promise<Array<{ hash: string; sessionFile: string }>> {
231
+ const sessionsDir = path.join(this.flowHomeDir, 'sessions');
232
+
233
+ if (!existsSync(sessionsDir)) {
234
+ return [];
235
+ }
236
+
237
+ const files = await fs.readdir(sessionsDir);
238
+ return files
239
+ .filter((file) => file.endsWith('.json'))
240
+ .map((file) => ({
241
+ hash: file.replace('.json', ''),
242
+ sessionFile: path.join(sessionsDir, file),
243
+ }));
244
+ }
245
+
246
+ /**
247
+ * Get project info from hash
248
+ */
249
+ async getProjectInfo(projectHash: string): Promise<{
250
+ hash: string;
251
+ hasActiveSession: boolean;
252
+ backupsCount: number;
253
+ hasSecrets: boolean;
254
+ } | null> {
255
+ const paths = this.getProjectPaths(projectHash);
256
+
257
+ const hasActiveSession = existsSync(paths.sessionFile);
258
+
259
+ let backupsCount = 0;
260
+ if (existsSync(paths.backupsDir)) {
261
+ const backups = await fs.readdir(paths.backupsDir);
262
+ backupsCount = backups.filter((b) => b.startsWith('session-')).length;
263
+ }
264
+
265
+ const hasSecrets = existsSync(paths.secretsDir);
266
+
267
+ return {
268
+ hash: projectHash,
269
+ hasActiveSession,
270
+ backupsCount,
271
+ hasSecrets,
272
+ };
273
+ }
274
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Secrets Manager
3
+ * Handles extraction, storage, and restoration of MCP secrets
4
+ * Stores secrets in ~/.sylphx-flow/secrets/{project-hash}/
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { existsSync } from 'node:fs';
10
+ import crypto from 'node:crypto';
11
+ import os from 'node:os';
12
+ import { ProjectManager } from './project-manager.js';
13
+
14
+ export interface MCPSecrets {
15
+ version: string;
16
+ extractedAt: string;
17
+ servers: Record<
18
+ string,
19
+ {
20
+ env?: Record<string, string>;
21
+ args?: string[];
22
+ }
23
+ >;
24
+ }
25
+
26
+ export class SecretsManager {
27
+ private projectManager: ProjectManager;
28
+
29
+ constructor(projectManager: ProjectManager) {
30
+ this.projectManager = projectManager;
31
+ }
32
+
33
+ /**
34
+ * Extract MCP secrets from project config
35
+ */
36
+ async extractMCPSecrets(
37
+ projectPath: string,
38
+ projectHash: string,
39
+ target: 'claude-code' | 'opencode'
40
+ ): Promise<MCPSecrets> {
41
+ const targetDir = this.projectManager.getTargetConfigDir(projectPath, target);
42
+ const configPath =
43
+ target === 'claude-code'
44
+ ? path.join(targetDir, 'settings.json')
45
+ : path.join(targetDir, '.mcp.json');
46
+
47
+ const secrets: MCPSecrets = {
48
+ version: '1.0.0',
49
+ extractedAt: new Date().toISOString(),
50
+ servers: {},
51
+ };
52
+
53
+ if (!existsSync(configPath)) {
54
+ return secrets;
55
+ }
56
+
57
+ try {
58
+ const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
59
+
60
+ // Extract MCP server secrets
61
+ if (config.mcp && config.mcp.servers) {
62
+ for (const [serverName, serverConfig] of Object.entries(config.mcp.servers)) {
63
+ const server = serverConfig as any;
64
+
65
+ // Extract env vars (sensitive)
66
+ if (server.env && Object.keys(server.env).length > 0) {
67
+ secrets.servers[serverName] = {
68
+ env: server.env,
69
+ };
70
+ }
71
+
72
+ // Extract args (may contain secrets)
73
+ if (server.args && Array.isArray(server.args)) {
74
+ if (!secrets.servers[serverName]) {
75
+ secrets.servers[serverName] = {};
76
+ }
77
+ secrets.servers[serverName].args = server.args;
78
+ }
79
+ }
80
+ }
81
+ } catch (error) {
82
+ // Config file exists but cannot be parsed, skip
83
+ }
84
+
85
+ return secrets;
86
+ }
87
+
88
+ /**
89
+ * Save secrets to ~/.sylphx-flow/secrets/{project-hash}/
90
+ */
91
+ async saveSecrets(projectHash: string, secrets: MCPSecrets): Promise<void> {
92
+ const paths = this.projectManager.getProjectPaths(projectHash);
93
+
94
+ // Ensure secrets directory exists
95
+ await fs.mkdir(paths.secretsDir, { recursive: true });
96
+
97
+ // Save unencrypted (for now - can add encryption later)
98
+ const secretsPath = path.join(paths.secretsDir, 'mcp-env.json');
99
+ await fs.writeFile(secretsPath, JSON.stringify(secrets, null, 2));
100
+ }
101
+
102
+ /**
103
+ * Load secrets from storage
104
+ */
105
+ async loadSecrets(projectHash: string): Promise<MCPSecrets | null> {
106
+ const paths = this.projectManager.getProjectPaths(projectHash);
107
+ const secretsPath = path.join(paths.secretsDir, 'mcp-env.json');
108
+
109
+ if (!existsSync(secretsPath)) {
110
+ return null;
111
+ }
112
+
113
+ try {
114
+ const data = await fs.readFile(secretsPath, 'utf-8');
115
+ return JSON.parse(data);
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Restore secrets to project config
123
+ */
124
+ async restoreSecrets(
125
+ projectPath: string,
126
+ projectHash: string,
127
+ target: 'claude-code' | 'opencode',
128
+ secrets: MCPSecrets
129
+ ): Promise<void> {
130
+ if (Object.keys(secrets.servers).length === 0) {
131
+ return;
132
+ }
133
+
134
+ const targetDir = this.projectManager.getTargetConfigDir(projectPath, target);
135
+ const configPath =
136
+ target === 'claude-code'
137
+ ? path.join(targetDir, 'settings.json')
138
+ : path.join(targetDir, '.mcp.json');
139
+
140
+ if (!existsSync(configPath)) {
141
+ return;
142
+ }
143
+
144
+ try {
145
+ const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
146
+
147
+ // Restore secrets to MCP servers
148
+ if (config.mcp && config.mcp.servers) {
149
+ for (const [serverName, serverSecrets] of Object.entries(secrets.servers)) {
150
+ if (config.mcp.servers[serverName]) {
151
+ // Restore env vars
152
+ if (serverSecrets.env) {
153
+ config.mcp.servers[serverName].env = serverSecrets.env;
154
+ }
155
+
156
+ // Restore args
157
+ if (serverSecrets.args) {
158
+ config.mcp.servers[serverName].args = serverSecrets.args;
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // Write updated config
165
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
166
+ } catch (error) {
167
+ // Config restore failed, skip
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Encrypt secrets (optional - for enhanced security)
173
+ */
174
+ private async encrypt(data: string): Promise<string> {
175
+ // Use machine ID + user HOME as stable key source
176
+ const keySource = `${os.homedir()}-${os.hostname()}`;
177
+ const key = crypto.scryptSync(keySource, 'sylphx-flow-salt', 32);
178
+ const iv = crypto.randomBytes(16);
179
+
180
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
181
+ let encrypted = cipher.update(data, 'utf8', 'hex');
182
+ encrypted += cipher.final('hex');
183
+
184
+ const authTag = cipher.getAuthTag();
185
+
186
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
187
+ }
188
+
189
+ /**
190
+ * Decrypt secrets (optional)
191
+ */
192
+ private async decrypt(encrypted: string): Promise<string> {
193
+ const [ivHex, authTagHex, encryptedData] = encrypted.split(':');
194
+
195
+ const keySource = `${os.homedir()}-${os.hostname()}`;
196
+ const key = crypto.scryptSync(keySource, 'sylphx-flow-salt', 32);
197
+ const iv = Buffer.from(ivHex, 'hex');
198
+ const authTag = Buffer.from(authTagHex, 'hex');
199
+
200
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
201
+ decipher.setAuthTag(authTag);
202
+
203
+ let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
204
+ decrypted += decipher.final('utf8');
205
+
206
+ return decrypted;
207
+ }
208
+
209
+ /**
210
+ * Clear secrets for a project
211
+ */
212
+ async clearSecrets(projectHash: string): Promise<void> {
213
+ const paths = this.projectManager.getProjectPaths(projectHash);
214
+ const secretsPath = path.join(paths.secretsDir, 'mcp-env.json');
215
+
216
+ if (existsSync(secretsPath)) {
217
+ await fs.unlink(secretsPath);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Check if project has stored secrets
223
+ */
224
+ async hasSecrets(projectHash: string): Promise<boolean> {
225
+ const paths = this.projectManager.getProjectPaths(projectHash);
226
+ const secretsPath = path.join(paths.secretsDir, 'mcp-env.json');
227
+ return existsSync(secretsPath);
228
+ }
229
+ }