Skip to content

Commit 3a84fc7

Browse files
committed
Implment env management. Fixes #4
1 parent 2949586 commit 3a84fc7

File tree

11 files changed

+1517
-4
lines changed

11 files changed

+1517
-4
lines changed

eslint.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default [
1919
console: 'readonly',
2020
process: 'readonly',
2121
Buffer: 'readonly',
22+
BufferEncoding: 'readonly',
2223
__dirname: 'readonly',
2324
__filename: 'readonly',
2425
global: 'readonly',
@@ -53,6 +54,7 @@ export default [
5354
console: 'readonly',
5455
process: 'readonly',
5556
Buffer: 'readonly',
57+
BufferEncoding: 'readonly',
5658
__dirname: 'readonly',
5759
__filename: 'readonly',
5860
global: 'readonly',
@@ -87,6 +89,7 @@ export default [
8789
console: 'readonly',
8890
process: 'readonly',
8991
Buffer: 'readonly',
92+
BufferEncoding: 'readonly',
9093
},
9194
},
9295
plugins: {

plan.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,11 +690,11 @@ hatchbox-ai/
690690
-**Issue #2** - Core Git Worktree Management
691691

692692
**Next (in order):**
693-
1. **Issue #27** - Logging Infrastructure ⭐ CRITICAL
693+
1. **Issue #27** - Logging Infrastructure ⭐ CRITICAL - DONE
694694
- Required by all subsequent commands
695695
- Establishes consistent output formatting
696696

697-
2. **Issue #28** - Enhanced Worktree Detection ⭐ CRITICAL
697+
2. **Issue #28** - Enhanced Worktree Detection ⭐ CRITICAL - DEFERRED - this can wait
698698
- Builds on completed Issue #2
699699
- Critical UX improvement for worktree reuse
700700

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { EnvironmentManager } from './EnvironmentManager.js'
3+
import fs from 'fs-extra'
4+
import path from 'path'
5+
import os from 'os'
6+
7+
describe('EnvironmentManager integration', () => {
8+
let testDir: string
9+
let manager: EnvironmentManager
10+
11+
beforeEach(async () => {
12+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'env-test-'))
13+
manager = new EnvironmentManager()
14+
})
15+
16+
afterEach(async () => {
17+
await fs.remove(testDir)
18+
})
19+
20+
describe('full workflow', () => {
21+
it('should handle complete workspace environment setup', async () => {
22+
const envPath = path.join(testDir, '.env')
23+
24+
// Create .env file
25+
const result1 = await manager.setEnvVar(envPath, 'DATABASE_URL', 'postgres://localhost/db')
26+
expect(result1.success).toBe(true)
27+
28+
// Add more variables
29+
const result2 = await manager.setEnvVar(envPath, 'API_KEY', 'test-key-123')
30+
expect(result2.success).toBe(true)
31+
32+
const result3 = await manager.setEnvVar(envPath, 'NODE_ENV', 'development')
33+
expect(result3.success).toBe(true)
34+
35+
// Set port for workspace
36+
const port = await manager.setPortForWorkspace(envPath, 42)
37+
expect(port).toBe(3042)
38+
39+
// Read and validate
40+
const envContent = await manager.readEnvFile(envPath)
41+
expect(envContent.get('DATABASE_URL')).toBe('postgres://localhost/db')
42+
expect(envContent.get('API_KEY')).toBe('test-key-123')
43+
expect(envContent.get('NODE_ENV')).toBe('development')
44+
expect(envContent.get('PORT')).toBe('3042')
45+
46+
// Copy to new location
47+
const newEnvPath = path.join(testDir, 'worktree', '.env')
48+
await fs.ensureDir(path.dirname(newEnvPath))
49+
await manager.copyEnvFile(envPath, newEnvPath)
50+
51+
// Verify copied content
52+
const copiedContent = await manager.readEnvFile(newEnvPath)
53+
expect(copiedContent.get('DATABASE_URL')).toBe('postgres://localhost/db')
54+
expect(copiedContent.get('PORT')).toBe('3042')
55+
56+
// Validate both files
57+
const validation1 = await manager.validateEnvFile(envPath)
58+
expect(validation1.valid).toBe(true)
59+
60+
const validation2 = await manager.validateEnvFile(newEnvPath)
61+
expect(validation2.valid).toBe(true)
62+
})
63+
64+
it('should preserve comments and formatting during updates', async () => {
65+
const envPath = path.join(testDir, '.env')
66+
67+
// Create initial file with comments
68+
const initialContent = `# Database configuration
69+
DATABASE_URL="postgres://localhost/db"
70+
71+
# API settings
72+
API_KEY="old-key"
73+
74+
# Environment
75+
NODE_ENV="development"`
76+
77+
await fs.writeFile(envPath, initialContent, 'utf8')
78+
79+
// Update a value
80+
const result = await manager.setEnvVar(envPath, 'API_KEY', 'new-key')
81+
expect(result.success).toBe(true)
82+
83+
// Read the file content directly
84+
const fileContent = await fs.readFile(envPath, 'utf8')
85+
86+
// Verify comments are preserved
87+
expect(fileContent).toContain('# Database configuration')
88+
expect(fileContent).toContain('# API settings')
89+
expect(fileContent).toContain('# Environment')
90+
91+
// Verify value was updated
92+
expect(fileContent).toContain('API_KEY="new-key"')
93+
expect(fileContent).not.toContain('API_KEY="old-key"')
94+
95+
// Verify other values unchanged
96+
expect(fileContent).toContain('DATABASE_URL="postgres://localhost/db"')
97+
expect(fileContent).toContain('NODE_ENV="development"')
98+
})
99+
100+
it('should handle backup and recovery', async () => {
101+
const envPath = path.join(testDir, '.env')
102+
103+
// Create initial file
104+
await manager.setEnvVar(envPath, 'KEY1', 'value1')
105+
await manager.setEnvVar(envPath, 'KEY2', 'value2')
106+
107+
// Update with backup
108+
const result = await manager.setEnvVar(envPath, 'KEY1', 'new-value', true)
109+
expect(result.success).toBe(true)
110+
expect(result.backupPath).toBeDefined()
111+
112+
// Verify backup exists
113+
const backupExists = await fs.pathExists(result.backupPath!)
114+
expect(backupExists).toBe(true)
115+
116+
// Verify backup contains old value
117+
const backupContent = await fs.readFile(result.backupPath!, 'utf8')
118+
expect(backupContent).toContain('KEY1="value1"')
119+
120+
// Verify main file has new value
121+
const mainContent = await manager.readEnvFile(envPath)
122+
expect(mainContent.get('KEY1')).toBe('new-value')
123+
124+
// Recovery: restore from backup
125+
await manager.copyEnvFile(result.backupPath!, envPath)
126+
const restoredContent = await manager.readEnvFile(envPath)
127+
expect(restoredContent.get('KEY1')).toBe('value1')
128+
})
129+
130+
it('should handle special characters and escaping', async () => {
131+
const envPath = path.join(testDir, '.env')
132+
133+
const specialValues = [
134+
['QUOTED', 'value with "quotes"'],
135+
['NEWLINES', 'value\nwith\nnewlines'],
136+
['SPECIAL', 'value with $pecial ch@rs!'],
137+
['EQUALS', 'value=with=equals'],
138+
['SPACES', ' value with spaces '],
139+
]
140+
141+
for (const [key, value] of specialValues) {
142+
const result = await manager.setEnvVar(envPath, key, value)
143+
expect(result.success).toBe(true)
144+
}
145+
146+
// Read back and verify
147+
const envContent = await manager.readEnvFile(envPath)
148+
149+
for (const [key, value] of specialValues) {
150+
expect(envContent.get(key)).toBe(value)
151+
}
152+
})
153+
154+
it('should handle port conflicts and calculations', async () => {
155+
const env1Path = path.join(testDir, '.env.workspace1')
156+
const env2Path = path.join(testDir, '.env.workspace2')
157+
const env3Path = path.join(testDir, '.env.workspace3')
158+
159+
// Create workspaces with different issue numbers
160+
const port1 = await manager.setPortForWorkspace(env1Path, 10)
161+
const port2 = await manager.setPortForWorkspace(env2Path, 20)
162+
const port3 = await manager.setPortForWorkspace(env3Path, 30)
163+
164+
expect(port1).toBe(3010)
165+
expect(port2).toBe(3020)
166+
expect(port3).toBe(3030)
167+
168+
// Verify all ports are unique
169+
expect(new Set([port1, port2, port3]).size).toBe(3)
170+
171+
// Verify ports are written to files
172+
const env1 = await manager.readEnvFile(env1Path)
173+
const env2 = await manager.readEnvFile(env2Path)
174+
const env3 = await manager.readEnvFile(env3Path)
175+
176+
expect(env1.get('PORT')).toBe('3010')
177+
expect(env2.get('PORT')).toBe('3020')
178+
expect(env3.get('PORT')).toBe('3030')
179+
})
180+
})
181+
182+
describe('error handling', () => {
183+
it('should handle missing directories gracefully', async () => {
184+
const envPath = path.join(testDir, 'nonexistent', 'directory', '.env')
185+
186+
// Should fail because parent directory doesn't exist
187+
const result = await manager.setEnvVar(envPath, 'KEY', 'value')
188+
189+
expect(result.success).toBe(false)
190+
expect(result.error).toBeDefined()
191+
expect(result.error).toContain('no such file or directory')
192+
})
193+
194+
it('should handle read-only files', async () => {
195+
const envPath = path.join(testDir, '.env')
196+
197+
await fs.writeFile(envPath, 'KEY="value"', 'utf8')
198+
await fs.chmod(envPath, 0o444) // Read-only
199+
200+
const result = await manager.setEnvVar(envPath, 'KEY', 'new-value')
201+
202+
expect(result.success).toBe(false)
203+
expect(result.error).toBeDefined()
204+
205+
// Cleanup
206+
await fs.chmod(envPath, 0o644)
207+
})
208+
209+
it('should validate file and report errors', async () => {
210+
const envPath = path.join(testDir, '.env')
211+
212+
// Create file with invalid variable names
213+
const invalidContent = `VALID_KEY="value"
214+
123INVALID="value"
215+
ANOTHER-INVALID="value"
216+
_VALID_KEY="value"`
217+
218+
await fs.writeFile(envPath, invalidContent, 'utf8')
219+
220+
const validation = await manager.validateEnvFile(envPath)
221+
222+
expect(validation.valid).toBe(false)
223+
expect(validation.errors.length).toBeGreaterThan(0)
224+
expect(validation.errors.some(e => e.includes('123INVALID'))).toBe(true)
225+
expect(validation.errors.some(e => e.includes('ANOTHER-INVALID'))).toBe(true)
226+
})
227+
})
228+
229+
describe('bash script parity', () => {
230+
it('should produce identical output to setEnvVar bash function', async () => {
231+
const envPath = path.join(testDir, '.env')
232+
233+
// Test case 1: Create new file
234+
const result1 = await manager.setEnvVar(envPath, 'KEY', 'value')
235+
expect(result1.success).toBe(true)
236+
237+
let content = await fs.readFile(envPath, 'utf8')
238+
expect(content).toBe('KEY="value"')
239+
240+
// Test case 2: Add variable to existing file
241+
const result2 = await manager.setEnvVar(envPath, 'KEY2', 'value2')
242+
expect(result2.success).toBe(true)
243+
244+
content = await fs.readFile(envPath, 'utf8')
245+
expect(content).toContain('KEY="value"')
246+
expect(content).toContain('KEY2="value2"')
247+
248+
// Test case 3: Update existing variable
249+
const result3 = await manager.setEnvVar(envPath, 'KEY', 'new-value')
250+
expect(result3.success).toBe(true)
251+
252+
content = await fs.readFile(envPath, 'utf8')
253+
expect(content).toContain('KEY="new-value"')
254+
expect(content).not.toContain('KEY="value"')
255+
expect(content).toContain('KEY2="value2"')
256+
257+
// Verify the bash script behavior: always quote values
258+
const lines = content.split('\n').filter(l => l.trim())
259+
for (const line of lines) {
260+
if (line.includes('=')) {
261+
const [, value] = line.split('=')
262+
expect(value.startsWith('"')).toBe(true)
263+
expect(value.endsWith('"')).toBe(true)
264+
}
265+
}
266+
})
267+
268+
it('should escape quotes like bash script does', async () => {
269+
const envPath = path.join(testDir, '.env')
270+
271+
// The bash script uses: escaped_value="${var_value//\"/\\\"}"
272+
const valueWithQuotes = 'value with "quotes" inside'
273+
const result = await manager.setEnvVar(envPath, 'KEY', valueWithQuotes)
274+
expect(result.success).toBe(true)
275+
276+
const fileContent = await fs.readFile(envPath, 'utf8')
277+
expect(fileContent).toBe('KEY="value with \\"quotes\\" inside"')
278+
279+
// Verify we can read it back correctly
280+
const envContent = await manager.readEnvFile(envPath)
281+
expect(envContent.get('KEY')).toBe(valueWithQuotes)
282+
})
283+
})
284+
})

0 commit comments

Comments
 (0)