ubersearch 0.0.0-development

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +374 -0
  3. package/package.json +76 -0
  4. package/src/app/index.ts +30 -0
  5. package/src/bootstrap/container.ts +157 -0
  6. package/src/cli.ts +380 -0
  7. package/src/config/defineConfig.ts +176 -0
  8. package/src/config/load.ts +368 -0
  9. package/src/config/types.ts +86 -0
  10. package/src/config/validation.ts +148 -0
  11. package/src/core/cache.ts +74 -0
  12. package/src/core/container.ts +268 -0
  13. package/src/core/credits/CreditManager.ts +158 -0
  14. package/src/core/credits/CreditStateProvider.ts +151 -0
  15. package/src/core/credits/FileCreditStateProvider.ts +137 -0
  16. package/src/core/credits/index.ts +3 -0
  17. package/src/core/docker/dockerComposeHelper.ts +177 -0
  18. package/src/core/docker/dockerLifecycleManager.ts +361 -0
  19. package/src/core/docker/index.ts +8 -0
  20. package/src/core/logger.ts +146 -0
  21. package/src/core/orchestrator.ts +103 -0
  22. package/src/core/paths.ts +157 -0
  23. package/src/core/provider/ILifecycleProvider.ts +120 -0
  24. package/src/core/provider/ProviderFactory.ts +120 -0
  25. package/src/core/provider.ts +61 -0
  26. package/src/core/serviceKeys.ts +45 -0
  27. package/src/core/strategy/AllProvidersStrategy.ts +245 -0
  28. package/src/core/strategy/FirstSuccessStrategy.ts +98 -0
  29. package/src/core/strategy/ISearchStrategy.ts +94 -0
  30. package/src/core/strategy/StrategyFactory.ts +204 -0
  31. package/src/core/strategy/index.ts +9 -0
  32. package/src/core/strategy/types.ts +56 -0
  33. package/src/core/types.ts +58 -0
  34. package/src/index.ts +1 -0
  35. package/src/plugin/PluginRegistry.ts +336 -0
  36. package/src/plugin/builtin.ts +130 -0
  37. package/src/plugin/index.ts +33 -0
  38. package/src/plugin/types.ts +212 -0
  39. package/src/providers/BaseProvider.ts +49 -0
  40. package/src/providers/brave.ts +66 -0
  41. package/src/providers/constants.ts +13 -0
  42. package/src/providers/helpers/index.ts +24 -0
  43. package/src/providers/helpers/lifecycleHelpers.ts +110 -0
  44. package/src/providers/helpers/resultMappers.ts +168 -0
  45. package/src/providers/index.ts +6 -0
  46. package/src/providers/linkup.ts +114 -0
  47. package/src/providers/retry.ts +95 -0
  48. package/src/providers/searchxng.ts +163 -0
  49. package/src/providers/tavily.ts +73 -0
  50. package/src/providers/types/index.ts +185 -0
  51. package/src/providers/utils.ts +182 -0
  52. package/src/tool/allSearchTool.ts +110 -0
  53. package/src/tool/interface.ts +71 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Configuration loader with multi-path resolution and validation
3
+ *
4
+ * Supports multiple config formats:
5
+ * - JSON: allsearch.config.json
6
+ * - TypeScript: allsearch.config.ts (with defineConfig helper)
7
+ *
8
+ * Config resolution order:
9
+ * 1. Explicit path (if provided)
10
+ * 2. Local directory (./allsearch.config.{ts,json})
11
+ * 3. XDG config ($XDG_CONFIG_HOME/allsearch/config.{ts,json})
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, isAbsolute, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { PluginRegistry, registerBuiltInPlugins } from "../plugin";
19
+ import type { ConfigFactory, ExtendedSearchConfig } from "./defineConfig";
20
+ import { formatValidationErrors, validateConfigSafe } from "./validation";
21
+
22
+ /**
23
+ * Get package root directory
24
+ * Handles both development (src/) and bundled (dist/) environments
25
+ */
26
+ function getPackageRoot(): string {
27
+ const currentFile = fileURLToPath(import.meta.url);
28
+ const currentDir = dirname(currentFile);
29
+
30
+ // Check if we're in dist/ or src/
31
+ if (currentDir.includes("/dist")) {
32
+ // Bundled: dist/cli.js -> go up 1 level
33
+ return dirname(currentDir);
34
+ }
35
+ // Development: src/config/load.ts -> go up 2 levels
36
+ return dirname(dirname(currentDir));
37
+ }
38
+
39
+ /** Supported config file extensions */
40
+ const CONFIG_EXTENSIONS = [".ts", ".json"] as const;
41
+
42
+ /** Config file base names */
43
+ const CONFIG_BASENAMES = {
44
+ local: "allsearch.config",
45
+ xdg: "config",
46
+ } as const;
47
+
48
+ /**
49
+ * Get local config paths (current directory)
50
+ * Returns paths for both .ts and .json variants
51
+ */
52
+ function getLocalConfigPaths(): string[] {
53
+ const base = join(process.cwd(), CONFIG_BASENAMES.local);
54
+ return CONFIG_EXTENSIONS.map((ext) => `${base}${ext}`);
55
+ }
56
+
57
+ /**
58
+ * Get XDG config paths ($XDG_CONFIG_HOME/allsearch/config.{ts,json})
59
+ */
60
+ function getXdgConfigPaths(): string[] {
61
+ const xdg = process.env.XDG_CONFIG_HOME;
62
+ const baseDir = xdg ?? join(homedir(), ".config");
63
+ const base = join(baseDir, "allsearch", CONFIG_BASENAMES.xdg);
64
+ return CONFIG_EXTENSIONS.map((ext) => `${base}${ext}`);
65
+ }
66
+
67
+ /**
68
+ * Get all possible config file paths in order of preference
69
+ * TypeScript files are preferred over JSON when both exist
70
+ */
71
+ export function getConfigPaths(explicitPath?: string): string[] {
72
+ const paths: string[] = [];
73
+
74
+ // Explicit path first (if provided)
75
+ if (explicitPath) {
76
+ paths.push(explicitPath);
77
+ }
78
+
79
+ // Local directory (prefer .ts over .json)
80
+ paths.push(...getLocalConfigPaths());
81
+
82
+ // XDG config (prefer .ts over .json)
83
+ paths.push(...getXdgConfigPaths());
84
+
85
+ return paths;
86
+ }
87
+
88
+ /**
89
+ * Check if a path is a TypeScript config
90
+ */
91
+ function isTypeScriptConfig(filePath: string): boolean {
92
+ return filePath.endsWith(".ts");
93
+ }
94
+
95
+ /**
96
+ * Load a TypeScript config file
97
+ * Uses Bun's native TS support for importing
98
+ */
99
+ async function loadTypeScriptConfig(path: string): Promise<ExtendedSearchConfig> {
100
+ try {
101
+ // Use dynamic import for TS files
102
+ // Bun natively supports importing .ts files
103
+ const module = await import(path);
104
+
105
+ // Config can be default export or named 'config'
106
+ const configOrFactory = module.default ?? module.config;
107
+
108
+ if (!configOrFactory) {
109
+ throw new Error(`Config file must export a default configuration or named 'config' export`);
110
+ }
111
+
112
+ // Handle factory functions (async config)
113
+ if (typeof configOrFactory === "function") {
114
+ return await (configOrFactory as ConfigFactory)();
115
+ }
116
+
117
+ return configOrFactory as ExtendedSearchConfig;
118
+ } catch (error) {
119
+ throw new Error(
120
+ `Failed to load TypeScript config from ${path}: ${error instanceof Error ? error.message : String(error)}`,
121
+ );
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Load a JSON config file
127
+ */
128
+ function loadJsonConfig(path: string): ExtendedSearchConfig {
129
+ const raw = readFileSync(path, "utf8");
130
+ return JSON.parse(raw);
131
+ }
132
+
133
+ /**
134
+ * Resolve relative paths in config to absolute paths
135
+ * This ensures paths like ./providers/searxng/docker-compose.yml work
136
+ * when running from any directory
137
+ *
138
+ * @param config - The loaded configuration
139
+ * @param configFilePath - The absolute path to the config file
140
+ * @returns Config with resolved absolute paths
141
+ */
142
+ function resolveConfigPaths(
143
+ config: ExtendedSearchConfig,
144
+ configFilePath: string,
145
+ ): ExtendedSearchConfig {
146
+ // Handle empty or malformed config
147
+ if (!config.engines || !Array.isArray(config.engines)) {
148
+ return config;
149
+ }
150
+
151
+ const configDir = dirname(configFilePath);
152
+
153
+ return {
154
+ ...config,
155
+ engines: config.engines.map((engine) => {
156
+ // Check if engine has composeFile property (SearXNG type)
157
+ if ("composeFile" in engine && engine.composeFile) {
158
+ // Only resolve if it's a relative path
159
+ if (!isAbsolute(engine.composeFile)) {
160
+ return {
161
+ ...engine,
162
+ composeFile: join(configDir, engine.composeFile),
163
+ };
164
+ }
165
+ }
166
+ return engine;
167
+ }),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Options for loading configuration
173
+ */
174
+ export interface LoadConfigOptions {
175
+ /** Skip config validation */
176
+ skipValidation?: boolean;
177
+ /** Custom plugin registry (defaults to singleton) */
178
+ registry?: PluginRegistry;
179
+ /** Skip registering built-in plugins */
180
+ skipBuiltInPlugins?: boolean;
181
+ }
182
+
183
+ /**
184
+ * Load configuration from the first available config file
185
+ * Supports both JSON and TypeScript configurations
186
+ *
187
+ * @param explicitPath Optional explicit path to config file
188
+ * @param options Optional loading options
189
+ * @returns Parsed and validated configuration
190
+ * @throws Error if no config file is found or validation fails
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * // Load from default locations
195
+ * const config = await loadConfig();
196
+ *
197
+ * // Load from specific path
198
+ * const config = await loadConfig('./my-config.ts');
199
+ *
200
+ * // Load with options
201
+ * const config = await loadConfig(undefined, { skipValidation: true });
202
+ * ```
203
+ */
204
+ /**
205
+ * Get default configuration when no config file is found
206
+ * Uses SearXNG as a sensible default (no API key required)
207
+ */
208
+ function getDefaultConfig(): ExtendedSearchConfig {
209
+ const packageRoot = getPackageRoot();
210
+ const composeFile = join(packageRoot, "providers", "searxng", "docker-compose.yml");
211
+
212
+ return {
213
+ defaultEngineOrder: ["searchxng"],
214
+ engines: [
215
+ {
216
+ id: "searchxng",
217
+ type: "searchxng",
218
+ enabled: true,
219
+ displayName: "SearXNG (Local)",
220
+ apiKeyEnv: "SEARXNG_API_KEY",
221
+ endpoint: "http://localhost:8888/search",
222
+ composeFile,
223
+ containerName: "searxng",
224
+ healthEndpoint: "http://localhost:8888/healthz",
225
+ defaultLimit: 10,
226
+ monthlyQuota: 10000,
227
+ creditCostPerSearch: 0,
228
+ lowCreditThresholdPercent: 80,
229
+ autoStart: true,
230
+ autoStop: true,
231
+ initTimeoutMs: 60000,
232
+ },
233
+ ],
234
+ };
235
+ }
236
+
237
+ export async function loadConfig(
238
+ explicitPath?: string,
239
+ options: LoadConfigOptions = {},
240
+ ): Promise<ExtendedSearchConfig> {
241
+ const { skipValidation = false, registry, skipBuiltInPlugins = false } = options;
242
+ const paths = getConfigPaths(explicitPath);
243
+
244
+ for (const path of paths) {
245
+ if (existsSync(path)) {
246
+ let rawConfig: ExtendedSearchConfig;
247
+
248
+ try {
249
+ if (isTypeScriptConfig(path)) {
250
+ rawConfig = await loadTypeScriptConfig(path);
251
+ } else {
252
+ rawConfig = loadJsonConfig(path);
253
+ }
254
+ } catch (error) {
255
+ throw new Error(
256
+ `Failed to load config file at ${path}: ${error instanceof Error ? error.message : String(error)}`,
257
+ );
258
+ }
259
+
260
+ // Resolve relative paths (like composeFile) to absolute paths
261
+ // based on the config file's directory
262
+ rawConfig = resolveConfigPaths(rawConfig, path);
263
+
264
+ // Skip validation if explicitly requested (useful for testing)
265
+ if (!skipValidation) {
266
+ // Validate configuration against schema
267
+ const result = validateConfigSafe(rawConfig);
268
+ if (!result.success) {
269
+ const errors = formatValidationErrors(result.error);
270
+ throw new Error(
271
+ `Invalid configuration in ${path}:\n${errors.map((e) => ` - ${e}`).join("\n")}`,
272
+ );
273
+ }
274
+ rawConfig = result.data as ExtendedSearchConfig;
275
+ }
276
+
277
+ // Register plugins
278
+ const pluginRegistry = registry ?? PluginRegistry.getInstance();
279
+
280
+ // Register built-in plugins first (unless skipped)
281
+ if (!skipBuiltInPlugins) {
282
+ await registerBuiltInPlugins(pluginRegistry);
283
+ }
284
+
285
+ // Register any custom plugins from config
286
+ if (rawConfig.plugins) {
287
+ for (const plugin of rawConfig.plugins) {
288
+ await pluginRegistry.register(plugin);
289
+ }
290
+ }
291
+
292
+ return rawConfig;
293
+ }
294
+ }
295
+
296
+ // No config file found - use default configuration
297
+ console.warn("No config file found. Using default configuration (SearXNG).");
298
+ console.warn("Create a config file for custom providers: allsearch.config.json");
299
+
300
+ const defaultConfig = getDefaultConfig();
301
+
302
+ // Register plugins for default config
303
+ const pluginRegistry = registry ?? PluginRegistry.getInstance();
304
+ if (!skipBuiltInPlugins) {
305
+ await registerBuiltInPlugins(pluginRegistry);
306
+ }
307
+
308
+ return defaultConfig;
309
+ }
310
+
311
+ /**
312
+ * Synchronous version of loadConfig for JSON files only
313
+ * @deprecated Use loadConfig() for full TypeScript support
314
+ */
315
+ export function loadConfigSync(
316
+ explicitPath?: string,
317
+ options: { skipValidation?: boolean } = {},
318
+ ): ExtendedSearchConfig {
319
+ const paths = getConfigPaths(explicitPath);
320
+
321
+ for (const path of paths) {
322
+ if (existsSync(path)) {
323
+ // Skip TypeScript files in sync mode
324
+ if (isTypeScriptConfig(path)) {
325
+ continue;
326
+ }
327
+
328
+ let rawConfig: ExtendedSearchConfig;
329
+
330
+ try {
331
+ rawConfig = loadJsonConfig(path);
332
+ } catch (error) {
333
+ throw new Error(
334
+ `Failed to parse config file at ${path}: ${error instanceof Error ? error.message : String(error)}`,
335
+ );
336
+ }
337
+
338
+ // Resolve relative paths (like composeFile) to absolute paths
339
+ rawConfig = resolveConfigPaths(rawConfig, path);
340
+
341
+ if (options.skipValidation) {
342
+ return rawConfig;
343
+ }
344
+
345
+ const result = validateConfigSafe(rawConfig);
346
+ if (!result.success) {
347
+ const errors = formatValidationErrors(result.error);
348
+ throw new Error(
349
+ `Invalid configuration in ${path}:\n${errors.map((e) => ` - ${e}`).join("\n")}`,
350
+ );
351
+ }
352
+
353
+ return result.data as ExtendedSearchConfig;
354
+ }
355
+ }
356
+
357
+ console.warn("No config file found. Using default configuration (SearXNG).");
358
+ console.warn("Create a config file for custom providers: allsearch.config.json");
359
+ return getDefaultConfig();
360
+ }
361
+
362
+ /**
363
+ * Check if a config file exists at any of the standard locations
364
+ */
365
+ export function configExists(): boolean {
366
+ const paths = getConfigPaths();
367
+ return paths.some((path) => existsSync(path));
368
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Configuration types for allsearch
3
+ */
4
+
5
+ import type { EngineId } from "../core/types";
6
+
7
+ /**
8
+ * Docker configuration options for providers that can manage container lifecycle
9
+ */
10
+ export interface DockerConfigurable {
11
+ /** Whether to auto-start the container on init */
12
+ autoStart?: boolean;
13
+ /** Whether to auto-stop the container on shutdown */
14
+ autoStop?: boolean;
15
+ /** Path to docker-compose file */
16
+ composeFile?: string;
17
+ /** Name of the container to manage */
18
+ containerName?: string;
19
+ /** Health check endpoint URL */
20
+ healthEndpoint?: string;
21
+ /** Timeout for initialization in milliseconds */
22
+ initTimeoutMs?: number;
23
+ }
24
+
25
+ export interface EngineConfigBase {
26
+ /** Unique identifier for this engine */
27
+ id: EngineId;
28
+
29
+ /** Whether this engine is enabled */
30
+ enabled: boolean;
31
+
32
+ /** Human-readable display name */
33
+ displayName: string;
34
+
35
+ /** Monthly query quota */
36
+ monthlyQuota: number;
37
+
38
+ /** Credit cost per search */
39
+ creditCostPerSearch: number;
40
+
41
+ /** Warning threshold (percentage of quota used) */
42
+ lowCreditThresholdPercent: number;
43
+ }
44
+
45
+ export interface TavilyConfig extends EngineConfigBase {
46
+ type: "tavily";
47
+ apiKeyEnv: string;
48
+ endpoint: string;
49
+ searchDepth: "basic" | "advanced";
50
+ }
51
+
52
+ export interface BraveConfig extends EngineConfigBase {
53
+ type: "brave";
54
+ apiKeyEnv: string;
55
+ endpoint: string;
56
+ defaultLimit: number;
57
+ }
58
+
59
+ export interface LinkupConfig extends EngineConfigBase, DockerConfigurable {
60
+ type: "linkup";
61
+ apiKeyEnv: string;
62
+ endpoint: string;
63
+ }
64
+
65
+ export interface SearchxngConfig extends EngineConfigBase, DockerConfigurable {
66
+ type: "searchxng";
67
+ apiKeyEnv?: string;
68
+ endpoint: string;
69
+ defaultLimit: number;
70
+ }
71
+
72
+ export type EngineConfig = TavilyConfig | BraveConfig | LinkupConfig | SearchxngConfig;
73
+
74
+ export interface AllSearchConfig {
75
+ /** Default order to try engines (can be overridden per query) */
76
+ defaultEngineOrder: EngineId[];
77
+
78
+ /** Configuration for each search provider */
79
+ engines: EngineConfig[];
80
+
81
+ /** Storage settings */
82
+ storage?: {
83
+ /** Path to store credit state (defaults to ~/.local/state/allsearch/credits.json) */
84
+ creditStatePath?: string;
85
+ };
86
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Zod schemas for configuration validation
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ // Base engine configuration (uses passthrough to preserve extra fields)
8
+ export const EngineConfigBaseSchema = z
9
+ .object({
10
+ id: z.string().min(1),
11
+ enabled: z.boolean(),
12
+ displayName: z.string().min(1),
13
+ monthlyQuota: z.number().int().positive(),
14
+ creditCostPerSearch: z.number().int().nonnegative(), // Allow 0 for free engines
15
+ lowCreditThresholdPercent: z.number().min(0).max(100),
16
+ })
17
+ .passthrough();
18
+
19
+ // Docker-specific configuration
20
+ export const DockerConfigSchema = z.object({
21
+ autoStart: z.boolean().optional(),
22
+ autoStop: z.boolean().optional(),
23
+ composeFile: z.string().optional(),
24
+ containerName: z.string().optional(),
25
+ healthEndpoint: z.string().url().optional(),
26
+ initTimeoutMs: z.number().int().positive().optional(),
27
+ });
28
+
29
+ // Provider-specific schemas
30
+ export const TavilyConfigSchema = EngineConfigBaseSchema.extend({
31
+ type: z.literal("tavily"),
32
+ apiKeyEnv: z.string(),
33
+ endpoint: z.string().url(),
34
+ searchDepth: z.enum(["basic", "advanced"]),
35
+ });
36
+
37
+ export const BraveConfigSchema = EngineConfigBaseSchema.extend({
38
+ type: z.literal("brave"),
39
+ apiKeyEnv: z.string(),
40
+ endpoint: z.string().url(),
41
+ defaultLimit: z.number().int().positive(),
42
+ });
43
+
44
+ export const LinkupConfigSchema = EngineConfigBaseSchema.extend({
45
+ type: z.literal("linkup"),
46
+ apiKeyEnv: z.string(),
47
+ endpoint: z.string().url(),
48
+ }).merge(DockerConfigSchema);
49
+
50
+ export const SearchxngConfigSchema = EngineConfigBaseSchema.extend({
51
+ type: z.literal("searchxng"),
52
+ apiKeyEnv: z.string().optional(),
53
+ endpoint: z.string().url(),
54
+ defaultLimit: z.number().int().positive(),
55
+ }).merge(DockerConfigSchema);
56
+
57
+ // Union type for all engine configs
58
+ export const EngineConfigSchema = z.discriminatedUnion("type", [
59
+ TavilyConfigSchema,
60
+ BraveConfigSchema,
61
+ LinkupConfigSchema,
62
+ SearchxngConfigSchema,
63
+ ]);
64
+
65
+ // Main configuration schema (uses passthrough to preserve extra fields)
66
+ export const AllSearchConfigSchema = z
67
+ .object({
68
+ defaultEngineOrder: z.array(z.string()).min(1),
69
+ engines: z.array(EngineConfigSchema).min(1),
70
+ storage: z
71
+ .object({
72
+ creditStatePath: z.string().optional(),
73
+ })
74
+ .optional(),
75
+ })
76
+ .passthrough();
77
+
78
+ // CLI input schema
79
+ export const CliInputSchema = z.object({
80
+ query: z.string().min(1),
81
+ limit: z.number().int().positive().optional(),
82
+ engines: z.array(z.string()).min(1).optional(),
83
+ includeRaw: z.boolean().optional(),
84
+ strategy: z.enum(["all", "first-success"]).optional(),
85
+ json: z.boolean().optional(),
86
+ });
87
+
88
+ // Export inferred types from schemas
89
+ export type ValidatedAllSearchConfig = z.infer<typeof AllSearchConfigSchema>;
90
+ export type ValidatedEngineConfig = z.infer<typeof EngineConfigSchema>;
91
+ export type ValidatedTavilyConfig = z.infer<typeof TavilyConfigSchema>;
92
+ export type ValidatedBraveConfig = z.infer<typeof BraveConfigSchema>;
93
+ export type ValidatedLinkupConfig = z.infer<typeof LinkupConfigSchema>;
94
+ export type ValidatedSearchxngConfig = z.infer<typeof SearchxngConfigSchema>;
95
+ export type ValidatedCliInput = z.infer<typeof CliInputSchema>;
96
+
97
+ /**
98
+ * Validate configuration against schema
99
+ * @param config - Raw configuration object
100
+ * @returns Validated configuration
101
+ * @throws ZodError if validation fails
102
+ */
103
+ export function validateConfig(config: unknown): ValidatedAllSearchConfig {
104
+ return AllSearchConfigSchema.parse(config);
105
+ }
106
+
107
+ /**
108
+ * Validate configuration safely (returns result object instead of throwing)
109
+ * @param config - Raw configuration object
110
+ * @returns Result object with success status and data or error
111
+ */
112
+ export function validateConfigSafe(config: unknown):
113
+ | {
114
+ success: true;
115
+ data: ValidatedAllSearchConfig;
116
+ }
117
+ | {
118
+ success: false;
119
+ error: z.ZodError;
120
+ } {
121
+ const result = AllSearchConfigSchema.safeParse(config);
122
+ if (result.success) {
123
+ return { success: true, data: result.data };
124
+ }
125
+ return { success: false, error: result.error };
126
+ }
127
+
128
+ /**
129
+ * Validate CLI input against schema
130
+ * @param input - Raw CLI input object
131
+ * @returns Validated CLI input
132
+ * @throws ZodError if validation fails
133
+ */
134
+ export function validateCliInput(input: unknown): ValidatedCliInput {
135
+ return CliInputSchema.parse(input);
136
+ }
137
+
138
+ /**
139
+ * Format Zod validation errors into human-readable messages
140
+ * @param error - ZodError from validation
141
+ * @returns Array of formatted error messages
142
+ */
143
+ export function formatValidationErrors(error: z.ZodError): string[] {
144
+ return error.issues.map((err) => {
145
+ const path = err.path.join(".");
146
+ return path ? `${path}: ${err.message}` : err.message;
147
+ });
148
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Request Cache for Multi-Search
3
+ *
4
+ * Simple in-memory cache with TTL support for caching search results
5
+ */
6
+
7
+ /**
8
+ * Cache entry with expiration time
9
+ */
10
+ interface CacheEntry<T> {
11
+ data: T;
12
+ expiresAt: number;
13
+ }
14
+
15
+ /**
16
+ * Simple in-memory cache with TTL support
17
+ */
18
+ export class SearchCache {
19
+ private cache = new Map<string, CacheEntry<unknown>>();
20
+ private readonly DEFAULT_TTL_MS = 5 * 60 * 1000;
21
+
22
+ /**
23
+ * Get a value from the cache if it exists and hasn't expired
24
+ *
25
+ * @param key - Cache key
26
+ * @returns Cached value or undefined if not found/expired
27
+ */
28
+ get<T>(key: string): T | undefined {
29
+ const entry = this.cache.get(key);
30
+ if (!entry) {
31
+ return undefined;
32
+ }
33
+
34
+ if (Date.now() > entry.expiresAt) {
35
+ this.cache.delete(key);
36
+ return undefined;
37
+ }
38
+
39
+ return entry.data as T;
40
+ }
41
+
42
+ /**
43
+ * Set a value in the cache with TTL
44
+ *
45
+ * @param key - Cache key
46
+ * @param value - Value to cache
47
+ * @param ttlMs - Time to live in milliseconds (defaults to 5 minutes)
48
+ */
49
+ set<T>(key: string, value: T, ttlMs = this.DEFAULT_TTL_MS): void {
50
+ this.cache.set(key, {
51
+ data: value,
52
+ expiresAt: Date.now() + ttlMs,
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Clear all cache entries
58
+ */
59
+ clear(): void {
60
+ this.cache.clear();
61
+ }
62
+
63
+ /**
64
+ * Remove expired entries from the cache
65
+ */
66
+ prune(): void {
67
+ const now = Date.now();
68
+ for (const [key, entry] of this.cache.entries()) {
69
+ if (now > entry.expiresAt) {
70
+ this.cache.delete(key);
71
+ }
72
+ }
73
+ }
74
+ }