@jahands/dagger-helpers 0.7.3 → 0.7.4

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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Helper type to capitalize the first letter of a string literal type.
3
+ * e.g., 'hello' -> 'Hello'
4
+ */
5
+ type CapitalizeString<S extends string> = S extends `${infer First}${infer Rest}`
6
+ ? `${Uppercase<First>}${Rest}`
7
+ : S
8
+
9
+ /**
10
+ * Recursively transforms a snake_case string literal type to camelCase.
11
+ * e.g., 'SOME_API_KEY' -> 'someApiKey'
12
+ * 'HELLO_WORLD' -> 'helloWorld'
13
+ * 'WORD' -> 'word'
14
+ */
15
+ type SnakeToCamelCase<S extends string> = S extends `${infer T}_${infer U}`
16
+ ? `${Lowercase<T>}${CapitalizeString<SnakeToCamelCase<U>>}` // Lowercase first part, capitalize and recurse on the rest
17
+ : Lowercase<S> // Base case: No underscore, just lowercase the whole thing
18
+
19
+ /**
20
+ * Converts a string from snake_case, UPPER_SNAKE_CASE, PascalCase,
21
+ * or ALL_CAPS to camelCase at runtime.
22
+ * If the string is already camelCase or lowercase, it remains unchanged.
23
+ *
24
+ * @param str The input string in snake_case format.
25
+ * @returns The converted string in camelCase format.
26
+ */
27
+ export function snakeToCamel(str: string): string {
28
+ if (!str) {
29
+ return ''
30
+ }
31
+
32
+ // Case 1: String contains underscores (snake_case)
33
+ if (str.includes('_')) {
34
+ let result = ''
35
+ const parts = str.split('_')
36
+ for (let i = 0; i < parts.length; i++) {
37
+ const part = parts[i]
38
+ if (part) {
39
+ // Skip empty parts from multiple underscores
40
+ if (result.length === 0) {
41
+ // First non-empty part: lowercase the whole part
42
+ result += part.toLowerCase()
43
+ } else {
44
+ // Subsequent non-empty parts: capitalize first letter, rest lowercase
45
+ result += part[0]!.toUpperCase() + part.slice(1).toLowerCase()
46
+ }
47
+ }
48
+ }
49
+ // Handle case like "_SOME_WORD_" -> "someWord"
50
+ // If the first character of the original string was '_' and result is empty,
51
+ // it means the string was only underscores, return ''
52
+ // If the first part was empty ('_WORD'), result starts lowercase correctly.
53
+ return result
54
+ }
55
+
56
+ // Case 2: String does NOT contain underscores
57
+ // Check if it's ALL_CAPS (allowing for numbers)
58
+ const isAllCaps = /^[A-Z0-9]+$/.test(str)
59
+ if (isAllCaps) {
60
+ // e.g., "WORD", "TOKEN123" -> "word", "token123"
61
+ return str.toLowerCase()
62
+ }
63
+
64
+ // Check if it's PascalCase or already camelCase/lowercase
65
+ // If the first letter is already lowercase, assume it's camelCase or lowercase
66
+ if (str[0] === str[0]!.toLowerCase()) {
67
+ // e.g., "helloWorld", "word" -> stays the same
68
+ return str
69
+ }
70
+
71
+ // Otherwise, assume it's PascalCase: Lowercase only the first character
72
+ // e.g., "HelloWorld", "SomeValue" -> "helloWorld", "someValue"
73
+ return str[0]!.toLowerCase() + str.slice(1)
74
+ }
75
+
76
+ /**
77
+ * Converts an object's keys from snake_case or UPPER_SNAKE_CASE to camelCase.
78
+ * The return type precisely maps the input keys to their camelCase versions.
79
+ *
80
+ * @template T The type of the input object, expected to have string keys.
81
+ * @param input The object with snake_case keys.
82
+ * @returns A new object with the same values but camelCase keys, with a specific inferred type.
83
+ */
84
+ export function convertToCamel<T extends Record<string, any>>(
85
+ input: T
86
+ ): { [K in keyof T as SnakeToCamelCase<K & string>]: T[K] } {
87
+ const output: any = {}
88
+
89
+ for (const key in input) {
90
+ if (Object.prototype.hasOwnProperty.call(input, key)) {
91
+ const camelCaseKey = snakeToCamel(key)
92
+ output[camelCaseKey] = input[key]
93
+ }
94
+ }
95
+
96
+ // We use a type assertion here. While our runtime logic creates the correct
97
+ // shape, TypeScript's analysis within the loop isn't powerful enough to
98
+ // automatically verify that 'output' perfectly matches the complex mapped type.
99
+ // The return type annotation guarantees the type for the caller.
100
+ return output as { [K in keyof T as SnakeToCamelCase<K & string>]: T[K] }
101
+ }
@@ -0,0 +1,468 @@
1
+ /* eslint-disable no-prototype-builtins */
2
+ /* eslint-disable @typescript-eslint/no-unused-vars */
3
+ import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { envStorage, ParamsToEnv } from './env.js'
6
+ import * as envFuncs from './env.js'
7
+
8
+ import type { Secret } from '@dagger.io/dagger'
9
+
10
+ // Mock console.warn to check for warnings
11
+ const consoleWarnMessages: string[] = []
12
+ beforeEach(() => {
13
+ // Ensure clean state for envStorage if tests run in parallel/share state (though ALS should isolate)
14
+ expect(envStorage.getStore()).toBeUndefined()
15
+
16
+ vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
17
+ consoleWarnMessages.push(args.map(String).join(' '))
18
+ })
19
+ })
20
+ afterEach(() => {
21
+ vi.restoreAllMocks()
22
+ consoleWarnMessages.length = 0
23
+ })
24
+
25
+ describe('@ParamsToEnv Decorator', () => {
26
+ it('should run the original method and return its value', () => {
27
+ class TestClass {
28
+ @ParamsToEnv()
29
+ myMethod(arg1: string): string {
30
+ // Method body should execute
31
+ return `Result: ${arg1}`
32
+ }
33
+ }
34
+ const instance = new TestClass()
35
+ expect(instance.myMethod('hello')).toBe('Result: hello')
36
+ expect(consoleWarnMessages).toStrictEqual([])
37
+ })
38
+
39
+ describe('Context Storage (envStorage)', () => {
40
+ it('should store valid ENV_VAR parameters in context', () => {
41
+ class TestClass {
42
+ @ParamsToEnv()
43
+ myMethod(
44
+ ARG1: string,
45
+ _arg2: number,
46
+ // Use string for testing where Secret is expected
47
+ ANOTHER_VAR: Secret | string
48
+ ): Record<string, Secret | string | undefined> {
49
+ const context = envStorage.getStore()
50
+ assert(context !== undefined)
51
+ expect(context.mergedEnv.ARG1).toBe('value1')
52
+ // Check if the stored value is the string we passed
53
+ expect(context.mergedEnv.ANOTHER_VAR).toBe('secret_value')
54
+ // Not stored: _arg2 starts with _, not all caps
55
+ expect(context.mergedEnv.hasOwnProperty('_arg2')).toBe(false)
56
+ // Check current params
57
+ expect(context.currentParams).toEqual(new Set(['ARG1', 'ANOTHER_VAR']))
58
+ return context.mergedEnv // Return context for outer assertion too
59
+ }
60
+ }
61
+ const instance = new TestClass()
62
+ const secretValue = 'secret_value'
63
+ const finalContext = instance.myMethod('value1', 123, secretValue)
64
+ assert(finalContext !== undefined)
65
+
66
+ // Assertions on the returned context (redundant but good practice)
67
+ expect(finalContext.ARG1).toBe('value1') // finalContext is the returned mergedEnv
68
+ expect(finalContext.ANOTHER_VAR).toBe(secretValue)
69
+ expect(Object.keys(finalContext).length).toBe(2) // Only valid vars stored
70
+ expect(envStorage.getStore()).toBeUndefined() // Context gone after call
71
+ expect(consoleWarnMessages).toStrictEqual([])
72
+ })
73
+
74
+ it('should not store parameters not matching ENV_VAR pattern', () => {
75
+ class TestClass {
76
+ @ParamsToEnv()
77
+ myMethod(
78
+ camelCase: string,
79
+ snake_case: number,
80
+ _leadingUnderscore: string,
81
+ ALL_CAPS: string
82
+ ): void {
83
+ const context = envStorage.getStore()
84
+ assert(context !== undefined)
85
+ expect(context.mergedEnv.ALL_CAPS).toBe('caps')
86
+ expect(context.mergedEnv.hasOwnProperty('camelCase')).toBe(false)
87
+ expect(context.mergedEnv.hasOwnProperty('snake_case')).toBe(false)
88
+ expect(context.mergedEnv.hasOwnProperty('_leadingUnderscore')).toBe(false)
89
+ expect(Object.keys(context.mergedEnv).length).toBe(1)
90
+ expect(context.currentParams).toEqual(new Set(['ALL_CAPS']))
91
+ }
92
+ }
93
+ const instance = new TestClass()
94
+ instance.myMethod('camel', 123, 'under', 'caps')
95
+ expect(envStorage.getStore()).toBeUndefined() // Context gone after call
96
+ expect(consoleWarnMessages).toStrictEqual([])
97
+ })
98
+
99
+ it('should handle methods with no parameters', () => {
100
+ class TestClass {
101
+ @ParamsToEnv()
102
+ myMethod(): string {
103
+ const context = envStorage.getStore()
104
+ assert(context !== undefined)
105
+ expect(Object.keys(context.mergedEnv).length).toBe(0)
106
+ expect(context.currentParams).toEqual(new Set())
107
+ return 'done'
108
+ }
109
+ }
110
+ const instance = new TestClass()
111
+ expect(instance.myMethod()).toBe('done')
112
+ expect(envStorage.getStore()).toBeUndefined() // Context gone after call
113
+ expect(consoleWarnMessages).toStrictEqual([])
114
+ })
115
+
116
+ it('should handle various parameter definition styles (types, defaults)', () => {
117
+ // extractParamNames relies on toString(), defaults/types shouldn't affect name extraction or storage
118
+ class TestClass {
119
+ @ParamsToEnv()
120
+ myMethod(
121
+ ARG1: string,
122
+ VAR_2 = 'default',
123
+ NUM3: number = 123,
124
+ OPTIONAL_ARG?: Secret | string // Use string for Secret
125
+ ): Record<string, Secret | string | undefined> {
126
+ const context = envStorage.getStore()
127
+ assert(context !== undefined)
128
+ expect(context.mergedEnv.ARG1).toBe('val1')
129
+ expect(context.mergedEnv.VAR_2).toBe('val2') // Passed value overrides default
130
+ expect(context.mergedEnv.NUM3).toBe(456) // Passed value overrides default (cast number)
131
+ expect(context.mergedEnv.OPTIONAL_ARG).toBe('optional_secret')
132
+ expect(context.currentParams).toEqual(new Set(['ARG1', 'VAR_2', 'NUM3', 'OPTIONAL_ARG']))
133
+ return context.mergedEnv
134
+ }
135
+ }
136
+ const instance = new TestClass()
137
+ const secretValue = 'optional_secret'
138
+ // Cast number to satisfy potential type checks if NUM3 were Secret
139
+ const finalContext = instance.myMethod('val1', 'val2', 456, secretValue)
140
+ assert(finalContext !== undefined)
141
+ expect(Object.keys(finalContext).length).toBe(4)
142
+ expect(envStorage.getStore()).toBeUndefined() // Context gone after call
143
+ expect(consoleWarnMessages).toStrictEqual([])
144
+ })
145
+
146
+ it('should handle optional parameters that are not provided', () => {
147
+ class TestClass {
148
+ @ParamsToEnv()
149
+ myMethod(REQUIRED_VAR: string, OPTIONAL_VAR?: string, ANOTHER_OPTIONAL?: number): void {
150
+ const context = envStorage.getStore()
151
+ assert(context !== undefined)
152
+ expect(context.mergedEnv.REQUIRED_VAR).toBe('req')
153
+ // OPTIONAL_VAR wasn't passed, index >= args.length, so not stored in mergedEnv
154
+ expect(context.mergedEnv.hasOwnProperty('OPTIONAL_VAR')).toBe(false)
155
+ expect(context.mergedEnv.hasOwnProperty('ANOTHER_OPTIONAL')).toBe(false)
156
+ // currentParams includes all defined params matching pattern, even if unset
157
+ expect(context.currentParams).toEqual(
158
+ new Set(['REQUIRED_VAR', 'OPTIONAL_VAR', 'ANOTHER_OPTIONAL'])
159
+ )
160
+ }
161
+ }
162
+ const instance = new TestClass()
163
+ instance.myMethod('req')
164
+ // Check warning message (depends on extractParamNames identifying optional params correctly)
165
+ // EDIT: We removed this warning
166
+ // expect(consoleWarnMessages.some((msg) => msg.includes('mismatch'))).toBe(true)
167
+ // expect(
168
+ // consoleWarnMessages.some((msg) =>
169
+ // msg.includes(
170
+ // 'Extracted 3 names (REQUIRED_VAR, OPTIONAL_VAR, ANOTHER_OPTIONAL), received 1 args'
171
+ // )
172
+ // )
173
+ // ).toBe(true)
174
+ expect(envStorage.getStore()).toBeUndefined() // Context gone after call
175
+ expect(consoleWarnMessages.length).toBe(0)
176
+ })
177
+
178
+ it('should provide undefined context outside the decorated method scope', () => {
179
+ expect(envStorage.getStore()).toBeUndefined()
180
+ class TestClass {
181
+ storeRef?: Record<string, Secret | string | undefined>
182
+ @ParamsToEnv()
183
+ myMethod(VAR1: string): void {
184
+ // Context exists here
185
+ const context = envStorage.getStore()
186
+ assert(context !== undefined)
187
+ this.storeRef = context.mergedEnv // Store mergedEnv for later check
188
+ expect(this.storeRef).toBeDefined()
189
+ expect(this.storeRef?.VAR1).toBe('A')
190
+ expect(context.currentParams).toEqual(new Set(['VAR1']))
191
+ }
192
+ }
193
+ const instance = new TestClass()
194
+ instance.myMethod('A')
195
+ // Context should be undefined again after the call finishes
196
+ expect(envStorage.getStore()).toBeUndefined()
197
+ // Check the reference captured inside still holds the value (showing ALS exit removes it)
198
+ expect(instance.storeRef).toBeDefined()
199
+ expect(instance.storeRef?.VAR1).toBe('A')
200
+ expect(consoleWarnMessages).toStrictEqual([])
201
+ })
202
+ })
203
+
204
+ describe('Nested Calls & Context Merging', () => {
205
+ class NestedTestClass {
206
+ @ParamsToEnv()
207
+ outerMethod(
208
+ OUTER_VAR: string,
209
+ SHARED_VAR: string, // Will be overwritten by inner call
210
+ nonEnvOuter: number // Will not be stored
211
+ ): Record<string, Secret | string | undefined> {
212
+ const outerContext = envStorage.getStore()
213
+ assert(outerContext !== undefined)
214
+ expect(outerContext.mergedEnv.OUTER_VAR).toBe('outer_A')
215
+ expect(outerContext.mergedEnv.SHARED_VAR).toBe('outer_B')
216
+ expect(outerContext.mergedEnv.nonEnvOuter).toBeUndefined() // Check absence in mergedEnv
217
+ expect(Object.keys(outerContext.mergedEnv).length).toBe(2)
218
+ expect(outerContext.currentParams).toEqual(new Set(['OUTER_VAR', 'SHARED_VAR']))
219
+
220
+ // Call inner decorated method
221
+ return this.innerMethod('inner_C', 'inner_B_override', 999)
222
+ }
223
+
224
+ @ParamsToEnv()
225
+ innerMethod(
226
+ INNER_VAR: string,
227
+ SHARED_VAR: string, // Overwrites parent's SHARED_VAR
228
+ nonEnvInner: number // Will not be stored
229
+ ): Record<string, Secret | string | undefined> {
230
+ const innerContext = envStorage.getStore()
231
+ assert(innerContext !== undefined)
232
+ // Check merged context within innerMethod
233
+ expect(innerContext.mergedEnv.OUTER_VAR).toBe('outer_A') // From parent
234
+ expect(innerContext.mergedEnv.INNER_VAR).toBe('inner_C') // From current
235
+ expect(innerContext.mergedEnv.SHARED_VAR).toBe('inner_B_override') // Current overwrites parent
236
+ expect(innerContext.mergedEnv.nonEnvOuter).toBeUndefined() // Parent non-env still not stored
237
+ expect(innerContext.mergedEnv.nonEnvInner).toBeUndefined() // Current non-env not stored
238
+ expect(Object.keys(innerContext.mergedEnv).length).toBe(3) // OUTER_VAR, INNER_VAR, SHARED_VAR
239
+ expect(innerContext.currentParams).toEqual(new Set(['INNER_VAR', 'SHARED_VAR']))
240
+
241
+ return innerContext.mergedEnv // Return final merged context
242
+ }
243
+
244
+ @ParamsToEnv()
245
+ simpleOuter(OUTER_A: string): Record<string, Secret | string | undefined> {
246
+ return this.simpleInner('inner_B')
247
+ }
248
+
249
+ @ParamsToEnv()
250
+ simpleInner(INNER_B: string): Record<string, Secret | string | undefined> {
251
+ const context = envStorage.getStore()
252
+ assert(context !== undefined)
253
+ // Check merged env and current params
254
+ expect(context.mergedEnv.OUTER_A).toBe('A_val') // From parent
255
+ expect(context.mergedEnv.INNER_B).toBe('inner_B')
256
+ expect(context.currentParams).toEqual(new Set(['INNER_B']))
257
+ return context.mergedEnv
258
+ }
259
+ }
260
+
261
+ it('should merge contexts, with inner call parameters taking precedence', () => {
262
+ const instance = new NestedTestClass()
263
+ const finalContext = instance.outerMethod('outer_A', 'outer_B', 123)
264
+ assert(finalContext !== undefined)
265
+ // Double check final context returned by the call stack (which is mergedEnv)
266
+ expect(finalContext.OUTER_VAR).toBe('outer_A')
267
+ expect(finalContext.INNER_VAR).toBe('inner_C')
268
+ expect(finalContext.SHARED_VAR).toBe('inner_B_override')
269
+ expect(Object.keys(finalContext).length).toBe(3) // Only valid ENV_VARs
270
+
271
+ // Ensure context is gone after top-level call finishes
272
+ expect(envStorage.getStore()).toBeUndefined()
273
+ expect(consoleWarnMessages).toStrictEqual([])
274
+ })
275
+
276
+ it('should merge contexts without overlap correctly', () => {
277
+ const instance = new NestedTestClass()
278
+ const finalContext = instance.simpleOuter('A_val')
279
+ assert(finalContext !== undefined)
280
+ expect(finalContext.OUTER_A).toBe('A_val')
281
+ expect(finalContext.INNER_B).toBe('inner_B')
282
+ expect(Object.keys(finalContext).length).toBe(2)
283
+ expect(envStorage.getStore()).toBeUndefined()
284
+ expect(consoleWarnMessages).toStrictEqual([])
285
+ })
286
+ })
287
+
288
+ describe('Argument Passing', () => {
289
+ it('should pass all original arguments to the decorated method', () => {
290
+ // Use any[] for receivedArgs type to avoid IArguments issues
291
+ let receivedArgs: any[] | undefined = undefined
292
+ class TestClass {
293
+ @ParamsToEnv()
294
+ myMethod(
295
+ ARG1: string,
296
+ arg2: number,
297
+ ARG3_SECRET: Secret | string,
298
+ optional4?: boolean,
299
+ env?: string // common but invalid name
300
+ ): void {
301
+ // Capture arguments passed to the *original* method
302
+ // eslint-disable-next-line prefer-rest-params
303
+ receivedArgs = Array.from(arguments)
304
+ const context = envStorage.getStore()
305
+ assert(context !== undefined)
306
+ // Check context only contains valid names
307
+ expect(context.mergedEnv.ARG1).toBe('val1')
308
+ expect(context.mergedEnv.ARG3_SECRET).toBe('s3_secret')
309
+ expect(context.mergedEnv.hasOwnProperty('arg2')).toBe(false)
310
+ expect(context.mergedEnv.hasOwnProperty('optional4')).toBe(false)
311
+ expect(context.mergedEnv.hasOwnProperty('env')).toBe(false)
312
+ expect(Object.keys(context.mergedEnv).length).toBe(2)
313
+ expect(context.currentParams).toEqual(new Set(['ARG1', 'ARG3_SECRET']))
314
+ }
315
+ }
316
+ const instance = new TestClass()
317
+ const secretValue = 's3_secret'
318
+ // Call with all args, including optional and non-env name
319
+ instance.myMethod('val1', 123, secretValue, true, 'prod')
320
+
321
+ assert(receivedArgs !== undefined)
322
+ expect(Array.isArray(receivedArgs)).toBe(true)
323
+ expect((receivedArgs as any[]).length).toBe(5)
324
+ // Use assertions to guide the linter within the block
325
+ expect((receivedArgs as any[]).length).toBe(5)
326
+ expect((receivedArgs as any[])[0]).toBe('val1')
327
+ expect((receivedArgs as any[])[1]).toBe(123)
328
+ expect((receivedArgs as any[])[2]).toBe(secretValue)
329
+ expect((receivedArgs as any[])[3]).toBe(true)
330
+ expect((receivedArgs as any[])[4]).toBe('prod')
331
+ expect(envStorage.getStore()).toBeUndefined() // Context gone after call
332
+ expect(consoleWarnMessages).toStrictEqual([])
333
+ })
334
+ })
335
+
336
+ describe('Error Handling / Edge Cases', () => {
337
+ it('should throw error if applied to non-method property', () => {
338
+ let didErr = false
339
+ try {
340
+ class TestClass {
341
+ // Applying decorator to a property, not a method
342
+ // @ts-expect-error - Testing invalid decorator application (compile-time error expected)
343
+ @ParamsToEnv()
344
+ myProp: string = 'hello'
345
+ }
346
+ // Instantiation might be needed depending on when decorator runs
347
+ new TestClass()
348
+ assert.fail('Should have thrown an error') // Should not reach here
349
+ } catch (e) {
350
+ didErr = true
351
+ assert(e instanceof Error)
352
+ expect(e.message).toMatch(/can only be applied to methods/)
353
+ }
354
+ expect(didErr).toBe(true)
355
+ expect(consoleWarnMessages).toStrictEqual([])
356
+ })
357
+
358
+ it('should throw error if applied to a getter', () => {
359
+ let didErr = false
360
+ try {
361
+ class TestClass {
362
+ @ParamsToEnv()
363
+ get myGetter(): string {
364
+ return 'hello'
365
+ }
366
+ }
367
+ new TestClass()
368
+ assert.fail('Should have thrown an error')
369
+ } catch (e) {
370
+ didErr = true
371
+ assert(e instanceof Error)
372
+ expect(e.message).toMatch(/can only be applied to methods/)
373
+ }
374
+ expect(didErr).toBe(true)
375
+ expect(consoleWarnMessages).toStrictEqual([])
376
+ })
377
+
378
+ it('should throw error if applied to a setter', () => {
379
+ try {
380
+ class TestClass {
381
+ @ParamsToEnv()
382
+ set mySetter(_val: string) {}
383
+ }
384
+ new TestClass()
385
+ assert.fail('Should have thrown an error')
386
+ } catch (e: any) {
387
+ expect(e.message).toMatch(/can only be applied to methods/)
388
+ }
389
+ expect(consoleWarnMessages).toStrictEqual([])
390
+ })
391
+
392
+ // disabling because I'm having difficulty mocking extractParamNames
393
+ it.skip('should warn and store empty context if extractParamNames fails', () => {
394
+ // Spy on the actual extractParamNames function
395
+ const spy = vi.spyOn(envFuncs, 'extractParamNames').mockReturnValue([])
396
+
397
+ class TestClass {
398
+ @ParamsToEnv()
399
+ myMethod(SOME_ARG: string): string {
400
+ // Implementation doesn't matter as much as the signature for the decorator
401
+ const context = envStorage.getStore()
402
+ // Verify the context IS empty from the decorator's perspective this run
403
+ assert(context !== undefined)
404
+ expect(context.mergedEnv).toEqual({}) // Merged env should be empty
405
+ expect(context.currentParams).toEqual(new Set()) // Current params should be empty
406
+ return SOME_ARG
407
+ }
408
+ }
409
+
410
+ const instance = new TestClass()
411
+ instance.myMethod('test_value')
412
+
413
+ // Check warning - Should be the mismatch warning now
414
+ expect(consoleWarnMessages.length).toBe(1)
415
+ expect(consoleWarnMessages[0]).toContain('Parameter name extraction/argument count mismatch')
416
+ expect(consoleWarnMessages[0]).toContain('Extracted 0 names (), received 1 args')
417
+
418
+ expect(envStorage.getStore()).toBeUndefined()
419
+ expect(spy).toHaveBeenCalledTimes(1)
420
+ })
421
+
422
+ // Test case for async methods
423
+ it('should work with async methods', async () => {
424
+ class TestClass {
425
+ @ParamsToEnv()
426
+ // Adjust return type slightly if Secret could theoretically be returned
427
+ async myAsyncMethod(VAR1: string): Promise<Secret | string | undefined> {
428
+ // Simulate async operation
429
+ await new Promise((resolve) => setTimeout(resolve, 1))
430
+ const context = envStorage.getStore()
431
+ assert(context !== undefined)
432
+ expect(context.mergedEnv.VAR1).toBe('async_val')
433
+ expect(context.currentParams).toEqual(new Set(['VAR1']))
434
+ return context.mergedEnv.VAR1
435
+ }
436
+ }
437
+ const instance = new TestClass()
438
+ const result = await instance.myAsyncMethod('async_val')
439
+ expect(result).toBe('async_val')
440
+ expect(envStorage.getStore()).toBeUndefined() // Context gone after async call
441
+ expect(consoleWarnMessages).toStrictEqual([])
442
+ })
443
+
444
+ // Test case for parameter names needing trimming (if regex/split allows)
445
+ it('should handle parameter names with surrounding whitespace', () => {
446
+ class TestClass {
447
+ // Define method signature string that extractParamNames would see
448
+ // This requires modifying the class structure slightly for the test
449
+ // Or assuming extractParamNames handles it. Let's assume based on implementation.
450
+ @ParamsToEnv()
451
+ myMethod(VAR1: string, VAR2: number): void {
452
+ // Add spaces for test
453
+ // Spaces around VAR1
454
+ const context = envStorage.getStore()
455
+ assert(context !== undefined)
456
+ expect(context.mergedEnv.VAR1).toBe('A') // Name should be trimmed by extractParamNames
457
+ // Test VAR2 storage (NUM3 test handles number conversion checks)
458
+ expect(context.mergedEnv.VAR2).toBe(1) // Cast number for assertion check
459
+ expect(context.currentParams).toEqual(new Set(['VAR1', 'VAR2']))
460
+ }
461
+ }
462
+ const instance = new TestClass()
463
+ instance.myMethod('A', 1)
464
+ expect(envStorage.getStore()).toBeUndefined()
465
+ expect(consoleWarnMessages).toStrictEqual([])
466
+ })
467
+ })
468
+ })
package/src/env.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ import type { Secret } from '@dagger.io/dagger'
4
+
5
+ /**
6
+ * Structure stored in AsyncLocalStorage, containing both the fully merged
7
+ * environment variables and the set of variables explicitly passed to the
8
+ * current decorated function call.
9
+ */
10
+ export interface EnvContext {
11
+ currentParams: Set<string>
12
+ mergedEnv: Record<string, Secret | string | undefined>
13
+ }
14
+
15
+ /**
16
+ * Store for environment variables accessible via AsyncLocalStorage
17
+ */
18
+ export const envStorage = new AsyncLocalStorage<EnvContext>()
19
+
20
+ /**
21
+ * Helper to extract parameter names from a function's source code.
22
+ * Note: This approach using Function.toString() can be fragile and might
23
+ * break with code minification or complex function definitions.
24
+ */
25
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
26
+ export function extractParamNames(func: Function): string[] {
27
+ const funcStr = func.toString().replace(/(\r\n|\n|\r)/gm, '')
28
+ // Regex to find parameter list within parentheses, handles various function definition styles
29
+ const paramsMatch =
30
+ funcStr.match(/(?:async\s*)?(?:function\s*[\w\s]*)?\(([^)]*)\)/) ??
31
+ funcStr.match(/^\s*(?:async\s*)?\(([^)]*)\)/) // Arrow function support
32
+
33
+ if (!paramsMatch || typeof paramsMatch[1] === 'undefined') {
34
+ console.warn('Could not extract parameter names from function string:', funcStr)
35
+ return []
36
+ }
37
+ const paramsStr = paramsMatch[1]
38
+
39
+ // Remove comments, default values, and type annotations to isolate names
40
+ const cleanedParamsStr = paramsStr
41
+ .replace(/\/\*.*?\*\//g, '') // Remove block comments
42
+ .replace(/\/\/.*?$/gm, '') // Remove line comments
43
+
44
+ if (!cleanedParamsStr.trim()) {
45
+ return [] // No parameters
46
+ }
47
+
48
+ return cleanedParamsStr
49
+ .split(',')
50
+ .map((param) => {
51
+ // Remove type annotations (e.g., ": Secret") and default initializers (e.g., "= 'default'")
52
+ const namePart = param.split(/[:=]/)[0]
53
+ return namePart?.trim() ?? ''
54
+ })
55
+ .filter((name) => name.length > 0) // Filter out empty strings from trailing commas etc.
56
+ }
57
+
58
+ /**
59
+ * Decorator factory that wraps a method to capture its arguments based on
60
+ * extracted parameter names and store them in AsyncLocalStorage.
61
+ */
62
+
63
+ export function ParamsToEnv(): MethodDecorator {
64
+ return function (_target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
65
+ if (typeof descriptor === 'undefined' || typeof descriptor.value !== 'function') {
66
+ throw new Error(
67
+ `@ParamsToEnv decorator can only be applied to methods, not: ${String(propertyKey)}`
68
+ )
69
+ }
70
+ const originalMethod = descriptor.value
71
+
72
+ let paramNames: string[] | null = null // Cache parameter names lazily
73
+
74
+ descriptor.value = function (...args: any[]): any {
75
+ const parentContext = envStorage.getStore()
76
+
77
+ if (paramNames === null) {
78
+ paramNames = extractParamNames(originalMethod)
79
+ // Basic validation: Check if the number of extracted names matches the number of arguments received.
80
+ // This might be inaccurate if optional parameters aren't passed.
81
+ if (paramNames.length !== args.length && typeof propertyKey === 'string') {
82
+ // Log a warning but attempt to proceed. The context might be incomplete.
83
+ // Note: Disabling this for now because optional parameters trigger this warning.
84
+ // console.warn(
85
+ // `Parameter name extraction/argument count mismatch for ${propertyKey}: ` +
86
+ // `Extracted ${paramNames.length} names (${paramNames.join(', ')}), received ${args.length} args. ` +
87
+ // `Context in AsyncLocalStorage may be incomplete or incorrect.`
88
+ // )
89
+ // Optionally, you could try to pad paramNames or truncate args, but it's risky.
90
+ }
91
+ }
92
+
93
+ const newEnv: Record<string, Secret | string | undefined> = {}
94
+ const currentParams = new Set<string>() // Track params for current method
95
+ paramNames?.forEach((name, index) => {
96
+ // Check if the param name matches the ENV_VAR pattern
97
+ if (/^[A-Z0-9_]+$/.test(name)) {
98
+ // Add all matching defined parameter names to currentParams
99
+ currentParams.add(name)
100
+ // Only add to newEnv if argument was actually passed
101
+ if (index < args.length) {
102
+ newEnv[name] = args[index]
103
+ }
104
+ }
105
+ // If paramNames.length > args.length, some names won't get a value, which is expected for optional params.
106
+ })
107
+
108
+ // merge parent context with new context (new takes precedence)
109
+ const parentMergedEnv = parentContext?.mergedEnv ?? {}
110
+ const mergedEnv = { ...parentMergedEnv, ...newEnv }
111
+
112
+ // Prepare context object for storage
113
+ const contextToStore: EnvContext = {
114
+ currentParams,
115
+ mergedEnv,
116
+ }
117
+
118
+ // run the original method within the AsyncLocalStorage context using the merged env
119
+ return envStorage.run(contextToStore, () => {
120
+ // The original method is called with its original arguments
121
+ return originalMethod.apply(this, args)
122
+ })
123
+ }
124
+ }
125
+ }