@sylphx/flow 3.18.0 → 3.19.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -3
  3. package/src/commands/flow/execute-v2.ts +126 -128
  4. package/src/commands/flow-command.ts +52 -42
  5. package/src/config/index.ts +0 -20
  6. package/src/config/targets.ts +1 -1
  7. package/src/core/__tests__/backup-restore.test.ts +1 -1
  8. package/src/core/__tests__/cleanup-handler.test.ts +292 -0
  9. package/src/core/__tests__/git-stash-manager.test.ts +246 -0
  10. package/src/core/__tests__/secrets-manager.test.ts +126 -0
  11. package/src/core/__tests__/session-cleanup.test.ts +147 -0
  12. package/src/core/agent-loader.ts +2 -2
  13. package/src/core/attach-manager.ts +12 -78
  14. package/src/core/backup-manager.ts +8 -20
  15. package/src/core/cleanup-handler.ts +187 -11
  16. package/src/core/flow-executor.ts +139 -126
  17. package/src/core/functional/index.ts +0 -11
  18. package/src/core/git-stash-manager.ts +50 -68
  19. package/src/core/index.ts +1 -1
  20. package/src/core/project-manager.ts +26 -43
  21. package/src/core/secrets-manager.ts +15 -18
  22. package/src/core/session-manager.ts +32 -41
  23. package/src/core/state-detector.ts +4 -15
  24. package/src/core/target-manager.ts +6 -3
  25. package/src/core/target-resolver.ts +14 -9
  26. package/src/core/template-loader.ts +7 -33
  27. package/src/core/upgrade-manager.ts +5 -16
  28. package/src/index.ts +7 -36
  29. package/src/services/auto-upgrade.ts +6 -14
  30. package/src/services/config-service.ts +7 -23
  31. package/src/services/index.ts +1 -1
  32. package/src/targets/claude-code.ts +24 -109
  33. package/src/targets/functional/claude-code-logic.ts +47 -103
  34. package/src/targets/opencode.ts +63 -197
  35. package/src/targets/shared/mcp-transforms.ts +20 -43
  36. package/src/targets/shared/target-operations.ts +1 -54
  37. package/src/types/agent.types.ts +5 -3
  38. package/src/types/mcp.types.ts +38 -1
  39. package/src/types/target.types.ts +4 -24
  40. package/src/types.ts +4 -0
  41. package/src/utils/agent-enhancer.ts +1 -1
  42. package/src/utils/config/target-config.ts +8 -14
  43. package/src/utils/config/target-utils.ts +1 -50
  44. package/src/utils/errors.ts +13 -0
  45. package/src/utils/files/file-operations.ts +16 -0
  46. package/src/utils/files/sync-utils.ts +5 -5
  47. package/src/utils/index.ts +1 -1
  48. package/src/utils/object-utils.ts +10 -2
  49. package/src/utils/security/secret-utils.ts +2 -2
  50. package/src/core/error-handling.ts +0 -512
  51. package/src/core/functional/async.ts +0 -101
  52. package/src/core/functional/either.ts +0 -109
  53. package/src/core/functional/error-handler.ts +0 -135
  54. package/src/core/functional/pipe.ts +0 -189
  55. package/src/core/functional/validation.ts +0 -138
  56. package/src/types/mcp-config.types.ts +0 -448
  57. package/src/utils/error-handler.ts +0 -53
@@ -4,14 +4,19 @@
4
4
  * All projects store data in ~/.sylphx-flow/ isolated by project hash
5
5
  */
6
6
 
7
+ import { exec } from 'node:child_process';
7
8
  import crypto from 'node:crypto';
8
9
  import { existsSync } from 'node:fs';
9
10
  import fs from 'node:fs/promises';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
13
+ import { promisify } from 'node:util';
12
14
  import type { Target } from '../types/target.types.js';
15
+ import { readJsonFileSafe } from '../utils/files/file-operations.js';
13
16
  import { targetManager } from './target-manager.js';
14
17
 
18
+ const execAsync = promisify(exec);
19
+
15
20
  export interface ProjectPaths {
16
21
  sessionFile: string;
17
22
  backupsDir: string;
@@ -63,26 +68,20 @@ export class ProjectManager {
63
68
  * Initialize Flow directories
64
69
  */
65
70
  async initialize(): Promise<void> {
66
- const dirs = [
67
- this.flowHomeDir,
68
- path.join(this.flowHomeDir, 'sessions'),
69
- path.join(this.flowHomeDir, 'backups'),
70
- path.join(this.flowHomeDir, 'secrets'),
71
- path.join(this.flowHomeDir, 'templates'),
72
- ];
73
-
74
- for (const dir of dirs) {
75
- await fs.mkdir(dir, { recursive: true });
76
- }
71
+ await Promise.all([
72
+ fs.mkdir(path.join(this.flowHomeDir, 'sessions'), { recursive: true }),
73
+ fs.mkdir(path.join(this.flowHomeDir, 'backups'), { recursive: true }),
74
+ fs.mkdir(path.join(this.flowHomeDir, 'secrets'), { recursive: true }),
75
+ fs.mkdir(path.join(this.flowHomeDir, 'templates'), { recursive: true }),
76
+ ]);
77
77
  }
78
78
 
79
79
  /**
80
- * Check if a command is available on the system
80
+ * Check if a command is available on the system (non-blocking)
81
81
  */
82
82
  private async isCommandAvailable(command: string): Promise<boolean> {
83
83
  try {
84
- const { execSync } = await import('node:child_process');
85
- execSync(`which ${command}`, { stdio: 'ignore' });
84
+ await execAsync(`which ${command}`, { timeout: 5000 });
86
85
  return true;
87
86
  } catch {
88
87
  return false;
@@ -111,16 +110,11 @@ export class ProjectManager {
111
110
  projectHash: string
112
111
  ): Promise<'claude-code' | 'opencode' | undefined> {
113
112
  const prefsPath = path.join(this.flowHomeDir, 'project-preferences.json');
114
- if (!existsSync(prefsPath)) {
115
- return undefined;
116
- }
117
-
118
- try {
119
- const prefs = JSON.parse(await fs.readFile(prefsPath, 'utf-8'));
120
- return prefs.projects?.[projectHash]?.target;
121
- } catch {
122
- return undefined;
123
- }
113
+ const prefs = await readJsonFileSafe<{ projects?: Record<string, { target?: string }> }>(
114
+ prefsPath,
115
+ {}
116
+ );
117
+ return prefs.projects?.[projectHash]?.target as 'claude-code' | 'opencode' | undefined;
124
118
  }
125
119
 
126
120
  /**
@@ -128,15 +122,10 @@ export class ProjectManager {
128
122
  */
129
123
  async saveProjectTargetPreference(projectHash: string, target: string): Promise<void> {
130
124
  const prefsPath = path.join(this.flowHomeDir, 'project-preferences.json');
131
- let prefs: { projects: Record<string, { target?: string }> } = { projects: {} };
132
-
133
- if (existsSync(prefsPath)) {
134
- try {
135
- prefs = JSON.parse(await fs.readFile(prefsPath, 'utf-8'));
136
- } catch {
137
- // Use default
138
- }
139
- }
125
+ const prefs = await readJsonFileSafe<{ projects: Record<string, { target?: string }> }>(
126
+ prefsPath,
127
+ { projects: {} }
128
+ );
140
129
 
141
130
  if (!prefs.projects) {
142
131
  prefs.projects = {};
@@ -189,16 +178,10 @@ export class ProjectManager {
189
178
  // If both are installed, use global default
190
179
  if (installed.claudeCode && installed.opencode) {
191
180
  const globalSettingsPath = path.join(this.flowHomeDir, 'settings.json');
192
- if (existsSync(globalSettingsPath)) {
193
- try {
194
- const settings = JSON.parse(await fs.readFile(globalSettingsPath, 'utf-8'));
195
- if (settings.defaultTarget) {
196
- await this.saveProjectTargetPreference(projectHash, settings.defaultTarget);
197
- return settings.defaultTarget;
198
- }
199
- } catch {
200
- // Fall through
201
- }
181
+ const settings = await readJsonFileSafe<{ defaultTarget?: string }>(globalSettingsPath, {});
182
+ if (settings.defaultTarget) {
183
+ await this.saveProjectTargetPreference(projectHash, settings.defaultTarget);
184
+ return settings.defaultTarget;
202
185
  }
203
186
 
204
187
  // Both installed, no global default, use claude-code
@@ -8,6 +8,7 @@ import { existsSync } from 'node:fs';
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
10
  import type { Target } from '../types/target.types.js';
11
+ import { readJsonFileSafe } from '../utils/files/file-operations.js';
11
12
  import type { ProjectManager } from './project-manager.js';
12
13
  import { resolveTargetOrId } from './target-resolver.js';
13
14
 
@@ -60,20 +61,26 @@ export class SecretsManager {
60
61
  const mcpServers = config[mcpPath] as Record<string, unknown> | undefined;
61
62
  if (mcpServers) {
62
63
  for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
63
- const server = serverConfig as any;
64
+ if (typeof serverConfig !== 'object' || serverConfig === null) {
65
+ continue;
66
+ }
67
+ const server = serverConfig as Record<string, unknown>;
64
68
 
65
69
  // Extract env vars (sensitive) - handle both 'env' and 'environment' keys
66
- const envVars = server.env || server.environment;
67
- if (envVars && Object.keys(envVars).length > 0) {
70
+ const envVars = (server.env || server.environment) as Record<string, string> | undefined;
71
+ if (envVars && typeof envVars === 'object' && Object.keys(envVars).length > 0) {
68
72
  secrets.servers[serverName] = {
69
73
  env: envVars,
70
74
  };
71
75
  }
72
76
 
73
77
  // Extract args (may contain secrets) - handle both 'args' and 'command' array
74
- const args =
75
- server.args || (Array.isArray(server.command) ? server.command.slice(1) : undefined);
76
- if (args && Array.isArray(args) && args.length > 0) {
78
+ const args = Array.isArray(server.args)
79
+ ? server.args
80
+ : Array.isArray(server.command)
81
+ ? (server.command as string[]).slice(1)
82
+ : undefined;
83
+ if (args && args.length > 0) {
77
84
  if (!secrets.servers[serverName]) {
78
85
  secrets.servers[serverName] = {};
79
86
  }
@@ -105,20 +112,10 @@ export class SecretsManager {
105
112
  /**
106
113
  * Load secrets from storage
107
114
  */
108
- async loadSecrets(projectHash: string): Promise<MCPSecrets | null> {
115
+ loadSecrets(projectHash: string): Promise<MCPSecrets | null> {
109
116
  const paths = this.projectManager.getProjectPaths(projectHash);
110
117
  const secretsPath = path.join(paths.secretsDir, 'mcp-env.json');
111
-
112
- if (!existsSync(secretsPath)) {
113
- return null;
114
- }
115
-
116
- try {
117
- const data = await fs.readFile(secretsPath, 'utf-8');
118
- return JSON.parse(data);
119
- } catch {
120
- return null;
121
- }
118
+ return readJsonFileSafe<MCPSecrets | null>(secretsPath, null);
122
119
  }
123
120
 
124
121
  /**
@@ -8,6 +8,7 @@ import { existsSync } from 'node:fs';
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
10
  import type { Target } from '../types/target.types.js';
11
+ import { readJsonFileSafe } from '../utils/files/file-operations.js';
11
12
  import type { ProjectManager } from './project-manager.js';
12
13
 
13
14
  export interface Session {
@@ -143,14 +144,9 @@ export class SessionManager {
143
144
  /**
144
145
  * Get active session for a project
145
146
  */
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
- }
147
+ getActiveSession(projectHash: string): Promise<Session | null> {
148
+ const paths = this.projectManager.getProjectPaths(projectHash);
149
+ return readJsonFileSafe<Session | null>(paths.sessionFile, null);
154
150
  }
155
151
 
156
152
  /**
@@ -208,6 +204,34 @@ export class SessionManager {
208
204
  }
209
205
  }
210
206
 
207
+ /**
208
+ * Prune old session history files to prevent unbounded accumulation
209
+ * Keeps the most recent N history entries (sorted by session timestamp in filename)
210
+ */
211
+ async cleanupSessionHistory(keepLast: number = 50): Promise<void> {
212
+ const flowHome = this.projectManager.getFlowHomeDir();
213
+ const historyDir = path.join(flowHome, 'sessions', 'history');
214
+
215
+ if (!existsSync(historyDir)) {
216
+ return;
217
+ }
218
+
219
+ const files = await fs.readdir(historyDir);
220
+ const sessionFiles = files
221
+ .filter((f) => f.endsWith('.json'))
222
+ .sort() // session-{timestamp}.json sorts chronologically
223
+ .reverse(); // newest first
224
+
225
+ // Delete files beyond keepLast
226
+ for (const file of sessionFiles.slice(keepLast)) {
227
+ try {
228
+ await fs.unlink(path.join(historyDir, file));
229
+ } catch {
230
+ // Ignore errors — file might already be deleted
231
+ }
232
+ }
233
+ }
234
+
211
235
  /**
212
236
  * Recover from crashed session
213
237
  */
@@ -230,37 +254,4 @@ export class SessionManager {
230
254
  // File might not exist
231
255
  }
232
256
  }
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) => new Date(b.session.startTime).getTime() - new Date(a.session.startTime).getTime()
258
- );
259
-
260
- // Remove old sessions
261
- const toRemove = sessions.slice(keepLast);
262
- for (const { file } of toRemove) {
263
- await fs.unlink(path.join(historyDir, file));
264
- }
265
- }
266
257
  }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { ConfigService } from '../services/config-service.js';
5
5
  import type { Target } from '../types/target.types.js';
6
- import { targetManager } from './target-manager.js';
6
+ import { tryResolveTarget } from './target-resolver.js';
7
7
 
8
8
  const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = path.dirname(__filename);
@@ -43,17 +43,6 @@ export class StateDetector {
43
43
  this.projectPath = projectPath;
44
44
  }
45
45
 
46
- /**
47
- * Resolve target from ID string to Target object
48
- */
49
- private resolveTarget(targetId: string): Target | null {
50
- const targetOption = targetManager.getTarget(targetId);
51
- if (targetOption._tag === 'None') {
52
- return null;
53
- }
54
- return targetOption.value;
55
- }
56
-
57
46
  async detect(): Promise<ProjectState> {
58
47
  const state: ProjectState = {
59
48
  initialized: false,
@@ -95,7 +84,7 @@ export class StateDetector {
95
84
  }
96
85
 
97
86
  // Resolve target to get config
98
- const target = state.target ? this.resolveTarget(state.target) : null;
87
+ const target = state.target ? tryResolveTarget(state.target) : null;
99
88
 
100
89
  // Check components based on target config
101
90
  if (target) {
@@ -376,7 +365,7 @@ export class StateDetector {
376
365
  targetId: string
377
366
  ): Promise<{ version: string | null; latestVersion: string | null }> {
378
367
  try {
379
- const target = this.resolveTarget(targetId);
368
+ const target = tryResolveTarget(targetId);
380
369
  if (!target) {
381
370
  return { version: null, latestVersion: null };
382
371
  }
@@ -428,7 +417,7 @@ export class StateDetector {
428
417
 
429
418
  // Check required components based on target
430
419
  if (state.initialized && state.target) {
431
- const target = this.resolveTarget(state.target);
420
+ const target = tryResolveTarget(state.target);
432
421
  // CLI-based targets (category: 'cli') require agents to be installed
433
422
  if (target && target.category === 'cli' && !state.components.agents.installed) {
434
423
  return true; // CLI target initialized but no agents
@@ -8,7 +8,7 @@ import {
8
8
  getTargetsWithMCPSupport,
9
9
  isTargetImplemented,
10
10
  } from '../config/targets.js';
11
- import { getOrElse, isSome } from '../core/functional/option.js';
11
+ import { isSome, match } from '../core/functional/option.js';
12
12
  import { projectSettings } from '../utils/config/settings.js';
13
13
  import { promptSelect } from '../utils/prompts/index.js';
14
14
 
@@ -85,9 +85,12 @@ export function createTargetManager(): TargetManager {
85
85
  message: 'Select target platform:',
86
86
  options: availableTargets.map((id) => {
87
87
  const targetOption = getTarget(id);
88
- const target = getOrElse({ id, name: id } as any)(targetOption);
88
+ const label = match(
89
+ (t: ReturnType<typeof getAllTargets>[number]) => t.name || id,
90
+ () => id
91
+ )(targetOption);
89
92
  return {
90
- label: target.name || id,
93
+ label,
91
94
  value: id,
92
95
  };
93
96
  }),
@@ -1,27 +1,32 @@
1
1
  /**
2
- * Target Resolver
3
- * Shared utility for resolving target IDs to Target objects
4
- * Eliminates duplication across BackupManager and SecretsManager
2
+ * Target Resolver — Single Source of Truth for target ID → Target resolution
5
3
  */
6
4
 
7
5
  import type { Target } from '../types/target.types.js';
8
6
  import { targetManager } from './target-manager.js';
9
7
 
10
8
  /**
11
- * Resolve a target from ID string to Target object
9
+ * Resolve target ID to Target object, returns null if not found
10
+ */
11
+ export function tryResolveTarget(targetId: string): Target | null {
12
+ const targetOption = targetManager.getTarget(targetId);
13
+ return targetOption._tag === 'Some' ? targetOption.value : null;
14
+ }
15
+
16
+ /**
17
+ * Resolve target ID to Target object
12
18
  * @throws Error if target ID is not found
13
19
  */
14
20
  export function resolveTarget(targetId: string): Target {
15
- const targetOption = targetManager.getTarget(targetId);
16
- if (targetOption._tag === 'None') {
21
+ const target = tryResolveTarget(targetId);
22
+ if (!target) {
17
23
  throw new Error(`Unknown target: ${targetId}`);
18
24
  }
19
- return targetOption.value;
25
+ return target;
20
26
  }
21
27
 
22
28
  /**
23
- * Resolve target, accepting either string ID or Target object
24
- * Returns the Target object in both cases
29
+ * Resolve target from either string ID or Target object
25
30
  */
26
31
  export function resolveTargetOrId(targetOrId: Target | string): Target {
27
32
  return typeof targetOrId === 'string' ? resolveTarget(targetOrId) : targetOrId;
@@ -30,15 +30,13 @@ export class TemplateLoader {
30
30
  const commandsDir = path.join(this.assetsDir, 'slash-commands');
31
31
  const skillsDir = path.join(this.assetsDir, 'skills');
32
32
  const mcpConfigPath = path.join(this.assetsDir, 'mcp-servers.json');
33
- const outputStylesDir = path.join(this.assetsDir, 'output-styles');
34
33
 
35
34
  // Load all directories in parallel
36
- const [agents, commands, skills, mcpServers, singleFiles, rules] = await Promise.all([
35
+ const [agents, commands, skills, mcpServers, rules] = await Promise.all([
37
36
  existsSync(agentsDir) ? this.loadAgents(agentsDir) : [],
38
37
  existsSync(commandsDir) ? this.loadCommands(commandsDir) : [],
39
38
  existsSync(skillsDir) ? this.loadSkills(skillsDir) : [],
40
39
  existsSync(mcpConfigPath) ? this.loadMCPServers(mcpConfigPath) : [],
41
- existsSync(outputStylesDir) ? this.loadSingleFiles(outputStylesDir) : [],
42
40
  this.loadRules(),
43
41
  ]);
44
42
 
@@ -48,8 +46,7 @@ export class TemplateLoader {
48
46
  skills,
49
47
  rules,
50
48
  mcpServers,
51
- hooks: [],
52
- singleFiles,
49
+ singleFiles: [],
53
50
  };
54
51
  }
55
52
 
@@ -134,11 +131,13 @@ export class TemplateLoader {
134
131
  /**
135
132
  * Load MCP servers configuration
136
133
  */
137
- private async loadMCPServers(configPath: string): Promise<Array<{ name: string; config: any }>> {
134
+ private async loadMCPServers(
135
+ configPath: string
136
+ ): Promise<Array<{ name: string; config: Record<string, unknown> }>> {
138
137
  const data = await fs.readFile(configPath, 'utf-8');
139
- const config = JSON.parse(data);
138
+ const config = JSON.parse(data) as Record<string, Record<string, unknown>>;
140
139
 
141
- const servers = [];
140
+ const servers: Array<{ name: string; config: Record<string, unknown> }> = [];
142
141
  for (const [name, serverConfig] of Object.entries(config)) {
143
142
  servers.push({ name, config: serverConfig });
144
143
  }
@@ -146,31 +145,6 @@ export class TemplateLoader {
146
145
  return servers;
147
146
  }
148
147
 
149
- /**
150
- * Load single files (parallel loading)
151
- */
152
- private async loadSingleFiles(
153
- singleFilesDir: string
154
- ): Promise<Array<{ path: string; content: string }>> {
155
- const entries = await fs.readdir(singleFilesDir);
156
-
157
- const results = await Promise.all(
158
- entries.map(async (entry) => {
159
- const filePath = path.join(singleFilesDir, entry);
160
- const stat = await fs.stat(filePath);
161
-
162
- if (!stat.isFile()) {
163
- return null;
164
- }
165
-
166
- const content = await fs.readFile(filePath, 'utf-8');
167
- return { path: entry, content };
168
- })
169
- );
170
-
171
- return results.filter((r): r is { path: string; content: string } => r !== null);
172
- }
173
-
174
148
  /**
175
149
  * Get assets directory path
176
150
  */
@@ -5,11 +5,11 @@ import { promisify } from 'node:util';
5
5
  import chalk from 'chalk';
6
6
  import { getProjectSettingsFile } from '../config/constants.js';
7
7
  import type { Target } from '../types/target.types.js';
8
- import { CLIError } from '../utils/error-handler.js';
8
+ import { CLIError } from '../utils/errors.js';
9
9
  import { detectPackageManager, getUpgradeCommand } from '../utils/package-manager-detector.js';
10
10
  import { createSpinner, log } from '../utils/prompts/index.js';
11
11
  import type { ProjectState } from './state-detector.js';
12
- import { targetManager } from './target-manager.js';
12
+ import { tryResolveTarget } from './target-resolver.js';
13
13
 
14
14
  const execAsync = promisify(exec);
15
15
 
@@ -152,23 +152,12 @@ export class UpgradeManager {
152
152
  }
153
153
  }
154
154
 
155
- /**
156
- * Resolve target from ID string to Target object
157
- */
158
- private resolveTarget(targetId: string): Target | null {
159
- const targetOption = targetManager.getTarget(targetId);
160
- if (targetOption._tag === 'None') {
161
- return null;
162
- }
163
- return targetOption.value;
164
- }
165
-
166
155
  async upgradeTarget(state: ProjectState, autoInstall: boolean = false): Promise<boolean> {
167
156
  if (!state.target || !state.targetLatestVersion) {
168
157
  return false;
169
158
  }
170
159
 
171
- const target = this.resolveTarget(state.target);
160
+ const target = tryResolveTarget(state.target);
172
161
  if (!target) {
173
162
  return false;
174
163
  }
@@ -263,7 +252,7 @@ export class UpgradeManager {
263
252
  const configPath = path.join(this.projectPath, getProjectSettingsFile());
264
253
  const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
265
254
  if (config.target) {
266
- return this.resolveTarget(config.target);
255
+ return tryResolveTarget(config.target);
267
256
  }
268
257
  } catch {
269
258
  // Cannot read config
@@ -355,7 +344,7 @@ export class UpgradeManager {
355
344
  }
356
345
 
357
346
  private async getCurrentTargetVersion(targetId: string): Promise<string | null> {
358
- const target = this.resolveTarget(targetId);
347
+ const target = tryResolveTarget(targetId);
359
348
  if (!target) {
360
349
  return null;
361
350
  }
package/src/index.ts CHANGED
@@ -98,48 +98,19 @@ export async function runCLI(): Promise<void> {
98
98
  * Set up global error handlers for uncaught exceptions and unhandled rejections
99
99
  */
100
100
  function setupGlobalErrorHandling(): void {
101
- // Handle uncaught exceptions
102
- process.on('uncaughtException', (error) => {
103
- console.error('✗ Uncaught Exception:');
104
- console.error(` ${error.message}`);
105
- if (process.env.NODE_ENV === 'development') {
106
- console.error(' Stack trace:', error.stack);
107
- }
108
- process.exit(1);
109
- });
110
-
111
- // Handle unhandled promise rejections
112
- process.on('unhandledRejection', (reason, promise) => {
113
- // Ignore AbortError - this is expected when user cancels operations
101
+ // Handle unhandled promise rejections (non-fatal, log only)
102
+ process.on('unhandledRejection', (reason) => {
103
+ // Ignore AbortError — expected when user cancels operations
114
104
  if (reason instanceof Error && reason.name === 'AbortError') {
115
105
  return;
116
106
  }
117
-
118
- // Only log unhandled rejections in development mode
119
- // Don't exit the process - let the application handle errors gracefully
120
107
  if (process.env.NODE_ENV === 'development' || process.env.DEBUG) {
121
- console.error('✗ Unhandled Promise Rejection:');
122
- console.error(` Reason: ${reason}`);
123
- console.error(' Promise:', promise);
108
+ console.error('✗ Unhandled Promise Rejection:', reason);
124
109
  }
125
110
  });
126
111
 
127
- // Handle process termination gracefully
128
- process.on('SIGINT', () => {
129
- console.log('\nSylphx Flow CLI terminated by user');
130
- process.exit(0);
131
- });
132
-
133
- process.on('SIGTERM', () => {
134
- console.log('\nSylphx Flow CLI terminated');
135
- process.exit(0);
136
- });
137
-
138
- // Ensure clean exit by allowing the event loop to drain
139
- process.on('beforeExit', () => {
140
- // Node.js will exit automatically after this handler completes
141
- // No explicit process.exit() needed
142
- });
112
+ // SIGINT/SIGTERM are handled by CleanupHandler which does async backup restoration.
113
+ // DO NOT register handlers here — process.exit() would preempt cleanup.
143
114
  }
144
115
 
145
116
  /**
@@ -155,7 +126,7 @@ function handleCommandError(error: unknown): void {
155
126
 
156
127
  // Handle Commander.js specific errors
157
128
  if (error.name === 'CommanderError') {
158
- const commanderError = error as any;
129
+ const commanderError = error as Error & { code?: string; exitCode?: number };
159
130
 
160
131
  // Don't exit for help or version commands - they should already be handled
161
132
  if (commanderError.code === 'commander.help' || commanderError.code === 'commander.version') {
@@ -10,6 +10,7 @@ import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { promisify } from 'node:util';
13
+ import { readJsonFileSafe } from '../utils/files/file-operations.js';
13
14
  import { getUpgradeCommand } from '../utils/package-manager-detector.js';
14
15
 
15
16
  const __filename = fileURLToPath(import.meta.url);
@@ -91,13 +92,8 @@ export class AutoUpgrade {
91
92
  /**
92
93
  * Read version info from disk
93
94
  */
94
- private async readVersionInfo(): Promise<VersionInfo | null> {
95
- try {
96
- const data = await fs.readFile(VERSION_FILE, 'utf-8');
97
- return JSON.parse(data);
98
- } catch {
99
- return null;
100
- }
95
+ private readVersionInfo(): Promise<VersionInfo | null> {
96
+ return readJsonFileSafe<VersionInfo | null>(VERSION_FILE, null);
101
97
  }
102
98
 
103
99
  /**
@@ -127,13 +123,9 @@ export class AutoUpgrade {
127
123
  * Get current Flow version from package.json
128
124
  */
129
125
  private async getCurrentVersion(): Promise<string> {
130
- try {
131
- const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
132
- const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
133
- return packageJson.version;
134
- } catch {
135
- return 'unknown';
136
- }
126
+ const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
127
+ const pkg = await readJsonFileSafe<{ version?: string }>(packageJsonPath, {});
128
+ return pkg.version ?? 'unknown';
137
129
  }
138
130
 
139
131
  /**