import Foundation @main struct AppleComposeCLI { static func main() async { do { let args = Array(CommandLine.arguments.dropFirst()) if args.first == "monitor" { try await MonitorCLI.run(Array(args.dropFirst())); return } try execute() } catch { fputs("container-compose: \(error)\n", stderr); exit(1) } } static func execute() throws { var arguments = Array(CommandLine.arguments.dropFirst()) let help = """ container-compose — Container Compose CLI Usage: container-compose [-f FILE] [-p PROJECT] COMMAND [OPTIONS] Commands: monitor [COMMAND] acdry resource manager; monitor --help for commands monitor watch Live CPU, memory and network terminal dashboard init Create a starter compose.yaml doctor Check Apple Container runtime config Validate Compose file and display configuration up [-d|--detach] [--build] Start; attach to logs unless detached down [--volumes] Stop and delete project containers ps List containers logs [SERVICE] Display service logs (snapshot only) pull Pull service images build Build services with Dockerfiles start [SERVICE] Start containers stop [SERVICE] Stop containers restart [SERVICE] Restart containers exec SERVICE CMD Execute a command in a container clean SERVICE Delete one managed container clean --all Delete all containers of current project clean --dry-run Preview managed container deletion prune --images Ask runtime to remove unused images import PATH Validate a Compose file or project directory Global options: -f, --file FILE Compose file path -p, --project-name N Override project name -v, -vv, -vvv Increase diagnostics (independent of detach) --version Display CLI version -h, --help Display help Supported: basic images, Dockerfile builds, ports, directory binds, named volumes, environment, dependencies, networks, commands. Other service keys are rejected instead of silently ignored. This is NOT a complete Docker Compose implementation. """ if arguments == ["--version"] { print("container-compose 0.2.0 (Compose + acdry native monitor)"); return } if arguments.contains("--help") || arguments.contains("-h") || arguments.isEmpty { print(help) return } var file: String?; var name: String? var verbosity = 0 var index = 0 while index < arguments.count { if ["-f", "--file", "-p", "--project-name"].contains(arguments[index]) { guard index + 1 < arguments.count else { throw ComposeError.message("Missing value for \(arguments[index])") } if arguments[index] == "-f" || arguments[index] == "--file" { file = arguments[index + 1] } else { name = arguments[index + 1] } arguments.removeSubrange(index...index + 1) } else if ["-v", "-vv", "-vvv"].contains(arguments[index]) { verbosity = min(3, verbosity + arguments[index].count - 1) arguments.remove(at: index) } else { // exec's command arguments belong to the invoked program. if arguments[index] == "exec" { break } index += 1 } } guard let command = arguments.first else { throw ComposeError.message("Missing command") } if command == "doctor" && file == nil && (try? ComposeProject.discover()) == nil { print("Container Compose diagnostics") guard let binary = Shell.executable("container") else { throw ComposeError.message("Apple container CLI not found. Install from https://github.com/apple/container/releases") } print("CLI: \(binary)") print(try Shell.checked("container", ["--version"])) let result = try Shell.run("container", ["system", "status"]) print(result.output) if result.status != 0 { throw ComposeError.message("Container runtime not ready. Try: container system start") } return } if command == "import" { guard arguments.count >= 2 else { throw ComposeError.message("Usage: container-compose import PATH") } let url = URL(fileURLWithPath: arguments[1]).standardizedFileURL var isDir: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) else { throw ComposeError.message("Path not found: \(url.path)") } let input: URL if isDir.boolValue { let candidates = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"] guard let match = candidates.map({ url.appendingPathComponent($0) }).first(where: { FileManager.default.fileExists(atPath: $0.path) }) else { throw ComposeError.message("No Compose file found in \(url.path)") } input = match } else { input = url } let project = try ComposeProject(file: input, overrideName: name) print("Validated \(project.services.count) services from \(input.path). No files modified.") return } if command == "init" { let target = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("compose.yaml") guard !FileManager.default.fileExists(atPath: target.path) else { throw ComposeError.message("compose.yaml already exists") } try "services:\n web:\n image: docker.io/library/nginx:alpine\n ports:\n - \"8080:80\"\n".write(to: target, atomically: true, encoding: .utf8) print("Created \(target.path)"); return } let commandArgs = Array(arguments.dropFirst()) let allowed: [String: Set] = [ "up": ["-d", "--detach", "--build"], "down": ["--volumes"], "clean": ["--all", "--project", "--dry-run"], "prune": ["--images", "--dry-run"], "config": [], "ps": [], "pull": [], "build": [], "logs": [], "start": [], "stop": [], "restart": [], "doctor": [] ] if let options = allowed[command], let unknown = commandArgs.first(where: { $0.hasPrefix("-") && !options.contains($0) }) { throw ComposeError.message("Unsupported \(command) option: \(unknown)") } if ["config", "ps", "pull", "build", "down", "doctor", "prune"].contains(command), commandArgs.contains(where: { !$0.hasPrefix("-") }) { throw ComposeError.message("Unexpected argument for \(command)") } if command == "up" { let supported: Set = ["-d", "--detach", "--build"] if let unknown = commandArgs.first(where: { !supported.contains($0) }) { throw ComposeError.message("Unsupported up option: \(unknown)") } } let project = try ComposeProject(file: ComposeProject.discover(file), overrideName: name) let attached = command == "up" && !commandArgs.contains("-d") && !commandArgs.contains("--detach") let output = try ComposeEngine(project).execute(command, args: commandArgs, attached: attached, verbosity: verbosity) print(output, terminator: output.hasSuffix("\n") ? "" : "\n") } }