@idealyst/config 1.2.12 → 1.2.14
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/README.md +164 -84
- package/bin/idealyst-config.js +169 -64
- package/package.json +6 -1
- package/plugin.js +233 -0
- package/src/cli/generate.ts +140 -34
- package/src/config.web.ts +55 -17
- package/src/index.web.ts +12 -7
- package/src/vite-env.d.ts +0 -22
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# @idealyst/config
|
|
2
2
|
|
|
3
|
-
Cross-platform configuration
|
|
3
|
+
Cross-platform configuration for React and React Native with env inheritance support.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Single API** - Same code works on web and native
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
8
|
+
- **Env inheritance** - Shared config with platform-specific overrides
|
|
9
|
+
- **Babel plugin** - Config injected at build time, no runtime overhead
|
|
10
|
+
- **Type-safe** - Auto-generated TypeScript declarations
|
|
11
|
+
- **Monorepo friendly** - Designed for shared/web/mobile patterns
|
|
12
12
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
@@ -22,89 +22,148 @@ cd ios && pod install
|
|
|
22
22
|
|
|
23
23
|
## Quick Start
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
import { config } from '@idealyst/config'
|
|
27
|
-
|
|
28
|
-
// Get a config value
|
|
29
|
-
const apiUrl = config.get('API_URL')
|
|
30
|
-
|
|
31
|
-
// Get with default value
|
|
32
|
-
const port = config.get('PORT', '3000')
|
|
33
|
-
|
|
34
|
-
// Get required value (throws if missing)
|
|
35
|
-
const secret = config.getRequired('JWT_SECRET')
|
|
25
|
+
### 1. Create your .env files
|
|
36
26
|
|
|
37
|
-
|
|
38
|
-
|
|
27
|
+
```
|
|
28
|
+
my-app/
|
|
29
|
+
├── packages/
|
|
30
|
+
│ ├── shared/
|
|
31
|
+
│ │ └── .env # Base config (lowest priority)
|
|
32
|
+
│ ├── web/
|
|
33
|
+
│ │ └── .env # Web overrides
|
|
34
|
+
│ └── mobile/
|
|
35
|
+
│ └── .env # Mobile overrides
|
|
39
36
|
```
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
### React Native (.env)
|
|
44
|
-
|
|
38
|
+
**shared/.env:**
|
|
45
39
|
```bash
|
|
46
|
-
# No prefix needed
|
|
47
40
|
API_URL=https://api.example.com
|
|
48
41
|
GOOGLE_CLIENT_ID=abc123
|
|
49
|
-
|
|
42
|
+
ANALYTICS_ENABLED=true
|
|
50
43
|
```
|
|
51
44
|
|
|
52
|
-
|
|
45
|
+
**web/.env:**
|
|
46
|
+
```bash
|
|
47
|
+
# Override API for web
|
|
48
|
+
API_URL=https://web-api.example.com
|
|
49
|
+
```
|
|
53
50
|
|
|
51
|
+
**mobile/.env:**
|
|
54
52
|
```bash
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
VITE_GOOGLE_CLIENT_ID=abc123
|
|
58
|
-
VITE_JWT_SECRET=supersecret
|
|
53
|
+
# Mobile uses shared API_URL, but different analytics
|
|
54
|
+
ANALYTICS_ENABLED=false
|
|
59
55
|
```
|
|
60
56
|
|
|
61
|
-
|
|
57
|
+
### 2. Add Babel plugin
|
|
58
|
+
|
|
59
|
+
**babel.config.js:**
|
|
60
|
+
```js
|
|
61
|
+
module.exports = {
|
|
62
|
+
presets: ['...'],
|
|
63
|
+
plugins: [
|
|
64
|
+
['@idealyst/config/plugin', {
|
|
65
|
+
extends: ['../shared/.env'],
|
|
66
|
+
envPath: '.env'
|
|
67
|
+
}]
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 3. Use in your app
|
|
62
73
|
|
|
63
74
|
```typescript
|
|
64
|
-
|
|
75
|
+
import { config } from '@idealyst/config'
|
|
76
|
+
|
|
77
|
+
// Values are injected at build time!
|
|
65
78
|
const apiUrl = config.get('API_URL')
|
|
79
|
+
const analyticsEnabled = config.get('ANALYTICS_ENABLED') === 'true'
|
|
66
80
|
```
|
|
67
81
|
|
|
68
|
-
|
|
82
|
+
That's it! The Babel plugin reads your .env files at compile time and injects the values automatically.
|
|
69
83
|
|
|
70
|
-
|
|
84
|
+
## Babel Plugin Options
|
|
71
85
|
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
```js
|
|
87
|
+
['@idealyst/config/plugin', {
|
|
88
|
+
// Inherit from these .env files (lowest to highest priority)
|
|
89
|
+
extends: ['../shared/.env', '../common/.env'],
|
|
75
90
|
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
// Main .env file (highest priority, default: auto-detect)
|
|
92
|
+
envPath: '.env',
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
94
|
+
// Project root (default: process.cwd())
|
|
95
|
+
root: '/path/to/project'
|
|
96
|
+
}]
|
|
81
97
|
```
|
|
82
98
|
|
|
83
|
-
|
|
99
|
+
### Auto-detection
|
|
84
100
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
If you don't specify options, the plugin will:
|
|
102
|
+
1. Auto-detect `.env.local`, `.env.development`, or `.env` in your project
|
|
103
|
+
2. Auto-detect `../shared/.env` or `../../shared/.env` for inheritance
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
// Minimal config - auto-detects everything
|
|
107
|
+
plugins: [
|
|
108
|
+
'@idealyst/config/plugin'
|
|
109
|
+
]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Inheritance Priority
|
|
113
|
+
|
|
114
|
+
Configs are merged in order, with later files overriding earlier ones:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
1. extends[0]: ../shared/.env (lowest priority)
|
|
118
|
+
2. extends[1]: ../common/.env
|
|
119
|
+
3. envPath: .env (highest priority)
|
|
94
120
|
```
|
|
95
121
|
|
|
96
|
-
|
|
122
|
+
## Vite Setup
|
|
123
|
+
|
|
124
|
+
For Vite projects, add to `vite.config.ts`:
|
|
97
125
|
|
|
98
126
|
```typescript
|
|
99
|
-
|
|
100
|
-
|
|
127
|
+
import { defineConfig } from 'vite'
|
|
128
|
+
import react from '@vitejs/plugin-react'
|
|
129
|
+
|
|
130
|
+
export default defineConfig({
|
|
131
|
+
plugins: [
|
|
132
|
+
react({
|
|
133
|
+
babel: {
|
|
134
|
+
plugins: [
|
|
135
|
+
['@idealyst/config/plugin', {
|
|
136
|
+
extends: ['../shared/.env'],
|
|
137
|
+
envPath: '.env'
|
|
138
|
+
}]
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
]
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## React Native / Metro Setup
|
|
147
|
+
|
|
148
|
+
Add to `babel.config.js`:
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
module.exports = {
|
|
152
|
+
presets: ['module:@react-native/babel-preset'],
|
|
153
|
+
plugins: [
|
|
154
|
+
['@idealyst/config/plugin', {
|
|
155
|
+
extends: ['../shared/.env'],
|
|
156
|
+
envPath: '.env'
|
|
157
|
+
}]
|
|
158
|
+
]
|
|
159
|
+
}
|
|
101
160
|
```
|
|
102
161
|
|
|
103
162
|
## API Reference
|
|
104
163
|
|
|
105
164
|
### `config.get(key: string): string | undefined`
|
|
106
165
|
|
|
107
|
-
Get a
|
|
166
|
+
Get a config value.
|
|
108
167
|
|
|
109
168
|
```typescript
|
|
110
169
|
const apiUrl = config.get('API_URL')
|
|
@@ -112,7 +171,7 @@ const apiUrl = config.get('API_URL')
|
|
|
112
171
|
|
|
113
172
|
### `config.get(key: string, defaultValue: string): string`
|
|
114
173
|
|
|
115
|
-
Get
|
|
174
|
+
Get with fallback default.
|
|
116
175
|
|
|
117
176
|
```typescript
|
|
118
177
|
const port = config.get('PORT', '3000')
|
|
@@ -120,16 +179,15 @@ const port = config.get('PORT', '3000')
|
|
|
120
179
|
|
|
121
180
|
### `config.getRequired(key: string): string`
|
|
122
181
|
|
|
123
|
-
Get
|
|
182
|
+
Get required value. Throws if not defined.
|
|
124
183
|
|
|
125
184
|
```typescript
|
|
126
185
|
const secret = config.getRequired('JWT_SECRET')
|
|
127
|
-
// Throws: 'Required config key "JWT_SECRET" is not defined'
|
|
128
186
|
```
|
|
129
187
|
|
|
130
188
|
### `config.has(key: string): boolean`
|
|
131
189
|
|
|
132
|
-
Check if
|
|
190
|
+
Check if key exists.
|
|
133
191
|
|
|
134
192
|
```typescript
|
|
135
193
|
if (config.has('DEBUG')) {
|
|
@@ -137,54 +195,76 @@ if (config.has('DEBUG')) {
|
|
|
137
195
|
}
|
|
138
196
|
```
|
|
139
197
|
|
|
140
|
-
### `config.
|
|
198
|
+
### `config.validate(requiredKeys: string[]): void`
|
|
141
199
|
|
|
142
|
-
|
|
200
|
+
Validate required keys at startup.
|
|
143
201
|
|
|
144
202
|
```typescript
|
|
145
|
-
|
|
203
|
+
config.validate(['API_URL', 'AUTH_SECRET'])
|
|
204
|
+
// Throws ConfigValidationError if any are missing
|
|
146
205
|
```
|
|
147
206
|
|
|
148
|
-
|
|
207
|
+
## Type Generation (Optional)
|
|
208
|
+
|
|
209
|
+
For TypeScript autocomplete, generate type declarations:
|
|
149
210
|
|
|
150
|
-
|
|
211
|
+
```bash
|
|
212
|
+
npx idealyst-config generate --extends ../shared/.env --env .env --types-only
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
This creates `src/config.generated.d.ts`:
|
|
151
216
|
|
|
152
217
|
```typescript
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
console.error('Missing config:', error.missingKeys)
|
|
218
|
+
declare module '@idealyst/config' {
|
|
219
|
+
interface ConfigKeys {
|
|
220
|
+
API_URL: string
|
|
221
|
+
GOOGLE_CLIENT_ID: string
|
|
222
|
+
ANALYTICS_ENABLED: string
|
|
159
223
|
}
|
|
160
224
|
}
|
|
161
225
|
```
|
|
162
226
|
|
|
163
|
-
|
|
227
|
+
Add to your build script for automatic updates:
|
|
164
228
|
|
|
165
|
-
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"scripts": {
|
|
232
|
+
"prebuild": "idealyst-config generate --extends ../shared/.env --types-only"
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
166
236
|
|
|
167
|
-
|
|
168
|
-
- Your code: `config.get('API_URL')`
|
|
169
|
-
- Internal lookup: `import.meta.env.VITE_API_URL`
|
|
237
|
+
## How It Works
|
|
170
238
|
|
|
171
|
-
|
|
239
|
+
The Babel plugin transforms your code at compile time:
|
|
172
240
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
241
|
+
**Input:**
|
|
242
|
+
```typescript
|
|
243
|
+
import { config } from '@idealyst/config'
|
|
176
244
|
|
|
177
|
-
|
|
245
|
+
const apiUrl = config.get('API_URL')
|
|
246
|
+
```
|
|
178
247
|
|
|
179
|
-
|
|
248
|
+
**Output (after Babel):**
|
|
249
|
+
```typescript
|
|
250
|
+
import { config, setConfig as __idealyst_setConfig } from '@idealyst/config'
|
|
251
|
+
__idealyst_setConfig({ API_URL: "https://api.example.com", GOOGLE_CLIENT_ID: "abc123" })
|
|
180
252
|
|
|
181
|
-
|
|
253
|
+
const apiUrl = config.get('API_URL')
|
|
254
|
+
```
|
|
182
255
|
|
|
183
|
-
|
|
256
|
+
This means:
|
|
257
|
+
- **No runtime .env parsing** - values are baked in at build time
|
|
258
|
+
- **Works with any bundler** - Vite, Webpack, Metro, esbuild
|
|
259
|
+
- **Tree-shakeable** - unused config keys can be eliminated
|
|
260
|
+
- **Secure** - .env files never shipped to client
|
|
184
261
|
|
|
185
|
-
|
|
262
|
+
## Best Practices
|
|
186
263
|
|
|
187
|
-
|
|
264
|
+
1. **Gitignore .env files** - Never commit secrets
|
|
265
|
+
2. **Commit .env.example** - Document required keys
|
|
266
|
+
3. **Use shared config** - DRY principle for common values
|
|
267
|
+
4. **Validate at startup** - Catch missing config early
|
|
188
268
|
|
|
189
269
|
## License
|
|
190
270
|
|
package/bin/idealyst-config.js
CHANGED
|
@@ -1,60 +1,93 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* CLI for @idealyst/config - Generate
|
|
4
|
+
* CLI for @idealyst/config - Generate config from .env files with inheritance
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Supports monorepo patterns with shared config:
|
|
7
|
+
* shared/.env → base config (lowest priority)
|
|
8
|
+
* web/.env → web overrides
|
|
9
|
+
* mobile/.env → mobile overrides
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
const fs = require('fs')
|
|
11
13
|
const path = require('path')
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
|
-
* Parse a .env file and extract
|
|
15
|
-
* Strips VITE_ prefix to normalize to canonical names.
|
|
16
|
+
* Parse a .env file and extract key-value pairs.
|
|
16
17
|
*/
|
|
17
|
-
function parseEnvFile(
|
|
18
|
-
|
|
18
|
+
function parseEnvFile(filePath) {
|
|
19
|
+
if (!fs.existsSync(filePath)) {
|
|
20
|
+
return {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
24
|
+
const config = {}
|
|
19
25
|
|
|
20
26
|
for (const line of content.split('\n')) {
|
|
21
27
|
const trimmed = line.trim()
|
|
22
28
|
|
|
23
|
-
// Skip empty lines and comments
|
|
24
29
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
25
30
|
continue
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
// Extract key name (everything before the first =)
|
|
29
33
|
const equalsIndex = trimmed.indexOf('=')
|
|
30
34
|
if (equalsIndex === -1) {
|
|
31
35
|
continue
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
let key = trimmed.substring(0, equalsIndex).trim()
|
|
39
|
+
let value = trimmed.substring(equalsIndex + 1).trim()
|
|
40
|
+
|
|
41
|
+
// Remove quotes if present
|
|
42
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
43
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
44
|
+
value = value.slice(1, -1)
|
|
45
|
+
}
|
|
35
46
|
|
|
36
|
-
// Strip VITE_ prefix to normalize
|
|
47
|
+
// Strip VITE_ prefix to normalize
|
|
37
48
|
if (key.startsWith('VITE_')) {
|
|
38
49
|
key = key.substring(5)
|
|
39
50
|
}
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
if (key && !keys.includes(key)) {
|
|
43
|
-
keys.push(key)
|
|
44
|
-
}
|
|
52
|
+
config[key] = value
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
return
|
|
55
|
+
return config
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
/**
|
|
51
|
-
* Generate TypeScript
|
|
59
|
+
* Generate TypeScript config module with actual values.
|
|
52
60
|
*/
|
|
53
|
-
function
|
|
61
|
+
function generateConfigModule(config, sourceFiles) {
|
|
62
|
+
const sources = sourceFiles.join(', ')
|
|
63
|
+
const entries = Object.entries(config)
|
|
64
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
65
|
+
.map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)
|
|
66
|
+
.join(',\n')
|
|
67
|
+
|
|
68
|
+
return `// Auto-generated by @idealyst/config - DO NOT EDIT
|
|
69
|
+
// Sources: ${sources}
|
|
70
|
+
// Run \`idealyst-config generate\` to regenerate
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generated configuration values.
|
|
74
|
+
* Merged from: ${sources}
|
|
75
|
+
*/
|
|
76
|
+
export const generatedConfig: Record<string, string> = {
|
|
77
|
+
${entries}
|
|
78
|
+
}
|
|
79
|
+
`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate TypeScript declaration file.
|
|
84
|
+
*/
|
|
85
|
+
function generateDeclaration(keys, sourceFiles) {
|
|
54
86
|
const keyDefinitions = keys.map(k => ` ${k}: string`).join('\n')
|
|
87
|
+
const sources = sourceFiles.join(', ')
|
|
55
88
|
|
|
56
89
|
return `// Auto-generated by @idealyst/config - DO NOT EDIT
|
|
57
|
-
//
|
|
90
|
+
// Sources: ${sources}
|
|
58
91
|
// Run \`idealyst-config generate\` to regenerate
|
|
59
92
|
|
|
60
93
|
declare module '@idealyst/config' {
|
|
@@ -76,42 +109,78 @@ export {}
|
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
/**
|
|
79
|
-
* Find
|
|
112
|
+
* Find .env file in directory.
|
|
80
113
|
*/
|
|
81
114
|
function findEnvFile(directory) {
|
|
82
115
|
const candidates = ['.env.local', '.env.development', '.env']
|
|
83
|
-
|
|
84
116
|
for (const candidate of candidates) {
|
|
85
117
|
const envPath = path.join(directory, candidate)
|
|
86
118
|
if (fs.existsSync(envPath)) {
|
|
87
119
|
return envPath
|
|
88
120
|
}
|
|
89
121
|
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
90
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Look for shared .env in common monorepo locations.
|
|
127
|
+
*/
|
|
128
|
+
function findSharedEnv(directory) {
|
|
129
|
+
const patterns = [
|
|
130
|
+
'../shared/.env',
|
|
131
|
+
'../../shared/.env',
|
|
132
|
+
'../../packages/shared/.env',
|
|
133
|
+
'../.env.shared',
|
|
134
|
+
]
|
|
135
|
+
for (const pattern of patterns) {
|
|
136
|
+
const sharedPath = path.resolve(directory, pattern)
|
|
137
|
+
if (fs.existsSync(sharedPath)) {
|
|
138
|
+
return sharedPath
|
|
139
|
+
}
|
|
140
|
+
}
|
|
91
141
|
return null
|
|
92
142
|
}
|
|
93
143
|
|
|
94
144
|
function printUsage() {
|
|
95
145
|
console.log(`
|
|
96
|
-
@idealyst/config - Generate
|
|
146
|
+
@idealyst/config - Generate config from .env files with inheritance
|
|
97
147
|
|
|
98
148
|
Usage:
|
|
99
149
|
idealyst-config generate [options]
|
|
100
150
|
|
|
101
151
|
Options:
|
|
102
|
-
--env <path>
|
|
103
|
-
--
|
|
104
|
-
--
|
|
152
|
+
--env <path> Path to .env file (default: auto-detect)
|
|
153
|
+
--extends <path> Inherit from another .env file (can use multiple times)
|
|
154
|
+
--output <path> Output path for generated config (default: src/config.generated.ts)
|
|
155
|
+
--types-only Generate only .d.ts file, no values
|
|
156
|
+
--help Show this help message
|
|
105
157
|
|
|
106
158
|
Examples:
|
|
159
|
+
# Simple usage - auto-detect .env
|
|
107
160
|
idealyst-config generate
|
|
108
|
-
|
|
109
|
-
|
|
161
|
+
|
|
162
|
+
# With shared config inheritance
|
|
163
|
+
idealyst-config generate --extends ../shared/.env --env .env
|
|
164
|
+
|
|
165
|
+
# Multiple inheritance (lowest to highest priority)
|
|
166
|
+
idealyst-config generate --extends ../../shared/.env --extends ../.env.common --env .env
|
|
167
|
+
|
|
168
|
+
# Types only (for type checking without exposing values)
|
|
169
|
+
idealyst-config generate --types-only --output src/env.d.ts
|
|
170
|
+
|
|
171
|
+
Inheritance:
|
|
172
|
+
In a monorepo with shared/web/mobile packages, you can set up inheritance:
|
|
173
|
+
|
|
174
|
+
shared/.env → API_URL=https://api.example.com
|
|
175
|
+
web/.env → API_URL=https://web-api.example.com (overrides shared)
|
|
176
|
+
mobile/.env → (inherits API_URL from shared)
|
|
177
|
+
|
|
178
|
+
The --extends flag loads configs in order, with later files taking priority.
|
|
110
179
|
`)
|
|
111
180
|
}
|
|
112
181
|
|
|
113
182
|
function parseArgs(args) {
|
|
114
|
-
const result = {}
|
|
183
|
+
const result = { extends: [] }
|
|
115
184
|
|
|
116
185
|
for (let i = 0; i < args.length; i++) {
|
|
117
186
|
const arg = args[i]
|
|
@@ -120,8 +189,12 @@ function parseArgs(args) {
|
|
|
120
189
|
result.help = true
|
|
121
190
|
} else if (arg === '--env' && args[i + 1]) {
|
|
122
191
|
result.env = args[++i]
|
|
192
|
+
} else if (arg === '--extends' && args[i + 1]) {
|
|
193
|
+
result.extends.push(args[++i])
|
|
123
194
|
} else if (arg === '--output' && args[i + 1]) {
|
|
124
195
|
result.output = args[++i]
|
|
196
|
+
} else if (arg === '--types-only') {
|
|
197
|
+
result.typesOnly = true
|
|
125
198
|
} else if (!arg.startsWith('-') && !result.command) {
|
|
126
199
|
result.command = arg
|
|
127
200
|
}
|
|
@@ -145,59 +218,91 @@ function main() {
|
|
|
145
218
|
}
|
|
146
219
|
|
|
147
220
|
const cwd = process.cwd()
|
|
221
|
+
const sourceFiles = []
|
|
222
|
+
const configs = []
|
|
223
|
+
|
|
224
|
+
// Load inherited configs first (lowest priority)
|
|
225
|
+
for (const extendPath of args.extends) {
|
|
226
|
+
const resolvedPath = path.isAbsolute(extendPath)
|
|
227
|
+
? extendPath
|
|
228
|
+
: path.resolve(cwd, extendPath)
|
|
229
|
+
|
|
230
|
+
if (fs.existsSync(resolvedPath)) {
|
|
231
|
+
configs.push(parseEnvFile(resolvedPath))
|
|
232
|
+
sourceFiles.push(path.relative(cwd, resolvedPath))
|
|
233
|
+
console.log(` ← ${path.relative(cwd, resolvedPath)}`)
|
|
234
|
+
} else {
|
|
235
|
+
console.warn(`Warning: Inherited env file not found: ${resolvedPath}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
148
238
|
|
|
149
|
-
//
|
|
239
|
+
// Auto-detect shared env if no extends specified
|
|
240
|
+
if (args.extends.length === 0) {
|
|
241
|
+
const sharedEnv = findSharedEnv(cwd)
|
|
242
|
+
if (sharedEnv) {
|
|
243
|
+
configs.push(parseEnvFile(sharedEnv))
|
|
244
|
+
sourceFiles.push(path.relative(cwd, sharedEnv))
|
|
245
|
+
console.log(` ← ${path.relative(cwd, sharedEnv)} (auto-detected shared)`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Load main env file (highest priority)
|
|
150
250
|
let envPath
|
|
151
251
|
if (args.env) {
|
|
152
|
-
envPath = path.isAbsolute(args.env) ? args.env : path.
|
|
252
|
+
envPath = path.isAbsolute(args.env) ? args.env : path.resolve(cwd, args.env)
|
|
153
253
|
} else {
|
|
154
254
|
envPath = findEnvFile(cwd)
|
|
155
|
-
if (!envPath) {
|
|
156
|
-
console.error('Error: No .env file found in current directory.')
|
|
157
|
-
console.error('Create a .env file or specify one with --env <path>')
|
|
158
|
-
process.exit(1)
|
|
159
|
-
}
|
|
160
255
|
}
|
|
161
256
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
257
|
+
if (envPath && fs.existsSync(envPath)) {
|
|
258
|
+
configs.push(parseEnvFile(envPath))
|
|
259
|
+
sourceFiles.push(path.relative(cwd, envPath))
|
|
260
|
+
console.log(` ← ${path.relative(cwd, envPath)}`)
|
|
261
|
+
} else if (args.env) {
|
|
262
|
+
console.error(`Error: Environment file not found: ${args.env}`)
|
|
165
263
|
process.exit(1)
|
|
166
264
|
}
|
|
167
265
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
try {
|
|
174
|
-
// Read and parse env file
|
|
175
|
-
const envContent = fs.readFileSync(envPath, 'utf-8')
|
|
176
|
-
const keys = parseEnvFile(envContent)
|
|
266
|
+
if (configs.length === 0) {
|
|
267
|
+
console.error('Error: No .env files found.')
|
|
268
|
+
console.error('Create a .env file or specify one with --env <path>')
|
|
269
|
+
process.exit(1)
|
|
270
|
+
}
|
|
177
271
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
272
|
+
// Merge configs (later configs override earlier ones)
|
|
273
|
+
const mergedConfig = Object.assign({}, ...configs)
|
|
274
|
+
const keys = Object.keys(mergedConfig).sort()
|
|
181
275
|
|
|
182
|
-
|
|
183
|
-
|
|
276
|
+
// Determine output path
|
|
277
|
+
const outputPath = args.output
|
|
278
|
+
? (path.isAbsolute(args.output) ? args.output : path.resolve(cwd, args.output))
|
|
279
|
+
: path.resolve(cwd, 'src', 'config.generated.ts')
|
|
184
280
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
281
|
+
// Ensure output directory exists
|
|
282
|
+
const outputDir = path.dirname(outputPath)
|
|
283
|
+
if (!fs.existsSync(outputDir)) {
|
|
284
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
285
|
+
}
|
|
190
286
|
|
|
191
|
-
|
|
287
|
+
if (args.typesOnly) {
|
|
288
|
+
// Generate declaration file only
|
|
289
|
+
const declaration = generateDeclaration(keys, sourceFiles)
|
|
192
290
|
fs.writeFileSync(outputPath, declaration)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
console.
|
|
199
|
-
|
|
291
|
+
console.log(`\nGenerated types at: ${path.relative(cwd, outputPath)}`)
|
|
292
|
+
} else {
|
|
293
|
+
// Generate config module
|
|
294
|
+
const module = generateConfigModule(mergedConfig, sourceFiles)
|
|
295
|
+
fs.writeFileSync(outputPath, module)
|
|
296
|
+
console.log(`\nGenerated config at: ${path.relative(cwd, outputPath)}`)
|
|
297
|
+
|
|
298
|
+
// Also generate declaration file
|
|
299
|
+
const declPath = outputPath.replace(/\.ts$/, '.d.ts')
|
|
300
|
+
const declaration = generateDeclaration(keys, sourceFiles)
|
|
301
|
+
fs.writeFileSync(declPath, declaration)
|
|
302
|
+
console.log(`Generated types at: ${path.relative(cwd, declPath)}`)
|
|
200
303
|
}
|
|
304
|
+
|
|
305
|
+
console.log(`Keys: ${keys.join(', ')}`)
|
|
201
306
|
}
|
|
202
307
|
|
|
203
308
|
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idealyst/config",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.14",
|
|
4
4
|
"description": "Cross-platform configuration and environment variable support for React and React Native",
|
|
5
5
|
"documentation": "https://github.com/IdealystIO/idealyst-framework/tree/main/packages/config#readme",
|
|
6
6
|
"readme": "README.md",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"require": "./src/index.ts"
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
|
+
"./plugin": {
|
|
39
|
+
"require": "./plugin.js",
|
|
40
|
+
"default": "./plugin.js"
|
|
41
|
+
},
|
|
38
42
|
"./generate": {
|
|
39
43
|
"types": "./src/cli/generate.ts",
|
|
40
44
|
"import": "./src/cli/generate.ts",
|
|
@@ -61,6 +65,7 @@
|
|
|
61
65
|
"files": [
|
|
62
66
|
"src",
|
|
63
67
|
"bin",
|
|
68
|
+
"plugin.js",
|
|
64
69
|
"README.md"
|
|
65
70
|
],
|
|
66
71
|
"keywords": [
|
package/plugin.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @idealyst/config Babel plugin
|
|
3
|
+
*
|
|
4
|
+
* Injects config values at compile time from .env files.
|
|
5
|
+
*
|
|
6
|
+
* Usage in babel.config.js:
|
|
7
|
+
* ```js
|
|
8
|
+
* module.exports = {
|
|
9
|
+
* plugins: [
|
|
10
|
+
* ['@idealyst/config/plugin', {
|
|
11
|
+
* extends: ['../shared/.env'],
|
|
12
|
+
* envPath: '.env'
|
|
13
|
+
* }]
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs')
|
|
20
|
+
const path = require('path')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a .env file and extract key-value pairs.
|
|
24
|
+
*/
|
|
25
|
+
function parseEnvFile(filePath) {
|
|
26
|
+
if (!fs.existsSync(filePath)) {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
31
|
+
const config = {}
|
|
32
|
+
|
|
33
|
+
for (const line of content.split('\n')) {
|
|
34
|
+
const trimmed = line.trim()
|
|
35
|
+
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const equalsIndex = trimmed.indexOf('=')
|
|
41
|
+
if (equalsIndex === -1) {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let key = trimmed.substring(0, equalsIndex).trim()
|
|
46
|
+
let value = trimmed.substring(equalsIndex + 1).trim()
|
|
47
|
+
|
|
48
|
+
// Remove quotes if present
|
|
49
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
50
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
51
|
+
value = value.slice(1, -1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Strip VITE_ prefix to normalize
|
|
55
|
+
if (key.startsWith('VITE_')) {
|
|
56
|
+
key = key.substring(5)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
config[key] = value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return config
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find .env file in directory.
|
|
67
|
+
*/
|
|
68
|
+
function findEnvFile(directory) {
|
|
69
|
+
const candidates = ['.env.local', '.env.development', '.env']
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
const envPath = path.join(directory, candidate)
|
|
72
|
+
if (fs.existsSync(envPath)) {
|
|
73
|
+
return envPath
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Look for shared .env in common monorepo locations.
|
|
81
|
+
*/
|
|
82
|
+
function findSharedEnv(directory) {
|
|
83
|
+
const patterns = [
|
|
84
|
+
'../shared/.env',
|
|
85
|
+
'../../shared/.env',
|
|
86
|
+
'../../packages/shared/.env',
|
|
87
|
+
]
|
|
88
|
+
for (const pattern of patterns) {
|
|
89
|
+
const sharedPath = path.resolve(directory, pattern)
|
|
90
|
+
if (fs.existsSync(sharedPath)) {
|
|
91
|
+
return sharedPath
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Load and merge config from .env files.
|
|
99
|
+
*/
|
|
100
|
+
function loadConfig(options, projectRoot) {
|
|
101
|
+
const configs = []
|
|
102
|
+
|
|
103
|
+
// Load inherited configs first (lowest priority)
|
|
104
|
+
if (options.extends) {
|
|
105
|
+
for (const extendPath of options.extends) {
|
|
106
|
+
const resolvedPath = path.isAbsolute(extendPath)
|
|
107
|
+
? extendPath
|
|
108
|
+
: path.resolve(projectRoot, extendPath)
|
|
109
|
+
|
|
110
|
+
if (fs.existsSync(resolvedPath)) {
|
|
111
|
+
configs.push(parseEnvFile(resolvedPath))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// Auto-detect shared env
|
|
116
|
+
const sharedEnv = findSharedEnv(projectRoot)
|
|
117
|
+
if (sharedEnv) {
|
|
118
|
+
configs.push(parseEnvFile(sharedEnv))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Load main env file (highest priority)
|
|
123
|
+
let envPath = null
|
|
124
|
+
if (options.envPath) {
|
|
125
|
+
envPath = path.isAbsolute(options.envPath)
|
|
126
|
+
? options.envPath
|
|
127
|
+
: path.resolve(projectRoot, options.envPath)
|
|
128
|
+
} else {
|
|
129
|
+
envPath = findEnvFile(projectRoot)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (envPath && fs.existsSync(envPath)) {
|
|
133
|
+
configs.push(parseEnvFile(envPath))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Merge configs (later configs override earlier ones)
|
|
137
|
+
return Object.assign({}, ...configs)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Babel plugin that injects config values at compile time.
|
|
142
|
+
*/
|
|
143
|
+
module.exports = function babelPluginIdealystConfig(babel) {
|
|
144
|
+
const { types: t } = babel
|
|
145
|
+
|
|
146
|
+
// Cache config per project root
|
|
147
|
+
const configCache = new Map()
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
name: '@idealyst/config',
|
|
151
|
+
|
|
152
|
+
visitor: {
|
|
153
|
+
Program: {
|
|
154
|
+
enter(programPath, state) {
|
|
155
|
+
const opts = state.opts || {}
|
|
156
|
+
const projectRoot = opts.root || state.cwd || process.cwd()
|
|
157
|
+
|
|
158
|
+
// Load config (cached per project root)
|
|
159
|
+
if (!configCache.has(projectRoot)) {
|
|
160
|
+
configCache.set(projectRoot, loadConfig(opts, projectRoot))
|
|
161
|
+
}
|
|
162
|
+
const configValues = configCache.get(projectRoot)
|
|
163
|
+
|
|
164
|
+
// Track if this file imports from @idealyst/config
|
|
165
|
+
let hasConfigImport = false
|
|
166
|
+
let configImportPath = null
|
|
167
|
+
|
|
168
|
+
// Find imports from @idealyst/config
|
|
169
|
+
programPath.traverse({
|
|
170
|
+
ImportDeclaration(importPath) {
|
|
171
|
+
if (importPath.node.source.value === '@idealyst/config') {
|
|
172
|
+
hasConfigImport = true
|
|
173
|
+
configImportPath = importPath
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
if (!hasConfigImport || !configImportPath) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check if setConfig is already imported
|
|
183
|
+
let hasSetConfigImport = false
|
|
184
|
+
let setConfigLocalName = '__idealyst_setConfig'
|
|
185
|
+
|
|
186
|
+
for (const specifier of configImportPath.node.specifiers) {
|
|
187
|
+
if (t.isImportSpecifier(specifier) &&
|
|
188
|
+
t.isIdentifier(specifier.imported) &&
|
|
189
|
+
specifier.imported.name === 'setConfig') {
|
|
190
|
+
hasSetConfigImport = true
|
|
191
|
+
setConfigLocalName = specifier.local.name
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Add setConfig to imports if not already present
|
|
197
|
+
if (!hasSetConfigImport) {
|
|
198
|
+
configImportPath.node.specifiers.push(
|
|
199
|
+
t.importSpecifier(
|
|
200
|
+
t.identifier('__idealyst_setConfig'),
|
|
201
|
+
t.identifier('setConfig')
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Create the config object literal
|
|
207
|
+
const configProperties = Object.entries(configValues).map(([key, value]) =>
|
|
208
|
+
t.objectProperty(t.identifier(key), t.stringLiteral(value))
|
|
209
|
+
)
|
|
210
|
+
const configObject = t.objectExpression(configProperties)
|
|
211
|
+
|
|
212
|
+
// Create setConfig call
|
|
213
|
+
const setConfigCall = t.expressionStatement(
|
|
214
|
+
t.callExpression(
|
|
215
|
+
t.identifier(setConfigLocalName),
|
|
216
|
+
[configObject]
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
// Insert after the import statement
|
|
221
|
+
const importIndex = programPath.node.body.indexOf(configImportPath.node)
|
|
222
|
+
programPath.node.body.splice(importIndex + 1, 0, setConfigCall)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Export utilities for direct use
|
|
230
|
+
module.exports.parseEnvFile = parseEnvFile
|
|
231
|
+
module.exports.loadConfig = loadConfig
|
|
232
|
+
module.exports.findEnvFile = findEnvFile
|
|
233
|
+
module.exports.findSharedEnv = findSharedEnv
|
package/src/cli/generate.ts
CHANGED
|
@@ -3,22 +3,38 @@ import path from 'path'
|
|
|
3
3
|
|
|
4
4
|
export interface GenerateOptions {
|
|
5
5
|
/**
|
|
6
|
-
* Path to the .env file
|
|
6
|
+
* Path to the platform-specific .env file (highest priority)
|
|
7
7
|
*/
|
|
8
|
-
envPath
|
|
8
|
+
envPath?: string
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Paths to inherited .env files (lowest to highest priority)
|
|
12
|
+
* e.g., ['../shared/.env'] - shared is loaded first, then envPath overrides
|
|
13
|
+
*/
|
|
14
|
+
inheritFrom?: string[]
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Path to write the generated TypeScript config file
|
|
12
18
|
*/
|
|
13
19
|
outputPath: string
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether to generate types only (declaration file) or full config module
|
|
23
|
+
*/
|
|
24
|
+
typesOnly?: boolean
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
/**
|
|
17
|
-
* Parse a .env file and extract
|
|
28
|
+
* Parse a .env file and extract key-value pairs.
|
|
18
29
|
* Strips VITE_ prefix to normalize to canonical names.
|
|
19
30
|
*/
|
|
20
|
-
export function parseEnvFile(
|
|
21
|
-
|
|
31
|
+
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
32
|
+
if (!fs.existsSync(filePath)) {
|
|
33
|
+
return {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
37
|
+
const config: Record<string, string> = {}
|
|
22
38
|
|
|
23
39
|
for (const line of content.split('\n')) {
|
|
24
40
|
const trimmed = line.trim()
|
|
@@ -28,36 +44,48 @@ export function parseEnvFile(content: string): string[] {
|
|
|
28
44
|
continue
|
|
29
45
|
}
|
|
30
46
|
|
|
31
|
-
// Extract key
|
|
47
|
+
// Extract key=value
|
|
32
48
|
const equalsIndex = trimmed.indexOf('=')
|
|
33
49
|
if (equalsIndex === -1) {
|
|
34
50
|
continue
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
let key = trimmed.substring(0, equalsIndex).trim()
|
|
54
|
+
let value = trimmed.substring(equalsIndex + 1).trim()
|
|
55
|
+
|
|
56
|
+
// Remove quotes if present
|
|
57
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
58
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
59
|
+
value = value.slice(1, -1)
|
|
60
|
+
}
|
|
38
61
|
|
|
39
62
|
// Strip VITE_ prefix to normalize to canonical names
|
|
40
63
|
if (key.startsWith('VITE_')) {
|
|
41
64
|
key = key.substring(5)
|
|
42
65
|
}
|
|
43
66
|
|
|
44
|
-
|
|
45
|
-
if (key && !keys.includes(key)) {
|
|
46
|
-
keys.push(key)
|
|
47
|
-
}
|
|
67
|
+
config[key] = value
|
|
48
68
|
}
|
|
49
69
|
|
|
50
|
-
return
|
|
70
|
+
return config
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
/**
|
|
54
|
-
*
|
|
74
|
+
* Merge multiple env configs with later configs taking priority.
|
|
55
75
|
*/
|
|
56
|
-
export function
|
|
76
|
+
export function mergeEnvConfigs(...configs: Record<string, string>[]): Record<string, string> {
|
|
77
|
+
return Object.assign({}, ...configs)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generate TypeScript declaration content from config keys.
|
|
82
|
+
*/
|
|
83
|
+
export function generateDeclaration(keys: string[], sourceFiles: string[]): string {
|
|
57
84
|
const keyDefinitions = keys.map(k => ` ${k}: string`).join('\n')
|
|
85
|
+
const sources = sourceFiles.join(', ')
|
|
58
86
|
|
|
59
87
|
return `// Auto-generated by @idealyst/config - DO NOT EDIT
|
|
60
|
-
//
|
|
88
|
+
// Sources: ${sources}
|
|
61
89
|
// Run \`idealyst-config generate\` to regenerate
|
|
62
90
|
|
|
63
91
|
declare module '@idealyst/config' {
|
|
@@ -79,42 +107,98 @@ export {}
|
|
|
79
107
|
}
|
|
80
108
|
|
|
81
109
|
/**
|
|
82
|
-
* Generate TypeScript config
|
|
83
|
-
*
|
|
84
|
-
* @param options - Generation options
|
|
85
|
-
* @returns The path to the generated file
|
|
110
|
+
* Generate a TypeScript config module with actual values.
|
|
86
111
|
*/
|
|
87
|
-
export function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
112
|
+
export function generateConfigModule(config: Record<string, string>, sourceFiles: string[]): string {
|
|
113
|
+
const sources = sourceFiles.join(', ')
|
|
114
|
+
const entries = Object.entries(config)
|
|
115
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
116
|
+
.map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)
|
|
117
|
+
.join(',\n')
|
|
118
|
+
|
|
119
|
+
return `// Auto-generated by @idealyst/config - DO NOT EDIT
|
|
120
|
+
// Sources: ${sources}
|
|
121
|
+
// Run \`idealyst-config generate\` to regenerate
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generated configuration values.
|
|
125
|
+
* Merged from: ${sources}
|
|
126
|
+
*/
|
|
127
|
+
export const generatedConfig: Record<string, string> = {
|
|
128
|
+
${entries}
|
|
129
|
+
}
|
|
130
|
+
`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generate config from .env files with inheritance support.
|
|
135
|
+
*/
|
|
136
|
+
export function generateConfigTypes(options: GenerateOptions): { outputPath: string; keys: string[] } {
|
|
137
|
+
const sourceFiles: string[] = []
|
|
138
|
+
const configs: Record<string, string>[] = []
|
|
139
|
+
|
|
140
|
+
// Load inherited configs first (lowest priority)
|
|
141
|
+
if (options.inheritFrom) {
|
|
142
|
+
for (const inheritPath of options.inheritFrom) {
|
|
143
|
+
const resolvedPath = path.isAbsolute(inheritPath)
|
|
144
|
+
? inheritPath
|
|
145
|
+
: path.resolve(path.dirname(options.outputPath), inheritPath)
|
|
146
|
+
|
|
147
|
+
if (fs.existsSync(resolvedPath)) {
|
|
148
|
+
configs.push(parseEnvFile(resolvedPath))
|
|
149
|
+
sourceFiles.push(path.basename(resolvedPath))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
91
152
|
}
|
|
92
153
|
|
|
93
|
-
|
|
94
|
-
|
|
154
|
+
// Load main env file (highest priority)
|
|
155
|
+
if (options.envPath) {
|
|
156
|
+
const resolvedEnvPath = path.isAbsolute(options.envPath)
|
|
157
|
+
? options.envPath
|
|
158
|
+
: path.resolve(process.cwd(), options.envPath)
|
|
95
159
|
|
|
96
|
-
|
|
97
|
-
|
|
160
|
+
if (fs.existsSync(resolvedEnvPath)) {
|
|
161
|
+
configs.push(parseEnvFile(resolvedEnvPath))
|
|
162
|
+
sourceFiles.push(path.basename(resolvedEnvPath))
|
|
163
|
+
}
|
|
98
164
|
}
|
|
99
165
|
|
|
100
|
-
//
|
|
101
|
-
const
|
|
166
|
+
// Merge configs
|
|
167
|
+
const mergedConfig = mergeEnvConfigs(...configs)
|
|
168
|
+
const keys = Object.keys(mergedConfig).sort()
|
|
169
|
+
|
|
170
|
+
if (keys.length === 0) {
|
|
171
|
+
console.warn('Warning: No environment variables found')
|
|
172
|
+
}
|
|
102
173
|
|
|
103
|
-
// Ensure
|
|
174
|
+
// Ensure output directory exists
|
|
104
175
|
const outputDir = path.dirname(options.outputPath)
|
|
105
176
|
if (!fs.existsSync(outputDir)) {
|
|
106
177
|
fs.mkdirSync(outputDir, { recursive: true })
|
|
107
178
|
}
|
|
108
179
|
|
|
109
|
-
|
|
110
|
-
|
|
180
|
+
if (options.typesOnly) {
|
|
181
|
+
// Generate declaration file only
|
|
182
|
+
const declaration = generateDeclaration(keys, sourceFiles)
|
|
183
|
+
fs.writeFileSync(options.outputPath, declaration)
|
|
184
|
+
} else {
|
|
185
|
+
// Generate full config module
|
|
186
|
+
const module = generateConfigModule(mergedConfig, sourceFiles)
|
|
187
|
+
fs.writeFileSync(options.outputPath, module)
|
|
188
|
+
|
|
189
|
+
// Also generate declaration file alongside
|
|
190
|
+
const declPath = options.outputPath.replace(/\.ts$/, '.d.ts')
|
|
191
|
+
if (declPath !== options.outputPath) {
|
|
192
|
+
const declaration = generateDeclaration(keys, sourceFiles)
|
|
193
|
+
fs.writeFileSync(declPath, declaration)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
111
196
|
|
|
112
|
-
return options.outputPath
|
|
197
|
+
return { outputPath: options.outputPath, keys }
|
|
113
198
|
}
|
|
114
199
|
|
|
115
200
|
/**
|
|
116
201
|
* Find the most appropriate .env file in a directory.
|
|
117
|
-
* Prefers .env.local > .env.development > .env
|
|
118
202
|
*/
|
|
119
203
|
export function findEnvFile(directory: string): string | null {
|
|
120
204
|
const candidates = ['.env.local', '.env.development', '.env']
|
|
@@ -128,3 +212,25 @@ export function findEnvFile(directory: string): string | null {
|
|
|
128
212
|
|
|
129
213
|
return null
|
|
130
214
|
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Look for a shared .env file in parent directories.
|
|
218
|
+
*/
|
|
219
|
+
export function findSharedEnv(directory: string): string | null {
|
|
220
|
+
// Common patterns for shared env in monorepos
|
|
221
|
+
const patterns = [
|
|
222
|
+
'../shared/.env',
|
|
223
|
+
'../../shared/.env',
|
|
224
|
+
'../.env.shared',
|
|
225
|
+
'../../.env.shared',
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
for (const pattern of patterns) {
|
|
229
|
+
const sharedPath = path.resolve(directory, pattern)
|
|
230
|
+
if (fs.existsSync(sharedPath)) {
|
|
231
|
+
return sharedPath
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return null
|
|
236
|
+
}
|
package/src/config.web.ts
CHANGED
|
@@ -2,22 +2,63 @@ import type { IConfig } from './types'
|
|
|
2
2
|
import { ConfigValidationError } from './types'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Config store - populated by the generated config module or manually via setConfig().
|
|
6
|
+
*/
|
|
7
|
+
let configStore: Record<string, string> = {}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Set config values. Called automatically when importing from a project that
|
|
11
|
+
* has generated config, or can be called manually.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* // In your app entry point:
|
|
16
|
+
* import { setConfig } from '@idealyst/config'
|
|
17
|
+
* import { generatedConfig } from './config.generated'
|
|
18
|
+
*
|
|
19
|
+
* setConfig(generatedConfig)
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function setConfig(config: Record<string, string>): void {
|
|
23
|
+
configStore = { ...configStore, ...config }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Clear all config values. Useful for testing.
|
|
28
|
+
*/
|
|
29
|
+
export function clearConfig(): void {
|
|
30
|
+
configStore = {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the raw config store. Useful for debugging.
|
|
35
|
+
*/
|
|
36
|
+
export function getConfigStore(): Record<string, string> {
|
|
37
|
+
return { ...configStore }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Web implementation of IConfig.
|
|
42
|
+
*
|
|
43
|
+
* Config values come from:
|
|
44
|
+
* 1. Generated config module (via setConfig)
|
|
45
|
+
* 2. Manual setConfig() calls
|
|
46
|
+
*
|
|
47
|
+
* Usage:
|
|
48
|
+
* ```typescript
|
|
49
|
+
* import { config, setConfig } from '@idealyst/config'
|
|
50
|
+
* import { generatedConfig } from './config.generated'
|
|
6
51
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - Internally we look up: import.meta.env.VITE_API_URL
|
|
52
|
+
* // Initialize config (do this once at app startup)
|
|
53
|
+
* setConfig(generatedConfig)
|
|
10
54
|
*
|
|
11
|
-
*
|
|
55
|
+
* // Then use anywhere
|
|
56
|
+
* const apiUrl = config.get('API_URL')
|
|
57
|
+
* ```
|
|
12
58
|
*/
|
|
13
59
|
class WebConfig implements IConfig {
|
|
14
60
|
get(key: string, defaultValue?: string): string | undefined {
|
|
15
|
-
|
|
16
|
-
// environment variables to client-side code.
|
|
17
|
-
// User code uses canonical names: config.get('API_URL')
|
|
18
|
-
// Internally we look up: import.meta.env.VITE_API_URL
|
|
19
|
-
const value = (import.meta.env as Record<string, string | undefined>)[`VITE_${key}`]
|
|
20
|
-
return value ?? defaultValue
|
|
61
|
+
return configStore[key] ?? defaultValue
|
|
21
62
|
}
|
|
22
63
|
|
|
23
64
|
getRequired(key: string): string {
|
|
@@ -25,21 +66,18 @@ class WebConfig implements IConfig {
|
|
|
25
66
|
if (value === undefined) {
|
|
26
67
|
throw new Error(
|
|
27
68
|
`Required config key "${key}" is not defined. ` +
|
|
28
|
-
`Make sure
|
|
69
|
+
`Make sure you've run "idealyst-config generate" and imported the generated config.`
|
|
29
70
|
)
|
|
30
71
|
}
|
|
31
72
|
return value
|
|
32
73
|
}
|
|
33
74
|
|
|
34
75
|
has(key: string): boolean {
|
|
35
|
-
return
|
|
76
|
+
return configStore[key] !== undefined
|
|
36
77
|
}
|
|
37
78
|
|
|
38
79
|
keys(): string[] {
|
|
39
|
-
|
|
40
|
-
return Object.keys(import.meta.env)
|
|
41
|
-
.filter(k => k.startsWith('VITE_'))
|
|
42
|
-
.map(k => k.replace(/^VITE_/, ''))
|
|
80
|
+
return Object.keys(configStore).sort()
|
|
43
81
|
}
|
|
44
82
|
|
|
45
83
|
validate(requiredKeys: string[]): void {
|
package/src/index.web.ts
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Web entry point for @idealyst/config
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Config values come from a generated module created by the CLI.
|
|
5
|
+
* This approach works with any bundler and supports env inheritance.
|
|
6
6
|
*
|
|
7
7
|
* @example
|
|
8
8
|
* ```typescript
|
|
9
|
-
*
|
|
9
|
+
* // 1. Generate config (run in terminal)
|
|
10
|
+
* // idealyst-config generate --extends ../shared/.env --env .env
|
|
10
11
|
*
|
|
11
|
-
* //
|
|
12
|
-
*
|
|
12
|
+
* // 2. Initialize in your app entry point
|
|
13
|
+
* import { config, setConfig } from '@idealyst/config'
|
|
14
|
+
* import { generatedConfig } from './config.generated'
|
|
15
|
+
* setConfig(generatedConfig)
|
|
16
|
+
*
|
|
17
|
+
* // 3. Use anywhere
|
|
13
18
|
* const apiUrl = config.get('API_URL')
|
|
14
19
|
* ```
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
|
-
import WebConfig from './config.web'
|
|
22
|
+
import WebConfig, { setConfig, clearConfig, getConfigStore } from './config.web'
|
|
18
23
|
|
|
19
24
|
// Create singleton instance for web
|
|
20
25
|
const config = new WebConfig()
|
|
21
26
|
|
|
22
27
|
export default config
|
|
23
|
-
export { config, config as Config, WebConfig }
|
|
28
|
+
export { config, config as Config, WebConfig, setConfig, clearConfig, getConfigStore }
|
|
24
29
|
export * from './types'
|
package/src/vite-env.d.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/// <reference types="vite/client" />
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Type declarations for Vite's import.meta.env
|
|
5
|
-
*
|
|
6
|
-
* This file provides type information for import.meta.env when Vite types
|
|
7
|
-
* are not available. In projects using Vite, these types are provided by
|
|
8
|
-
* vite/client.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
interface ImportMetaEnv {
|
|
12
|
-
[key: string]: string | undefined
|
|
13
|
-
readonly MODE: string
|
|
14
|
-
readonly BASE_URL: string
|
|
15
|
-
readonly PROD: boolean
|
|
16
|
-
readonly DEV: boolean
|
|
17
|
-
readonly SSR: boolean
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface ImportMeta {
|
|
21
|
-
readonly env: ImportMetaEnv
|
|
22
|
-
}
|