@maestro-js/agent-skills 1.0.0-alpha.0
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/choosing-an-npm-package/SKILL.md +72 -0
- package/dist/maestro-cli/SKILL.md +257 -0
- package/dist/maestro-packages/SKILL.md +42 -0
- package/dist/maestro-packages/broadcast.md +306 -0
- package/dist/maestro-packages/cache-control.md +337 -0
- package/dist/maestro-packages/cache.md +397 -0
- package/dist/maestro-packages/config.md +203 -0
- package/dist/maestro-packages/context.md +365 -0
- package/dist/maestro-packages/crypt.md +253 -0
- package/dist/maestro-packages/db.md +391 -0
- package/dist/maestro-packages/events.md +295 -0
- package/dist/maestro-packages/exceptions.md +231 -0
- package/dist/maestro-packages/form.md +391 -0
- package/dist/maestro-packages/hash.md +248 -0
- package/dist/maestro-packages/helpers.md +249 -0
- package/dist/maestro-packages/log.md +321 -0
- package/dist/maestro-packages/mail.md +365 -0
- package/dist/maestro-packages/metrics.md +236 -0
- package/dist/maestro-packages/queue.md +284 -0
- package/dist/maestro-packages/rate-limiting.md +206 -0
- package/dist/maestro-packages/react-router-file-routes.md +337 -0
- package/dist/maestro-packages/recurring-jobs.md +353 -0
- package/dist/maestro-packages/sql.md +316 -0
- package/dist/maestro-packages/tracing.md +240 -0
- package/package.json +33 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cache
|
|
3
|
+
description: "Key-value caching with TTL, stale-while-revalidate, distributed locking, cache-aside pattern, atomic increment/decrement, and set-if-not-exists (add) for @maestro-js/cache. Use when working with @maestro-js/cache, adding caching to services, implementing distributed locks, using the remember/SWR pattern, incrementing or decrementing counters, adding values only if absent, configuring Redis or MockRedis drivers, or following the canonical Provider pattern from service-registry."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @maestro-js/cache
|
|
7
|
+
|
|
8
|
+
Key-value caching with optional TTL, stale-while-revalidate (SWR), distributed locking, and
|
|
9
|
+
cache-aside (`remember`) pattern. This is the canonical example of the Maestro Provider pattern.
|
|
10
|
+
|
|
11
|
+
## Quick Setup
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Cache } from '@maestro-js/cache'
|
|
15
|
+
|
|
16
|
+
// Create and register a cache instance
|
|
17
|
+
const cacheService = Cache.Provider.create({
|
|
18
|
+
driver: Cache.drivers.redis({ url: 'redis-host', port: 6379 })
|
|
19
|
+
})
|
|
20
|
+
Cache.Provider.register('default', cacheService)
|
|
21
|
+
|
|
22
|
+
// Use directly via the facade
|
|
23
|
+
await Cache.set('user:123', { name: 'Alice' }, 3600)
|
|
24
|
+
const user = await Cache.get<User>('user:123')
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Provider Pattern
|
|
28
|
+
|
|
29
|
+
Cache follows the standard Maestro Provider pattern built on `@maestro-js/service-registry`:
|
|
30
|
+
|
|
31
|
+
1. **`Cache.Provider.create(config)`** -- factory that instantiates a cache service with a driver
|
|
32
|
+
2. **`Cache.Provider.register(name, service)`** -- register the instance in a named registry
|
|
33
|
+
3. **`Cache.provider(key)`** -- resolve a named instance (deferred via Proxy)
|
|
34
|
+
4. **Facade** -- `Cache.get()`, `Cache.set()`, etc. spread from `provider('default')`
|
|
35
|
+
5. **`Cache.drivers`** -- factory functions for pluggable backends (`redis`, `mockRedis`)
|
|
36
|
+
|
|
37
|
+
Extend provider keys via declaration merging:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
declare module '@maestro-js/cache' {
|
|
41
|
+
namespace Cache.Provider {
|
|
42
|
+
interface Keys {
|
|
43
|
+
default: unknown
|
|
44
|
+
sessions: unknown
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Source: `packages/cache/src/index.ts`
|
|
51
|
+
|
|
52
|
+
## Drivers
|
|
53
|
+
|
|
54
|
+
### Redis
|
|
55
|
+
|
|
56
|
+
Production driver backed by ioredis. Pass connection details and optional tuning:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
Cache.drivers.redis({
|
|
60
|
+
url: 'redis-host',
|
|
61
|
+
port: 6379,
|
|
62
|
+
operationTimeout: 5000, // ms, default 5000
|
|
63
|
+
connectTimeout: 10000, // ms, default 10000
|
|
64
|
+
keyPrefix: 'myapp:', // optional prefix for all keys
|
|
65
|
+
logger: customLogger // optional Log.LogFunctions
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The returned driver exposes a `connection` property (the raw ioredis `Redis` instance) for
|
|
70
|
+
advanced use cases.
|
|
71
|
+
|
|
72
|
+
Config interface:
|
|
73
|
+
|
|
74
|
+
| Field | Type | Default | Description |
|
|
75
|
+
|--------------------|-------------------|---------|-------------------------------|
|
|
76
|
+
| `url` | `string` | -- | Redis host |
|
|
77
|
+
| `port` | `number` | -- | Redis port |
|
|
78
|
+
| `operationTimeout` | `number` | `5000` | Per-operation timeout (ms) |
|
|
79
|
+
| `connectTimeout` | `number` | `10000` | Connection timeout (ms) |
|
|
80
|
+
| `keyPrefix` | `string` | `''` | Prefix applied to all keys |
|
|
81
|
+
| `logger` | `Log.LogFunctions`| -- | Optional structured logger |
|
|
82
|
+
|
|
83
|
+
Source: `packages/cache/src/redis-cache-driver.ts`
|
|
84
|
+
|
|
85
|
+
### MockRedis
|
|
86
|
+
|
|
87
|
+
In-memory driver using `ioredis-mock` for testing. Same interface as Redis:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
Cache.drivers.mockRedis({
|
|
91
|
+
keyPrefix: 'test:', // optional
|
|
92
|
+
logger: customLogger // optional
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Source: `packages/cache/src/mock-redis-cache-driver.ts`
|
|
97
|
+
|
|
98
|
+
## API Reference
|
|
99
|
+
|
|
100
|
+
All values must be JSON-serializable. TTL is always in seconds; pass `null` to persist forever.
|
|
101
|
+
|
|
102
|
+
### Core Methods
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
Cache.has(key: string): Promise<boolean>
|
|
106
|
+
```
|
|
107
|
+
Return `true` if the key exists in the cache.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
Cache.get<T>(key: string): Promise<T | null>
|
|
111
|
+
```
|
|
112
|
+
Retrieve a value. Return `null` on miss.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
Cache.set(key: string, value: any, ttlSeconds: number | null): Promise<void>
|
|
116
|
+
```
|
|
117
|
+
Store a value with TTL in seconds. Pass `null` for no expiration.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
Cache.del(key: string): Promise<void>
|
|
121
|
+
```
|
|
122
|
+
Remove a key from the cache.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
Cache.flush(): Promise<void>
|
|
126
|
+
```
|
|
127
|
+
Remove all entries. Ignores key prefix -- affects the entire store.
|
|
128
|
+
|
|
129
|
+
### Atomic Operations
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
Cache.add(key: string, value: any, ttlSeconds: number): Promise<boolean>
|
|
133
|
+
```
|
|
134
|
+
Store a value only if the key does not already exist. Returns `true` if added, `false` if the key
|
|
135
|
+
was already present. The value is JSON-serialized (compatible with `get`).
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
Cache.increment(key: string, value?: number): Promise<number>
|
|
139
|
+
```
|
|
140
|
+
Atomically increment a numeric value by `value` (default `1`). Returns the new value.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
Cache.decrement(key: string, value?: number): Promise<number>
|
|
144
|
+
```
|
|
145
|
+
Atomically decrement a numeric value by `value` (default `1`). Returns the new value.
|
|
146
|
+
|
|
147
|
+
> **Warning:** `increment` and `decrement` use Redis `INCRBY`/`DECRBY` on raw integers, not
|
|
148
|
+
> JSON-serialized values. Values stored with `set` (which JSON-serializes) cannot be incremented.
|
|
149
|
+
> Conversely, values created by `increment`/`decrement` are raw numbers and will come back as plain
|
|
150
|
+
> numbers from `get`, not wrapped in JSON.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// Rate-limit counter
|
|
154
|
+
await Cache.increment('rate:user:42')
|
|
155
|
+
const count = await Cache.increment('rate:user:42')
|
|
156
|
+
if (count > 100) throw new Error('Rate limit exceeded')
|
|
157
|
+
|
|
158
|
+
// Add-if-absent: claim a one-time token
|
|
159
|
+
const claimed = await Cache.add('token:abc123', { userId: 42 }, 300)
|
|
160
|
+
if (!claimed) throw new Error('Token already used')
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### remember (Cache-Aside)
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
Cache.remember<T>(key: string, options: RememberOptions, fn: () => Promise<T>): Promise<T>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Retrieve `key` from cache. On miss, call `fn`, cache the result, and return it.
|
|
170
|
+
|
|
171
|
+
**RememberOptions** accepts:
|
|
172
|
+
- `number` -- TTL in seconds
|
|
173
|
+
- `null` -- persist forever
|
|
174
|
+
- `DurationString` -- e.g. `'5 minutes'`, `'1 hour'`, `'30 seconds'`, `'2 days'`
|
|
175
|
+
- `{ ttl: number | DurationString | null, swr: number | DurationString }` -- TTL with
|
|
176
|
+
stale-while-revalidate window
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// Simple TTL
|
|
180
|
+
await Cache.remember('user:1', 60, async () => fetchUser(1))
|
|
181
|
+
|
|
182
|
+
// Duration string
|
|
183
|
+
await Cache.remember('config', '5 minutes', async () => loadConfig())
|
|
184
|
+
|
|
185
|
+
// Forever
|
|
186
|
+
await Cache.remember('constants', null, async () => getConstants())
|
|
187
|
+
|
|
188
|
+
// Stale-while-revalidate
|
|
189
|
+
await Cache.remember('feed', { ttl: 60, swr: 30 }, async () => buildFeed())
|
|
190
|
+
await Cache.remember('feed', { ttl: '1 minute', swr: '30 seconds' }, async () => buildFeed())
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
SWR behavior: when the TTL expires but the entry is within the SWR window, the stale value is
|
|
194
|
+
returned immediately and `fn` runs in the background to refresh the cache. Background refresh
|
|
195
|
+
errors are silently swallowed (the stale value remains). Only one background refresh per key runs
|
|
196
|
+
at a time.
|
|
197
|
+
|
|
198
|
+
Do not mix `Cache.set()` and `Cache.remember()` on the same key -- `remember` wraps values in an
|
|
199
|
+
internal envelope (`__remember` marker) and throws if it encounters a raw value.
|
|
200
|
+
|
|
201
|
+
### item (Typed Key Handle)
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
Cache.item<T>(key: string)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Return a typed handle scoped to a single key:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const user = Cache.item<User>('user:456')
|
|
211
|
+
await user.set({ name: 'Bob' }, 7200)
|
|
212
|
+
await user.exists() // true
|
|
213
|
+
await user.get() // { name: 'Bob' }
|
|
214
|
+
await user.del()
|
|
215
|
+
await user.remember(60, async () => fetchUser(456))
|
|
216
|
+
await user.remember('5 minutes', async () => fetchUser(456))
|
|
217
|
+
await user.remember({ ttl: 1, swr: 10 }, async () => fetchUser(456))
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### lock (Distributed Locking)
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
Cache.lock(name: string, ttlSeconds: number | null): Cache.Lock
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Create a distributed lock backed by Redis `SET NX`. Lock methods:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
const lock = Cache.lock('process-orders', 30)
|
|
230
|
+
|
|
231
|
+
await lock.get() // true if acquired, false if held by another
|
|
232
|
+
await lock.isAvailable() // true if no one holds the lock
|
|
233
|
+
await lock.release() // release if owned by this caller (atomic Lua script)
|
|
234
|
+
await lock.forceRelease() // release regardless of owner
|
|
235
|
+
await lock.block(5) // retry acquiring for up to 5 seconds, throw on timeout
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Pass `null` for TTL to create a lock with no automatic expiration.
|
|
239
|
+
|
|
240
|
+
`block()` polls every 250ms and also listens for Redis pub/sub release events for faster
|
|
241
|
+
acquisition. Throws `Error('Could not acquire lock: <name>')` on timeout.
|
|
242
|
+
|
|
243
|
+
## Common Patterns
|
|
244
|
+
|
|
245
|
+
### Cache-aside with TTL
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
const user = await Cache.remember('user:123', 3600, async () => {
|
|
249
|
+
return await db.query('SELECT * FROM users WHERE id = 123')
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Stale-while-revalidate
|
|
254
|
+
|
|
255
|
+
Serve stale data instantly while refreshing in the background:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
const dashboard = await Cache.remember(
|
|
259
|
+
'dashboard:stats',
|
|
260
|
+
{ ttl: '5 minutes', swr: '1 minute' },
|
|
261
|
+
async () => computeExpensiveStats()
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Distributed mutex
|
|
266
|
+
|
|
267
|
+
Ensure only one process runs a task at a time:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
const lock = Cache.lock('import-job', 60)
|
|
271
|
+
const acquired = await lock.get()
|
|
272
|
+
if (acquired) {
|
|
273
|
+
try {
|
|
274
|
+
await runImport()
|
|
275
|
+
} finally {
|
|
276
|
+
await lock.release()
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Blocking lock with timeout
|
|
282
|
+
|
|
283
|
+
Wait for availability:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const lock = Cache.lock('critical-section', 30)
|
|
287
|
+
await lock.block(10) // wait up to 10 seconds
|
|
288
|
+
try {
|
|
289
|
+
await doCriticalWork()
|
|
290
|
+
} finally {
|
|
291
|
+
await lock.release()
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Named provider instances
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
const sessions = Cache.Provider.create({
|
|
299
|
+
driver: Cache.drivers.redis({ url: 'redis-host', port: 6379, keyPrefix: 'sess:' })
|
|
300
|
+
})
|
|
301
|
+
Cache.Provider.register('sessions', sessions)
|
|
302
|
+
|
|
303
|
+
// Resolve by name
|
|
304
|
+
const sessCache = Cache.provider('sessions')
|
|
305
|
+
await sessCache.set('abc', { userId: 1 }, 1800)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Testing
|
|
309
|
+
|
|
310
|
+
Use MockRedis driver in tests. Flush before each test to ensure isolation:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
import { Cache } from '@maestro-js/cache'
|
|
314
|
+
import { test } from 'beartest-js'
|
|
315
|
+
import { expect } from 'expect'
|
|
316
|
+
|
|
317
|
+
const cache = Cache.Provider.create({ driver: Cache.drivers.mockRedis({}) })
|
|
318
|
+
|
|
319
|
+
test.beforeEach(async () => {
|
|
320
|
+
await cache.flush()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('cache stores and retrieves values', async () => {
|
|
324
|
+
await cache.set('key', { a: 1 }, null)
|
|
325
|
+
expect(await cache.has('key')).toBeTruthy()
|
|
326
|
+
expect(await cache.get('key')).toEqual({ a: 1 })
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('remember caches on miss', async () => {
|
|
330
|
+
const result = await cache.remember('user:1', 60, async () => ({ name: 'Alice' }))
|
|
331
|
+
expect(result).toEqual({ name: 'Alice' })
|
|
332
|
+
|
|
333
|
+
let called = false
|
|
334
|
+
const cached = await cache.remember('user:1', 60, async () => {
|
|
335
|
+
called = true
|
|
336
|
+
return { name: 'Bob' }
|
|
337
|
+
})
|
|
338
|
+
expect(cached).toEqual({ name: 'Alice' })
|
|
339
|
+
expect(called).toBe(false)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('locks acquire and release', async () => {
|
|
343
|
+
const lock = cache.lock('test-lock', null)
|
|
344
|
+
expect(await lock.isAvailable()).toBe(true)
|
|
345
|
+
await lock.get()
|
|
346
|
+
expect(await lock.isAvailable()).toBe(false)
|
|
347
|
+
await lock.release()
|
|
348
|
+
expect(await lock.isAvailable()).toBe(true)
|
|
349
|
+
})
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Run tests:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
pnpm --filter @maestro-js/cache test # all cache tests
|
|
356
|
+
cd packages/cache && npx beartest ./tests/cache.test.ts # single file
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Cross-Package Integration
|
|
360
|
+
|
|
361
|
+
- **Depends on**: `@maestro-js/service-registry` (Provider pattern foundation),
|
|
362
|
+
`@maestro-js/log` (structured logging in drivers)
|
|
363
|
+
- **Used by**: `@maestro-js/cache-control` (HTTP cache-control headers backed by cache)
|
|
364
|
+
- **Complements**: `@maestro-js/context` (per-request via AsyncLocalStorage; cache is
|
|
365
|
+
cross-request shared state)
|
|
366
|
+
|
|
367
|
+
## Driver Interface
|
|
368
|
+
|
|
369
|
+
Implement `Cache.Driver` to create a custom backend:
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
interface CacheDriver {
|
|
373
|
+
has(key: string): Promise<boolean>
|
|
374
|
+
get(key: string): Promise<any | null>
|
|
375
|
+
set(key: string, value: any, ttlSeconds: number): Promise<unknown>
|
|
376
|
+
add(key: string, value: any, ttlSeconds: number): Promise<boolean>
|
|
377
|
+
setForever(key: string, value: any): Promise<unknown>
|
|
378
|
+
del(key: string): Promise<unknown>
|
|
379
|
+
increment(key: string, value: number): Promise<number>
|
|
380
|
+
decrement(key: string, value: number): Promise<number>
|
|
381
|
+
flush(): Promise<unknown>
|
|
382
|
+
getPrefix(): string
|
|
383
|
+
lock(name: string, ttlSeconds: number | null): Cache.Lock
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Source: `packages/cache/src/cache-types.ts`
|
|
388
|
+
|
|
389
|
+
## Key Source Files
|
|
390
|
+
|
|
391
|
+
- `packages/cache/src/index.ts` -- main export, Provider pattern, `create()`, `remember()` logic
|
|
392
|
+
- `packages/cache/src/cache-types.ts` -- `CacheDriver` and `CacheLock` interfaces
|
|
393
|
+
- `packages/cache/src/redis-cache-driver.ts` -- production Redis driver
|
|
394
|
+
- `packages/cache/src/mock-redis-cache-driver.ts` -- in-memory mock driver for tests
|
|
395
|
+
- `packages/cache/src/duration.ts` -- `DurationString` type and parser
|
|
396
|
+
- `packages/cache/tests/cache.test.ts` -- core cache and remember/SWR tests
|
|
397
|
+
- `packages/cache/tests/cache-locks.test.ts` -- distributed lock tests
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: config
|
|
3
|
+
description: "Configuration loader for .env files and TypeScript/JavaScript config modules with AES-256-GCM encryption support. Use when working with @maestro-js/config, including loading config into process.env, reading/writing encrypted .env files, creating config modules, deriving computed env values, and generating encryption keys. Key capabilities: load config files, parse .env files (plaintext or encrypted), encrypt/decrypt env files, generate encryption keys, resolve config modules (named exports, default exports, sync/async functions), type-safe process.env via Config.Resolve utility type, and auto-load via node --import @maestro-js/config/load."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @maestro-js/config
|
|
7
|
+
|
|
8
|
+
Standalone configuration loader that reads `.env` files (plaintext or AES-256-GCM encrypted), imports a TypeScript or JavaScript config module, and writes the resolved key-value pairs into `process.env`. Zero `@maestro-js` dependencies.
|
|
9
|
+
|
|
10
|
+
## Quick Setup
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Config } from '@maestro-js/config'
|
|
14
|
+
|
|
15
|
+
// Load config into process.env (existing keys are NOT overwritten)
|
|
16
|
+
await Config.loadConfigFile({
|
|
17
|
+
rootDir: process.cwd(),
|
|
18
|
+
configName: 'app',
|
|
19
|
+
envName: 'production',
|
|
20
|
+
encryptionKey: process.env.MAESTRO_ENV_ENCRYPTION_KEY ?? null
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or auto-load at startup without application code:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node --import @maestro-js/config/load --config app --env production --root /path/to/project
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## API Reference
|
|
31
|
+
|
|
32
|
+
### ConfigOptions
|
|
33
|
+
|
|
34
|
+
Every primary function accepts this options object:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
type ConfigOptions = {
|
|
38
|
+
rootDir: string // Project root directory
|
|
39
|
+
configName: string // Config file name without extension (looked up in <rootDir>/config/)
|
|
40
|
+
envName: string | null // Environment name (.env.<envName>) or null for .env
|
|
41
|
+
encryptionKey: string | null // 64-char hex key for encrypted .env files, or null
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Config.loadConfigFile(options: ConfigOptions): Promise<void>
|
|
46
|
+
|
|
47
|
+
Read the `.env` file, import the config file, and write the resolved values into `process.env`. Keys already present in `process.env` are not overwritten.
|
|
48
|
+
|
|
49
|
+
### Config.getConfig(options: ConfigOptions): Promise<NodeJS.Dict<string>>
|
|
50
|
+
|
|
51
|
+
Same as `loadConfigFile` but returns the resolved config object without modifying `process.env`.
|
|
52
|
+
|
|
53
|
+
### Config.getEnvVars(options): NodeJS.Dict<string>
|
|
54
|
+
|
|
55
|
+
Read and parse an env file (plaintext or encrypted). Returns `{}` if no env file is found. Throws if an encrypted file is found but no `encryptionKey` is provided.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
Config.getEnvVars({ rootDir: '.', envName: null, encryptionKey: null })
|
|
59
|
+
// => { FOO: 'bar', BAZ: 'qux' }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Config.resolveConfig(module: any): Promise<NodeJS.Dict<string>>
|
|
63
|
+
|
|
64
|
+
Normalize an imported config module into a flat string dictionary. Handles four export shapes: named exports, default object, default sync function, and default async function. Numbers and booleans are stringified. Objects and arrays throw.
|
|
65
|
+
|
|
66
|
+
### Config.generateEncryptedEnvFile(options): void
|
|
67
|
+
|
|
68
|
+
Read a plaintext `.env` file and write its AES-256-GCM encrypted counterpart (`.env.encrypted` or `.env.<envName>.encrypted`).
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
Config.generateEncryptedEnvFile({
|
|
72
|
+
rootDir: process.cwd(),
|
|
73
|
+
envName: null,
|
|
74
|
+
encryptionKey: key
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Config.generateDecryptedEnvFile(options): void
|
|
79
|
+
|
|
80
|
+
Read an encrypted `.env.encrypted` file and write the decrypted plaintext. Throws if the target file already exists unless `force: true`.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
Config.generateDecryptedEnvFile({
|
|
84
|
+
rootDir: process.cwd(),
|
|
85
|
+
envName: 'production',
|
|
86
|
+
encryptionKey: key,
|
|
87
|
+
force: true
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Config.generateEncryptionKey(): string
|
|
92
|
+
|
|
93
|
+
Generate a cryptographically random 32-byte key as a 64-character hex string.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const key = Config.generateEncryptionKey()
|
|
97
|
+
// => '7a3f8b2c...' (64 hex chars)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Config.Resolve<T> (type utility)
|
|
101
|
+
|
|
102
|
+
Extract the resolved key-value shape from a config module type. Use in an `environment.d.ts` file to make `process.env` type-safe.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import type { Config } from '@maestro-js/config'
|
|
106
|
+
import type appConfig from './config/app.ts'
|
|
107
|
+
|
|
108
|
+
declare global {
|
|
109
|
+
namespace NodeJS {
|
|
110
|
+
interface ProcessEnv extends Config.Resolve<typeof appConfig> {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Common Patterns
|
|
116
|
+
|
|
117
|
+
### Config file with named exports
|
|
118
|
+
|
|
119
|
+
Place config files in `<rootDir>/config/`. Supported extensions: `.mts`, `.cts`, `.ts`, `.mjs`, `.cjs`, `.js`.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// config/app.ts
|
|
123
|
+
export const DB_HOST = 'localhost'
|
|
124
|
+
export const DB_PORT = '3306'
|
|
125
|
+
export const DEBUG = true // stringified to 'true'
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Config file as a function (derive values from .env)
|
|
129
|
+
|
|
130
|
+
When the config exports a function, it receives the merged env vars (process.env values take precedence over .env file values).
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// config/app.ts
|
|
134
|
+
export default (env: Record<string, string | undefined>) => ({
|
|
135
|
+
DATABASE_URL: `mysql://${env.DB_USER}:${env.DB_PASS}@${env.DB_HOST}:${env.DB_PORT}/${env.DB_NAME}`,
|
|
136
|
+
API_KEY: env.API_KEY
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Async config function
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// config/app.ts
|
|
144
|
+
export default async (env: Record<string, string | undefined>) => {
|
|
145
|
+
return {
|
|
146
|
+
SECRET: env.SECRET,
|
|
147
|
+
COMPUTED: await someAsyncOperation()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Env file resolution order
|
|
153
|
+
|
|
154
|
+
When `envName` is provided (e.g., `'production'`), files are checked in this order:
|
|
155
|
+
1. `.env.production`
|
|
156
|
+
2. `.env.production.encrypted`
|
|
157
|
+
3. `.env`
|
|
158
|
+
4. `.env.encrypted`
|
|
159
|
+
|
|
160
|
+
When `envName` is null:
|
|
161
|
+
1. `.env`
|
|
162
|
+
2. `.env.encrypted`
|
|
163
|
+
|
|
164
|
+
### Encryption round-trip
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const key = Config.generateEncryptionKey()
|
|
168
|
+
|
|
169
|
+
// Encrypt: reads .env, writes .env.encrypted
|
|
170
|
+
Config.generateEncryptedEnvFile({ rootDir: '.', envName: null, encryptionKey: key })
|
|
171
|
+
|
|
172
|
+
// Decrypt: reads .env.encrypted, writes .env
|
|
173
|
+
Config.generateDecryptedEnvFile({ rootDir: '.', envName: null, encryptionKey: key, force: true })
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Encrypted values use the format `<iv_base64>.<authTag_base64>.<ciphertext_base64>` per key.
|
|
177
|
+
|
|
178
|
+
### Auto-load entry point
|
|
179
|
+
|
|
180
|
+
The `@maestro-js/config/load` export is a standalone script intended for `node --import`. It parses CLI arguments and calls `Config.loadConfigFile`.
|
|
181
|
+
|
|
182
|
+
CLI flags:
|
|
183
|
+
- `--config <name>` (required) -- config file name without extension
|
|
184
|
+
- `--env <name>` -- environment name
|
|
185
|
+
- `--key <hex>` -- encryption key (falls back to `MAESTRO_ENV_ENCRYPTION_KEY` env var)
|
|
186
|
+
- `--root <path>` -- project root (defaults to `process.cwd()`)
|
|
187
|
+
|
|
188
|
+
## Cross-Package Integration
|
|
189
|
+
|
|
190
|
+
Config is a standalone package with zero `@maestro-js` dependencies. It is used by:
|
|
191
|
+
|
|
192
|
+
- **CLI** -- The Maestro CLI uses `Config.generateEncryptedEnvFile`, `Config.generateDecryptedEnvFile`, and `Config.generateEncryptionKey` for the `env:encrypt`, `env:decrypt`, and `env:gen-key` commands.
|
|
193
|
+
- **Application boot** -- Applications use `Config.loadConfigFile` (or `node --import @maestro-js/config/load`) to populate `process.env` before initializing other Maestro packages that read from `process.env`.
|
|
194
|
+
- **Type safety** -- The `Config.Resolve` utility type lets applications create a typed `process.env` derived from the config module shape.
|
|
195
|
+
|
|
196
|
+
## Testing
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
pnpm --filter @maestro-js/config test
|
|
200
|
+
cd packages/config && npx beartest ./tests/config.test.ts
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Tests use `beartest-js` with `expect`. They create temporary directories, write config/env files, and verify loading, encryption round-trips, and error cases.
|