From ecfa74a35f0c3d45a731d2bbb0b7262768bcd5c6 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sun, 8 Nov 2026 17:28:29 +0000 Subject: [PATCH] new features --- .gitignore | 6 + .idea/.gitignore | 5 + .idea/AppleContainerCompose.iml | 16 + .idea/jsonCatalog.xml | 6 + .idea/modules.xml | 8 + .idea/noctule.xml | 23 ++ .idea/vcs.xml | 6 + .../project.pbxproj | 46 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 15 + Examples/compose.yaml | 41 ++ Examples/compose_basic.yaml | 12 + Examples/site/index.html | 1 + ICON-DESIGN.md | 7 + README.md | 113 ++++-- Scripts/install-cli.sh | 21 + Sources/App.swift | 10 + Sources/CLI.swift | 111 ++++++ Sources/ComposeCore.swift | 367 ++++++++++++++++++ Sources/ComposeModel.swift | 47 +++ Sources/ContentView.swift | 77 ++++ Tests/cli_regression.py | 86 ++++ VERIFICATION.md | 13 + Validation/AppleContainerCompose-Debug.log | 120 ++++++ Validation/AppleContainerCompose-Release.log | 120 ++++++ Validation/VALIDATION.md | 36 ++ Validation/accompose-Release.log | 137 +++++++ Validation/cli-debug.log | 125 ++++++ Validation/runtime-validation.log | 49 +++ fix_composecore.py | 47 +++ 30 files changed, 1651 insertions(+), 27 deletions(-) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/AppleContainerCompose.iml create mode 100644 .idea/jsonCatalog.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/noctule.xml create mode 100644 .idea/vcs.xml create mode 100644 AppleContainerCompose.xcodeproj/project.pbxproj create mode 100644 AppleContainerCompose.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 AppleContainerCompose.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/compose.yaml create mode 100644 Examples/compose_basic.yaml create mode 100644 Examples/site/index.html create mode 100644 ICON-DESIGN.md create mode 100755 Scripts/install-cli.sh create mode 100644 Sources/App.swift create mode 100644 Sources/CLI.swift create mode 100644 Sources/ComposeCore.swift create mode 100644 Sources/ComposeModel.swift create mode 100644 Sources/ContentView.swift create mode 100644 Tests/cli_regression.py create mode 100644 VERIFICATION.md create mode 100644 Validation/AppleContainerCompose-Debug.log create mode 100644 Validation/AppleContainerCompose-Release.log create mode 100644 Validation/VALIDATION.md create mode 100644 Validation/accompose-Release.log create mode 100644 Validation/cli-debug.log create mode 100644 Validation/runtime-validation.log create mode 100644 fix_composecore.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4a45e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +.build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +*.bak-* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..62ece6f --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Markdown personal settings +/markdown.xml diff --git a/.idea/AppleContainerCompose.iml b/.idea/AppleContainerCompose.iml new file mode 100644 index 0000000..0eb62d7 --- /dev/null +++ b/.idea/AppleContainerCompose.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/jsonCatalog.xml b/.idea/jsonCatalog.xml new file mode 100644 index 0000000..0b181a9 --- /dev/null +++ b/.idea/jsonCatalog.xml @@ -0,0 +1,6 @@ + + + + false + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..118f335 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/noctule.xml b/.idea/noctule.xml new file mode 100644 index 0000000..a9de147 --- /dev/null +++ b/.idea/noctule.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + $PROJECT_DIR$/AppleContainerCompose.xcodeproj/project.pbxproj + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AppleContainerCompose.xcodeproj/project.pbxproj b/AppleContainerCompose.xcodeproj/project.pbxproj new file mode 100644 index 0000000..50d1559 --- /dev/null +++ b/AppleContainerCompose.xcodeproj/project.pbxproj @@ -0,0 +1,46 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 56; + objects = { + A00000000000000000000001 = {isa = PBXProject; attributes = {LastUpgradeCheck = 1600; TargetAttributes = {A00000000000000000000002 = {CreatedOnToolsVersion = 16.0; }; }; }; buildConfigurationList = A00000000000000000000010; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base); mainGroup = A00000000000000000000003; packageReferences = (A00000000000000000000020); productRefGroup = A00000000000000000000004; projectDirPath = ""; projectRoot = ""; targets = (A00000000000000000000002, B00000000000000000000002); }; + A00000000000000000000002 = {isa = PBXNativeTarget; buildConfigurationList = A00000000000000000000011; buildPhases = (A00000000000000000000005, A00000000000000000000006, A00000000000000000000007); buildRules = (); dependencies = (); name = AppleContainerCompose; packageProductDependencies = (A00000000000000000000021); productName = AppleContainerCompose; productReference = A00000000000000000000008; productType = "com.apple.product-type.application"; }; + A00000000000000000000003 = {isa = PBXGroup; children = (A00000000000000000000009, A00000000000000000000004); sourceTree = ""; }; + A00000000000000000000004 = {isa = PBXGroup; children = (A00000000000000000000008, B00000000000000000000008); name = Products; sourceTree = ""; }; + A00000000000000000000009 = {isa = PBXGroup; children = (A00000000000000000000030, A00000000000000000000031, A00000000000000000000032, A00000000000000000000034, A00000000000000000000035); path = Sources; sourceTree = ""; }; + A00000000000000000000030 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + A00000000000000000000031 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeModel.swift; sourceTree = ""; }; + A00000000000000000000032 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A00000000000000000000034 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCore.swift; sourceTree = ""; }; + A00000000000000000000035 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLI.swift; sourceTree = ""; }; + A00000000000000000000008 = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppleContainerCompose.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A00000000000000000000040 = {isa = PBXBuildFile; fileRef = A00000000000000000000030; }; + A00000000000000000000041 = {isa = PBXBuildFile; fileRef = A00000000000000000000031; }; + A00000000000000000000042 = {isa = PBXBuildFile; fileRef = A00000000000000000000032; }; + A00000000000000000000043 = {isa = PBXBuildFile; fileRef = A00000000000000000000034; }; + B00000000000000000000043 = {isa = PBXBuildFile; fileRef = A00000000000000000000034; }; + B00000000000000000000044 = {isa = PBXBuildFile; fileRef = A00000000000000000000035; }; + B00000000000000000000045 = {isa = PBXBuildFile; productRef = A00000000000000000000021; }; + A00000000000000000000044 = {isa = PBXBuildFile; productRef = A00000000000000000000021; }; + A00000000000000000000005 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000040, A00000000000000000000041, A00000000000000000000042, A00000000000000000000043); runOnlyForDeploymentPostprocessing = 0; }; + A00000000000000000000006 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000044); runOnlyForDeploymentPostprocessing = 0; }; + A00000000000000000000007 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + A00000000000000000000010 = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000012, A00000000000000000000013); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A00000000000000000000011 = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000014, A00000000000000000000015); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A00000000000000000000012 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; name = Debug; }; + A00000000000000000000013 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; name = Release; }; + A00000000000000000000014 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Apple Container Compose"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.AppleContainerCompose; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; ENABLE_APP_SANDBOX = NO; }; name = Debug; }; + A00000000000000000000015 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Apple Container Compose"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.AppleContainerCompose; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; ENABLE_APP_SANDBOX = NO; }; name = Release; }; + B00000000000000000000002 = {isa = PBXNativeTarget; buildConfigurationList = B00000000000000000000011; buildPhases = (B00000000000000000000005, B00000000000000000000006); buildRules = (); dependencies = (); name = accompose; packageProductDependencies = (A00000000000000000000021); productName = accompose; productReference = B00000000000000000000008; productType = "com.apple.product-type.tool"; }; + B00000000000000000000008 = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = accompose; sourceTree = BUILT_PRODUCTS_DIR; }; + B00000000000000000000005 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B00000000000000000000043, B00000000000000000000044); runOnlyForDeploymentPostprocessing = 0; }; + B00000000000000000000006 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (B00000000000000000000045); runOnlyForDeploymentPostprocessing = 0; }; + B00000000000000000000011 = {isa = XCConfigurationList; buildConfigurations = (B00000000000000000000014, B00000000000000000000015); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B00000000000000000000014 = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGNING_ALLOWED = NO; MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_NAME = accompose; SWIFT_VERSION = 5.0; }; name = Debug; }; + B00000000000000000000015 = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGNING_ALLOWED = NO; MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_NAME = accompose; SWIFT_VERSION = 5.0; }; name = Release; }; + A00000000000000000000020 = {isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/jpsim/Yams.git"; requirement = {kind = upToNextMajorVersion; minimumVersion = 5.1.3; }; }; + A00000000000000000000021 = {isa = XCSwiftPackageProductDependency; package = A00000000000000000000020; productName = Yams; }; + }; + rootObject = A00000000000000000000001; +} diff --git a/AppleContainerCompose.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/AppleContainerCompose.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/AppleContainerCompose.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/AppleContainerCompose.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AppleContainerCompose.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..197d030 --- /dev/null +++ b/AppleContainerCompose.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "00fc82b0de716b0b6fae8646fae1c289e5b4679f93a4b0a9aca7e47cd71f9315", + "pins" : [ + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17", + "version" : "5.4.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/compose.yaml b/Examples/compose.yaml new file mode 100644 index 0000000..7937ea6 --- /dev/null +++ b/Examples/compose.yaml @@ -0,0 +1,41 @@ +name: laravel +services: + web: + build: + context: ./docker/php + args: + uid: ${UID:-1000} + gid: ${GID:-1000} + depends_on: + - db + volumes: + - ./:/home/devuser/app + - ./storage/tmp/composer:/home/devuser/.composer/cache + - ./storage/tmp/npm:/home/devuser/.npm + ports: + - ${WORKSPACE_PORT:-8080}:8080 + restart: always + db: + image: mysql:5.7 + environment: + - MYSQL_ROOT_PASSWORD=root_secret + - MYSQL_DATABASE=zeus + - MYSQL_USER=zeus_user + - MYSQL_PASSWORD=secret + volumes: + - ./storage/tmp/db:/var/lib/mysql + restart: always + adminer: + image: adminer + restart: always + ports: + - 9000:8080 + nginx: + build: ./docker/nginx + ports: + - ${NGINX_PORT:-80}:80 + volumes: + - ./:/home/devuser/app + depends_on: + - web + restart: always diff --git a/Examples/compose_basic.yaml b/Examples/compose_basic.yaml new file mode 100644 index 0000000..9635e33 --- /dev/null +++ b/Examples/compose_basic.yaml @@ -0,0 +1,12 @@ +name: demo +services: + web: + image: docker.io/library/nginx:alpine + ports: + - "8080:80" + volumes: + - ./site:/usr/share/nginx/html:ro + networks: + - frontend +networks: + frontend: {} diff --git a/Examples/site/index.html b/Examples/site/index.html new file mode 100644 index 0000000..2af1ebc --- /dev/null +++ b/Examples/site/index.html @@ -0,0 +1 @@ +AppleContainerCompose

AppleContainerCompose is running

diff --git a/ICON-DESIGN.md b/ICON-DESIGN.md new file mode 100644 index 0000000..9f3389a --- /dev/null +++ b/ICON-DESIGN.md @@ -0,0 +1,7 @@ +# App icon + +Generated with the built-in image generation tool, then resized into standard macOS AppIcon slots. The selected image is bundled in this asset catalog; no external file is required. + +Design prompt: Create a finished professional macOS application icon for Apple Container Compose, a native container orchestration developer tool. Use case logo-brand. Square 1024x1024 icon artwork, full-bleed opaque soft pearl white background, no pre-rounded outer corners and no exterior shadow. Single centered abstract blue container cube made from three simple carefully proportioned interlocking rounded panels, suggesting composition and order. Sparse, calm, confident Apple-like craftsmanship, subtle dimensional shading and restrained cool blue gradients, crisp silhouette recognizable at 16 pixels, generous negative space, large geometric shapes, no tiny details. No text, no letters, no Apple logo, no whale, no border, no mockup, no watermark. The image itself is the icon, not a picture of an icon. + +Reference: https://developer.apple.com/design/human-interface-guidelines/app-icons diff --git a/README.md b/README.md index 58ad509..689626e 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,96 @@ -# Apple Container Compose — Xcode macOS application (prototype) +# AppleContainerCompose — Native Xcode project (developer preview) -A native SwiftUI macOS front end for Apple's `container` runtime. Includes a bundled Python Compose-style orchestration engine from the initial MVP. +A SwiftUI macOS application and **native Swift** `accompose` CLI that operate Apple's `container` runtime. No Python, PyYAML, ServBay, Docker Desktop, or Docker daemon is needed. Both targets compile the same `ComposeCore.swift` orchestration implementation and use the Yams Swift package for YAML parsing. ## Requirements -- Apple silicon Mac running macOS 26 or newer -- Xcode 26 or newer -- Apple's `container` CLI installed and configured -- Python 3 with PyYAML available to the **python3** interpreter launched by the app -- Internet access on first Xcode build to resolve the Yams Swift package -## Open and build -1. Open `AppleContainerCompose.xcodeproj` in Xcode. -2. Select the `AppleContainerCompose` scheme and `My Mac` destination. -3. Build and run (Command-R). -4. Click **Open YAML** and choose a Compose file (sample in `Examples/compose.yaml`). -5. Use **Up**, **Down**, **Status**, **Logs**, **Restart**, **Pull Images**, or **Build Images**. +- Apple silicon, macOS 26+, Xcode 26+ +- Apple's [`container`](https://github.com/apple/container) CLI installed, with a configured kernel (`container system start`) +- Internet on first build to fetch the Yams Swift package -## Python dependency -The bundled engine imports PyYAML. Install it for the Python interpreter available in your macOS app's PATH. For example, using Homebrew Python: +## Build and run GUI -```sh -brew install python -/opt/homebrew/bin/python3 -m venv ~/.local/share/accompose-venv -~/.local/share/accompose-venv/bin/pip install PyYAML +Open `AppleContainerCompose.xcodeproj`, select the `AppleContainerCompose` scheme and run. Use **Open YAML** to load `Examples/compose.yaml`, then Up / Down / Status / Logs. The GUI invokes the same native Swift engine as the CLI, not Python. + +## Install command line utility + +From Terminal, in the project root: + +```bash +./Scripts/install-cli.sh +accompose doctor +cd Examples +accompose up -d +accompose ps +accompose logs +accompose down ``` -The current app executes `python3` from PATH, not the venv automatically. Install PyYAML into that interpreter, or modify `executeEngine` to use `~/.local/share/accompose-venv/bin/python` (recommended for isolated dependencies). +The installer builds the `accompose` Xcode scheme in Release and copies the binary to `/opt/homebrew/bin/accompose` (override `PREFIX=/your/prefix`). It asks for administrator permissions only if the destination directory is not writable. Add the prefix's `bin` directory to your PATH if needed. -## Current implementation scope -- Native Xcode project with SwiftUI project browser, service summary, and command output -- Compose YAML parsing for service listing using Yams -- Bundled MVP engine: up/down, start/stop/restart, ps/logs, build/pull, state, dependencies, networks and volumes -- App process runs without macOS sandbox because it invokes the host's container CLI +## CLI usage -## Limitations -This is a development prototype, not a fully native orchestration engine or production-grade Docker Compose replacement. Runtime integration is not tested on macOS here. The Python engine requires PyYAML; service status is currently based on project state; logs are batch output, not continuous streaming; health checks, advanced Compose compatibility, restart supervision, service scaling and secure secrets management remain future work. The UI's restart action restarts the whole project. Consult Apple's `container` version for supported CLI flags. +``` +accompose init +accompose [-f compose.yaml] [-p project] config +accompose [-v|-vv|-vvv] up [-d|--detach] [--build] +accompose down [--volumes] +accompose ps +accompose pull +accompose build +accompose logs [service] +accompose start|stop|restart [service] +accompose exec SERVICE COMMAND [ARG ...] +accompose doctor +``` + +The default file discovery order is `compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`. `.env` substitution, build contexts, Dockerfiles, service dependency ordering, environment variables, published ports, bind mounts, named volumes, and custom networks have initial support. Names are scoped by project. + +## Limitations / important safety notes + +This is an **early developer preview**, not a drop-in Docker Compose implementation. Only a subset of Compose YAML is parsed. Long-form mounts/ports, secrets, service health gating, robust reconciliation, profiles, scaling, live `logs -f`, restart supervision, and full network aliases are **not implemented**. A service with multiple networks only attaches to its first network; the CLI prints a warning. Restart policies are parsed but **not enforced**. `up` does not update an existing container when YAML changes: use `down` then `up`. `ps` currently lists all Apple containers, not just the project. Named volumes are not removed by `down` unless `--volumes` is provided. `down` may stop/remove project containers: check the project name before running it. The installer never automatically downloads or configures an unverified kernel. + +Debug and Release builds of both Xcode targets and real Apple container runtime checks passed on macOS. See `Validation/VALIDATION.md` for the environment, checks and limitations. + +## Updated developer preview: CLI rename and cleanup + +The CLI target and installer are now named `accompose`. Re-run `./Scripts/install-cli.sh` after replacing your old project. The older `acccompose` executable is not automatically deleted. Verify using `accompose --help`. + +Additional commands: + +```sh +accompose import ./docker-compose.yml # validate existing YAML without modifying it +accompose import ~/Projects/example # validate directory's default Compose file +accompose clean web --dry-run # preview deletion +accompose clean web # delete managed service container +accompose clean --all --dry-run # preview project container deletion +accompose clean --all # delete project containers +accompose prune --images # request runtime unused-image cleanup +``` + +The sample `Examples/site/index.html` is included to prevent the original Nginx bind mount failure. Missing directory bind sources are created during `up`; do not use a missing *file* bind source because it will be created as a directory. + +### Compatibility limitations + +This is **not** full Docker Compose support. Import validates only the subset of YAML handled by ComposeCore. Unrecognized service keys cause an explicit error rather than being silently ignored. Import does not yet persist projects to a GUI library, multi-file merging and YAML extension semantics are incomplete, and clean only deletes managed project containers (not host data, volumes, images or networks). Image pruning relies on the installed Apple `container` version supporting `container image prune`. The kernel and all host bind files are preserved. `--dry-run` is only supported for clean and prune. The detached/foreground lifecycle has been integration-tested on the runtime version recorded in `Validation/VALIDATION.md`. + + +## Foreground and detached mode fix + +- `accompose up` streams each service's logs live and stays attached until log followers finish or the command is interrupted. +- `accompose up -d` and `accompose up --detach` return after startup, leaving containers running. +- `-v`, `-vv` and `-vvv` change diagnostics only. They work before or after `up`, for example `accompose -vvv up -d` and `accompose up -d -vvv`. +- Ctrl+C (or SIGTERM) in foreground mode stops managed containers in reverse dependency order using the runtime's graceful `stop`; containers are preserved for subsequent `up`. Interrupted startup stops the containers reached so far. +- Existing stopped containers are restarted. Configuration changes still require `down` and `up`. +- The GUI's Up action retains its asynchronous, detached engine behavior. +- The runtime executable is resolved from PATH first, with Homebrew/system fallback paths. + +Verbosity levels add mode/project details, Compose file location, then startup order. They do not change container execution mode. `logs` remains a snapshot command; foreground `up` uses independent live log followers. Multiple service logs share the terminal without added service prefixes. + +To rerun the isolated regression suite (Python 3 is needed only for tests): + +```sh +python3 Tests/cli_regression.py /absolute/path/to/built/accompose +``` + +The suite substitutes a temporary fake runtime and never changes real containers. diff --git a/Scripts/install-cli.sh b/Scripts/install-cli.sh new file mode 100755 index 0000000..3c09ea0 --- /dev/null +++ b/Scripts/install-cli.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PREFIX="${PREFIX:-/opt/homebrew}" +if ! command -v xcodebuild >/dev/null 2>&1; then + echo 'Xcode command-line tools / Xcode required.' >&2; exit 1 +fi +xcodebuild -project "$ROOT/AppleContainerCompose.xcodeproj" -scheme accompose \ + -configuration Release -derivedDataPath "$ROOT/.build" CODE_SIGNING_ALLOWED=NO build +BIN="$ROOT/.build/Build/Products/Release/accompose" +if [ ! -x "$BIN" ]; then + echo "CLI executable not found at $BIN" >&2; exit 1 +fi +mkdir -p "$PREFIX/bin" 2>/dev/null || sudo mkdir -p "$PREFIX/bin" +if [ -w "$PREFIX/bin" ]; then + install -m 755 "$BIN" "$PREFIX/bin/accompose" +else + sudo install -m 755 "$BIN" "$PREFIX/bin/accompose" +fi +printf 'Installed: %s/bin/accompose\n' "$PREFIX" +"$PREFIX/bin/accompose" --help diff --git a/Sources/App.swift b/Sources/App.swift new file mode 100644 index 0000000..a8b9d6e --- /dev/null +++ b/Sources/App.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct AppleContainerComposeApp: App { + @StateObject private var model = ComposeModel() + var body: some Scene { + WindowGroup("Apple Container Compose") { ContentView().environmentObject(model) } + .defaultSize(width: 1050, height: 720) + } +} diff --git a/Sources/CLI.swift b/Sources/CLI.swift new file mode 100644 index 0000000..5f993ad --- /dev/null +++ b/Sources/CLI.swift @@ -0,0 +1,111 @@ +import Foundation + +@main +struct AppleComposeCLI { + static func main() { + do { try execute() } + catch { fputs("accompose: \(error)\n", stderr); exit(1) } + } + static func execute() throws { + var arguments = Array(CommandLine.arguments.dropFirst()) + let help = """ + accompose — Apple Container Compose CLI + Usage: accompose [-f FILE] [-p PROJECT] COMMAND [OPTIONS] + + Commands: + 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) + -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.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("Apple 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: accompose 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()) + 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") + } +} diff --git a/Sources/ComposeCore.swift b/Sources/ComposeCore.swift new file mode 100644 index 0000000..49340ea --- /dev/null +++ b/Sources/ComposeCore.swift @@ -0,0 +1,367 @@ +import Foundation +import Yams +import Darwin + +// Signal handlers only enqueue cancellation. Runtime stop commands run on the caller. +private final class ForegroundInterrupt { + private let lock = NSLock() + private var cancelled = false + private var sources: [DispatchSourceSignal] = [] + private var previous: [(Int32, sig_t?)] = [] + var interrupted: Bool { lock.lock(); defer { lock.unlock() }; return cancelled } + init() { + for number in [SIGINT, SIGTERM] { + previous.append((number, signal(number, { _ in }))) + let source = DispatchSource.makeSignalSource(signal: number, queue: .global()) + source.setEventHandler { [weak self] in + guard let self else { return } + self.lock.lock(); self.cancelled = true; self.lock.unlock() + } + source.resume() + sources.append(source) + } + } + deinit { + for source in sources { source.cancel() } + for (number, handler) in previous { signal(number, handler) } + } +} + + +public struct ComposeService: Identifiable { + public let id: String + public var image: String + public var buildContext: String? + public var dockerfile: String? + public var ports: [String] + public var volumes: [String] + public var environment: [String] + public var dependencies: [String] + public var networks: [String] + public var command: [String] + public var workingDirectory: String? + public var restart: String? +} + +public enum ComposeError: Error, CustomStringConvertible { + case message(String) + public var description: String { if case .message(let value) = self { return value }; return "Unknown error" } +} + +public struct CommandResult { + public let status: Int32 + public let output: String +} + +public enum Shell { + public static func executable(_ name: String) -> String? { + let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] + return paths.map { ($0 as NSString).appendingPathComponent(name) }.first { FileManager.default.isExecutableFile(atPath: $0) } + } + @discardableResult public static func run(_ name: String, _ args: [String], directory: URL? = nil, streaming: Bool = false) throws -> CommandResult { + guard let binary = executable(name) else { throw ComposeError.message("Missing executable: \(name). Install Apple's container runtime first.") } + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = args + if let directory { process.currentDirectoryURL = directory } + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + try process.run() + // Drain while running to avoid blocking on full pipe buffers. + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(decoding: data, as: UTF8.self) + if streaming { print(output, terminator: "") } + return CommandResult(status: process.terminationStatus, output: output) + } + @discardableResult public static func checked(_ name: String, _ args: [String], directory: URL? = nil) throws -> String { + let result = try run(name, args, directory: directory) + guard result.status == 0 else { throw ComposeError.message("\(name) \(args.joined(separator: " ")) failed (\(result.status)):\n\(result.output)") } + return result.output + } +} + +public struct ComposeProject { + public let file: URL + public let name: String + public let services: [ComposeService] + public let networks: [String] + public let volumes: [String] + public var directory: URL { file.deletingLastPathComponent() } + public static func discover(_ supplied: String? = nil) throws -> URL { + let manager = FileManager.default + if let supplied { let url = URL(fileURLWithPath: supplied, relativeTo: URL(fileURLWithPath: manager.currentDirectoryPath)).standardizedFileURL; guard manager.fileExists(atPath: url.path) else { throw ComposeError.message("Compose file not found: \(url.path)") }; return url } + for name in ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"] { + let url = URL(fileURLWithPath: manager.currentDirectoryPath).appendingPathComponent(name) + if manager.fileExists(atPath: url.path) { return url } + } + throw ComposeError.message("No compose.yaml found. Use -f FILE or run accompose init.") + } + public init(file: URL, overrideName: String? = nil) throws { + self.file = file + let source = try String(contentsOf: file, encoding: .utf8) + let envFile = file.deletingLastPathComponent().appendingPathComponent(".env") + let dotenv = (try? String(contentsOf: envFile, encoding: .utf8)) ?? "" + var vars = ProcessInfo.processInfo.environment + for line in dotenv.split(separator: "\n") { + let line = line.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") { continue } + let parts = line.split(separator: "=", maxSplits: 1).map(String.init) + if parts.count == 2 && vars[parts[0]] == nil { vars[parts[0]] = parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) } + } + let expanded = try Self.interpolate(source, vars: vars) + guard let root = try Yams.load(yaml: expanded) as? [String: Any], let rawServices = root["services"] as? [String: Any], !rawServices.isEmpty else { throw ComposeError.message("Compose file must contain a nonempty services mapping") } + let requestedName = overrideName ?? (root["name"] as? String) ?? file.deletingLastPathComponent().lastPathComponent + self.name = Self.safeName(requestedName) + self.networks = (root["networks"] as? [String: Any]).map { Array($0.keys).sorted() } ?? [] + self.volumes = (root["volumes"] as? [String: Any]).map { Array($0.keys).sorted() } ?? [] + let resolvedProjectName = self.name + self.services = try rawServices.keys.sorted().map { key in + guard let raw = rawServices[key] as? [String: Any] else { throw ComposeError.message("Service \(key) must be a mapping") } + let supported: Set = ["image", "build", "ports", "volumes", "environment", "depends_on", "networks", "command", "working_dir", "restart"] + let ignored = Set(raw.keys).subtracting(supported) + if !ignored.isEmpty { + throw ComposeError.message("Service \(key) contains options not yet supported by Apple Container: \(ignored.sorted().joined(separator: ", ")). Refusing to silently ignore them.") + } + let build = raw["build"] + let buildMap = build as? [String: Any] + let context = (build as? String) ?? (buildMap?["context"] as? String) + let image = (raw["image"] as? String) ?? "\(resolvedProjectName)-\(key):latest" + guard context != nil || raw["image"] is String else { throw ComposeError.message("Service \(key) requires image or build") } + let deps: [String] + if let values = raw["depends_on"] as? [String] { deps = values } + else if let values = raw["depends_on"] as? [String: Any] { deps = Array(values.keys) } + else { deps = [] } + let env: [String] + if let values = raw["environment"] as? [String] { env = values } + else if let values = raw["environment"] as? [String: Any] { env = values.keys.sorted().compactMap { key in values[key].map { "\(key)=\($0)" } } } + else { env = [] } + let networks = (raw["networks"] as? [String]) ?? (raw["networks"] as? [String: Any]).map { Array($0.keys) } ?? [] + let command: [String] + if let arr = raw["command"] as? [String] { command = arr } + else if let str = raw["command"] as? String { command = ["sh", "-c", str] } + else { command = [] } + return ComposeService(id: key, image: image, buildContext: context, dockerfile: buildMap?["dockerfile"] as? String, ports: Self.strings(raw["ports"]), volumes: Self.strings(raw["volumes"]), environment: env, dependencies: deps, networks: networks, command: command, workingDirectory: raw["working_dir"] as? String, restart: raw["restart"] as? String) + } + let ids = Set(services.map(\.id)) + for service in services { for dep in service.dependencies where !ids.contains(dep) { throw ComposeError.message("Unknown dependency \(dep) for service \(service.id)") } } + _ = try orderedServices() + } + private static func strings(_ value: Any?) -> [String] { (value as? [Any])?.compactMap { $0 as? String ?? ($0 as? Int).map(String.init) } ?? [] } + public static func safeName(_ value: String) -> String { + let mapped = value.lowercased().map { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") ? $0 : "-" } + return String(mapped).trimmingCharacters(in: CharacterSet(charactersIn: "-_")) + } + private static func interpolate(_ text: String, vars: [String: String]) throws -> String { + let regex = try NSRegularExpression(pattern: #"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-|:\?|\?|-)([^}]*))?\}"#) + var output = text + for match in regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).reversed() { + guard let range = Range(match.range, in: output), let keyRange = Range(match.range(at: 1), in: output) else { continue } + let key = String(output[keyRange]); let mode = Range(match.range(at: 2), in: output).map { String(output[$0]) } ?? "" + let fallback = Range(match.range(at: 3), in: output).map { String(output[$0]) } ?? "" + let value = vars[key] + if (mode == "?" || mode == ":?") && (value == nil || (mode == ":?" && value == "")) { throw ComposeError.message("Missing required environment variable \(key): \(fallback)") } + let replacement = ((mode == ":-" && (value == nil || value == "")) || (mode == "-" && value == nil)) ? fallback : (value ?? "") + output.replaceSubrange(range, with: replacement) + } + return output.replacingOccurrences(of: "$$", with: "$") + } + public func orderedServices() throws -> [ComposeService] { + var result: [ComposeService] = []; var visiting = Set(); var visited = Set() + let lookup = Dictionary(uniqueKeysWithValues: services.map { ($0.id, $0) }) + func visit(_ key: String) throws { + if visiting.contains(key) { throw ComposeError.message("Dependency cycle involving \(key)") } + if visited.contains(key) { return } + visiting.insert(key) + for dependency in lookup[key]!.dependencies.sorted() { try visit(dependency) } + visiting.remove(key); visited.insert(key); result.append(lookup[key]!) + } + for service in services { try visit(service.id) } + return result + } + public func containerName(_ service: ComposeService) -> String { "\(name)-\(service.id)-1" } + public func networkName(_ name: String) -> String { "\(self.name)-\(name)" } + public func volumeName(_ name: String) -> String { "\(self.name)-\(name)" } +} + +public struct ComposeEngine { + public let project: ComposeProject + public init(_ project: ComposeProject) { self.project = project } + private func run(_ args: [String]) throws -> String { try Shell.checked("container", args, directory: project.directory) } + private func path(_ value: String) -> String { URL(fileURLWithPath: value, relativeTo: project.directory).standardizedFileURL.path } + private func existingContainers() throws -> String { try run(["list", "--all"]) } + private func exists(_ name: String) throws -> Bool { let result = try Shell.run("container", ["inspect", name]); return result.status == 0 } + private func ensureNetwork(_ network: String) throws { + let name = project.networkName(network) + let result = try Shell.run("container", ["network", "create", name]) + if result.status != 0 && !result.output.localizedCaseInsensitiveContains("already exists") { throw ComposeError.message(result.output) } + } + private func ensureVolume(_ volume: String) throws { + let name = project.volumeName(volume) + let result = try Shell.run("container", ["volume", "create", name]) + if result.status != 0 && !result.output.localizedCaseInsensitiveContains("already exists") { throw ComposeError.message(result.output) } + } + private func build(_ service: ComposeService) throws { + guard let context = service.buildContext else { return } + var args = ["build", "-t", service.image] + if let dockerfile = service.dockerfile { args += ["-f", path((context as NSString).appendingPathComponent(dockerfile))] } + args.append(path(context)) + print(try run(args)) + } + public func execute(_ action: String, args: [String] = [], attached: Bool = false, verbosity: Int = 0) throws -> String { + switch action { + case "doctor": + var lines = ["Apple Container Compose diagnostics", "Project: \(project.file.path)"] + guard let binary = Shell.executable("container") else { return (lines + ["FAIL: Apple container CLI not found. Install from https://github.com/apple/container/releases"]).joined(separator: "\n") } + lines.append("CLI: \(binary)") + let version = try Shell.run("container", ["--version"]); lines.append(version.output.trimmingCharacters(in: .whitespacesAndNewlines)) + let status = try Shell.run("container", ["system", "status"]); lines.append("Runtime: \(status.status == 0 ? "available" : "not ready")\n\(status.output)") + return lines.joined(separator: "\n") + case "config": return try String(contentsOf: project.file, encoding: .utf8) + "\nValidated \(project.services.count) services; startup order: \(project.orderedServices().map(\.id).joined(separator: ", "))\n" + case "pull": + var output = "" + for service in project.services where service.buildContext == nil { output += try run(["image", "pull", service.image]) } + return output + case "build": + for service in project.services { try build(service) } + return "Build completed.\n" + case "up": + let interrupt = attached ? ForegroundInterrupt() : nil + var managed: [String] = [] + var followers: [Process] = [] + defer { + for follower in followers where follower.isRunning { follower.terminate() } + for follower in followers { follower.waitUntilExit() } + if interrupt?.interrupted == true { + fputs("Stopping project gracefully…\n", stderr) + for name in managed.reversed() { + do { _ = try run(["stop", name]) } + catch { fputs("Failed to stop \(name): \(error)\n", stderr) } + } + } + } + let rebuild = args.contains("--build") + if verbosity > 0 { + fputs("accompose: project=\(project.name) mode=\(attached ? "foreground" : "detached") verbosity=\(verbosity)\n", stderr) + } + if verbosity > 1 { fputs("Compose file: \(project.file.path)\n", stderr) } + if verbosity > 2 { fputs("Startup order: \(try project.orderedServices().map(\.id).joined(separator: ", "))\n", stderr) } + for network in project.networks { try ensureNetwork(network) } + for volume in project.volumes { try ensureVolume(volume) } + var output = "" + for service in try project.orderedServices() { + if interrupt?.interrupted == true { break } + if rebuild { try build(service) } + let name = project.containerName(service) + managed.append(name) + if try exists(name) { + let inspection = try run(["inspect", name]) + let containers = try JSONSerialization.jsonObject(with: Data(inspection.utf8)) as? [[String: Any]] + if let status = containers?.first?["status"] as? [String: Any], status["state"] as? String == "stopped" { + output += try run(["start", name]) + } + output += "Already exists: \(name) (use down before changing configuration)\n"; continue } + var cmd = ["run", "-d", "--name", name] + for port in service.ports { cmd += ["-p", port] } + for item in service.environment { cmd += ["-e", item] } + if let wd = service.workingDirectory { cmd += ["-w", wd] } + for item in service.volumes { + let parts = item.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + guard parts.count >= 2 else { throw ComposeError.message("Invalid volume mount: \(item)") } + let source = parts[0].hasPrefix(".") || parts[0].hasPrefix("/") || parts[0].hasPrefix("~") ? path((parts[0] as NSString).expandingTildeInPath) : project.volumeName(parts[0]) + if source.hasPrefix("/") && !FileManager.default.fileExists(atPath: source) { + // Docker Compose creates missing directory bind sources by default. + try FileManager.default.createDirectory(atPath: source, withIntermediateDirectories: true) + } + cmd += ["-v", ([source] + parts.dropFirst()).joined(separator: ":")] + } + if let first = service.networks.first { cmd += ["--network", project.networkName(first)] } + cmd.append(service.image); cmd += service.command + output += try run(cmd) + "\nStarted \(name)\n" + if service.networks.count > 1 { output += "WARNING: multiple networks requested for \(service.id); only first attached\n" } + if service.restart != nil { output += "WARNING: restart policy requires external supervision; not enabled for \(service.id)\n" } + } + if attached && interrupt?.interrupted != true { + print(output, terminator: "") + fflush(stdout) + guard let binary = Shell.executable("container") else { throw ComposeError.message("Missing container executable") } + for name in managed { + let follower = Process() + follower.executableURL = URL(fileURLWithPath: binary) + follower.arguments = ["logs", "--follow", name] + follower.standardOutput = FileHandle.standardOutput + follower.standardError = FileHandle.standardError + try follower.run() + followers.append(follower) + } + while followers.contains(where: { $0.isRunning }) && interrupt?.interrupted != true { + if let failed = followers.first(where: { !$0.isRunning && $0.terminationStatus != 0 }) { + throw ComposeError.message("Log follower failed (\(failed.terminationStatus))") + } + Thread.sleep(forTimeInterval: 0.05) + } + if interrupt?.interrupted != true { + for follower in followers where follower.terminationStatus != 0 { + throw ComposeError.message("Log follower failed (\(follower.terminationStatus))") + } + } + return "" + } + return output + case "down": + var output = "" + for service in (try project.orderedServices()).reversed() { + let name = project.containerName(service) + if try exists(name) { _ = try? run(["stop", name]); output += try run(["delete", name]) } + } + for network in project.networks { _ = try? run(["network", "delete", project.networkName(network)]) } + if args.contains("--volumes") { for volume in project.volumes { _ = try? run(["volume", "delete", project.volumeName(volume)]) } } + return output + "Project stopped.\n" + case "clean": + let dry = args.contains("--dry-run") + let all = args.contains("--all") || args.contains("--project") + let selected = args.first { !$0.hasPrefix("-") } + let targets = all ? project.services : project.services.filter { $0.id == selected || project.containerName($0) == selected } + guard !targets.isEmpty else { throw ComposeError.message("Select a service/container or use --all; see accompose clean --help") } + var output = "" + for service in targets { + let name = project.containerName(service) + if try exists(name) { + output += "\(dry ? "Would remove" : "Removing") container \(name)\n" + if !dry { _ = try? run(["stop", name]); output += try run(["delete", name]) } + } + } + output += "Named volumes, shared images, external networks and host bind paths were preserved.\n" + return output + case "prune": + // Only safe, explicit image cleanup; no blind deletion of user data. + guard args.contains("--images") else { return "No resources deleted. Use accompose prune --images to request unused image pruning; named volumes are never pruned automatically.\n" } + if args.contains("--dry-run") { return "Dry run: inspect unused images with container image list; no changes made.\n" } + let result = try Shell.run("container", ["image", "prune"]) + guard result.status == 0 else { throw ComposeError.message("Image prune unsupported or failed: \(result.output)") } + return result.output + case "ps": return try run(["list", "--all"]) + case "logs": + let selected = args.filter { !$0.hasPrefix("-") } + let services = selected.isEmpty ? project.services : project.services.filter { selected.contains($0.id) } + var output = "" + for service in services { output += "=== \(service.id) ===\n" + (try run(["logs", project.containerName(service)])) } + return output + case "start", "stop", "restart": + let selected = args.filter { !$0.hasPrefix("-") } + let services = selected.isEmpty ? project.services : project.services.filter { selected.contains($0.id) } + var output = "" + for service in services { + let name = project.containerName(service) + if action == "restart" { output += try run(["stop", name]); output += try run(["start", name]) } + else { output += try run([action, name]) } + } + return output + case "exec": + guard let service = args.first, let match = project.services.first(where: { $0.id == service }), args.count > 1 else { throw ComposeError.message("Usage: accompose exec SERVICE COMMAND [ARGS]") } + return try run(["exec", project.containerName(match)] + Array(args.dropFirst())) + default: throw ComposeError.message("Unsupported command: \(action)") + } + } +} diff --git a/Sources/ComposeModel.swift b/Sources/ComposeModel.swift new file mode 100644 index 0000000..3d789a4 --- /dev/null +++ b/Sources/ComposeModel.swift @@ -0,0 +1,47 @@ +import SwiftUI +import AppKit + +@MainActor final class ComposeModel: ObservableObject { + @Published var projectURL: URL? + @Published var services: [ComposeService] = [] + @Published var selectedService: String? + @Published var status = "Ready" + @Published var busy = false + @Published var output = "Open a compose.yaml file to begin.\n" + func openProject() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.yaml, .init(filenameExtension: "yml")!] + panel.canChooseDirectories = false + if panel.runModal() == .OK, let url = panel.url { load(url) } + } + func load(_ url: URL) { + do { + let project = try ComposeProject(file: url) + projectURL = url; services = project.services + selectedService = services.first?.id + output += "Loaded \(url.path) (\(services.count) services)\n" + } catch { output += "ERROR: \(error)\n" } + } + func clearLog() { output = "" } + func checkRuntime() { + guard let url = projectURL else { output += "Open a Compose project first.\n"; return } + run(url: url, action: "doctor") + } + func perform(_ action: String) { + guard let url = projectURL else { return } + run(url: url, action: action) + } + private func run(url: URL, action: String) { + guard !busy else { return } + busy = true; status = "Running \(action)…"; output += "\n$ acccompose \(action)\n" + Task.detached { + let result: String + do { result = try ComposeEngine(ComposeProject(file: url)).execute(action) } + catch { result = "ERROR: \(error)\n" } + await MainActor.run { + self.output += result + "\n" + self.busy = false; self.status = "Ready" + } + } + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift new file mode 100644 index 0000000..53b6622 --- /dev/null +++ b/Sources/ContentView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var model: ComposeModel + var body: some View { + NavigationSplitView { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "shippingbox.fill").foregroundStyle(.blue) + Text("Services").font(.headline) + Spacer() + Text("\(model.services.count)").foregroundStyle(.secondary) + }.padding(.horizontal) + List(selection: $model.selectedService) { + ForEach(model.services) { service in + VStack(alignment: .leading, spacing: 4) { + Text(service.id).font(.headline) + Text(service.image).font(.caption).foregroundStyle(.secondary).lineLimit(1) + }.padding(.vertical, 4).tag(service.id) + } + } + Text(model.projectURL?.lastPathComponent ?? "No project opened") + .font(.caption).foregroundStyle(.secondary).lineLimit(1).padding() + } + .navigationSplitViewColumnWidth(min: 220, ideal: 265) + } detail: { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Apple Container Compose").font(.title2.bold()) + Text(model.projectURL?.deletingLastPathComponent().path ?? "Open a Compose project to get started") + .font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + Spacer() + Text(model.status).foregroundStyle(.secondary) + } + if let service = model.services.first(where: { $0.id == model.selectedService }) { + GroupBox("Selected service") { + VStack(alignment: .leading, spacing: 6) { + LabeledContent("Name", value: service.id) + LabeledContent("Image", value: service.image) + LabeledContent("Ports", value: service.ports.joined(separator: ", ").isEmpty ? "None" : service.ports.joined(separator: ", ")) + LabeledContent("Depends on", value: service.dependencies.joined(separator: ", ").isEmpty ? "None" : service.dependencies.joined(separator: ", ")) + }.frame(maxWidth: .infinity, alignment: .leading).padding(5) + } + } + HStack { + Text("Command output").font(.headline) + Spacer() + Button("Clear") { model.clearLog() } + } + ScrollView { + Text(model.output).font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled).padding(12) + } + .background(.black.opacity(0.87), in: RoundedRectangle(cornerRadius: 9)) + .foregroundStyle(.green) + }.padding(20) + } + .toolbar { + ToolbarItemGroup { + Button("Open YAML", systemImage: "folder") { model.openProject() } + Button("Up", systemImage: "play.fill") { model.perform("up") }.disabled(model.busy || model.projectURL == nil) + Button("Down", systemImage: "stop.fill") { model.perform("down") }.disabled(model.busy || model.projectURL == nil) + Button("Restart", systemImage: "arrow.clockwise") { model.perform("restart") }.disabled(model.busy || model.projectURL == nil) + Button("Status", systemImage: "list.bullet.rectangle") { model.perform("ps") }.disabled(model.busy || model.projectURL == nil) + Button("Logs", systemImage: "text.alignleft") { model.perform("logs") }.disabled(model.busy || model.projectURL == nil) + Menu("More") { + Button("Pull Images") { model.perform("pull") } + Button("Build Images") { model.perform("build") } + Button("Check Runtime") { model.checkRuntime() } + } + } + } + } +} diff --git a/Tests/cli_regression.py b/Tests/cli_regression.py new file mode 100644 index 0000000..be8ecc0 --- /dev/null +++ b/Tests/cli_regression.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Run against a built CLI: python3 Tests/cli_regression.py /absolute/path/accompose. +Uses a fake container executable; never touches the real runtime. +""" +import json, os, pathlib, signal, subprocess, sys, tempfile, time + +binary = str(pathlib.Path(sys.argv[1]).resolve()) +with tempfile.TemporaryDirectory(prefix='accompose-regression-', dir='/private/tmp') as tmp: + root = pathlib.Path(tmp) + fake = root / 'container' + fake.write_text('#!' + sys.executable + '\n' + r''' +import json, os, signal, sys, time +args = sys.argv[1:] +with open(os.environ['CALLS'], 'a') as f: f.write(json.dumps(args) + '\n') +if args[0] == 'inspect': sys.exit(1) +if args[0] == 'run': + if os.environ.get('SLOW_START'): time.sleep(0.7) + print('created', flush=True) +if args[0] == 'logs': + print('LIVE ' + args[-1], flush=True) + if os.environ.get('FAIL_LOGS'): sys.exit(7) + while True: time.sleep(0.05) +''') + fake.chmod(0o755) + compose = root / 'compose.yaml' + compose.write_text('name: regression\nservices:\n db:\n image: example/db\n web:\n image: example/web\n depends_on: [db]\n') + calls = root / 'calls.jsonl' + env = dict(os.environ, PATH=str(root)+':'+os.environ.get('PATH',''), CALLS=str(calls)) + def recorded(): return [json.loads(x) for x in calls.read_text().splitlines()] + def reset(): calls.write_text('') + def command(args, extra=None): + return subprocess.Popen([binary, '-f', str(compose)] + args, env=env | (extra or {}), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) + count = 0 + # Both detach aliases, verbosity positions and all requested verbosity levels. + for detach in ['-d', '--detach']: + for level in ['', '-v', '-vv', '-vvv']: + for before in [False, True]: + reset() + args = ([level] if level and before else []) + ['up', detach] + ([level] if level and not before else []) + p = command(args); output = p.communicate(timeout=5)[0].decode() + assert p.returncode == 0, output + records = recorded() + assert len([x for x in records if x[0]=='run']) == 2, records + assert not any(x[0] in ['logs','stop'] for x in records), records + if level: assert 'mode=detached' in output and f'verbosity={len(level)-1}' in output, output + count += 1 + # Attached log output arrives while CLI is still alive. Interrupt parent or process group. + for level in ['', '-v', '-vv', '-vvv']: + for before in [False, True]: + reset() + args = ([level] if level and before else []) + ['up'] + ([level] if level and not before else []) + p = command(args) + output = b'' + import select + deadline = time.monotonic()+5 + while b'LIVE regression-db-1' not in output or b'LIVE regression-web-1' not in output: + assert time.monotonic()