import pLimit from "p-limit";
import YAML from "yaml";
import { DEFAULT_COMPOSE_SCAN_CONCURRENCY } from "../constants.js";
import type { HostConfig } from "../types.js";
import type { ILocalExecutorService, ISSHService } from "./interfaces.js";
/**
* Default search paths for compose files if not specified in host config
*/
const DEFAULT_SEARCH_PATHS = ["/compose", "/mnt/cache/compose", "/mnt/cache/code"];
/**
* Maximum depth to search for compose files
*/
const MAX_SCAN_DEPTH = 3;
/**
* Timeout for remote find operations (ms)
*/
const FIND_TIMEOUT_MS = 60_000;
/**
* Timeout for reading individual compose files (ms)
*/
const CAT_TIMEOUT_MS = 10_000;
/**
* Maximum number of files to read in a single batched cat command.
* Keeps argument lists and output size manageable.
*/
const BATCH_CAT_CHUNK_SIZE = 20;
/**
* Compose file patterns to search for
*/
const COMPOSE_FILE_PATTERNS = [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
];
/**
* Service for scanning filesystems to find Docker Compose files.
* Supports both remote (SSH) and local execution.
*/
export class ComposeScanner {
constructor(
private readonly sshService: ISSHService,
private readonly localExecutor: ILocalExecutorService
) {}
/**
* Find all compose files in configured search paths.
* Uses SSH for remote hosts, local execution for localhost.
*
* @param host - Host configuration with optional composeSearchPaths
* @returns Array of absolute paths to compose files
*/
async findComposeFiles(host: HostConfig): Promise<string[]> {
const searchPaths = host.composeSearchPaths ?? DEFAULT_SEARCH_PATHS;
const isLocal = this.isLocalHost(host);
// Build find command args to avoid shell injection
// find /path1 /path2 -maxdepth 3 -type f \( -name "docker-compose.yml" -o -name "compose.yaml" ... \) -print
const args = [...searchPaths, "-maxdepth", MAX_SCAN_DEPTH.toString(), "-type", "f", "("];
// Add each pattern with -o (OR) between them
for (let i = 0; i < COMPOSE_FILE_PATTERNS.length; i++) {
if (i > 0) {
args.push("-o");
}
const pattern = COMPOSE_FILE_PATTERNS[i];
if (pattern) {
args.push("-name", pattern);
}
}
args.push(")", "-print");
try {
const output = isLocal
? await this.localExecutor.executeLocalCommand("find", args, { timeoutMs: FIND_TIMEOUT_MS })
: await this.sshService.executeSSHCommand(host, "find", args, {
timeoutMs: FIND_TIMEOUT_MS,
});
// Split output by newlines, filter empty lines, and deduplicate
const allFiles = output
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
return Array.from(new Set(allFiles));
} catch (_error) {
return [];
}
}
/**
* Extract project name from compose file path (parent directory name).
*
* @param filePath - Absolute path to compose file
* @returns Parent directory name, or empty string if at root
*/
extractProjectName(filePath: string): string {
const parts = filePath.split("/").filter((p) => p.length > 0);
// Return parent directory name (last directory before filename)
if (parts.length < 2) {
return "";
}
return parts[parts.length - 2] ?? "";
}
/**
* Parse compose file to extract explicit 'name:' field.
* Returns null if parsing fails or no name field exists.
*
* SECURITY: Uses args array to prevent shell injection when reading files.
*
* @param host - Host configuration
* @param filePath - Absolute path to compose file
* @returns Explicit project name from 'name:' field, or null
*/
async parseComposeName(host: HostConfig, filePath: string): Promise<string | null> {
const isLocal = this.isLocalHost(host);
try {
// Read file contents using cat command with args array
const content = isLocal
? await this.localExecutor.executeLocalCommand("cat", [filePath], {
timeoutMs: CAT_TIMEOUT_MS,
})
: await this.sshService.executeSSHCommand(host, "cat", [filePath], {
timeoutMs: CAT_TIMEOUT_MS,
});
// Parse YAML and extract name field
const parsed = YAML.parse(content);
if (parsed && typeof parsed === "object" && "name" in parsed) {
const name = parsed.name;
if (typeof name === "string" && name.length > 0) {
return name;
}
}
return null;
} catch (_error) {
// Alternative: Log errors for debugging while still returning null.
return null;
}
}
/**
* Parse multiple compose files in chunks and read each chunk in parallel.
* Avoids shell string concatenation by issuing one cat command per file.
*
* @param host - Host configuration where files are located
* @param filePaths - Array of absolute paths to compose files
* @param concurrency - Maximum concurrent SSH connections (default from constants)
* @returns Map of file path to project name (null if parse failed)
*/
async batchParseComposeNames(
host: HostConfig,
filePaths: string[],
concurrency: number = DEFAULT_COMPOSE_SCAN_CONCURRENCY
): Promise<Map<string, string | null>> {
const results = new Map<string, string | null>();
if (filePaths.length === 0) {
return results;
}
// Split file paths into chunks for batched reading
const chunks: string[][] = [];
for (let i = 0; i < filePaths.length; i += BATCH_CAT_CHUNK_SIZE) {
chunks.push(filePaths.slice(i, i + BATCH_CAT_CHUNK_SIZE));
}
// Process chunks in parallel with concurrency control
const limit = pLimit(concurrency);
const chunkPromises = chunks.map((chunk) =>
limit(async () => this.batchReadAndParse(host, chunk))
);
const chunkResults = await Promise.allSettled(chunkPromises);
// Merge results from all chunks
for (const settled of chunkResults) {
if (settled.status === "fulfilled") {
for (const [path, name] of settled.value) {
results.set(path, name);
}
}
}
// Ensure every requested path has an entry
for (const filePath of filePaths) {
if (!results.has(filePath)) {
results.set(filePath, null);
}
}
return results;
}
/**
* Read and parse a batch of compose files using parallel cat commands.
*
* @param host - Host configuration
* @param filePaths - Batch of file paths to read
* @returns Map of file path to parsed project name (null if parse failed)
*/
private async batchReadAndParse(
host: HostConfig,
filePaths: string[]
): Promise<Map<string, string | null>> {
const results = new Map<string, string | null>();
const isLocal = this.isLocalHost(host);
if (filePaths.length === 1) {
// Single file — use simple cat (no delimiter overhead)
const name = await this.parseComposeName(host, filePaths[0]!);
results.set(filePaths[0]!, name);
return results;
}
try {
const contents = await Promise.all(
filePaths.map((filePath) =>
isLocal
? this.localExecutor.executeLocalCommand("cat", [filePath], {
timeoutMs: CAT_TIMEOUT_MS,
})
: this.sshService.executeSSHCommand(host, "cat", [filePath], {
timeoutMs: CAT_TIMEOUT_MS,
})
)
);
for (let i = 0; i < filePaths.length; i++) {
const content = contents[i]?.trim();
const filePath = filePaths[i]!;
if (!content) {
results.set(filePath, null);
continue;
}
try {
const parsed = YAML.parse(content);
if (parsed && typeof parsed === "object" && "name" in parsed) {
const name = parsed.name;
if (typeof name === "string" && name.length > 0) {
results.set(filePath, name);
continue;
}
}
results.set(filePath, null);
} catch {
results.set(filePath, null);
}
}
} catch {
// Batch read failed — fall back to individual reads
const individualPromises = filePaths.map(async (filePath) => {
try {
const name = await this.parseComposeName(host, filePath);
return { path: filePath, name };
} catch {
return { path: filePath, name: null };
}
});
const settled = await Promise.allSettled(individualPromises);
for (const result of settled) {
if (result.status === "fulfilled") {
results.set(result.value.path, result.value.name);
}
}
}
return results;
}
/**
* Check if host is localhost (avoids SSH overhead for local operations).
*
* @param host - Host configuration
* @returns true if host is localhost/127.0.0.1
*/
private isLocalHost(host: HostConfig): boolean {
const hostname = host.host.toLowerCase();
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
}