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();
+ });
+});