Skip to content

Commit 5d6bb58

Browse files
authored
feat: enhance env script with conflict resolution and tests (#2726)
1 parent 155b232 commit 5d6bb58

File tree

11 files changed

+2454
-175
lines changed

11 files changed

+2454
-175
lines changed

apps/web/client/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ CSB_API_KEY="<Your api key from https://codesandbox.io/t/api>"
1515

1616
# ------------- Optional Keys -------------
1717

18-
# Fast apply model providers to reliably resolve code changes. Either will work since we will fall back.
18+
# Fast apply model providers to reliably resolve code changes if search replace does not work. We will attempt to use one or the other.
1919
# Option 1: MorphLLM
2020
MORPH_API_KEY="<Your api key from https://morphllm.com/dashboard>"
2121
# Option 2: Relace

bun.lock

Lines changed: 509 additions & 107 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"prepare": "husky",
3535
"clean": "git clean -xdf node_modules",
3636
"clean:workspaces": "bun --filter '*' clean",
37-
"setup:env": "bun --filter @onlook/scripts build && node packages/scripts/dist/index.js",
37+
"setup:env": "cd packages/scripts && bun run start",
3838
"docker:build": "docker compose build",
3939
"docker:up": "docker compose up -d",
4040
"docker:down": "docker compose down",

packages/scripts/package.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"scripts": {
77
"build": "bun build src/index.ts --outdir=dist --target=node",
88
"dev": "tsc --watch",
9-
"start": "bun run build && bun dist/index.js",
9+
"start": "bun run build && node dist/index.js",
10+
"test": "bun test",
1011
"typecheck": "tsc --noEmit",
1112
"lint": "eslint . --ext .ts",
1213
"format": "prettier --write ."
@@ -15,15 +16,17 @@
1516
"env": "dist/index.js"
1617
},
1718
"dependencies": {
18-
"chalk": "^4.1.2",
19-
"commander": "^4.1.1",
20-
"prompts": "^2.4.2",
21-
"ora": "^5.4.1"
19+
"chalk": "^5.6.0",
20+
"commander": "^14.0.0",
21+
"ora": "^8.2.0",
22+
"prompts": "^2.4.2"
2223
},
2324
"devDependencies": {
2425
"@onlook/typescript": "*",
26+
"@types/jest": "^30.0.0",
2527
"@types/node": "^20.10.5",
2628
"@types/prompts": "^2.4.9",
29+
"jest": "^30.0.5",
2730
"typescript": "^5.3.3"
2831
}
2932
}

packages/scripts/src/api-keys.ts

Lines changed: 211 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk';
2+
import fs from 'node:fs';
23
import prompts from 'prompts';
3-
import { writeEnvFile } from './helpers';
44

55
interface ApiKeyConfig {
66
name: string;
@@ -14,54 +14,232 @@ const API_KEYS: Record<string, ApiKeyConfig> = {
1414
name: 'CSB_API_KEY',
1515
message: 'Enter your Codesandbox API key:',
1616
required: true,
17-
description: 'Codesandbox',
1817
},
1918
OPENROUTER_API_KEY: {
2019
name: 'OPENROUTER_API_KEY',
2120
message: 'Enter your OpenRouter API key:',
2221
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',
3622
},
3723
};
3824

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+
3964
export const promptAndWriteApiKeys = async (clientEnvPath: string) => {
40-
const responses = await promptForApiKeys();
65+
const existingKeys = readExistingApiKeys(clientEnvPath);
66+
const responses = await promptForApiKeys(existingKeys);
4167
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+
}
43164
};
44165

166+
/**
167+
* Generates environment content for API keys
168+
* @param responses - User responses for API keys
169+
* @returns Formatted environment content
170+
*/
45171
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');
54181
};
55182

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({
59220
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+
}
65243

66244
validateResponses(responses);
67245
return responses;

0 commit comments

Comments
 (0)