@sylphx/flow 1.8.2 → 2.1.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 +159 -0
- package/UPGRADE.md +151 -0
- package/package.json +11 -6
- package/src/commands/flow/execute-v2.ts +372 -0
- package/src/commands/flow/execute.ts +1 -18
- package/src/commands/flow/types.ts +3 -2
- package/src/commands/flow-command.ts +32 -69
- package/src/commands/flow-orchestrator.ts +18 -55
- package/src/commands/run-command.ts +12 -6
- package/src/commands/settings-command.ts +536 -0
- package/src/config/ai-config.ts +2 -69
- package/src/config/targets.ts +0 -11
- package/src/core/attach-manager.ts +495 -0
- package/src/core/backup-manager.ts +308 -0
- package/src/core/cleanup-handler.ts +166 -0
- package/src/core/flow-executor.ts +323 -0
- package/src/core/git-stash-manager.ts +133 -0
- package/src/core/installers/file-installer.ts +0 -57
- package/src/core/installers/mcp-installer.ts +0 -33
- package/src/core/project-manager.ts +274 -0
- package/src/core/secrets-manager.ts +229 -0
- package/src/core/session-manager.ts +268 -0
- package/src/core/template-loader.ts +189 -0
- package/src/core/upgrade-manager.ts +79 -47
- package/src/index.ts +15 -29
- package/src/services/auto-upgrade.ts +248 -0
- package/src/services/first-run-setup.ts +220 -0
- package/src/services/global-config.ts +337 -0
- package/src/services/target-installer.ts +254 -0
- package/src/targets/claude-code.ts +5 -7
- package/src/targets/opencode.ts +6 -26
- package/src/utils/__tests__/package-manager-detector.test.ts +163 -0
- package/src/utils/agent-enhancer.ts +40 -22
- package/src/utils/errors.ts +9 -0
- package/src/utils/package-manager-detector.ts +139 -0
- package/src/utils/prompt-helpers.ts +48 -0
- package/src/utils/target-selection.ts +169 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
detectPackageManagerFromUserAgent,
|
|
4
|
+
detectPackageManagerFromLockFiles,
|
|
5
|
+
detectPackageManager,
|
|
6
|
+
getPackageManagerInfo,
|
|
7
|
+
getUpgradeCommand,
|
|
8
|
+
} from '../package-manager-detector';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
|
|
13
|
+
describe('Package Manager Detection', () => {
|
|
14
|
+
const originalEnv = process.env;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
process.env = { ...originalEnv };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
process.env = originalEnv;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('detectPackageManagerFromUserAgent', () => {
|
|
25
|
+
it('should detect bun from user agent', () => {
|
|
26
|
+
process.env.npm_config_user_agent = 'bun/1.0.0';
|
|
27
|
+
expect(detectPackageManagerFromUserAgent()).toBe('bun');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should detect pnpm from user agent', () => {
|
|
31
|
+
process.env.npm_config_user_agent = 'pnpm/8.0.0 npm/? node/v18.0.0';
|
|
32
|
+
expect(detectPackageManagerFromUserAgent()).toBe('pnpm');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should detect yarn from user agent', () => {
|
|
36
|
+
process.env.npm_config_user_agent = 'yarn/1.22.0 npm/? node/v18.0.0';
|
|
37
|
+
expect(detectPackageManagerFromUserAgent()).toBe('yarn');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should detect npm from user agent', () => {
|
|
41
|
+
process.env.npm_config_user_agent = 'npm/9.0.0 node/v18.0.0';
|
|
42
|
+
expect(detectPackageManagerFromUserAgent()).toBe('npm');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return null when no user agent', () => {
|
|
46
|
+
delete process.env.npm_config_user_agent;
|
|
47
|
+
expect(detectPackageManagerFromUserAgent()).toBe(null);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('detectPackageManagerFromLockFiles', () => {
|
|
52
|
+
let tempDir: string;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-test-'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should detect bun from bun.lockb', () => {
|
|
63
|
+
fs.writeFileSync(path.join(tempDir, 'bun.lockb'), '');
|
|
64
|
+
expect(detectPackageManagerFromLockFiles(tempDir)).toBe('bun');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should detect bun from bun.lock', () => {
|
|
68
|
+
fs.writeFileSync(path.join(tempDir, 'bun.lock'), '');
|
|
69
|
+
expect(detectPackageManagerFromLockFiles(tempDir)).toBe('bun');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should detect pnpm from pnpm-lock.yaml', () => {
|
|
73
|
+
fs.writeFileSync(path.join(tempDir, 'pnpm-lock.yaml'), '');
|
|
74
|
+
expect(detectPackageManagerFromLockFiles(tempDir)).toBe('pnpm');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should detect yarn from yarn.lock', () => {
|
|
78
|
+
fs.writeFileSync(path.join(tempDir, 'yarn.lock'), '');
|
|
79
|
+
expect(detectPackageManagerFromLockFiles(tempDir)).toBe('yarn');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should detect npm from package-lock.json', () => {
|
|
83
|
+
fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '');
|
|
84
|
+
expect(detectPackageManagerFromLockFiles(tempDir)).toBe('npm');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should prioritize bun over others', () => {
|
|
88
|
+
fs.writeFileSync(path.join(tempDir, 'bun.lock'), '');
|
|
89
|
+
fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '');
|
|
90
|
+
expect(detectPackageManagerFromLockFiles(tempDir)).toBe('bun');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should return null when no lock files', () => {
|
|
94
|
+
expect(detectPackageManagerFromLockFiles(tempDir)).toBe(null);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('detectPackageManager', () => {
|
|
99
|
+
it('should prioritize user agent over lock files', () => {
|
|
100
|
+
process.env.npm_config_user_agent = 'pnpm/8.0.0';
|
|
101
|
+
const result = detectPackageManager();
|
|
102
|
+
expect(result).toBe('pnpm');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should default to npm when no detection methods work', () => {
|
|
106
|
+
delete process.env.npm_config_user_agent;
|
|
107
|
+
const result = detectPackageManager('/nonexistent');
|
|
108
|
+
expect(result).toBe('npm');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('getPackageManagerInfo', () => {
|
|
113
|
+
it('should return correct info for npm', () => {
|
|
114
|
+
const info = getPackageManagerInfo('npm');
|
|
115
|
+
expect(info.name).toBe('npm');
|
|
116
|
+
expect(info.installCommand).toBe('npm install');
|
|
117
|
+
expect(info.globalInstallCommand('foo')).toBe('npm install -g foo');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should return correct info for bun', () => {
|
|
121
|
+
const info = getPackageManagerInfo('bun');
|
|
122
|
+
expect(info.name).toBe('bun');
|
|
123
|
+
expect(info.installCommand).toBe('bun install');
|
|
124
|
+
expect(info.globalInstallCommand('foo')).toBe('bun install -g foo');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should return correct info for pnpm', () => {
|
|
128
|
+
const info = getPackageManagerInfo('pnpm');
|
|
129
|
+
expect(info.name).toBe('pnpm');
|
|
130
|
+
expect(info.installCommand).toBe('pnpm install');
|
|
131
|
+
expect(info.globalInstallCommand('foo')).toBe('pnpm install -g foo');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should return correct info for yarn', () => {
|
|
135
|
+
const info = getPackageManagerInfo('yarn');
|
|
136
|
+
expect(info.name).toBe('yarn');
|
|
137
|
+
expect(info.installCommand).toBe('yarn install');
|
|
138
|
+
expect(info.globalInstallCommand('foo')).toBe('yarn global add foo');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('getUpgradeCommand', () => {
|
|
143
|
+
it('should return correct upgrade command for npm', () => {
|
|
144
|
+
const cmd = getUpgradeCommand('my-package', 'npm');
|
|
145
|
+
expect(cmd).toBe('npm install -g my-package@latest');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should return correct upgrade command for bun', () => {
|
|
149
|
+
const cmd = getUpgradeCommand('my-package', 'bun');
|
|
150
|
+
expect(cmd).toBe('bun install -g my-package@latest');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should return correct upgrade command for pnpm', () => {
|
|
154
|
+
const cmd = getUpgradeCommand('my-package', 'pnpm');
|
|
155
|
+
expect(cmd).toBe('pnpm install -g my-package@latest');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should return correct upgrade command for yarn', () => {
|
|
159
|
+
const cmd = getUpgradeCommand('my-package', 'yarn');
|
|
160
|
+
expect(cmd).toBe('yarn global add my-package@latest');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -18,17 +18,20 @@ import { yamlUtils } from './config/target-utils.js';
|
|
|
18
18
|
/**
|
|
19
19
|
* Load and combine rules and output styles
|
|
20
20
|
*/
|
|
21
|
-
export async function loadRulesAndStyles(
|
|
21
|
+
export async function loadRulesAndStyles(
|
|
22
|
+
ruleNames?: string[],
|
|
23
|
+
outputStyleNames?: string[]
|
|
24
|
+
): Promise<string> {
|
|
22
25
|
const sections: string[] = [];
|
|
23
26
|
|
|
24
|
-
// Load rules (either specified rules or default to
|
|
27
|
+
// Load rules (either specified rules or default to all)
|
|
25
28
|
const rulesContent = await loadRules(ruleNames);
|
|
26
29
|
if (rulesContent) {
|
|
27
30
|
sections.push(rulesContent);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
// Load output styles
|
|
31
|
-
const stylesContent = await loadOutputStyles();
|
|
33
|
+
// Load output styles (either specified or all)
|
|
34
|
+
const stylesContent = await loadOutputStyles(outputStyleNames);
|
|
32
35
|
if (stylesContent) {
|
|
33
36
|
sections.push(stylesContent);
|
|
34
37
|
}
|
|
@@ -69,26 +72,36 @@ async function loadRules(ruleNames?: string[]): Promise<string> {
|
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* Load output styles from assets/output-styles/
|
|
75
|
+
* @param styleNames - Array of style file names (without .md extension). If not provided, loads all styles.
|
|
72
76
|
*/
|
|
73
|
-
async function loadOutputStyles(): Promise<string> {
|
|
77
|
+
async function loadOutputStyles(styleNames?: string[]): Promise<string> {
|
|
74
78
|
try {
|
|
75
79
|
const outputStylesDir = getOutputStylesDir();
|
|
76
|
-
const files = await fs.readdir(outputStylesDir);
|
|
77
|
-
const mdFiles = files.filter((f) => f.endsWith('.md'));
|
|
78
|
-
|
|
79
|
-
if (mdFiles.length === 0) {
|
|
80
|
-
return '';
|
|
81
|
-
}
|
|
82
|
-
|
|
83
80
|
const sections: string[] = [];
|
|
84
81
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
82
|
+
// If specific styles are requested, load only those
|
|
83
|
+
if (styleNames && styleNames.length > 0) {
|
|
84
|
+
for (const styleName of styleNames) {
|
|
85
|
+
const filePath = path.join(outputStylesDir, `${styleName}.md`);
|
|
86
|
+
try {
|
|
87
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
88
|
+
const stripped = await yamlUtils.stripFrontMatter(content);
|
|
89
|
+
sections.push(stripped);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.warn(`Warning: Output style file not found: ${styleName}.md`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// Load all styles
|
|
96
|
+
const files = await fs.readdir(outputStylesDir);
|
|
97
|
+
const mdFiles = files.filter((f) => f.endsWith('.md'));
|
|
98
|
+
|
|
99
|
+
for (const file of mdFiles) {
|
|
100
|
+
const filePath = path.join(outputStylesDir, file);
|
|
101
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
102
|
+
const stripped = await yamlUtils.stripFrontMatter(content);
|
|
103
|
+
sections.push(stripped);
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
return sections.join('\n\n');
|
|
@@ -101,10 +114,15 @@ async function loadOutputStyles(): Promise<string> {
|
|
|
101
114
|
/**
|
|
102
115
|
* Enhance agent content by appending rules and output styles
|
|
103
116
|
* @param agentContent - The agent markdown content
|
|
104
|
-
* @param ruleNames - Optional array of rule file names to include
|
|
117
|
+
* @param ruleNames - Optional array of rule file names to include
|
|
118
|
+
* @param outputStyleNames - Optional array of output style file names to include
|
|
105
119
|
*/
|
|
106
|
-
export async function enhanceAgentContent(
|
|
107
|
-
|
|
120
|
+
export async function enhanceAgentContent(
|
|
121
|
+
agentContent: string,
|
|
122
|
+
ruleNames?: string[],
|
|
123
|
+
outputStyleNames?: string[]
|
|
124
|
+
): Promise<string> {
|
|
125
|
+
const rulesAndStyles = await loadRulesAndStyles(ruleNames, outputStyleNames);
|
|
108
126
|
|
|
109
127
|
if (!rulesAndStyles) {
|
|
110
128
|
return agentContent;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package Manager Detection
|
|
3
|
+
* Detects which package manager is being used (npm, bun, pnpm, yarn)
|
|
4
|
+
* Based on lock files and environment variables
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export type PackageManager = 'npm' | 'bun' | 'pnpm' | 'yarn';
|
|
11
|
+
|
|
12
|
+
export interface PackageManagerInfo {
|
|
13
|
+
name: PackageManager;
|
|
14
|
+
installCommand: string;
|
|
15
|
+
globalInstallCommand: (packageName: string) => string;
|
|
16
|
+
version?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect package manager from environment variable (npm_config_user_agent)
|
|
21
|
+
* This is set when running through npm/bun/pnpm/yarn scripts
|
|
22
|
+
*/
|
|
23
|
+
export function detectPackageManagerFromUserAgent(): PackageManager | null {
|
|
24
|
+
const userAgent = process.env.npm_config_user_agent;
|
|
25
|
+
|
|
26
|
+
if (!userAgent) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (userAgent.includes('bun')) return 'bun';
|
|
31
|
+
if (userAgent.includes('pnpm')) return 'pnpm';
|
|
32
|
+
if (userAgent.includes('yarn')) return 'yarn';
|
|
33
|
+
if (userAgent.includes('npm')) return 'npm';
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Detect package manager from lock files in directory
|
|
40
|
+
*/
|
|
41
|
+
export function detectPackageManagerFromLockFiles(dir: string = process.cwd()): PackageManager | null {
|
|
42
|
+
const lockFiles: Record<PackageManager, string[]> = {
|
|
43
|
+
bun: ['bun.lockb', 'bun.lock'],
|
|
44
|
+
pnpm: ['pnpm-lock.yaml'],
|
|
45
|
+
yarn: ['yarn.lock'],
|
|
46
|
+
npm: ['package-lock.json'],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Check in priority order: bun > pnpm > yarn > npm
|
|
50
|
+
const priority: PackageManager[] = ['bun', 'pnpm', 'yarn', 'npm'];
|
|
51
|
+
|
|
52
|
+
for (const pm of priority) {
|
|
53
|
+
const files = lockFiles[pm];
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
if (fs.existsSync(path.join(dir, file))) {
|
|
56
|
+
return pm;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Detect which package manager to use
|
|
66
|
+
* Priority: user agent > lock files > npm (default)
|
|
67
|
+
*/
|
|
68
|
+
export function detectPackageManager(dir: string = process.cwd()): PackageManager {
|
|
69
|
+
// 1. Try user agent (most reliable when running as script)
|
|
70
|
+
const fromUserAgent = detectPackageManagerFromUserAgent();
|
|
71
|
+
if (fromUserAgent) {
|
|
72
|
+
return fromUserAgent;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Try lock files
|
|
76
|
+
const fromLockFiles = detectPackageManagerFromLockFiles(dir);
|
|
77
|
+
if (fromLockFiles) {
|
|
78
|
+
return fromLockFiles;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Default to npm
|
|
82
|
+
return 'npm';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get package manager info with commands
|
|
87
|
+
*/
|
|
88
|
+
export function getPackageManagerInfo(pm?: PackageManager): PackageManagerInfo {
|
|
89
|
+
const detected = pm || detectPackageManager();
|
|
90
|
+
|
|
91
|
+
const info: Record<PackageManager, PackageManagerInfo> = {
|
|
92
|
+
npm: {
|
|
93
|
+
name: 'npm',
|
|
94
|
+
installCommand: 'npm install',
|
|
95
|
+
globalInstallCommand: (pkg) => `npm install -g ${pkg}`,
|
|
96
|
+
},
|
|
97
|
+
bun: {
|
|
98
|
+
name: 'bun',
|
|
99
|
+
installCommand: 'bun install',
|
|
100
|
+
globalInstallCommand: (pkg) => `bun install -g ${pkg}`,
|
|
101
|
+
},
|
|
102
|
+
pnpm: {
|
|
103
|
+
name: 'pnpm',
|
|
104
|
+
installCommand: 'pnpm install',
|
|
105
|
+
globalInstallCommand: (pkg) => `pnpm install -g ${pkg}`,
|
|
106
|
+
},
|
|
107
|
+
yarn: {
|
|
108
|
+
name: 'yarn',
|
|
109
|
+
installCommand: 'yarn install',
|
|
110
|
+
globalInstallCommand: (pkg) => `yarn global add ${pkg}`,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return info[detected];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get upgrade command for a package
|
|
119
|
+
*/
|
|
120
|
+
export function getUpgradeCommand(packageName: string, pm?: PackageManager): string {
|
|
121
|
+
const pmInfo = getPackageManagerInfo(pm);
|
|
122
|
+
return pmInfo.globalInstallCommand(`${packageName}@latest`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if package manager is available in system
|
|
127
|
+
*/
|
|
128
|
+
export async function isPackageManagerAvailable(pm: PackageManager): Promise<boolean> {
|
|
129
|
+
const { exec } = await import('node:child_process');
|
|
130
|
+
const { promisify } = await import('node:util');
|
|
131
|
+
const execAsync = promisify(exec);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await execAsync(`${pm} --version`);
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Helpers
|
|
3
|
+
* Utilities for handling user prompts and error handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { UserCancelledError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if error is a user cancellation (Ctrl+C or force closed)
|
|
10
|
+
* @param error - Error to check
|
|
11
|
+
* @returns True if error represents user cancellation
|
|
12
|
+
*/
|
|
13
|
+
export function isUserCancellation(error: any): boolean {
|
|
14
|
+
return error?.name === 'ExitPromptError' || error?.message?.includes('force closed');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle inquirer prompt errors with consistent error handling
|
|
19
|
+
* Throws UserCancelledError for user cancellations, re-throws other errors
|
|
20
|
+
* @param error - Error from inquirer prompt
|
|
21
|
+
* @param message - Custom cancellation message
|
|
22
|
+
* @throws {UserCancelledError} If user cancelled the prompt
|
|
23
|
+
* @throws Original error if not a cancellation
|
|
24
|
+
*/
|
|
25
|
+
export function handlePromptError(error: any, message: string): never {
|
|
26
|
+
if (isUserCancellation(error)) {
|
|
27
|
+
throw new UserCancelledError(message);
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wrap an inquirer prompt with consistent error handling
|
|
34
|
+
* @param promptFn - Function that returns a promise from inquirer
|
|
35
|
+
* @param errorMessage - Message to use if user cancels
|
|
36
|
+
* @returns Promise with the prompt result
|
|
37
|
+
* @throws {UserCancelledError} If user cancels the prompt
|
|
38
|
+
*/
|
|
39
|
+
export async function withPromptErrorHandling<T>(
|
|
40
|
+
promptFn: () => Promise<T>,
|
|
41
|
+
errorMessage: string
|
|
42
|
+
): Promise<T> {
|
|
43
|
+
try {
|
|
44
|
+
return await promptFn();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
handlePromptError(error, errorMessage);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target Selection Utilities
|
|
3
|
+
* Shared logic for target selection UI across settings and execution flows
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import inquirer from 'inquirer';
|
|
8
|
+
import { TargetInstaller } from '../services/target-installer.js';
|
|
9
|
+
import { handlePromptError } from './prompt-helpers.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Represents a target CLI choice with installation status
|
|
13
|
+
*/
|
|
14
|
+
export interface TargetChoice {
|
|
15
|
+
/** Display name of the target */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Target identifier */
|
|
18
|
+
value: 'claude-code' | 'opencode' | 'cursor';
|
|
19
|
+
/** Whether the target is currently installed */
|
|
20
|
+
installed: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build list of available targets with installation status
|
|
25
|
+
* @param installedTargets - List of currently installed target IDs
|
|
26
|
+
* @returns Array of target choices with installation status
|
|
27
|
+
*/
|
|
28
|
+
export function buildAvailableTargets(installedTargets: string[]): TargetChoice[] {
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
name: 'Claude Code',
|
|
32
|
+
value: 'claude-code',
|
|
33
|
+
installed: installedTargets.includes('claude-code'),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'OpenCode',
|
|
37
|
+
value: 'opencode',
|
|
38
|
+
installed: installedTargets.includes('opencode'),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'Cursor',
|
|
42
|
+
value: 'cursor',
|
|
43
|
+
installed: installedTargets.includes('cursor'),
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format target choice for display in prompts
|
|
50
|
+
* @param target - Target choice to format
|
|
51
|
+
* @param context - Context where choice is displayed (affects status message)
|
|
52
|
+
* @returns Formatted string with target name and installation status
|
|
53
|
+
*/
|
|
54
|
+
export function formatTargetChoice(target: TargetChoice, context: 'execution' | 'settings'): string {
|
|
55
|
+
const status = target.installed
|
|
56
|
+
? chalk.green(' ✓ installed')
|
|
57
|
+
: context === 'execution'
|
|
58
|
+
? chalk.dim(' (will auto-install)')
|
|
59
|
+
: chalk.dim(' (not installed - will auto-install on first use)');
|
|
60
|
+
|
|
61
|
+
return `${target.name}${status}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Prompt user to select a target
|
|
66
|
+
* @param installedTargets - List of currently installed target IDs
|
|
67
|
+
* @param message - Prompt message to display
|
|
68
|
+
* @param context - Context where prompt is shown (affects status formatting)
|
|
69
|
+
* @returns Selected target ID
|
|
70
|
+
* @throws {UserCancelledError} If user cancels the prompt
|
|
71
|
+
*/
|
|
72
|
+
export async function promptForTargetSelection(
|
|
73
|
+
installedTargets: string[],
|
|
74
|
+
message: string,
|
|
75
|
+
context: 'execution' | 'settings'
|
|
76
|
+
): Promise<string> {
|
|
77
|
+
const availableTargets = buildAvailableTargets(installedTargets);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const { targetId } = await inquirer.prompt([
|
|
81
|
+
{
|
|
82
|
+
type: 'list',
|
|
83
|
+
name: 'targetId',
|
|
84
|
+
message,
|
|
85
|
+
choices: availableTargets.map((target) => ({
|
|
86
|
+
name: formatTargetChoice(target, context),
|
|
87
|
+
value: target.value,
|
|
88
|
+
})),
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
return targetId;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
handlePromptError(error, 'Target selection cancelled');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Prompt for default target with "Ask me every time" option
|
|
100
|
+
* @param installedTargets - List of currently installed target IDs
|
|
101
|
+
* @param currentDefault - Current default target (if any)
|
|
102
|
+
* @returns Selected default target ID or 'ask-every-time'
|
|
103
|
+
* @throws {UserCancelledError} If user cancels the prompt
|
|
104
|
+
*/
|
|
105
|
+
export async function promptForDefaultTarget(
|
|
106
|
+
installedTargets: string[],
|
|
107
|
+
currentDefault?: string
|
|
108
|
+
): Promise<string> {
|
|
109
|
+
const availableTargets = buildAvailableTargets(installedTargets);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const { defaultTarget } = await inquirer.prompt([
|
|
113
|
+
{
|
|
114
|
+
type: 'list',
|
|
115
|
+
name: 'defaultTarget',
|
|
116
|
+
message: 'Select default target platform:',
|
|
117
|
+
choices: [
|
|
118
|
+
...availableTargets.map((target) => ({
|
|
119
|
+
name: formatTargetChoice(target, 'settings'),
|
|
120
|
+
value: target.value,
|
|
121
|
+
})),
|
|
122
|
+
new inquirer.Separator(),
|
|
123
|
+
{
|
|
124
|
+
name: 'Ask me every time',
|
|
125
|
+
value: 'ask-every-time',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
default: currentDefault || 'ask-every-time',
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
return defaultTarget;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
handlePromptError(error, 'Target selection cancelled');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Ensure target is installed, auto-installing if needed
|
|
140
|
+
* @param targetId - Target ID to ensure is installed
|
|
141
|
+
* @param targetInstaller - TargetInstaller instance to use
|
|
142
|
+
* @param installedTargets - List of currently installed target IDs
|
|
143
|
+
* @returns True if target is installed (or successfully installed), false otherwise
|
|
144
|
+
*/
|
|
145
|
+
export async function ensureTargetInstalled(
|
|
146
|
+
targetId: string,
|
|
147
|
+
targetInstaller: TargetInstaller,
|
|
148
|
+
installedTargets: string[]
|
|
149
|
+
): Promise<boolean> {
|
|
150
|
+
const installation = targetInstaller.getInstallationInfo(targetId);
|
|
151
|
+
|
|
152
|
+
// Already installed - nothing to do
|
|
153
|
+
if (installedTargets.includes(targetId)) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Not installed - try to install
|
|
158
|
+
console.log();
|
|
159
|
+
const installed = await targetInstaller.install(targetId, true);
|
|
160
|
+
|
|
161
|
+
if (!installed) {
|
|
162
|
+
console.log(chalk.red(`\n✗ Failed to install ${installation?.name}`));
|
|
163
|
+
console.log(chalk.yellow(' Please install manually and try again.\n'));
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log();
|
|
168
|
+
return true;
|
|
169
|
+
}
|