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.
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/package.json +76 -0
- package/src/app/index.ts +30 -0
- package/src/bootstrap/container.ts +157 -0
- package/src/cli.ts +380 -0
- package/src/config/defineConfig.ts +176 -0
- package/src/config/load.ts +368 -0
- package/src/config/types.ts +86 -0
- package/src/config/validation.ts +148 -0
- package/src/core/cache.ts +74 -0
- package/src/core/container.ts +268 -0
- package/src/core/credits/CreditManager.ts +158 -0
- package/src/core/credits/CreditStateProvider.ts +151 -0
- package/src/core/credits/FileCreditStateProvider.ts +137 -0
- package/src/core/credits/index.ts +3 -0
- package/src/core/docker/dockerComposeHelper.ts +177 -0
- package/src/core/docker/dockerLifecycleManager.ts +361 -0
- package/src/core/docker/index.ts +8 -0
- package/src/core/logger.ts +146 -0
- package/src/core/orchestrator.ts +103 -0
- package/src/core/paths.ts +157 -0
- package/src/core/provider/ILifecycleProvider.ts +120 -0
- package/src/core/provider/ProviderFactory.ts +120 -0
- package/src/core/provider.ts +61 -0
- package/src/core/serviceKeys.ts +45 -0
- package/src/core/strategy/AllProvidersStrategy.ts +245 -0
- package/src/core/strategy/FirstSuccessStrategy.ts +98 -0
- package/src/core/strategy/ISearchStrategy.ts +94 -0
- package/src/core/strategy/StrategyFactory.ts +204 -0
- package/src/core/strategy/index.ts +9 -0
- package/src/core/strategy/types.ts +56 -0
- package/src/core/types.ts +58 -0
- package/src/index.ts +1 -0
- package/src/plugin/PluginRegistry.ts +336 -0
- package/src/plugin/builtin.ts +130 -0
- package/src/plugin/index.ts +33 -0
- package/src/plugin/types.ts +212 -0
- package/src/providers/BaseProvider.ts +49 -0
- package/src/providers/brave.ts +66 -0
- package/src/providers/constants.ts +13 -0
- package/src/providers/helpers/index.ts +24 -0
- package/src/providers/helpers/lifecycleHelpers.ts +110 -0
- package/src/providers/helpers/resultMappers.ts +168 -0
- package/src/providers/index.ts +6 -0
- package/src/providers/linkup.ts +114 -0
- package/src/providers/retry.ts +95 -0
- package/src/providers/searchxng.ts +163 -0
- package/src/providers/tavily.ts +73 -0
- package/src/providers/types/index.ts +185 -0
- package/src/providers/utils.ts +182 -0
- package/src/tool/allSearchTool.ts +110 -0
- 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
|
+
}
|