@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.
- package/CHANGELOG.md +41 -0
- package/package.json +1 -1
- package/src/config/targets.ts +1 -1
- package/src/core/__tests__/backup-restore.test.ts +1 -1
- package/src/core/__tests__/cleanup-handler.test.ts +292 -0
- package/src/core/__tests__/git-stash-manager.test.ts +246 -0
- package/src/core/__tests__/secrets-manager.test.ts +126 -0
- package/src/core/__tests__/session-cleanup.test.ts +147 -0
- package/src/core/attach-manager.ts +7 -77
- package/src/core/backup-manager.ts +8 -20
- package/src/core/cleanup-handler.ts +179 -7
- package/src/core/error-handling.ts +0 -30
- package/src/core/flow-executor.ts +58 -76
- package/src/core/git-stash-manager.ts +50 -68
- package/src/core/project-manager.ts +12 -14
- package/src/core/session-manager.ts +28 -33
- package/src/core/state-detector.ts +4 -15
- package/src/core/target-resolver.ts +14 -9
- package/src/core/template-loader.ts +7 -33
- package/src/core/upgrade-manager.ts +4 -15
- package/src/index.ts +6 -35
- package/src/targets/claude-code.ts +16 -107
- package/src/targets/functional/claude-code-logic.ts +47 -103
- package/src/targets/opencode.ts +2 -158
- package/src/targets/shared/target-operations.ts +1 -54
- package/src/types/target.types.ts +4 -24
- package/src/utils/config/target-config.ts +8 -14
- package/src/utils/config/target-utils.ts +1 -50
- package/src/utils/files/sync-utils.ts +5 -5
- package/src/utils/object-utils.ts +10 -2
- package/src/utils/security/secret-utils.ts +2 -2
|
@@ -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
|
|
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'
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
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
|
/**
|