ubersearch 1.6.2 → 1.7.1
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/README.md +4 -2
- package/package.json +5 -5
- package/providers/searxng/.env.example +1 -1
- package/src/bootstrap/container.ts +4 -17
- package/src/cli.ts +7 -14
- package/src/config/load.ts +12 -33
- package/src/config/validation.ts +22 -1
- package/src/core/credits/CreditManager.ts +18 -0
- package/src/core/credits/FileCreditStateProvider.ts +1 -1
- package/src/core/docker/dockerComposeHelper.ts +21 -14
- package/src/core/docker/dockerLifecycleManager.ts +60 -40
- package/src/core/errorUtils.ts +6 -0
- package/src/core/orchestrator.ts +2 -28
- package/src/core/paths.ts +56 -16
- package/src/core/provider.ts +1 -14
- package/src/core/retry.ts +102 -0
- package/src/core/strategy/AllProvidersStrategy.ts +52 -42
- package/src/core/strategy/FirstSuccessStrategy.ts +8 -12
- package/src/core/strategy/ISearchStrategy.ts +2 -2
- package/src/core/types.ts +1 -0
- package/src/mcp-server.ts +128 -28
- package/src/plugin/PluginRegistry.ts +17 -6
- package/src/plugin/builtin.ts +8 -10
- package/src/plugin/types.ts +3 -4
- package/src/providers/brave.ts +2 -7
- package/src/providers/helpers/resultMappers.ts +2 -2
- package/src/providers/linkup.ts +15 -47
- package/src/providers/retry.ts +1 -102
- package/src/providers/searchxng.ts +25 -46
- package/src/providers/tavily.ts +10 -16
- package/src/tool/interface.ts +3 -1
package/README.md
CHANGED
|
@@ -69,8 +69,10 @@ ubersearch "query" [options]
|
|
|
69
69
|
Options:
|
|
70
70
|
--json Output results as JSON
|
|
71
71
|
--engines engine1,engine2 Use specific engines
|
|
72
|
-
--strategy all|first-success Search strategy (default:
|
|
72
|
+
--strategy all|first-success Search strategy (default: first-success)
|
|
73
73
|
--limit number Max results per engine
|
|
74
|
+
--categories categories Categories filter for SearXNG (e.g., "it,science")
|
|
75
|
+
--config path Config file path
|
|
74
76
|
--include-raw Include raw provider responses
|
|
75
77
|
--help, -h Show help
|
|
76
78
|
health Run provider health checks (starts Docker-backed ones if needed)
|
|
@@ -286,7 +288,7 @@ tavily (10 results)
|
|
|
286
288
|
|
|
287
289
|
## Search Strategies
|
|
288
290
|
|
|
289
|
-
### All
|
|
291
|
+
### All
|
|
290
292
|
|
|
291
293
|
Queries all configured/enabled providers and combines results.
|
|
292
294
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ubersearch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"module": "src/index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"prepare": "git config core.hooksPath scripts/hooks",
|
|
44
|
-
"lint": "bunx biome check
|
|
45
|
-
"lint:fix": "bunx biome check --write
|
|
46
|
-
"format": "bunx biome format --write
|
|
44
|
+
"lint": "bunx biome check src/ test/ scripts/ docs/ *.json *.md",
|
|
45
|
+
"lint:fix": "bunx biome check --write src/ test/ scripts/ docs/ *.json *.md",
|
|
46
|
+
"format": "bunx biome format --write src/ test/ scripts/ docs/ *.json *.md",
|
|
47
47
|
"test": "SKIP_DOCKER_TESTS=true bun test --preload ./test/setup.ts test/",
|
|
48
48
|
"test:unit": "bun test --preload ./test/setup.ts test/unit/",
|
|
49
49
|
"test:integration": "bun test --preload ./test/setup.ts test/integration/",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"mcp": "bun run src/cli.ts mcp",
|
|
56
56
|
"mcp:test": "bun run scripts/test-mcp.ts",
|
|
57
57
|
"build": "bun run scripts/build.ts",
|
|
58
|
-
"build:binary": "bun build --compile --minify src/cli.ts --outfile dist/ubersearch",
|
|
58
|
+
"build:binary": "bun build --compile --minify src/cli.ts --outfile dist/ubersearch && bun run scripts/copy-searxng-assets.ts",
|
|
59
59
|
"semantic-release": "semantic-release",
|
|
60
60
|
"semantic-release:dry-run": "semantic-release --dry-run",
|
|
61
61
|
"release": "bun run build && bun run semantic-release"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Copy this file to .env and update values
|
|
3
3
|
|
|
4
4
|
# Secret key for SearXNG (generate with: openssl rand -hex 32)
|
|
5
|
-
|
|
5
|
+
SEARXNG_SECRET=your-secret-key-here
|
|
6
6
|
|
|
7
7
|
# API key for ubersearch integration
|
|
8
8
|
# Generate with: openssl rand -hex 16
|
|
@@ -9,9 +9,10 @@ import type { EngineConfig, UberSearchConfig } from "../config/types";
|
|
|
9
9
|
import { type Container, container } from "../core/container";
|
|
10
10
|
import { CreditManager } from "../core/credits";
|
|
11
11
|
import { FileCreditStateProvider } from "../core/credits/FileCreditStateProvider";
|
|
12
|
+
import { getErrorMessage } from "../core/errorUtils";
|
|
12
13
|
import { createLogger } from "../core/logger";
|
|
13
14
|
import { UberSearchOrchestrator } from "../core/orchestrator";
|
|
14
|
-
import type {
|
|
15
|
+
import type { SearchProvider } from "../core/provider";
|
|
15
16
|
import { ProviderRegistry } from "../core/provider";
|
|
16
17
|
import { ProviderFactory } from "../core/provider/ProviderFactory";
|
|
17
18
|
import { ServiceKeys } from "../core/serviceKeys";
|
|
@@ -103,8 +104,8 @@ export async function bootstrapContainer(
|
|
|
103
104
|
registry.register(provider);
|
|
104
105
|
log.debug(`Registered provider: ${engineConfig.id}`);
|
|
105
106
|
} catch (error) {
|
|
106
|
-
const errorMsg =
|
|
107
|
-
log.
|
|
107
|
+
const errorMsg = getErrorMessage(error);
|
|
108
|
+
log.warn(`Failed to register provider ${engineConfig.id}: ${errorMsg}`);
|
|
108
109
|
failedProviders.push(engineConfig.id);
|
|
109
110
|
}
|
|
110
111
|
}
|
|
@@ -156,17 +157,3 @@ export async function bootstrapContainer(
|
|
|
156
157
|
function createProvider(engineConfig: EngineConfig): SearchProvider {
|
|
157
158
|
return ProviderFactory.createProvider(engineConfig, container);
|
|
158
159
|
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Helper function to check if a provider implements ILifecycleProvider
|
|
162
|
-
*/
|
|
163
|
-
export function isLifecycleProvider(provider: unknown): provider is ILifecycleProvider {
|
|
164
|
-
return (
|
|
165
|
-
provider != null &&
|
|
166
|
-
typeof provider === "object" &&
|
|
167
|
-
"init" in provider &&
|
|
168
|
-
typeof provider.init === "function" &&
|
|
169
|
-
"healthcheck" in provider &&
|
|
170
|
-
typeof provider.healthcheck === "function"
|
|
171
|
-
);
|
|
172
|
-
}
|
package/src/cli.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* Unified search interface for multiple search providers
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { bootstrapContainer
|
|
8
|
+
import { bootstrapContainer } from "./bootstrap/container";
|
|
9
|
+
import { getErrorMessage } from "./core/errorUtils";
|
|
9
10
|
import type { ProviderRegistry } from "./core/provider";
|
|
10
11
|
import { ServiceKeys } from "./core/serviceKeys";
|
|
11
12
|
import { serve as serveMcp } from "./mcp-server";
|
|
13
|
+
import { isLifecycleProvider } from "./plugin/types";
|
|
12
14
|
import type { UberSearchOutput } from "./tool/interface";
|
|
13
15
|
import { getCreditStatus, uberSearch } from "./tool/uberSearchTool";
|
|
14
16
|
|
|
@@ -101,12 +103,6 @@ if (args[0] === "health") {
|
|
|
101
103
|
process.exit(0);
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
// MCP server command
|
|
105
|
-
if (args[0] === "mcp") {
|
|
106
|
-
await serveMcp();
|
|
107
|
-
process.exit(0);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
106
|
// Parse options
|
|
111
107
|
const options = {
|
|
112
108
|
json: args.includes("--json"),
|
|
@@ -213,7 +209,7 @@ async function main() {
|
|
|
213
209
|
printHumanReadable(result);
|
|
214
210
|
}
|
|
215
211
|
} catch (error) {
|
|
216
|
-
console.error("Search failed:",
|
|
212
|
+
console.error("Search failed:", getErrorMessage(error));
|
|
217
213
|
process.exit(1);
|
|
218
214
|
}
|
|
219
215
|
}
|
|
@@ -332,7 +328,7 @@ async function runHealthChecks(configPath?: string) {
|
|
|
332
328
|
});
|
|
333
329
|
console.log(`✓ ${engineId.padEnd(15)} Healthy`);
|
|
334
330
|
} catch (error) {
|
|
335
|
-
const message =
|
|
331
|
+
const message = getErrorMessage(error);
|
|
336
332
|
results.push({
|
|
337
333
|
engineId,
|
|
338
334
|
status: "unhealthy",
|
|
@@ -366,7 +362,7 @@ async function runHealthChecks(configPath?: string) {
|
|
|
366
362
|
|
|
367
363
|
console.log();
|
|
368
364
|
} catch (error) {
|
|
369
|
-
console.error("Health check failed:",
|
|
365
|
+
console.error("Health check failed:", getErrorMessage(error));
|
|
370
366
|
process.exit(1);
|
|
371
367
|
}
|
|
372
368
|
}
|
|
@@ -402,10 +398,7 @@ async function showCredits(configPath?: string) {
|
|
|
402
398
|
|
|
403
399
|
console.log();
|
|
404
400
|
} catch (error) {
|
|
405
|
-
console.error(
|
|
406
|
-
"Failed to load credits:",
|
|
407
|
-
error instanceof Error ? error.message : String(error),
|
|
408
|
-
);
|
|
401
|
+
console.error("Failed to load credits:", getErrorMessage(error));
|
|
409
402
|
process.exit(1);
|
|
410
403
|
}
|
|
411
404
|
}
|
package/src/config/load.ts
CHANGED
|
@@ -14,28 +14,12 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { dirname, isAbsolute, join } from "node:path";
|
|
17
|
-
import {
|
|
17
|
+
import { getErrorMessage } from "../core/errorUtils";
|
|
18
|
+
import { getBundledSearxngComposePath } from "../core/paths";
|
|
18
19
|
import { PluginRegistry, registerBuiltInPlugins } from "../plugin";
|
|
19
20
|
import type { ConfigFactory, ExtendedSearchConfig } from "./defineConfig";
|
|
20
21
|
import { formatValidationErrors, validateConfigSafe } from "./validation";
|
|
21
22
|
|
|
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
23
|
/** Supported config file extensions */
|
|
40
24
|
const CONFIG_EXTENSIONS = [".ts", ".json"] as const;
|
|
41
25
|
|
|
@@ -106,7 +90,10 @@ async function loadTypeScriptConfig(path: string): Promise<ExtendedSearchConfig>
|
|
|
106
90
|
const configOrFactory = module.default ?? module.config;
|
|
107
91
|
|
|
108
92
|
if (!configOrFactory) {
|
|
109
|
-
throw new Error(
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Config file at ${path} must export a config object as 'default' or 'config'. ` +
|
|
95
|
+
`Found exports: ${Object.keys(module).join(", ") || "none"}`,
|
|
96
|
+
);
|
|
110
97
|
}
|
|
111
98
|
|
|
112
99
|
// Handle factory functions (async config)
|
|
@@ -116,9 +103,7 @@ async function loadTypeScriptConfig(path: string): Promise<ExtendedSearchConfig>
|
|
|
116
103
|
|
|
117
104
|
return configOrFactory as ExtendedSearchConfig;
|
|
118
105
|
} catch (error) {
|
|
119
|
-
throw new Error(
|
|
120
|
-
`Failed to load TypeScript config from ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
-
);
|
|
106
|
+
throw new Error(`Failed to load TypeScript config from ${path}: ${getErrorMessage(error)}`);
|
|
122
107
|
}
|
|
123
108
|
}
|
|
124
109
|
|
|
@@ -210,8 +195,7 @@ function getDefaultConfig(): ExtendedSearchConfig {
|
|
|
210
195
|
const defaultEngineOrder: string[] = [];
|
|
211
196
|
|
|
212
197
|
// Always add SearXNG first - it's free and unlimited (requires Docker)
|
|
213
|
-
const
|
|
214
|
-
const composeFile = join(packageRoot, "providers", "searxng", "docker-compose.yml");
|
|
198
|
+
const composeFile = getBundledSearxngComposePath();
|
|
215
199
|
|
|
216
200
|
defaultEngineOrder.push("searchxng");
|
|
217
201
|
engines.push({
|
|
@@ -219,12 +203,11 @@ function getDefaultConfig(): ExtendedSearchConfig {
|
|
|
219
203
|
type: "searchxng",
|
|
220
204
|
enabled: true,
|
|
221
205
|
displayName: "SearXNG (Local)",
|
|
222
|
-
apiKeyEnv: "SEARXNG_API_KEY",
|
|
223
206
|
endpoint: "http://localhost:8888/search",
|
|
224
207
|
composeFile,
|
|
225
208
|
containerName: "searxng",
|
|
226
209
|
healthEndpoint: "http://localhost:8888/healthz",
|
|
227
|
-
defaultLimit:
|
|
210
|
+
defaultLimit: 10,
|
|
228
211
|
monthlyQuota: 10000,
|
|
229
212
|
creditCostPerSearch: 0,
|
|
230
213
|
lowCreditThresholdPercent: 80,
|
|
@@ -243,7 +226,7 @@ function getDefaultConfig(): ExtendedSearchConfig {
|
|
|
243
226
|
displayName: "Brave Search",
|
|
244
227
|
apiKeyEnv: "BRAVE_API_KEY",
|
|
245
228
|
endpoint: "https://api.search.brave.com/res/v1/web/search",
|
|
246
|
-
defaultLimit:
|
|
229
|
+
defaultLimit: 10,
|
|
247
230
|
monthlyQuota: 2000,
|
|
248
231
|
creditCostPerSearch: 1,
|
|
249
232
|
lowCreditThresholdPercent: 80,
|
|
@@ -302,9 +285,7 @@ export async function loadConfig(
|
|
|
302
285
|
rawConfig = loadJsonConfig(path);
|
|
303
286
|
}
|
|
304
287
|
} catch (error) {
|
|
305
|
-
throw new Error(
|
|
306
|
-
`Failed to load config file at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
307
|
-
);
|
|
288
|
+
throw new Error(`Failed to load config file at ${path}: ${getErrorMessage(error)}`);
|
|
308
289
|
}
|
|
309
290
|
|
|
310
291
|
// Resolve relative paths (like composeFile) to absolute paths
|
|
@@ -377,9 +358,7 @@ export function loadConfigSync(
|
|
|
377
358
|
try {
|
|
378
359
|
rawConfig = loadJsonConfig(path);
|
|
379
360
|
} catch (error) {
|
|
380
|
-
throw new Error(
|
|
381
|
-
`Failed to parse config file at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
382
|
-
);
|
|
361
|
+
throw new Error(`Failed to parse config file at ${path}: ${getErrorMessage(error)}`);
|
|
383
362
|
}
|
|
384
363
|
|
|
385
364
|
// Resolve relative paths (like composeFile) to absolute paths
|
package/src/config/validation.ts
CHANGED
|
@@ -73,7 +73,28 @@ export const UberSearchConfigSchema = z
|
|
|
73
73
|
})
|
|
74
74
|
.optional(),
|
|
75
75
|
})
|
|
76
|
-
.passthrough()
|
|
76
|
+
.passthrough()
|
|
77
|
+
.refine(
|
|
78
|
+
(config) => {
|
|
79
|
+
const ids = config.engines.map((e: { id: string }) => e.id);
|
|
80
|
+
return new Set(ids).size === ids.length;
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
message: "Duplicate engine IDs found in engines array",
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
.refine(
|
|
87
|
+
(config) => {
|
|
88
|
+
if (!config.defaultEngineOrder) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const engineIds = new Set(config.engines.map((e: { id: string }) => e.id));
|
|
92
|
+
return config.defaultEngineOrder.every((id: string) => engineIds.has(id));
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
message: "defaultEngineOrder contains engine IDs not defined in engines array",
|
|
96
|
+
},
|
|
97
|
+
);
|
|
77
98
|
|
|
78
99
|
// CLI input schema
|
|
79
100
|
export const CliInputSchema = z.object({
|
|
@@ -100,6 +100,24 @@ export class CreditManager {
|
|
|
100
100
|
return result;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Refund credits for a failed search
|
|
105
|
+
* @note This does NOT persist the state - call saveState() separately
|
|
106
|
+
*/
|
|
107
|
+
refund(engineId: EngineId): void {
|
|
108
|
+
const config = this.engines.get(engineId);
|
|
109
|
+
if (!config) {
|
|
110
|
+
throw new Error(`Unknown engine: ${engineId}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const record = this.state[engineId];
|
|
114
|
+
if (!record) {
|
|
115
|
+
throw new Error(`No credit record for engine: ${engineId}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
record.used = Math.max(0, record.used - config.creditCostPerSearch);
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
/**
|
|
104
122
|
* Check if engine has sufficient credits
|
|
105
123
|
*/
|
|
@@ -112,7 +112,7 @@ export class FileCreditStateProvider implements CreditStateProvider {
|
|
|
112
112
|
// Write file using Bun's async API
|
|
113
113
|
await Bun.write(this.statePath, JSON.stringify(state, null, 2));
|
|
114
114
|
} catch (error) {
|
|
115
|
-
log.
|
|
115
|
+
log.error(`Failed to save credit state to ${this.statePath}:`, error);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* Manages Docker Compose services for local providers
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
11
|
import { getSearxngPaths } from "../paths";
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Get or generate a persistent SearXNG secret key
|
|
@@ -30,9 +30,8 @@ function getSearxngSecret(configDir: string): string {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// Generate a new secret using crypto
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
.join("");
|
|
33
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
34
|
+
const secret = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
36
35
|
try {
|
|
37
36
|
writeFileSync(secretFile, secret, { mode: 0o600 });
|
|
38
37
|
} catch {
|
|
@@ -59,19 +58,21 @@ export class DockerComposeHelper {
|
|
|
59
58
|
args: string[],
|
|
60
59
|
options: DockerComposeOptions = {},
|
|
61
60
|
): Promise<string> {
|
|
62
|
-
const cmd = `docker compose -f "${this.composeFile}" ${args.join(" ")}`;
|
|
63
61
|
const cwd = options.cwd || this.getComposeDir();
|
|
64
62
|
const timeout = options.timeout || 30000;
|
|
63
|
+
const cmdArgs = ["compose", "-f", this.composeFile, ...args];
|
|
65
64
|
|
|
66
65
|
try {
|
|
67
66
|
// Get SearXNG paths and ensure directories exist
|
|
68
67
|
const { configDir, dataDir } = getSearxngPaths();
|
|
69
68
|
|
|
70
|
-
const { stdout, stderr } = await
|
|
69
|
+
const { stdout, stderr } = await execFileAsync("docker", cmdArgs, {
|
|
71
70
|
cwd,
|
|
72
71
|
timeout,
|
|
73
72
|
env: {
|
|
74
|
-
|
|
73
|
+
PATH: process.env.PATH,
|
|
74
|
+
HOME: process.env.HOME,
|
|
75
|
+
DOCKER_HOST: process.env.DOCKER_HOST,
|
|
75
76
|
SEARXNG_CONFIG: configDir,
|
|
76
77
|
SEARXNG_DATA: dataDir,
|
|
77
78
|
SEARXNG_SECRET: getSearxngSecret(configDir),
|
|
@@ -90,7 +91,7 @@ export class DockerComposeHelper {
|
|
|
90
91
|
|
|
91
92
|
const errorDetails = [
|
|
92
93
|
`Docker Compose command ${timedOut ? "timed out" : "failed"}: ${errorMessage}`,
|
|
93
|
-
`Command: ${
|
|
94
|
+
`Command: docker compose -f ${this.composeFile} ${args.join(" ")}`,
|
|
94
95
|
];
|
|
95
96
|
if (err.stdout) {
|
|
96
97
|
errorDetails.push(`Output: ${err.stdout}`);
|
|
@@ -107,7 +108,7 @@ export class DockerComposeHelper {
|
|
|
107
108
|
* Get directory containing compose file
|
|
108
109
|
*/
|
|
109
110
|
private getComposeDir(): string {
|
|
110
|
-
return
|
|
111
|
+
return dirname(this.composeFile);
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/**
|
|
@@ -175,13 +176,19 @@ export class DockerComposeHelper {
|
|
|
175
176
|
async isRunning(service?: string, options?: DockerComposeOptions): Promise<boolean> {
|
|
176
177
|
try {
|
|
177
178
|
const output = await this.ps(options);
|
|
179
|
+
const lines = output.split("\n").filter((l) => l.trim());
|
|
178
180
|
|
|
179
181
|
if (service) {
|
|
180
|
-
return
|
|
182
|
+
return lines.some((line) => {
|
|
183
|
+
const lower = line.toLowerCase();
|
|
184
|
+
return (
|
|
185
|
+
lower.includes(service.toLowerCase()) && /\bup\b/i.test(line) && !/exit/i.test(line)
|
|
186
|
+
);
|
|
187
|
+
});
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
// Check if any services are running
|
|
184
|
-
return
|
|
191
|
+
return lines.some((line) => /\bup\b/i.test(line));
|
|
185
192
|
} catch (_error) {
|
|
186
193
|
return false;
|
|
187
194
|
}
|
|
@@ -192,7 +199,7 @@ export class DockerComposeHelper {
|
|
|
192
199
|
*/
|
|
193
200
|
static async isDockerAvailable(): Promise<boolean> {
|
|
194
201
|
try {
|
|
195
|
-
await
|
|
202
|
+
await execFileAsync("docker", ["version"], { timeout: 5000 });
|
|
196
203
|
return true;
|
|
197
204
|
} catch {
|
|
198
205
|
return false;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { getErrorMessage } from "../errorUtils";
|
|
23
24
|
import { createLogger } from "../logger";
|
|
24
25
|
import { bootstrapSearxngConfig } from "../paths";
|
|
25
26
|
import { DockerComposeHelper } from "./dockerComposeHelper";
|
|
@@ -63,21 +64,20 @@ export class DockerLifecycleManager {
|
|
|
63
64
|
* @throws Error if container startup fails
|
|
64
65
|
*/
|
|
65
66
|
async init(): Promise<void> {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Atomic check-and-set pattern to prevent race conditions
|
|
68
|
+
if (!this.initPromise) {
|
|
69
|
+
if (!this.config.autoStart || !this.dockerHelper) {
|
|
70
|
+
this.initialized = true;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
this.initPromise = this.performInit().catch((error) => {
|
|
75
|
+
const message = getErrorMessage(error);
|
|
76
|
+
log.debug("Initialization failed:", message);
|
|
77
|
+
this.initialized = false;
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
73
80
|
}
|
|
74
|
-
|
|
75
|
-
this.initPromise = this.performInit().catch((error) => {
|
|
76
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
-
log.debug("Initialization failed:", message);
|
|
78
|
-
this.initialized = false;
|
|
79
|
-
throw error;
|
|
80
|
-
});
|
|
81
81
|
return this.initPromise;
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -89,11 +89,19 @@ export class DockerLifecycleManager {
|
|
|
89
89
|
timeoutMs: number,
|
|
90
90
|
operation: string,
|
|
91
91
|
): Promise<T> {
|
|
92
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
92
93
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
93
|
-
|
|
94
|
+
timeoutId = setTimeout(
|
|
95
|
+
() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)),
|
|
96
|
+
timeoutMs,
|
|
97
|
+
);
|
|
94
98
|
});
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
try {
|
|
101
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timeoutId);
|
|
104
|
+
}
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
private async performInit(): Promise<void> {
|
|
@@ -142,7 +150,7 @@ export class DockerLifecycleManager {
|
|
|
142
150
|
|
|
143
151
|
this.initialized = true;
|
|
144
152
|
} catch (error) {
|
|
145
|
-
const message =
|
|
153
|
+
const message = getErrorMessage(error);
|
|
146
154
|
log.debug("Failed to start container:", message);
|
|
147
155
|
throw error;
|
|
148
156
|
} finally {
|
|
@@ -162,20 +170,23 @@ export class DockerLifecycleManager {
|
|
|
162
170
|
// Try health endpoint first - works for any running instance
|
|
163
171
|
// (even if started manually, via different docker-compose, or k8s, etc.)
|
|
164
172
|
if (this.config.healthEndpoint) {
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
165
175
|
try {
|
|
166
|
-
const controller = new AbortController();
|
|
167
|
-
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
168
|
-
|
|
169
176
|
const response = await fetch(this.config.healthEndpoint, {
|
|
170
177
|
signal: controller.signal,
|
|
171
178
|
});
|
|
172
179
|
|
|
173
|
-
clearTimeout(timeoutId);
|
|
174
180
|
if (response.ok) {
|
|
175
181
|
return true;
|
|
176
182
|
}
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
log.debug(`Health endpoint returned non-ok status: ${response.status}`);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const msg = getErrorMessage(error);
|
|
186
|
+
log.debug(`Health endpoint check failed: ${msg}`);
|
|
187
|
+
// Fall through to Docker status check
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(timeoutId);
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
192
|
|
|
@@ -244,7 +255,7 @@ export class DockerLifecycleManager {
|
|
|
244
255
|
);
|
|
245
256
|
log.debug("Container stopped.");
|
|
246
257
|
} catch (error) {
|
|
247
|
-
const message =
|
|
258
|
+
const message = getErrorMessage(error);
|
|
248
259
|
log.debug("Failed to stop container:", message);
|
|
249
260
|
// Don't throw on shutdown errors
|
|
250
261
|
}
|
|
@@ -269,6 +280,23 @@ export class DockerLifecycleManager {
|
|
|
269
280
|
const errors: string[] = [];
|
|
270
281
|
const warnings: string[] = [];
|
|
271
282
|
|
|
283
|
+
// Validate config fields first (these don't require Docker)
|
|
284
|
+
|
|
285
|
+
// Check health endpoint if configured
|
|
286
|
+
if (this.config.healthEndpoint) {
|
|
287
|
+
try {
|
|
288
|
+
// Try to parse the URL
|
|
289
|
+
new URL(this.config.healthEndpoint);
|
|
290
|
+
} catch {
|
|
291
|
+
warnings.push(`Health endpoint URL is invalid: ${this.config.healthEndpoint}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Check container name if specified
|
|
296
|
+
if (this.config.containerName && !/^[a-zA-Z0-9_-]+$/.test(this.config.containerName)) {
|
|
297
|
+
warnings.push(`Container name contains invalid characters: ${this.config.containerName}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
272
300
|
// Check Docker is available
|
|
273
301
|
const dockerAvailable = await DockerComposeHelper.isDockerAvailable();
|
|
274
302
|
if (!dockerAvailable) {
|
|
@@ -290,28 +318,13 @@ export class DockerLifecycleManager {
|
|
|
290
318
|
const projectRoot = this.config.projectRoot || process.cwd();
|
|
291
319
|
await this.dockerHelper.ps({ cwd: projectRoot });
|
|
292
320
|
} catch (error: unknown) {
|
|
293
|
-
const message =
|
|
321
|
+
const message = getErrorMessage(error);
|
|
294
322
|
if (message.includes("config")) {
|
|
295
323
|
errors.push(`Invalid compose file: ${message}`);
|
|
296
324
|
}
|
|
297
325
|
}
|
|
298
326
|
}
|
|
299
327
|
|
|
300
|
-
// Check health endpoint if configured
|
|
301
|
-
if (this.config.healthEndpoint) {
|
|
302
|
-
try {
|
|
303
|
-
// Try to parse the URL
|
|
304
|
-
new URL(this.config.healthEndpoint);
|
|
305
|
-
} catch {
|
|
306
|
-
warnings.push(`Health endpoint URL is invalid: ${this.config.healthEndpoint}`);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Check container name if specified
|
|
311
|
-
if (this.config.containerName && !/^[a-zA-Z0-9_-]+$/.test(this.config.containerName)) {
|
|
312
|
-
warnings.push(`Container name contains invalid characters: ${this.config.containerName}`);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
328
|
return {
|
|
316
329
|
valid: errors.length === 0,
|
|
317
330
|
errors,
|
|
@@ -333,6 +346,13 @@ export class DockerLifecycleManager {
|
|
|
333
346
|
return this.initialized;
|
|
334
347
|
}
|
|
335
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Check if initialization is currently in progress
|
|
351
|
+
*/
|
|
352
|
+
isInitializing(): boolean {
|
|
353
|
+
return this.initPromise !== null;
|
|
354
|
+
}
|
|
355
|
+
|
|
336
356
|
/**
|
|
337
357
|
* Check if container is running
|
|
338
358
|
*
|
|
@@ -349,7 +369,7 @@ export class DockerLifecycleManager {
|
|
|
349
369
|
cwd: projectRoot,
|
|
350
370
|
});
|
|
351
371
|
} catch (error) {
|
|
352
|
-
const message =
|
|
372
|
+
const message = getErrorMessage(error);
|
|
353
373
|
log.debug("Error checking if container is running:", message);
|
|
354
374
|
return false;
|
|
355
375
|
}
|