Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions .github/workflows/ci.yml

This file was deleted.

1 change: 1 addition & 0 deletions App/ConsaiApp.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import ConsaiKit
import AppKit

/// Entry point. `--render-shots <dir>` renders UI screenshots and exits; otherwise the
Expand Down
40 changes: 0 additions & 40 deletions App/Shots/MockEngines.swift

This file was deleted.

1 change: 1 addition & 0 deletions App/Shots/ShotRenderer.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI
import AppKit
import ConsaiCore
import ConsaiKit

/// Screenshot harness for the `--render-shots <dir>` entry mode. Hosts the REAL SwiftUI
/// views in real NSWindows backed by the REAL AppState (live daemon data), then captures
Expand Down
1 change: 1 addition & 0 deletions App/Views/Banners.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import ConsaiKit

/// Shown when the `container` system service is not running.
struct ServiceBanner: View {
Expand Down
1 change: 1 addition & 0 deletions App/Views/ContainerRow.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import ConsaiCore
import ConsaiKit

/// One container row (used standalone and inside a stack branch): living dot, name + image,
/// and IP/state vitals on the right; hover reveals quick actions.
Expand Down
1 change: 1 addition & 0 deletions App/Views/CreateContainerWindow.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import ConsaiCore
import ConsaiKit
import AppKit

/// Form to create + run a new container (`container run -d …`).
Expand Down
19 changes: 1 addition & 18 deletions App/Views/DetailWindow.swift
Original file line number Diff line number Diff line change
@@ -1,25 +1,8 @@
import SwiftUI
import ConsaiCore
import ConsaiKit
import AppKit

/// Opens an interactive shell into a container via Terminal.
enum ContainerShell {
static func openShell(binaryPath: String, id: String) {
// Refuse ids that aren't plain container names — they're interpolated into an
// AppleScript/shell `do script`, so anything exotic is a command-injection risk.
guard isValidContainerName(id) else { return }
let command = containerExecCommand(binary: binaryPath, id: id)
// Escape for AppleScript string literal (defense-in-depth).
let escaped = command.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = "tell application \"Terminal\"\nactivate\ndo script \"\(escaped)\"\nend tell"
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
proc.arguments = ["-e", script]
try? proc.run()
}
}

/// Per-container detail: image, command, started, env, ports, mounts; open a shell or logs.
struct DetailWindow: View {
@Environment(AppState.self) private var appState
Expand Down
1 change: 1 addition & 0 deletions App/Views/ImagesWindow.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import ConsaiCore
import ConsaiKit
import AppKit

/// Browse local images, pull by reference, delete.
Expand Down
1 change: 1 addition & 0 deletions App/Views/InfraWindow.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import ConsaiCore
import ConsaiKit
import AppKit

/// Networks & volumes: list, create by name, delete.
Expand Down
1 change: 1 addition & 0 deletions App/Views/PanelView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import ConsaiCore
import ConsaiKit

/// The menu bar panel — Bonsai look: mark + wordmark, leaf-marked stacks on branches,
/// standalone containers, a tend-the-garden footer.
Expand Down
1 change: 1 addition & 0 deletions App/Views/SettingsWindow.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import ConsaiCore
import ConsaiKit
import AppKit

/// Settings: container system service control, compose availability, poll cadence.
Expand Down
1 change: 1 addition & 0 deletions App/Views/StackSection.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import ConsaiCore
import ConsaiKit
import AppKit

/// A compose stack as a "plant": a leaf-marked header whose services hang off a branch.
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ window-close and `applicationWillTerminate`.
### R7 — Build environment not present here (MEDIUM)
On this machine `xcodebuild`/Xcode 26 was not detected and `container-compose` is not
installed, so the app **cannot be compile-verified locally yet**. `container` *is* present.
**Mitigation:** treat "builds in Xcode 26" as a manual gate the developer runs; CI runs only
the pure `ConsaiCore` tests.
**Mitigation:** treat "builds in Xcode 26" as a manual gate the developer runs. There is no
hosted CI (Apple's SDK graph can't build on hosted runners) — verify locally with `swift
test` / `swift package check` (the `check` SwiftPM command plugin). See `docs/TESTING.md`.

### R8 — Crowded ecosystem (LOW / product)
Many compose tools and full GUIs already exist; the menu-bar-first niche is the
Expand Down
128 changes: 128 additions & 0 deletions ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable {
try await engine.up(file: composeFile)
}
}

@Test func availableWhenBinaryPresent() {
#expect(CLIComposeEngine(binaryURL: binary, runner: SpyProcessRunner()).isAvailable)
}

@Test func downFailureFallsBackToStdoutWhenStderrEmpty() async {
let spy = SpyProcessRunner(result: ProcessResult(exitCode: 1, stdout: "no such stack", stderr: ""))
let engine = CLIComposeEngine(binaryURL: binary, runner: spy)
await #expect(throws: ConsaiError.self) { try await engine.down(file: composeFile) }
}

@Test func resolveBinaryPrefersExecutableExplicitPath() throws {
let exe = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("container-compose-\(UUID().uuidString)")
try "#!/bin/sh\n".write(to: exe, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: exe.path)
defer { try? FileManager.default.removeItem(at: exe) }
#expect(CLIComposeEngine.resolveBinary(explicit: exe.path) == exe)
}
}

@Suite struct CLIContainerCreatorTests {
Expand Down Expand Up @@ -172,4 +191,113 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable {
try await health.start()
#expect(spy.invocations.first?.arguments == ["system", "start"])
}

@Test func stopSendsSystemStop() async throws {
let spy = SpyProcessRunner()
let health = CLIServiceHealth(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: spy)
try await health.stop()
#expect(spy.invocations.first?.arguments == ["system", "stop"])
}

@Test func statusRunsCliAndParsesOutput() async {
let spy = SpyProcessRunner(result: ProcessResult(exitCode: 0, stdout: "apiserver is running", stderr: ""))
let health = CLIServiceHealth(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: spy)
let status = await health.status()
#expect(status == .running)
#expect(spy.invocations.first?.arguments == ["system", "status"])
}

@Test func statusUnknownWhenBinaryMissing() async {
let health = CLIServiceHealth(binaryURL: nil, runner: SpyProcessRunner())
#expect(await health.status() == .unknown)
}

@Test func startThrowsOnNonZeroExitAndMissingBinary() async {
let failing = SpyProcessRunner(result: ProcessResult(exitCode: 1, stdout: "", stderr: "permission denied"))
let health = CLIServiceHealth(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: failing)
await #expect(throws: ConsaiError.self) { try await health.start() }

let noBinary = CLIServiceHealth(binaryURL: nil, runner: SpyProcessRunner())
await #expect(throws: ConsaiError.self) { try await noBinary.start() }
}

@Test func resolveBinaryPrefersExecutableExplicitPath() throws {
// An explicit, executable path is preferred over the built-in default candidates.
let exe = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("container-\(UUID().uuidString)")
try "#!/bin/sh\n".write(to: exe, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: exe.path)
defer { try? FileManager.default.removeItem(at: exe) }
#expect(CLIServiceHealth.resolveBinary(explicit: exe.path) == exe)
}
}

@Suite struct CLIContainerCreatorRunTests {
@Test func createSpawnsRunWithBuiltArguments() async throws {
let spy = SpyProcessRunner()
let creator = CLIContainerCreator(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: spy)
try await creator.create(NewContainerSpec(image: "redis", name: "cache"))
#expect(spy.invocations.first?.executable == "/usr/local/bin/container")
#expect(spy.invocations.first?.arguments == ["run", "-d", "--name", "cache", "redis"])
}

@Test func createThrowsOnNonZeroExit() async {
let spy = SpyProcessRunner(result: ProcessResult(exitCode: 125, stdout: "", stderr: "no such image"))
let creator = CLIContainerCreator(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: spy)
await #expect(throws: ConsaiError.self) { try await creator.create(NewContainerSpec(image: "ghost")) }
}

@Test func createThrowsWhenBinaryMissing() async {
let creator = CLIContainerCreator(binaryURL: nil, runner: SpyProcessRunner())
await #expect(throws: ConsaiError.self) { try await creator.create(NewContainerSpec(image: "redis")) }
}
}

@Suite struct RegistryStorePersistenceTests {
private func tempDir() -> URL {
URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("consai-store-\(UUID().uuidString)", isDirectory: true)
}

@Test func saveThenLoadRoundTrips() throws {
let dir = tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let store = RegistryStore(directory: dir)

var registry = ProjectRegistry()
registry.record(project: "shop", composeFile: URL(fileURLWithPath: "/p/shop/docker-compose.yml"))
try store.save(registry)

let loaded = store.load()
#expect(loaded.knownProjects["shop"]?.path == "/p/shop/docker-compose.yml")
#expect(loaded.recentComposeFiles.count == 1)
#expect(loaded == registry)
}

@Test func loadFromEmptyDirectoryReturnsEmptyRegistry() {
let store = RegistryStore(directory: tempDir())
#expect(store.load() == ProjectRegistry())
}

@Test func loadIgnoresCorruptFile() throws {
let dir = tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try "not json".write(to: dir.appendingPathComponent("registry.json"), atomically: true, encoding: .utf8)
// Corrupt JSON decodes to an empty registry rather than throwing.
#expect(RegistryStore(directory: dir).load() == ProjectRegistry())
}

@Test func defaultDirectoryIsUnderApplicationSupport() {
#expect(RegistryStore.defaultDirectory().path.hasSuffix("/Consai"))
}
}

@Suite struct ContainerImageDigestTests {
@Test func shortDigestHandlesDigestWithoutAlgorithmPrefix() {
// No ":" → take the raw hex prefix, not an empty split tail.
#expect(ContainerImage(reference: "x", digest: "abcdef0123456789").shortDigest == "abcdef012345")
#expect(ContainerImage(reference: "x", digest: "short").shortDigest == "short")
#expect(ContainerImage(reference: "nginx:latest", digest: "d").id == "nginx:latest")
}
}
Loading