mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
Merge pull request #1531 from kaitranntt/dev
feat(bar): CCS Bar seamless launch, app icon, and panel fixes
This commit is contained in:
+30
-5
@@ -52,19 +52,44 @@ xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app"
|
||||
|
||||
Or right-click the app and choose Open.
|
||||
|
||||
## Launch
|
||||
## Run
|
||||
|
||||
There are two ways to run CCS Bar, and both share one background server.
|
||||
|
||||
**Open the app.** Launch `CCS Bar` from Spotlight, Finder, or the Dock. The first time you open the menu, the app looks for a running CCS server and, if it does not find one, starts it in the background using the launch details recorded at install (`~/.ccs/bar/launch.json`). No terminal needed.
|
||||
|
||||
**Or use the CLI.**
|
||||
|
||||
```bash
|
||||
ccs bar # alias: ccs bar launch
|
||||
```
|
||||
|
||||
This checks whether a CCS web-server is already running (probing port 3000, 3001, 3002, 8000, and 8080, with the port from the previous `bar.json` checked first). If a live server is found it is reused; otherwise a new one is started. Either way the discovery file `~/.ccs/bar.json` is written and the app is opened. The discovery file looks like this:
|
||||
`ccs bar` probes for a running server (ports 3000, 3001, 3002, 8000, 8080, with the previous `bar.json` port first). If one is live it is reused; otherwise `ccs bar` starts the server as a detached background process and returns your prompt right away, then opens the app. The server keeps running after the command exits, so closing the terminal does not take the bar offline.
|
||||
|
||||
### Server lifecycle
|
||||
|
||||
```bash
|
||||
ccs bar serve # run the server in the current terminal (advanced; ccs bar runs this detached for you)
|
||||
ccs bar stop # stop the background server
|
||||
ccs bar status # report whether the server is running
|
||||
```
|
||||
|
||||
### Files under ~/.ccs
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.ccs/bar.json` | Discovery file the app reads to find the live server |
|
||||
| `~/.ccs/bar/launch.json` | How to start the server (recorded at install) so the app can start it without a shell PATH |
|
||||
| `~/.ccs/bar/server.pid` | PID of the background server, used by `ccs bar stop` and `ccs bar status` |
|
||||
| `~/.ccs/bar/serve.log` | stdout and stderr of the background server |
|
||||
|
||||
`~/.ccs/bar.json` looks like this:
|
||||
|
||||
```json
|
||||
{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" }
|
||||
```
|
||||
|
||||
The Swift app reads `~/.ccs/bar.json` to find the server.
|
||||
The Swift app reads `~/.ccs/bar.json` to find the server, and falls back to probing the candidate ports if it is missing or stale.
|
||||
|
||||
## Loopback / Localhost Requirement
|
||||
|
||||
@@ -83,9 +108,9 @@ This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a
|
||||
## Troubleshooting
|
||||
|
||||
- Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`.
|
||||
- Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`.
|
||||
- Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`. Check `~/.ccs/bar/serve.log` for the background server's output.
|
||||
- App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above.
|
||||
- Blank app: the web-server is not running. Re-run `ccs bar` and confirm `~/.ccs/bar.json` exists.
|
||||
- Menu shows "CCS is not running": open the menu again to let the app start the server, or run `ccs bar status` to check and `ccs bar` to start it.
|
||||
- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback.
|
||||
|
||||
## Development
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
@@ -20,6 +20,8 @@
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>CCS Bar</string>
|
||||
</dict>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,40 @@ 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 {
|
||||
let cap = screenCap
|
||||
// Before the first measurement, use a modest default so the popover is never
|
||||
// sized to the full screen (which macOS then centers). Once the content is
|
||||
// measured, use its height, capped.
|
||||
guard contentHeight > 0 else { return min(cap, 560) }
|
||||
return min(contentHeight, cap)
|
||||
}
|
||||
|
||||
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 +120,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, alignment: .top)
|
||||
.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 +265,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
/// Guard against overlapping start-and-connect sequences.
|
||||
private var connectTask: Task<Void, Never>?
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "8.3.0",
|
||||
"version": "8.3.0-dev.3",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('version-checker stale cache fallback', () => {
|
||||
getVersionCachePath('plus'),
|
||||
JSON.stringify({
|
||||
latestVersion: '6.9.23-0',
|
||||
repo: 'kaitranntt/CLIProxyAPIPlus',
|
||||
checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000,
|
||||
}),
|
||||
'utf8'
|
||||
@@ -56,6 +57,33 @@ describe('version-checker stale cache fallback', () => {
|
||||
expect(result.fromCache).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores legacy or cross-repo latest-version caches', async () => {
|
||||
const { getVersionCachePath, writeInstalledVersion } = await import('../version-cache');
|
||||
const { checkForUpdates } = await import('../version-checker');
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
writeInstalledVersion(plusBinDir, '6.6.80');
|
||||
for (const repo of [undefined, 'router-for-me/CLIProxyAPI']) {
|
||||
fs.writeFileSync(
|
||||
getVersionCachePath('plus'),
|
||||
JSON.stringify({
|
||||
latestVersion: '7.2.5',
|
||||
checkedAt: Date.now(),
|
||||
...(repo ? { repo } : {}),
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await checkForUpdates(plusBinDir, '6.6.80', false, 'plus', {
|
||||
fetchLatestVersionFn: async () => '7.1.68-2',
|
||||
});
|
||||
|
||||
expect(result.latestVersion).toBe('7.1.68-2');
|
||||
expect(result.fromCache).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a stale release-list cache when GitHub list lookup fails', async () => {
|
||||
const { getVersionListCachePath } = await import('../version-cache');
|
||||
const { VERSION_CACHE_DURATION_MS } = await import('../types');
|
||||
|
||||
@@ -83,11 +83,55 @@ const RELEASE_ARCH_MAP: Record<SupportedArch, SupportedArch> = {
|
||||
aarch64: 'aarch64',
|
||||
};
|
||||
|
||||
const PLUS_NO_PLUGIN_ASSET_MIN_VERSION = '7.1.68-0';
|
||||
const PLUS_AARCH64_ASSET_MIN_VERSION = '7.1.45-1';
|
||||
|
||||
export function mapNodeArchToReleaseArch(nodeArch: string): SupportedArch | undefined {
|
||||
const arch = ARCH_MAP[nodeArch];
|
||||
return arch ? RELEASE_ARCH_MAP[arch] : undefined;
|
||||
}
|
||||
|
||||
function parseVersionParts(version: string): [number, number, number, number] {
|
||||
const [coreVersion, forkRelease = '0'] = version.replace(/^v/, '').split('-', 2);
|
||||
const [major = 0, minor = 0, patch = 0] = coreVersion
|
||||
.split('.')
|
||||
.map((part) => parseInt(part, 10) || 0);
|
||||
return [major, minor, patch, parseInt(forkRelease, 10) || 0];
|
||||
}
|
||||
|
||||
function isAtLeastVersion(version: string, minimum: string): boolean {
|
||||
const left = parseVersionParts(version);
|
||||
const right = parseVersionParts(minimum);
|
||||
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] > right[index]) return true;
|
||||
if (left[index] < right[index]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function usesPlusNoPluginAsset(version: string, os: SupportedOS): boolean {
|
||||
return os !== 'windows' && isAtLeastVersion(version, PLUS_NO_PLUGIN_ASSET_MIN_VERSION);
|
||||
}
|
||||
|
||||
function getReleaseArchForBackend(
|
||||
backend: CLIProxyBackend,
|
||||
version: string,
|
||||
publicArch: SupportedArch,
|
||||
releaseArch: SupportedArch
|
||||
): SupportedArch {
|
||||
if (
|
||||
backend === 'plus' &&
|
||||
publicArch === 'arm64' &&
|
||||
!isAtLeastVersion(version, PLUS_AARCH64_ASSET_MIN_VERSION)
|
||||
) {
|
||||
return 'arm64';
|
||||
}
|
||||
|
||||
return releaseArch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect current platform and return binary info
|
||||
* @param version Optional version for binaryName (defaults to backend fallback)
|
||||
@@ -121,7 +165,9 @@ export function detectPlatform(
|
||||
const config = BACKEND_CONFIG[backend];
|
||||
const ver = version || config.fallbackVersion;
|
||||
const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz';
|
||||
const binaryName = `${config.binaryPrefix}_${ver}_${os}_${releaseArch}.${extension}`;
|
||||
const assetArch = getReleaseArchForBackend(backend, ver, arch, releaseArch);
|
||||
const assetVariant = backend === 'plus' && usesPlusNoPluginAsset(ver, os) ? '_no-plugin' : '';
|
||||
const binaryName = `${config.binaryPrefix}_${ver}_${os}_${assetArch}${assetVariant}.${extension}`;
|
||||
|
||||
return {
|
||||
os,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export interface VersionCache {
|
||||
latestVersion: string;
|
||||
checkedAt: number;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/** Update check result */
|
||||
@@ -39,9 +40,14 @@ export const GITHUB_REPOS = {
|
||||
plus: 'kaitranntt/CLIProxyAPIPlus',
|
||||
} as const;
|
||||
|
||||
/** Get GitHub repository for specific backend */
|
||||
export function getGitHubRepo(backend: 'original' | 'plus'): string {
|
||||
return GITHUB_REPOS[backend];
|
||||
}
|
||||
|
||||
/** Get GitHub API URLs for specific backend */
|
||||
export function getGitHubApiUrls(backend: 'original' | 'plus') {
|
||||
const repo = GITHUB_REPOS[backend];
|
||||
const repo = getGitHubRepo(backend);
|
||||
return {
|
||||
latestRelease: `https://api.github.com/repos/${repo}/releases/latest`,
|
||||
allReleases: `https://api.github.com/repos/${repo}/releases`,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
VERSION_CACHE_DURATION_MS,
|
||||
VERSION_PIN_FILE,
|
||||
VersionListCache,
|
||||
getGitHubRepo,
|
||||
} from './types';
|
||||
import { DEFAULT_BACKEND } from '../binary/platform-detector';
|
||||
import type { CLIProxyBackend } from '../types';
|
||||
@@ -59,6 +60,10 @@ function readVersionCacheInternal(
|
||||
const content = fs.readFileSync(cachePath, 'utf8');
|
||||
const cache: VersionCache = JSON.parse(content);
|
||||
|
||||
if (cache.repo !== getGitHubRepo(backend)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if cache is still valid, unless caller explicitly allows stale fallback.
|
||||
if (allowExpired || Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
|
||||
return cache;
|
||||
@@ -82,6 +87,7 @@ export function writeVersionCache(
|
||||
const cache: VersionCache = {
|
||||
latestVersion: version,
|
||||
checkedAt: Date.now(),
|
||||
repo: getGitHubRepo(backend),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -73,9 +73,50 @@ describe('Backend Selection', () => {
|
||||
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
|
||||
});
|
||||
|
||||
it('generates correct binary name for plus backend', () => {
|
||||
const info = platformDetector.detectPlatform('6.6.51-0', 'plus');
|
||||
assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_'));
|
||||
it('keeps old plus non-Windows archive names unsuffixed', () => {
|
||||
withMockedProcessPlatform('darwin', 'arm64', () => {
|
||||
assert.strictEqual(
|
||||
platformDetector.detectPlatform('6.9.45-0', 'plus').binaryName,
|
||||
'CLIProxyAPIPlus_6.9.45-0_darwin_arm64.tar.gz'
|
||||
);
|
||||
assert.strictEqual(
|
||||
platformDetector.detectPlatform('7.1.45-1', 'plus').binaryName,
|
||||
'CLIProxyAPIPlus_7.1.45-1_darwin_aarch64.tar.gz'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('generates no-plugin archive names for current plus backend on macOS and Linux', () => {
|
||||
withMockedProcessPlatform('darwin', 'arm64', () => {
|
||||
assert.strictEqual(
|
||||
platformDetector.detectPlatform('7.1.68-0', 'plus').binaryName,
|
||||
'CLIProxyAPIPlus_7.1.68-0_darwin_aarch64_no-plugin.tar.gz'
|
||||
);
|
||||
const info = platformDetector.detectPlatform('7.1.68-2', 'plus');
|
||||
assert.strictEqual(
|
||||
info.binaryName,
|
||||
'CLIProxyAPIPlus_7.1.68-2_darwin_aarch64_no-plugin.tar.gz'
|
||||
);
|
||||
assert.strictEqual(
|
||||
platformDetector.getDownloadUrl('7.1.68-2', 'plus'),
|
||||
'https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v7.1.68-2/CLIProxyAPIPlus_7.1.68-2_darwin_aarch64_no-plugin.tar.gz'
|
||||
);
|
||||
});
|
||||
|
||||
withMockedProcessPlatform('linux', 'x64', () => {
|
||||
const info = platformDetector.detectPlatform('7.1.68-2', 'plus');
|
||||
assert.strictEqual(
|
||||
info.binaryName,
|
||||
'CLIProxyAPIPlus_7.1.68-2_linux_amd64_no-plugin.tar.gz'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add no-plugin suffix to plus Windows archives', () => {
|
||||
withMockedProcessPlatform('win32', 'x64', () => {
|
||||
const info = platformDetector.detectPlatform('7.1.68-2', 'plus');
|
||||
assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_7.1.68-2_windows_amd64.zip');
|
||||
});
|
||||
});
|
||||
|
||||
it('uses original backend by default', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DashboardInfo | null> {
|
||||
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;
|
||||
}
|
||||
@@ -13,7 +13,9 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'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<void> {
|
||||
[
|
||||
'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<void> {
|
||||
}
|
||||
|
||||
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,'
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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<DashboardInfo | null>;
|
||||
/**
|
||||
* 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<DashboardInfo>;
|
||||
getPort: (opts: { port: number[]; host: string }) => Promise<number>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/** 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<BarDiscoveryJson>;
|
||||
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<DashboardInfo | null> {
|
||||
const { request } = await import('undici');
|
||||
async function defaultGetPort(opts: { port: number[]; host: string }): Promise<number> {
|
||||
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<void> {
|
||||
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<void>((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<DashboardInfo> {
|
||||
// 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<void> {
|
||||
@@ -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<LaunchDeps> = {}
|
||||
): Promise<void> {
|
||||
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<void>
|
||||
): Promise<void> {
|
||||
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.');
|
||||
|
||||
@@ -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<DashboardInfo | null>;
|
||||
/**
|
||||
* 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<DashboardInfo>;
|
||||
/**
|
||||
* Find a free port from the candidate list.
|
||||
* Returns one of the candidates or another free port.
|
||||
*/
|
||||
getPort: (opts: { port: number[]; host: string }) => Promise<number>;
|
||||
/** 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<DashboardInfo> {
|
||||
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<number> {
|
||||
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<ServeDeps> = {}): Promise<void> {
|
||||
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.
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<StatusDeps> = {}
|
||||
): Promise<void> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -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<StopDeps> = {}): Promise<void> {
|
||||
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.');
|
||||
}
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, string> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
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<void>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user