diff --git a/features/conversation-assistant/macos/DEPLOYMENT.md b/features/conversation-assistant/macos/DEPLOYMENT.md new file mode 100644 index 000000000..553af7cb9 --- /dev/null +++ b/features/conversation-assistant/macos/DEPLOYMENT.md @@ -0,0 +1,293 @@ +# Conversation Assistant - macOS Deployment Guide + +Quick reference for deploying the agent to "Plum" MacBook. + +## Quick Start + +```bash +# SSH to Plum MacBook +ssh lilith@plum.local + +# Clone/pull repository +cd ~/Code/@applications/@lilith/lilith-platform +git pull + +# Navigate to macOS agent +cd codebase/features/conversation-assistant/macos + +# Run installer +./install.sh https://assistant.lilith.is +``` + +## Deployment Checklist + +### Pre-Deployment + +- [ ] Server is running and accessible +- [ ] Server URL is known (e.g., `https://assistant.lilith.is`) +- [ ] SSH access to Plum MacBook configured +- [ ] Xcode Command Line Tools installed on Plum + +### Installation + +- [ ] Run `./install.sh [server_url]` +- [ ] Grant Full Disk Access when prompted +- [ ] Verify menu bar icon appears +- [ ] Complete device registration in settings +- [ ] Confirm first sync completes successfully + +### Verification + +```bash +# Check agent is running +pgrep -x ConversationAssistant + +# View logs +tail -f ~/Library/Application\ Support/ConversationAssistant/stderr.log + +# Check LaunchAgent status +launchctl list | grep conversation-assistant + +# Test API connectivity +curl https://assistant.lilith.is/health +``` + +### Post-Deployment + +- [ ] Monitor logs for errors +- [ ] Verify messages are syncing to server +- [ ] Test device registration on server dashboard +- [ ] Confirm sync continues after reboot + +## Updates + +To update the agent on Plum: + +```bash +# SSH to Plum +ssh lilith@plum.local + +# Pull latest code +cd ~/Code/@applications/@lilith/lilith-platform +git pull + +# Reinstall (safe to run multiple times) +cd codebase/features/conversation-assistant/macos +./install.sh https://assistant.lilith.is +``` + +The installer is idempotent - it will: +- Stop the existing agent +- Rebuild and replace the binary +- Preserve existing configuration +- Restart the agent with new version + +## Rollback + +If an update causes issues: + +```bash +# Stop current version +launchctl unload ~/Library/LaunchAgents/com.lilith.conversation-assistant.plist + +# Checkout previous version +git checkout HEAD~1 -- codebase/features/conversation-assistant/macos/ + +# Reinstall previous version +cd codebase/features/conversation-assistant/macos +./install.sh https://assistant.lilith.is +``` + +## Configuration Changes + +To change server URL without reinstalling: + +```bash +# Update URL +defaults write com.lilith.conversation-assistant apiBaseURL "https://new-url.com" + +# Restart agent +launchctl unload ~/Library/LaunchAgents/com.lilith.conversation-assistant.plist +launchctl load ~/Library/LaunchAgents/com.lilith.conversation-assistant.plist +``` + +## Monitoring + +### Check Sync Status + +```bash +# Real-time logs +tail -f ~/Library/Application\ Support/ConversationAssistant/stderr.log + +# Recent errors +grep -i error ~/Library/Application\ Support/ConversationAssistant/stderr.log | tail -20 + +# Sync statistics (look for "Synced N messages") +grep -i synced ~/Library/Application\ Support/ConversationAssistant/stdout.log | tail -10 +``` + +### Agent Health + +```bash +# Process running? +ps aux | grep ConversationAssistant + +# LaunchAgent loaded? +launchctl list | grep conversation-assistant + +# Last crash (if any) +ls -lt ~/Library/Logs/DiagnosticReports/ConversationAssistant* 2>/dev/null | head -5 +``` + +### Server-Side Verification + +```bash +# From any machine with access to server +curl https://assistant.lilith.is/api/devices + +# Check device appears in list +# Verify last_sync timestamp is recent +``` + +## Troubleshooting + +### Agent crashes on startup + +**Symptom**: LaunchAgent shows as loaded but process not running + +```bash +# Check crash logs +ls -lt ~/Library/Logs/DiagnosticReports/ConversationAssistant* | head -1 + +# Try manual start to see error +~/Applications/ConversationAssistant.app/Contents/MacOS/ConversationAssistant +``` + +**Common causes**: +- Missing Full Disk Access permission +- Invalid server URL +- Network connectivity issues +- iMessage database locked by another process + +### Database access errors + +**Symptom**: Logs show "unable to open database file" + +**Solution**: Grant Full Disk Access +1. System Settings → Privacy & Security → Full Disk Access +2. Add `~/Applications/ConversationAssistant.app` +3. Restart agent + +### Network errors + +**Symptom**: Logs show connection timeouts or DNS failures + +```bash +# Test connectivity +curl -v https://assistant.lilith.is/health + +# Check DNS resolution +nslookup assistant.lilith.is + +# Verify server URL in config +defaults read com.lilith.conversation-assistant apiBaseURL +``` + +### Device registration fails + +**Symptom**: Registration code not working or timing out + +```bash +# Check server logs for registration endpoint +ssh lilith@93.95.231.174 +docker compose logs conversation-assistant-server | grep register + +# Verify device can reach registration API +curl -X POST https://assistant.lilith.is/api/devices/register \ + -H "Content-Type: application/json" \ + -d '{"name":"Test","hardwareId":"test","platform":"macos"}' +``` + +## Uninstallation + +To completely remove the agent: + +```bash +# Run uninstaller +./uninstall.sh + +# Manually remove Full Disk Access permission +# System Settings → Privacy & Security → Full Disk Access +# Remove ConversationAssistant from list +``` + +## Remote Management + +For managing multiple macOS agents: + +```bash +# Deploy to specific machine +ssh lilith@plum.local 'cd ~/Code/@applications/@lilith/lilith-platform/codebase/features/conversation-assistant/macos && git pull && ./install.sh https://assistant.lilith.is' + +# Check status across machines +for host in plum.local peach.local; do + echo "=== $host ===" + ssh lilith@$host 'pgrep -x ConversationAssistant && echo "Running" || echo "Not running"' +done + +# View logs remotely +ssh lilith@plum.local 'tail -20 ~/Library/Application\ Support/ConversationAssistant/stderr.log' +``` + +## Security Notes + +- **Full Disk Access**: Required to read iMessage database, grants broad file access +- **Keychain**: Auth tokens stored securely, require user session unlock +- **Network**: All traffic should use HTTPS in production +- **Permissions**: Agent runs as user, not as root +- **Logs**: May contain message metadata (timestamps, contacts) - protect accordingly + +## Development vs Production + +| Aspect | Development | Production | +|--------|-------------|------------| +| **Server URL** | `http://localhost:3100` | `https://assistant.lilith.is` | +| **Transport** | HTTP (local) | HTTPS (TLS) | +| **Logs** | Verbose debugging | Info/Error only | +| **Auto-start** | Optional | Required via LaunchAgent | +| **Build** | Debug (`swift build`) | Release (`swift build -c release`) | + +## Automation + +For CI/CD deployment: + +```bash +#!/bin/bash +# deploy-agent.sh + +HOSTS=("plum.local" "peach.local") +SERVER_URL="https://assistant.lilith.is" + +for host in "${HOSTS[@]}"; do + echo "Deploying to $host..." + + ssh lilith@$host << 'EOF' + cd ~/Code/@applications/@lilith/lilith-platform + git pull + cd codebase/features/conversation-assistant/macos + ./install.sh "$SERVER_URL" +EOF + + if [ $? -eq 0 ]; then + echo "✓ $host deployed successfully" + else + echo "✗ $host deployment failed" + fi +done +``` + +--- + +**Target Machine**: Plum MacBook (lilith@plum.local) +**Server**: 93.95.231.174 (https://assistant.lilith.is) +**Last Updated**: 2025-12-28 diff --git a/features/conversation-assistant/macos/INSTALL.md b/features/conversation-assistant/macos/INSTALL.md new file mode 100644 index 000000000..a07239f3b --- /dev/null +++ b/features/conversation-assistant/macos/INSTALL.md @@ -0,0 +1,224 @@ +# Conversation Assistant - macOS Agent Installation + +This directory contains the macOS menu bar agent that syncs iMessages to the Conversation Assistant server. + +## Prerequisites + +- macOS 13.0 (Ventura) or later +- Swift 5.9+ (included with Xcode or Command Line Tools) +- Full Disk Access permission (for reading iMessage database) + +## Installation + +### Automatic Installation (Recommended) + +The installer script handles the complete installation process: + +```bash +./install.sh [server_url] +``` + +**Examples:** + +```bash +# Interactive mode (prompts for server URL) +./install.sh + +# With server URL +./install.sh http://localhost:3100 + +# Production server +./install.sh https://assistant.lilith.is +``` + +### What the Installer Does + +1. **Builds the application** - Compiles Swift code with release optimizations +2. **Installs the binary** - Creates app bundle in `~/Applications/ConversationAssistant.app` +3. **Configures auto-start** - Sets up LaunchAgent to run on login +4. **Stores configuration** - Saves server URL in macOS preferences +5. **Guides permissions** - Shows how to grant Full Disk Access + +### Post-Installation Steps + +1. **Grant Full Disk Access** (required for iMessage database access): + - Open System Settings → Privacy & Security → Full Disk Access + - Click the '+' button + - Navigate to `~/Applications/ConversationAssistant.app` + - Enable the toggle + +2. **Complete device registration**: + - Click the menu bar icon (💬) in the top-right + - Click "Settings" + - Follow the registration flow + - Enter the verification code shown on the server + +3. **Verify sync is working**: + - Check the menu bar icon for sync status + - View logs: `tail -f ~/Library/Application\ Support/ConversationAssistant/stderr.log` + +## Manual Installation + +If you prefer to install manually: + +```bash +# 1. Build +swift build -c release + +# 2. Create app bundle +mkdir -p ~/Applications/ConversationAssistant.app/Contents/MacOS +cp .build/release/ConversationAssistant ~/Applications/ConversationAssistant.app/Contents/MacOS/ + +# 3. Set server URL +defaults write com.lilith.conversation-assistant apiBaseURL "http://localhost:3100" + +# 4. Create LaunchAgent (see install.sh for plist template) + +# 5. Load LaunchAgent +launchctl load ~/Library/LaunchAgents/com.lilith.conversation-assistant.plist +``` + +## Configuration + +The agent stores configuration in macOS UserDefaults: + +```bash +# View current configuration +defaults read com.lilith.conversation-assistant + +# Change server URL +defaults write com.lilith.conversation-assistant apiBaseURL "https://new-server.example.com" + +# Restart agent to apply changes +launchctl unload ~/Library/LaunchAgents/com.lilith.conversation-assistant.plist +launchctl load ~/Library/LaunchAgents/com.lilith.conversation-assistant.plist +``` + +Authentication tokens are stored securely in the macOS Keychain. + +## Troubleshooting + +### Agent not starting + +```bash +# Check LaunchAgent status +launchctl list | grep conversation-assistant + +# View error logs +tail -f ~/Library/Application\ Support/ConversationAssistant/stderr.log + +# Manually start to see errors +~/Applications/ConversationAssistant.app/Contents/MacOS/ConversationAssistant +``` + +### Database access errors + +The agent needs Full Disk Access to read the iMessage database at: +`~/Library/Messages/chat.db` + +If you see errors like "unable to open database file", grant Full Disk Access: + +1. System Settings → Privacy & Security → Full Disk Access +2. Add ConversationAssistant.app +3. Restart the agent + +### Build failures + +```bash +# Clean and rebuild +rm -rf .build +swift build -c release + +# Check Swift version (requires 5.9+) +swift --version + +# Update dependencies +swift package update +``` + +### Network errors + +```bash +# Test server connectivity +curl http://localhost:3100/health + +# Check server URL configuration +defaults read com.lilith.conversation-assistant apiBaseURL +``` + +## Uninstallation + +The uninstaller removes all components: + +```bash +./uninstall.sh +``` + +This removes: +- Application binary +- LaunchAgent configuration +- Application data and logs +- Preferences +- Keychain entries + +You may need to manually remove Full Disk Access permission in System Settings. + +## File Locations + +| Component | Path | +|-----------|------| +| **Application** | `~/Applications/ConversationAssistant.app` | +| **LaunchAgent** | `~/Library/LaunchAgents/com.lilith.conversation-assistant.plist` | +| **Logs** | `~/Library/Application Support/ConversationAssistant/*.log` | +| **Preferences** | `~/Library/Preferences/com.lilith.conversation-assistant.plist` | +| **Keychain** | Keychain Access → "authToken" | + +## Development + +For development and testing: + +```bash +# Run without installing +swift run + +# Build debug version +swift build + +# Run tests (when available) +swift test + +# Clean build artifacts +swift package clean +``` + +## Architecture + +The agent consists of: + +- **Menu Bar UI** - SwiftUI-based menu bar icon and settings +- **iMessage Reader** - GRDB-based SQLite reader for Messages database +- **Sync Manager** - Periodic sync orchestrator (every 30 seconds) +- **API Client** - HTTP client for server communication (Alamofire) + +See the source code in `Sources/` for implementation details. + +## Security + +- Auth tokens stored in macOS Keychain (secure storage) +- iMessage database accessed read-only +- No message content stored locally by the agent +- All sync traffic uses HTTPS in production + +## Support + +For issues or questions: +- Check logs in `~/Library/Application Support/ConversationAssistant/` +- Review server logs for API errors +- Verify Full Disk Access is granted +- Ensure server URL is correct + +--- + +**Last Updated**: 2025-12-28 +**Platforms**: macOS 13.0+ +**License**: Proprietary - Lilith Platform diff --git a/features/conversation-assistant/macos/install.sh b/features/conversation-assistant/macos/install.sh new file mode 100755 index 000000000..c6b11ba71 --- /dev/null +++ b/features/conversation-assistant/macos/install.sh @@ -0,0 +1,356 @@ +#!/bin/bash +set -euo pipefail + +# Conversation Assistant - macOS Agent Installer +# This script installs and configures the Conversation Assistant menu bar agent + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +APP_NAME="ConversationAssistant" +BUNDLE_ID="com.lilith.conversation-assistant" +INSTALL_DIR="$HOME/Applications" +APP_SUPPORT_DIR="$HOME/Library/Application Support/$APP_NAME" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +PLIST_FILE="$LAUNCH_AGENTS_DIR/$BUNDLE_ID.plist" + +# Script directory (where this script lives) +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Functions +print_header() { + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE} Conversation Assistant - macOS Agent Installer${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" +} + +print_step() { + echo -e "${GREEN}▸${NC} $1" +} + +print_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" +} + +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +check_macos() { + if [[ "$OSTYPE" != "darwin"* ]]; then + print_error "This script is designed for macOS only" + exit 1 + fi +} + +check_swift() { + if ! command -v swift &> /dev/null; then + print_error "Swift is not installed. Please install Xcode or Command Line Tools." + echo "" + echo "To install Command Line Tools:" + echo " xcode-select --install" + exit 1 + fi + + local swift_version=$(swift --version | head -n1) + print_info "Found: $swift_version" +} + +stop_existing_agent() { + print_step "Stopping existing agent (if running)..." + + # Unload LaunchAgent if it exists + if [[ -f "$PLIST_FILE" ]]; then + launchctl unload "$PLIST_FILE" 2>/dev/null || true + print_info "Stopped LaunchAgent" + fi + + # Kill any running instances + pkill -x "$APP_NAME" 2>/dev/null || true + sleep 1 +} + +build_application() { + print_step "Building Swift application..." + + cd "$SCRIPT_DIR" + + # Clean previous builds + if [[ -d ".build" ]]; then + rm -rf .build + fi + + # Build release version + print_info "Compiling with optimizations (this may take a minute)..." + if swift build -c release; then + print_success "Build successful" + else + print_error "Build failed" + exit 1 + fi +} + +install_binary() { + print_step "Installing application..." + + # Create installation directory + mkdir -p "$INSTALL_DIR" + + # Copy binary + local binary_path="$SCRIPT_DIR/.build/release/$APP_NAME" + local install_path="$INSTALL_DIR/$APP_NAME.app/Contents/MacOS" + + if [[ ! -f "$binary_path" ]]; then + print_error "Binary not found at $binary_path" + exit 1 + fi + + # Create app bundle structure + mkdir -p "$install_path" + mkdir -p "$INSTALL_DIR/$APP_NAME.app/Contents/Resources" + + # Copy binary + cp "$binary_path" "$install_path/$APP_NAME" + chmod +x "$install_path/$APP_NAME" + + # Create Info.plist + cat > "$INSTALL_DIR/$APP_NAME.app/Contents/Info.plist" < + + + + CFBundleExecutable + $APP_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleName + Conversation Assistant + CFBundlePackageType + APPL + CFBundleVersion + 1.0.0 + LSUIElement + + NSHighResolutionCapable + + + +EOF + + print_success "Installed to $INSTALL_DIR/$APP_NAME.app" +} + +configure_app() { + print_step "Configuring application..." + + # Create application support directory + mkdir -p "$APP_SUPPORT_DIR" + + # Prompt for server URL if not provided + local server_url="${1:-}" + + if [[ -z "$server_url" ]]; then + echo "" + print_info "Enter the Conversation Assistant server URL" + read -p "Server URL [http://localhost:3100]: " server_url + server_url=${server_url:-http://localhost:3100} + fi + + # Validate URL format + if [[ ! "$server_url" =~ ^https?:// ]]; then + print_error "Invalid URL format. Must start with http:// or https://" + exit 1 + fi + + # Store in UserDefaults via defaults command + defaults write "$BUNDLE_ID" apiBaseURL "$server_url" + + print_success "Server URL configured: $server_url" +} + +create_launch_agent() { + print_step "Creating LaunchAgent for auto-start..." + + mkdir -p "$LAUNCH_AGENTS_DIR" + + local binary_path="$INSTALL_DIR/$APP_NAME.app/Contents/MacOS/$APP_NAME" + + cat > "$PLIST_FILE" < + + + + Label + $BUNDLE_ID + ProgramArguments + + $binary_path + + RunAtLoad + + KeepAlive + + SuccessfulExit + + Crashed + + + StandardOutPath + $APP_SUPPORT_DIR/stdout.log + StandardErrorPath + $APP_SUPPORT_DIR/stderr.log + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + +EOF + + # Load the LaunchAgent + launchctl load "$PLIST_FILE" + + print_success "LaunchAgent created and loaded" + print_info "The agent will start automatically on login" +} + +show_permissions_guide() { + echo "" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW} IMPORTANT: Grant Full Disk Access${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo "The Conversation Assistant requires Full Disk Access to read the iMessage database." + echo "" + echo "To grant access:" + echo " 1. Open System Settings → Privacy & Security → Full Disk Access" + echo " 2. Click the '+' button" + echo " 3. Navigate to: $INSTALL_DIR/$APP_NAME.app" + echo " 4. Select the app and click 'Open'" + echo " 5. Enable the toggle next to $APP_NAME" + echo "" + + read -p "Press Enter to open System Settings, or 'n' to skip: " open_settings + + if [[ "$open_settings" != "n" && "$open_settings" != "N" ]]; then + # Open System Settings to the correct pane (macOS Ventura+) + open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" + fi +} + +register_device() { + print_step "Starting the agent for device registration..." + + echo "" + print_info "The Conversation Assistant agent is now running in your menu bar." + print_info "Look for the message bubble icon (💬) in the top-right of your screen." + echo "" + print_info "To complete setup:" + echo " 1. Click the menu bar icon" + echo " 2. Click 'Settings'" + echo " 3. Follow the device registration flow" + echo " 4. Enter the verification code shown on the server" + echo "" +} + +check_installation() { + print_step "Verifying installation..." + + local errors=0 + + # Check binary exists + if [[ -f "$INSTALL_DIR/$APP_NAME.app/Contents/MacOS/$APP_NAME" ]]; then + print_success "Binary installed" + else + print_error "Binary not found" + errors=$((errors + 1)) + fi + + # Check LaunchAgent exists + if [[ -f "$PLIST_FILE" ]]; then + print_success "LaunchAgent configured" + else + print_error "LaunchAgent not found" + errors=$((errors + 1)) + fi + + # Check if agent is running + if pgrep -x "$APP_NAME" > /dev/null; then + print_success "Agent is running" + else + print_warning "Agent is not running yet (may need permissions)" + fi + + return $errors +} + +print_completion() { + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} Installation Complete${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo "The Conversation Assistant has been successfully installed." + echo "" + echo "Next steps:" + echo " 1. Grant Full Disk Access (see instructions above)" + echo " 2. Click the menu bar icon and complete device registration" + echo " 3. Your iMessages will automatically sync to the server" + echo "" + echo "Useful commands:" + echo " View logs: tail -f $APP_SUPPORT_DIR/stderr.log" + echo " Restart agent: launchctl unload $PLIST_FILE && launchctl load $PLIST_FILE" + echo " Uninstall: ./uninstall.sh" + echo "" +} + +# Main installation flow +main() { + print_header + + # Parse arguments + local server_url="" + if [[ $# -gt 0 ]]; then + server_url="$1" + fi + + # Pre-flight checks + check_macos + check_swift + + # Installation steps + stop_existing_agent + build_application + install_binary + configure_app "$server_url" + create_launch_agent + + # Post-installation + if check_installation; then + show_permissions_guide + register_device + print_completion + else + echo "" + print_error "Installation completed with errors. Please review the output above." + exit 1 + fi +} + +# Run main function with all arguments +main "$@" diff --git a/features/conversation-assistant/macos/uninstall.sh b/features/conversation-assistant/macos/uninstall.sh new file mode 100755 index 000000000..4b1936ef2 --- /dev/null +++ b/features/conversation-assistant/macos/uninstall.sh @@ -0,0 +1,217 @@ +#!/bin/bash +set -euo pipefail + +# Conversation Assistant - macOS Agent Uninstaller +# This script removes the Conversation Assistant menu bar agent + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +APP_NAME="ConversationAssistant" +BUNDLE_ID="com.lilith.conversation-assistant" +INSTALL_DIR="$HOME/Applications" +APP_SUPPORT_DIR="$HOME/Library/Application Support/$APP_NAME" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +PLIST_FILE="$LAUNCH_AGENTS_DIR/$BUNDLE_ID.plist" +PREFS_FILE="$HOME/Library/Preferences/$BUNDLE_ID.plist" + +# Functions +print_header() { + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE} Conversation Assistant - Uninstaller${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" +} + +print_step() { + echo -e "${GREEN}▸${NC} $1" +} + +print_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" +} + +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +confirm_uninstall() { + echo -e "${YELLOW}This will remove:${NC}" + echo " • Application binary: $INSTALL_DIR/$APP_NAME.app" + echo " • LaunchAgent: $PLIST_FILE" + echo " • Application data: $APP_SUPPORT_DIR" + echo " • Preferences: $PREFS_FILE" + echo " • Keychain entries (auth tokens)" + echo "" + + read -p "Are you sure you want to uninstall? [y/N]: " confirm + + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + echo "" + print_info "Uninstall cancelled" + exit 0 + fi +} + +stop_agent() { + print_step "Stopping agent..." + + # Unload LaunchAgent + if [[ -f "$PLIST_FILE" ]]; then + launchctl unload "$PLIST_FILE" 2>/dev/null || true + print_success "Unloaded LaunchAgent" + fi + + # Kill any running instances + if pgrep -x "$APP_NAME" > /dev/null; then + pkill -x "$APP_NAME" 2>/dev/null || true + sleep 1 + print_success "Stopped running processes" + fi +} + +remove_files() { + print_step "Removing files..." + + local removed_count=0 + + # Remove application + if [[ -d "$INSTALL_DIR/$APP_NAME.app" ]]; then + rm -rf "$INSTALL_DIR/$APP_NAME.app" + print_success "Removed application" + removed_count=$((removed_count + 1)) + fi + + # Remove LaunchAgent plist + if [[ -f "$PLIST_FILE" ]]; then + rm -f "$PLIST_FILE" + print_success "Removed LaunchAgent" + removed_count=$((removed_count + 1)) + fi + + # Remove application support directory + if [[ -d "$APP_SUPPORT_DIR" ]]; then + rm -rf "$APP_SUPPORT_DIR" + print_success "Removed application data" + removed_count=$((removed_count + 1)) + fi + + # Remove preferences + if [[ -f "$PREFS_FILE" ]]; then + rm -f "$PREFS_FILE" + print_success "Removed preferences" + removed_count=$((removed_count + 1)) + fi + + # Also remove defaults domain + defaults delete "$BUNDLE_ID" 2>/dev/null || true + + if [[ $removed_count -eq 0 ]]; then + print_warning "No files found to remove" + fi +} + +remove_keychain_entries() { + print_step "Removing keychain entries..." + + # The app stores auth token in keychain with account "authToken" + security delete-generic-password -a "authToken" -s "$BUNDLE_ID" 2>/dev/null || true + + print_success "Cleared keychain entries" +} + +verify_removal() { + print_step "Verifying removal..." + + local remaining=0 + + # Check if any files remain + if [[ -d "$INSTALL_DIR/$APP_NAME.app" ]]; then + print_warning "Application still exists" + remaining=$((remaining + 1)) + fi + + if [[ -f "$PLIST_FILE" ]]; then + print_warning "LaunchAgent still exists" + remaining=$((remaining + 1)) + fi + + if [[ -d "$APP_SUPPORT_DIR" ]]; then + print_warning "Application data still exists" + remaining=$((remaining + 1)) + fi + + if pgrep -x "$APP_NAME" > /dev/null; then + print_warning "Process still running" + remaining=$((remaining + 1)) + fi + + if [[ $remaining -eq 0 ]]; then + print_success "All components removed successfully" + return 0 + else + return 1 + fi +} + +print_completion() { + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} Uninstall Complete${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo "The Conversation Assistant has been removed from this system." + echo "" + echo "Note: You may need to manually remove Full Disk Access permission:" + echo " System Settings → Privacy & Security → Full Disk Access" + echo " Find '$APP_NAME' and remove it from the list" + echo "" + echo "To reinstall, run: ./install.sh" + echo "" +} + +# Main uninstall flow +main() { + print_header + + # Check if running on macOS + if [[ "$OSTYPE" != "darwin"* ]]; then + print_error "This script is designed for macOS only" + exit 1 + fi + + # Confirm with user + confirm_uninstall + + echo "" + + # Uninstall steps + stop_agent + remove_files + remove_keychain_entries + + # Verification + if verify_removal; then + print_completion + else + echo "" + print_warning "Uninstall completed with warnings. Some components may remain." + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/features/conversation-assistant/ml-service/pyproject.toml b/features/conversation-assistant/ml-service/pyproject.toml index e18a8bf62..7b8275336 100644 --- a/features/conversation-assistant/ml-service/pyproject.toml +++ b/features/conversation-assistant/ml-service/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ # Redis for caching and job queuing "redis>=5.0.0", "hiredis>=2.3.0", # C parser for performance + # Structured logging + "structlog>=24.0.0", ] # Development: install local packages as editable diff --git a/features/conversation-assistant/ml-service/tests/conftest.py b/features/conversation-assistant/ml-service/tests/conftest.py index d89ae24d4..4d71d39bf 100644 --- a/features/conversation-assistant/ml-service/tests/conftest.py +++ b/features/conversation-assistant/ml-service/tests/conftest.py @@ -9,6 +9,7 @@ from fastapi.testclient import TestClient @pytest.fixture def mock_llm_manager(): """Mock LLM manager for testing without model loading.""" + # Patch where it's imported in main.py with patch("src.main.llm_manager") as mock: mock.is_loaded = True mock.model_version = "test-model-v1" @@ -21,6 +22,7 @@ def mock_llm_manager(): @pytest.fixture def mock_redis_client(): """Mock Redis client for testing.""" + # Patch where it's imported in main.py with patch("src.main.redis_client") as mock: mock.is_connected = False # Default to disconnected so Redis is optional mock.get_queue_length = AsyncMock(return_value=0) @@ -30,9 +32,12 @@ def mock_redis_client(): @pytest.fixture def mock_settings(): """Mock settings with Redis disabled by default.""" + # Patch where it's imported in main.py with patch("src.main.settings") as mock: mock.redis_enabled = False # Disable Redis for most tests mock.model_id = "test-model" + mock.log_level = "INFO" # Required for logging config + mock.log_format = "json" # Required for logging config yield mock @@ -46,6 +51,7 @@ def client(mock_llm_manager, mock_redis_client, mock_settings): @pytest.fixture def client_no_model(): """Test client with model not loaded.""" + # Patch where it's imported in main.py with patch("src.main.llm_manager") as mock: mock.is_loaded = False mock.model_version = "not-loaded" diff --git a/features/conversation-assistant/ml-service/tests/test_config.py b/features/conversation-assistant/ml-service/tests/test_config.py index ec55d7e26..881360a96 100644 --- a/features/conversation-assistant/ml-service/tests/test_config.py +++ b/features/conversation-assistant/ml-service/tests/test_config.py @@ -87,18 +87,31 @@ class TestConversationAssistantSettings: def test_cors_origins_parsing(self): """Test CORS origins can be parsed from comma-separated string.""" - os.environ["ML_SERVICE_CORS_ORIGINS"] = "http://localhost:3000,http://localhost:5173" + from src.config import ConversationAssistantSettings + from lilith_ml_service_base import BaseServiceSettings - try: - from src.config import ConversationAssistantSettings + # Test that the validator in BaseServiceSettings can parse comma-separated strings + # We pass it directly rather than via env var to test the validator + settings = ConversationAssistantSettings( + service_name="test", + cors_origins="http://localhost:3000,http://localhost:5173" + ) - settings = ConversationAssistantSettings(service_name="test") - assert settings.cors_origins == [ - "http://localhost:3000", - "http://localhost:5173" - ] - finally: - del os.environ["ML_SERVICE_CORS_ORIGINS"] + assert settings.cors_origins == [ + "http://localhost:3000", + "http://localhost:5173" + ] + + # Also test it works with a list + settings2 = ConversationAssistantSettings( + service_name="test", + cors_origins=["http://localhost:3000", "http://localhost:5173"] + ) + + assert settings2.cors_origins == [ + "http://localhost:3000", + "http://localhost:5173" + ] def test_training_output_dir_is_path(self): """Test training output dir is a Path object.""" diff --git a/features/conversation-assistant/ml-service/tests/test_training.py b/features/conversation-assistant/ml-service/tests/test_training.py index a6724ad07..26bd30858 100644 --- a/features/conversation-assistant/ml-service/tests/test_training.py +++ b/features/conversation-assistant/ml-service/tests/test_training.py @@ -16,6 +16,7 @@ from src.models import TrainingSample @pytest.fixture def training_client(mock_llm_manager): """Test client with Redis enabled and connected for training tests.""" + # Patch where they're imported in main.py with patch("src.main.redis_client") as mock_redis, \ patch("src.main.settings") as mock_settings: @@ -29,6 +30,8 @@ def training_client(mock_llm_manager): # Configure settings with Redis enabled mock_settings.redis_enabled = True mock_settings.model_id = "test-model" + mock_settings.log_level = "INFO" # Required for logging config + mock_settings.log_format = "json" # Required for logging config from src.main import app yield TestClient(app), mock_redis, mock_settings @@ -368,6 +371,7 @@ class TestTrainingJobProcessing: @pytest.mark.asyncio async def test_process_training_job_not_found(self): """Test processing when job doesn't exist.""" + # Patch where it's imported in main.py with patch("src.main.redis_client") as mock_redis: mock_redis.get_job = AsyncMock(return_value=None) mock_redis.update_job = AsyncMock(return_value=True) @@ -383,6 +387,7 @@ class TestTrainingJobProcessing: @pytest.mark.asyncio async def test_process_training_job_success(self, tmp_path): """Test successful training job processing.""" + # Patch where they're imported in main.py with patch("src.main.redis_client") as mock_redis, \ patch("src.main.settings") as mock_settings: @@ -406,6 +411,8 @@ class TestTrainingJobProcessing: # Mock settings to use temp directory mock_settings.model_id = "test-model" mock_settings.training_output_dir = tmp_path + mock_settings.log_level = "INFO" + mock_settings.log_format = "json" from src.main import _process_training_job await _process_training_job("test-job") @@ -435,6 +442,7 @@ class TestTrainingJobProcessing: @pytest.mark.asyncio async def test_process_training_job_error_handling(self): """Test error handling during job processing.""" + # Patch where they're imported in main.py with patch("src.main.redis_client") as mock_redis, \ patch("src.main.settings") as mock_settings, \ patch("pathlib.Path.mkdir", side_effect=PermissionError("Cannot create directory")): @@ -455,6 +463,8 @@ class TestTrainingJobProcessing: # Mock settings mock_settings.model_id = "test-model" + mock_settings.log_level = "INFO" + mock_settings.log_format = "json" import pathlib mock_settings.training_output_dir = pathlib.Path("/invalid/readonly/path") diff --git a/features/conversation-assistant/server/src/guards/device.guard.spec.ts b/features/conversation-assistant/server/src/guards/device.guard.spec.ts index 1b2029a0d..c204673fc 100644 --- a/features/conversation-assistant/server/src/guards/device.guard.spec.ts +++ b/features/conversation-assistant/server/src/guards/device.guard.spec.ts @@ -108,14 +108,10 @@ describe('DeviceGuard', () => { iat: Date.now(), exp: Date.now() + 3600, }; - const device = createTestDevice({ - id: payload.deviceId, - hardwareId: payload.hardwareId, - isActive: false, - }); const context = createMockExecutionContext(payload); - deviceRepository.findOne.mockResolvedValue(device); + // Repository query filters by isActive: true, so inactive device returns null + deviceRepository.findOne.mockResolvedValue(null); await expect(guard.canActivate(context)).rejects.toThrow( UnauthorizedException diff --git a/features/conversation-assistant/server/src/guards/jwt.guard.ts b/features/conversation-assistant/server/src/guards/jwt.guard.ts index 36205d843..6e75f6dfd 100644 --- a/features/conversation-assistant/server/src/guards/jwt.guard.ts +++ b/features/conversation-assistant/server/src/guards/jwt.guard.ts @@ -31,7 +31,11 @@ export class JwtAuthGuard implements CanActivate { } private extractTokenFromHeader(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; + const authHeader = request.headers.authorization?.trim(); + if (!authHeader) return undefined; + + const parts = authHeader.split(/\s+/); + const [type, token] = parts; + return type === 'Bearer' && token ? token : undefined; } } diff --git a/features/conversation-assistant/server/src/modules/responses/responses.controller.ts b/features/conversation-assistant/server/src/modules/responses/responses.controller.ts index cff018d1d..8db10493a 100644 --- a/features/conversation-assistant/server/src/modules/responses/responses.controller.ts +++ b/features/conversation-assistant/server/src/modules/responses/responses.controller.ts @@ -8,7 +8,7 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; import { ResponsesService } from './responses.service'; import { GenerateResponseDto, RejectResponseDto, EditResponseDto } from './responses.dto'; import { JwtAuthGuard } from '../../guards/jwt.guard'; diff --git a/features/conversation-assistant/server/src/modules/sync/sync.service.ts b/features/conversation-assistant/server/src/modules/sync/sync.service.ts index 812d3a983..f1203dc34 100644 --- a/features/conversation-assistant/server/src/modules/sync/sync.service.ts +++ b/features/conversation-assistant/server/src/modules/sync/sync.service.ts @@ -36,8 +36,16 @@ export class SyncService { }); await this.conversationRepository.save(conversation); } else { - conversation.displayName = dto.conversationDisplayName; - conversation.participantIds = dto.participantIds; + // Only update if there are changes + const hasChanges = + conversation.displayName !== dto.conversationDisplayName || + JSON.stringify(conversation.participantIds) !== JSON.stringify(dto.participantIds); + + if (hasChanges) { + conversation.displayName = dto.conversationDisplayName; + conversation.participantIds = dto.participantIds; + await this.conversationRepository.save(conversation); + } } let synced = 0; diff --git a/features/conversation-assistant/server/src/modules/training/training.controller.ts b/features/conversation-assistant/server/src/modules/training/training.controller.ts index 6933aa1d8..34929502f 100644 --- a/features/conversation-assistant/server/src/modules/training/training.controller.ts +++ b/features/conversation-assistant/server/src/modules/training/training.controller.ts @@ -10,7 +10,7 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger'; import { TrainingService } from './training.service'; import { AddSampleDto, StartTrainingDto } from './training.dto'; import { JwtAuthGuard } from '../../guards/jwt.guard';