@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,268 @@
1
+ /**
2
+ * Session Manager (Updated for ~/.sylphx-flow)
3
+ * Manages temporary Flow sessions with multi-project support
4
+ * All sessions stored in ~/.sylphx-flow/sessions/
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { existsSync } from 'node:fs';
10
+ import { ProjectManager } from './project-manager.js';
11
+
12
+ export interface Session {
13
+ projectHash: string;
14
+ projectPath: string;
15
+ sessionId: string;
16
+ pid: number;
17
+ startTime: string;
18
+ backupPath: string;
19
+ status: 'active' | 'completed' | 'crashed';
20
+ target: 'claude-code' | 'opencode';
21
+ cleanupRequired: boolean;
22
+ // Multi-session support
23
+ isOriginal: boolean; // First session that created backup
24
+ sharedBackupId: string; // Shared backup ID for all sessions
25
+ refCount: number; // Number of active sessions
26
+ activePids: number[]; // All active PIDs sharing this session
27
+ }
28
+
29
+ export class SessionManager {
30
+ private projectManager: ProjectManager;
31
+
32
+ constructor(projectManager: ProjectManager) {
33
+ this.projectManager = projectManager;
34
+ }
35
+
36
+ /**
37
+ * Start a new session for a project (with multi-session support)
38
+ */
39
+ async startSession(
40
+ projectPath: string,
41
+ projectHash: string,
42
+ target: 'claude-code' | 'opencode',
43
+ backupPath: string,
44
+ sessionId?: string
45
+ ): Promise<{ session: Session; isFirstSession: boolean }> {
46
+ const paths = this.projectManager.getProjectPaths(projectHash);
47
+
48
+ // Ensure sessions directory exists
49
+ await fs.mkdir(path.dirname(paths.sessionFile), { recursive: true });
50
+
51
+ // Check for existing session
52
+ const existingSession = await this.getActiveSession(projectHash);
53
+
54
+ if (existingSession) {
55
+ // Join existing session (don't create new backup)
56
+ existingSession.refCount++;
57
+ existingSession.activePids.push(process.pid);
58
+
59
+ await fs.writeFile(paths.sessionFile, JSON.stringify(existingSession, null, 2));
60
+
61
+ return {
62
+ session: existingSession,
63
+ isFirstSession: false,
64
+ };
65
+ }
66
+
67
+ // First session - create new (use provided sessionId or generate one)
68
+ const newSessionId = sessionId || `session-${Date.now()}`;
69
+ const session: Session = {
70
+ projectHash,
71
+ projectPath,
72
+ sessionId: newSessionId,
73
+ pid: process.pid,
74
+ startTime: new Date().toISOString(),
75
+ backupPath,
76
+ status: 'active',
77
+ target,
78
+ cleanupRequired: true,
79
+ isOriginal: true,
80
+ sharedBackupId: newSessionId,
81
+ refCount: 1,
82
+ activePids: [process.pid],
83
+ };
84
+
85
+ await fs.writeFile(paths.sessionFile, JSON.stringify(session, null, 2));
86
+
87
+ return {
88
+ session,
89
+ isFirstSession: true,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Mark session as completed (with reference counting)
95
+ */
96
+ async endSession(projectHash: string): Promise<{ shouldRestore: boolean; session: Session | null }> {
97
+ try {
98
+ const session = await this.getActiveSession(projectHash);
99
+
100
+ if (!session) {
101
+ return { shouldRestore: false, session: null };
102
+ }
103
+
104
+ const paths = this.projectManager.getProjectPaths(projectHash);
105
+
106
+ // Remove current PID from active PIDs
107
+ session.activePids = session.activePids.filter(pid => pid !== process.pid);
108
+ session.refCount = session.activePids.length;
109
+
110
+ if (session.refCount === 0) {
111
+ // Last session - mark completed and cleanup
112
+ session.status = 'completed';
113
+ session.cleanupRequired = false;
114
+
115
+ const flowHome = this.projectManager.getFlowHomeDir();
116
+
117
+ // Archive to history
118
+ const historyPath = path.join(
119
+ flowHome,
120
+ 'sessions',
121
+ 'history',
122
+ `${session.sessionId}.json`
123
+ );
124
+ await fs.mkdir(path.dirname(historyPath), { recursive: true });
125
+ await fs.writeFile(historyPath, JSON.stringify(session, null, 2));
126
+
127
+ // Remove active session file
128
+ await fs.unlink(paths.sessionFile);
129
+
130
+ return { shouldRestore: true, session };
131
+ } else {
132
+ // Still have active sessions, update session file
133
+ await fs.writeFile(paths.sessionFile, JSON.stringify(session, null, 2));
134
+
135
+ return { shouldRestore: false, session };
136
+ }
137
+ } catch (error) {
138
+ // Session file might not exist
139
+ return { shouldRestore: false, session: null };
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Get active session for a project
145
+ */
146
+ async getActiveSession(projectHash: string): Promise<Session | null> {
147
+ try {
148
+ const paths = this.projectManager.getProjectPaths(projectHash);
149
+ const data = await fs.readFile(paths.sessionFile, 'utf-8');
150
+ return JSON.parse(data);
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Detect orphaned sessions (from crashes) across all projects
158
+ * Handles multi-session by checking all PIDs
159
+ */
160
+ async detectOrphanedSessions(): Promise<Map<string, Session>> {
161
+ const orphaned = new Map<string, Session>();
162
+
163
+ // Get all active projects
164
+ const projects = await this.projectManager.getActiveProjects();
165
+
166
+ for (const { hash } of projects) {
167
+ const session = await this.getActiveSession(hash);
168
+
169
+ if (!session) {
170
+ continue;
171
+ }
172
+
173
+ // Check all active PIDs
174
+ const stillRunning = [];
175
+ for (const pid of session.activePids) {
176
+ if (await this.checkPIDRunning(pid)) {
177
+ stillRunning.push(pid);
178
+ }
179
+ }
180
+
181
+ // Update active PIDs and refCount
182
+ session.activePids = stillRunning;
183
+ session.refCount = stillRunning.length;
184
+
185
+ if (session.refCount === 0 && session.cleanupRequired) {
186
+ // All sessions crashed
187
+ orphaned.set(hash, session);
188
+ } else if (session.refCount !== session.activePids.length) {
189
+ // Some PIDs crashed, update session file
190
+ const paths = this.projectManager.getProjectPaths(hash);
191
+ await fs.writeFile(paths.sessionFile, JSON.stringify(session, null, 2));
192
+ }
193
+ }
194
+
195
+ return orphaned;
196
+ }
197
+
198
+ /**
199
+ * Check if process is still running
200
+ */
201
+ private async checkPIDRunning(pid: number): Promise<boolean> {
202
+ try {
203
+ // Send signal 0 to check if process exists
204
+ process.kill(pid, 0);
205
+ return true;
206
+ } catch (error) {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Recover from crashed session
213
+ */
214
+ async recoverSession(projectHash: string, session: Session): Promise<void> {
215
+ session.status = 'crashed';
216
+ session.cleanupRequired = false;
217
+
218
+ const flowHome = this.projectManager.getFlowHomeDir();
219
+ const paths = this.projectManager.getProjectPaths(projectHash);
220
+
221
+ // Archive to history
222
+ const historyPath = path.join(flowHome, 'sessions', 'history', `${session.sessionId}.json`);
223
+ await fs.mkdir(path.dirname(historyPath), { recursive: true });
224
+ await fs.writeFile(historyPath, JSON.stringify(session, null, 2));
225
+
226
+ // Remove active session
227
+ try {
228
+ await fs.unlink(paths.sessionFile);
229
+ } catch {
230
+ // File might not exist
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Clean up old session history
236
+ */
237
+ async cleanupOldSessions(keepLast: number = 10): Promise<void> {
238
+ const flowHome = this.projectManager.getFlowHomeDir();
239
+ const historyDir = path.join(flowHome, 'sessions', 'history');
240
+
241
+ if (!existsSync(historyDir)) {
242
+ return;
243
+ }
244
+
245
+ const files = await fs.readdir(historyDir);
246
+ const sessions = await Promise.all(
247
+ files.map(async (file) => {
248
+ const filePath = path.join(historyDir, file);
249
+ const data = await fs.readFile(filePath, 'utf-8');
250
+ const session = JSON.parse(data) as Session;
251
+ return { file, session };
252
+ })
253
+ );
254
+
255
+ // Sort by start time (newest first)
256
+ sessions.sort(
257
+ (a, b) =>
258
+ new Date(b.session.startTime).getTime() -
259
+ new Date(a.session.startTime).getTime()
260
+ );
261
+
262
+ // Remove old sessions
263
+ const toRemove = sessions.slice(keepLast);
264
+ for (const { file } of toRemove) {
265
+ await fs.unlink(path.join(historyDir, file));
266
+ }
267
+ }
268
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Template Loader
3
+ * Loads Flow templates from assets directory
4
+ * Supports both claude-code and opencode targets
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { existsSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import type { FlowTemplates } from './attach-manager.js';
12
+
13
+ export class TemplateLoader {
14
+ private assetsDir: string;
15
+
16
+ constructor() {
17
+ // Get assets directory relative to this file
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+ this.assetsDir = path.join(__dirname, '..', '..', 'assets');
21
+ }
22
+
23
+ /**
24
+ * Load all templates for target
25
+ * Uses flat assets directory structure (no target-specific subdirectories)
26
+ */
27
+ async loadTemplates(target: 'claude-code' | 'opencode'): Promise<FlowTemplates> {
28
+ const templates: FlowTemplates = {
29
+ agents: [],
30
+ commands: [],
31
+ rules: undefined,
32
+ mcpServers: [],
33
+ hooks: [],
34
+ singleFiles: [],
35
+ };
36
+
37
+ // Load agents
38
+ const agentsDir = path.join(this.assetsDir, 'agents');
39
+ if (existsSync(agentsDir)) {
40
+ templates.agents = await this.loadAgents(agentsDir);
41
+ }
42
+
43
+ // Load commands (slash-commands directory)
44
+ const commandsDir = path.join(this.assetsDir, 'slash-commands');
45
+ if (existsSync(commandsDir)) {
46
+ templates.commands = await this.loadCommands(commandsDir);
47
+ }
48
+
49
+ // Load rules (check multiple possible locations)
50
+ const rulesLocations = [
51
+ path.join(this.assetsDir, 'rules', 'AGENTS.md'),
52
+ path.join(this.assetsDir, 'AGENTS.md'),
53
+ ];
54
+
55
+ for (const rulesPath of rulesLocations) {
56
+ if (existsSync(rulesPath)) {
57
+ templates.rules = await fs.readFile(rulesPath, 'utf-8');
58
+ break;
59
+ }
60
+ }
61
+
62
+ // Load MCP servers (if any)
63
+ const mcpConfigPath = path.join(this.assetsDir, 'mcp-servers.json');
64
+ if (existsSync(mcpConfigPath)) {
65
+ templates.mcpServers = await this.loadMCPServers(mcpConfigPath);
66
+ }
67
+
68
+ // Load output styles (single files)
69
+ const outputStylesDir = path.join(this.assetsDir, 'output-styles');
70
+ if (existsSync(outputStylesDir)) {
71
+ templates.singleFiles = await this.loadSingleFiles(outputStylesDir);
72
+ }
73
+
74
+ return templates;
75
+ }
76
+
77
+ /**
78
+ * Load agents from directory
79
+ */
80
+ private async loadAgents(
81
+ agentsDir: string
82
+ ): Promise<Array<{ name: string; content: string }>> {
83
+ const agents = [];
84
+ const files = await fs.readdir(agentsDir);
85
+
86
+ for (const file of files) {
87
+ if (!file.endsWith('.md')) continue;
88
+
89
+ const content = await fs.readFile(path.join(agentsDir, file), 'utf-8');
90
+ agents.push({ name: file, content });
91
+ }
92
+
93
+ return agents;
94
+ }
95
+
96
+ /**
97
+ * Load commands/modes from directory
98
+ */
99
+ private async loadCommands(
100
+ commandsDir: string
101
+ ): Promise<Array<{ name: string; content: string }>> {
102
+ const commands = [];
103
+ const files = await fs.readdir(commandsDir);
104
+
105
+ for (const file of files) {
106
+ if (!file.endsWith('.md')) continue;
107
+
108
+ const content = await fs.readFile(path.join(commandsDir, file), 'utf-8');
109
+ commands.push({ name: file, content });
110
+ }
111
+
112
+ return commands;
113
+ }
114
+
115
+ /**
116
+ * Load MCP servers configuration
117
+ */
118
+ private async loadMCPServers(
119
+ configPath: string
120
+ ): Promise<Array<{ name: string; config: any }>> {
121
+ const data = await fs.readFile(configPath, 'utf-8');
122
+ const config = JSON.parse(data);
123
+
124
+ const servers = [];
125
+ for (const [name, serverConfig] of Object.entries(config)) {
126
+ servers.push({ name, config: serverConfig });
127
+ }
128
+
129
+ return servers;
130
+ }
131
+
132
+ /**
133
+ * Load hooks from directory
134
+ */
135
+ private async loadHooks(
136
+ hooksDir: string
137
+ ): Promise<Array<{ name: string; content: string }>> {
138
+ const hooks = [];
139
+ const files = await fs.readdir(hooksDir);
140
+
141
+ for (const file of files) {
142
+ if (!file.endsWith('.js')) continue;
143
+
144
+ const content = await fs.readFile(path.join(hooksDir, file), 'utf-8');
145
+ hooks.push({ name: file, content });
146
+ }
147
+
148
+ return hooks;
149
+ }
150
+
151
+ /**
152
+ * Load single files (CLAUDE.md, .cursorrules, etc.)
153
+ */
154
+ private async loadSingleFiles(
155
+ singleFilesDir: string
156
+ ): Promise<Array<{ path: string; content: string }>> {
157
+ const files = [];
158
+ const entries = await fs.readdir(singleFilesDir);
159
+
160
+ for (const entry of entries) {
161
+ const filePath = path.join(singleFilesDir, entry);
162
+ const stat = await fs.stat(filePath);
163
+
164
+ if (stat.isFile()) {
165
+ const content = await fs.readFile(filePath, 'utf-8');
166
+ files.push({ path: entry, content });
167
+ }
168
+ }
169
+
170
+ return files;
171
+ }
172
+
173
+ /**
174
+ * Get assets directory path
175
+ */
176
+ getAssetsDir(): string {
177
+ return this.assetsDir;
178
+ }
179
+
180
+ /**
181
+ * Check if templates exist (uses flat directory structure)
182
+ */
183
+ async hasTemplates(target: 'claude-code' | 'opencode'): Promise<boolean> {
184
+ // Check if any template directories exist
185
+ const agentsDir = path.join(this.assetsDir, 'agents');
186
+ const commandsDir = path.join(this.assetsDir, 'slash-commands');
187
+ return existsSync(agentsDir) || existsSync(commandsDir);
188
+ }
189
+ }
@@ -8,6 +8,7 @@ import type { ProjectState } from './state-detector.js';
8
8
  import { CLIError } from '../utils/error-handler.js';
9
9
  import { ConfigService } from '../services/config-service.js';
10
10
  import { getProjectSettingsFile } from '../config/constants.js';
11
+ import { detectPackageManager, getUpgradeCommand, type PackageManager } from '../utils/package-manager-detector.js';
11
12
 
12
13
  const execAsync = promisify(exec);
13
14
 
@@ -87,86 +88,115 @@ export class UpgradeManager {
87
88
  };
88
89
  }
89
90
 
90
- async upgradeFlow(state: ProjectState): Promise<boolean> {
91
+ async upgradeFlow(state: ProjectState, autoInstall: boolean = false): Promise<boolean> {
91
92
  if (!state.outdated || !state.latestVersion) {
92
93
  return false;
93
94
  }
94
95
 
95
- const spinner = ora('升级 Sylphx Flow...').start();
96
+ const packageManager = detectPackageManager(this.projectPath);
97
+ const spinner = ora('Upgrading Sylphx Flow...').start();
96
98
 
97
99
  try {
98
- // 备份当前配置
100
+ // Backup current config
99
101
  if (!this.options.skipBackup) {
100
102
  await this.backupConfig();
101
103
  }
102
104
 
103
105
  if (this.options.dryRun) {
104
- spinner.succeed(`模拟升级: ${state.version} ${state.latestVersion}`);
106
+ const cmd = getUpgradeCommand('@sylphx/flow', packageManager);
107
+ spinner.succeed(`Dry run: ${state.version} → ${state.latestVersion}`);
108
+ console.log(chalk.dim(` Would run: ${cmd}`));
105
109
  return true;
106
110
  }
107
111
 
108
- // sym link 方式 - 实际需要重新安装
109
- // 这里假设用户会通过 git pull 或 npm update 更新
110
- // 我们只需要更新配置文件和 component
111
-
112
- // 更新配置文件中的版本号
113
- const configPath = path.join(this.projectPath, getProjectSettingsFile());
114
- try {
115
- const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
116
- config.version = state.latestVersion;
117
- config.lastUpdated = new Date().toISOString();
118
- await fs.writeFile(configPath, JSON.stringify(config, null, 2));
119
- } catch {
120
- // 无法更新配置
112
+ // Auto-install using detected package manager
113
+ if (autoInstall) {
114
+ const installCmd = getUpgradeCommand('@sylphx/flow', packageManager);
115
+ spinner.text = `Installing latest version via ${packageManager}...`;
116
+
117
+ try {
118
+ await execAsync(installCmd);
119
+ spinner.succeed(`Upgraded to ${state.latestVersion} using ${packageManager}`);
120
+ } catch (error) {
121
+ spinner.warn(`Auto-install failed, please run: ${installCmd}`);
122
+ if (this.options.verbose) {
123
+ console.error(error);
124
+ }
125
+ }
126
+ } else {
127
+ // Show manual upgrade command
128
+ const installCmd = getUpgradeCommand('@sylphx/flow', packageManager);
129
+ spinner.info(`To upgrade, run: ${chalk.cyan(installCmd)}`);
130
+
131
+ // Update config metadata
132
+ const configPath = path.join(this.projectPath, getProjectSettingsFile());
133
+ try {
134
+ const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
135
+ config.version = state.latestVersion;
136
+ config.lastUpdated = new Date().toISOString();
137
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
138
+ } catch {
139
+ // Cannot update config
140
+ }
121
141
  }
122
142
 
123
- spinner.succeed(`已升级到 ${state.latestVersion}`);
124
143
  return true;
125
144
  } catch (error) {
126
- spinner.fail('升级失败');
145
+ spinner.fail('Upgrade failed');
127
146
  throw new CLIError(
128
- `升级 Sylphx Flow 失败: ${error instanceof Error ? error.message : String(error)}`,
147
+ `Failed to upgrade Sylphx Flow: ${error instanceof Error ? error.message : String(error)}`,
129
148
  'UPGRADE_FAILED'
130
149
  );
131
150
  }
132
151
  }
133
152
 
134
- async upgradeTarget(state: ProjectState): Promise<boolean> {
153
+ async upgradeTarget(state: ProjectState, autoInstall: boolean = false): Promise<boolean> {
135
154
  if (!state.target || !state.targetLatestVersion) {
136
155
  return false;
137
156
  }
138
157
 
139
- const spinner = ora(`升级 ${state.target}...`).start();
158
+ const spinner = ora(`Upgrading ${state.target}...`).start();
140
159
 
141
160
  try {
142
161
  if (state.target === 'claude-code') {
143
- await this.upgradeClaudeCode();
162
+ await this.upgradeClaudeCode(autoInstall);
144
163
  } else if (state.target === 'opencode') {
145
164
  await this.upgradeOpenCode();
146
165
  }
147
166
 
148
- spinner.succeed(`${state.target} 已升级到最新版本`);
167
+ spinner.succeed(`${state.target} upgraded to latest version`);
149
168
  return true;
150
169
  } catch (error) {
151
- spinner.fail(`${state.target} 升级失败`);
170
+ spinner.fail(`${state.target} upgrade failed`);
152
171
  throw new CLIError(
153
- `升级 ${state.target} 失败: ${error instanceof Error ? error.message : String(error)}`,
172
+ `Failed to upgrade ${state.target}: ${error instanceof Error ? error.message : String(error)}`,
154
173
  'TARGET_UPGRADE_FAILED'
155
174
  );
156
175
  }
157
176
  }
158
177
 
159
- private async upgradeClaudeCode(): Promise<void> {
178
+ private async upgradeClaudeCode(autoInstall: boolean = false): Promise<void> {
160
179
  if (this.options.dryRun) {
161
- console.log('模拟: claude update');
180
+ console.log('Dry run: claude update');
162
181
  return;
163
182
  }
164
183
 
165
- // Claude Code has built-in update command
166
- const { stdout } = await execAsync('claude update');
184
+ if (autoInstall) {
185
+ // Use detected package manager to install latest version
186
+ const packageManager = detectPackageManager(this.projectPath);
187
+ const installCmd = getUpgradeCommand('@anthropic-ai/claude-code', packageManager);
188
+ const { stdout } = await execAsync(installCmd);
167
189
 
168
- if (this.options.verbose) {
169
- console.log(stdout);
190
+ if (this.options.verbose) {
191
+ console.log(stdout);
192
+ }
193
+ } else {
194
+ // Claude Code has built-in update command
195
+ const { stdout } = await execAsync('claude update');
196
+
197
+ if (this.options.verbose) {
198
+ console.log(stdout);
199
+ }
170
200
  }
171
201
  }
172
202
 
@@ -256,12 +286,18 @@ export class UpgradeManager {
256
286
 
257
287
  private async getLatestFlowVersion(): Promise<string | null> {
258
288
  try {
259
- // 从当前 package.json 获取(假设是当前开发版本)
260
- const packagePath = path.join(__dirname, '..', '..', 'package.json');
261
- const packageJson = JSON.parse(await fs.readFile(packagePath, 'utf-8'));
262
- return packageJson.version || null;
289
+ // Check npm registry for latest published version
290
+ const { stdout } = await execAsync('npm view @sylphx/flow version');
291
+ return stdout.trim();
263
292
  } catch {
264
- return null;
293
+ // Fallback: read from local package.json
294
+ try {
295
+ const packagePath = path.join(__dirname, '..', '..', 'package.json');
296
+ const packageJson = JSON.parse(await fs.readFile(packagePath, 'utf-8'));
297
+ return packageJson.version || null;
298
+ } catch {
299
+ return null;
300
+ }
265
301
  }
266
302
  }
267
303
 
@@ -279,17 +315,13 @@ export class UpgradeManager {
279
315
  return null;
280
316
  }
281
317
 
282
- private async getLatestTargetVersion(target: string): Promise<string | null> {
283
- if (target === 'claude-code') {
284
- try {
285
- const { stdout } = await execAsync('npm view @anthropic-ai/claude-code version');
286
- return stdout.trim();
287
- } catch {
288
- return null;
289
- }
318
+ private async getLatestTargetVersion(): Promise<string | null> {
319
+ try {
320
+ const { stdout } = await execAsync('npm view @anthropic-ai/claude-code version');
321
+ return stdout.trim();
322
+ } catch {
323
+ return null;
290
324
  }
291
-
292
- return null;
293
325
  }
294
326
 
295
327
  static async isUpgradeAvailable(): Promise<boolean> {