@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.
- package/dist/config/__tests__/loader.test.js +395 -85
- package/dist/config/__tests__/loader.test.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +7 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/loader.d.ts +84 -4
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +243 -18
- package/dist/config/loader.js.map +1 -1
- package/dist/work/providers/github.d.ts.map +1 -1
- package/dist/work/providers/github.js +43 -6
- package/dist/work/providers/github.js.map +1 -1
- package/package.json +1 -1
|
@@ -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('
|
|
68
|
-
it('should load .env from
|
|
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 ===
|
|
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:
|
|
79
|
+
path: standardPath,
|
|
76
80
|
override: true,
|
|
77
81
|
});
|
|
78
82
|
});
|
|
79
|
-
it('should
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
(
|
|
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
|
-
|
|
90
|
-
return
|
|
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('
|
|
117
|
-
it('should
|
|
118
|
-
const
|
|
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
|
-
|
|
109
|
+
// Standard location doesn't exist, only legacy
|
|
110
|
+
return String(p) === legacyPath;
|
|
121
111
|
});
|
|
122
|
-
const result = (0, loader_1.loadEnv)({
|
|
112
|
+
const result = (0, loader_1.loadEnv)({ force: true });
|
|
123
113
|
expect(result).toBe(true);
|
|
124
114
|
expect(mockedDotenv.config).toHaveBeenCalledWith({
|
|
125
|
-
path:
|
|
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('
|
|
131
|
-
it('should load .env
|
|
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
|
-
|
|
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:
|
|
158
|
+
path: standardEnv,
|
|
145
159
|
override: true,
|
|
146
160
|
});
|
|
147
161
|
expect(mockedDotenv.config).toHaveBeenCalledWith({
|
|
148
|
-
path:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
pathStr ===
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
pathStr === path.join(projectRoot, '.env.
|
|
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);
|