1
1
import chalk from 'chalk' ;
2
+ import fs from 'node:fs' ;
2
3
import prompts from 'prompts' ;
3
- import { writeEnvFile } from './helpers' ;
4
4
5
5
interface ApiKeyConfig {
6
6
name : string ;
@@ -14,54 +14,232 @@ const API_KEYS: Record<string, ApiKeyConfig> = {
14
14
name : 'CSB_API_KEY' ,
15
15
message : 'Enter your Codesandbox API key:' ,
16
16
required : true ,
17
- description : 'Codesandbox' ,
18
17
} ,
19
18
OPENROUTER_API_KEY : {
20
19
name : 'OPENROUTER_API_KEY' ,
21
20
message : 'Enter your OpenRouter API key:' ,
22
21
required : true ,
23
- description : 'OpenRouter' ,
24
- } ,
25
- MORPH_API_KEY : {
26
- name : 'MORPH_API_KEY' ,
27
- message : 'Enter your MorphLLM API key (optional, leave blank if you are using Relace):' ,
28
- required : false ,
29
- description : 'MorphLLM' ,
30
- } ,
31
- RELACE_API_KEY : {
32
- name : 'RELACE_API_KEY' ,
33
- message : 'Enter your Relace API key (optional, leave blank if you are using MorphLLM):' ,
34
- required : false ,
35
- description : 'Relace' ,
36
22
} ,
37
23
} ;
38
24
25
+ /**
26
+ * Reads existing API keys from the environment file
27
+ * @param clientEnvPath - Path to the client .env file
28
+ * @returns Object containing existing API key values
29
+ */
30
+ const readExistingApiKeys = ( clientEnvPath : string ) : Record < string , string > => {
31
+ const existingKeys : Record < string , string > = { } ;
32
+
33
+ if ( ! fs . existsSync ( clientEnvPath ) ) {
34
+ return existingKeys ;
35
+ }
36
+
37
+ try {
38
+ const content = fs . readFileSync ( clientEnvPath , 'utf-8' ) ;
39
+ const lines = content . split ( '\n' ) ;
40
+ const validApiKeys = new Set ( Object . keys ( API_KEYS ) ) ;
41
+
42
+ for ( const line of lines ) {
43
+ const trimmedLine = line . trim ( ) ;
44
+ if (
45
+ trimmedLine . includes ( '=' ) &&
46
+ ! trimmedLine . startsWith ( '#' ) &&
47
+ trimmedLine . indexOf ( '=' ) > 0
48
+ ) {
49
+ const [ key , ...valueParts ] = trimmedLine . split ( '=' ) ;
50
+ const cleanKey = key ?. trim ( ) ;
51
+
52
+ if ( cleanKey && validApiKeys . has ( cleanKey ) ) {
53
+ existingKeys [ cleanKey ] = valueParts . join ( '=' ) ;
54
+ }
55
+ }
56
+ }
57
+ } catch ( err ) {
58
+ console . warn ( chalk . yellow ( `Warning: Could not read existing .env file: ${ err } ` ) ) ;
59
+ }
60
+
61
+ return existingKeys ;
62
+ } ;
63
+
39
64
export const promptAndWriteApiKeys = async ( clientEnvPath : string ) => {
40
- const responses = await promptForApiKeys ( ) ;
65
+ const existingKeys = readExistingApiKeys ( clientEnvPath ) ;
66
+ const responses = await promptForApiKeys ( existingKeys ) ;
41
67
const envContent = generateEnvContent ( responses ) ;
42
- writeEnvFile ( clientEnvPath , envContent , 'web client' ) ;
68
+
69
+ // Since we already handled existing key conflicts in promptForApiKeys,
70
+ // we need to manually update the file to avoid duplicate prompting
71
+ await writeApiKeysToFile ( clientEnvPath , envContent ) ;
72
+ } ;
73
+
74
+ /**
75
+ * Writes API keys to file, removing old API key sections
76
+ * @param filePath - Path to the .env file
77
+ * @param newContent - New API key content to write
78
+ */
79
+ const writeApiKeysToFile = async ( filePath : string , newContent : string ) : Promise < void > => {
80
+ try {
81
+ const existingContent = fs . existsSync ( filePath ) ? fs . readFileSync ( filePath , 'utf-8' ) : '' ;
82
+ const filteredContent = removeOldApiKeyEntries ( existingContent ) ;
83
+
84
+ ensureDirectoryExists ( filePath ) ;
85
+
86
+ // Only add newline separator if filtered content exists and doesn't end with newline
87
+ const separator = filteredContent && ! filteredContent . endsWith ( '\n' ) ? '\n' : '' ;
88
+ const finalContent = filteredContent + separator + newContent ;
89
+ fs . writeFileSync ( filePath , finalContent ) ;
90
+
91
+ console . log ( chalk . green ( '✅ API keys updated successfully!' ) ) ;
92
+ } catch ( err ) {
93
+ console . error ( chalk . red ( 'Failed to write API keys:' ) , err ) ;
94
+ throw err ;
95
+ }
96
+ } ;
97
+
98
+ /**
99
+ * Removes old API key entries from existing content
100
+ * @param content - Existing file content
101
+ * @returns Filtered content without old API keys
102
+ */
103
+ const removeOldApiKeyEntries = ( content : string ) : string => {
104
+ const lines = content . split ( '\n' ) ;
105
+ const filteredLines : string [ ] = [ ] ;
106
+ const apiKeyNames = new Set ( Object . keys ( API_KEYS ) ) ;
107
+ let skipNextLine = false ;
108
+
109
+ for ( const line of lines ) {
110
+ const trimmedLine = line . trim ( ) ;
111
+
112
+ // Skip API key variable lines
113
+ const keyName = extractKeyName ( trimmedLine ) ;
114
+ if ( trimmedLine . includes ( '=' ) && keyName && apiKeyNames . has ( keyName ) ) {
115
+ skipNextLine = false ;
116
+ continue ;
117
+ }
118
+
119
+ // Skip empty lines after API key comments
120
+ if ( skipNextLine && trimmedLine === '' ) {
121
+ skipNextLine = false ;
122
+ continue ;
123
+ }
124
+
125
+ filteredLines . push ( line ) ;
126
+ skipNextLine = false ;
127
+ }
128
+
129
+ return filteredLines . join ( '\n' ) . trim ( ) ;
130
+ } ;
131
+
132
+ /**
133
+ * Extracts description from a comment line
134
+ * @param commentLine - Comment line starting with #
135
+ * @returns Description text or undefined
136
+ */
137
+ const extractDescription = ( commentLine : string ) : string | undefined => {
138
+ const match = commentLine . match ( / ^ # \s * ( .+ ) / ) ;
139
+ return match ?. [ 1 ] ?. trim ( ) ;
140
+ } ;
141
+
142
+ /**
143
+ * Extracts key name from a variable line
144
+ * @param variableLine - Variable line with key=value format
145
+ * @returns Key name or undefined
146
+ */
147
+ const extractKeyName = ( variableLine : string ) : string | undefined => {
148
+ const equalIndex = variableLine . indexOf ( '=' ) ;
149
+ if ( equalIndex > 0 ) {
150
+ return variableLine . substring ( 0 , equalIndex ) . trim ( ) ;
151
+ }
152
+ return undefined ;
153
+ } ;
154
+
155
+ /**
156
+ * Ensures the directory for a file path exists
157
+ * @param filePath - Full path to the file
158
+ */
159
+ const ensureDirectoryExists = ( filePath : string ) : void => {
160
+ const dir = filePath . substring ( 0 , filePath . lastIndexOf ( '/' ) ) ;
161
+ if ( ! fs . existsSync ( dir ) ) {
162
+ fs . mkdirSync ( dir , { recursive : true } ) ;
163
+ }
43
164
} ;
44
165
166
+ /**
167
+ * Generates environment content for API keys
168
+ * @param responses - User responses for API keys
169
+ * @returns Formatted environment content
170
+ */
45
171
const generateEnvContent = ( responses : Record < string , string > ) : string => {
46
- return Object . entries ( API_KEYS )
47
- . map ( ( [ key , config ] ) => {
48
- const value = responses [ key ] || '' ;
49
- return config . description
50
- ? `# ${ config . description } \n${ key } =${ value } \n`
51
- : `${ key } =${ value } \n` ;
52
- } )
53
- . join ( '\n' ) ;
172
+ const lines : string [ ] = [ ] ;
173
+ const entries = Object . entries ( API_KEYS ) ;
174
+
175
+ for ( const [ key ] of entries ) {
176
+ const value = responses [ key ] || '' ;
177
+ lines . push ( `${ key } =${ value } ` ) ;
178
+ }
179
+
180
+ return lines . join ( '\n' ) ;
54
181
} ;
55
182
56
- const promptForApiKeys = async ( ) => {
57
- const responses = await prompts (
58
- Object . values ( API_KEYS ) . map ( ( api ) => ( {
183
+ const promptForApiKeys = async ( existingKeys : Record < string , string > ) => {
184
+ const responses : Record < string , string > = { } ;
185
+
186
+ console . log ( chalk . blue ( '\n🔑 API Key Configuration' ) ) ;
187
+ console . log ( chalk . gray ( 'Configure your API keys for Onlook services\n' ) ) ;
188
+
189
+ for ( const [ keyName , config ] of Object . entries ( API_KEYS ) ) {
190
+ const hasExisting = existingKeys [ keyName ] ;
191
+
192
+ if ( hasExisting ) {
193
+ console . log ( chalk . yellow ( `\n⚠️ ${ keyName } API key already exists` ) ) ;
194
+
195
+ const action = await prompts ( {
196
+ type : 'select' ,
197
+ name : 'choice' ,
198
+ message : `What would you like to do with ${ keyName } ?` ,
199
+ choices : [
200
+ { title : 'Keep existing key' , value : 'keep' } ,
201
+ { title : 'Replace with new key' , value : 'replace' } ,
202
+ ...( config . required ? [ ] : [ { title : 'Remove key' , value : 'remove' } ] ) ,
203
+ ] ,
204
+ initial : 0 ,
205
+ } ) ;
206
+
207
+ if ( action . choice === 'keep' ) {
208
+ responses [ keyName ] = hasExisting ;
209
+ console . log ( chalk . green ( `✓ Keeping existing ${ keyName } key` ) ) ;
210
+ continue ;
211
+ } else if ( action . choice === 'remove' ) {
212
+ responses [ keyName ] = '' ;
213
+ console . log ( chalk . blue ( `✓ Removed ${ keyName } key` ) ) ;
214
+ continue ;
215
+ }
216
+ // If 'replace' is selected, continue to prompt for new key
217
+ }
218
+
219
+ const response = await prompts ( {
59
220
type : 'password' ,
60
- name : api . name ,
61
- message : api . message ,
62
- required : api . required ,
63
- } ) ) ,
64
- ) ;
221
+ name : 'value' ,
222
+ message : hasExisting ? `Enter new ${ keyName } API key:` : config . message ,
223
+ validate : config . required
224
+ ? ( value : string ) => value . length > 0 || `${ keyName } is required`
225
+ : undefined ,
226
+ } ) ;
227
+
228
+ if ( response . value !== undefined ) {
229
+ responses [ keyName ] = response . value ;
230
+ if ( response . value ) {
231
+ console . log ( chalk . green ( `✓ ${ hasExisting ? 'Updated' : 'Set' } ${ keyName } key` ) ) ;
232
+ }
233
+ } else {
234
+ // User cancelled, keep existing if available
235
+ if ( hasExisting ) {
236
+ responses [ keyName ] = hasExisting ;
237
+ } else if ( config . required ) {
238
+ console . error ( chalk . red ( `${ keyName } API key is required.` ) ) ;
239
+ process . exit ( 1 ) ;
240
+ }
241
+ }
242
+ }
65
243
66
244
validateResponses ( responses ) ;
67
245
return responses ;
0 commit comments