@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.
- package/package.json +6 -5
- package/src/constants.spec.ts +16 -0
- package/src/constants.ts +21 -0
- package/src/convert/camel-to-snake.spec.ts +244 -0
- package/src/convert/camel-to-snake.ts +135 -0
- package/src/convert/snake-to-camel.spec.ts +221 -0
- package/src/convert/snake-to-camel.ts +101 -0
- package/src/env.spec.ts +468 -0
- package/src/env.ts +125 -0
- package/src/index.ts +11 -0
- package/src/path.spec.ts +34 -0
- package/src/path.ts +39 -0
- package/src/shell.ts +58 -0
- package/src/test/fixtures/repo/path/to/module/.dagger/src/index.ts +1 -0
- package/src/test/fixtures/repo/path/to/module/dagger.json +0 -0
- package/src/test/fixtures/repo/pnpm-lock.yaml +0 -0
|
@@ -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
|
+
}
|
package/src/env.spec.ts
ADDED
|
@@ -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
|
+
}
|