@fractary/core 0.7.20 → 0.7.21

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.
@@ -54,6 +54,8 @@ describe('loadEnv', () => {
54
54
  beforeEach(() => {
55
55
  jest.clearAllMocks();
56
56
  delete process.env.FRACTARY_ENV;
57
+ // Reset internal state
58
+ (0, loader_1.clearEnv)();
57
59
  });
58
60
  afterAll(() => {
59
61
  process.cwd = originalCwd;
@@ -64,104 +66,144 @@ describe('loadEnv', () => {
64
66
  delete process.env.FRACTARY_ENV;
65
67
  }
66
68
  });
67
- describe('when .env exists in cwd', () => {
68
- it('should load .env from current working directory', () => {
69
+ describe('standard location (.fractary/env/)', () => {
70
+ it('should load .env from .fractary/env/ when present', () => {
71
+ const projectRoot = process.cwd();
72
+ const standardPath = path.join(projectRoot, '.fractary', 'env', '.env');
69
73
  mockedFs.existsSync.mockImplementation((p) => {
70
- return p === path.join(process.cwd(), '.env');
74
+ return String(p) === standardPath;
71
75
  });
72
76
  const result = (0, loader_1.loadEnv)({ force: true });
73
77
  expect(result).toBe(true);
74
78
  expect(mockedDotenv.config).toHaveBeenCalledWith({
75
- path: path.join(process.cwd(), '.env'),
79
+ path: standardPath,
76
80
  override: true,
77
81
  });
78
82
  });
79
- it('should not load again if already loaded', () => {
80
- mockedFs.existsSync.mockReturnValue(true);
81
- (0, loader_1.loadEnv)({ force: true });
82
- const callCount = mockedDotenv.config.mock.calls.length;
83
- (0, loader_1.loadEnv)(); // Second call without force
84
- // Should not call dotenv.config again
85
- expect(mockedDotenv.config).toHaveBeenCalledTimes(callCount);
86
- });
87
- it('should reload with force option', () => {
83
+ it('should load environment-specific file from .fractary/env/', () => {
84
+ process.env.FRACTARY_ENV = 'prod';
85
+ const projectRoot = process.cwd();
86
+ const standardEnv = path.join(projectRoot, '.fractary', 'env', '.env');
87
+ const standardProd = path.join(projectRoot, '.fractary', 'env', '.env.prod');
88
88
  mockedFs.existsSync.mockImplementation((p) => {
89
- // Only .env exists (no .env.local)
90
- return String(p).endsWith('.env') && !String(p).endsWith('.local');
91
- });
92
- (0, loader_1.loadEnv)({ force: true });
93
- const callCount = mockedDotenv.config.mock.calls.length;
94
- (0, loader_1.loadEnv)({ force: true }); // Force reload
95
- // Each reload loads .env (and would load .env.local if it existed)
96
- expect(mockedDotenv.config.mock.calls.length).toBeGreaterThan(callCount);
97
- });
98
- });
99
- describe('when .env does not exist', () => {
100
- it('should return false when no .env file found', () => {
101
- mockedFs.existsSync.mockReturnValue(false);
102
- const result = (0, loader_1.loadEnv)({ force: true });
103
- expect(result).toBe(false);
104
- });
105
- it('should try project root if cwd .env does not exist', () => {
106
- // First call (cwd .env) returns false, second call (project root) returns true
107
- let callCount = 0;
108
- mockedFs.existsSync.mockImplementation(() => {
109
- callCount++;
110
- return callCount > 1; // Project root .env exists
89
+ const pathStr = String(p);
90
+ return pathStr === standardEnv || pathStr === standardProd;
111
91
  });
112
92
  const result = (0, loader_1.loadEnv)({ force: true });
113
93
  expect(result).toBe(true);
94
+ expect(mockedDotenv.config).toHaveBeenCalledWith({
95
+ path: standardEnv,
96
+ override: true,
97
+ });
98
+ expect(mockedDotenv.config).toHaveBeenCalledWith({
99
+ path: standardProd,
100
+ override: true,
101
+ });
114
102
  });
115
103
  });
116
- describe('with custom cwd', () => {
117
- it('should use provided cwd', () => {
118
- const customCwd = '/custom/path';
104
+ describe('legacy fallback (project root)', () => {
105
+ it('should load .env from project root as fallback', () => {
106
+ const projectRoot = process.cwd();
107
+ const legacyPath = path.join(projectRoot, '.env');
119
108
  mockedFs.existsSync.mockImplementation((p) => {
120
- return p === path.join(customCwd, '.env');
109
+ // Standard location doesn't exist, only legacy
110
+ return String(p) === legacyPath;
121
111
  });
122
- const result = (0, loader_1.loadEnv)({ cwd: customCwd, force: true });
112
+ const result = (0, loader_1.loadEnv)({ force: true });
123
113
  expect(result).toBe(true);
124
114
  expect(mockedDotenv.config).toHaveBeenCalledWith({
125
- path: path.join(customCwd, '.env'),
115
+ path: legacyPath,
126
116
  override: true,
127
117
  });
128
118
  });
119
+ it('should fire deprecation warning when loading from root', () => {
120
+ const projectRoot = process.cwd();
121
+ const legacyPath = path.join(projectRoot, '.env');
122
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
123
+ mockedFs.existsSync.mockImplementation((p) => {
124
+ return String(p) === legacyPath;
125
+ });
126
+ (0, loader_1.loadEnv)({ force: true });
127
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Deprecation'));
128
+ warnSpy.mockRestore();
129
+ });
130
+ it('should fire deprecation warning only once per session', () => {
131
+ const projectRoot = process.cwd();
132
+ const legacyPath = path.join(projectRoot, '.env');
133
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
134
+ mockedFs.existsSync.mockImplementation((p) => {
135
+ return String(p) === legacyPath;
136
+ });
137
+ (0, loader_1.loadEnv)({ force: true });
138
+ (0, loader_1.loadEnv)({ force: true });
139
+ const deprecationCalls = warnSpy.mock.calls.filter((c) => String(c[0]).includes('Deprecation'));
140
+ expect(deprecationCalls.length).toBe(1);
141
+ warnSpy.mockRestore();
142
+ });
129
143
  });
130
- describe('multi-environment support (FRACTARY_ENV)', () => {
131
- it('should load .env.{FRACTARY_ENV} when FRACTARY_ENV is set', () => {
132
- process.env.FRACTARY_ENV = 'prod';
144
+ describe('mixed standard and legacy', () => {
145
+ it('should load .env from standard and .env.prod from legacy', () => {
133
146
  const projectRoot = process.cwd();
147
+ process.env.FRACTARY_ENV = 'prod';
148
+ const standardEnv = path.join(projectRoot, '.fractary', 'env', '.env');
149
+ const legacyProd = path.join(projectRoot, '.env.prod');
150
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
134
151
  mockedFs.existsSync.mockImplementation((p) => {
135
152
  const pathStr = String(p);
136
- // Both .env and .env.prod exist
137
- return (pathStr === path.join(projectRoot, '.env') ||
138
- pathStr === path.join(projectRoot, '.env.prod'));
153
+ return pathStr === standardEnv || pathStr === legacyProd;
139
154
  });
140
155
  const result = (0, loader_1.loadEnv)({ force: true });
141
156
  expect(result).toBe(true);
142
- // Should load .env first, then .env.prod
143
157
  expect(mockedDotenv.config).toHaveBeenCalledWith({
144
- path: path.join(projectRoot, '.env'),
158
+ path: standardEnv,
145
159
  override: true,
146
160
  });
147
161
  expect(mockedDotenv.config).toHaveBeenCalledWith({
148
- path: path.join(projectRoot, '.env.prod'),
162
+ path: legacyProd,
149
163
  override: true,
150
164
  });
165
+ // Deprecation fired for legacy .env.prod
166
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Deprecation'));
167
+ warnSpy.mockRestore();
151
168
  });
152
- it('should load .env.local last for local overrides', () => {
169
+ });
170
+ describe('existing behavior', () => {
171
+ it('should not load again if already loaded', () => {
172
+ const projectRoot = process.cwd();
173
+ mockedFs.existsSync.mockImplementation((p) => {
174
+ return String(p) === path.join(projectRoot, '.fractary', 'env', '.env');
175
+ });
176
+ (0, loader_1.loadEnv)({ force: true });
177
+ const callCount = mockedDotenv.config.mock.calls.length;
178
+ (0, loader_1.loadEnv)(); // Second call without force
179
+ expect(mockedDotenv.config).toHaveBeenCalledTimes(callCount);
180
+ });
181
+ it('should reload with force option', () => {
153
182
  const projectRoot = process.cwd();
154
183
  mockedFs.existsSync.mockImplementation((p) => {
155
184
  const pathStr = String(p);
156
- // Both .env and .env.local exist
157
- return (pathStr === path.join(projectRoot, '.env') ||
158
- pathStr === path.join(projectRoot, '.env.local'));
185
+ return pathStr === path.join(projectRoot, '.fractary', 'env', '.env');
159
186
  });
187
+ (0, loader_1.loadEnv)({ force: true });
188
+ const callCount = mockedDotenv.config.mock.calls.length;
189
+ (0, loader_1.loadEnv)({ force: true });
190
+ expect(mockedDotenv.config.mock.calls.length).toBeGreaterThan(callCount);
191
+ });
192
+ it('should return false when no .env file found', () => {
193
+ mockedFs.existsSync.mockReturnValue(false);
160
194
  const result = (0, loader_1.loadEnv)({ force: true });
161
- expect(result).toBe(true);
162
- // Get all call arguments as strings
195
+ expect(result).toBe(false);
196
+ });
197
+ it('should load .env.local last for local overrides', () => {
198
+ const projectRoot = process.cwd();
199
+ const standardEnv = path.join(projectRoot, '.fractary', 'env', '.env');
200
+ const standardLocal = path.join(projectRoot, '.fractary', 'env', '.env.local');
201
+ mockedFs.existsSync.mockImplementation((p) => {
202
+ const pathStr = String(p);
203
+ return pathStr === standardEnv || pathStr === standardLocal;
204
+ });
205
+ (0, loader_1.loadEnv)({ force: true });
163
206
  const calls = mockedDotenv.config.mock.calls.map((c) => String(c[0]?.path || ''));
164
- // .env should be loaded before .env.local
165
207
  const envIndex = calls.findIndex((p) => p.endsWith('.env') && !p.includes('.local'));
166
208
  const localIndex = calls.findIndex((p) => p.endsWith('.env.local'));
167
209
  expect(envIndex).toBeGreaterThanOrEqual(0);
@@ -180,13 +222,33 @@ describe('loadEnv', () => {
180
222
  expect((0, loader_1.getCurrentEnv)()).toBeUndefined();
181
223
  });
182
224
  });
225
+ describe('clearEnv resets deprecationWarned', () => {
226
+ it('should fire deprecation warning again after clearEnv', () => {
227
+ const projectRoot = process.cwd();
228
+ const legacyPath = path.join(projectRoot, '.env');
229
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
230
+ mockedFs.existsSync.mockImplementation((p) => {
231
+ return String(p) === legacyPath;
232
+ });
233
+ (0, loader_1.loadEnv)({ force: true });
234
+ (0, loader_1.clearEnv)();
235
+ (0, loader_1.loadEnv)({ force: true });
236
+ const deprecationCalls = warnSpy.mock.calls.filter((c) => String(c[0]).includes('Deprecation'));
237
+ expect(deprecationCalls.length).toBe(2);
238
+ warnSpy.mockRestore();
239
+ });
240
+ });
183
241
  });
184
242
  describe('isEnvLoaded', () => {
185
243
  beforeEach(() => {
186
244
  jest.clearAllMocks();
245
+ (0, loader_1.clearEnv)();
187
246
  });
188
247
  it('should return true after successful loadEnv', () => {
189
- mockedFs.existsSync.mockReturnValue(true);
248
+ const projectRoot = process.cwd();
249
+ mockedFs.existsSync.mockImplementation((p) => {
250
+ return String(p) === path.join(projectRoot, '.fractary', 'env', '.env');
251
+ });
190
252
  (0, loader_1.loadEnv)({ force: true });
191
253
  expect((0, loader_1.isEnvLoaded)()).toBe(true);
192
254
  });
@@ -195,36 +257,38 @@ describe('switchEnv', () => {
195
257
  beforeEach(() => {
196
258
  jest.clearAllMocks();
197
259
  delete process.env.FRACTARY_ENV;
260
+ (0, loader_1.clearEnv)();
198
261
  });
199
262
  it('should set FRACTARY_ENV and reload environment', () => {
200
263
  const projectRoot = process.cwd();
264
+ const standardEnv = path.join(projectRoot, '.fractary', 'env', '.env');
265
+ const standardTest = path.join(projectRoot, '.fractary', 'env', '.env.test');
201
266
  mockedFs.existsSync.mockImplementation((p) => {
202
267
  const pathStr = String(p);
203
- return (pathStr === path.join(projectRoot, '.env') ||
204
- pathStr === path.join(projectRoot, '.env.test'));
268
+ return pathStr === standardEnv || pathStr === standardTest;
205
269
  });
206
270
  const result = (0, loader_1.switchEnv)('test');
207
271
  expect(result).toBe(true);
208
272
  expect(process.env.FRACTARY_ENV).toBe('test');
209
273
  expect((0, loader_1.getCurrentEnv)()).toBe('test');
210
274
  expect(mockedDotenv.config).toHaveBeenCalledWith({
211
- path: path.join(projectRoot, '.env.test'),
275
+ path: standardTest,
212
276
  override: true,
213
277
  });
214
278
  });
215
279
  it('should allow switching between environments (FABR workflow)', () => {
216
280
  const projectRoot = process.cwd();
281
+ const standardEnv = path.join(projectRoot, '.fractary', 'env', '.env');
282
+ const standardTest = path.join(projectRoot, '.fractary', 'env', '.env.test');
283
+ const standardProd = path.join(projectRoot, '.fractary', 'env', '.env.prod');
217
284
  mockedFs.existsSync.mockImplementation((p) => {
218
285
  const pathStr = String(p);
219
- // Simulate having .env, .env.test, and .env.prod
220
- return (pathStr === path.join(projectRoot, '.env') ||
221
- pathStr === path.join(projectRoot, '.env.test') ||
222
- pathStr === path.join(projectRoot, '.env.prod'));
286
+ return (pathStr === standardEnv ||
287
+ pathStr === standardTest ||
288
+ pathStr === standardProd);
223
289
  });
224
- // Start with test (evaluate phase)
225
290
  (0, loader_1.switchEnv)('test');
226
291
  expect((0, loader_1.getCurrentEnv)()).toBe('test');
227
- // Switch to prod (release phase)
228
292
  (0, loader_1.switchEnv)('prod');
229
293
  expect((0, loader_1.getCurrentEnv)()).toBe('prod');
230
294
  expect(process.env.FRACTARY_ENV).toBe('prod');
@@ -249,7 +313,6 @@ describe('clearEnv', () => {
249
313
  jest.clearAllMocks();
250
314
  });
251
315
  it('should clear default Fractary environment variables', () => {
252
- // Set some env vars
253
316
  process.env.GITHUB_TOKEN = 'test-token';
254
317
  process.env.AWS_ACCESS_KEY_ID = 'test-key';
255
318
  process.env.AWS_SECRET_ACCESS_KEY = 'test-secret';
@@ -263,20 +326,19 @@ describe('clearEnv', () => {
263
326
  process.env.CUSTOM_VAR = 'custom-value';
264
327
  process.env.ANOTHER_VAR = 'another-value';
265
328
  (0, loader_1.clearEnv)(['CUSTOM_VAR', 'ANOTHER_VAR']);
266
- // GITHUB_TOKEN should remain (not in the list)
267
329
  expect(process.env.GITHUB_TOKEN).toBe('test-token');
268
330
  expect(process.env.CUSTOM_VAR).toBeUndefined();
269
331
  expect(process.env.ANOTHER_VAR).toBeUndefined();
270
- // Cleanup
271
332
  delete process.env.GITHUB_TOKEN;
272
333
  });
273
334
  it('should reset getCurrentEnv to undefined', () => {
274
- mockedFs.existsSync.mockReturnValue(true);
275
- // Load an environment first
335
+ const projectRoot = process.cwd();
336
+ mockedFs.existsSync.mockImplementation((p) => {
337
+ return String(p) === path.join(projectRoot, '.fractary', 'env', '.env');
338
+ });
276
339
  process.env.FRACTARY_ENV = 'test';
277
340
  (0, loader_1.loadEnv)({ force: true });
278
341
  expect((0, loader_1.getCurrentEnv)()).toBe('test');
279
- // Clear should reset
280
342
  (0, loader_1.clearEnv)();
281
343
  expect((0, loader_1.getCurrentEnv)()).toBeUndefined();
282
344
  });
@@ -288,25 +350,278 @@ describe('clearEnv', () => {
288
350
  expect((0, loader_1.isEnvLoaded)()).toBe(false);
289
351
  });
290
352
  });
353
+ describe('getEnvDir', () => {
354
+ it('should return .fractary/env/ under project root', () => {
355
+ const projectRoot = '/my/project';
356
+ const result = (0, loader_1.getEnvDir)(projectRoot);
357
+ expect(result).toBe(path.join(projectRoot, '.fractary', 'env'));
358
+ });
359
+ });
360
+ describe('ensureEnvDir', () => {
361
+ beforeEach(() => {
362
+ jest.clearAllMocks();
363
+ });
364
+ it('should create directory if it does not exist', () => {
365
+ const projectRoot = '/my/project';
366
+ mockedFs.existsSync.mockReturnValue(false);
367
+ const result = (0, loader_1.ensureEnvDir)(projectRoot);
368
+ expect(result).toBe(path.join(projectRoot, '.fractary', 'env'));
369
+ expect(mockedFs.mkdirSync).toHaveBeenCalledWith(path.join(projectRoot, '.fractary', 'env'), { recursive: true });
370
+ });
371
+ it('should not create directory if it already exists', () => {
372
+ const projectRoot = '/my/project';
373
+ mockedFs.existsSync.mockReturnValue(true);
374
+ (0, loader_1.ensureEnvDir)(projectRoot);
375
+ expect(mockedFs.mkdirSync).not.toHaveBeenCalled();
376
+ });
377
+ });
378
+ describe('resolveEnvFile', () => {
379
+ beforeEach(() => {
380
+ jest.clearAllMocks();
381
+ });
382
+ it('should prefer standard location', () => {
383
+ const projectRoot = '/my/project';
384
+ const standardPath = path.join(projectRoot, '.fractary', 'env', '.env.test');
385
+ mockedFs.existsSync.mockImplementation((p) => {
386
+ return String(p) === standardPath;
387
+ });
388
+ const result = (0, loader_1.resolveEnvFile)('.env.test', projectRoot);
389
+ expect(result).toEqual({ path: standardPath, location: 'standard' });
390
+ });
391
+ it('should fall back to legacy location', () => {
392
+ const projectRoot = '/my/project';
393
+ const legacyPath = path.join(projectRoot, '.env.test');
394
+ mockedFs.existsSync.mockImplementation((p) => {
395
+ return String(p) === legacyPath;
396
+ });
397
+ const result = (0, loader_1.resolveEnvFile)('.env.test', projectRoot);
398
+ expect(result).toEqual({ path: legacyPath, location: 'legacy' });
399
+ });
400
+ it('should return null if file not found anywhere', () => {
401
+ mockedFs.existsSync.mockReturnValue(false);
402
+ const result = (0, loader_1.resolveEnvFile)('.env.test', '/my/project');
403
+ expect(result).toBeNull();
404
+ });
405
+ });
406
+ describe('listEnvFiles', () => {
407
+ beforeEach(() => {
408
+ jest.clearAllMocks();
409
+ });
410
+ it('should return standard files from .fractary/env/', () => {
411
+ const projectRoot = '/my/project';
412
+ const envDir = path.join(projectRoot, '.fractary', 'env');
413
+ mockedFs.existsSync.mockImplementation((p) => {
414
+ const pathStr = String(p);
415
+ return pathStr === envDir || pathStr === projectRoot;
416
+ });
417
+ mockedFs.readdirSync.mockImplementation(((p) => {
418
+ if (String(p) === envDir)
419
+ return ['.env', '.env.test', '.env.prod'];
420
+ return [];
421
+ }));
422
+ const result = (0, loader_1.listEnvFiles)(projectRoot);
423
+ expect(result).toEqual(expect.arrayContaining([
424
+ expect.objectContaining({ name: '(default)', location: 'standard' }),
425
+ expect.objectContaining({ name: 'prod', location: 'standard' }),
426
+ expect.objectContaining({ name: 'test', location: 'standard' }),
427
+ ]));
428
+ });
429
+ it('should return legacy files from project root', () => {
430
+ const projectRoot = '/my/project';
431
+ mockedFs.existsSync.mockImplementation((p) => {
432
+ const pathStr = String(p);
433
+ return pathStr === projectRoot; // envDir does not exist
434
+ });
435
+ mockedFs.readdirSync.mockImplementation(((p) => {
436
+ if (String(p) === projectRoot)
437
+ return ['.env', '.env.prod'];
438
+ return [];
439
+ }));
440
+ const result = (0, loader_1.listEnvFiles)(projectRoot);
441
+ expect(result).toEqual(expect.arrayContaining([
442
+ expect.objectContaining({ name: '(default)', file: '.env', location: 'legacy' }),
443
+ expect.objectContaining({ name: 'prod', file: '.env.prod', location: 'legacy' }),
444
+ ]));
445
+ });
446
+ it('should deduplicate (standard wins over legacy)', () => {
447
+ const projectRoot = '/my/project';
448
+ const envDir = path.join(projectRoot, '.fractary', 'env');
449
+ mockedFs.existsSync.mockReturnValue(true);
450
+ mockedFs.readdirSync.mockImplementation(((p) => {
451
+ if (String(p) === envDir)
452
+ return ['.env', '.env.test'];
453
+ if (String(p) === projectRoot)
454
+ return ['.env', '.env.test', '.env.prod'];
455
+ return [];
456
+ }));
457
+ const result = (0, loader_1.listEnvFiles)(projectRoot);
458
+ // .env and .env.test should be standard, .env.prod should be legacy
459
+ const envDefault = result.find((e) => e.name === '(default)');
460
+ const envTest = result.find((e) => e.name === 'test');
461
+ const envProd = result.find((e) => e.name === 'prod');
462
+ expect(envDefault?.location).toBe('standard');
463
+ expect(envTest?.location).toBe('standard');
464
+ expect(envProd?.location).toBe('legacy');
465
+ });
466
+ it('should exclude .env.example', () => {
467
+ const projectRoot = '/my/project';
468
+ const envDir = path.join(projectRoot, '.fractary', 'env');
469
+ mockedFs.existsSync.mockReturnValue(true);
470
+ mockedFs.readdirSync.mockImplementation(((p) => {
471
+ if (String(p) === envDir)
472
+ return ['.env', '.env.example'];
473
+ return [];
474
+ }));
475
+ const result = (0, loader_1.listEnvFiles)(projectRoot);
476
+ expect(result).toHaveLength(1);
477
+ expect(result[0].name).toBe('(default)');
478
+ });
479
+ });
480
+ describe('readManagedSection', () => {
481
+ beforeEach(() => {
482
+ jest.clearAllMocks();
483
+ });
484
+ it('should parse a plugin section correctly', () => {
485
+ const filePath = '/my/project/.fractary/env/.env.test';
486
+ const content = [
487
+ '# ===== fractary-core (managed) =====',
488
+ 'GITHUB_TOKEN=ghp_abc123',
489
+ 'AWS_REGION=us-east-1',
490
+ '# ===== end fractary-core =====',
491
+ ].join('\n');
492
+ mockedFs.existsSync.mockReturnValue(true);
493
+ mockedFs.readFileSync.mockReturnValue(content);
494
+ const result = (0, loader_1.readManagedSection)(filePath, 'fractary-core');
495
+ expect(result).toEqual({
496
+ GITHUB_TOKEN: 'ghp_abc123',
497
+ AWS_REGION: 'us-east-1',
498
+ });
499
+ });
500
+ it('should return null for missing section', () => {
501
+ const filePath = '/my/project/.fractary/env/.env.test';
502
+ const content = [
503
+ '# ===== fractary-other (managed) =====',
504
+ 'KEY=value',
505
+ '# ===== end fractary-other =====',
506
+ ].join('\n');
507
+ mockedFs.existsSync.mockReturnValue(true);
508
+ mockedFs.readFileSync.mockReturnValue(content);
509
+ const result = (0, loader_1.readManagedSection)(filePath, 'fractary-core');
510
+ expect(result).toBeNull();
511
+ });
512
+ it('should return null for missing file', () => {
513
+ mockedFs.existsSync.mockReturnValue(false);
514
+ const result = (0, loader_1.readManagedSection)('/nonexistent', 'fractary-core');
515
+ expect(result).toBeNull();
516
+ });
517
+ it('should skip comment lines within section', () => {
518
+ const content = [
519
+ '# ===== fractary-core (managed) =====',
520
+ 'GITHUB_TOKEN=ghp_abc123',
521
+ '# This is a comment',
522
+ '# AWS_ACCESS_KEY_ID=',
523
+ 'AWS_REGION=us-east-1',
524
+ '# ===== end fractary-core =====',
525
+ ].join('\n');
526
+ mockedFs.existsSync.mockReturnValue(true);
527
+ mockedFs.readFileSync.mockReturnValue(content);
528
+ const result = (0, loader_1.readManagedSection)('/file', 'fractary-core');
529
+ expect(result).toEqual({
530
+ GITHUB_TOKEN: 'ghp_abc123',
531
+ AWS_REGION: 'us-east-1',
532
+ });
533
+ });
534
+ it('should handle multiple sections in same file', () => {
535
+ const content = [
536
+ '# ===== fractary-core (managed) =====',
537
+ 'GITHUB_TOKEN=ghp_abc123',
538
+ '# ===== end fractary-core =====',
539
+ '',
540
+ '# ===== fractary-faber-cloud (managed) =====',
541
+ 'FABER_CLOUD_AWS_ACCOUNT_ID=123456789012',
542
+ '# ===== end fractary-faber-cloud =====',
543
+ ].join('\n');
544
+ mockedFs.existsSync.mockReturnValue(true);
545
+ mockedFs.readFileSync.mockReturnValue(content);
546
+ const core = (0, loader_1.readManagedSection)('/file', 'fractary-core');
547
+ const cloud = (0, loader_1.readManagedSection)('/file', 'fractary-faber-cloud');
548
+ expect(core).toEqual({ GITHUB_TOKEN: 'ghp_abc123' });
549
+ expect(cloud).toEqual({ FABER_CLOUD_AWS_ACCOUNT_ID: '123456789012' });
550
+ });
551
+ });
552
+ describe('writeManagedSection', () => {
553
+ beforeEach(() => {
554
+ jest.clearAllMocks();
555
+ });
556
+ it('should create file with section if file does not exist', () => {
557
+ mockedFs.existsSync.mockReturnValue(false);
558
+ (0, loader_1.writeManagedSection)('/file', 'fractary-core', {
559
+ GITHUB_TOKEN: 'ghp_abc123',
560
+ });
561
+ expect(mockedFs.mkdirSync).toHaveBeenCalled();
562
+ expect(mockedFs.writeFileSync).toHaveBeenCalledWith('/file', expect.stringContaining('# ===== fractary-core (managed) ====='), 'utf-8');
563
+ expect(mockedFs.writeFileSync).toHaveBeenCalledWith('/file', expect.stringContaining('GITHUB_TOKEN=ghp_abc123'), 'utf-8');
564
+ });
565
+ it('should append section if it does not exist in file', () => {
566
+ mockedFs.existsSync.mockImplementation((_p) => {
567
+ // Parent dir exists, file exists
568
+ return true;
569
+ });
570
+ mockedFs.readFileSync.mockReturnValue('EXISTING_VAR=value\n');
571
+ (0, loader_1.writeManagedSection)('/file', 'fractary-core', {
572
+ GITHUB_TOKEN: 'ghp_abc123',
573
+ });
574
+ const written = mockedFs.writeFileSync.mock.calls[0][1];
575
+ expect(written).toContain('EXISTING_VAR=value');
576
+ expect(written).toContain('# ===== fractary-core (managed) =====');
577
+ expect(written).toContain('GITHUB_TOKEN=ghp_abc123');
578
+ expect(written).toContain('# ===== end fractary-core =====');
579
+ });
580
+ it('should replace existing section without touching others', () => {
581
+ const existingContent = [
582
+ '# ===== fractary-core (managed) =====',
583
+ 'GITHUB_TOKEN=old_token',
584
+ '# ===== end fractary-core =====',
585
+ '',
586
+ '# ===== fractary-faber-cloud (managed) =====',
587
+ 'FABER_CLOUD_AWS_ACCOUNT_ID=123456789012',
588
+ '# ===== end fractary-faber-cloud =====',
589
+ ].join('\n');
590
+ mockedFs.existsSync.mockReturnValue(true);
591
+ mockedFs.readFileSync.mockReturnValue(existingContent);
592
+ (0, loader_1.writeManagedSection)('/file', 'fractary-core', {
593
+ GITHUB_TOKEN: 'new_token',
594
+ AWS_REGION: 'eu-west-1',
595
+ });
596
+ const written = mockedFs.writeFileSync.mock.calls[0][1];
597
+ // New values in core section
598
+ expect(written).toContain('GITHUB_TOKEN=new_token');
599
+ expect(written).toContain('AWS_REGION=eu-west-1');
600
+ // Old core value gone
601
+ expect(written).not.toContain('old_token');
602
+ // Other section untouched
603
+ expect(written).toContain('FABER_CLOUD_AWS_ACCOUNT_ID=123456789012');
604
+ expect(written).toContain('# ===== fractary-faber-cloud (managed) =====');
605
+ });
606
+ });
291
607
  describe('edge cases', () => {
292
608
  beforeEach(() => {
293
609
  jest.clearAllMocks();
294
610
  delete process.env.FRACTARY_ENV;
611
+ (0, loader_1.clearEnv)();
295
612
  });
296
613
  it('should still work when FRACTARY_ENV file does not exist', () => {
297
614
  const projectRoot = process.cwd();
298
615
  process.env.FRACTARY_ENV = 'nonexistent';
299
- // Only .env exists, not .env.nonexistent
300
616
  mockedFs.existsSync.mockImplementation((p) => {
301
617
  const pathStr = String(p);
302
- return pathStr === path.join(projectRoot, '.env');
618
+ return pathStr === path.join(projectRoot, '.fractary', 'env', '.env');
303
619
  });
304
620
  const result = (0, loader_1.loadEnv)({ force: true });
305
621
  expect(result).toBe(true);
306
622
  expect((0, loader_1.getCurrentEnv)()).toBe('nonexistent');
307
- // Should have loaded .env but not .env.nonexistent
308
623
  expect(mockedDotenv.config).toHaveBeenCalledWith({
309
- path: path.join(projectRoot, '.env'),
624
+ path: path.join(projectRoot, '.fractary', 'env', '.env'),
310
625
  override: true,
311
626
  });
312
627
  });
@@ -315,14 +630,12 @@ describe('edge cases', () => {
315
630
  process.env.FRACTARY_ENV = 'prod';
316
631
  mockedFs.existsSync.mockImplementation((p) => {
317
632
  const pathStr = String(p);
318
- // All three files exist
319
- return (pathStr === path.join(projectRoot, '.env') ||
320
- pathStr === path.join(projectRoot, '.env.prod') ||
321
- pathStr === path.join(projectRoot, '.env.local'));
633
+ return (pathStr === path.join(projectRoot, '.fractary', 'env', '.env') ||
634
+ pathStr === path.join(projectRoot, '.fractary', 'env', '.env.prod') ||
635
+ pathStr === path.join(projectRoot, '.fractary', 'env', '.env.local'));
322
636
  });
323
637
  (0, loader_1.loadEnv)({ force: true });
324
638
  const calls = mockedDotenv.config.mock.calls.map((c) => String(c[0]?.path || ''));
325
- // Verify order: .env → .env.prod → .env.local
326
639
  const envIndex = calls.findIndex((p) => p.endsWith('.env') && !p.includes('.prod') && !p.includes('.local'));
327
640
  const prodIndex = calls.findIndex((p) => p.endsWith('.env.prod'));
328
641
  const localIndex = calls.findIndex((p) => p.endsWith('.env.local'));
@@ -332,14 +645,11 @@ describe('edge cases', () => {
332
645
  });
333
646
  it('should allow loadEnv after clearEnv to reload files', () => {
334
647
  mockedFs.existsSync.mockReturnValue(true);
335
- // Initial load
336
648
  (0, loader_1.loadEnv)({ force: true });
337
649
  expect((0, loader_1.isEnvLoaded)()).toBe(true);
338
650
  const initialCallCount = mockedDotenv.config.mock.calls.length;
339
- // Clear
340
651
  (0, loader_1.clearEnv)();
341
652
  expect((0, loader_1.isEnvLoaded)()).toBe(false);
342
- // Reload should work without force (since envLoaded is false)
343
653
  (0, loader_1.loadEnv)();
344
654
  expect((0, loader_1.isEnvLoaded)()).toBe(true);
345
655
  expect(mockedDotenv.config.mock.calls.length).toBeGreaterThan(initialCallCount);