diff --git a/macos-bar/Resources/AppIcon-source.png b/macos-bar/Resources/AppIcon-source.png new file mode 100644 index 00000000..05e924ab Binary files /dev/null and b/macos-bar/Resources/AppIcon-source.png differ diff --git a/macos-bar/Resources/Info.plist b/macos-bar/Resources/Info.plist index 73a77fb9..f0849ba7 100644 --- a/macos-bar/Resources/Info.plist +++ b/macos-bar/Resources/Info.plist @@ -20,6 +20,8 @@ 13.0 LSUIElement + CFBundleIconFile + AppIcon NSHumanReadableCopyright CCS Bar diff --git a/macos-bar/Scripts/package_app.sh b/macos-bar/Scripts/package_app.sh index 39f34466..b4e17ca7 100755 --- a/macos-bar/Scripts/package_app.sh +++ b/macos-bar/Scripts/package_app.sh @@ -42,6 +42,29 @@ if [[ -d "$ROOT/Resources/Assets" ]]; then cp "$ROOT/Resources/Assets/"*.png "$APP/Contents/Resources/" 2>/dev/null || true fi +# Build AppIcon.icns from AppIcon-source.png (1024x1024 RGBA PNG). +ICON_SRC="$ROOT/Resources/AppIcon-source.png" +if [[ -f "$ICON_SRC" ]]; then + echo "[i] Building AppIcon.icns..." + ICONSET_DIR="$(mktemp -d)/AppIcon.iconset" + mkdir -p "$ICONSET_DIR" + sips -z 16 16 "$ICON_SRC" --out "$ICONSET_DIR/icon_16x16.png" > /dev/null + sips -z 32 32 "$ICON_SRC" --out "$ICONSET_DIR/icon_16x16@2x.png" > /dev/null + sips -z 32 32 "$ICON_SRC" --out "$ICONSET_DIR/icon_32x32.png" > /dev/null + sips -z 64 64 "$ICON_SRC" --out "$ICONSET_DIR/icon_32x32@2x.png" > /dev/null + sips -z 128 128 "$ICON_SRC" --out "$ICONSET_DIR/icon_128x128.png" > /dev/null + sips -z 256 256 "$ICON_SRC" --out "$ICONSET_DIR/icon_128x128@2x.png" > /dev/null + sips -z 256 256 "$ICON_SRC" --out "$ICONSET_DIR/icon_256x256.png" > /dev/null + sips -z 512 512 "$ICON_SRC" --out "$ICONSET_DIR/icon_256x256@2x.png" > /dev/null + sips -z 512 512 "$ICON_SRC" --out "$ICONSET_DIR/icon_512x512.png" > /dev/null + sips -z 1024 1024 "$ICON_SRC" --out "$ICONSET_DIR/icon_512x512@2x.png" > /dev/null + iconutil -c icns "$ICONSET_DIR" -o "$APP/Contents/Resources/AppIcon.icns" + rm -rf "$(dirname "$ICONSET_DIR")" + echo "[OK] AppIcon.icns built." +else + echo "[!] AppIcon-source.png not found at $ICON_SRC — skipping icon." >&2 +fi + echo "[i] Signing ($SIGNING)..." case "$SIGNING" in adhoc) diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 14a09437..59099ede 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -2,6 +2,21 @@ import SwiftUI import AppKit import CCSBarCore +// MARK: - Content height preference key + +/// Preference key used to bubble the measured content VStack height up to the +/// ScrollView parent without a feedback loop. The reduction takes the MAX so +/// that intermediate layout passes that report a smaller size don't cause the +/// frame to shrink, which would trigger another layout pass (the classic +/// GeometryReader loop). A `@State` var is updated only when the value changes, +/// keeping SwiftUI's diffing stable. +private struct ContentHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + /// Dropdown content for the menu bar: a CCS-branded header, usage analytics, /// per-account rows + actions, an offline state when CCS isn't running, and /// footer controls. @@ -16,13 +31,36 @@ struct BarMenuView: View { /// no modal, no .confirmationDialog (those steal focus and dismiss the popover, /// the exact fragility of BUG 1). @State private var quitArmed = false + /// Measured height of the scroll content VStack, updated via preference key. + /// Starts at 0; the preference fires on the first layout pass and grows + /// monotonically (ContentHeightKey.reduce takes the max), so the frame never + /// thrashes downward. + @State private var contentHeight: CGFloat = 0 + + // MARK: - Screen cap + + /// Maximum height the scroll area may occupy: screen visible height minus space + /// for the macOS menu bar, the CCS Bar header, footer, and a small safety margin. + /// Clamped to a floor of 240 so the popover is always usable even on tiny displays. + private var screenCap: CGFloat { + let visibleHeight = NSScreen.main?.visibleFrame.height ?? 800 + return max(240, visibleHeight - 120) + } + + /// The resolved scroll-area height: the smaller of measured content and the cap. + /// Content shorter than the cap renders FULLY with no scroll bar. + /// Content taller than the cap scrolls. + private var scrollAreaHeight: CGFloat { + guard contentHeight > 0 else { return screenCap } + return min(contentHeight, screenCap) + } var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() - if viewModel.offline { + if viewModel.offline || viewModel.isStarting { offlineState.padding(14) } else { // The scroll indicator is suppressed (.never, not just .hidden) AND the @@ -78,12 +116,30 @@ struct BarMenuView: View { ScrollerHider().frame(width: 0, height: 0) } .padding(14) + // Measure the content height via a background GeometryReader that + // reports into ContentHeightKey. Using .background keeps the reader + // out of the layout pass that sizes the VStack itself, avoiding the + // classic GeometryReader-inside-ScrollView feedback loop. + .background( + GeometryReader { geo in + Color.clear + .preference(key: ContentHeightKey.self, value: geo.size.height) + } + ) } .scrollIndicators(.never) - // 700 gives more vertical breathing room before scroll engages — useful - // for 3-4 subscription cards each carrying multiple quota windows. - // Scroll still engages gracefully on genuine overflow. - .frame(maxHeight: 700) + // Deterministic height: exactly the content size up to the screen cap. + // Content shorter than the cap renders FULLY (no empty space, no clip). + // Content taller scrolls. contentHeight starts at 0 so we show screenCap + // until the first preference fires. + .frame(height: scrollAreaHeight) + .onPreferenceChange(ContentHeightKey.self) { measured in + // Only grow, never shrink — prevents a feedback loop where a smaller + // frame triggers a re-layout that reports an even smaller height. + if measured > contentHeight { + contentHeight = measured + } + } } Divider() @@ -205,15 +261,51 @@ struct BarMenuView: View { .padding(.vertical, 10) } - private var offlineState: some View { - VStack(alignment: .leading, spacing: 6) { - Label("CCS is not running", systemImage: "bolt.slash.fill") - .font(.body) - Text("Start CCS, then reopen this menu.") - .font(.caption) - .foregroundStyle(.secondary) - Button("Retry") { viewModel.reconnect(); viewModel.onOpen() } - .controlSize(.small) + /// Offline / starting state view. Two sub-states: + /// + /// **Starting** (`isStarting == true`): server launch is in progress. Shows a + /// spinner row so the user knows something is happening. No action needed. + /// + /// **Truly offline** (`offline == true`, `isStarting == false`): server is not + /// running and no launch is in progress. Shows a primary "Start CCS" button + /// that triggers the launcher + poll sequence, a secondary "Retry" that + /// re-probes without launching, and a guidance caption. + /// + /// All controls remain inside the popover — NO sheets, modals, or + /// .confirmationDialog (those steal focus and auto-dismiss the MenuBarExtra, + /// the known constraint noted throughout BarMenuView). + @ViewBuilder private var offlineState: some View { + if viewModel.isStarting { + // Starting state: spinner + label. Primary action is implicit (wait). + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Starting CCS…") + .font(.body) + .foregroundStyle(.secondary) + } + } else { + // Truly offline state: actionable controls. + VStack(alignment: .leading, spacing: 8) { + Label("CCS is not running", systemImage: "bolt.slash.fill") + .font(.body) + Text("Start CCS, then the menu will connect automatically.") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + // Primary: launches the server (reads launch.json or falls back to ccs on PATH). + Button("Start CCS") { + viewModel.startCCS() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + // Secondary: re-probe only (no launch attempt). + Button("Retry") { + viewModel.onOpen() + } + .controlSize(.small) + } + } } } diff --git a/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift b/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift new file mode 100644 index 00000000..45691c59 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift @@ -0,0 +1,163 @@ +import Foundation +import CCSBarCore + +/// Reads `~/.ccs/bar/launch.json` and spawns the CCS bar server detached, +/// redirecting stdout/stderr to `~/.ccs/bar/serve.log`. +/// +/// If `launch.json` is absent the launcher falls back to resolving `ccs` on +/// PATH via `/bin/zsh -lic "command -v ccs"` and spawning `ccs bar serve`. +/// +/// This type is intentionally NOT on the main actor: `Process` is blocking and +/// must never run on the main thread. Explicit `Sendable` is safe because the +/// only stored property is an immutable `String`. +struct BarServerLauncher: Sendable { + let home: String + + init(home: String = NSHomeDirectory()) { + self.home = home + } + + // MARK: - Public + + /// Attempt to start the CCS bar server. + /// Returns `true` when the process was successfully spawned (detached), + /// `false` when the launch is not possible (no descriptor, no ccs on PATH). + /// This does NOT wait for the server to become reachable — callers must + /// poll BarServerProbe after calling start(). + @discardableResult + func start() -> Bool { + if let descriptor = loadDescriptor() { + return spawnFromDescriptor(descriptor) + } + return spawnFallback() + } + + // MARK: - Descriptor loading + + private func loadDescriptor() -> BarLaunchDescriptor? { + let path = BarLaunchDescriptor.defaultPath(home: home) + guard FileManager.default.fileExists(atPath: path), + let data = FileManager.default.contents(atPath: path) + else { return nil } + return try? JSONDecoder().decode(BarLaunchDescriptor.self, from: data) + } + + // MARK: - Spawn from descriptor + + private func spawnFromDescriptor(_ descriptor: BarLaunchDescriptor) -> Bool { + guard !descriptor.runtime.isEmpty, !descriptor.args.isEmpty else { return false } + + let logURL = serveLogURL() + ensureLogDirectory() + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: descriptor.runtime) + // Drop the first arg (the entry point itself) when the runtime IS the + // entry point; args[0] is the entry script, args[1..] are the remaining. + // Per the plan: args = [absoluteEntryScript, "bar", "serve"]. + proc.arguments = Array(descriptor.args.dropFirst()) + + // Prepend the entry script as the first positional arg to the runtime. + // e.g. node /path/to/ccs.js bar serve + if let first = descriptor.args.first { + proc.arguments = [first] + (proc.arguments ?? []) + } + + proc.currentDirectoryURL = URL(fileURLWithPath: descriptor.home) + + var env = ProcessInfo.processInfo.environment + if let ccsHome = descriptor.ccsHome, !ccsHome.isEmpty { + env["CCS_HOME"] = ccsHome + } + proc.environment = env + + attachLog(proc, logURL: logURL) + return launchDetached(proc) + } + + // MARK: - Fallback: resolve `ccs` then spawn + + private func spawnFallback() -> Bool { + guard let ccsPath = resolveCCSPath() else { return false } + + let logURL = serveLogURL() + ensureLogDirectory() + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: ccsPath) + proc.arguments = ["bar", "serve"] + proc.currentDirectoryURL = URL(fileURLWithPath: home) + proc.environment = ProcessInfo.processInfo.environment + attachLog(proc, logURL: logURL) + return launchDetached(proc) + } + + /// Resolve the `ccs` binary path by running `/bin/zsh -lic "command -v ccs"`. + /// Synchronous; called only when launch.json is absent. + private func resolveCCSPath() -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/zsh") + proc.arguments = ["-lic", "command -v ccs"] + + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = Pipe() // discard stderr + + guard (try? proc.run()) != nil else { return nil } + proc.waitUntilExit() + + guard proc.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let raw = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? nil : raw + } + + // MARK: - Helpers + + /// URL for the serve log file: `~/.ccs/bar/serve.log`. + private func serveLogURL() -> URL { + URL(fileURLWithPath: home) + .appendingPathComponent(".ccs") + .appendingPathComponent("bar") + .appendingPathComponent("serve.log") + } + + /// Ensure `~/.ccs/bar/` directory exists before writing the log. + private func ensureLogDirectory() { + let dir = URL(fileURLWithPath: home) + .appendingPathComponent(".ccs") + .appendingPathComponent("bar") + .path + try? FileManager.default.createDirectory( + atPath: dir, withIntermediateDirectories: true, attributes: nil) + } + + /// Wire up stdout + stderr to the log file (appending). Creates the file if absent. + private func attachLog(_ proc: Process, logURL: URL) { + // Create or open the file for appending. + if !FileManager.default.fileExists(atPath: logURL.path) { + FileManager.default.createFile(atPath: logURL.path, contents: nil) + } + if let handle = try? FileHandle(forWritingTo: logURL) { + handle.seekToEndOfFile() + proc.standardOutput = handle + proc.standardError = handle + } else { + // Fallback: discard output rather than block the launch. + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + } + } + + /// Launch `proc` detached (does not wait for it). Returns success/failure. + private func launchDetached(_ proc: Process) -> Bool { + do { + try proc.run() + // Do NOT call waitUntilExit() — the process must outlive this app launch. + return true + } catch { + return false + } + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index b8106ef4..cbfd324a 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -3,8 +3,9 @@ import SwiftUI import CCSBarCore /// Observable state for the menu bar. Holds the last-known rows for instant -/// paint, reconnects to the CCS web-server via the discovery file, and fires a -/// debounced force-refresh when the menu opens. +/// paint, reconnects to the CCS web-server via the discovery file or by +/// self-starting the server, and fires a debounced force-refresh when the +/// menu opens. @MainActor final class BarViewModel: ObservableObject { @Published var rows: [BarSummaryRow] = [] @@ -12,6 +13,8 @@ final class BarViewModel: ObservableObject { @Published var offline = false @Published var lastError: String? @Published var isRefreshing = false + /// True while the launcher is waiting for the server to become reachable. + @Published var isStarting = false @Published var iconStyle: BarIconStyle { didSet { MenuBarIcon.saveStyle(iconStyle) } } @@ -38,27 +41,32 @@ final class BarViewModel: ObservableObject { private var client: CCSBarClient? private var debouncer = RefreshDebouncer(interval: 15) /// Periodic background refresh so the glance self-heals from a transient - /// server gap (e.g. a momentary backend restart that dropped the native rows) - /// without the user having to reopen the menu. Cheap + safe: it reads the - /// local server's caches and never hammers providers (native quota is - /// TTL-gated server-side). + /// server gap without the user having to reopen the menu. Each tick re-probes + /// (not just re-reads bar.json) so a server that restarted on a new port is + /// found automatically. private var pollTask: Task? + /// Guard against overlapping start-and-connect sequences. + private var connectTask: Task? private let prefs: BarPreferences private let notifier: NotificationDelivering + private let probe: BarServerProbe + private let launcher: BarServerLauncher + + // MARK: - Init init( home: String = NSHomeDirectory(), prefs: BarPreferences = BarPreferences(), - notifier: NotificationDelivering? = nil + notifier: NotificationDelivering? = nil, + probe: BarServerProbe = BarServerProbe(), + launcher: BarServerLauncher? = nil ) { self.home = home self.prefs = prefs - // Seed registration defaults before the first pref read. Idempotent, and the - // App-level call is a redundant safety net for the @StateObject default-init - // ordering (stored-property defaults run before the App.init body). prefs.registerDefaults() - // Default to the real UN-backed notifier; tests inject a recording one. self.notifier = notifier ?? BarNotifier() + self.probe = probe + self.launcher = launcher ?? BarServerLauncher(home: home) self.iconStyle = MenuBarIcon.loadStyle() self.appearance = BarAppearanceStore.load() self.glanceMode = prefs.load().glanceMode @@ -67,24 +75,27 @@ final class BarViewModel: ObservableObject { startBackgroundPolling() } - /// Periodically re-poll for the app's lifetime so a transient empty/missing-row - /// state recovers on its own within one interval — each tick reconnects if the - /// client/discovery was lost, then loads (non-force, so it respects the - /// server-side caches). This is what prevents the menu from getting stuck after - /// the server momentarily restarts. + // MARK: - Background polling + + /// Periodically re-probe for the app's lifetime so a transient empty/missing-row + /// state recovers on its own within one interval. Each tick re-probes the + /// network (not just re-reads bar.json) so a server that moved ports is found. private func startBackgroundPolling() { pollTask?.cancel() pollTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) guard let self else { return } + // Re-probe first; if live, reconnect; then load cached data. + await self.reconnectIfNeeded() await self.load(force: false) } } } - /// Re-read prefs after the preferences sheet writes through, so the next poll - /// (and the live title) reflects the change immediately. + // MARK: - Prefs + + /// Re-read prefs after the preferences sheet writes through. func reloadPrefs() { glanceMode = prefs.load().glanceMode } @@ -94,44 +105,136 @@ final class BarViewModel: ObservableObject { iconStyle = (iconStyle == .color) ? .mono : .color } + // MARK: - Status title + /// Compact status-bar title, resolved through the user's chosen glance mode. var statusTitle: String { - offline + if isStarting { return "CCS starting…" } + return offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows, analytics: analytics, mode: glanceMode) } - /// Resolve the discovery file and (re)build the client. Marks offline when - /// CCS hasn't been launched. + // MARK: - Connection / discovery + + /// Fast synchronous reconnect: reads bar.json and builds a client if the URL + /// is valid. Does NOT probe the network. Used as a cheap first-pass before + /// the async probe path. func reconnect() { switch BarDiscovery.load(home: home) { case .success(let discovery): if let url = discovery.resolvedURL { client = CCSBarClient(baseURL: url) - offline = false + // Don't clear `offline` yet — the network probe in connectAndLoad() + // will confirm liveness. Leave existing offline state until we know. } else { client = nil - offline = true } case .failure: client = nil - offline = true } } - /// Menu opened: cached rows are already on screen; fire a debounced - /// force-refresh so the glance reflects live provider data. - func onOpen() { - let force = debouncer.shouldRefresh(now: Date()) - Task { await load(force: force) } + /// Full async reconnect: probe the network for a live server, build a client + /// when found, launch the server if not found. Updates `isStarting`/`offline`. + /// + /// This is the entry point called by `onOpen()` and the background poll. + func reconnectAndLoad(force: Bool = false) { + // Cancel any in-flight connect so we don't stack up. + connectTask?.cancel() + connectTask = Task { [weak self] in + guard let self else { return } + await self.connectSequence(force: force) + } } - func load(force: Bool) async { - if client == nil { reconnect() } - guard let client else { + /// Core sequence: probe → if live build client + load; else launch + poll → load. + private func connectSequence(force: Bool) async { + let discovery = try? BarDiscovery.load(home: home).get() + + // 1. Probe for an already-running server. + if let liveURL = await probe.findLiveServer(discovery: discovery) { + client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport()) + offline = false + isStarting = false + await load(force: force) + return + } + + // 2. No live server found — try to start one. + isStarting = true + offline = false // show spinner, not the error state + + let launched = await Task.detached(priority: .utility) { [launcher = self.launcher] in + launcher.start() + }.value + + if !launched { + // Could not even attempt a launch (no descriptor, no ccs on PATH). + isStarting = false offline = true return } + + // 3. Poll until reachable (timeout ~12s, 500ms interval). + let deadline = Date().addingTimeInterval(12) + while Date() < deadline { + guard !Task.isCancelled else { break } + try? await Task.sleep(for: .milliseconds(500)) + if let liveURL = await probe.findLiveServer(discovery: BarDiscovery.load(home: home).ok) { + client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport()) + isStarting = false + offline = false + await load(force: true) + return + } + } + + // 4. Timed out. + isStarting = false + offline = true + } + + /// Re-probe silently during the background poll. If a live server is found and + /// we currently have no client (or are offline), build the client and clear + /// offline. If no server found and we had one, mark offline. + private func reconnectIfNeeded() async { + let discovery = try? BarDiscovery.load(home: home).get() + if let liveURL = await probe.findLiveServer(discovery: discovery) { + client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport()) + if offline { offline = false } + } else if client != nil { + // We had a client but server is gone. + client = nil + if rows.isEmpty { offline = true } + } + } + + // MARK: - Menu open + + /// Menu opened: cached rows are already on screen; fire a debounced reconnect + /// + force-refresh so the glance reflects live provider data. + func onOpen() { + let force = debouncer.shouldRefresh(now: Date()) + reconnectAndLoad(force: force) + } + + // MARK: - Start CCS (called from offline UI button) + + /// Trigger a full connect sequence (probe → launch → poll). Used by the + /// "Start CCS" button in the offline state. + func startCCS() { + reconnectAndLoad(force: true) + } + + // MARK: - Data loading + + func load(force: Bool) async { + // If we still have no client after the connect sequence, bail. + guard let client else { + if !isStarting { offline = true } + return + } if force { isRefreshing = true } defer { isRefreshing = false } do { @@ -154,11 +257,9 @@ final class BarViewModel: ObservableObject { evaluateAlerts() } - /// Run the pure rule engine once per poll against the freshly-loaded state, - /// deliver any new notifications, and overwrite the persisted fired-key set - /// verbatim. The engine's dedupe means repeated polls with unchanged state - /// deliver nothing, so this never spams. Delivery is best-effort and never - /// blocks the UI (the notifier hops to its own task). + // MARK: - Alert engine + + /// Run the pure rule engine once per poll against the freshly-loaded state. private func evaluateAlerts() { let current = prefs.load() let eval = BarAlertEngine.evaluate( @@ -170,13 +271,11 @@ final class BarViewModel: ObservableObject { for notification in eval.toDeliver { notifier.deliver(notification) } - // Persist the COMPLETE next-state set verbatim (no merge) — this is what keeps - // the stored set bounded and lets cleared conditions re-arm. prefs.firedKeys = eval.firedKeys activeAlerts = eval.toDeliver } - // MARK: Account actions + // MARK: - Account actions func pause(_ row: BarSummaryRow) { perform { try await $0.pause(provider: row.provider, accountId: row.accountId) } @@ -188,9 +287,6 @@ final class BarViewModel: ObservableObject { perform { try await $0.solo(provider: row.provider, accountId: row.accountId) } } func setDefault(_ row: BarSummaryRow) { - // The server's /api/accounts/default parses CLIProxy accounts as the - // composite "provider:accountId" key; row.id already has that shape. - // Sending the bare accountId fails parseCliproxyKey and 500s / no-ops. perform { try await $0.setDefault(name: row.id) } } func tierLock(_ row: BarSummaryRow, tier: String?) { @@ -213,3 +309,14 @@ final class BarViewModel: ObservableObject { String(describing: error) } } + +// MARK: - Result convenience + +private extension Result { + /// Extract the success value without throwing, for use in async contexts where + /// we want an optional rather than a thrown error. + var ok: Success? { + guard case .success(let v) = self else { return nil } + return v + } +} diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index a49e9b92..f4379124 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1445,6 +1445,188 @@ check(BarVersionDisplay.displayString(for: "2.0.0") == "v2.0.0", "version '2.0.0 // Outside a bundle, string() must not crash and returns nil (no assertion on value — bundle absent). let _ = BarVersionDisplay.string() +// MARK: - BarLaunchDescriptor JSON decoding + +// (L1) Full descriptor with ccsHome round-trips correctly. +do { + let json = """ + { + "schema": 1, + "runtime": "/usr/local/bin/node", + "args": ["/home/kai/.ccs/bin/ccs.js", "bar", "serve"], + "home": "/home/kai", + "ccsHome": "/home/kai/.ccs-custom" + } + """ + let d = try JSONDecoder().decode(BarLaunchDescriptor.self, from: Data(json.utf8)) + check(d.schema == 1, "descriptor: schema decodes to 1") + check(d.runtime == "/usr/local/bin/node", "descriptor: runtime decodes") + check(d.args == ["/home/kai/.ccs/bin/ccs.js", "bar", "serve"], "descriptor: args decode") + check(d.home == "/home/kai", "descriptor: home decodes") + check(d.ccsHome == "/home/kai/.ccs-custom", "descriptor: ccsHome decodes when present") +} + +// (L2) Descriptor without ccsHome (optional field absent) decodes to nil. +do { + let json = """ + { + "schema": 1, + "runtime": "/opt/homebrew/bin/bun", + "args": ["/usr/local/lib/ccs/dist/index.js", "bar", "serve"], + "home": "/Users/kai" + } + """ + let d = try JSONDecoder().decode(BarLaunchDescriptor.self, from: Data(json.utf8)) + check(d.ccsHome == nil, "descriptor: absent ccsHome decodes to nil") + check(d.runtime == "/opt/homebrew/bin/bun", "descriptor: bun runtime decodes") + check(d.args.count == 3, "descriptor: args count is 3") + check(d.args.last == "serve", "descriptor: last arg is 'serve'") +} + +// (L3) defaultPath uses ~/.ccs/bar/launch.json. +do { + let home = "/Users/testuser" + let path = BarLaunchDescriptor.defaultPath(home: home) + check(path.hasSuffix("/.ccs/bar/launch.json"), "descriptor: defaultPath ends with /.ccs/bar/launch.json") + check(path.hasPrefix(home), "descriptor: defaultPath starts with home dir") +} + +// (L4) Malformed JSON is non-decodable (no crash, just nil from try?). +do { + let bad = Data("{\"schema\": \"not-an-int\"}".utf8) + let d = try? JSONDecoder().decode(BarLaunchDescriptor.self, from: bad) + check(d == nil, "descriptor: malformed JSON decodes to nil (no crash)") +} + +// MARK: - BarServerProbe ordering and selection + +// Mock transport that returns 200 for exactly one (host, port) combination and +// times out (throws) for everything else. Used to verify probe ordering. +final class SelectiveTransport: HTTPTransport, @unchecked Sendable { + /// The URL prefix that should succeed (e.g. "http://127.0.0.1:3001"). + let successPrefix: String + /// Track every URL probed, in order. + var probed: [String] = [] + + init(successPrefix: String) { + self.successPrefix = successPrefix + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let urlStr = request.url?.absoluteString ?? "" + probed.append(urlStr) + if urlStr.hasPrefix(successPrefix) { + let http = HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(), http) + } + // Simulate connection refused — throw so the probe moves on. + throw URLError(.cannotConnectToHost) + } +} + +// (P1) bar.json port is probed FIRST, then fallbacks in order. +// With no live server the probe visits: bar.json port (127.0.0.1 then ::1), +// then 3000, 3001, ... until it finds one or exhausts all. +do { + // bar.json says port 9999 (unusual, not in fallbacks). We make 127.0.0.1:9999 succeed. + let transport = SelectiveTransport(successPrefix: "http://127.0.0.1:9999") + let probe = BarServerProbe(transport: transport) + let discovery = BarDiscovery(baseUrl: "http://127.0.0.1:9999", port: 9999, authMode: "loopback") + let result = await probe.findLiveServer(discovery: discovery) + + // The first URL probed must be the bar.json port on 127.0.0.1. + check( + transport.probed.first?.hasPrefix("http://127.0.0.1:9999") == true, + "probe: bar.json port is tried first (127.0.0.1)") + check(result?.absoluteString.hasPrefix("http://127.0.0.1:9999") == true, + "probe: returns live URL when bar.json port responds 200") +} + +// (P2) Falls through to fallback list when bar.json port is dead. +// Make port 3001 on 127.0.0.1 the live one. bar.json points at dead port 9999. +do { + let transport = SelectiveTransport(successPrefix: "http://127.0.0.1:3001") + let probe = BarServerProbe(transport: transport) + let discovery = BarDiscovery(baseUrl: "http://127.0.0.1:9999", port: 9999, authMode: "loopback") + let result = await probe.findLiveServer(discovery: discovery) + + check(result?.absoluteString.hasPrefix("http://127.0.0.1:3001") == true, + "probe: falls through to fallback 3001 when bar.json port 9999 is dead") + + // bar.json port 9999 must have been probed before any fallback. + let idx9999 = transport.probed.firstIndex(where: { $0.contains(":9999/") }) + let idx3001 = transport.probed.firstIndex(where: { $0.contains(":3001/") }) + if let i = idx9999, let j = idx3001 { + check(i < j, "probe: bar.json port (9999) tried before fallback port (3001)") + } else { + check(false, "probe: expected probes for ports 9999 and 3001") + } +} + +// (P3) IPv4 tried before IPv6 for each port. +// Make only the IPv6 address of 3000 succeed so we can verify that 127.0.0.1 +// was tried before [::1] for the same port. +do { + let transport = SelectiveTransport(successPrefix: "http://[::1]:3000") + let probe = BarServerProbe(transport: transport) + // No bar.json — nil discovery. + let result = await probe.findLiveServer(discovery: nil) + + check(result?.absoluteString.hasPrefix("http://[::1]:3000") == true, + "probe: IPv6 [::1]:3000 selected when 127.0.0.1:3000 is dead") + + let idx4 = transport.probed.firstIndex(where: { $0.contains("127.0.0.1:3000") }) + let idx6 = transport.probed.firstIndex(where: { $0.contains("[::1]:3000") }) + if let i = idx4, let j = idx6 { + check(i < j, "probe: 127.0.0.1 tried before [::1] for same port") + } else { + check(false, "probe: expected probes for both 127.0.0.1:3000 and [::1]:3000") + } +} + +// (P4) Returns nil when no server responds. +do { + // A transport that always throws — simulates total offline. + struct DeadTransport: HTTPTransport { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + throw URLError(.cannotConnectToHost) + } + } + let probe = BarServerProbe(transport: DeadTransport()) + let result = await probe.findLiveServer(discovery: nil) + check(result == nil, "probe: returns nil when no server responds") +} + +// (P5) Non-200 response is treated as dead (not live). +do { + struct Status404Transport: HTTPTransport { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let http = HTTPURLResponse( + url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)! + return (Data(), http) + } + } + let probe = BarServerProbe(transport: Status404Transport()) + let result = await probe.findLiveServer(discovery: nil) + check(result == nil, "probe: 404 response treated as dead (not live)") +} + +// (P6) Fallback list deduplication: when bar.json port equals a fallback port +// (e.g. 3000), that port is not probed twice. +do { + let transport = SelectiveTransport(successPrefix: "http://127.0.0.1:3000") + let probe = BarServerProbe(transport: transport) + // bar.json port == 3000, which is also in the fallback list. + let discovery = BarDiscovery(baseUrl: "http://127.0.0.1:3000", port: 3000, authMode: "loopback") + _ = await probe.findLiveServer(discovery: discovery) + + // Count how many times :3000/ appears across all probed URLs. + let port3000Count = transport.probed.filter { $0.contains(":3000/") }.count + // Expect exactly 1 probe for 3000 on 127.0.0.1 (finds it immediately; stops before ::1). + check(port3000Count == 1, "probe: bar.json port 3000 deduped — probed once, not twice") +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarServerProbe.swift b/macos-bar/Sources/CCSBarCore/BarServerProbe.swift new file mode 100644 index 00000000..cd9327cb --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarServerProbe.swift @@ -0,0 +1,114 @@ +import Foundation + +// MARK: - BarLaunchDescriptor (written by ccs bar install / ccs bar, decoded by the Swift app) + +/// Schema-versioned descriptor that tells the Swift app how to start the server +/// without a shell PATH. Stored at `~/.ccs/bar/launch.json`. +public struct BarLaunchDescriptor: Codable, Sendable { + /// Schema version — always 1 for the current format. + public let schema: Int + /// Absolute path to the node/bun/runtime binary (`process.execPath`). + public let runtime: String + /// Arguments to pass to `runtime`: [absoluteEntryScript, "bar", "serve"]. + public let args: [String] + /// Working directory for the spawned server (`os.homedir()`). + public let home: String + /// Value of `CCS_HOME` env var, if it was set when the descriptor was written. + public let ccsHome: String? + + public init(schema: Int = 1, runtime: String, args: [String], home: String, ccsHome: String?) { + self.schema = schema + self.runtime = runtime + self.args = args + self.home = home + self.ccsHome = ccsHome + } + + /// Default path for the launch descriptor under `~/.ccs/bar/launch.json`. + public static func defaultPath(home: String = NSHomeDirectory()) -> String { + URL(fileURLWithPath: home) + .appendingPathComponent(".ccs") + .appendingPathComponent("bar") + .appendingPathComponent("launch.json") + .path + } +} + +// MARK: - BarServerProbe + +/// Async port-probe that mirrors the TS `defaultFindRunningServer` order: +/// 1. bar.json port (if available) +/// 2. 3000, 3001, 3002, 8000, 8080 +/// Each port is tried on 127.0.0.1 then ::1. +/// Liveness check: GET /api/bar/summary -> 200. +/// +/// The transport is injectable so the check harness can test ordering without +/// a live server. +public struct BarServerProbe: Sendable { + /// Fallback probe ports in order (after bar.json port). + static let fallbackPorts = [3000, 3001, 3002, 8000, 8080] + + private let transport: HTTPTransport + + public init(transport: HTTPTransport = URLSessionTransport()) { + self.transport = transport + } + + /// Probe for a live CCS server. Returns the base URL of the first responding + /// server, or `nil` if none respond within the attempt. + /// + /// - Parameter discovery: The current bar.json contents, if available. + /// Its port is probed first before the fallback list. + public func findLiveServer(discovery: BarDiscovery?) async -> URL? { + let candidatePorts = buildCandidatePorts(discovery: discovery) + + for port in candidatePorts { + if let url = await probePort(port) { + return url + } + } + return nil + } + + // MARK: - Private + + /// Build the ordered port list: bar.json port first (deduplicated), then fallbacks. + private func buildCandidatePorts(discovery: BarDiscovery?) -> [Int] { + var ports: [Int] = [] + if let d = discovery { + ports.append(d.port) + } + for p in Self.fallbackPorts where !ports.contains(p) { + ports.append(p) + } + return ports + } + + /// Try 127.0.0.1 then ::1 for the given port. + /// Returns the first base URL that responds 200 to /api/bar/summary, or nil. + private func probePort(_ port: Int) async -> URL? { + let hosts = ["127.0.0.1", "::1"] + for host in hosts { + // IPv6 addresses must be bracketed in URLs. + let hostStr = host.contains(":") ? "[\(host)]" : host + guard let base = URL(string: "http://\(hostStr):\(port)") else { continue } + if await isLive(baseURL: base) { + return base + } + } + return nil + } + + /// Returns true if GET {baseURL}/api/bar/summary responds with HTTP 200. + private func isLive(baseURL: URL) async -> Bool { + let url = baseURL.appendingPathComponent("api/bar/summary") + var req = URLRequest(url: url) + req.timeoutInterval = 2.0 + do { + let (_, http) = try await transport.send(req) + return http.statusCode == 200 + } catch { + return false + } + } +} diff --git a/src/commands/bar/bar-paths.ts b/src/commands/bar/bar-paths.ts new file mode 100644 index 00000000..c8edfe93 --- /dev/null +++ b/src/commands/bar/bar-paths.ts @@ -0,0 +1,50 @@ +/** + * Shared path helpers for the `ccs bar` command family. + * + * Centralises the paths under ~/.ccs/bar/ so every subcommand stays DRY. + * All paths derive from ccsDir (i.e. getCcsDir()) so CCS_HOME isolation + * works correctly in tests. + */ + +import * as path from 'path'; + +/** launch.json — consumed by the Swift app to spawn the server without a shell PATH. */ +export function getLaunchJsonPath(ccsDir: string): string { + return path.join(ccsDir, 'bar', 'launch.json'); +} + +/** server.pid — PID of the live detached server process. */ +export function getServerPidPath(ccsDir: string): string { + return path.join(ccsDir, 'bar', 'server.pid'); +} + +/** serve.log — stdout/stderr of the detached server process. */ +export function getServeLogPath(ccsDir: string): string { + return path.join(ccsDir, 'bar', 'serve.log'); +} + +/** bar.json — live discovery file consumed by the Swift app. */ +export function getBarJsonPath(ccsDir: string): string { + return path.join(ccsDir, 'bar.json'); +} + +/** bar/ subdirectory (parent of all bar artefacts). */ +export function getBarDir(ccsDir: string): string { + return path.join(ccsDir, 'bar'); +} + +/** Schema version constant for launch.json. */ +export const LAUNCH_JSON_SCHEMA = 1; + +/** Shape of launch.json written by install and refreshed by launch. */ +export interface LaunchJson { + schema: typeof LAUNCH_JSON_SCHEMA; + /** Absolute path to the node/bun binary (process.execPath). */ + runtime: string; + /** Absolute CCS entry point + subcommand args: [process.argv[1], 'bar', 'serve']. */ + args: string[]; + /** os.homedir() — cwd for the spawned server. */ + home: string; + /** CCS_HOME env value when set; omitted otherwise. */ + ccsHome?: string; +} diff --git a/src/commands/bar/bar-server-probe.ts b/src/commands/bar/bar-server-probe.ts new file mode 100644 index 00000000..c4c05a0e --- /dev/null +++ b/src/commands/bar/bar-server-probe.ts @@ -0,0 +1,77 @@ +/** + * CCS Bar — server liveness probe utilities. + * + * Shared by launch-subcommand.ts and serve-subcommand.ts so neither imports + * from the other (which would create a cross-module dependency that breaks + * Bun's test module isolation when cache-busting URLs are used). + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +export interface DashboardInfo { + port: number; + baseUrl: string; +} + +/** + * Read the port recorded in an existing bar.json. + * Returns null when the file is absent or malformed. + */ +export function resolveBarPort(ccsDir: string): number | null { + const barJsonPath = path.join(ccsDir, 'bar.json'); + try { + const raw = fs.readFileSync(barJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial<{ port: number }>; + return typeof parsed.port === 'number' ? parsed.port : null; + } catch { + return null; + } +} + +/** + * Probe candidate ports for a running CCS server. + * + * Both IPv4 (127.0.0.1) and IPv6 (::1) loopback addresses are probed for each + * port. All probes are fired concurrently so worst-case latency is ~1.5 s + * (one timeout) rather than N × 1.5 s sequentially. Priority selection is + * applied after all results are in: the bar.json port is preferred over the + * defaults, and within a port 127.0.0.1 is preferred over [::1]. + */ +export async function defaultFindRunningServer(ccsDir: string): Promise { + const { request } = await import('undici'); + + async function probe(url: string): Promise<{ ok: boolean }> { + try { + const { statusCode, body } = await request(url, { + method: 'GET', + headersTimeout: 1500, + bodyTimeout: 1500, + }); + await body.text(); + return { ok: statusCode === 200 }; + } catch { + return { ok: false }; + } + } + + const barJsonPort = resolveBarPort(ccsDir); + const base = [3000, 3001, 3002, 8000, 8080]; + const candidates: number[] = + barJsonPort !== null ? [barJsonPort, ...base.filter((p) => p !== barJsonPort)] : base; + + const probeTargets = candidates.flatMap((port) => [ + { port, baseUrl: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}/api/bar/summary` }, + { port, baseUrl: `http://[::1]:${port}`, url: `http://[::1]:${port}/api/bar/summary` }, + ]); + + const results = await Promise.all(probeTargets.map((t) => probe(t.url))); + + for (let i = 0; i < probeTargets.length; i++) { + if (results[i].ok) { + const { port, baseUrl } = probeTargets[i]; + return { port, baseUrl }; + } + } + return null; +} diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index 5d9c37dd..b244a277 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -13,7 +13,9 @@ export async function showHelp(): Promise { [ 'Commands:', [ - ['launch', 'Start the web-server, write ~/.ccs/bar.json, and open the app (default)'], + ['launch', 'Spawn the server detached, write ~/.ccs/bar.json, open the app (default)'], + ['stop', 'Stop the detached CCS Bar server'], + ['status', 'Show whether the CCS Bar server is running'], ['install', 'Download CCS Bar from the ccs-bar-latest GitHub release into ~/Applications'], ['uninstall', 'Remove the app and version pin'], ['version', 'Show CLI and installed app versions'], @@ -36,7 +38,9 @@ export async function showHelp(): Promise { [ 'Examples:', [ - ['ccs bar', 'Start the web-server and open CCS Bar (default launch)'], + ['ccs bar', 'Start the server detached and open CCS Bar'], + ['ccs bar stop', 'Stop the detached CCS Bar server'], + ['ccs bar status', 'Show server running state and PID'], ['ccs bar install', 'Download and install CCS Bar, then prompt to launch'], ['ccs bar install --launch', 'Install and launch immediately (no prompt)'], ['ccs bar install --no-launch', 'Install without launching'], @@ -56,6 +60,10 @@ export async function showHelp(): Promise { } console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.')); + console.log( + dim(' `ccs bar launch` spawns the server detached — the terminal is freed immediately.') + ); + console.log(dim(' The server persists until stopped with `ccs bar stop` or system reboot.')); console.log( dim( ' Gatekeeper quarantine is cleared automatically after install. If the app is still blocked,' diff --git a/src/commands/bar/index.ts b/src/commands/bar/index.ts index 6ae1e57f..27ecb28b 100644 --- a/src/commands/bar/index.ts +++ b/src/commands/bar/index.ts @@ -2,7 +2,11 @@ * `ccs bar` command dispatcher * * Mirrors the pattern in src/commands/docker/index.ts. - * Subcommands: launch (default), install, uninstall, version / --version, help / --help / -h. + * Subcommands: launch (default), serve, stop, status, install, uninstall, + * version / --version, help / --help / -h. + * + * `serve` is the long-lived server host (spawned detached by `launch` and by + * the Swift app). `stop` and `status` manage the detached server lifecycle. */ import { hasAnyFlag } from '../arg-extractor'; @@ -11,7 +15,6 @@ export async function handleBarCommand(args: string[]): Promise { const subcommand = args[0]; // --help / -h anywhere in args (e.g. `ccs bar install --help`) → show help. - // Mirrors docker/index.ts: hasAnyFlag(normalizedArgs, ['--help', '-h']). // Also dispatch bare `help` subcommand for symmetry. if (hasAnyFlag(args, ['--help', '-h']) || subcommand === 'help') { const { showHelp } = await import('./help-subcommand'); @@ -31,6 +34,18 @@ export async function handleBarCommand(args: string[]): Promise { const { handleBarLaunch } = await import('./launch-subcommand'); await handleBarLaunch(subArgs); }, + serve: async (subArgs) => { + const { handleBarServe } = await import('./serve-subcommand'); + await handleBarServe(subArgs); + }, + stop: async (subArgs) => { + const { handleBarStop } = await import('./stop-subcommand'); + await handleBarStop(subArgs); + }, + status: async (subArgs) => { + const { handleBarStatus } = await import('./status-subcommand'); + await handleBarStatus(subArgs); + }, install: async (subArgs) => { const { handleBarInstall } = await import('./install-subcommand'); await handleBarInstall(subArgs); @@ -50,7 +65,7 @@ export async function handleBarCommand(args: string[]): Promise { const handler = commandHandlers[subcommand]; if (!handler) { console.error(`[X] Unknown bar subcommand: ${subcommand}`); - console.error('[i] Usage: ccs bar [launch|install|uninstall|version|--help]'); + console.error('[i] Usage: ccs bar [launch|serve|stop|status|install|uninstall|version|--help]'); return; } diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index c51d740a..f2d20790 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -20,6 +20,8 @@ import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; import { hasAnyFlag } from '../arg-extractor'; +import { getLaunchJsonPath, LAUNCH_JSON_SCHEMA } from './bar-paths'; +import type { LaunchJson } from './bar-paths'; // --------------------------------------------------------------------------- // Constants @@ -118,6 +120,13 @@ export interface InstallDeps { * Throws on failure; caller catches and aborts install. */ removeExistingApp: (appPath: string) => void; + /** + * Write launch.json so the Swift app can spawn the server without a shell PATH. + * Called after successful install so the descriptor is always fresh. + * Injectable for tests — asserts the file is written without real fs side-effects. + * Non-fatal when it throws (install already succeeded). + */ + writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void; } // --------------------------------------------------------------------------- @@ -398,6 +407,11 @@ function defaultRemoveExistingApp(appPath: string): void { fs.rmSync(appPath, { recursive: true, force: true }); } +function defaultWriteLaunchDescriptor(jsonPath: string, descriptor: LaunchJson): void { + fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); + fs.writeFileSync(jsonPath, JSON.stringify(descriptor, null, 2)); +} + async function defaultLaunchBar(args: string[]): Promise { const { handleBarLaunch } = await import('./launch-subcommand'); await handleBarLaunch(args); @@ -483,6 +497,7 @@ export async function handleBarInstall( const promptLaunch = deps.promptLaunch ?? defaultPromptLaunch; const isBarRunning = deps.isBarRunning ?? defaultIsBarRunning; const removeExistingApp = deps.removeExistingApp ?? defaultRemoveExistingApp; + const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor; const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)(); @@ -616,6 +631,24 @@ export async function handleBarInstall( console.log('[!] Could not read app version from Info.plist.'); } + // 4b. Write launch.json so the Swift app can spawn the server without a shell PATH. + // Non-fatal — install has already succeeded at this point. + try { + const launchJsonPath = getLaunchJsonPath(ccsDir); + const launchDescriptor: LaunchJson = { + schema: LAUNCH_JSON_SCHEMA, + runtime: process.execPath, + args: [process.argv[1], 'bar', 'serve'], + home: os.homedir(), + ...(process.env.CCS_HOME ? { ccsHome: process.env.CCS_HOME } : {}), + }; + writeLaunchDescriptor(launchJsonPath, launchDescriptor); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Could not write launch.json: ${msg}`); + // Non-fatal — continue to quarantine clear + launch handoff. + } + // 5. Capability handshake via GET /api/bar/summary. // Runs BEFORE the quarantine-clear + launch handoff because it is a server-side // check unrelated to Gatekeeper. A failed quarantine clear must not prevent the diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index 605981e5..5a449638 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -1,21 +1,47 @@ /** - * `ccs bar launch` — ensure web-server is up, write ~/.ccs/bar.json, open the app. + * `ccs bar launch` — start the CCS Bar server detached, write bar.json, open the app. * * bar.json shape (v1): * { baseUrl: string, port: number, authMode: "loopback" } * - * authMode is always "loopback" for v1 (auth-enabled unsupported until v1.1). + * Detached model (replaces the old in-process model): + * 1. Probe candidate ports (bar.json port first, then 3000/3001/3002/8000/8080). + * 2. If a live server is found → reuse it, write bar.json, open app, return. + * 3. Else → refresh launch.json, getPort to pick a free port, spawn + * `ccs bar serve --port N` detached with stdio → serve.log, poll + * /api/bar/summary until 200 (timeout ~10 s), write bar.json, open app, + * return. The CLI process exits; the server continues as a detached child. * - * Reuse-first behavior: before starting a new server, launch probes the candidate - * ports (bar.json port first, then 3000, 3001, 3002, 8000, 8080) for a live CCS - * server at GET /api/bar/summary. A 200 response means the server is already - * running; launch reuses it without attempting to bind a new one. + * All side-effectful deps are injectable so tests can run without real + * servers, ports, or file-system writes. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; +import { + getBarDir, + getBarJsonPath, + getLaunchJsonPath, + getServeLogPath, + LAUNCH_JSON_SCHEMA, +} from './bar-paths'; +import type { LaunchJson } from './bar-paths'; +import { + defaultFindRunningServer as _defaultFindRunningServer, + resolveBarPort as _resolveBarPort, +} from './bar-server-probe'; +import type { DashboardInfo as _DashboardInfo } from './bar-server-probe'; + +// --------------------------------------------------------------------------- +// Re-exports — backward compat for tests that import from this module. +// resolveBarPort + defaultFindRunningServer are canonical in bar-server-probe.ts; +// we re-export them so existing imports from launch-subcommand continue to work. +// --------------------------------------------------------------------------- + +export { _defaultFindRunningServer as defaultFindRunningServer }; +export { _resolveBarPort as resolveBarPort }; // --------------------------------------------------------------------------- // Types @@ -27,10 +53,7 @@ export interface BarDiscoveryJson { authMode: 'loopback'; } -export interface DashboardInfo { - port: number; - baseUrl: string; -} +export type DashboardInfo = _DashboardInfo; export interface LaunchDeps { /** @@ -40,11 +63,24 @@ export interface LaunchDeps { */ findRunningServer: () => Promise; /** - * Ensure the CCS web-server / dashboard is running. - * Returns { port, baseUrl } of the running server. - * Throws if the server cannot be started (degraded path). + * Find a free port from the candidate list. + * Used to pre-select a port before spawning the detached server. */ - ensureDashboard: () => Promise; + getPort: (opts: { port: number[]; host: string }) => Promise; + /** + * Spawn the `ccs bar serve --port N` process detached and return immediately. + * The spawned process must be unref()ed so the launcher can exit. + */ + spawnDetachedServer: (port: number, logPath: string) => void; + /** + * Poll GET {baseUrl}/api/bar/summary until HTTP 200 or timeout. + * Returns the live baseUrl on success, throws on timeout. + */ + waitForServerLive: (baseUrl: string) => Promise; + /** + * Write launch.json so the Swift app can spawn the server independently. + */ + writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void; /** Open the installed .app bundle. Throws if the app is not found. */ openApp: (appPath: string) => Promise; /** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */ @@ -53,114 +89,68 @@ export interface LaunchDeps { appInstallPath: string; } -// --------------------------------------------------------------------------- -// Port discovery helpers (exported for unit tests) -// --------------------------------------------------------------------------- - -/** - * Read the port recorded in an existing bar.json. - * Returns null when the file is absent or malformed. - */ -export function resolveBarPort(ccsDir: string): number | null { - const barJsonPath = path.join(ccsDir, 'bar.json'); - try { - const raw = fs.readFileSync(barJsonPath, 'utf8'); - const parsed = JSON.parse(raw) as Partial; - return typeof parsed.port === 'number' ? parsed.port : null; - } catch { - return null; - } -} - // --------------------------------------------------------------------------- // Default production dependencies // --------------------------------------------------------------------------- -/** - * Probe candidate ports for a running CCS server. - * - * Both IPv4 (127.0.0.1) and IPv6 (::1) loopback addresses are probed for - * each port. This is necessary because `ccs config` starts the server with - * host 'localhost', which macOS resolves to ::1 — an IPv4-only probe would - * miss that server and start a redundant second instance. - * - * All probes are fired concurrently (cheap loopback requests) so worst-case - * latency is ~1.5 s (one timeout) rather than N × 1.5 s sequentially. - * Priority selection is still applied after all results are in: the bar.json - * port is preferred over the defaults (3000, 3001, 3002, 8000, 8080), and - * within a port 127.0.0.1 is preferred over [::1]. - */ -export async function defaultFindRunningServer(ccsDir: string): Promise { - const { request } = await import('undici'); +async function defaultGetPort(opts: { port: number[]; host: string }): Promise { + const getPort = (await import('get-port')).default; + return getPort(opts); +} - /** - * Probe a single URL. Resolves to { ok: true } on HTTP 200, { ok: false } - * on any other status or error (connection refused, timeout, etc.). - * Never rejects — all errors are absorbed so Promise.all never short-circuits. - */ - async function probe(url: string): Promise<{ ok: boolean }> { +/** + * Spawn `ccs bar serve --port N` detached so it outlives this CLI process. + * + * stdio is redirected to serve.log so server output is preserved for + * debugging without a terminal. unref() lets the launcher exit immediately. + */ +function defaultSpawnDetachedServer(port: number, logPath: string): void { + const { spawn } = require('child_process') as typeof import('child_process'); + + // Open (or create) the log file for appending. + const logFd = fs.openSync(logPath, 'a'); + const child = spawn(process.execPath, [process.argv[1], 'bar', 'serve', '--port', String(port)], { + detached: true, + stdio: ['ignore', logFd, logFd], + cwd: os.homedir(), + env: process.env, + }); + child.unref(); + // Close our copy of the fd — the child has its own reference. + fs.closeSync(logFd); +} + +/** + * Poll GET {baseUrl}/api/bar/summary every 250 ms until HTTP 200 or ~10 s. + * Resolves when the server is live. Rejects on timeout. + */ +async function defaultWaitForServerLive(baseUrl: string): Promise { + const { request } = await import('undici'); + const INTERVAL_MS = 250; + const TIMEOUT_MS = 10_000; + const deadline = Date.now() + TIMEOUT_MS; + + while (Date.now() < deadline) { try { - const { statusCode, body } = await request(url, { + const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, { method: 'GET', headersTimeout: 1500, bodyTimeout: 1500, }); - // Drain the body to avoid hanging sockets regardless of status. - // body.dump() is not available in Bun's undici shim; body.text() works - // cross-runtime and fully consumes the response stream. await body.text(); - return { ok: statusCode === 200 }; + if (statusCode === 200) return; } catch { - return { ok: false }; + /* not yet live — keep polling */ } + await new Promise((resolve) => setTimeout(resolve, INTERVAL_MS)); } - const barJsonPort = resolveBarPort(ccsDir); - const base = [3000, 3001, 3002, 8000, 8080]; - const candidates: number[] = - barJsonPort !== null ? [barJsonPort, ...base.filter((p) => p !== barJsonPort)] : base; - - // Build the ordered list of (port, host, baseUrl) tuples. Within each port, - // IPv4 comes before IPv6 to preserve the existing priority semantics. - const probeTargets = candidates.flatMap((port) => [ - { port, baseUrl: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}/api/bar/summary` }, - { port, baseUrl: `http://[::1]:${port}`, url: `http://[::1]:${port}/api/bar/summary` }, - ]); - - // Fire all probes concurrently. Each probe resolves (never rejects), so - // Promise.all collects all results without short-circuiting on failure. - const results = await Promise.all(probeTargets.map((t) => probe(t.url))); - - // Walk the results in priority order and return the first successful hit. - for (let i = 0; i < probeTargets.length; i++) { - if (results[i].ok) { - const { port, baseUrl } = probeTargets[i]; - return { port, baseUrl }; - } - } - return null; + throw new Error(`CCS Bar server did not become live at ${baseUrl} within ${TIMEOUT_MS / 1000}s`); } -async function defaultEnsureDashboard(): Promise { - // Reuse the same startup path as `ccs config`: - // find a free port then start the web-server via startServer(). - const getPort = (await import('get-port')).default; - const { startServer } = await import('../../web-server'); - - // Pass host: '127.0.0.1' so getPort probes the same address that startServer - // will bind. On macOS, a wildcard bind and a specific 127.0.0.1 bind are - // independent: getPort without a host can return 3000 "free" while a live - // CCS server already holds 127.0.0.1:3000, causing EADDRINUSE on startServer. - const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); - // Bind IPv4 loopback explicitly. Without a host, startServer defaults to - // 'localhost', which on macOS resolves to ::1 (IPv6) — but bar.json's baseUrl - // (and the Swift app) use 127.0.0.1, so the app could not reach its own server. - const { server } = await startServer({ port, host: '127.0.0.1' }); - - const addr = server.address(); - const resolvedPort = addr && typeof addr === 'object' ? addr.port : port; - const baseUrl = `http://127.0.0.1:${resolvedPort}`; - return { port: resolvedPort, baseUrl }; +function defaultWriteLaunchDescriptor(jsonPath: string, descriptor: LaunchJson): void { + fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); + fs.writeFileSync(jsonPath, JSON.stringify(descriptor, null, 2)); } async function defaultOpenApp(appPath: string): Promise { @@ -175,8 +165,6 @@ function defaultGetCcsDir(): string { } // Fix #5: use os.homedir() to match install-subcommand.ts and uninstall-subcommand.ts. -// process.env.HOME may be unset in restricted environments, and CCS_HOME is the CCS -// data directory (~/.ccs), not the user's home — neither is a safe fallback here. const DEFAULT_APP_INSTALL_PATH = path.join(os.homedir(), 'Applications', 'CCS Bar.app'); // --------------------------------------------------------------------------- @@ -187,69 +175,136 @@ export async function handleBarLaunch( _args: string[], deps: Partial = {} ): Promise { - const ensureDashboard = deps.ensureDashboard ?? defaultEnsureDashboard; - const openApp = deps.openApp ?? defaultOpenApp; const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); + const openApp = deps.openApp ?? defaultOpenApp; const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH; + const getPortFn = deps.getPort ?? defaultGetPort; + const spawnDetachedServer = deps.spawnDetachedServer ?? defaultSpawnDetachedServer; + const waitForServerLive = deps.waitForServerLive ?? defaultWaitForServerLive; + const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor; - // Wire findRunningServer after ccsDir is resolved so the default impl can - // read bar.json from the correct directory. - const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir)); + // Wire findRunningServer after ccsDir is resolved. + const findRunningServer = deps.findRunningServer ?? (() => _defaultFindRunningServer(ccsDir)); - // 1. Try to reuse an already-running CCS server; fall back to starting one. - // Probe errors are treated as "not found" — they never abort the launch flow. + const barJsonPath = getBarJsonPath(ccsDir); + const launchJsonPath = getLaunchJsonPath(ccsDir); + + // 1. Probe for an already-running server. let running: DashboardInfo | null = null; try { running = await findRunningServer(); } catch { - // Any probe error counts as null; ensureDashboard() is the fallback. + /* any probe error counts as null */ } - let dashboardInfo: DashboardInfo; - let reused = false; if (running !== null) { - dashboardInfo = running; - reused = true; - } else { + // Reuse the live server — write bar.json and open the app. + const barJson: BarDiscoveryJson = { + baseUrl: running.baseUrl, + port: running.port, + authMode: 'loopback', + }; try { - dashboardInfo = await ensureDashboard(); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2)); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error(`[X] Could not start CCS web-server: ${msg}`); - console.error('[i] Run `ccs config` to start the dashboard manually.'); + console.error(`[X] Failed to write bar.json: ${msg}`); return; } + console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`); + console.log(`[i] Discovery file written: ${barJsonPath}`); + await _openAppWithFallback(appInstallPath, openApp); + return; } - // 2. Write bar.json — this is the single source of discovery for the Swift app. + // 2. No live server — pick a port, write/refresh launch.json, spawn detached. + + // 2a. Pick a free port. + let port: number; + try { + port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not find a free port: ${msg}`); + return; + } + + // 2b. Write/refresh launch.json so the Swift app can self-start next time. + const launchDescriptor: LaunchJson = { + schema: LAUNCH_JSON_SCHEMA, + runtime: process.execPath, + args: [process.argv[1], 'bar', 'serve'], + home: os.homedir(), + ...(process.env.CCS_HOME ? { ccsHome: process.env.CCS_HOME } : {}), + }; + try { + writeLaunchDescriptor(launchJsonPath, launchDescriptor); + } catch (err) { + // Non-fatal — the Swift app falls back to resolving `ccs` via PATH. + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Could not write launch.json: ${msg}`); + } + + // 2c. Spawn the detached server. + const serveLogPath = getServeLogPath(ccsDir); + const baseUrl = `http://127.0.0.1:${port}`; + try { + fs.mkdirSync(getBarDir(ccsDir), { recursive: true }); + spawnDetachedServer(port, serveLogPath); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not start CCS web-server: ${msg}`); + console.error('[i] Run `ccs config` to start the dashboard manually.'); + return; + } + + console.log('[i] Starting CCS Bar server...'); + + // 2d. Poll until live or timeout. + try { + await waitForServerLive(baseUrl); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not connect to CCS web-server: ${msg}`); + console.error(`[i] Check logs at ${serveLogPath}`); + return; + } + + // 2e. Write bar.json. const barJson: BarDiscoveryJson = { - baseUrl: dashboardInfo.baseUrl, - port: dashboardInfo.port, + baseUrl, + port, authMode: 'loopback', }; - try { fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify(barJson, null, 2)); + fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2)); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[X] Failed to write bar.json: ${msg}`); return; } - if (reused) { - console.log(`[OK] Reusing running CCS web-server at ${dashboardInfo.baseUrl}`); - } else { - console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`); - } - console.log(`[i] Discovery file written: ${path.join(ccsDir, 'bar.json')}`); + console.log(`[OK] CCS web-server running at ${baseUrl}`); + console.log(`[i] Discovery file written: ${barJsonPath}`); - // 3. Open the app. + // 3. Open the app — then return (process exits; server continues detached). + await _openAppWithFallback(appInstallPath, openApp); +} + +// --------------------------------------------------------------------------- +// Internal helper — open app with graceful degraded path +// --------------------------------------------------------------------------- + +async function _openAppWithFallback( + appInstallPath: string, + openApp: (p: string) => Promise +): Promise { try { await openApp(appInstallPath); console.log('[OK] CCS Bar launched.'); } catch { - // Degraded path: app not installed or open failed. if (!fs.existsSync(appInstallPath)) { console.log('[!] CCS Bar app is not installed.'); console.log('[i] Run `ccs bar install` to install it.'); diff --git a/src/commands/bar/serve-subcommand.ts b/src/commands/bar/serve-subcommand.ts new file mode 100644 index 00000000..e0769baa --- /dev/null +++ b/src/commands/bar/serve-subcommand.ts @@ -0,0 +1,214 @@ +/** + * `ccs bar serve` — long-lived server host for CCS Bar. + * + * Reuse-or-start: probe candidate ports first. If a live server exists, + * write bar.json pointing at it and exit 0 (no double-start). Otherwise + * pick a free port, call startServer(), write bar.json + server.pid, + * then stay alive until SIGINT/SIGTERM. + * + * This is the process that `ccs bar launch` spawns detached. The Swift + * app also spawns it directly via launch.json. + * + * Accepts: --port N (honours a port the launcher pre-selected via getPort). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../config/config-loader-facade'; +import { getBarJsonPath, getServerPidPath } from './bar-paths'; +import { defaultFindRunningServer } from './bar-server-probe'; +import type { DashboardInfo } from './bar-server-probe'; +import type { BarDiscoveryJson } from './launch-subcommand'; + +// --------------------------------------------------------------------------- +// Types — injectable deps for testability +// --------------------------------------------------------------------------- + +export interface ServeDeps { + /** Probe candidate ports for a running CCS server. Never throws. */ + findRunningServer: () => Promise; + /** + * Start the CCS web-server on the given port. + * Returns { port, baseUrl } of the bound server. + * The returned server keeps the process event loop alive while listening. + */ + startServer: (opts: { port: number; host: string }) => Promise; + /** + * Find a free port from the candidate list. + * Returns one of the candidates or another free port. + */ + getPort: (opts: { port: number[]; host: string }) => Promise; + /** Returns ~/.ccs dir (respects CCS_HOME). */ + getCcsDir: () => string; + /** Write a file, creating parent dirs as needed. */ + writeFile: (filePath: string, content: string) => void; + /** Remove a file if it exists (for cleanup on exit). */ + removeFile: (filePath: string) => void; + /** Register process signal handlers. */ + onSignal: (signal: 'SIGINT' | 'SIGTERM', handler: () => void) => void; + /** Exit the process. */ + exit: (code: number) => never; +} + +// --------------------------------------------------------------------------- +// Default implementations +// --------------------------------------------------------------------------- + +async function defaultStartServer(opts: { port: number; host: string }): Promise { + const { startServer } = await import('../../web-server'); + const { server } = await startServer({ port: opts.port, host: opts.host }); + const addr = server.address(); + const resolvedPort = addr && typeof addr === 'object' ? addr.port : opts.port; + const baseUrl = `http://127.0.0.1:${resolvedPort}`; + return { port: resolvedPort, baseUrl }; +} + +async function defaultGetPort(opts: { port: number[]; host: string }): Promise { + const getPort = (await import('get-port')).default; + return getPort(opts); +} + +function defaultWriteFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function defaultRemoveFile(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + /* ignore — file may already be gone */ + } +} + +function defaultOnSignal(signal: 'SIGINT' | 'SIGTERM', handler: () => void): void { + process.on(signal, handler); +} + +function defaultExit(code: number): never { + process.exit(code); +} + +function defaultGetCcsDir(): string { + return getCcsDir(); +} + +// --------------------------------------------------------------------------- +// Argument parsing helpers +// --------------------------------------------------------------------------- + +function parsePortArg(args: string[]): number | null { + const idx = args.indexOf('--port'); + if (idx !== -1 && idx + 1 < args.length) { + const n = parseInt(args[idx + 1], 10); + return Number.isFinite(n) && n > 0 && n < 65536 ? n : null; + } + return null; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export async function handleBarServe(args: string[], deps: Partial = {}): Promise { + const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); + const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir)); + const startServerFn = deps.startServer ?? defaultStartServer; + const getPortFn = deps.getPort ?? defaultGetPort; + const writeFile = deps.writeFile ?? defaultWriteFile; + const removeFile = deps.removeFile ?? defaultRemoveFile; + const onSignal = deps.onSignal ?? defaultOnSignal; + const exit = deps.exit ?? defaultExit; + + const barJsonPath = getBarJsonPath(ccsDir); + const serverPidPath = getServerPidPath(ccsDir); + + // 1. Reuse-or-start: probe candidate ports first. + let running: DashboardInfo | null = null; + try { + running = await findRunningServer(); + } catch { + /* probe errors count as null */ + } + + if (running !== null) { + // A live server is already running — write bar.json and exit cleanly. + const barJson: BarDiscoveryJson = { + baseUrl: running.baseUrl, + port: running.port, + authMode: 'loopback', + }; + try { + writeFile(barJsonPath, JSON.stringify(barJson, null, 2)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Failed to write bar.json: ${msg}`); + exit(1); + } + console.log(`[OK] CCS server already running at ${running.baseUrl} — reusing.`); + exit(0); + } + + // 2. No live server found — start one. + // Honor --port N from the launcher (it pre-selected via getPort to avoid races). + const requestedPort = parsePortArg(args); + let port: number; + if (requestedPort !== null) { + port = requestedPort; + } else { + port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); + } + + // TypeScript cannot infer that exit(1) is `never` when it is injected as a dep, + // so we use a definite-assignment assertion on dashboardInfo. + + let dashboardInfo!: DashboardInfo; + try { + dashboardInfo = await startServerFn({ port, host: '127.0.0.1' }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Failed to start CCS server: ${msg}`); + exit(1); + } + + // 3. Write bar.json and server.pid. + const barJson: BarDiscoveryJson = { + baseUrl: dashboardInfo.baseUrl, + port: dashboardInfo.port, + authMode: 'loopback', + }; + try { + writeFile(barJsonPath, JSON.stringify(barJson, null, 2)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Failed to write bar.json: ${msg}`); + exit(1); + } + + try { + writeFile(serverPidPath, String(process.pid)); + } catch (err) { + // Non-fatal — stop/status will just degrade gracefully. + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Failed to write server.pid: ${msg}`); + } + + console.log(`[OK] CCS Bar server started at ${dashboardInfo.baseUrl}`); + console.log(`[i] PID ${process.pid} — stop with \`ccs bar stop\``); + + // 4. Clean shutdown on SIGINT / SIGTERM. + const shutdown = (): void => { + removeFile(serverPidPath); + // bar.json is intentionally left in place on clean shutdown so + // the Swift app self-heal poll can detect the server is gone via + // the liveness check, not a stale discovery file. + console.log('\n[OK] CCS Bar server stopped.'); + exit(0); + }; + + onSignal('SIGINT', shutdown); + onSignal('SIGTERM', shutdown); + + // 5. Stay alive — the startServer() HTTP server keeps the Node/Bun event loop + // alive while listening. Nothing else needed here. +} diff --git a/src/commands/bar/status-subcommand.ts b/src/commands/bar/status-subcommand.ts new file mode 100644 index 00000000..24e25602 --- /dev/null +++ b/src/commands/bar/status-subcommand.ts @@ -0,0 +1,142 @@ +/** + * `ccs bar status` — report whether the CCS Bar server is running. + * + * Checks server.pid for the PID, then verifies the process is alive and + * the server is reachable at GET /api/bar/summary. ASCII output only. + */ + +import * as fs from 'fs'; +import { getCcsDir } from '../../config/config-loader-facade'; +import { getBarJsonPath, getServerPidPath } from './bar-paths'; + +// --------------------------------------------------------------------------- +// Types — injectable deps +// --------------------------------------------------------------------------- + +export interface StatusDeps { + /** Returns ~/.ccs dir (respects CCS_HOME). */ + getCcsDir: () => string; + /** + * Read the server.pid file. Returns the raw string content, or null when + * absent or unreadable. + */ + readPidFile: (pidPath: string) => string | null; + /** + * Check whether a process is alive. + * Uses kill(pid, 0) semantics: no error = alive, ESRCH = gone. + * Returns true when alive, false otherwise. + */ + isProcessAlive: (pid: number) => boolean; + /** + * Probe whether the server is reachable at GET {baseUrl}/api/bar/summary. + * Returns true on HTTP 200, false otherwise. Never throws. + */ + probeServer: (baseUrl: string) => Promise; + /** + * Read bar.json and return the baseUrl field, or null when absent/malformed. + */ + readBarJsonBaseUrl: (barJsonPath: string) => string | null; +} + +// --------------------------------------------------------------------------- +// Default implementations +// --------------------------------------------------------------------------- + +function defaultGetCcsDir(): string { + return getCcsDir(); +} + +function defaultReadPidFile(pidPath: string): string | null { + try { + return fs.readFileSync(pidPath, 'utf8').trim(); + } catch { + return null; + } +} + +function defaultIsProcessAlive(pid: number): boolean { + try { + // kill(pid, 0) is a POSIX trick: sends no signal but checks if the + // process exists and is accessible. Throws ESRCH when gone. + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function defaultProbeServer(baseUrl: string): Promise { + try { + const { request } = await import('undici'); + const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, { + method: 'GET', + headersTimeout: 2000, + bodyTimeout: 2000, + }); + // Drain body to release the socket. + await body.text(); + return statusCode === 200; + } catch { + return false; + } +} + +function defaultReadBarJsonBaseUrl(barJsonPath: string): string | null { + try { + const raw = fs.readFileSync(barJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial<{ baseUrl: string }>; + return typeof parsed.baseUrl === 'string' ? parsed.baseUrl : null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export async function handleBarStatus( + _args: string[], + deps: Partial = {} +): Promise { + const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); + const readPidFile = deps.readPidFile ?? defaultReadPidFile; + const isProcessAlive = deps.isProcessAlive ?? defaultIsProcessAlive; + const probeServer = deps.probeServer ?? defaultProbeServer; + const readBarJsonBaseUrl = deps.readBarJsonBaseUrl ?? defaultReadBarJsonBaseUrl; + + const pidPath = getServerPidPath(ccsDir); + const barJsonPath = getBarJsonPath(ccsDir); + + // 1. Check PID file. + const pidRaw = readPidFile(pidPath); + if (pidRaw === null) { + console.log('[i] CCS Bar server: stopped (no server.pid)'); + return; + } + + const pid = parseInt(pidRaw, 10); + if (!Number.isFinite(pid) || pid <= 0) { + console.log(`[!] CCS Bar server: server.pid is invalid ("${pidRaw}")`); + return; + } + + // 2. Check process liveness. + const alive = isProcessAlive(pid); + if (!alive) { + console.log(`[!] CCS Bar server: PID ${pid} is no longer running (stale server.pid)`); + console.log('[i] Run `ccs bar stop` to clean up, then `ccs bar` to restart.'); + return; + } + + // 3. Probe HTTP reachability. + const baseUrl = readBarJsonBaseUrl(barJsonPath) ?? 'http://127.0.0.1:3000'; + const reachable = await probeServer(baseUrl); + + if (reachable) { + console.log(`[OK] CCS Bar server: running (PID ${pid}, ${baseUrl})`); + } else { + console.log(`[!] CCS Bar server: PID ${pid} alive but HTTP probe failed at ${baseUrl}`); + console.log('[i] The server may still be starting up. Try again in a moment.'); + } +} diff --git a/src/commands/bar/stop-subcommand.ts b/src/commands/bar/stop-subcommand.ts new file mode 100644 index 00000000..edca8033 --- /dev/null +++ b/src/commands/bar/stop-subcommand.ts @@ -0,0 +1,109 @@ +/** + * `ccs bar stop` — stop the detached CCS Bar server. + * + * Reads ~/.ccs/bar/server.pid, sends SIGTERM, then removes pid + bar.json. + * ASCII output only. Non-fatal if the server is already gone. + */ + +import * as fs from 'fs'; +import { getCcsDir } from '../../config/config-loader-facade'; +import { getBarJsonPath, getServerPidPath } from './bar-paths'; + +// --------------------------------------------------------------------------- +// Types — injectable deps +// --------------------------------------------------------------------------- + +export interface StopDeps { + /** Returns ~/.ccs dir (respects CCS_HOME). */ + getCcsDir: () => string; + /** + * Read the server.pid file. Returns the raw string content, or null when + * the file is absent or unreadable. + */ + readPidFile: (pidPath: string) => string | null; + /** + * Send SIGTERM to the given PID. + * Throws if the signal cannot be delivered (e.g. ESRCH — no such process). + */ + killProcess: (pid: number, signal: 'SIGTERM') => void; + /** Remove a file, ignoring errors if absent. */ + removeFile: (filePath: string) => void; +} + +// --------------------------------------------------------------------------- +// Default implementations +// --------------------------------------------------------------------------- + +function defaultGetCcsDir(): string { + return getCcsDir(); +} + +function defaultReadPidFile(pidPath: string): string | null { + try { + return fs.readFileSync(pidPath, 'utf8').trim(); + } catch { + return null; + } +} + +function defaultKillProcess(pid: number, signal: 'SIGTERM'): void { + process.kill(pid, signal); +} + +function defaultRemoveFile(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + /* ignore — file may already be gone */ + } +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export async function handleBarStop(_args: string[], deps: Partial = {}): Promise { + const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); + const readPidFile = deps.readPidFile ?? defaultReadPidFile; + const killProcess = deps.killProcess ?? defaultKillProcess; + const removeFile = deps.removeFile ?? defaultRemoveFile; + + const pidPath = getServerPidPath(ccsDir); + const barJsonPath = getBarJsonPath(ccsDir); + + // 1. Read the PID file. + const pidRaw = readPidFile(pidPath); + if (pidRaw === null) { + console.log('[i] CCS Bar server is not running (no server.pid found).'); + return; + } + + const pid = parseInt(pidRaw, 10); + if (!Number.isFinite(pid) || pid <= 0) { + console.error(`[X] server.pid contains an invalid PID: "${pidRaw}"`); + // Clean up the corrupted file so subsequent runs start fresh. + removeFile(pidPath); + return; + } + + // 2. Send SIGTERM. + try { + killProcess(pid, 'SIGTERM'); + console.log(`[OK] Sent SIGTERM to CCS Bar server (PID ${pid}).`); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ESRCH') { + // Process no longer exists — stale PID file, clean up silently. + console.log(`[i] Server PID ${pid} is no longer running. Cleaning up stale files.`); + } else { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Failed to stop server (PID ${pid}): ${msg}`); + // Still remove the pid file so the user is not blocked. + } + } + + // 3. Remove pid + bar.json regardless of kill result. + removeFile(pidPath); + removeFile(barJsonPath); + console.log('[i] Removed server.pid and bar.json.'); +} diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 9c1f7c8d..cf6b102c 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -261,26 +261,39 @@ describe('root-command-router registers bar', () => { // 3. bar.json contract written by launch // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Detached-model launch dep helpers (shared by tests in this describe block) +// --------------------------------------------------------------------------- + +/** + * Build a minimal set of detached-model deps that behave like a successful + * launch for most tests. Individual tests override specific deps as needed. + */ +function makeDetachedDeps(ccsDir: string, port = 4242) { + return { + findRunningServer: async () => null, + getPort: async () => port, + spawnDetachedServer: (_p: number, _log: string) => { /* noop */ }, + waitForServerLive: async (_url: string) => { /* live immediately */ }, + writeLaunchDescriptor: () => { /* noop */ }, + openApp: async (_appPath: string) => { /* noop */ }, + getCcsDir: () => ccsDir, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }; +} + describe('bar.json contract (launch subcommand)', () => { it('writes ~/.ccs/bar.json with correct shape when server starts', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - // Mock dependencies injected into handleBarLaunch - const mockEnsureDashboard = async () => ({ port: 4242, baseUrl: 'http://127.0.0.1:4242' }); - const mockOpenApp = async (_appPath: string) => { - calls.push(`open:${_appPath}`); - }; - const mockGetCcsDir = () => ccsDir; - const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: mockEnsureDashboard, - openApp: mockOpenApp, - getCcsDir: mockGetCcsDir, - appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + ...makeDetachedDeps(ccsDir, 4242), + openApp: async (_appPath: string) => { + calls.push(`open:${_appPath}`); + }, }); const barJsonPath = path.join(ccsDir, 'bar.json'); @@ -300,15 +313,7 @@ describe('bar.json contract (launch subcommand)', () => { const { handleBarLaunch } = await loadLaunchSubcommand(); - await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: async () => ({ port: 9000, baseUrl: 'http://127.0.0.1:9000' }), - openApp: async () => { - /* noop */ - }, - getCcsDir: () => ccsDir, - appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), - }); + await handleBarLaunch([], makeDetachedDeps(ccsDir, 9000)); const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { authMode: string; @@ -322,16 +327,14 @@ describe('bar.json contract (launch subcommand)', () => { const { handleBarLaunch } = await loadLaunchSubcommand(); - // App doesn't exist at appInstallPath + // App doesn't exist at appInstallPath — openApp throws so degraded path runs. const nonExistentApp = path.join(tempHome, 'Applications', 'CCS Bar.app'); await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), + ...makeDetachedDeps(ccsDir, 3000), openApp: async () => { throw new Error('App not found'); }, - getCcsDir: () => ccsDir, appInstallPath: nonExistentApp, }); @@ -347,13 +350,10 @@ describe('bar.json contract (launch subcommand)', () => { const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: async () => ({ port: 3001, baseUrl: 'http://127.0.0.1:3001' }), + ...makeDetachedDeps(ccsDir, 3001), openApp: async () => { throw new Error('open failed'); }, - getCcsDir: () => ccsDir, - appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); // bar.json should still be written despite open failure @@ -361,22 +361,17 @@ describe('bar.json contract (launch subcommand)', () => { expect(fs.existsSync(barJsonPath)).toBe(true); }); - it('prints degraded-path warning when server cannot start', async () => { + it('prints degraded-path warning when spawnDetachedServer throws', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: async () => { - throw new Error('port busy'); + ...makeDetachedDeps(ccsDir, 3000), + spawnDetachedServer: () => { + throw new Error('spawn failed'); }, - openApp: async () => { - /* noop */ - }, - getCcsDir: () => ccsDir, - appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); const allOutput = consoleOutput.join('\n'); @@ -1588,27 +1583,25 @@ describe('bar command dispatcher: --help anywhere in args (Fix 2)', () => { // --------------------------------------------------------------------------- describe('launch: findRunningServer reuse-first (GH-1500)', () => { - it('reuses a running server: ensureDashboard NOT called; bar.json has reused port/baseUrl', async () => { + it('reuses a running server: spawnDetachedServer NOT called; bar.json has reused port/baseUrl', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - let ensureCalled = false; + let spawnCalled = false; const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), - ensureDashboard: async () => { - ensureCalled = true; - return { port: 9999, baseUrl: 'http://127.0.0.1:9999' }; - }, - openApp: async () => { - /* noop */ - }, + getPort: async () => 9999, + spawnDetachedServer: () => { spawnCalled = true; }, + waitForServerLive: async () => { /* noop */ }, + writeLaunchDescriptor: () => { /* noop */ }, + openApp: async () => { /* noop */ }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); - expect(ensureCalled).toBe(false); + expect(spawnCalled).toBe(false); const barJson = JSON.parse( fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8') @@ -1624,48 +1617,44 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - let ensureCalled = false; + let spawnCalled = false; const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { findRunningServer: async () => null, - ensureDashboard: async () => { - ensureCalled = true; - return { port: 4242, baseUrl: 'http://127.0.0.1:4242' }; - }, - openApp: async () => { - /* noop */ - }, + getPort: async () => 4242, + spawnDetachedServer: () => { spawnCalled = true; }, + waitForServerLive: async () => { /* live */ }, + writeLaunchDescriptor: () => { /* noop */ }, + openApp: async () => { /* noop */ }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); - expect(ensureCalled).toBe(true); + expect(spawnCalled).toBe(true); }); - it('treats findRunningServer throw as null: ensureDashboard called; no crash', async () => { + it('treats findRunningServer throw as null: spawnDetachedServer called; no crash', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - let ensureCalled = false; + let spawnCalled = false; const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { findRunningServer: async () => { throw new Error('probe exploded'); }, - ensureDashboard: async () => { - ensureCalled = true; - return { port: 4242, baseUrl: 'http://127.0.0.1:4242' }; - }, - openApp: async () => { - /* noop */ - }, + getPort: async () => 4242, + spawnDetachedServer: () => { spawnCalled = true; }, + waitForServerLive: async () => { /* live */ }, + writeLaunchDescriptor: () => { /* noop */ }, + openApp: async () => { /* noop */ }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); - expect(ensureCalled).toBe(true); + expect(spawnCalled).toBe(true); // Should not have printed any bind error const allOutput = consoleOutput.join('\n'); expect(allOutput).not.toMatch(/Could not start/); @@ -1685,7 +1674,10 @@ describe('launch: bar.json contract (deterministic — GH-1500 null probe)', () await handleBarLaunch([], { findRunningServer: async () => null, - ensureDashboard: async () => ({ port: 4242, baseUrl: 'http://127.0.0.1:4242' }), + getPort: async () => 4242, + spawnDetachedServer: () => { /* noop */ }, + waitForServerLive: async () => { /* live */ }, + writeLaunchDescriptor: () => { /* noop */ }, openApp: async (_appPath: string) => { calls.push(`open:${_appPath}`); }, diff --git a/tests/unit/commands/bar-lifecycle-subcommands.test.ts b/tests/unit/commands/bar-lifecycle-subcommands.test.ts new file mode 100644 index 00000000..97abd5e1 --- /dev/null +++ b/tests/unit/commands/bar-lifecycle-subcommands.test.ts @@ -0,0 +1,850 @@ +/** + * Tests for the new CCS Bar lifecycle subcommands: + * serve-subcommand.ts — reuse-vs-start, pid write, signal cleanup + * stop-subcommand.ts — SIGTERM, pid + bar.json removal, stale-pid handling + * status-subcommand.ts — running/stopped/alive-but-unreachable states + * launch-subcommand.ts — detached-spawn path (injects spawnDetachedServer) + * install-subcommand.ts — writeLaunchDescriptor called after install + * + * All deps are injected — never touches real ~/.ccs, real ports, or real processes. + * Follows the DI + platform-fork patterns from bar-command.test.ts. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let consoleOutput: string[] = []; +let tempHome: string; +let originalCcsHome: string | undefined; +let originalConsoleLog: typeof console.log; +let originalConsoleError: typeof console.error; + +function captureConsole(): void { + originalConsoleLog = console.log; + originalConsoleError = console.error; + console.log = (...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }; +} + +function restoreConsole(): void { + console.log = originalConsoleLog; + console.error = originalConsoleError; +} + +function allOutput(): string { + return consoleOutput.join('\n'); +} + +let moduleSeq = 0; + +async function loadServeSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/serve-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarServe: (args: string[], deps?: Record) => Promise; + }; +} + +async function loadStopSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/stop-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarStop: (args: string[], deps?: Record) => Promise; + }; +} + +async function loadStatusSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/status-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarStatus: (args: string[], deps?: Record) => Promise; + }; +} + +async function loadLaunchSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarLaunch: (args: string[], deps?: Record) => Promise; + }; +} + +async function loadInstallSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/install-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarInstall: (args: string[], deps?: Record) => Promise; + }; +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + consoleOutput = []; + captureConsole(); + + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-lifecycle-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; +}); + +afterEach(() => { + restoreConsole(); + + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + try { + fs.rmSync(tempHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +// --------------------------------------------------------------------------- +// serve-subcommand: reuse-or-start +// --------------------------------------------------------------------------- + +describe('serve: reuse existing server', () => { + it('writes bar.json and exits 0 when a server is already live', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const exitCodes: number[] = []; + const writtenFiles: Record = {}; + + const { handleBarServe } = await loadServeSubcommand(); + + await handleBarServe([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), + startServer: async () => { + throw new Error('startServer should NOT be called when reusing'); + }, + getPort: async () => 3000, + writeFile: (filePath: string, content: string) => { + writtenFiles[filePath] = content; + }, + removeFile: () => { + /* noop */ + }, + onSignal: () => { + /* noop */ + }, + exit: (code: number) => { + exitCodes.push(code); + throw new Error(`__EXIT_${code}__`); + }, + }).catch((e: Error) => { + if (!e.message.startsWith('__EXIT_')) throw e; + }); + + expect(exitCodes).toContain(0); + // bar.json must reference the reused server + const barJsonPath = path.join(ccsDir, 'bar.json'); + expect(writtenFiles[barJsonPath]).toBeDefined(); + const barJson = JSON.parse(writtenFiles[barJsonPath]) as { port: number; baseUrl: string }; + expect(barJson.port).toBe(3000); + expect(barJson.baseUrl).toBe('http://127.0.0.1:3000'); + expect(allOutput()).toMatch(/reusing/i); + }); +}); + +describe('serve: start new server', () => { + it('calls startServer, writes bar.json + server.pid, registers signal handlers', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const writtenFiles: Record = {}; + const signals: string[] = []; + + const { handleBarServe } = await loadServeSubcommand(); + + // serve does NOT exit after starting — it stays alive. So we just await it + // and assert side-effects. In tests the event loop will drain because + // startServer returns immediately (no real HTTP server binding). + await handleBarServe(['--port', '4242'], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + startServer: async (opts: { port: number; host: string }) => ({ + port: opts.port, + baseUrl: `http://127.0.0.1:${opts.port}`, + }), + getPort: async () => 4242, + writeFile: (filePath: string, content: string) => { + writtenFiles[filePath] = content; + }, + removeFile: () => { + /* noop */ + }, + onSignal: (signal: string) => { + signals.push(signal); + }, + exit: (code: number) => { + throw new Error(`__EXIT_${code}__`); + }, + }); + + // bar.json must be written + const barJsonPath = path.join(ccsDir, 'bar.json'); + expect(writtenFiles[barJsonPath]).toBeDefined(); + const barJson = JSON.parse(writtenFiles[barJsonPath]) as { port: number; authMode: string }; + expect(barJson.port).toBe(4242); + expect(barJson.authMode).toBe('loopback'); + + // server.pid must be written + const pidPath = path.join(ccsDir, 'bar', 'server.pid'); + expect(writtenFiles[pidPath]).toBeDefined(); + expect(writtenFiles[pidPath]).toBe(String(process.pid)); + + // Both SIGINT and SIGTERM handlers registered + expect(signals).toContain('SIGINT'); + expect(signals).toContain('SIGTERM'); + + expect(allOutput()).toMatch(/\[OK\].*started/i); + }); + + it('honors --port N arg passed by the launcher', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const usedPorts: number[] = []; + const { handleBarServe } = await loadServeSubcommand(); + + await handleBarServe(['--port', '8080'], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + startServer: async (opts: { port: number; host: string }) => { + usedPorts.push(opts.port); + return { port: opts.port, baseUrl: `http://127.0.0.1:${opts.port}` }; + }, + getPort: async () => 3000, // should NOT be used when --port is given + writeFile: () => { + /* noop */ + }, + removeFile: () => { + /* noop */ + }, + onSignal: () => { + /* noop */ + }, + exit: (code: number) => { + throw new Error(`__EXIT_${code}__`); + }, + }); + + expect(usedPorts).toContain(8080); + expect(usedPorts).not.toContain(3000); + }); + + it('exits 1 when startServer throws', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const exitCodes: number[] = []; + const { handleBarServe } = await loadServeSubcommand(); + + await handleBarServe([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + startServer: async () => { + throw new Error('EADDRINUSE'); + }, + getPort: async () => 3000, + writeFile: () => { + /* noop */ + }, + removeFile: () => { + /* noop */ + }, + onSignal: () => { + /* noop */ + }, + exit: (code: number) => { + exitCodes.push(code); + throw new Error(`__EXIT_${code}__`); + }, + }).catch((e: Error) => { + if (!e.message.startsWith('__EXIT_')) throw e; + }); + + expect(exitCodes).toContain(1); + expect(allOutput()).toMatch(/\[X\]/); + }); +}); + +// --------------------------------------------------------------------------- +// stop-subcommand +// --------------------------------------------------------------------------- + +describe('stop: SIGTERM and cleanup', () => { + it('reads server.pid, sends SIGTERM, removes pid + bar.json', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const barDir = path.join(ccsDir, 'bar'); + fs.mkdirSync(barDir, { recursive: true }); + fs.writeFileSync(path.join(barDir, 'server.pid'), '12345'); + fs.writeFileSync(path.join(ccsDir, 'bar.json'), '{"baseUrl":"http://127.0.0.1:3000"}'); + + const killed: Array<{ pid: number; signal: string }> = []; + const removed: string[] = []; + + const { handleBarStop } = await loadStopSubcommand(); + + await handleBarStop([], { + getCcsDir: () => ccsDir, + readPidFile: (pidPath: string) => { + try { + return fs.readFileSync(pidPath, 'utf8').trim(); + } catch { + return null; + } + }, + killProcess: (pid: number, signal: string) => { + killed.push({ pid, signal }); + }, + removeFile: (filePath: string) => { + removed.push(filePath); + }, + }); + + expect(killed).toEqual([{ pid: 12345, signal: 'SIGTERM' }]); + // Both pid and bar.json must be removed + expect(removed.some((p) => p.includes('server.pid'))).toBe(true); + expect(removed.some((p) => p.includes('bar.json'))).toBe(true); + expect(allOutput()).toMatch(/\[OK\].*SIGTERM/i); + }); + + it('prints guidance and returns cleanly when no server.pid exists', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const { handleBarStop } = await loadStopSubcommand(); + + await expect( + handleBarStop([], { + getCcsDir: () => ccsDir, + readPidFile: () => null, + killProcess: () => { + throw new Error('should not be called'); + }, + removeFile: () => { + /* noop */ + }, + }) + ).resolves.toBeUndefined(); + + expect(allOutput()).toMatch(/not running|no server\.pid/i); + }); + + it('cleans up stale pid file when process does not exist (ESRCH)', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const removed: string[] = []; + const { handleBarStop } = await loadStopSubcommand(); + + await handleBarStop([], { + getCcsDir: () => ccsDir, + readPidFile: () => '99999', + killProcess: () => { + const err = new Error('no such process') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + }, + removeFile: (filePath: string) => { + removed.push(filePath); + }, + }); + + // pid file must still be cleaned up + expect(removed.some((p) => p.includes('server.pid'))).toBe(true); + expect(allOutput()).toMatch(/no longer running|stale/i); + }); + + it('handles invalid PID in server.pid gracefully', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const removed: string[] = []; + const { handleBarStop } = await loadStopSubcommand(); + + await handleBarStop([], { + getCcsDir: () => ccsDir, + readPidFile: () => 'not-a-number', + killProcess: () => { + throw new Error('should not be called'); + }, + removeFile: (filePath: string) => { + removed.push(filePath); + }, + }); + + expect(allOutput()).toMatch(/\[X\].*invalid/i); + // Corrupted pid file must be cleaned up + expect(removed.some((p) => p.includes('server.pid'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// status-subcommand +// --------------------------------------------------------------------------- + +describe('status: running state reporting', () => { + it('reports running + reachable when pid alive and HTTP probe succeeds', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => '12345', + isProcessAlive: () => true, + probeServer: async () => true, + readBarJsonBaseUrl: () => 'http://127.0.0.1:3000', + }); + + expect(allOutput()).toMatch(/\[OK\].*running/i); + expect(allOutput()).toMatch(/12345/); + expect(allOutput()).toMatch(/127\.0\.0\.1:3000/); + }); + + it('reports stopped when no server.pid exists', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => null, + isProcessAlive: () => false, + probeServer: async () => false, + readBarJsonBaseUrl: () => null, + }); + + expect(allOutput()).toMatch(/stopped|no server\.pid/i); + }); + + it('reports stale pid when process is no longer alive', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => '99999', + isProcessAlive: () => false, + probeServer: async () => false, + readBarJsonBaseUrl: () => null, + }); + + expect(allOutput()).toMatch(/no longer running|stale/i); + expect(allOutput()).toMatch(/99999/); + }); + + it('reports alive-but-unreachable when PID is alive but HTTP probe fails', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => '12345', + isProcessAlive: () => true, + probeServer: async () => false, + readBarJsonBaseUrl: () => 'http://127.0.0.1:3000', + }); + + expect(allOutput()).toMatch(/alive|running/i); + expect(allOutput()).toMatch(/probe failed|starting up|not reachable/i); + }); +}); + +// --------------------------------------------------------------------------- +// launch-subcommand: detached-spawn model +// --------------------------------------------------------------------------- + +describe('launch: detached-spawn model', () => { + it('does NOT call startServer in-process — uses spawnDetachedServer instead', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + let inProcessStartCalled = false; + const spawnCalls: Array<{ port: number; logPath: string }> = []; + + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 3001, + spawnDetachedServer: (port: number, logPath: string) => { + spawnCalls.push({ port, logPath }); + }, + waitForServerLive: async () => { + /* simulate server becoming live immediately */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop — app not installed in test env */ + throw new Error('app not found'); + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + // In-process startServer must never be called + expect(inProcessStartCalled).toBe(false); + // spawnDetachedServer must be called once + expect(spawnCalls.length).toBe(1); + expect(spawnCalls[0].port).toBe(3001); + // log path must be inside ccsDir/bar/ + expect(spawnCalls[0].logPath).toContain('serve.log'); + }); + + it('writes bar.json after server becomes live', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 4242, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + /* live immediately */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + const barJsonPath = path.join(ccsDir, 'bar.json'); + expect(fs.existsSync(barJsonPath)).toBe(true); + const barJson = JSON.parse(fs.readFileSync(barJsonPath, 'utf8')) as { + port: number; + baseUrl: string; + authMode: string; + }; + expect(barJson.port).toBe(4242); + expect(barJson.baseUrl).toBe('http://127.0.0.1:4242'); + expect(barJson.authMode).toBe('loopback'); + }); + + it('reuses live server without spawning — spawnDetachedServer NOT called', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + let spawnCalled = false; + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), + getPort: async () => 3001, + spawnDetachedServer: () => { + spawnCalled = true; + }, + waitForServerLive: async () => { + /* noop */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + expect(spawnCalled).toBe(false); + expect(allOutput()).toMatch(/reusing/i); + }); + + it('reports error and returns when waitForServerLive times out', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 3000, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + throw new Error('timeout after 10s'); + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + expect(allOutput()).toMatch(/\[X\]/); + expect(allOutput()).toMatch(/timeout|connect|server/i); + }); + + it('writes launch.json via writeLaunchDescriptor on the start path', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const descriptorCalls: Array<{ jsonPath: string; descriptor: unknown }> = []; + + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 3000, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + /* live */ + }, + writeLaunchDescriptor: (jsonPath: string, descriptor: unknown) => { + descriptorCalls.push({ jsonPath, descriptor }); + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + expect(descriptorCalls.length).toBe(1); + expect(descriptorCalls[0].jsonPath).toContain('launch.json'); + const desc = descriptorCalls[0].descriptor as { + schema: number; + runtime: string; + args: string[]; + home: string; + }; + expect(desc.schema).toBe(1); + expect(desc.runtime).toBe(process.execPath); + expect(desc.args).toContain('bar'); + expect(desc.args).toContain('serve'); + expect(desc.home).toBe(os.homedir()); + }); + + it('does NOT call writeLaunchDescriptor on the reuse path', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + let descriptorWritten = false; + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), + getPort: async () => 3001, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + /* noop */ + }, + writeLaunchDescriptor: () => { + descriptorWritten = true; + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + // On the reuse path there is no need to refresh launch.json + expect(descriptorWritten).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// install-subcommand: writeLaunchDescriptor after successful install +// --------------------------------------------------------------------------- + +describe('install: writeLaunchDescriptor called after successful install', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function fakeExtract(appsDir: string) { + return async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }; + } + + it('calls writeLaunchDescriptor with correct schema/runtime/args/home after install', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + const descriptorCalls: Array<{ jsonPath: string; descriptor: unknown }> = []; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.2.3', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + writeLaunchDescriptor: (jsonPath: string, descriptor: unknown) => { + descriptorCalls.push({ jsonPath, descriptor }); + }, + }); + + expect(descriptorCalls.length).toBe(1); + expect(descriptorCalls[0].jsonPath).toContain('launch.json'); + const desc = descriptorCalls[0].descriptor as { + schema: number; + runtime: string; + args: string[]; + home: string; + }; + expect(desc.schema).toBe(1); + expect(desc.runtime).toBe(process.execPath); + expect(desc.args).toContain('bar'); + expect(desc.args).toContain('serve'); + expect(desc.home).toBe(os.homedir()); + }); + + it('does NOT hard-fail install when writeLaunchDescriptor throws', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + + const { handleBarInstall } = await loadInstallSubcommand(); + + await expect( + handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.2.3', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + writeLaunchDescriptor: () => { + throw new Error('disk full'); + }, + }) + ).resolves.toBeUndefined(); + + // Install itself should still succeed + expect(allOutput()).toMatch(/\[OK\].*CCS Bar/); + // Warning about launch.json failure + expect(allOutput()).toMatch(/\[!\].*launch\.json/i); + }); + + it('does not call writeLaunchDescriptor when install fails at download step', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + let descriptorCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => { + throw new Error('network error'); + }, + downloadAndExtract: async () => { + /* noop */ + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.0.0', + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + writeLaunchDescriptor: () => { + descriptorCalled = true; + }, + }); + + expect(descriptorCalled).toBe(false); + expect(allOutput()).toMatch(/\[X\]/); + }); +}); + +// --------------------------------------------------------------------------- +// index.ts: dispatcher routes serve / stop / status +// --------------------------------------------------------------------------- + +describe('bar command dispatcher: serve / stop / status routing', () => { + it('routes `ccs bar serve` to serve subcommand', async () => { + const routed: string[] = []; + // We test routing by importing index and verifying serve-subcommand is called. + // Use dynamic import with cache-busting. + moduleSeq++; + const { mock } = await import('bun:test'); + + mock.module('../../../src/commands/bar/serve-subcommand', () => ({ + handleBarServe: async (subArgs: string[]) => { + routed.push(`serve:${subArgs.join(' ')}`); + }, + })); + mock.module('../../../src/commands/bar/stop-subcommand', () => ({ + handleBarStop: async (subArgs: string[]) => { + routed.push(`stop:${subArgs.join(' ')}`); + }, + })); + mock.module('../../../src/commands/bar/status-subcommand', () => ({ + handleBarStatus: async (subArgs: string[]) => { + routed.push(`status:${subArgs.join(' ')}`); + }, + })); + mock.module('../../../src/commands/bar/launch-subcommand', () => ({ + handleBarLaunch: async (subArgs: string[]) => { + routed.push(`launch:${subArgs.join(' ')}`); + }, + })); + + const { mock: _m, ...bunTest } = await import('bun:test'); + void _m; + void bunTest; + + moduleSeq++; + const indexMod = await import( + `../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}` + ); + const { handleBarCommand } = indexMod as { + handleBarCommand: (args: string[]) => Promise; + }; + + await handleBarCommand(['serve']); + await handleBarCommand(['stop']); + await handleBarCommand(['status']); + + expect(routed.some((r) => r.startsWith('serve:'))).toBe(true); + expect(routed.some((r) => r.startsWith('stop:'))).toBe(true); + expect(routed.some((r) => r.startsWith('status:'))).toBe(true); + + mock.restore(); + }); +});