@sylphx/flow 3.18.0 → 3.19.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,126 @@
1
+ /**
2
+ * Tests for SecretsManager
3
+ * Covers: save, load, clear, hasSecrets operations
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
+ import { ProjectManager } from '../project-manager.js';
11
+ import { SecretsManager } from '../secrets-manager.js';
12
+
13
+ describe('SecretsManager', () => {
14
+ let tempDir: string;
15
+ let flowHome: string;
16
+ let projectManager: ProjectManager;
17
+ let secretsManager: SecretsManager;
18
+ let projectHash: string;
19
+
20
+ beforeEach(() => {
21
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-secrets-test-'));
22
+ flowHome = path.join(tempDir, '.sylphx-flow');
23
+
24
+ fs.mkdirSync(path.join(flowHome, 'sessions'), { recursive: true });
25
+ fs.mkdirSync(path.join(flowHome, 'backups'), { recursive: true });
26
+ fs.mkdirSync(path.join(flowHome, 'secrets'), { recursive: true });
27
+ fs.mkdirSync(path.join(flowHome, 'templates'), { recursive: true });
28
+
29
+ projectManager = new ProjectManager();
30
+ (projectManager as any).flowHomeDir = flowHome;
31
+
32
+ secretsManager = new SecretsManager(projectManager);
33
+ projectHash = 'testproject1234';
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (tempDir && fs.existsSync(tempDir)) {
38
+ fs.rmSync(tempDir, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ describe('saveSecrets()', () => {
43
+ it('should save secrets to correct path', async () => {
44
+ const secrets = {
45
+ version: '1.0.0',
46
+ extractedAt: new Date().toISOString(),
47
+ servers: {
48
+ 'test-server': {
49
+ env: { API_KEY: 'secret-value' },
50
+ },
51
+ },
52
+ };
53
+
54
+ await secretsManager.saveSecrets(projectHash, secrets);
55
+
56
+ const paths = projectManager.getProjectPaths(projectHash);
57
+ const secretsPath = path.join(paths.secretsDir, 'mcp-env.json');
58
+ expect(fs.existsSync(secretsPath)).toBe(true);
59
+
60
+ const saved = JSON.parse(fs.readFileSync(secretsPath, 'utf-8'));
61
+ expect(saved.servers['test-server'].env.API_KEY).toBe('secret-value');
62
+ });
63
+ });
64
+
65
+ describe('loadSecrets()', () => {
66
+ it('should load saved secrets', async () => {
67
+ const secrets = {
68
+ version: '1.0.0',
69
+ extractedAt: '2024-01-01T00:00:00.000Z',
70
+ servers: {
71
+ 'my-server': { env: { TOKEN: 'abc123' } },
72
+ },
73
+ };
74
+
75
+ await secretsManager.saveSecrets(projectHash, secrets);
76
+ const loaded = await secretsManager.loadSecrets(projectHash);
77
+
78
+ expect(loaded).not.toBeNull();
79
+ expect(loaded?.servers['my-server'].env?.TOKEN).toBe('abc123');
80
+ });
81
+
82
+ it('should return null when no secrets exist', async () => {
83
+ const loaded = await secretsManager.loadSecrets('nonexistent');
84
+ expect(loaded).toBeNull();
85
+ });
86
+ });
87
+
88
+ describe('clearSecrets()', () => {
89
+ it('should delete secrets file', async () => {
90
+ // Save some secrets first
91
+ await secretsManager.saveSecrets(projectHash, {
92
+ version: '1.0.0',
93
+ extractedAt: new Date().toISOString(),
94
+ servers: { s: { env: { KEY: 'val' } } },
95
+ });
96
+
97
+ const paths = projectManager.getProjectPaths(projectHash);
98
+ const secretsPath = path.join(paths.secretsDir, 'mcp-env.json');
99
+ expect(fs.existsSync(secretsPath)).toBe(true);
100
+
101
+ // Clear
102
+ await secretsManager.clearSecrets(projectHash);
103
+ expect(fs.existsSync(secretsPath)).toBe(false);
104
+ });
105
+
106
+ it('should not throw when secrets file does not exist', async () => {
107
+ await secretsManager.clearSecrets('nonexistent-hash'); // should not throw
108
+ });
109
+ });
110
+
111
+ describe('hasSecrets()', () => {
112
+ it('should return true when secrets exist', async () => {
113
+ await secretsManager.saveSecrets(projectHash, {
114
+ version: '1.0.0',
115
+ extractedAt: new Date().toISOString(),
116
+ servers: { s: { env: { A: 'b' } } },
117
+ });
118
+
119
+ expect(await secretsManager.hasSecrets(projectHash)).toBe(true);
120
+ });
121
+
122
+ it('should return false when secrets do not exist', async () => {
123
+ expect(await secretsManager.hasSecrets('no-such-project')).toBe(false);
124
+ });
125
+ });
126
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Tests for SessionManager cleanup functionality
3
+ * Covers: cleanupSessionHistory, multi-session lifecycle
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
+ import { ProjectManager } from '../project-manager.js';
11
+ import { SessionManager } from '../session-manager.js';
12
+
13
+ describe('SessionManager', () => {
14
+ let tempDir: string;
15
+ let flowHome: string;
16
+ let projectManager: ProjectManager;
17
+ let sessionManager: SessionManager;
18
+
19
+ beforeEach(() => {
20
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-session-test-'));
21
+ flowHome = path.join(tempDir, '.sylphx-flow');
22
+
23
+ // Create required directories
24
+ fs.mkdirSync(path.join(flowHome, 'sessions', 'history'), { recursive: true });
25
+ fs.mkdirSync(path.join(flowHome, 'backups'), { recursive: true });
26
+ fs.mkdirSync(path.join(flowHome, 'secrets'), { recursive: true });
27
+ fs.mkdirSync(path.join(flowHome, 'templates'), { recursive: true });
28
+
29
+ projectManager = new ProjectManager();
30
+ // Override flowHomeDir to use temp
31
+ (projectManager as any).flowHomeDir = flowHome;
32
+
33
+ sessionManager = new SessionManager(projectManager);
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (tempDir && fs.existsSync(tempDir)) {
38
+ fs.rmSync(tempDir, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ describe('cleanupSessionHistory()', () => {
43
+ it('should keep only last N session history files', async () => {
44
+ const historyDir = path.join(flowHome, 'sessions', 'history');
45
+
46
+ // Create 10 history files with incrementing timestamps
47
+ for (let i = 1; i <= 10; i++) {
48
+ const filename = `session-${1000 + i}.json`;
49
+ fs.writeFileSync(path.join(historyDir, filename), JSON.stringify({ sessionId: `session-${1000 + i}` }));
50
+ }
51
+
52
+ // Keep last 3
53
+ await sessionManager.cleanupSessionHistory(3);
54
+
55
+ const remaining = fs.readdirSync(historyDir).filter((f) => f.endsWith('.json'));
56
+ expect(remaining.length).toBe(3);
57
+
58
+ // Most recent files should remain
59
+ expect(remaining).toContain('session-1010.json');
60
+ expect(remaining).toContain('session-1009.json');
61
+ expect(remaining).toContain('session-1008.json');
62
+
63
+ // Older files should be deleted
64
+ expect(remaining).not.toContain('session-1001.json');
65
+ });
66
+
67
+ it('should do nothing when fewer files than limit', async () => {
68
+ const historyDir = path.join(flowHome, 'sessions', 'history');
69
+
70
+ // Create 2 files
71
+ fs.writeFileSync(path.join(historyDir, 'session-100.json'), '{}');
72
+ fs.writeFileSync(path.join(historyDir, 'session-200.json'), '{}');
73
+
74
+ // Keep last 50
75
+ await sessionManager.cleanupSessionHistory(50);
76
+
77
+ const remaining = fs.readdirSync(historyDir).filter((f) => f.endsWith('.json'));
78
+ expect(remaining.length).toBe(2);
79
+ });
80
+
81
+ it('should handle empty history directory', async () => {
82
+ // No files in history
83
+ await sessionManager.cleanupSessionHistory(50); // should not throw
84
+ });
85
+
86
+ it('should handle missing history directory', async () => {
87
+ // Remove history dir
88
+ fs.rmSync(path.join(flowHome, 'sessions', 'history'), { recursive: true });
89
+
90
+ await sessionManager.cleanupSessionHistory(50); // should not throw
91
+ });
92
+
93
+ it('should ignore non-JSON files', async () => {
94
+ const historyDir = path.join(flowHome, 'sessions', 'history');
95
+
96
+ // Create mix of files
97
+ fs.writeFileSync(path.join(historyDir, 'session-1.json'), '{}');
98
+ fs.writeFileSync(path.join(historyDir, 'session-2.json'), '{}');
99
+ fs.writeFileSync(path.join(historyDir, '.gitkeep'), '');
100
+ fs.writeFileSync(path.join(historyDir, 'notes.txt'), 'some notes');
101
+
102
+ await sessionManager.cleanupSessionHistory(1);
103
+
104
+ // Only 1 JSON file should remain, non-JSON untouched
105
+ const remaining = fs.readdirSync(historyDir);
106
+ const jsonFiles = remaining.filter((f) => f.endsWith('.json'));
107
+ expect(jsonFiles.length).toBe(1);
108
+ expect(jsonFiles[0]).toBe('session-2.json');
109
+
110
+ // Non-JSON files still exist
111
+ expect(remaining).toContain('.gitkeep');
112
+ expect(remaining).toContain('notes.txt');
113
+ });
114
+ });
115
+
116
+ describe('Session lifecycle', () => {
117
+ it('should create and end a session', async () => {
118
+ const projectPath = path.join(tempDir, 'project');
119
+ fs.mkdirSync(projectPath, { recursive: true });
120
+
121
+ const hash = projectManager.getProjectHash(projectPath);
122
+
123
+ const { session, isFirstSession } = await sessionManager.startSession(
124
+ projectPath,
125
+ hash,
126
+ 'claude-code',
127
+ '/tmp/backup',
128
+ 'session-test-1'
129
+ );
130
+
131
+ expect(isFirstSession).toBe(true);
132
+ expect(session.projectPath).toBe(projectPath);
133
+ expect(session.sessionId).toBe('session-test-1');
134
+ expect(session.refCount).toBe(1);
135
+
136
+ // Verify active session exists
137
+ const active = await sessionManager.getActiveSession(hash);
138
+ expect(active).not.toBeNull();
139
+ expect(active?.sessionId).toBe('session-test-1');
140
+ });
141
+
142
+ it('should return null for non-existent session', async () => {
143
+ const active = await sessionManager.getActiveSession('nonexistent');
144
+ expect(active).toBeNull();
145
+ });
146
+ });
147
+ });
@@ -13,8 +13,7 @@ import { GlobalConfigService } from '../services/global-config.js';
13
13
  import type { Target } from '../types/target.types.js';
14
14
  import { attachItemsToDir, attachRulesFile } from './attach/index.js';
15
15
  import type { BackupManifest } from './backup-manager.js';
16
- import type { ProjectManager } from './project-manager.js';
17
- import { targetManager } from './target-manager.js';
16
+ import { resolveTargetOrId } from './target-resolver.js';
18
17
 
19
18
  export interface AttachResult {
20
19
  agentsAdded: string[];
@@ -27,13 +26,11 @@ export interface AttachResult {
27
26
  mcpServersAdded: string[];
28
27
  mcpServersOverridden: string[];
29
28
  singleFilesMerged: string[];
30
- hooksAdded: string[];
31
- hooksOverridden: string[];
32
29
  conflicts: ConflictInfo[];
33
30
  }
34
31
 
35
32
  export interface ConflictInfo {
36
- type: 'agent' | 'command' | 'skill' | 'mcp' | 'hook';
33
+ type: 'agent' | 'command' | 'skill' | 'mcp';
37
34
  name: string;
38
35
  action: 'overridden' | 'merged';
39
36
  message: string;
@@ -45,15 +42,13 @@ export interface FlowTemplates {
45
42
  skills: Array<{ name: string; content: string }>;
46
43
  rules?: string;
47
44
  mcpServers: Array<{ name: string; config: Record<string, unknown> }>;
48
- hooks: Array<{ name: string; content: string }>;
49
45
  singleFiles: Array<{ path: string; content: string }>;
50
46
  }
51
47
 
52
48
  export class AttachManager {
53
49
  private configService: GlobalConfigService;
54
50
 
55
- constructor(projectManager: ProjectManager) {
56
- this.projectManager = projectManager;
51
+ constructor() {
57
52
  this.configService = new GlobalConfigService();
58
53
  }
59
54
 
@@ -69,17 +64,6 @@ export class AttachManager {
69
64
  }
70
65
  }
71
66
 
72
- /**
73
- * Resolve target from ID string to Target object
74
- */
75
- private resolveTarget(targetId: string): Target {
76
- const targetOption = targetManager.getTarget(targetId);
77
- if (targetOption._tag === 'None') {
78
- throw new Error(`Unknown target: ${targetId}`);
79
- }
80
- return targetOption.value;
81
- }
82
-
83
67
  /**
84
68
  * Load global MCP servers from ~/.sylphx-flow/mcp-config.json
85
69
  * Uses SSOT: computeEffectiveServers for determining enabled servers
@@ -125,21 +109,14 @@ export class AttachManager {
125
109
  /**
126
110
  * Attach Flow templates to project
127
111
  * Strategy: Override with warning, backup handles restoration
128
- * @param projectPath - Project root path
129
- * @param _projectHash - Project hash (unused but kept for API compatibility)
130
- * @param targetOrId - Target object or target ID string
131
- * @param templates - Flow templates to attach
132
- * @param manifest - Backup manifest to track changes
133
112
  */
134
113
  async attach(
135
114
  projectPath: string,
136
- _projectHash: string,
137
115
  targetOrId: Target | string,
138
116
  templates: FlowTemplates,
139
117
  manifest: BackupManifest
140
118
  ): Promise<AttachResult> {
141
- // Resolve target from ID if needed
142
- const target = typeof targetOrId === 'string' ? this.resolveTarget(targetOrId) : targetOrId;
119
+ const target = resolveTargetOrId(targetOrId);
143
120
 
144
121
  const result: AttachResult = {
145
122
  agentsAdded: [],
@@ -152,8 +129,6 @@ export class AttachManager {
152
129
  mcpServersAdded: [],
153
130
  mcpServersOverridden: [],
154
131
  singleFilesMerged: [],
155
- hooksAdded: [],
156
- hooksOverridden: [],
157
132
  conflicts: [],
158
133
  };
159
134
 
@@ -183,14 +158,9 @@ export class AttachManager {
183
158
  await this.attachMCPServers(projectPath, target, allMCPServers, result, manifest);
184
159
  }
185
160
 
186
- // 6. Attach hooks
187
- if (templates.hooks.length > 0) {
188
- await this.attachHooks(projectPath, target, templates.hooks, result, manifest);
189
- }
190
-
191
- // 7. Attach single files
161
+ // 6. Attach single files
192
162
  if (templates.singleFiles.length > 0) {
193
- await this.attachSingleFiles(projectPath, templates.singleFiles, result, manifest);
163
+ await this.attachSingleFiles(projectPath, target, templates.singleFiles, result, manifest);
194
164
  }
195
165
 
196
166
  return result;
@@ -386,40 +356,6 @@ export class AttachManager {
386
356
  };
387
357
  }
388
358
 
389
- /**
390
- * Attach hooks (override strategy)
391
- */
392
- private async attachHooks(
393
- projectPath: string,
394
- target: Target,
395
- hooks: Array<{ name: string; content: string }>,
396
- result: AttachResult,
397
- _manifest: BackupManifest
398
- ): Promise<void> {
399
- // Hooks are in configDir/hooks
400
- const hooksDir = path.join(projectPath, target.config.configDir, 'hooks');
401
- await fs.mkdir(hooksDir, { recursive: true });
402
-
403
- for (const hook of hooks) {
404
- const hookPath = path.join(hooksDir, hook.name);
405
- const existed = existsSync(hookPath);
406
-
407
- if (existed) {
408
- result.hooksOverridden.push(hook.name);
409
- result.conflicts.push({
410
- type: 'hook',
411
- name: hook.name,
412
- action: 'overridden',
413
- message: `Hook '${hook.name}' overridden (will be restored on exit)`,
414
- });
415
- } else {
416
- result.hooksAdded.push(hook.name);
417
- }
418
-
419
- await fs.writeFile(hookPath, hook.content);
420
- }
421
- }
422
-
423
359
  /**
424
360
  * Attach single files (currently unused, output styles merged into core.md)
425
361
  * NOTE: These files are placed in the target config directory (.claude/ or .opencode/),
@@ -427,17 +363,11 @@ export class AttachManager {
427
363
  */
428
364
  private async attachSingleFiles(
429
365
  projectPath: string,
366
+ target: Target,
430
367
  singleFiles: Array<{ path: string; content: string }>,
431
368
  result: AttachResult,
432
369
  manifest: BackupManifest
433
370
  ): Promise<void> {
434
- // Get target from manifest to determine config directory
435
- const targetOption = targetManager.getTarget(manifest.target);
436
- if (targetOption._tag === 'None') {
437
- return; // Unknown target, skip
438
- }
439
- const target = targetOption.value;
440
-
441
371
  for (const file of singleFiles) {
442
372
  // Write to target config directory (e.g., .claude/ or .opencode/)
443
373
  const filePath = path.join(projectPath, target.config.configDir, file.path);
@@ -233,32 +233,20 @@ export class BackupManager {
233
233
  }))
234
234
  .sort((a, b) => b.timestamp - a.timestamp);
235
235
 
236
- // Remove old backups
236
+ // Remove old backups in parallel
237
237
  const toRemove = sessions.slice(keepLast);
238
- for (const session of toRemove) {
239
- const sessionPath = path.join(paths.backupsDir, session.name);
240
- await fs.rm(sessionPath, { recursive: true, force: true });
241
- }
238
+ await Promise.all(
239
+ toRemove.map((session) =>
240
+ fs.rm(path.join(paths.backupsDir, session.name), { recursive: true, force: true })
241
+ )
242
+ );
242
243
  }
243
244
 
244
245
  /**
245
- * Copy directory recursively
246
+ * Copy directory recursively using native fs.cp
246
247
  */
247
248
  private async copyDirectory(src: string, dest: string): Promise<void> {
248
- await fs.mkdir(dest, { recursive: true });
249
-
250
- const entries = await fs.readdir(src, { withFileTypes: true });
251
-
252
- for (const entry of entries) {
253
- const srcPath = path.join(src, entry.name);
254
- const destPath = path.join(dest, entry.name);
255
-
256
- if (entry.isDirectory()) {
257
- await this.copyDirectory(srcPath, destPath);
258
- } else {
259
- await fs.copyFile(srcPath, destPath);
260
- }
261
- }
249
+ await fs.cp(src, dest, { recursive: true });
262
250
  }
263
251
 
264
252
  /**