@buenojs/bueno 0.8.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/.env.example +109 -0
- package/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/architecture.md +652 -0
- package/bun.lock +70 -0
- package/dist/cli/index.js +3233 -0
- package/dist/index.js +9014 -0
- package/package.json +77 -0
- package/src/cache/index.ts +795 -0
- package/src/cli/ARCHITECTURE.md +837 -0
- package/src/cli/bin.ts +10 -0
- package/src/cli/commands/build.ts +425 -0
- package/src/cli/commands/dev.ts +248 -0
- package/src/cli/commands/generate.ts +541 -0
- package/src/cli/commands/help.ts +55 -0
- package/src/cli/commands/index.ts +112 -0
- package/src/cli/commands/migration.ts +355 -0
- package/src/cli/commands/new.ts +804 -0
- package/src/cli/commands/start.ts +208 -0
- package/src/cli/core/args.ts +283 -0
- package/src/cli/core/console.ts +349 -0
- package/src/cli/core/index.ts +60 -0
- package/src/cli/core/prompt.ts +424 -0
- package/src/cli/core/spinner.ts +265 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/templates/deploy.ts +295 -0
- package/src/cli/templates/docker.ts +307 -0
- package/src/cli/templates/index.ts +24 -0
- package/src/cli/utils/fs.ts +428 -0
- package/src/cli/utils/index.ts +8 -0
- package/src/cli/utils/strings.ts +197 -0
- package/src/config/env.ts +408 -0
- package/src/config/index.ts +506 -0
- package/src/config/loader.ts +329 -0
- package/src/config/merge.ts +285 -0
- package/src/config/types.ts +320 -0
- package/src/config/validation.ts +441 -0
- package/src/container/forward-ref.ts +143 -0
- package/src/container/index.ts +386 -0
- package/src/context/index.ts +360 -0
- package/src/database/index.ts +1142 -0
- package/src/database/migrations/index.ts +371 -0
- package/src/database/schema/index.ts +619 -0
- package/src/frontend/api-routes.ts +640 -0
- package/src/frontend/bundler.ts +643 -0
- package/src/frontend/console-client.ts +419 -0
- package/src/frontend/console-stream.ts +587 -0
- package/src/frontend/dev-server.ts +846 -0
- package/src/frontend/file-router.ts +611 -0
- package/src/frontend/frameworks/index.ts +106 -0
- package/src/frontend/frameworks/react.ts +85 -0
- package/src/frontend/frameworks/solid.ts +104 -0
- package/src/frontend/frameworks/svelte.ts +110 -0
- package/src/frontend/frameworks/vue.ts +92 -0
- package/src/frontend/hmr-client.ts +663 -0
- package/src/frontend/hmr.ts +728 -0
- package/src/frontend/index.ts +342 -0
- package/src/frontend/islands.ts +552 -0
- package/src/frontend/isr.ts +555 -0
- package/src/frontend/layout.ts +475 -0
- package/src/frontend/ssr/react.ts +446 -0
- package/src/frontend/ssr/solid.ts +523 -0
- package/src/frontend/ssr/svelte.ts +546 -0
- package/src/frontend/ssr/vue.ts +504 -0
- package/src/frontend/ssr.ts +699 -0
- package/src/frontend/types.ts +2274 -0
- package/src/health/index.ts +604 -0
- package/src/index.ts +410 -0
- package/src/lock/index.ts +587 -0
- package/src/logger/index.ts +444 -0
- package/src/logger/transports/index.ts +969 -0
- package/src/metrics/index.ts +494 -0
- package/src/middleware/built-in.ts +360 -0
- package/src/middleware/index.ts +94 -0
- package/src/modules/filters.ts +458 -0
- package/src/modules/guards.ts +405 -0
- package/src/modules/index.ts +1256 -0
- package/src/modules/interceptors.ts +574 -0
- package/src/modules/lazy.ts +418 -0
- package/src/modules/lifecycle.ts +478 -0
- package/src/modules/metadata.ts +90 -0
- package/src/modules/pipes.ts +626 -0
- package/src/router/index.ts +339 -0
- package/src/router/linear.ts +371 -0
- package/src/router/regex.ts +292 -0
- package/src/router/tree.ts +562 -0
- package/src/rpc/index.ts +1263 -0
- package/src/security/index.ts +436 -0
- package/src/ssg/index.ts +631 -0
- package/src/storage/index.ts +456 -0
- package/src/telemetry/index.ts +1097 -0
- package/src/testing/index.ts +1586 -0
- package/src/types/index.ts +236 -0
- package/src/types/optional-deps.d.ts +219 -0
- package/src/validation/index.ts +276 -0
- package/src/websocket/index.ts +1004 -0
- package/tests/integration/cli.test.ts +1016 -0
- package/tests/integration/fullstack.test.ts +234 -0
- package/tests/unit/cache.test.ts +174 -0
- package/tests/unit/cli-commands.test.ts +892 -0
- package/tests/unit/cli.test.ts +1258 -0
- package/tests/unit/container.test.ts +279 -0
- package/tests/unit/context.test.ts +221 -0
- package/tests/unit/database.test.ts +183 -0
- package/tests/unit/linear-router.test.ts +280 -0
- package/tests/unit/lock.test.ts +336 -0
- package/tests/unit/middleware.test.ts +184 -0
- package/tests/unit/modules.test.ts +142 -0
- package/tests/unit/pubsub.test.ts +257 -0
- package/tests/unit/regex-router.test.ts +265 -0
- package/tests/unit/router.test.ts +373 -0
- package/tests/unit/rpc.test.ts +1248 -0
- package/tests/unit/security.test.ts +174 -0
- package/tests/unit/telemetry.test.ts +371 -0
- package/tests/unit/test-cache.test.ts +110 -0
- package/tests/unit/test-database.test.ts +282 -0
- package/tests/unit/tree-router.test.ts +325 -0
- package/tests/unit/validation.test.ts +794 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration file loader for Bueno Framework
|
|
3
|
+
* Uses Bun's native TypeScript loader to import config files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { BuenoConfig, DeepPartial, UserConfig, UserConfigFn } from "./types";
|
|
7
|
+
import { deepMerge } from "./merge";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configuration file search order
|
|
11
|
+
*/
|
|
12
|
+
const CONFIG_FILES = [
|
|
13
|
+
"bueno.config.ts",
|
|
14
|
+
"bueno.config.js",
|
|
15
|
+
".buenorc.ts",
|
|
16
|
+
".buenorc.js",
|
|
17
|
+
"bueno.config.mjs",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Loaded configuration information
|
|
22
|
+
*/
|
|
23
|
+
export interface LoadedConfig {
|
|
24
|
+
/** The loaded configuration */
|
|
25
|
+
config: DeepPartial<BuenoConfig>;
|
|
26
|
+
/** Path to the config file that was loaded */
|
|
27
|
+
filePath?: string;
|
|
28
|
+
/** Whether the config was loaded from cache */
|
|
29
|
+
fromCache: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Config loader cache
|
|
34
|
+
*/
|
|
35
|
+
const configCache = new Map<string, DeepPartial<BuenoConfig>>();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a file exists
|
|
39
|
+
*/
|
|
40
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
const file = Bun.file(path);
|
|
43
|
+
return await file.exists();
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find the first existing config file
|
|
51
|
+
*/
|
|
52
|
+
export async function findConfigFile(
|
|
53
|
+
cwd?: string,
|
|
54
|
+
): Promise<string | undefined> {
|
|
55
|
+
const baseDir = cwd ?? process.cwd();
|
|
56
|
+
|
|
57
|
+
for (const file of CONFIG_FILES) {
|
|
58
|
+
const filePath = `${baseDir}/${file}`;
|
|
59
|
+
if (await fileExists(filePath)) {
|
|
60
|
+
return filePath;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Clear the config cache
|
|
69
|
+
*/
|
|
70
|
+
export function clearConfigCache(): void {
|
|
71
|
+
configCache.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get cached config
|
|
76
|
+
*/
|
|
77
|
+
export function getCachedConfig(path: string): DeepPartial<BuenoConfig> | undefined {
|
|
78
|
+
return configCache.get(path);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load a configuration file
|
|
83
|
+
* Supports both default exports and named exports
|
|
84
|
+
*/
|
|
85
|
+
export async function loadConfigFile<T extends BuenoConfig = BuenoConfig>(
|
|
86
|
+
filePath: string,
|
|
87
|
+
options?: {
|
|
88
|
+
/** Whether to use cache */
|
|
89
|
+
useCache?: boolean;
|
|
90
|
+
/** Additional context to pass to config function */
|
|
91
|
+
context?: Record<string, unknown>;
|
|
92
|
+
},
|
|
93
|
+
): Promise<LoadedConfig> {
|
|
94
|
+
const useCache = options?.useCache !== false;
|
|
95
|
+
|
|
96
|
+
// Check cache first
|
|
97
|
+
if (useCache) {
|
|
98
|
+
const cached = configCache.get(filePath);
|
|
99
|
+
if (cached) {
|
|
100
|
+
return {
|
|
101
|
+
config: cached,
|
|
102
|
+
filePath,
|
|
103
|
+
fromCache: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if file exists
|
|
109
|
+
if (!(await fileExists(filePath))) {
|
|
110
|
+
throw new Error(`Config file not found: ${filePath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Use Bun's native TypeScript loader
|
|
115
|
+
const module = await import(filePath);
|
|
116
|
+
|
|
117
|
+
let config: DeepPartial<T>;
|
|
118
|
+
|
|
119
|
+
// Handle different export styles
|
|
120
|
+
if (typeof module.default === "function") {
|
|
121
|
+
// Function export: export default defineConfig(() => ({ ... }))
|
|
122
|
+
config = await module.default(options?.context);
|
|
123
|
+
} else if (typeof module.default === "object" && module.default !== null) {
|
|
124
|
+
// Object export: export default { ... }
|
|
125
|
+
config = module.default;
|
|
126
|
+
} else if (module.config) {
|
|
127
|
+
// Named export: export const config = { ... }
|
|
128
|
+
config = module.config;
|
|
129
|
+
} else {
|
|
130
|
+
// Try to use the module itself as config
|
|
131
|
+
config = module;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Cache the result
|
|
135
|
+
if (useCache) {
|
|
136
|
+
configCache.set(filePath, config as DeepPartial<BuenoConfig>);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
config: config as DeepPartial<BuenoConfig>,
|
|
141
|
+
filePath,
|
|
142
|
+
fromCache: false,
|
|
143
|
+
};
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Failed to load config from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Load configuration from file
|
|
153
|
+
* Searches for config files in order and loads the first one found
|
|
154
|
+
*/
|
|
155
|
+
export async function loadConfig<T extends BuenoConfig = BuenoConfig>(
|
|
156
|
+
options?: {
|
|
157
|
+
/** Custom config file path */
|
|
158
|
+
configPath?: string;
|
|
159
|
+
/** Working directory to search for config */
|
|
160
|
+
cwd?: string;
|
|
161
|
+
/** Whether to use cache */
|
|
162
|
+
useCache?: boolean;
|
|
163
|
+
/** Additional context to pass to config function */
|
|
164
|
+
context?: Record<string, unknown>;
|
|
165
|
+
},
|
|
166
|
+
): Promise<LoadedConfig> {
|
|
167
|
+
// If a specific path is provided, use it
|
|
168
|
+
if (options?.configPath) {
|
|
169
|
+
return loadConfigFile<T>(options.configPath, {
|
|
170
|
+
useCache: options.useCache,
|
|
171
|
+
context: options.context,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Find config file
|
|
176
|
+
const filePath = await findConfigFile(options?.cwd);
|
|
177
|
+
|
|
178
|
+
if (!filePath) {
|
|
179
|
+
return {
|
|
180
|
+
config: {},
|
|
181
|
+
filePath: undefined,
|
|
182
|
+
fromCache: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return loadConfigFile<T>(filePath, {
|
|
187
|
+
useCache: options?.useCache,
|
|
188
|
+
context: options?.context,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Load and merge multiple config files
|
|
194
|
+
* Later files override earlier ones
|
|
195
|
+
*/
|
|
196
|
+
export async function loadConfigFiles(
|
|
197
|
+
filePaths: string[],
|
|
198
|
+
options?: {
|
|
199
|
+
/** Whether to use cache */
|
|
200
|
+
useCache?: boolean;
|
|
201
|
+
},
|
|
202
|
+
): Promise<LoadedConfig> {
|
|
203
|
+
const configs: DeepPartial<BuenoConfig>[] = [];
|
|
204
|
+
let lastFilePath: string | undefined;
|
|
205
|
+
|
|
206
|
+
for (const filePath of filePaths) {
|
|
207
|
+
if (await fileExists(filePath)) {
|
|
208
|
+
const { config } = await loadConfigFile(filePath, options);
|
|
209
|
+
configs.push(config);
|
|
210
|
+
lastFilePath = filePath;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const mergedConfig = configs.reduce(
|
|
215
|
+
(acc, config) => deepMerge(acc, config),
|
|
216
|
+
{} as DeepPartial<BuenoConfig>,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
config: mergedConfig,
|
|
221
|
+
filePath: lastFilePath,
|
|
222
|
+
fromCache: false,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Watch a config file for changes
|
|
228
|
+
* Returns an unsubscribe function
|
|
229
|
+
*/
|
|
230
|
+
export function watchConfig(
|
|
231
|
+
filePath: string,
|
|
232
|
+
callback: (config: DeepPartial<BuenoConfig>) => void,
|
|
233
|
+
options?: {
|
|
234
|
+
/** Debounce time in milliseconds */
|
|
235
|
+
debounce?: number;
|
|
236
|
+
},
|
|
237
|
+
): () => void {
|
|
238
|
+
let timeout: Timer | undefined;
|
|
239
|
+
const debounce = options?.debounce ?? 100;
|
|
240
|
+
|
|
241
|
+
// Use Bun's file watcher
|
|
242
|
+
const watcher = Bun.file(filePath);
|
|
243
|
+
|
|
244
|
+
// Note: Bun doesn't have a built-in file watcher API yet
|
|
245
|
+
// This is a placeholder for future implementation
|
|
246
|
+
// For now, we'll use a polling approach
|
|
247
|
+
|
|
248
|
+
const interval = setInterval(async () => {
|
|
249
|
+
try {
|
|
250
|
+
// Clear cache to force reload
|
|
251
|
+
configCache.delete(filePath);
|
|
252
|
+
const { config } = await loadConfigFile(filePath, { useCache: false });
|
|
253
|
+
|
|
254
|
+
if (timeout) {
|
|
255
|
+
clearTimeout(timeout);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
timeout = setTimeout(() => {
|
|
259
|
+
callback(config);
|
|
260
|
+
}, debounce);
|
|
261
|
+
} catch {
|
|
262
|
+
// Ignore errors during watch
|
|
263
|
+
}
|
|
264
|
+
}, 1000);
|
|
265
|
+
|
|
266
|
+
return () => {
|
|
267
|
+
clearInterval(interval);
|
|
268
|
+
if (timeout) {
|
|
269
|
+
clearTimeout(timeout);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Validate that a config object has the expected structure
|
|
276
|
+
*/
|
|
277
|
+
export function validateConfigStructure(
|
|
278
|
+
config: unknown,
|
|
279
|
+
): config is DeepPartial<BuenoConfig> {
|
|
280
|
+
if (config === null || typeof config !== "object") {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Basic validation - check that all top-level keys are valid
|
|
285
|
+
const validKeys = new Set([
|
|
286
|
+
"server",
|
|
287
|
+
"database",
|
|
288
|
+
"cache",
|
|
289
|
+
"logger",
|
|
290
|
+
"health",
|
|
291
|
+
"metrics",
|
|
292
|
+
"telemetry",
|
|
293
|
+
"frontend",
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
const cfg = config as Record<string, unknown>;
|
|
297
|
+
for (const key of Object.keys(cfg)) {
|
|
298
|
+
if (!validKeys.has(key)) {
|
|
299
|
+
console.warn(`Unknown config key: ${key}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Extract config file path from CLI args
|
|
308
|
+
*/
|
|
309
|
+
export function getConfigPathFromArgs(args: string[] = process.argv): string | undefined {
|
|
310
|
+
const configIndex = args.indexOf("--config");
|
|
311
|
+
if (configIndex !== -1 && args[configIndex + 1]) {
|
|
312
|
+
return args[configIndex + 1];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Also support -c shorthand
|
|
316
|
+
const shortIndex = args.indexOf("-c");
|
|
317
|
+
if (shortIndex !== -1 && args[shortIndex + 1]) {
|
|
318
|
+
return args[shortIndex + 1];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Extract config file path from environment
|
|
326
|
+
*/
|
|
327
|
+
export function getConfigPathFromEnv(): string | undefined {
|
|
328
|
+
return Bun.env.BUENO_CONFIG;
|
|
329
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep merge utilities for configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { BuenoConfig, DeepPartial } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if a value is a plain object (not an array, not null, not a class instance)
|
|
9
|
+
*/
|
|
10
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
if (value === null || typeof value !== "object") {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const proto = Object.getPrototypeOf(value);
|
|
15
|
+
return proto === null || proto === Object.prototype;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a value is an object (alias for isPlainObject)
|
|
20
|
+
*/
|
|
21
|
+
export function isObject(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return isPlainObject(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deep merge two values
|
|
27
|
+
* - Objects are merged recursively
|
|
28
|
+
* - Arrays are concatenated (not merged element-wise)
|
|
29
|
+
* - Primitive values from source override target
|
|
30
|
+
*/
|
|
31
|
+
export function deepMerge<T>(target: T, source: DeepPartial<T>): T {
|
|
32
|
+
// Handle null/undefined source
|
|
33
|
+
if (source === null || source === undefined) {
|
|
34
|
+
return target;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Handle null/undefined target
|
|
38
|
+
if (target === null || target === undefined) {
|
|
39
|
+
return source as T;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If source is not an object, return source (override)
|
|
43
|
+
if (!isPlainObject(source)) {
|
|
44
|
+
return source as T;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If target is not an object but source is, return source
|
|
48
|
+
if (!isPlainObject(target)) {
|
|
49
|
+
return source as T;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Both are objects, merge them
|
|
53
|
+
const result = { ...target } as Record<string, unknown>;
|
|
54
|
+
const sourceRecord = source as Record<string, unknown>;
|
|
55
|
+
const targetRecord = target as Record<string, unknown>;
|
|
56
|
+
|
|
57
|
+
for (const key of Object.keys(source)) {
|
|
58
|
+
const sourceValue = sourceRecord[key];
|
|
59
|
+
const targetValue = targetRecord[key];
|
|
60
|
+
|
|
61
|
+
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
|
62
|
+
// Both are objects, merge recursively
|
|
63
|
+
result[key] = deepMerge(targetValue, sourceValue as DeepPartial<typeof targetValue>);
|
|
64
|
+
} else if (Array.isArray(sourceValue) && Array.isArray(targetValue)) {
|
|
65
|
+
// Both are arrays, concatenate them
|
|
66
|
+
result[key] = [...targetValue, ...sourceValue];
|
|
67
|
+
} else {
|
|
68
|
+
// Override with source value
|
|
69
|
+
result[key] = sourceValue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result as T;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Merge multiple configuration objects
|
|
78
|
+
* Later configs have higher priority
|
|
79
|
+
*/
|
|
80
|
+
export function mergeConfigs<T extends BuenoConfig = BuenoConfig>(
|
|
81
|
+
...configs: (DeepPartial<T> | undefined | null)[]
|
|
82
|
+
): DeepPartial<T> {
|
|
83
|
+
return configs.reduce<DeepPartial<T>>((acc, config) => {
|
|
84
|
+
if (config === undefined || config === null) {
|
|
85
|
+
return acc;
|
|
86
|
+
}
|
|
87
|
+
// Use unknown as intermediate type to avoid recursive type issues
|
|
88
|
+
return deepMerge(acc as unknown as T, config as unknown as DeepPartial<T>) as unknown as DeepPartial<T>;
|
|
89
|
+
}, {} as DeepPartial<T>);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Deep clone a configuration object
|
|
94
|
+
*/
|
|
95
|
+
export function deepClone<T>(obj: T): T {
|
|
96
|
+
if (obj === null || typeof obj !== "object") {
|
|
97
|
+
return obj;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (Array.isArray(obj)) {
|
|
101
|
+
return obj.map((item) => deepClone(item)) as T;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isPlainObject(obj)) {
|
|
105
|
+
const result: Record<string, unknown> = {};
|
|
106
|
+
for (const key of Object.keys(obj)) {
|
|
107
|
+
result[key] = deepClone(obj[key]);
|
|
108
|
+
}
|
|
109
|
+
return result as T;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// For other types (Date, Map, Set, etc.), return as-is
|
|
113
|
+
return obj;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get a value from an object using dot notation
|
|
118
|
+
* @example getNestedValue({ a: { b: { c: 1 } } }, 'a.b.c') // returns 1
|
|
119
|
+
*/
|
|
120
|
+
export function getNestedValue<T = unknown>(
|
|
121
|
+
obj: Record<string, unknown>,
|
|
122
|
+
path: string,
|
|
123
|
+
): T | undefined {
|
|
124
|
+
const keys = path.split(".");
|
|
125
|
+
let current: unknown = obj;
|
|
126
|
+
|
|
127
|
+
for (const key of keys) {
|
|
128
|
+
if (current === null || current === undefined) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (isPlainObject(current)) {
|
|
132
|
+
current = current[key];
|
|
133
|
+
} else {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return current as T;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set a value in an object using dot notation
|
|
143
|
+
* @example setNestedValue({}, 'a.b.c', 1) // returns { a: { b: { c: 1 } } }
|
|
144
|
+
*/
|
|
145
|
+
export function setNestedValue(
|
|
146
|
+
obj: Record<string, unknown>,
|
|
147
|
+
path: string,
|
|
148
|
+
value: unknown,
|
|
149
|
+
): Record<string, unknown> {
|
|
150
|
+
const keys = path.split(".");
|
|
151
|
+
const result = deepClone(obj);
|
|
152
|
+
let current: Record<string, unknown> = result;
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
155
|
+
const key = keys[i];
|
|
156
|
+
if (!isPlainObject(current[key])) {
|
|
157
|
+
current[key] = {};
|
|
158
|
+
}
|
|
159
|
+
current = current[key] as Record<string, unknown>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
current[keys[keys.length - 1]] = value;
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Delete a value from an object using dot notation
|
|
168
|
+
* @example deleteNestedValue({ a: { b: { c: 1 } } }, 'a.b.c') // returns { a: { b: {} } }
|
|
169
|
+
*/
|
|
170
|
+
export function deleteNestedValue(
|
|
171
|
+
obj: Record<string, unknown>,
|
|
172
|
+
path: string,
|
|
173
|
+
): Record<string, unknown> {
|
|
174
|
+
const keys = path.split(".");
|
|
175
|
+
const result = deepClone(obj);
|
|
176
|
+
let current: Record<string, unknown> = result;
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
179
|
+
const key = keys[i];
|
|
180
|
+
if (!isPlainObject(current[key])) {
|
|
181
|
+
return result; // Path doesn't exist, nothing to delete
|
|
182
|
+
}
|
|
183
|
+
current = current[key] as Record<string, unknown>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
delete current[keys[keys.length - 1]];
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check if a path exists in an object
|
|
192
|
+
*/
|
|
193
|
+
export function hasNestedValue(
|
|
194
|
+
obj: Record<string, unknown>,
|
|
195
|
+
path: string,
|
|
196
|
+
): boolean {
|
|
197
|
+
return getNestedValue(obj, path) !== undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Flatten a nested object to dot notation keys
|
|
202
|
+
* @example flattenObject({ a: { b: { c: 1 } } }) // returns { 'a.b.c': 1 }
|
|
203
|
+
*/
|
|
204
|
+
export function flattenObject(
|
|
205
|
+
obj: Record<string, unknown>,
|
|
206
|
+
prefix = "",
|
|
207
|
+
): Record<string, unknown> {
|
|
208
|
+
const result: Record<string, unknown> = {};
|
|
209
|
+
|
|
210
|
+
for (const key of Object.keys(obj)) {
|
|
211
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
212
|
+
const value = obj[key];
|
|
213
|
+
|
|
214
|
+
if (isPlainObject(value)) {
|
|
215
|
+
Object.assign(result, flattenObject(value, newKey));
|
|
216
|
+
} else {
|
|
217
|
+
result[newKey] = value;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Unflatten a dot notation object to nested object
|
|
226
|
+
* @example unflattenObject({ 'a.b.c': 1 }) // returns { a: { b: { c: 1 } } }
|
|
227
|
+
*/
|
|
228
|
+
export function unflattenObject(
|
|
229
|
+
obj: Record<string, unknown>,
|
|
230
|
+
): Record<string, unknown> {
|
|
231
|
+
const result: Record<string, unknown> = {};
|
|
232
|
+
|
|
233
|
+
for (const key of Object.keys(obj)) {
|
|
234
|
+
const keys = key.split(".");
|
|
235
|
+
let current: Record<string, unknown> = result;
|
|
236
|
+
|
|
237
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
238
|
+
const k = keys[i];
|
|
239
|
+
if (!isPlainObject(current[k])) {
|
|
240
|
+
current[k] = {};
|
|
241
|
+
}
|
|
242
|
+
current = current[k] as Record<string, unknown>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
current[keys[keys.length - 1]] = obj[key];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Compare two configurations and return the differences
|
|
253
|
+
*/
|
|
254
|
+
export function diffConfigs(
|
|
255
|
+
target: BuenoConfig,
|
|
256
|
+
source: BuenoConfig,
|
|
257
|
+
): { added: string[]; removed: string[]; changed: string[] } {
|
|
258
|
+
const flatTarget = flattenObject(target as Record<string, unknown>);
|
|
259
|
+
const flatSource = flattenObject(source as Record<string, unknown>);
|
|
260
|
+
|
|
261
|
+
const targetKeys = new Set(Object.keys(flatTarget));
|
|
262
|
+
const sourceKeys = new Set(Object.keys(flatSource));
|
|
263
|
+
|
|
264
|
+
const added: string[] = [];
|
|
265
|
+
const removed: string[] = [];
|
|
266
|
+
const changed: string[] = [];
|
|
267
|
+
|
|
268
|
+
// Find added keys
|
|
269
|
+
for (const key of sourceKeys) {
|
|
270
|
+
if (!targetKeys.has(key)) {
|
|
271
|
+
added.push(key);
|
|
272
|
+
} else if (flatTarget[key] !== flatSource[key]) {
|
|
273
|
+
changed.push(key);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Find removed keys
|
|
278
|
+
for (const key of targetKeys) {
|
|
279
|
+
if (!sourceKeys.has(key)) {
|
|
280
|
+
removed.push(key);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { added, removed, changed };
|
|
285
|
+
}
|