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 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: all)
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 (Default)
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.6.2",
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
- SEARXNG_SECRET_KEY=your-secret-key-here
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 { ILifecycleProvider, SearchProvider } from "../core/provider";
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 = error instanceof Error ? error.message : String(error);
107
- log.debug(`Failed to register provider ${engineConfig.id}: ${errorMsg}`);
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, isLifecycleProvider } from "./bootstrap/container";
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:", error instanceof Error ? error.message : String(error));
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 = error instanceof Error ? error.message : String(error);
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:", error instanceof Error ? error.message : String(error));
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
  }
@@ -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 { fileURLToPath } from "node:url";
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(`Config file must export a default configuration or named 'config' export`);
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 packageRoot = getPackageRoot();
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: 15,
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: 15,
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
@@ -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.warn(`Failed to save credit state to ${this.statePath}:`, error);
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 { exec, execSync } from "node:child_process";
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 execAsync = promisify(exec);
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 secret = [...Array(64)]
34
- .map(() => Math.floor(Math.random() * 16).toString(16))
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 execAsync(cmd, {
69
+ const { stdout, stderr } = await execFileAsync("docker", cmdArgs, {
71
70
  cwd,
72
71
  timeout,
73
72
  env: {
74
- ...process.env,
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: ${cmd}`,
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 require("node:path").dirname(this.composeFile);
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 output.includes(service) && !output.includes("Exit");
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 output.includes("Up");
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 execAsync("docker version", { timeout: 5000 });
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
- if (this.initPromise) {
67
- return this.initPromise;
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
- if (!this.config.autoStart || !this.dockerHelper) {
71
- this.initialized = true;
72
- return;
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
- setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
94
+ timeoutId = setTimeout(
95
+ () => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)),
96
+ timeoutMs,
97
+ );
94
98
  });
95
99
 
96
- return Promise.race([promise, timeoutPromise]);
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 = error instanceof Error ? error.message : String(error);
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
- } catch {
178
- // Health endpoint failed, fall through to Docker check
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 = error instanceof Error ? error.message : String(error);
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 = error instanceof Error ? error.message : String(error);
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 = error instanceof Error ? error.message : String(error);
372
+ const message = getErrorMessage(error);
353
373
  log.debug("Error checking if container is running:", message);
354
374
  return false;
355
375
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Safely extracts an error message from an unknown error value.
3
+ */
4
+ export function getErrorMessage(error: unknown): string {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }