@idealyst/config 1.2.12 → 1.2.13

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 CHANGED
@@ -1,14 +1,14 @@
1
1
  # @idealyst/config
2
2
 
3
- Cross-platform configuration and environment variable support for React and React Native applications.
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
- - **Type-safe** - Auto-generated TypeScript declarations for autocomplete
9
- - **Prefix abstraction** - Use canonical names (`API_URL`), web implementation handles `VITE_` prefix internally
10
- - **Validation** - Check required config at app startup
11
- - **Zero runtime dependencies** - Uses platform-native solutions
8
+ - **Env inheritance** - Shared config with platform-specific overrides
9
+ - **Type-safe** - Auto-generated TypeScript declarations
10
+ - **Bundler agnostic** - No bundler configuration needed
11
+ - **Monorepo friendly** - Designed for shared/web/mobile patterns
12
12
 
13
13
  ## Installation
14
14
 
@@ -22,89 +22,124 @@ cd ios && pod install
22
22
 
23
23
  ## Quick Start
24
24
 
25
- ```typescript
26
- import { config } from '@idealyst/config'
25
+ ### 1. Create your .env files
27
26
 
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')
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
36
+ ```
33
37
 
34
- // Get required value (throws if missing)
35
- const secret = config.getRequired('JWT_SECRET')
38
+ **shared/.env:**
39
+ ```bash
40
+ API_URL=https://api.example.com
41
+ GOOGLE_CLIENT_ID=abc123
42
+ ANALYTICS_ENABLED=true
43
+ ```
36
44
 
37
- // Validate required vars at startup
38
- config.validate(['API_URL', 'AUTH_SECRET'])
45
+ **web/.env:**
46
+ ```bash
47
+ # Override API for web
48
+ API_URL=https://web-api.example.com
39
49
  ```
40
50
 
41
- ## Environment Files
51
+ **mobile/.env:**
52
+ ```bash
53
+ # Mobile uses shared API_URL, but different analytics
54
+ ANALYTICS_ENABLED=false
55
+ ```
42
56
 
43
- ### React Native (.env)
57
+ ### 2. Generate config
44
58
 
45
59
  ```bash
46
- # No prefix needed
47
- API_URL=https://api.example.com
48
- GOOGLE_CLIENT_ID=abc123
49
- JWT_SECRET=supersecret
60
+ # In packages/web/
61
+ npx idealyst-config generate --extends ../shared/.env --env .env
62
+
63
+ # In packages/mobile/
64
+ npx idealyst-config generate --extends ../shared/.env --env .env
50
65
  ```
51
66
 
52
- ### Vite Web (.env)
67
+ This creates `src/config.generated.ts` with merged values:
53
68
 
54
- ```bash
55
- # Must use VITE_ prefix for client exposure
56
- VITE_API_URL=https://api.example.com
57
- VITE_GOOGLE_CLIENT_ID=abc123
58
- VITE_JWT_SECRET=supersecret
69
+ ```typescript
70
+ // Web: API_URL overridden, others inherited
71
+ export const generatedConfig = {
72
+ API_URL: "https://web-api.example.com",
73
+ GOOGLE_CLIENT_ID: "abc123",
74
+ ANALYTICS_ENABLED: "true"
75
+ }
59
76
  ```
60
77
 
61
- **Important:** Your code always uses canonical names without the `VITE_` prefix. The web implementation handles this internally:
78
+ ### 3. Initialize and use
62
79
 
63
80
  ```typescript
64
- // Both platforms - same code
65
- const apiUrl = config.get('API_URL')
81
+ // In your app entry point (e.g., App.tsx, main.tsx)
82
+ import { setConfig } from '@idealyst/config'
83
+ import { generatedConfig } from './config.generated'
84
+
85
+ setConfig(generatedConfig)
66
86
  ```
67
87
 
68
- ## Type Generation
88
+ ```typescript
89
+ // Anywhere in your app
90
+ import { config } from '@idealyst/config'
69
91
 
70
- Generate TypeScript declarations for autocomplete support:
92
+ const apiUrl = config.get('API_URL')
93
+ const analyticsEnabled = config.get('ANALYTICS_ENABLED') === 'true'
94
+ ```
95
+
96
+ ## CLI Reference
71
97
 
72
98
  ```bash
73
- # Auto-detect .env file
74
- npx idealyst-config generate
99
+ idealyst-config generate [options]
100
+
101
+ Options:
102
+ --env <path> Path to .env file (default: auto-detect)
103
+ --extends <path> Inherit from another .env (can use multiple times)
104
+ --output <path> Output path (default: src/config.generated.ts)
105
+ --types-only Generate only .d.ts file, no values
106
+ --help Show help
107
+ ```
75
108
 
76
- # Specify .env file
77
- npx idealyst-config generate --env .env.local
109
+ ### Examples
78
110
 
79
- # Custom output path
80
- npx idealyst-config generate --output types/env.d.ts
81
- ```
111
+ ```bash
112
+ # Simple - auto-detect .env
113
+ idealyst-config generate
82
114
 
83
- This creates a declaration file that provides autocomplete for your config keys:
115
+ # With shared inheritance
116
+ idealyst-config generate --extends ../shared/.env --env .env
84
117
 
85
- ```typescript
86
- // Generated: src/env.d.ts
87
- declare module '@idealyst/config' {
88
- interface ConfigKeys {
89
- API_URL: string
90
- GOOGLE_CLIENT_ID: string
91
- JWT_SECRET: string
92
- }
93
- }
118
+ # Multiple inheritance (lowest to highest priority)
119
+ idealyst-config generate \
120
+ --extends ../../shared/.env \
121
+ --extends ../.env.common \
122
+ --env .env
123
+
124
+ # Types only (for CI/type checking without values)
125
+ idealyst-config generate --types-only --output src/env.d.ts
94
126
  ```
95
127
 
96
- Now you get autocomplete when calling `config.get()`:
128
+ ## Inheritance Priority
129
+
130
+ Configs are merged in order, with later files overriding earlier ones:
97
131
 
98
- ```typescript
99
- config.get('API_URL') // Autocomplete shows available keys
100
- config.get('INVALID') // TypeScript error - key not in ConfigKeys
132
+ ```
133
+ 1. --extends ../shared/.env (lowest priority)
134
+ 2. --extends ../.env.common
135
+ 3. --env .env (highest priority)
101
136
  ```
102
137
 
103
138
  ## API Reference
104
139
 
105
140
  ### `config.get(key: string): string | undefined`
106
141
 
107
- Get a configuration value by key.
142
+ Get a config value.
108
143
 
109
144
  ```typescript
110
145
  const apiUrl = config.get('API_URL')
@@ -112,7 +147,7 @@ const apiUrl = config.get('API_URL')
112
147
 
113
148
  ### `config.get(key: string, defaultValue: string): string`
114
149
 
115
- Get a configuration value with a fallback default.
150
+ Get with fallback default.
116
151
 
117
152
  ```typescript
118
153
  const port = config.get('PORT', '3000')
@@ -120,16 +155,15 @@ const port = config.get('PORT', '3000')
120
155
 
121
156
  ### `config.getRequired(key: string): string`
122
157
 
123
- Get a required configuration value. Throws an error if not defined.
158
+ Get required value. Throws if not defined.
124
159
 
125
160
  ```typescript
126
161
  const secret = config.getRequired('JWT_SECRET')
127
- // Throws: 'Required config key "JWT_SECRET" is not defined'
128
162
  ```
129
163
 
130
164
  ### `config.has(key: string): boolean`
131
165
 
132
- Check if a configuration key exists.
166
+ Check if key exists.
133
167
 
134
168
  ```typescript
135
169
  if (config.has('DEBUG')) {
@@ -137,54 +171,77 @@ if (config.has('DEBUG')) {
137
171
  }
138
172
  ```
139
173
 
140
- ### `config.keys(): string[]`
174
+ ### `config.validate(requiredKeys: string[]): void`
141
175
 
142
- Get all available configuration keys.
176
+ Validate required keys at startup.
143
177
 
144
178
  ```typescript
145
- console.log('Available config:', config.keys())
179
+ config.validate(['API_URL', 'AUTH_SECRET'])
180
+ // Throws ConfigValidationError if any are missing
146
181
  ```
147
182
 
148
- ### `config.validate(requiredKeys: string[]): void`
183
+ ### `setConfig(config: Record<string, string>)`
149
184
 
150
- Validate that all required keys are present. Throws `ConfigValidationError` if any are missing.
185
+ Initialize config values (call once at app startup).
151
186
 
152
187
  ```typescript
153
- // At app startup
154
- try {
155
- config.validate(['API_URL', 'AUTH_SECRET', 'DATABASE_URL'])
156
- } catch (error) {
157
- if (error instanceof ConfigValidationError) {
158
- console.error('Missing config:', error.missingKeys)
159
- }
160
- }
188
+ import { setConfig } from '@idealyst/config'
189
+ import { generatedConfig } from './config.generated'
190
+
191
+ setConfig(generatedConfig)
161
192
  ```
162
193
 
163
- ## Platform Implementation Details
194
+ ## React Native Setup
164
195
 
165
- ### Web (Vite)
196
+ 1. Install react-native-config:
197
+ ```bash
198
+ npm install react-native-config
199
+ cd ios && pod install
200
+ ```
166
201
 
167
- Uses `import.meta.env` with automatic `VITE_` prefix handling:
168
- - Your code: `config.get('API_URL')`
169
- - Internal lookup: `import.meta.env.VITE_API_URL`
202
+ 2. Follow [react-native-config setup](https://github.com/luggit/react-native-config#setup)
170
203
 
171
- ### React Native
204
+ 3. Generate and use the same way as web:
205
+ ```bash
206
+ idealyst-config generate --extends ../shared/.env --env .env
207
+ ```
172
208
 
173
- Uses `react-native-config` for native environment variable injection:
174
- - Your code: `config.get('API_URL')`
175
- - Internal lookup: `Config.API_URL`
209
+ ## Type Safety
176
210
 
177
- Make sure to follow [react-native-config setup](https://github.com/luggit/react-native-config#setup) for your platform.
211
+ The CLI generates TypeScript declarations for autocomplete:
178
212
 
179
- ## Best Practices
213
+ ```typescript
214
+ // Generated: src/config.generated.d.ts
215
+ declare module '@idealyst/config' {
216
+ interface ConfigKeys {
217
+ API_URL: string
218
+ GOOGLE_CLIENT_ID: string
219
+ ANALYTICS_ENABLED: string
220
+ }
221
+ }
222
+ ```
223
+
224
+ This provides autocomplete and catches typos at compile time.
225
+
226
+ ## Build Integration
180
227
 
181
- 1. **Generate types after .env changes** - Run `idealyst-config generate` whenever you add/remove environment variables
228
+ Add to your build scripts:
182
229
 
183
- 2. **Validate at startup** - Call `config.validate()` early in your app to catch missing config
230
+ ```json
231
+ {
232
+ "scripts": {
233
+ "prebuild": "idealyst-config generate --extends ../shared/.env",
234
+ "build": "vite build"
235
+ }
236
+ }
237
+ ```
184
238
 
185
- 3. **Use .env.example** - Commit an example file with all required keys (no values)
239
+ ## Best Practices
186
240
 
187
- 4. **Don't commit secrets** - Add `.env` and `.env.local` to `.gitignore`
241
+ 1. **Generate before build** - Add to prebuild script
242
+ 2. **Gitignore generated files** - Add `config.generated.ts` to `.gitignore`
243
+ 3. **Commit .env.example** - Document required keys without values
244
+ 4. **Validate at startup** - Use `config.validate()` early
188
245
 
189
246
  ## License
190
247
 
@@ -1,60 +1,93 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * CLI for @idealyst/config - Generate TypeScript types from .env files
4
+ * CLI for @idealyst/config - Generate config from .env files with inheritance
5
5
  *
6
- * This is a self-contained JavaScript CLI that doesn't require TypeScript
7
- * compilation at runtime. It implements the same logic as src/cli/generate.ts.
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 all key names.
15
- * Strips VITE_ prefix to normalize to canonical names.
16
+ * Parse a .env file and extract key-value pairs.
16
17
  */
17
- function parseEnvFile(content) {
18
- const keys = []
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 to canonical names
47
+ // Strip VITE_ prefix to normalize
37
48
  if (key.startsWith('VITE_')) {
38
49
  key = key.substring(5)
39
50
  }
40
51
 
41
- // Only add unique keys
42
- if (key && !keys.includes(key)) {
43
- keys.push(key)
44
- }
52
+ config[key] = value
45
53
  }
46
54
 
47
- return keys.sort()
55
+ return config
48
56
  }
49
57
 
50
58
  /**
51
- * Generate TypeScript declaration content from a list of config keys.
59
+ * Generate TypeScript config module with actual values.
52
60
  */
53
- function generateDeclaration(keys, sourceFile) {
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
- // Generated from: ${sourceFile}
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 the most appropriate .env file in a directory.
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 TypeScript types from .env files
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> Path to .env file (default: auto-detect)
103
- --output <path> Output path for .d.ts file (default: src/env.d.ts)
104
- --help Show this help message
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
- idealyst-config generate --env .env.local
109
- idealyst-config generate --env .env --output types/env.d.ts
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
- // Find or use the specified .env file
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.join(cwd, args.env)
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
- // Check if env file exists
163
- if (!fs.existsSync(envPath)) {
164
- console.error(`Error: Environment file not found: ${envPath}`)
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
- // Determine output path
169
- const outputPath = args.output
170
- ? (path.isAbsolute(args.output) ? args.output : path.join(cwd, args.output))
171
- : path.join(cwd, 'src', 'env.d.ts')
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
- if (keys.length === 0) {
179
- console.warn('Warning: No environment variables found in', envPath)
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
- // Generate declaration
183
- const declaration = generateDeclaration(keys, path.basename(envPath))
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
- // Ensure output directory exists
186
- const outputDir = path.dirname(outputPath)
187
- if (!fs.existsSync(outputDir)) {
188
- fs.mkdirSync(outputDir, { recursive: true })
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
- // Write declaration file
287
+ if (args.typesOnly) {
288
+ // Generate declaration file only
289
+ const declaration = generateDeclaration(keys, sourceFiles)
192
290
  fs.writeFileSync(outputPath, declaration)
193
-
194
- console.log(`Generated config types at: ${outputPath}`)
195
- console.log(`Source: ${envPath}`)
196
- console.log(`Keys: ${keys.join(', ')}`)
197
- } catch (error) {
198
- console.error('Error generating config types:', error.message)
199
- process.exit(1)
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.12",
3
+ "version": "1.2.13",
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",
@@ -3,22 +3,38 @@ import path from 'path'
3
3
 
4
4
  export interface GenerateOptions {
5
5
  /**
6
- * Path to the .env file to read
6
+ * Path to the platform-specific .env file (highest priority)
7
7
  */
8
- envPath: string
8
+ envPath?: string
9
9
 
10
10
  /**
11
- * Path to write the generated TypeScript declaration file
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 all key names.
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(content: string): string[] {
21
- const keys: string[] = []
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 name (everything before the first =)
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
- // Only add unique keys
45
- if (key && !keys.includes(key)) {
46
- keys.push(key)
47
- }
67
+ config[key] = value
48
68
  }
49
69
 
50
- return keys.sort()
70
+ return config
51
71
  }
52
72
 
53
73
  /**
54
- * Generate TypeScript declaration content from a list of config keys.
74
+ * Merge multiple env configs with later configs taking priority.
55
75
  */
56
- export function generateDeclaration(keys: string[], sourceFile: string): string {
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
- // Generated from: ${sourceFile}
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 types from an .env file.
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 generateConfigTypes(options: GenerateOptions): string {
88
- // Read the .env file
89
- if (!fs.existsSync(options.envPath)) {
90
- throw new Error(`Environment file not found: ${options.envPath}`)
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
- const envContent = fs.readFileSync(options.envPath, 'utf-8')
94
- const keys = parseEnvFile(envContent)
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
- if (keys.length === 0) {
97
- console.warn('Warning: No environment variables found in', options.envPath)
160
+ if (fs.existsSync(resolvedEnvPath)) {
161
+ configs.push(parseEnvFile(resolvedEnvPath))
162
+ sourceFiles.push(path.basename(resolvedEnvPath))
163
+ }
98
164
  }
99
165
 
100
- // Generate the declaration content
101
- const declaration = generateDeclaration(keys, path.basename(options.envPath))
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 the output directory exists
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
- // Write the declaration file
110
- fs.writeFileSync(options.outputPath, declaration)
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
- * Web implementation of IConfig using Vite's import.meta.env.
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
- * This implementation automatically handles the VITE_ prefix:
8
- * - User code uses canonical names: config.get('API_URL')
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
- * This allows the same code to work on both web and native platforms.
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
- // Always use VITE_ prefix - this is the Vite convention for exposing
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 VITE_${key} is set in your .env file.`
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 (import.meta.env as Record<string, string | undefined>)[`VITE_${key}`] !== undefined
76
+ return configStore[key] !== undefined
36
77
  }
37
78
 
38
79
  keys(): string[] {
39
- // Return canonical names (strip VITE_ prefix)
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
- * Uses Vite's import.meta.env for environment variable access.
5
- * The VITE_ prefix is handled automatically - use canonical names in your code.
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
- * import { config } from '@idealyst/config'
9
+ * // 1. Generate config (run in terminal)
10
+ * // idealyst-config generate --extends ../shared/.env --env .env
10
11
  *
11
- * // In your .env file: VITE_API_URL=https://api.example.com
12
- * // In your code: use canonical name without prefix
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
- }