diff --git a/.agents/skills/devpt-release/SKILL.md b/.agents/skills/devpt-release/SKILL.md new file mode 100644 index 0000000..46c0e68 --- /dev/null +++ b/.agents/skills/devpt-release/SKILL.md @@ -0,0 +1,68 @@ +--- +name: devpt-release +description: Increment version and update CHANGELOG.md from commits since last update. Use when making a release, bumping version, or updating changelog for dev-process-tracker. +--- + +# DevPT Release Skill + +## Usage + +``` + or "bump minor version" or "devpt release major" +``` + +## Workflow + +1. **Read CHANGELOG.md** — extract current version from first `## X.Y.Z` header +2. **Find last update** — get SHA of the commit that last modified CHANGELOG.md +3. **Get commits since** — `git log ..HEAD --oneline --no-merges` +4. **Group & classify**: + - Parse commit messages for intent (add/fix/change/remove/refactor/docs) + - **Group related commits**: if a "fix" or "polish" follows a feature in time/subject, fold it into that feature line + - Prioritize user-facing changes over internal polish +5. **Determine bump**: + - `major` (0.x → 1.0 or breaking) / `minor` (features) / `patch` (fixes) — use user-specified if provided +6. **Generate entries** — write concise imperative-mood bullets: + - "Added X so Y" for features + - "Fixed Z so W" for bugs + - Group related fixes with their feature when they're clearly connected +7. **Update CHANGELOG.md** — prepend new version section +8. **Set version** — run `./scripts/set-version.sh ` to update version.go, commit, and tag +9. **Push** — `git push && git push origin v` + +## Version Management + +- **Version file**: `pkg/buildinfo/version.go` (`const Version = "X.Y.Z"`) +- **Set version script**: `./scripts/set-version.sh ` — updates version.go, commits, creates tag +- **Tags use `v` prefix**: `v0.2.1` +- **Pre-push hook**: validates version.go matches latest tag (via lefthook) + +## Grouping Heuristics + +When classifying commits, apply these rules: + +1. **Time proximity**: Fixes within 1-3 commits of a feature likely belong to it +2. **Subject overlap**: "fix search" after "add search input" → same entry +3. **Keyword clues**: "polish", "tweak", "adjust", "follow-up" often indicate related work +4. **When uncertain**: Keep separate rather than over-grouping + +## Flags + +- `--review` — show grouped commits and proposed entries before writing +- `--dry-run` — output the new section without modifying the file + +## Example Output + +```markdown +## 0.3.0 + +- Added dark mode toggle so users can switch themes without reloading +- Fixed theme persistence so preference survives across sessions +- Removed deprecated `/legacy` endpoint +``` + +## Edge Cases + +- **No commits since last update**: Report "no changes since last release" and exit +- **Uncommitted changes**: Warn but proceed (commits are the source of truth) +- **Version is 0.x**: Treat as pre-release; minor bumps for features, patch for fixes diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a1b6731..56dd876 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # DevPortTrack Copilot Instructions -A macOS CLI tool for discovering, tracking, and managing local development servers. ~3,900 lines of Go across 9 packages. +A macOS CLI tool for discovering, tracking, and managing local development servers. ~8,300 lines of Go across 10 packages. ## Quick Reference @@ -27,12 +27,14 @@ go test -v ./pkg/cli -run TestWarnLegacyManagedCommands ### Key Directories - **cmd/devpt/main.go** - CLI entry point (~170 lines). Routes commands and prints results to stdout/stderr. -- **pkg/cli/** - Command handlers (commands.go), TUI app controller (app.go), and Bubble Tea UI (tui.go). ~50KB of code. +- **pkg/cli/** - Command handlers (commands.go), TUI app controller (app.go), and Bubble Tea UI (pkg/cli/tui/). ~50KB of code. - **pkg/scanner/** - Process discovery via `lsof`, project root detection, and AI agent detection. - **pkg/registry/** - Service registry (JSON at ~/.config/devpt/registry.json). Thread-safe CRUD operations. - **pkg/process/** - Process lifecycle management: spawning, log capture, graceful shutdown. +- **pkg/lifecycle/** - Service lifecycle orchestration: identity verification, reconciliation, readiness checks, per-service locking, start/stop/restart workflows. See PROCESS_MANAGEMENT.md for the behavioral contract. - **pkg/models/** - Core data structures (ProcessRecord, ManagedService, AgentTag) and config paths. - **pkg/health/** - Health check utilities (basic placeholder for future expansion). +- **pkg/resource/** - Runtime resource metrics (memory RSS) via batch `ps` calls. ## Architecture Overview @@ -41,8 +43,11 @@ go test -v ./pkg/cli -run TestWarnLegacyManagedCommands 2. **Resolver** walks filesystem to find project roots (.git, go.mod, package.json, etc.) 3. **Detector** analyzes parent process/env to identify AI-agent-started servers 4. **Registry** (`pkg/registry`) manages user-registered managed services (JSON at ~/.config/devpt/registry.json) -5. **Process Manager** (`pkg/process`) handles spawning, stopping, and log capture -6. **CLI/TUI** presents the unified list and command interface +5. **Lifecycle Manager** (`pkg/lifecycle`) orchestrates start/stop/restart with identity verification, readiness, and per-service locking +6. **Process Manager** (`pkg/process`) handles spawning, stopping, and log capture +7. **Resource Collector** (`pkg/resource`) fetches runtime metrics (memory) via batch `ps` calls +8. **Health Checker** (`pkg/health`) probes ports for responsiveness +9. **CLI/TUI** presents the unified list and command interface ### Key Models - **ProcessRecord**: Discovered listening process (PID, port, command, project root, agent detection) @@ -50,6 +55,13 @@ go test -v ./pkg/cli -run TestWarnLegacyManagedCommands - **AgentTag**: Detection result (source, agent name, confidence level) - **Registry**: Container for all managed services (versioned JSON format) +### Runtime Observations +Memory and health are runtime observations, separate from discovery data: +- Fetched asynchronously in the TUI update loop (every 2s when idle) +- Stored in TUI model maps keyed by PID (memory) or port (health) +- Not persisted — they reflect current state only +- Batch collection via `ps -p -o pid=,rss=` — returns KB for each PID + ### Command Routing Entry point (cmd/devpt/main.go) routes commands: - No args → `app.TopCmd()` (opens interactive TUI in pkg/cli/tui.go) @@ -57,10 +69,17 @@ Entry point (cmd/devpt/main.go) routes commands: ## Critical Implementation Details +### Service Identity and Lifecycle +The lifecycle layer (`pkg/lifecycle`) manages service identity using an ordered evidence chain: +1. PID + start time → 1b. Stored LastPID + path → 2. Declared port → 3. CWD + resolved command → 4. Exact CWD → 5. Project root + +After spawn, the OS-resolved command is captured and stored for future identity matching. +See PROCESS_MANAGEMENT.md §1.3 and §3.4 for the full contract. + ### ProcessRecord vs ManagedService Merging When listing services: -1. Merge discovered processes with managed registry entries -2. Managed service appears as "running" if its PID is in discovered processes +1. Merge discovered processes with managed registry entries via the reconciler +2. Managed service appears as "running" if identity verification confirms ownership 3. Source field shows: "manual" (discovered but unmanaged), "managed" (registered), or "agent:xxx" (detected) ### Agent Detection Confidence @@ -77,7 +96,8 @@ Returns confidence level: low, medium, or high. Code uses these intelligently fo - Processes spawn in separate process groups (setpgid) - stdout/stderr redirected to ~/.config/devpt/logs/{serviceName}/{timestamp}.log - Graceful shutdown: SIGTERM with timeout, then SIGKILL -- PID and start time tracked in registry after spawn +- PID, start time, and resolved command tracked in registry after spawn +- TUI stop/restart routes through the lifecycle layer (not raw PID calls) ### Directory Caching Project resolver caches directory → project root mappings. @@ -97,18 +117,27 @@ Cache can be invalidated selectively. Important for performance (lsof calls are ## Testing ### Test Locations -- **pkg/cli/**: app_warning_test.go (TestWarnLegacyManagedCommands), command_validation_test.go (TestValidateManagedCommand, TestFirstBlockedShellPattern) - 3 tests total -- **pkg/process/**: manager_parse_test.go (TestParseCommandArgs, TestParseCommandArgs_UnterminatedQuote) - 2 tests total +- **pkg/cli/**: command validation, pattern matching, batch ops, status commands, display formatting — 10 test files +- **pkg/cli/tui/**: UI rendering, key input, state transitions, viewports, memory display, namespaces, OSC8, group color — 12 test files +- **pkg/lifecycle/**: identity, reconciliation, start/stop/restart flows, readiness, locking, outcomes — 11 test files +- **pkg/process/**: command parsing, start time — 2 test files +- **pkg/resource/**: memory formatting, color thresholds, collection — 1 test file +- **pkg/registry/**: CRUD operations — 1 test file +- **pkg/scanner/**: process discovery — 1 test file +- **pkg/models/**: lifecycle models — 1 test file ### Test Patterns - Table-driven tests for command parsing and validation - No external dependencies; tests use pure Go (no mocking framework) -- Run full suite: `go test ./...` (2 seconds) +- Run full suite: `go test ./...` (~20 seconds) - Run single package: `go test -v ./pkg/cli` - Run specific test: `go test -v ./pkg/cli -run TestWarnLegacyManagedCommands` ## Conventions +### Spec Updates +- Removed specs: delete cleanly, re-render. No ~~strikethrough~~, no **REMOVED** annotations, no tombstone rows. + ### Naming - Packages use lowercase, no underscores (Go convention) - Function names: `CommandName()` pattern for exported, `helperName()` for unexported @@ -133,12 +162,31 @@ Cache can be invalidated selectively. Important for performance (lsof calls are - Create dirs if missing with MkdirAll (mode 0755) - Log files timestamped as: ~/.config/devpt/logs/{serviceName}/{ISO8601}.log -### TUI-Specific (pkg/cli/tui.go) +### TUI-Specific (pkg/cli/tui/) - Model-based architecture (Bubble Tea): Cmd returns effects, Model contains state -- Top-level ListModel has tabs for "Running" and "Managed" lists +- Split-view layout: running services table (top) + managed services list (bottom) + universal details pane (right) +- Details pane shows info for whichever service is currently selected (running or managed) - Never mutate Model state directly—use Cmd/Update pattern - Exit conditions: user presses 'q', or explicit quit() command - Key handlers prioritized: modal state (logs/input) takes precedence over list navigation +- Async observation: memory collected every 2s via batch `ps`, health checked asynchronously + +## Before Submitting Changes + +Always run these checks before considering work complete: + +```bash +# 1. Build succeeds +go build ./... + +# 2. All tests pass +go test ./... + +# 3. CLI runs without error +go build -o devpt ./cmd/devpt && ./devpt ls +``` + +If adding user-facing features, also update README.md and QUICKSTART.md. ## Common Tasks @@ -173,8 +221,11 @@ Cache can be invalidated selectively. Important for performance (lsof calls are ## Documentation Files - **README.md** - Full user documentation and CLI reference - **QUICKSTART.md** - Getting started guide for new users +- **PROCESS_MANAGEMENT.md** - Behavioral contract for service lifecycle (identity, reconciliation, outcomes) +- **DEBUG.md** - Debug protocol with verified runtime workflows - **IMPLEMENTATION_SUMMARY.md** - Architecture and feature overview (reference only) - **techspec.md** - Original technical specification +- **.agents/skills/devpt-release/SKILL.md** - Release workflow (changelog + version bump) Update README and QUICKSTART when adding user-facing features or commands. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..66f4bde --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Build binaries + run: | + mkdir -p dist + + # Linux + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-linux-x64 ./cmd/devpt + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-linux-arm64 ./cmd/devpt + + # macOS + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-macos-x64 ./cmd/devpt + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-macos-arm64 ./cmd/devpt + + # Windows + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-windows-x64.exe ./cmd/devpt + + - name: Generate checksums + run: | + cd dist + sha256sum * > checksums.txt + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 542d28e..ef0aeda 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,28 @@ /.tmp-home/ /.tmp-home*/ - # Local draft/working docs -/docs \ No newline at end of file +/docs +/coverage.out + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +vendor/ + +# Coverage +*.coverprofile +coverage.html + +# Test fixture binaries (no extension on macOS) +/sandbox/servers/*/go-basic +/sandbox/servers/*/*/node +/sandbox/servers/*/*/server.js +/.claude/settings.local.json +wireframe*.* diff --git a/.tickets/DEVPT-004-managed-service-state-panel-in-tui.md b/.tickets/DEVPT-004-managed-service-state-panel-in-tui.md new file mode 100644 index 0000000..7dd090a --- /dev/null +++ b/.tickets/DEVPT-004-managed-service-state-panel-in-tui.md @@ -0,0 +1,214 @@ +--- +code: DEVPT-004 +status: Implemented +dateCreated: 2026-04-02T08:50:48.448Z +type: Feature Enhancement +priority: High +relatedTickets: DEVPT-003 +--- + +# Managed service state panel in TUI + +## 1. Description + +### Requirements Scope +`brief` + +### Problem +- Managed services in the TUI are harder to scan than the CLI status output. +- Users cannot quickly distinguish healthy, stopped, starting, and failed services from the managed list alone. +- When a managed service stops unexpectedly, the TUI does not provide enough immediate context to understand what happened. + +### Affected Areas +- Terminal UI presentation of managed services +- Managed service status communication +- Runtime troubleshooting flow for local development services + +### Scope +- **In scope**: Improve how managed service state is presented in the TUI, make failures easier to spot visually, and show concise diagnostic context for the selected service. +- **Out of scope**: Redesigning the CLI status command, changing process supervision architecture, or defining the final internal implementation approach for lifecycle tracking. + +## 2. Desired Outcome + +### Success Conditions +- Users can identify managed service state at a glance from the TUI list. +- Visually distinct symbols or markers communicate whether a managed service is running, starting, stopped, or failed. +- Selecting a managed service switches the managed-services area into a 50|50 split view that keeps the list visible and shows concise details for the selected service. +- When a managed service has failed, the TUI shows a short headline and recent log context similar in spirit to the CLI status experience. +- The managed services area remains easy to read in a terminal with limited width. + +### Constraints +- Must fit within the existing terminal-based workflow. +- Must remain readable with color and symbol usage in common terminal themes. +- Must preserve current managed service interactions such as selection and service actions. +- Must not require users to leave the TUI for basic state recognition and first-level troubleshooting. + +### Non-Goals +- Not redefining how managed services are started or stopped internally. +- Not requiring a full crash-analysis or process-history system in this phase. +- Not replacing the detailed CLI status output. +- Not introducing a new workflow that depends on mouse usage. + +## 3. Open Questions + +| Area | Question | Constraints | +|------|----------|-------------| +| UX | Which symbols and visual treatments are most readable for running, stopped, starting, and failed states? | Must remain understandable in terminal environments | +| Layout | How should the managed-services split view balance list width and details width while staying readable? | Must fit narrow terminal widths | +| Diagnostics | What is the minimum useful detail for a failed service in TUI view? | Should support first-level troubleshooting without overwhelming the screen | +| Consistency | How closely should the TUI details mirror CLI status output? | Must avoid duplicating excessive verbosity in the list view | + +### Known Constraints +- The TUI should remain scan-friendly for multiple managed services. +- Status communication should work even when only limited runtime context is available. +- The design should support future deeper diagnostics without forcing another major UI rewrite. + +### Decisions Deferred +- Exact implementation approach for deriving service stop and crash details. +- Specific internal data model changes for richer lifecycle tracking. +- Final layout mechanics for any grouped or divided managed service sections. +- Task breakdown and technical solution details. + +## 4. Acceptance Criteria + +### Functional (Outcome-focused) +- [ ] Managed services show a visually distinct state marker in the TUI. +- [ ] Users can distinguish running, stopped, starting, and failed managed services without opening another view. +- [ ] Selecting a managed service reveals a 50|50 split managed-services view with the list on one side and a compact details pane for the selected service on the other side. +- [ ] Failed managed services show a concise headline explaining the failure or best available reason. +- [ ] Failed managed services show recent log context that helps users understand the issue. +- [ ] The details view helps users decide whether to restart, inspect logs, or leave the service stopped. +- [ ] Details pane displays working directory, port(s), and command for the selected managed service. +- [ ] Details pane shows comprehensive details for selected running services (PID, port, command, directory, project, start time, agent info, health status). + +### Non-Functional +- [ ] The managed services area remains readable in standard terminal sizes. +- [ ] Visual state markers are easy to recognize quickly. +- [ ] Additional details do not make the TUI feel cluttered during normal use. + +### Edge Cases +- No managed service is currently selected. +- Managed service has no captured logs. +- Managed service was intentionally stopped by the user. +- Managed service exits immediately after start. +- Multiple managed services share similar names or ports. +- Terminal width is too narrow for full details. +- Managed service has multiple ports. +- Managed service has empty or unset metadata fields (CWD, command, ports). + +## 5. Verification + +### How to Verify Success +- Manual verification: + - Open the TUI with managed services in mixed states. + - Confirm users can identify problematic services at a glance. + - Select running, stopped, and failed services and verify the details area changes appropriately. + - Confirm failed services show a short headline and recent log context when available. +- Automated verification: + - Validate rendering of managed service state markers. + - Validate managed-service detail rendering for different service states. + - Validate 50|50 split behavior at normal widths. +- Validate placeholder details behavior when nothing is selected. +- Validate narrow-width behavior and truncation. +- Usability verification: + - Compare the TUI experience against the CLI status flow for identifying and triaging service failures. + +> Requirements trace projection: [requirements.trace.md](./DEVPT-004/requirements.trace.md) +> +> Requirements notes: [requirements.md](./DEVPT-004/requirements.md) +> +> BDD trace projection: [bdd.trace.md](./DEVPT-004/bdd.trace.md) +> +> BDD notes: [bdd.md](./DEVPT-004/bdd.md) +> +> Architecture trace projection: [architecture.trace.md](./DEVPT-004/architecture.trace.md) +> +> Architecture notes: [architecture.md](./DEVPT-004/architecture.md) +> +> Tests trace projection: [tests.trace.md](./DEVPT-004/tests.trace.md) +> +> Tests notes: [tests.md](./DEVPT-004/tests.md) +> +> Tasks trace projection: [tasks.trace.md](./DEVPT-004/tasks.trace.md) +> +> Tasks notes: [tasks.md](./DEVPT-004/tasks.md) + +--- + +## 8. Clarifications + +### UAT Session 2026-04-06 + +**Approved changes**: +- Details pane header shall display action buttons for service management (start/restart, stop, edit) +- Action buttons shall be context-sensitive based on service state +- Action buttons shall use icons and colors: restart (↻), start (▶), stop (■), edit (✎) +- Each section (running processes, managed list, details) shall scroll independently +- Charm bubbles v2.1.0 has no built-in button component; buttons will be custom-styled text elements + +**Changed requirement IDs**: +- BR-8: added (details pane header shows action buttons) +- BR-9: added (action buttons are context-sensitive) +- BR-10: added (sections scroll independently) +- Edge-7: added (action buttons hidden/disabled when no service selected) +- Edge-8: added (action buttons handle transition states) + +**Updated workflow documents**: +- `requirements.md` — new semantic decisions for action buttons and independent scrolling +- `bdd.md` — Journey 7 and 8 added, 3 new scenarios, scenario budget updated (10/12) +- `architecture.md` — Flow 3 (action buttons), Flow 4 (independent scrolling) added, new invariants +- `tests.md` — new test coverage for action buttons and independent scroll +- `tasks.md` — TASK-5 and TASK-6 added, M5 and M6 milestones added + +**uat.md**: written (replaced previous version) + +**Strict drift/lock**: not used + +**Resolved decisions**: +- Button keyboard shortcuts: Already exist in current keymap - no new shortcuts needed +- Edit button action: Removed from scope - deferred to future phase +- Discovered services details and actions: Recommended approach documented in uat.md (show details with "Add to managed" button) + +### UAT Session 2026-04-03 + +**Approved changes**: +- Details pane shall display working directory, port(s), and command for the selected managed service. +- Metadata fields placed after state line and before crash context. +- Empty/unset fields omitted gracefully (no blank lines). +- Multi-port display format must be compact. +- Metadata must appear alongside crash context for crashed services. + +**Changed requirement IDs**: +- BR-3: refined (broadened to include service metadata in details pane) +- BR-7: added (details pane displays CWD, ports, command) +- Edge-5: added (graceful degradation for missing metadata) +- Edge-6: added (multi-port compact display) + +**Updated workflow documents**: +- `requirements.md` — semantic decision + review note added +- `bdd.md` — Journey 2 refined, Journey 5 added, scenario budget updated +- `architecture.md` — Flow 2 step 4-5 updated, new invariants +- `tests.md` — new data mechanism tests, C1 coverage expanded +- `tasks.md` — TASK-4 added, M4 milestone added + +**uat.md**: written + +**Strict drift/lock**: not used + +### Spec Audit 2026-04-03 + +Resolved 14 issues found during cross-stage alignment review: +- Fixed BR-3/BR-7 overlap (BR-3 structural, BR-7 content) +- Fixed CR status (Implemented → In Progress) +- Fixed CR acceptance criteria (added metadata criterion) +- Fixed BDD scenario Given/When/Then format +- Added missing obligation `OBL-managed-service-metadata-display` +- Added missing artifact `ART-tui-managed-split-test-go` +- Added scenario `crashed_service_shows_metadata_alongside_crash_context` +- Added edge case Edge-6 (multi-port display) +- Clarified architecture Flow 4 wording +- Clarified source/metadata/crash render order +- Updated tests.md mapping, verification, and data mechanism tests +- Fixed TASK-4 milestone header (M3 → M4) +- Linked TASK-4 done-when to test IDs +- Updated all trace projections diff --git a/.tickets/DEVPT-004/.checkpoint.yaml b/.tickets/DEVPT-004/.checkpoint.yaml new file mode 100644 index 0000000..40b2182 --- /dev/null +++ b/.tickets/DEVPT-004/.checkpoint.yaml @@ -0,0 +1,37 @@ +checkpoint: + version: 4 + cr_key: "DEVPT-004" + mode: "feature" + part: null + task_id: "3" + step: "complete" + batch: + size: 3 + current_count: 3 + accumulated_files: [] + all_clean: true + baseline: + tests: null + scope: null + implementation: + files_changed: + - pkg/cli/tui/table.go + - pkg/cli/tui/helpers.go + - pkg/cli/tui/tui_ui_test.go + files_created: [] + notes: "Code agent completed split-view implementation with crash context details" + latest_verify: + tests: + exit_code: 0 + command: "go test ./pkg/cli/... -count=1" + scope: null + behavioral: null + fix_attempts: 0 + fix_history: [] + completion: + verification_round: 0 + last_verdict: "pass" + issues_found: [] + fix_tasks_generated: [] + batch_verifies_clean: true + updated_at: "2026-04-02T10:30:00Z" diff --git a/.tickets/DEVPT-004/architecture.md b/.tickets/DEVPT-004/architecture.md new file mode 100644 index 0000000..1c362ca --- /dev/null +++ b/.tickets/DEVPT-004/architecture.md @@ -0,0 +1,175 @@ +# Architecture + +## Overview + +This feature is implemented as a presentation-layer enhancement inside the existing TUI table flow. +The architecture moves managed services to a split interaction model: at normal widths, when a managed service is selected, the managed-services area renders as a 50|50 two-pane view with the list on one side and selected-service details on the other side. +The design intentionally avoids introducing a new process-lifecycle subsystem in this phase and instead composes existing service state and log data through the current TUI dependency boundary. + +## Pattern + +### Pattern Name +Split-pane state enrichment + +### Rationale +- The user problem is primarily scanability and first-level triage inside the TUI. +- Keeping the list visible while showing details preserves orientation and speeds up repeated inspection across multiple services. +- A 50|50 split gives predictable spatial behavior and prevents the details pane from feeling like an afterthought. +- A persistent placeholder pane keeps the layout stable even when no service is selected. +- Existing service discovery already exposes enough state for running, stopped, and crashed rendering. +- Existing log access is sufficient for showing a short headline and recent tail context without redesigning process supervision. + +## Runtime Flow + +### Flow 1: Managed list state recognition +1. The TUI loads managed services and discovered server state. +2. Managed row rendering derives a current service state for each managed service. +3. The row renderer maps state to a symbol-plus-text presentation. +4. The managed list remains the first-level scan surface for state recognition. + +### Flow 2: 50|50 split-pane selection rendering +1. Focus moves to the managed services list. +2. A managed service becomes the active selection. +3. At normal widths, the managed-services area renders as a 50|50 split view with the list retained on one side and the selected-service details on the other side. +4. The details pane header includes action buttons: [restart|start] [stop] [edit]. +5. The details pane renders content in this order: state line (name + symbol + state), source, service metadata (CWD, port(s), command), then crash-specific context if applicable. +6. Service metadata fields (CWD, command, ports) are rendered conditionally — only when non-empty — and placed after the source line and before any crash-specific context. + +### Flow 3: Action button rendering and interaction +1. The details pane header renders action buttons based on the selected service's current state. +2. For stopped services: show [start] [edit]. +3. For running services: show [restart] [stop] [edit]. +4. For crashed services: show [restart] [edit]. +5. Buttons are styled with icons and colors: restart (↻), start (▶), stop (■), edit (✎). +6. Button clicks are detected via mouse position tracking within the header region. +7. Clicking a button triggers the corresponding service action command. + +### Flow 4: Independent scrolling +1. The TUI maintains three separate viewport models: runningVP, managedListVP, managedDetailsVP. +2. Mouse scroll events are routed to the viewport under the cursor position. +3. Keyboard scroll events (up/down arrows, page up/down) affect only the currently focused section. +4. Scrolling one viewport does not change the scroll position of other viewports. +5. Selection changes do not reset scroll positions in other sections. + +### Flow 5: Empty-selection placeholder rendering +1. The managed-services split view is visible but no managed service is currently selected. +2. The details pane remains visible instead of collapsing. +3. The pane renders a placeholder prompt that tells the user to select a managed service for inspection. +4. Action buttons are hidden or disabled when no service is selected. + +### Flow 6: Failed-service context enrichment +1. The TUI loads managed services and discovered server state. +2. Managed row rendering derives a current service state for each managed service. +3. The row renderer maps state to a symbol-plus-text presentation. +4. The managed list remains the first-level scan surface for state recognition. + +### Flow 2: 50|50 split-pane selection rendering +1. Focus moves to the managed services list. +2. A managed service becomes the active selection. +3. At normal widths, the managed-services area renders as a 50|50 split view with the list retained on one side and the selected-service details on the other side. +4. The details pane renders content in this order: state line (name + symbol + state), source, service metadata (CWD, port(s), command), then crash-specific context if applicable. +5. Service metadata fields (CWD, command, ports) are rendered conditionally — only when non-empty — and placed after the source line and before any crash-specific context. + +### Flow 3: Empty-selection placeholder rendering +1. The managed-services split view is visible but no managed service is currently selected. +2. The details pane remains visible instead of collapsing. +3. The pane renders a placeholder prompt that tells the user to select a managed service for inspection. + +### Flow 6: Failed-service context enrichment +1. The selected managed service resolves to failed or crashed state. +2. The details pane asks the TUI dependency boundary for best-available crash reason and latest log path. +3. Recent non-empty log lines are reduced to a compact tail for triage. +4. The crash-specific section shows the failure headline first, then log path, then recent log context. This section appears after service metadata in the details pane. + +## Module Boundaries + +### `pkg/cli/tui/table.go` +- Owns managed-service presentation in the TUI. +- Owns the split-pane managed-services layout and selected-service details rendering. +- Owns layout decisions for width fitting, 50|50 pane proportions at normal widths, selection highlighting, and visible detail density. + +### `pkg/cli/tui/helpers.go` +- Owns reusable state-to-presentation mapping. +- Owns symbol/color selection helpers, placeholder text helpers, and compact log-tail shaping helpers. +- Must remain presentation-oriented rather than becoming a secondary data source. + +### `pkg/cli/tui/deps.go` +- Owns the narrow TUI dependency contract. +- Must expose only the data the TUI needs for rendering and first-level diagnostics. + +### `pkg/cli/tui_adapter.go` +- Owns adaptation from CLI application services into the TUI dependency contract. +- Bridges existing process-manager log access into TUI-safe methods. +- Must not introduce business logic beyond adaptation. + +### `pkg/cli/tui/tui_ui_test.go` +- Owns UI regression coverage for managed-service rendering. +- Validates split-view rendering, placeholder rendering, symbol visibility, crash context visibility, and width-sensitive output behavior. + +## Structure + +```text +pkg/ +├── cli/ +│ ├── tui_adapter.go # CLI-to-TUI runtime bridge +│ └── tui/ +│ ├── deps.go # TUI dependency contract +│ ├── helpers.go # state/symbol/log/placeholder helpers +│ ├── table.go # managed list + split details rendering +│ └── tui_ui_test.go # UI regression coverage +``` + +## Module Boundaries and Ownership Rule + +- The managed-services list remains the single source of selection. +- The details pane is a pure projection of the currently selected managed service including its operational metadata (working directory, port(s), command), or a placeholder when nothing is selected. +- Selection changes must update the details pane without changing the list interaction contract. +- The split-pane layout belongs to the TUI presentation layer and must not leak process-manager concerns into row rendering. + +## Layout Rule + +- At normal terminal widths, the managed-services area uses a 50|50 split between list and details. +- At narrow widths, the layout may compress or rebalance, but it must preserve: + - visible state markers in the list + - a visible details or placeholder pane + - the primary failure headline when relevant + +## Invariants + +- Managed-service state must be visible directly in the managed list. +- When a managed service is selected, the list must remain visible beside the selected-service details pane. +- When no managed service is selected, the details pane must remain visible with a placeholder prompt. +- Symbol usage must be paired with text state so meaning does not depend on color alone. +- Service metadata must degrade gracefully when individual fields (CWD, command, ports) are empty or unset. +- Multi-port metadata must render in a compact format consistent with the list-view port presentation. +- Failure context must degrade gracefully when logs are unavailable. +- Existing keyboard-driven managed-service interactions must remain intact. +- Width pressure must remove secondary detail before removing primary state signals. +- Action buttons must be context-sensitive based on the selected service's state. +- Action buttons must be hidden or disabled when no service is selected. +- Action buttons must use icons that are recognizable independent of color (C3 compliance). +- Each section (running, managed list, details) must scroll independently without affecting other sections. +- Scroll position must persist per section across selection changes. + +## Runtime vs Test Scaffolding Separation + +- Runtime presentation logic lives in `table.go`, `helpers.go`, `deps.go`, and `tui_adapter.go`. +- Output assertions and regression expectations live in `tui_ui_test.go`. +- No test-only helpers should leak into the runtime rendering path. + +## E2E Decision + +- No browser or API E2E framework exists for this TUI flow. +- Acceptance remains spec-first through BDD scenarios and Go-based TUI rendering tests. +- If the project later adds TUI automation or higher-level acceptance tooling, that layer should reuse the same managed-state and split-pane behavioral contract rather than redefining it. + +## Extension Rule + +Future deeper diagnostics must extend the selected-service details pane through the TUI dependency boundary. +They should not bypass the current boundary by embedding process-manager calls directly into table rendering or by mixing lifecycle-capture decisions into list-presentation helpers. + +## Review Notes + +- This architecture keeps ownership concentrated in the TUI presentation layer because the problem is UX-first. +- A later lifecycle/exit-cause improvement can enrich the same details pane without invalidating list semantics. +- Constraint carryover from requirements is handled here through 50|50 split preservation, empty-state stability, keyboard-preservation, and symbol-plus-text readability invariants. diff --git a/.tickets/DEVPT-004/architecture.trace.md b/.tickets/DEVPT-004/architecture.trace.md new file mode 100644 index 0000000..2373cba --- /dev/null +++ b/.tickets/DEVPT-004/architecture.trace.md @@ -0,0 +1,49 @@ +# Architecture + +## Obligations + +- Action buttons must render in details pane header with proper styling and context sensitivity (`OBL-action-buttons-in-header`) + Derived From: `BR-8`, `BR-9` + Artifacts: `ART-tui-table-go`, `ART-tui-helpers-go`, `ART-tui-action-buttons-test-go` +- Details pane must show crash headline and recent logs for crashed services (`OBL-crash-context-display`) + Derived From: `BR-4`, `BR-5` + Artifacts: `ART-tui-table-go`, `ART-tui-helpers-go`, `ART-tui-managed-split-test-go` +- Each viewport must maintain independent scroll state (`OBL-independent-viewport-scroll`) + Derived From: `BR-10` + Artifacts: `ART-tui-table-go` +- Details pane must display service metadata (CWD, ports, command) (`OBL-managed-service-metadata-display`) + Derived From: `BR-7` + Artifacts: `ART-tui-table-go`, `ART-tui-helpers-go` +- Details pane must show placeholder when no service selected (`OBL-placeholder-details`) + Derived From: `BR-6` + Artifacts: `ART-tui-table-go`, `ART-tui-helpers-go` +- Managed services must render as 50|50 split when service selected (`OBL-split-pane-rendering`) + Derived From: `BR-3` + Artifacts: `ART-tui-table-go`, `ART-tui-managed-split-test-go` +- TUI adapter must expose latest managed service log path (`OBL-tui-log-path-bridge`) + Derived From: `BR-5` + Artifacts: `ART-cli-tui-adapter-go`, `ART-tui-deps-go` + +## Artifacts + +| Artifact ID | Path | Kind | Referencing Obligations | +|---|---|---|---| +| `ART-cli-tui-adapter-go` | `pkg/cli/tui_adapter.go` | runtime | `OBL-tui-log-path-bridge` | +| `ART-tui-action-buttons-test-go` | `pkg/cli/tui/tui_action_buttons_test.go` | test | `OBL-action-buttons-in-header` | +| `ART-tui-deps-go` | `pkg/cli/tui/deps.go` | runtime | `OBL-tui-log-path-bridge` | +| `ART-tui-helpers-go` | `pkg/cli/tui/helpers.go` | runtime | `OBL-action-buttons-in-header`, `OBL-crash-context-display`, `OBL-managed-service-metadata-display`, `OBL-placeholder-details` | +| `ART-tui-managed-split-test-go` | `pkg/cli/tui/tui_managed_split_test.go` | test | `OBL-crash-context-display`, `OBL-split-pane-rendering` | +| `ART-tui-table-go` | `pkg/cli/tui/table.go` | runtime | `OBL-action-buttons-in-header`, `OBL-crash-context-display`, `OBL-independent-viewport-scroll`, `OBL-managed-service-metadata-display`, `OBL-placeholder-details`, `OBL-split-pane-rendering` | + +## Derivation Summary + +| Requirement ID | Obligation Count | Obligation IDs | +|---|---:|---| +| `BR-3` | 1 | `OBL-split-pane-rendering` | +| `BR-4` | 1 | `OBL-crash-context-display` | +| `BR-5` | 2 | `OBL-crash-context-display`, `OBL-tui-log-path-bridge` | +| `BR-6` | 1 | `OBL-placeholder-details` | +| `BR-7` | 1 | `OBL-managed-service-metadata-display` | +| `BR-8` | 1 | `OBL-action-buttons-in-header` | +| `BR-9` | 1 | `OBL-action-buttons-in-header` | +| `BR-10` | 1 | `OBL-independent-viewport-scroll` | diff --git a/.tickets/DEVPT-004/bdd.md b/.tickets/DEVPT-004/bdd.md new file mode 100644 index 0000000..e1d7859 --- /dev/null +++ b/.tickets/DEVPT-004/bdd.md @@ -0,0 +1,41 @@ +# BDD + +## Overview + +This ticket has a spec-only BDD acceptance layer. +The project does not currently expose a browser or API E2E framework for this TUI workflow, so the scenarios serve as canonical acceptance behavior rather than executable end-to-end tests. +The scenarios focus on fast list-level recognition, 50|50 split-view selected-service details, placeholder behavior when nothing is selected, and failed-service triage context. + +## Acceptance Strategy + +- Journey 1: users scan the managed services list and identify service state immediately. +- Journey 2: users select a managed service and receive a 50|50 side-by-side details pane with service metadata (working directory, port(s), command) without losing the list context. +- Journey 3: users see a stable placeholder in the details pane when nothing is selected. +- Journey 4: users select a failed service and receive concise failure context for triage. +- Constraints and edge cases such as narrow width, missing logs, and stopped-vs-crashed semantics remain downstream responsibilities for architecture and tests. + +## Execution Notes + +- E2E framework: none detected +- Verification mode: Spec-Only +- Follow-up expectation: `/mdt:architecture` should preserve the split managed-services interaction model, include empty-state placeholder behavior, and route layout resilience and edge-state behavior to tests. +- Journey 5: users see working directory, port(s), and command for the selected service in the details pane, providing at-a-glance operational context. +- Journey 6: users see service metadata rendered before crash context when selecting a crashed service, confirming the rendering order. +- Journey 7: users can perform service actions (start/restart/stop) directly from the details pane via action buttons in the header. +- Journey 8: users can scroll each section independently to inspect long lists or detailed information without affecting other sections. +- Journey 9: users see relevant details in the details pane regardless of which section is focused — running services show their own details, managed services show their details. +- Scenario budget used: 13 of 12 (exceeded by 1, within tolerance) + +## Review Notes + +- `managed_list_shows_state_markers` covers the scanability outcome. +- `selected_service_shows_5050_split_pane` establishes the persistent two-pane managed-services interaction. +- `split_view_without_selection_shows_placeholder` protects the empty-selection UX from collapsing or becoming ambiguous. +- `selected_service_shows_service_metadata` ensures working directory, port(s), and command are visible in the details pane. +- `crashed_service_shows_metadata_alongside_crash_context` ensures metadata appears before crash context when a crashed service is selected. +- `details_header_shows_action_buttons` ensures action buttons appear in the details pane header. +- `action_buttons_are_context_sensitive` ensures buttons show appropriate actions based on service state. +- `sections_scroll_independently` ensures each section can be scrolled without affecting others. +- `running_service_shows_details_in_details_pane` ensures running services show their details in the details pane when focused. +- `managed_service_shows_details_in_details_pane` ensures managed services show their details in the details pane when focused. +- The two crashed-service scenarios separate headline visibility from log-context visibility so downstream implementation can keep both concerns independently testable. diff --git a/.tickets/DEVPT-004/bdd.trace.md b/.tickets/DEVPT-004/bdd.trace.md new file mode 100644 index 0000000..7a48981 --- /dev/null +++ b/.tickets/DEVPT-004/bdd.trace.md @@ -0,0 +1,123 @@ +# BDD + +## Scenarios By Requirement Family + +### BR-1 + +- Managed list shows service state markers (`managed_list_shows_state_markers`) + Covers: `BR-1`, `BR-2` + Given: the managed services list is displayed in the TUI + When: managed services exist in various states (running, starting, stopped, crashed) + Then: each service shows a distinct visual state marker (▶ for running, appropriate markers for other states) + +### BR-2 + +- Managed list shows service state markers (`managed_list_shows_state_markers`) + Covers: `BR-1`, `BR-2` + Given: the managed services list is displayed in the TUI + When: managed services exist in various states (running, starting, stopped, crashed) + Then: each service shows a distinct visual state marker (▶ for running, appropriate markers for other states) + +### BR-3 + +- Crashed service shows failure headline (`crashed_service_shows_failure_headline`) + Covers: `BR-3`, `BR-4` + Given: a crashed managed service is selected + When: the details pane renders + Then: a concise failure headline appears using the best available reason +- Crashed service shows recent log context (`crashed_service_shows_recent_log_context`) + Covers: `BR-3`, `BR-5` + Given: a crashed managed service with recent logs is selected + When: the details pane renders + Then: recent log context appears to help users triage the failure +- Selected managed service shows 50|50 split details pane (`selected_service_shows_5050_split_pane`) + Covers: `BR-3` + Given: a managed service is selected in the TUI + When: the managed services area renders + Then: the area splits 50|50 with the list on one side and service details on the other + +### BR-4 + +- Crashed service shows failure headline (`crashed_service_shows_failure_headline`) + Covers: `BR-3`, `BR-4` + Given: a crashed managed service is selected + When: the details pane renders + Then: a concise failure headline appears using the best available reason +- Crashed service shows metadata alongside crash context (`crashed_service_shows_metadata_alongside_crash_context`) + Covers: `BR-7`, `BR-4`, `BR-5` + Given: a crashed managed service with metadata is selected + When: the details pane renders + Then: metadata appears before crash context in the details pane + +### BR-5 + +- Crashed service shows metadata alongside crash context (`crashed_service_shows_metadata_alongside_crash_context`) + Covers: `BR-7`, `BR-4`, `BR-5` + Given: a crashed managed service with metadata is selected + When: the details pane renders + Then: metadata appears before crash context in the details pane +- Crashed service shows recent log context (`crashed_service_shows_recent_log_context`) + Covers: `BR-3`, `BR-5` + Given: a crashed managed service with recent logs is selected + When: the details pane renders + Then: recent log context appears to help users triage the failure + +### BR-6 + +- Split view without selection shows placeholder (`split_view_without_selection_shows_placeholder`) + Covers: `BR-6` + Given: the managed services split view is visible + When: no managed service is selected + Then: the details pane shows a placeholder prompting the user to select a service + +### BR-7 + +- Crashed service shows metadata alongside crash context (`crashed_service_shows_metadata_alongside_crash_context`) + Covers: `BR-7`, `BR-4`, `BR-5` + Given: a crashed managed service with metadata is selected + When: the details pane renders + Then: metadata appears before crash context in the details pane +- Selected managed service shows service metadata (`selected_service_shows_service_metadata`) + Covers: `BR-7` + Given: a managed service is selected in the split view + When: the details pane renders + Then: the service's working directory, port(s), and command are displayed + +### BR-8 + +- Details header shows action buttons (`details_header_shows_action_buttons`) + Covers: `BR-8` + Given: a managed service is selected in the TUI + When: the details pane header renders + Then: action buttons (start/restart, stop) appear in the details pane header + +### BR-9 + +- Action buttons are context-sensitive (`action_buttons_are_context_sensitive`) + Covers: `BR-9` + Given: a managed service is selected with a specific state (stopped, running, or crashed) + When: the details pane header renders action buttons + Then: buttons show appropriate actions for the service state (start for stopped, restart for running/crashed, stop for running) + +### BR-10 + +- Sections scroll independently (`sections_scroll_independently`) + Covers: `BR-10` + Given: the TUI displays running processes, managed list, and details pane + When: user scrolls in one section + Then: only that section scrolls while others maintain their scroll position + +## Coverage Summary + +| Requirement ID | Scenario Count | Scenario IDs | +|---|---:|---| +| `BR-1` | 1 | `managed_list_shows_state_markers` | +| `BR-2` | 1 | `managed_list_shows_state_markers` | +| `BR-3` | 3 | `crashed_service_shows_failure_headline`, `crashed_service_shows_recent_log_context`, `selected_service_shows_5050_split_pane` | +| `BR-4` | 2 | `crashed_service_shows_failure_headline`, `crashed_service_shows_metadata_alongside_crash_context` | +| `BR-5` | 2 | `crashed_service_shows_metadata_alongside_crash_context`, `crashed_service_shows_recent_log_context` | +| `BR-6` | 1 | `split_view_without_selection_shows_placeholder` | +| `BR-7` | 2 | `crashed_service_shows_metadata_alongside_crash_context`, `selected_service_shows_service_metadata` | +| `BR-8` | 1 | `details_header_shows_action_buttons` | +| `BR-9` | 1 | `action_buttons_are_context_sensitive` | +| `BR-10` | 1 | `sections_scroll_independently` | diff --git a/.tickets/DEVPT-004/requirements.md b/.tickets/DEVPT-004/requirements.md new file mode 100644 index 0000000..0fc7199 --- /dev/null +++ b/.tickets/DEVPT-004/requirements.md @@ -0,0 +1,44 @@ +# Requirements: DEVPT-004 + +**Source**: [DEVPT-004](../DEVPT-004-investigate-split-managed-services-and-detailed-st.md) +**Generated**: 2026-04-02 + +## Overview + +This ticket improves how managed service state is communicated inside the managed-services area of the TUI. +The primary outcome is faster visual triage: users should be able to spot problematic services immediately and, once a service is selected, inspect its details in a 50|50 split view without leaving the managed services surface. +The requirements intentionally stay at the UX and behavior level and defer lifecycle-capture implementation choices to architecture. + +## Constraint Carryover + +| Constraint ID | Must Appear In | +|---------------|----------------| +| C1 | architecture.md (50|50 split and narrow-width fallback), tests.md (terminal width coverage), tasks.md (verification) | +| C2 | architecture.md (interaction preservation), tests.md (keyboard regression coverage), tasks.md (do-not-break scope) | +| C3 | architecture.md (status presentation rules), tests.md (theme/readability coverage), tasks.md (UX verification) | + +## Semantic Decisions + +- State recognition: the managed list must communicate state directly in the list view; users should not need a separate screen just to know whether a service is healthy or failed. +- Selection behavior: selecting a managed service should keep the list visible and open a side-by-side details pane rather than replacing the list with a separate full-width detail block. +- Split proportion: at normal terminal widths, the managed-services split should be treated as a 50|50 layout between list and details. +- Empty selection: when no service is selected, the details pane should remain visible and show a placeholder prompt rather than collapsing away. +- Service metadata: the details pane must display key operational metadata for the selected service — working directory, port(s), and command — so users can inspect service configuration at a glance without leaving the TUI. Metadata is a content concern separate from the split-view structure (BR-3). +- Failure context: the minimum useful failed-state context is a short headline plus recent log context when available; full diagnostics remain the responsibility of deeper views and later stages. +- Stopped vs. crashed: intentionally stopped services and failed services are different user-facing states and must not be conflated in the managed service experience. +- Narrow layouts: when width is limited, the state marker and primary failure signal take precedence over secondary details. +- Action buttons: the details pane header must include context-sensitive action buttons (start/restart, stop, edit) that allow users to manage the selected service directly from the details view. +- Button styling: action buttons must use icons and colors to make them visually distinct and recognizable across different terminal color schemes. +- Independent scrolling: each section (running processes, managed list, details pane) must scroll independently to allow users to inspect long lists or detailed information without losing context in other sections. + +## Review Notes + +- The requirement set remains brief because the CR already defines the user-facing goal clearly. +- BDD should focus on list recognition, 50|50 split-view selection behavior, placeholder details behavior, and failed-service context visibility. +- Architecture should define exactly how the 50|50 split degrades gracefully when width is constrained. +- Service metadata display is additive to existing details-pane content and should not displace crash context or state information. +- BR-3 owns the split-view structural contract (50|50 layout, list persistence, details pane existence). BR-7 owns what content appears in the details pane. +- BR-8 owns action button rendering in the details header. BR-9 owns button context-sensitivity. BR-10 owns independent scrolling behavior. + +--- +Use `requirements.trace.md` for canonical requirement rows and route summaries. diff --git a/.tickets/DEVPT-004/requirements.trace.md b/.tickets/DEVPT-004/requirements.trace.md new file mode 100644 index 0000000..2d10917 --- /dev/null +++ b/.tickets/DEVPT-004/requirements.trace.md @@ -0,0 +1,75 @@ +# Requirements + +Ticket: `DEVPT-004` + +## Behavioral Requirements + +### BR-1 + +- `BR-1` [bdd] WHEN the managed services list is shown in the TUI, the system shall display a distinct visual state marker for each managed service state: running (using a play marker `▶`), starting, stopped, and crashed. + +### BR-2 + +- `BR-2` [bdd] WHEN users view the managed services list in the TUI, the system shall make running, starting, stopped, and crashed services distinguishable without requiring navigation to another screen. + +### BR-3 + +- `BR-3` [bdd] WHEN a managed service is selected in the TUI, the system shall present the managed-services area as a split view with a 50|50 list pane and details pane for the selected service. + +### BR-4 + +- `BR-4` [bdd] IF the selected managed service is crashed, THEN the system shall display a concise failure headline using the best available reason. + +### BR-5 + +- `BR-5` [bdd] IF the selected managed service is crashed and recent logs are available, THEN the system shall display recent log context that helps users decide whether to restart the service or inspect logs further. + +### BR-6 + +- `BR-6` [bdd] WHEN no managed service is selected in the split view, the system shall show a placeholder details pane that prompts the user to select a managed service for inspection. + +### BR-7 + +- `BR-7` [bdd] WHEN a managed service is selected in the split view, the system shall display the service's working directory, port(s), and command in the details pane so users can inspect service configuration at a glance. + +### BR-8 + +- `BR-8` [bdd] WHEN a managed service is selected in the details pane, the system shall display action buttons (start/restart, stop) in the details pane header for managing the service directly. + +### BR-9 + +- `BR-9` [bdd] WHEN action buttons are displayed in the details pane header, the system shall show appropriate buttons based on the service state: start for stopped services, restart for running/crashed services, stop for running services. + +### BR-10 + +- `BR-10` [bdd] WHEN users scroll in the TUI, each section (running processes, managed list, details pane) shall scroll independently without affecting the scroll position of other sections. + +### BR-11 + +- `BR-11` [bdd] WHEN a service is selected in the TUI (either running or managed), the system shall display relevant details for that service in the details pane, with the content tailored to the service type: running services show PID, port, command, directory, project, start time, agent info, and health status; managed services show their existing details format. + +## Constraints + +- `C1` [tests] The managed services area shall remain readable within standard terminal sizes, using a 50|50 split at normal widths and degrading gracefully at narrow widths while preserving list scanability and selected-service context without obscuring primary state signals. +- `C2` [tests] The TUI shall preserve existing keyboard-driven managed service interactions while adding status markers and state details. +- `C3` [tests] Status markers shall be understandable using both symbol shape and text state so that service state remains recognizable across common terminal themes; the running state shall use a play marker (`▶`). + +## Edge Cases + +- `Edge-1` [tests] IF a crashed managed service has no captured logs, THEN the system shall still show its crashed state and present the best available failure context without leaving the details area blank. +- `Edge-2` [tests] IF a managed service was intentionally stopped by the user, THEN the system shall present it as stopped rather than crashed in the managed service view. +- `Edge-3` [tests] IF a managed service exits immediately after start, THEN the system shall surface a non-running state and the best available context in the managed service details area. +- `Edge-4` [tests] IF terminal width is insufficient for full managed service details, THEN the system shall truncate or compress details while preserving the service state marker and primary failure headline. +- `Edge-5` [tests] IF a managed service has empty or unset metadata fields (CWD, command, ports), THEN the system shall still render the details pane gracefully without displaying blank lines for missing fields. +- `Edge-6` [tests] IF a managed service has multiple ports, THEN the details pane shall render all ports in a compact format without duplicating the list-view port summary. +- `Edge-7` [tests] IF no service is selected in the details pane, the system shall hide or disable action buttons to prevent invalid actions. +- `Edge-8` [tests] IF a service is in a transition state (starting, stopping), the system shall handle action button state appropriately to prevent conflicting actions. + +## Route Policy Summary + +| Route | Count | IDs | +|---|---:|---| +| bdd | 10 | `BR-1`, `BR-2`, `BR-3`, `BR-4`, `BR-5`, `BR-6`, `BR-7`, `BR-8`, `BR-9`, `BR-10` | +| tests | 11 | `C1`, `C2`, `C3`, `Edge-1`, `Edge-2`, `Edge-3`, `Edge-4`, `Edge-5`, `Edge-6`, `Edge-7`, `Edge-8` | +| clarification | 0 | - | +| not_applicable | 0 | - | diff --git a/.tickets/DEVPT-004/tasks.md b/.tickets/DEVPT-004/tasks.md new file mode 100644 index 0000000..f71c8c5 --- /dev/null +++ b/.tickets/DEVPT-004/tasks.md @@ -0,0 +1,448 @@ +# Tasks: DEVPT-004 + +**Source**: canonical architecture/tests/bdd state + `tasks.trace.md` for trace cross-checking + +## Scope Boundaries + +- Managed services split view: keep the managed list visible while rendering selected-service details beside it. +- Crash diagnostics in TUI: use existing best-available reason and latest-log bridge; do not redesign process supervision. +- Interaction preservation: keep current keyboard-driven managed-service actions intact while adding split-view behavior. +- Pointer interaction preservation: mouse clicks in both running and managed sections must resolve to the exact rendered row without ±1 drift. + +## Ownership Guardrails + +| Critical Behavior | Owner Module | Merge/Refactor Task if Overlap | +|-------------------|--------------|--------------------------------| +| latest managed log path bridge | `pkg/cli/tui_adapter.go` + `pkg/cli/tui/deps.go` | Task 1 | +| split managed-services layout | `pkg/cli/tui/table.go` | Task 2 | +| managed status presentation + crash details | `pkg/cli/tui/helpers.go` + `pkg/cli/tui/table.go` | Task 3 | + +## Constraint Coverage + +| Constraint ID | Tasks | +|---------------|-------| +| C1 | Task 2, Task 3, Task 4, Task 7 | +| C2 | Task 2, Task 3, Task 7 | +| C3 | Task 3, Task 7 | + +## Milestones + +| Milestone | BDD Scenarios | Tasks | Checkpoint | +|-----------|---------------|-------|------------| +| M1: diagnostics bridge | — | Task 1 | `TEST-tui-adapter-log-path-bridge` GREEN | +| M2: split selection UX | `selected_service_shows_5050_split_pane`, `split_view_without_selection_shows_placeholder` | Task 2 | split pane + placeholder behavior GREEN | +| M3: crash context and final polish | `managed_list_shows_state_markers`, `crashed_service_shows_failure_headline`, `crashed_service_shows_recent_log_context` | Task 3 | status markers + crash context GREEN | +| M4: service metadata | `selected_service_shows_service_metadata`, `crashed_service_shows_metadata_alongside_crash_context` | Task 4 | service metadata (CWD, ports, command) GREEN | +| M5: action buttons | `details_header_shows_action_buttons`, `action_buttons_are_context_sensitive` | Task 5 | action buttons in details header GREEN | +| M6: independent scrolling | `sections_scroll_independently` | Task 6 | independent scroll verification GREEN | +| M7: universal details pane | `running_service_shows_details_in_details_pane`, `managed_service_shows_details_in_details_pane`, `no_selection_shows_placeholder` | Task 7 | universal details pane GREEN | + +## Architecture Coverage + +| Layer | Arch Files | In Tasks | Gap | Status | +|-------|-----------:|---------:|----:|--------| +| `pkg/cli/` | 1 | 1 | 0 | ✅ | +| `pkg/cli/tui/` | 4 | 4 | 0 | ✅ | + +## Tasks + +### Task 1: Wire managed diagnostics bridge + +**Milestone**: M1 — diagnostics bridge + +**Structure**: `pkg/cli/tui_adapter.go`, `pkg/cli/tui/deps.go` + +**Makes GREEN (Automated Tests)**: +- `TEST-tui-adapter-log-path-bridge` → `pkg/cli/tui_adapter_test.go`: latest managed log path is exposed to the TUI + +**Scope**: Finalize the TUI-facing dependency bridge for managed log-path access and keep the boundary narrow. +**Boundary**: Adapter and dependency interface only; no table layout work in this task. + +**Creates**: +- none + +**Modifies**: +- `pkg/cli/tui_adapter.go` +- `pkg/cli/tui/deps.go` + +**Must Not Touch**: +- `pkg/cli/tui/table.go` +- `pkg/cli/tui/helpers.go` +- `pkg/cli/tui/tui_ui_test.go` + +**Create/Move**: +- Ensure the TUI dependency contract exposes latest managed log path access +- Keep CLI-to-TUI adaptation free of presentation logic + +**Exclude**: No split-layout rendering, no placeholder copy, no status-marker changes. + +**Anti-duplication**: Reuse existing process-manager log-path APIs through the adapter — do NOT create a parallel log lookup path. + +**Duplication Guard**: +- Check whether log-path lookup already exists in the CLI application layer before adding helpers +- If adaptation logic duplicates process-manager behavior, merge back into the adapter instead of creating another runtime owner + +**Verify**: +```bash +go test ./pkg/cli -run TestTUIAdapterLatestServiceLogPath -count=1 +``` + +**Done when**: +- [x] `TEST-tui-adapter-log-path-bridge` is GREEN +- [x] Adapter boundary stays presentation-free +- [x] No duplicate log lookup path is introduced + +### Task 2: Build split selection pane + +**Milestone**: M2 — split selection UX (BR-3, BR-6) + +**Structure**: `pkg/cli/tui/table.go`, `pkg/cli/tui/helpers.go` + +**Makes GREEN (Automated Tests)**: +- `TEST-managed-keyboard-interactions` → `pkg/cli/tui/tui_state_test.go`: managed keyboard interaction regression remains GREEN + +**Makes GREEN (Behavior)**: +- `selected_service_shows_5050_split_pane` → `pkg/cli/tui/tui_managed_split_test.go` (BR-3) +- `split_view_without_selection_shows_placeholder` → `pkg/cli/tui/tui_managed_split_test.go` (BR-6) + +**Scope**: Implement the managed-services 50|50 split behavior and stable placeholder details pane. +**Boundary**: Split layout, selected-service projection, and placeholder rendering only. + +**Creates**: +- none + +**Modifies**: +- `pkg/cli/tui/table.go` +- `pkg/cli/tui/helpers.go` + +**Must Not Touch**: +- `pkg/cli/tui_adapter.go` +- `pkg/cli/tui/deps.go` +- `pkg/cli/tui/tui_ui_test.go` + +**Create/Move**: +- Render the managed-services area as a two-pane 50|50 layout at normal widths +- Keep the list visible while switching the details side based on selection state +- Show a placeholder prompt when no managed service is selected +- Preserve exact mouse row-to-selection mapping while introducing the split layout + +**Exclude**: No process-lifecycle redesign, no new CLI status output work, no deeper crash-cause model. + +**Anti-duplication**: Use shared helper functions for selection-state rendering and placeholder copy — do NOT duplicate pane logic in multiple render branches. + +**Duplication Guard**: +- Check existing managed-section rendering paths before adding pane branches +- If similar placeholder logic exists elsewhere, merge into shared helpers rather than creating a second placeholder owner + +**Verify**: +```bash +go test ./pkg/cli/tui -run 'TestManagedSplitView|TestTUISimpleUpdate|TestTUIKeySequence' -count=1 +``` + +**Done when**: +- [x] `selected_service_shows_5050_split_pane` is GREEN +- [x] `split_view_without_selection_shows_placeholder` is GREEN +- [x] `TEST-managed-keyboard-interactions` remains GREEN +- [x] Mouse clicks select the exact rendered managed row without ±1 drift +- [x] The list stays visible beside the details pane +- [x] No duplicate selection-rendering path is introduced + +### Task 3: Finish crash context and UI regression coverage + +**Milestone**: M3 — crash context and final polish (BR-1, BR-2, BR-4, BR-5) + +**Structure**: `pkg/cli/tui/table.go`, `pkg/cli/tui/helpers.go`, `pkg/cli/tui/tui_ui_test.go` + +**Makes GREEN (Automated Tests)**: +- `TEST-managed-status-markers-ui` → `pkg/cli/tui/tui_ui_test.go`: state markers and crash context rendering +- `TEST-managed-split-view-ui` → `pkg/cli/tui/tui_managed_split_test.go`: split-view crash details and narrow-width preservation + +**Makes GREEN (Behavior)**: +- `managed_list_shows_state_markers` → `pkg/cli/tui/tui_ui_test.go` (BR-1, BR-2) +- `crashed_service_shows_failure_headline` → `pkg/cli/tui/tui_managed_split_test.go` (BR-4) +- `crashed_service_shows_recent_log_context` → `pkg/cli/tui/tui_managed_split_test.go` (BR-5) + +**Scope**: Complete status-symbol presentation, crash headline rendering, recent log context rendering, and final regression coverage. +**Boundary**: Final managed-service presentation and tests only. + +**Creates**: +- none + +**Modifies**: +- `pkg/cli/tui/table.go` +- `pkg/cli/tui/helpers.go` +- `pkg/cli/tui/tui_ui_test.go` + +**Must Not Touch**: +- `pkg/cli/tui_adapter.go` +- `pkg/cli/tui/deps.go` + +**Create/Move**: +- Ensure symbols and text state remain visible together in the managed list +- Use a play marker (`▶`) for running/active state presentation +- Render crash headline, log path, and compact recent tail in the details pane +- Expand UI regression coverage for standard and narrow widths +- Keep selected managed-row highlight applied to the full row, not only the state symbol + +**Exclude**: No changes to process manager, registry schema, or CLI `status` output semantics. + +**Anti-duplication**: Reuse existing crash-reason and log-tail helpers — do NOT add a second failure-summary formatter. + +**Duplication Guard**: +- Check for existing crash-summary formatting before adding new presentation helpers +- If row rendering and details rendering diverge in state mapping, consolidate through shared helpers immediately + +**Verify**: +```bash +go test ./pkg/cli/tui -run 'TestManagedSplitView|TestView_ManagedCrashContextAndSymbols|TestView_ManagedServicesSection' -count=1 +``` + +**Done when**: +- [x] `managed_list_shows_state_markers` is GREEN +- [x] `crashed_service_shows_failure_headline` is GREEN +- [x] `crashed_service_shows_recent_log_context` is GREEN +- [x] `TEST-managed-status-markers-ui` is GREEN +- [x] `TEST-managed-split-view-ui` is GREEN +- [x] Selected managed-row highlight covers the full row +- [x] No duplicate crash-summary path is introduced + +### Task 4: Add service metadata to details pane + +**Milestone**: M4 — service metadata (BR-7, Edge-5, Edge-6) + +**Structure**: `pkg/cli/tui/table.go`, `pkg/cli/tui/helpers.go` + +**Makes GREEN (Automated Tests)**: +- `TEST-managed-service-metadata-ui` → `pkg/cli/tui/tui_managed_split_test.go`: working directory, port(s), and command are visible in the details pane + +**Makes GREEN (Behavior)**: +- `selected_service_shows_service_metadata` → `pkg/cli/tui/tui_managed_split_test.go` (BR-7) + +**Scope**: Display operational metadata (working directory, port(s), command) in the details pane for the selected managed service. +**Boundary**: Details pane content only; no layout or interaction changes. + +**Creates**: +- none + +**Modifies**: +- `pkg/cli/tui/table.go` +- `pkg/cli/tui/helpers.go` + +**Must Not Touch**: +- `pkg/cli/tui_adapter.go` +- `pkg/cli/tui/deps.go` +- `pkg/cli/tui/tui_ui_test.go` + +**Create/Move**: +- Render working directory (`CWD`), port(s), and command from `ManagedService` in the details pane +- Place metadata after the state line and before any crash-specific context +- Omit individual fields gracefully when empty or unset (no blank lines) + +**Exclude**: No changes to split layout proportions, no new dependency bridge methods, no process-lifecycle changes. + +**Anti-duplication**: Reuse existing `fitLine` and `ManagedService` data access — do NOT create a separate metadata formatter. + +**Duplication Guard**: +- Check whether metadata fields are already accessible through `selectedManagedService()` or `serverInfoForService()` before adding new access patterns +- If similar field rendering exists elsewhere (e.g., running service details), align the format + +**Verify**: +```bash +go test ./pkg/cli/tui -run 'TestManagedSplitView.*Metadata|TestManagedSplitView.*ServiceMetadata' -count=1 +``` + +**Done when**: +- [x] `selected_service_shows_service_metadata` is GREEN +- [x] `crashed_service_shows_metadata_alongside_crash_context` is GREEN +- [x] `TEST-managed-service-metadata-ui` is GREEN +- [x] `TEST-managed-split-view-ui` remains GREEN +- [x] Empty CWD, command, or ports do not produce blank lines (Edge-5) +- [x] Multi-port metadata renders compactly (Edge-6) +- [x] Metadata appears after source and before crash context +- [x] No duplicate metadata access pattern is introduced + +### Task 5: Add action buttons to details pane header + +**Milestone**: M5 — action buttons (BR-8, BR-9, Edge-7, Edge-8) + +**Structure**: `pkg/cli/tui/table.go`, `pkg/cli/tui/helpers.go`, `pkg/cli/tui/model.go`, `pkg/cli/tui/commands.go` + +**Makes GREEN (Automated Tests)**: +- `TEST-action-buttons-ui` → `pkg/cli/tui/tui_action_buttons_test.go`: action buttons render correctly with proper styling and icons + +**Makes GREEN (Behavior)**: +- `details_header_shows_action_buttons` → `pkg/cli/tui/tui_action_buttons_test.go` (BR-8) +- `action_buttons_are_context_sensitive` → `pkg/cli/tui/tui_action_buttons_test.go` (BR-9) + +**Scope**: Render action buttons in the details pane header and wire them to service actions. +**Boundary**: Details pane header only; button click handling and action triggering. + +**Creates**: +- `pkg/cli/tui/tui_action_buttons_test.go` + +**Modifies**: +- `pkg/cli/tui/table.go` +- `pkg/cli/tui/helpers.go` +- `pkg/cli/tui/model.go` +- `pkg/cli/tui/commands.go` + +**Must Not Touch**: +- `pkg/cli/tui_adapter.go` +- `pkg/cli/tui/deps.go` + +**Create/Move**: +- Add button rendering logic to `renderManagedDetails()` header +- Create button style helpers with icons and colors +- Add button click detection via mouse position tracking +- Wire button clicks to existing service action commands (start/stop/restart) +- Handle context-sensitive button visibility (start vs restart) + +**Exclude**: No changes to process manager, no new service actions, no keyboard shortcut implementation (unless specified). + +**Anti-duplication**: Reuse existing service action commands — do NOT create parallel action paths. + +**Duplication Guard**: +- Check existing command structure before adding new action handlers +- If similar button logic exists elsewhere, consolidate into shared helpers + +**Verify**: +```bash +go test ./pkg/cli/tui -run 'TestActionButtons' -count=1 +``` + +**Done when**: +- [ ] `details_header_shows_action_buttons` is GREEN +- [ ] `action_buttons_are_context_sensitive` is GREEN +- [ ] `TEST-action-buttons-ui` is GREEN +- [ ] Buttons show correct icons and colors +- [ ] Button clicks trigger correct service actions +- [ ] Buttons are hidden/disabled when no service is selected (Edge-7) +- [ ] Buttons handle transition states correctly (Edge-8) + +### Task 6: Verify and refine independent scrolling + +**Milestone**: M6 — independent scrolling (BR-10) + +**Structure**: `pkg/cli/tui/table.go`, `pkg/cli/tui/tui_viewport_test.go` + +**Makes GREEN (Automated Tests)**: +- `TEST-independent-scroll` → `pkg/cli/tui/tui_viewport_test.go`: each section scrolls independently + +**Makes GREEN (Behavior)**: +- `sections_scroll_independently` → `pkg/cli/tui/tui_viewport_test.go` (BR-10) + +**Scope**: Verify that all three viewports scroll independently and refine if necessary. +**Boundary**: Viewport interaction routing only. + +**Creates**: +- none + +**Modifies**: +- `pkg/cli/tui/table.go` +- `pkg/cli/tui/tui_viewport_test.go` + +**Must Not Touch**: +- `pkg/cli/tui_adapter.go` +- `pkg/cli/tui/deps.go` +- `pkg/cli/tui/helpers.go` + +**Create/Move**: +- Verify mouse scroll routing to correct viewport +- Verify keyboard scroll affects only focused section +- Add tests for independent scroll behavior +- Fix any cross-viewport scroll interference + +**Exclude**: No layout changes, no selection changes, no content changes. + +**Anti-duplication**: Use existing viewport models — do NOT create new scroll mechanisms. + +**Duplication Guard**: +- Check existing viewport update logic before adding new handlers +- Ensure scroll routing doesn't duplicate selection routing + +**Verify**: +```bash +go test ./pkg/cli/tui -run 'TestViewport.*Independent' -count=1 +``` + +**Done when**: +- [ ] `sections_scroll_independently` is GREEN +- [ ] `TEST-independent-scroll` is GREEN +- [ ] Scrolling one section does not affect others +- [ ] Scroll positions persist across selection changes +- [ ] Mouse scroll routed to correct viewport +- [ ] Keyboard scroll affects only focused section + +### Task 7: Make details pane universal for running and managed services + +**Milestone**: M7 — universal details pane (BR-11, Edge-9) + +**Structure**: `pkg/cli/tui/table.go`, `pkg/cli/tui/helpers.go` + +**Makes GREEN (Automated Tests)**: +- `TEST-universal-details-pane-ui` → `pkg/cli/tui/tui_managed_split_test.go`: details pane shows appropriate content based on focus and selection + +**Makes GREEN (Behavior)**: +- `running_service_shows_details_in_details_pane` → `pkg/cli/tui/tui_managed_split_test.go` (BR-11) +- `managed_service_shows_details_in_details_pane` → `pkg/cli/tui/tui_managed_split_test.go` (BR-11) +- `no_selection_shows_placeholder` → `pkg/cli/tui/tui_managed_split_test.go` (Edge-9) + +**Scope**: Make the details pane work for both running and managed services based on which section is currently focused. +**Boundary**: Details pane content selection only; no layout changes. + +**Creates**: +- none + +**Modifies**: +- `pkg/cli/tui/table.go` + +**Must Not Touch**: +- `pkg/cli/tui_adapter.go` +- `pkg/cli/tui/deps.go` +- `pkg/cli/tui/helpers.go` (selection logic only) + +**Create/Move**: +- Rename `managedDetailsVP` to `selectedDetailsVP` for semantic clarity +- Rename `renderManagedDetails()` to `renderSelectedServiceDetails()` +- Add focus-based logic to determine which service details to show: + - When `focusRunning` and a running service is selected, show running service details (PID, port, command, directory, project, start time, agent info, health, crash info) + - When `focusManaged` and a managed service is selected, show managed service details (existing behavior) + - When no service is selected in the focused section, show appropriate placeholder + +**Exclude**: No layout changes, no viewport structure changes, no new data models. + +**Anti-duplication**: Reuse existing service information access methods — do NOT create parallel lookup paths. + +**Duplication Guard**: +- Check existing service data access patterns before adding new helpers +- Ensure running service details don't duplicate managed service details rendering +- Maintain single source of truth for service information display + +**Verify**: +```bash +go test ./pkg/cli/tui -run 'TestManagedSplitView|TestUniversalDetailsPane' -count=1 +``` + +**Done when**: +- [x] `running_service_shows_details_in_details_pane` is GREEN +- [x] `managed_service_shows_details_in_details_pane` is GREEN +- [x] `no_selection_shows_placeholder` is GREEN +- [x] `TEST-universal-details-pane-ui` is GREEN +- [x] Running services show PID, port, command, directory, project, start time, agent info, health, crash info +- [x] Managed services maintain existing details display +- [x] Placeholder shown when no service selected in focused section (Edge-9) +- [x] No duplicate service data access patterns introduced +- [x] Semantic naming matches universal behavior + +## Post-Implementation + +- [x] No duplication (grep check) +- [x] Scope boundaries respected +- [x] All unit/integration tests GREEN +- [x] All BDD scenarios GREEN +- [ ] Smoke test passes through the TUI flow +- [x] Fallback and narrow-width behavior matches requirements +- [x] Universal details pane implemented (commit 097772f) +- [x] Running services show comprehensive details +- [x] Managed services maintain existing details behavior +- [x] Placeholder shows appropriate message based on focus diff --git a/.tickets/DEVPT-004/tasks.trace.md b/.tickets/DEVPT-004/tasks.trace.md new file mode 100644 index 0000000..4712693 --- /dev/null +++ b/.tickets/DEVPT-004/tasks.trace.md @@ -0,0 +1,55 @@ +# Tasks + +## Task List + +- Wire managed diagnostics bridge (`TASK-1`) + Owns: `ART-cli-tui-adapter-go`, `ART-tui-deps-go` + Makes Green: `TEST-managed-split-view-ui` +- Build split selection pane (`TASK-2`) + Owns: `ART-tui-helpers-go`, `ART-tui-table-go` + Makes Green: `selected_service_shows_5050_split_pane`, `split_view_without_selection_shows_placeholder`, `TEST-managed-keyboard-interactions` +- Finish crash context and UI regression coverage (`TASK-3`) + Owns: `ART-tui-helpers-go`, `ART-tui-managed-split-test-go`, `ART-tui-table-go` + Makes Green: `crashed_service_shows_failure_headline`, `crashed_service_shows_recent_log_context`, `managed_list_shows_state_markers`, `TEST-immediate-exit-service`, `TEST-managed-split-view-ui`, `TEST-managed-status-markers-ui` +- Add service metadata to details pane (`TASK-4`) + Owns: `ART-tui-helpers-go`, `ART-tui-managed-split-test-go`, `ART-tui-table-go` + Makes Green: `crashed_service_shows_metadata_alongside_crash_context`, `selected_service_shows_service_metadata`, `TEST-managed-service-metadata-ui` +- Add action buttons to details pane header (`TASK-5`) + Owns: `ART-tui-action-buttons-test-go`, `ART-tui-helpers-go`, `ART-tui-table-go` + Makes Green: `action_buttons_are_context_sensitive`, `details_header_shows_action_buttons`, `TEST-action-buttons-ui` +- Verify and refine independent scrolling (`TASK-6`) + Owns: `ART-tui-table-go` + Makes Green: `sections_scroll_independently`, `TEST-independent-scroll` + +## Artifact Ownership Summary + +| Artifact ID | Owning Task IDs | +|---|---| +| `ART-cli-tui-adapter-go` | `TASK-1` | +| `ART-tui-action-buttons-test-go` | `TASK-5` | +| `ART-tui-deps-go` | `TASK-1` | +| `ART-tui-helpers-go` | `TASK-2`, `TASK-3`, `TASK-4`, `TASK-5` | +| `ART-tui-managed-split-test-go` | `TASK-3`, `TASK-4` | +| `ART-tui-table-go` | `TASK-2`, `TASK-3`, `TASK-4`, `TASK-5`, `TASK-6` | + +## Makes Green Summary + +| ID | Task IDs | +|---|---| +| `action_buttons_are_context_sensitive` | `TASK-5` | +| `crashed_service_shows_failure_headline` | `TASK-3` | +| `crashed_service_shows_metadata_alongside_crash_context` | `TASK-4` | +| `crashed_service_shows_recent_log_context` | `TASK-3` | +| `details_header_shows_action_buttons` | `TASK-5` | +| `managed_list_shows_state_markers` | `TASK-3` | +| `sections_scroll_independently` | `TASK-6` | +| `selected_service_shows_5050_split_pane` | `TASK-2` | +| `selected_service_shows_service_metadata` | `TASK-4` | +| `split_view_without_selection_shows_placeholder` | `TASK-2` | +| `TEST-action-buttons-ui` | `TASK-5` | +| `TEST-immediate-exit-service` | `TASK-3` | +| `TEST-independent-scroll` | `TASK-6` | +| `TEST-managed-keyboard-interactions` | `TASK-2` | +| `TEST-managed-service-metadata-ui` | `TASK-4` | +| `TEST-managed-split-view-ui` | `TASK-1`, `TASK-3` | +| `TEST-managed-status-markers-ui` | `TASK-3` | diff --git a/.tickets/DEVPT-004/tests.md b/.tickets/DEVPT-004/tests.md new file mode 100644 index 0000000..8542e89 --- /dev/null +++ b/.tickets/DEVPT-004/tests.md @@ -0,0 +1,68 @@ +# Tests: DEVPT-004 + +**Source**: canonical requirements, BDD, and architecture trace state +**Generated**: 2026-04-02 + +## Module → Test Mapping + +| Module | Test File | Purpose | +|--------|-----------|---------| +| `pkg/cli/tui/table.go` + `pkg/cli/tui/helpers.go` | `pkg/cli/tui/tui_managed_split_test.go` | Split view rendering, placeholder pane, stopped vs crashed semantics, narrow-width signal preservation, selected managed-row full-line highlight coverage, and service metadata display | +| `pkg/cli/tui/table.go` + `pkg/cli/tui/helpers.go` | `pkg/cli/tui/tui_ui_test.go` | Existing UI regression coverage for status markers and crash context rendering, including the running-state play marker | +| `pkg/cli/tui/update.go` + `pkg/cli/tui/helpers.go` | `pkg/cli/tui/tui_viewport_test.go` | Mouse interaction regression coverage for exact row selection in running and managed sections, including viewport-offset cases | +| `pkg/cli/tui/update.go` interaction contract | `pkg/cli/tui/tui_state_test.go` | Keyboard interaction regression coverage for managed-service navigation | +| `pkg/cli/tui_adapter.go` + `pkg/cli/tui/deps.go` | `pkg/cli/tui_adapter_test.go` | Real integration coverage for latest managed log path bridging | + +## Data Mechanism Tests + +| Pattern | Module | Tests | +|---------|--------|-------| +| 50|50 split at normal width | `pkg/cli/tui/table.go` | selected service renders list pane + details pane with service metadata | +| Service metadata display | `pkg/cli/tui/table.go` | working directory, port(s), and command are visible in the details pane for the selected service | +| Service metadata with crash context | `pkg/cli/tui/table.go` | metadata appears before failure headline and log context when a crashed service is selected | +| Missing metadata degradation | `pkg/cli/tui/table.go` | details pane remains readable when individual metadata fields (CWD, command, ports) are empty or unset | +| Multi-port metadata display | `pkg/cli/tui/table.go` | multiple ports render in a compact format without duplicating the list-view port summary | +| Empty selection placeholder | `pkg/cli/tui/table.go` | placeholder remains visible when no managed service is selected | +| Failure context compaction | `pkg/cli/tui/helpers.go` | crash headline + recent tail remain visible without full log expansion | +| Narrow-width degradation | `pkg/cli/tui/table.go` | state marker and primary headline remain visible when width is constrained | +| Mouse row mapping | `pkg/cli/tui/helpers.go` + `pkg/cli/tui/update.go` | mouse clicks select the exact rendered row in both running and managed sections | +| Selected managed-row highlight | `pkg/cli/tui/table.go` | selected managed service row applies full-line highlight instead of symbol-only highlight | +| Running-state marker shape | `pkg/cli/tui/helpers.go` | running/active processes use a play marker (`▶`) instead of a tick | + +## External Dependency Tests + +| Dependency | Real Test | Behavior When Absent | +|------------|-----------|----------------------| +| Managed log files via process manager | `TestTUIAdapterLatestServiceLogPath_ReturnsManagedLogFile` | details pane must fall back to best available non-log context | + +## Constraint Coverage + +| Constraint ID | Test File | Tests | +|---------------|-----------|-------| +| C1 | `pkg/cli/tui/tui_managed_split_test.go` | split layout visibility, placeholder stability, narrow-width preservation, service metadata display, multi-port metadata format | +| C2 | `pkg/cli/tui/tui_state_test.go`, `pkg/cli/tui/tui_viewport_test.go` | managed keyboard interaction regression plus mouse row-selection preservation | +| C3 | `pkg/cli/tui/tui_ui_test.go`, `pkg/cli/tui/tui_managed_split_test.go` | symbol + text readability for managed state plus full-line selected-row highlight | + +## Verification + +```bash +# split-view and selected-row rendering coverage +go test ./pkg/cli/tui -run 'TestManagedSplitView|TestManagedSplitView_SelectedManagedRowHighlightsWholeLine' -count=1 + +# service metadata coverage (new) +go test ./pkg/cli/tui -run 'TestManagedSplitView.*Metadata|TestManagedSplitView.*ServiceMetadata' -count=1 + +# mouse row-mapping coverage +go test ./pkg/cli/tui -run 'TestTableMouseClickSelection' -count=1 + +# integration coverage for adapter bridge +go test ./pkg/cli -run TestTUIAdapterLatestServiceLogPath -count=1 +``` + +## Review Notes + +- `pkg/cli/tui/tui_managed_split_test.go` started RED-first and now also covers selected managed-row full-line highlight behavior. +- `pkg/cli/tui/tui_viewport_test.go` now verifies that mouse clicks map to the exact rendered row in both running and managed sections, including viewport-offset cases. +- Existing keyboard and UI tests remain part of the coverage story; together they protect status-marker readability and managed-service interaction continuity across both keyboard and mouse input. +- Running-state presentation now uses a play marker (`▶`) to better communicate active process state while preserving symbol-plus-text readability. +- The ticket docs now reflect the implemented regression coverage; canonical `spec-trace` state should be refreshed separately if it must include the new mouse/highlight evidence. diff --git a/.tickets/DEVPT-004/tests.trace.md b/.tickets/DEVPT-004/tests.trace.md new file mode 100644 index 0000000..da9e608 --- /dev/null +++ b/.tickets/DEVPT-004/tests.trace.md @@ -0,0 +1,43 @@ +# Test Plan + +## Test Plans By Kind + +### unit + +- Action buttons render and respond correctly in details header (`TEST-action-buttons-ui`) + Covers: `BR-8`, `BR-9`, `Edge-7`, `Edge-8` + File: `pkg/cli/tui/tui_action_buttons_test.go` +- Services that exit immediately show proper context (`TEST-immediate-exit-service`) + Covers: `Edge-3` + File: `pkg/cli/tui/tui_managed_split_test.go` +- Sections scroll independently (`TEST-independent-scroll`) + Covers: `BR-10` + File: `pkg/cli/tui/tui_viewport_test.go` +- Managed keyboard interactions remain intact (`TEST-managed-keyboard-interactions`) + Covers: `C2` + File: `pkg/cli/tui/tui_state_test.go` +- Service metadata (CWD, ports, command) display in details pane (`TEST-managed-service-metadata-ui`) + Covers: `BR-7`, `Edge-5`, `Edge-6`, `C1` + File: `pkg/cli/tui/tui_managed_split_test.go` +- Managed split view renders correctly (`TEST-managed-split-view-ui`) + Covers: `BR-3`, `BR-4`, `BR-5`, `BR-6`, `BR-7`, `C1`, `Edge-1`, `Edge-2`, `Edge-4`, `Edge-5`, `Edge-6` + File: `pkg/cli/tui/tui_managed_split_test.go` +- Managed status markers render correctly (`TEST-managed-status-markers-ui`) + Covers: `BR-1`, `BR-2`, `C3` + File: `pkg/cli/tui/tui_ui_test.go` + +## Requirement Coverage Summary + +| Requirement ID | Route Policy | Direct Test Plans | Indirect Test Plans | +|---|---|---|---| +| `C1` | tests | `TEST-managed-service-metadata-ui`, `TEST-managed-split-view-ui` | - | +| `C2` | tests | `TEST-managed-keyboard-interactions` | - | +| `C3` | tests | `TEST-managed-status-markers-ui` | - | +| `Edge-1` | tests | `TEST-managed-split-view-ui` | - | +| `Edge-2` | tests | `TEST-managed-split-view-ui` | - | +| `Edge-3` | tests | `TEST-immediate-exit-service` | - | +| `Edge-4` | tests | `TEST-managed-split-view-ui` | - | +| `Edge-5` | tests | `TEST-managed-service-metadata-ui`, `TEST-managed-split-view-ui` | - | +| `Edge-6` | tests | `TEST-managed-service-metadata-ui`, `TEST-managed-split-view-ui` | - | +| `Edge-7` | tests | `TEST-action-buttons-ui` | - | +| `Edge-8` | tests | `TEST-action-buttons-ui` | - | diff --git a/.tickets/DEVPT-004/uat.md b/.tickets/DEVPT-004/uat.md new file mode 100644 index 0000000..54979cb --- /dev/null +++ b/.tickets/DEVPT-004/uat.md @@ -0,0 +1,169 @@ +# UAT Refinement Brief + +## Objective + +Add interactive action buttons to the details pane header and ensure independent scrolling for all sections. This enables users to perform service actions directly from the details view without navigating away. + +## Approved Changes + +1. **Action buttons in details header**: The details pane title line now includes a button group for service actions. + - Format: "Selected service details" → "Details {button_group}" + - Buttons: `[restart|start] [stop]` + - Context-sensitive: show "start" when stopped, "restart" when running/crashed + - Note: Edit button removed from scope - deferred to future phase + +2. **Button styling and icons**: + - Restart: circular arrow icon (↻ or ⟳) + - Start: play icon (▶) + - Stop: stop icon (■ or ◼) + - Colors: distinguishable from regular text, likely using lipgloss styling + +3. **TUI library investigation findings**: + - Charm bubbles v2.1.0 does not include a built-in button component + - Buttons will be implemented as styled text with click detection + - Will use lipgloss for styling and custom click handlers + +4. **Details pane context scope**: + - Details show information for the actively selected service + - If selection is in the process (running) area, show details for that service + - If selection is in managed services area, show details for the managed service + - **IMPLEMENTED**: Discovered (non-managed) services show full details including PID, port, command, directory, project, start time, agent info, and health status + +5. **Independent scrolling**: + - All three viewports (running, managed list, managed details) must scroll independently + - Already implemented via separate viewport models, needs verification and potential refinement + +## Changed Requirement IDs + +| ID | Action | Summary | +|----|--------|---------| +| BR-8 | additive_change | New: details pane header shall display action buttons for the selected service | +| BR-9 | additive_change | New: action buttons shall be context-sensitive based on service state | +| BR-10 | additive_change | New: each section (running, managed list, details) shall scroll independently | +| BR-11 | additive_change | New: details pane shows information for actively selected service, whether running or managed | +| Edge-7 | additive_change | New: action buttons shall be disabled/hidden when no service is selected | +| Edge-8 | additive_change | New: action buttons shall handle edge cases (service starting, service just stopped) | +| Edge-9 | additive_change | New: placeholder shown when no service selected in currently focused section | + +## Affected Downstream Trace + +| Stage | Impact | +|-------|--------| +| bdd | New scenarios: `details_header_shows_action_buttons`, `action_buttons_are_context_sensitive`, `sections_scroll_independently` | +| architecture | Flow updates for button rendering, click handling, viewport interaction routing | +| tests | New tests for button rendering, button state transitions, independent scroll behavior | +| tasks | New task: implement action buttons, verify independent scrolling | +| obligations | New: `OBL-action-button-rendering`, `OBL-independent-scroll` | +| artifacts | New: `ART-action-buttons-test-go` | + +## Execution Slices + +### Slice 1: Add action buttons to details header + +**Objective**: Render action buttons in the details pane title line with appropriate styling and icons. + +**Direct artifacts/files**: +- `pkg/cli/tui/table.go` — `renderManagedDetails()` function +- `pkg/cli/tui/helpers.go` — button rendering helpers +- `pkg/cli/tui/tui_action_buttons_test.go` — new test file + +**Direct GREEN targets**: +- `details_header_shows_action_buttons` (BR-8) +- `action_buttons_are_context_sensitive` (BR-9) +- `TEST-action-buttons-ui` + +**Impacted canonical task IDs**: TASK-5 (new) + +**Why this slice exists**: Buttons are a new interaction mechanism. Need to render them first with proper styling before adding click handling. The Charm library has no built-in buttons, so we'll use styled text with click regions. + +### Slice 2: Wire button click handling + +**Objective**: Make buttons clickable/activatable via mouse and keyboard. + +**Direct artifacts/files**: +- `pkg/cli/tui/model.go` — click and key handling +- `pkg/cli/tui/commands.go` — service action commands (start/stop/restart) +- `pkg/cli/tui/tui_action_buttons_test.go` — interaction tests + +**Direct GREEN targets**: +- Button click triggers correct service action +- Button state updates after action + +**Impacted canonical task IDs**: TASK-5 + +**Why this slice exists**: Rendering alone isn't enough; buttons must trigger actions. This slice connects button clicks to existing service management commands. + +### Slice 3: Verify and refine independent scrolling + +**Objective**: Ensure all three viewports scroll independently without interfering with each other. + +**Direct artifacts/files**: +- `pkg/cli/tui/table.go` — viewport management +- `pkg/cli/tui/tui_viewport_test.go` — existing, may need expansion + +**Direct GREEN targets**: +- `sections_scroll_independently` (BR-10) +- `TEST-independent-scroll` + +**Impacted canonical task IDs**: TASK-6 (new) + +**Why this slice exists**: The current implementation has separate viewports but we need to verify they scroll independently and handle edge cases correctly (e.g., mouse scroll in details shouldn't scroll list). + +## Validation + +```bash +# New button rendering tests +go test ./pkg/cli/tui -run 'TestActionButtons' -count=1 + +# Independent scroll tests +go test ./pkg/cli/tui -run 'TestViewport.*Independent' -count=1 + +# Full managed service test suite +go test ./pkg/cli/tui -run 'TestManagedSplitView|TestView_ManagedCrashContextAndSymbols|TestActionButtons' -count=1 + +# Integration test with buttons +go test ./pkg/cli/tui -run 'TestTUIKeySequence|TestTUISimpleUpdate' -count=1 +``` + +## Watchlist + +- Button styling must work across different terminal color schemes (C3 interaction) +- Button click regions must be accurately detected (no ±1 drift) +- Independent scrolling must not interfere with selection navigation +- Action buttons for discovered services need design decision (see Open Decisions) +- Button state during service transitions (starting, stopping) needs careful handling + +## Open Decisions + +### Decision 1: Discovered services details and actions + +**Question**: What should the details pane show when a discovered (non-managed) service is selected in the running processes area? + +**Options**: +1. Show basic info (PID, port, command) with no action buttons +2. Show basic info with "Add to managed" button instead of start/stop/restart +3. Hide details pane or show different placeholder +4. Show same details as managed services but disable action buttons + +**Resolution**: Option 1 (enhanced) - Show comprehensive details including PID, port, command, directory, project, start time, agent info, health status, and crash information if applicable. Action buttons for discovered services are deferred to future implementation. + +**Status**: **RESOLVED - Implemented in commit 097772f** + +**Implementation details**: +- Renamed `managedDetailsVP` to `selectedDetailsVP` for semantic clarity +- Renamed `renderManagedDetails()` to `renderSelectedServiceDetails()` +- Added focus-based logic: running services show their own details, managed services show their details +- Discovered services show: Name, Source, Status, PID, Port/Protocol, Command, Directory, Project, Start time, Agent info, Health, Crash info +- Placeholder shown when no service is selected: "Select a running service to inspect details" + +### Decision 2: Button keyboard shortcuts + +**Decision**: Keyboard shortcuts already exist in the current keymap. No new shortcuts needed for buttons. + +**Status**: **RESOLVED** + +### Decision 3: Edit button action + +**Decision**: Remove edit button from this phase. Defer to future implementation. + +**Status**: **RESOLVED** diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..30f7bd3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +@.github/copilot-instructions.md + +@DEBUG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7e5a442 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,74 @@ +# Changelog + +## 0.5.1 + +- Fixed `remove`/`rm` command so `devpt remove ` works from the CLI instead of failing as unknown; also added to help text + +## 0.5.0 + +- Added resolved command capture at spawn time so the system learns the OS-interpreted command (e.g., `bunx vite` → `node .../vite`) for reliable identity matching +- Added process start time to the identity evidence chain so PID reuse is detected safely +- Added universal details pane so selecting a running service shows its full details alongside managed services +- Added per-process memory display in the details pane with color-coded thresholds so resource usage is visible at a glance +- Added copy-to-clipboard icon next to command text in logs and details pane +- Fixed TUI managed stop to route through the lifecycle layer instead of raw PID calls so "invalid pid: 0" no longer occurs on stale registry entries +- Fixed TUI restart/stop routing so keyboard shortcuts target managed services when the managed list has focus +- Fixed scanner to recognize versioned Python binaries (e.g., `python3.12`) in runtime command checks +- Refactored identity matching to use an ordered evidence chain (PID+time → port → CWD+command → CWD → root) for shared-CWD correctness +- Refactored TUI row color logic into a shared source of truth across running table and managed list +- Updated PROCESS_MANAGEMENT.md with identity architecture, resolved command capture, restart preflight rules, and non-negotiable rules + +## 0.4.2 + +- Fixed port-bound readiness timeout so services like Open WebUI that take 10–15s to bind their port are no longer falsely marked unhealthy +- Fixed false ambiguity warnings so processes already uniquely claimed by another service via their port binding are skipped +- Fixed managed details pane click routing so clicking the right-side details pane no longer selects items in the left-side service list +- Fixed Windows cross-compilation so the lock file compiles without missing `syscall.Kill` +- Refactored package internals to remove ~330 lines of dead code, unreachable paths, and duplicated logic + +## 0.4.1 + +- Fixed Linux crash when running as non-root by adding /proc/net/tcp fallback so lsof is no longer required +- Refactored TUI render-path to reduce recomputation overhead +- Aligned process lifecycle with behavioral contract for consistent start/stop/restart behavior +- Refactored TUI commands module into focused files for maintainability + +## 0.4.0 + +- Added namespace-based process grouping so related managed services can be controlled together +- Added OSC 8 clickable hyperlinks to the TUI so service names and commands are directly actionable from the terminal +- Added wildcard pattern support to the status command so multiple services can be queried at once +- Added service metadata to the managed details pane so context like namespace and tags are visible alongside process info +- Fixed namespace extraction so leading non-alphanumeric characters are handled correctly +- Fixed ^C in command mode so it properly cancels without side effects and managed list/details scrolling is independent + +## 0.3.0 + +- Added a managed-services split view in the TUI so selection and navigation stay clear when browsing running and registered services +- Fixed TUI selection behavior so focus, row targeting, and split-pane navigation stay aligned while moving between running and managed services + +## 0.2.2 + +- Added a Shift+S sort direction toggle in the TUI so sort order can be reversed without changing the active column +- Fixed managed service PID validation so stop and restart only act on processes that still match the registered service +- Fixed cross-platform builds by separating Unix and Windows process control paths + +## 0.2.1 + +- Added table sorting controls with mouse support and reverse sort in the TUI + +## 0.2.0 + +- Added multi-service `start`, `stop`, and `restart` commands with quoted glob pattern support so multiple managed services can be controlled in one invocation +- Added `name:port` targeting for managed services so ambiguous service names can be disambiguated from the CLI +- Extracted the Bubble Tea UI into `pkg/cli/tui` so the TUI logic is isolated from the main CLI package +- Added mouse row selection, mouse wheel scrolling, and viewport-focused navigation so table and log interaction works without keyboard-only control +- Added centered modal overlays for help and confirmation dialogs so help and destructive actions no longer replace the main table view +- Replaced the ad hoc search field with Bubbles text input so filter editing behaves like a real input control and updates inline in the footer +- Simplified the table chrome by moving counts into headers, bolding the active sort column, and removing redundant status text from the top of the screen +- Fixed `Enter` handling so the top section opens logs and the bottom section starts the selected managed service without being swallowed by confirm bindings +- Fixed log rendering so the header is separated from the first log line and the viewport uses the actual remaining terminal height +- Fixed stale table layout offsets so footer spacing, viewport sizing, and mouse hit-testing stay aligned after the filter moved into the footer +- Added shared keymap-driven help text with Bubble components so visible shortcuts and actual bindings stay in sync +- Added clearer TUI and quickstart documentation so the current footer filter, modal help, mouse controls, batch commands, and logs header behavior are documented +- Bumped the application version to `0.2.0` and rendered the version in the TUI header in muted gray diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..80a633c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +@AGENTS.md +@.github/copilot-instructions.md \ No newline at end of file diff --git a/DEBUG.md b/DEBUG.md new file mode 100644 index 0000000..de6dcc7 --- /dev/null +++ b/DEBUG.md @@ -0,0 +1,220 @@ +# DevPortTrack Debug Protocol + +> Runtime coverage index: 2 runtimes (devpt-cli, sandbox fixtures) + +--- + +## Runtime: `devpt-cli` + +| Field | Value | +|------------|--------------------------------------------| +| `id` | devpt-cli | +| `class` | backend / CLI | +| `entry` | `cmd/devpt/main.go` | +| `owner` | root | +| `observe` | stdout/stderr, `~/.config/devpt/logs/` | +| `control` | `./devpt {start\|stop\|restart} ` | +| `inject` | `go run ./cmd/devpt` | +| `rollout` | `go build && ./devpt ` | +| `test` | `go test ./...` | + +--- + +### devpt-cli / OBSERVE / VERIFIED + +- Action: `./devpt ls` +- Signal: Tabular output showing Name, Port, PID, Project, Source, Status +- Constraints: Requires `lsof` and `ps` system utilities (macOS only) + +### devpt-cli / CONTROL / VERIFIED + +- Action: + ```bash + ./devpt add test-svc /path/to/cwd "command" 3400 + ./devpt start test-svc + ./devpt stop test-svc + ./devpt restart test-svc + ./devpt start 'test-*' + ./devpt stop test-svc:3400 + ``` +- Signal: + - `start`: start/status lines for each targeted service + - `stop`: stop/status lines for each targeted service + - `restart`: restart/status lines for each targeted service +- Constraints: + - Registry stored at `~/.config/devpt/registry.json` + - Logs written to `~/.config/devpt/logs//.log` + - Processes spawn in separate process groups (setpgid) + - Quote glob patterns to avoid shell expansion before `devpt` sees them + - `name:port` can be used to target a specific managed service identifier + +### devpt-cli / ROLLOUT / VERIFIED + +- Action: Build and verify version output +- Signal: `devpt version 0.5.0` (via `./devpt --version`) +- Constraints: No hot reload; requires full rebuild +- See: `.github/copilot-instructions.md` → Quick Reference for build commands + +### devpt-cli / TEST / VERIFIED + +- Action: Run test suite +- Signal: `ok` for each package; tests across 8 packages (~20s total) +- Constraints: Tests in `pkg/cli/*_test.go`, `pkg/cli/tui/*_test.go`, `pkg/lifecycle/*_test.go`, `pkg/process/*_test.go`, `pkg/resource/*_test.go`, `pkg/scanner/*_test.go`, `pkg/registry/*_test.go`, `pkg/models/*_test.go` + - `pkg/cli/tui/`: 12 test files covering UI rendering, key input, state, viewports, memory, namespaces, OSC8, group color + - `pkg/cli/`: 10 test files covering commands, patterns, batch, status, display + - `pkg/lifecycle/`: 11 test files covering identity, reconciliation, start/stop/restart, readiness, locking, outcomes + - `pkg/resource/`: memory formatting and collection tests + - `pkg/process/`: command parsing and start time tests + - `pkg/registry/`: CRUD and persistence tests + - `pkg/scanner/`: discovery tests + - `pkg/models/`: lifecycle model tests +- See: `.github/copilot-instructions.md` → Testing section for commands + +### devpt-cli / TEST / UI VERIFICATION + +- Action: Run UI rendering tests +- Signal: `PASS` for all UI rendering tests covering: + - Escape sequences (screen clear, ANSI codes) + - Layout structure (table headers, columns, dividers, footer-based filter state) + - Responsive design (widths 40-200 chars, heights 10-100 lines) + - All view modes (table, logs, command, search, help, confirm) + - Footer content (keybindings, live filter rendering, status) + - Namespace display, group color logic, OSC8 hyperlinks +- Constraints: + - Tests verify rendered content, not specific ANSI colors + - Footer assertions tolerate wrapping + - No external deps beyond `testify/assert` + - focused command for current UI work: `go test -mod=mod ./pkg/cli/tui ./pkg/cli` + - lifecycle tests: `go test -v ./pkg/lifecycle` + +### devpt-cli / OBSERVE / TUI INTERACTIONS / VERIFIED + +- Action: `./devpt` +- Signal: + - top table shows running services with health and memory indicators + - lower section shows `Managed Services ()` + - right-side details pane shows info for selected service (running or managed) + - `/` activates inline footer filter editing + - `?` opens a centered help modal + - logs view header is `Logs: | Port: | PID: ` +- Constraints: + - mouse click selects rows + - mouse wheel and page keys scroll the active viewport + - help and confirmation dialogs are overlay modals, not separate screens + +### devpt-cli / INJECT / VERIFIED + +- Action: `go run ./cmd/devpt ` +- Signal: Immediate execution without explicit build step +- Constraints: Slower than compiled binary + +### devpt-cli / EGRESS / N/A + +- Rationale: CLI outputs directly to stdout/stderr; no sandboxed context + +### devpt-cli / STATE / VERIFIED + +- Action: + ```bash + # Add managed service to registry + ./devpt add my-app /path/to/project "npm run dev" 3000 + + # Verify registry state + cat ~/.config/devpt/registry.json | jq '.services["my-app"]' + ``` +- Signal: JSON entry created in registry with name, cwd, command, ports, timestamps +- Constraints: Registry is file-based JSON; thread-safe via RWMutex + +--- + +## Runtime: `sandbox/servers/*` (Test Fixtures) + +| Field | Value | +|------------|-----------------------------------------------------------------------------| +| `id` | go-basic, node-basic, node-crash, node-warnings, node-port-fallback, python-basic | +| `class` | test fixtures | +| `entry` | `sandbox/servers//main.go` or `server.js` or `dev.js` | +| `owner` | devpt-cli (managed) | +| `observe` | `~/.config/devpt/logs//*.log` | +| `control` | Via devpt-cli: `./devpt {start\|stop} ` | +| `inject` | `go run .` (Go) or `node server.js` (Node) | +| `rollout` | Rebuild + restart via devpt | +| `test` | No dedicated tests (fixtures for manual testing) | + +### go-basic / OBSERVE / VERIFIED + +- Action: `./devpt logs test-go-basic --lines 5` +- Signal: `2026/03/12 14:59:04 [go-basic] listening on http://localhost:3400` +- Constraints: Logs captured only for managed services started via `devpt start` + +### go-basic / INJECT / VERIFIED + +- Action: + ```bash + cd sandbox/servers/go-basic + go run . + ``` +- Signal: `[go-basic] listening on http://localhost:3400` +- Constraints: Runs in foreground; use with `&` for background execution + +--- + +## Runtime: `pkg/lifecycle` (Service Lifecycle) + +| Field | Value | +|------------|------------------------------------------------| +| `id` | lifecycle | +| `class` | backend / orchestration | +| `entry` | `pkg/lifecycle/manager.go` | +| `owner` | devpt-cli | +| `observe` | Service identity verification, status outcomes | +| `control` | Via devpt-cli: start/stop/restart | +| `inject` | `go test ./pkg/lifecycle` | +| `rollout` | Rebuild + restart via devpt | +| `test` | 11 test files covering all lifecycle flows | + +### lifecycle / IDENTITY / VERIFIED + +- Action: Start a service, then verify identity chain +- Signal: Service correctly identified via ordered evidence (PID+time → port → CWD+command → CWD) +- Constraints: See PROCESS_MANAGEMENT.md §3.4 for full algorithm + +### lifecycle / RECONCILIATION / VERIFIED + +- Action: Stop a service externally, then run reconcile +- Signal: Service transitions from `running` to `crashed` or `stopped` +- Constraints: Reconciler checks live state before any mutation + +--- + +## Debug Helper Commands + +```bash +# Quick rebuild and test +go build -o devpt ./cmd/devpt && ./devpt ls + +# Run all CLI tests with coverage +go test ./pkg/cli/... -cover + +# Run the focused TUI and CLI package suite used for current UI work +go test -mod=mod ./pkg/cli/tui ./pkg/cli + +# Run specific test with verbose output +go test -v ./pkg/cli -run TestWarnLegacyManagedCommands + +# Run UI rendering tests (visual regression checks) +go test -v ./pkg/cli/tui -run TestView + +# Run state transition tests +go test -v ./pkg/cli/tui -run TestTUI + +# View registry state +cat ~/.config/devpt/registry.json | jq '.' + +# Check logs for a service +ls ~/.config/devpt/logs// +cat ~/.config/devpt/logs//*.log | tail -20 + +# Quick health check on a running service +curl -s http://localhost:/health +``` diff --git a/PROCESS_MANAGEMENT.md b/PROCESS_MANAGEMENT.md new file mode 100644 index 0000000..69398be --- /dev/null +++ b/PROCESS_MANAGEMENT.md @@ -0,0 +1,539 @@ +# Process Management Behavioral Contract + +Defines the correct workflow and operator-facing behavior for managed service lifecycle operations: `start`, `stop`, `restart`, and batch execution. + +This is a process contract, not an implementation note. It defines what must be true before, during, and after each lifecycle action. + +This document standardizes the workflow algorithm and operator experience. It is intentionally stricter than the current implementation. Where the implementation is simpler, this document defines the target behavior to converge toward. + +--- + +## 1. Operating Model + +### 1.1 Sources of Truth + +The system has three different kinds of state: + +- **Desired state**: the managed service definition in the registry +- **Observed state**: what the system can prove right now by scanning processes and ports +- **Operation state**: an in-progress lifecycle action owned by exactly one operator flow + +The key rule: + +> Observed state is authoritative for whether a service is running. +> Registry state stores configuration and last confirmed ownership metadata. + +Because this is a daemonless workflow, the registry cannot be treated as continuously current. A process can die immediately after a successful write. Every command must reconcile live state before acting. + +### 1.2 Durable State vs Command Phase + +The contract separates persistent service status from command-local execution phase. + +Persistent service status is what operators may rely on between commands: + +- **running** +- **stopped** +- **crashed** +- **unknown** + +Command phase is transient and exists only while a lifecycle command owns the service: + +- **starting** +- **stopping** +- **restarting** + +Unless the system introduces persisted operation records, command phase is not durable state and must not be shown later as if it were. + +### 1.3 Service Identity + +A service must never be identified by PID alone. + +Identity must be verified using an ordered evidence chain: + +1. **PID + start time** — definitive after confirmed start +1b. **Stored LastPID + path corroboration** — strong, even without start time +2. **Declared port** — primary runtime signal for services that declare one +3. **CWD + resolved command** — grouping key for discovering related processes +4. **Exact CWD match** — fallback for portless services with unique CWDs +5. **Exact project root match** — fallback for portless services with unique roots + +Additionally, the system captures a **resolved command** at spawn time — the actual +command visible via `ps` after the OS interprets the declared command. For example, +`bunx vite` resolves to `node .../node_modules/.bin/vite`. This learned mapping enables +reliable runtime identity matching without fuzzy heuristics. + +If PID reuse is possible and identity cannot be proven, the service must be treated as **unknown**, not **running**. + +#### Shared-CWD Services + +Multiple services sharing the same CWD (e.g., backend, frontend, preview from the same project) is a first-class scenario. In this case, **port** is the primary distinguishing signal. Services without declared ports in a shared-CWD must have unique commands or risk being classified as `unknown`. + +#### Identity Groups (Target Architecture) + +The system is evolving toward a **service group** model where processes sharing CWD + resolved command are grouped together: + +| Member | Match | Meaning | +|--------|-------|---------| +| **Primary** | Declared port match | The tracked instance | +| **Related** | Same CWD+command, different port | Sibling process (orphan, duplicate) | +| **Conflict** | Declared port held by process outside group | Someone else using our port | +| **Untracked** | Doesn't match any group | Not our concern | + +### 1.4 Operation Ownership + +Only one lifecycle operation may own a service at a time. + +Before `start`, `stop`, or `restart`, the system must acquire a per-service operation lock. + +If the lock cannot be acquired: + +- Do not continue optimistically +- Report that another operation is already in progress +- Exit with a blocked result + +### 1.5 Registry Write Rule + +The registry may store: + +- service definition +- last confirmed PID +- last confirmed process start time +- last confirmed readiness timestamp +- last log path or log session metadata + +The registry must not be used as the sole proof that a service is alive. + +--- + +## 2. Status, Phase, and Outcomes + +```mermaid +stateDiagram-v2 + [*] --> stopped + + stopped --> starting : start + starting --> running : ready + starting --> stopped : start failed + + running --> stopping : stop + stopping --> stopped : stopped + + running --> restarting : restart + restarting --> running : ready + restarting --> stopped : restart failed + + running --> crashed : observed dead + crashed --> stopped : reconcile + crashed --> starting : restart +``` + +### 2.1 Persistent Service Status + +- **running**: a live process identity has been verified and readiness has passed when required +- **stopped**: no verified running instance exists +- **crashed**: the last confirmed instance is gone and the tool has evidence of an unexpected exit or stale last-run metadata +- **unknown**: a process may exist, but ownership cannot be proven safely + +### 2.2 Command Phase + +- **starting**: a start operation owns the service and readiness is being verified +- **stopping**: shutdown is in progress and the current instance may still own resources +- **restarting**: one verified instance is being replaced by another + +These are command-local phases, not durable statuses, unless a future operation journal explicitly persists them. + +### 2.3 Command Outcomes + +Every lifecycle command must end in one of these outcomes: + +- **success**: requested state change completed +- **noop**: requested end state already existed +- **blocked**: action was prevented by a lock, conflict, or unsafe ambiguity that may be resolved externally +- **failed**: action was attempted but could not complete +- **invalid**: the request or service definition is invalid +- **not_found**: the requested service identifier matched nothing + +This standard replaces vague failure-only reporting with explicit operator-facing outcomes. + +### 2.4 Outcome Rules + +- use **blocked** for lock contention, identity ambiguity, or external resource conflicts +- use **invalid** for malformed commands, missing working directories, or impossible service definitions +- use **not_found** when resolution fails before any lifecycle work begins +- do not collapse all non-success results into **failed** + +--- + +## 3. Universal Workflow + +Every lifecycle operation must follow the same high-level algorithm. + +```mermaid +flowchart TD + A[Resolve service] --> B{Service exists} + B -- No --> X1[Outcome: not_found] + B -- Yes --> C[Validate request and service contract] + C --> D{Valid} + D -- No --> X2[Outcome: invalid] + D -- Yes --> E[Acquire service lock] + E --> F{Lock acquired} + F -- No --> X3[Outcome: blocked] + F -- Yes --> G[Reconcile live state] + G --> H[Run command-specific flow] + H --> I[Persist confirmed metadata] + I --> J[Release lock] +``` + +### 3.1 Reconcile Live State + +Before any mutation: + +- scan current listeners and processes +- match live processes against managed services by identity, not just PID +- clear stale metadata that can no longer be verified +- classify the service as `running`, `stopped`, `crashed`, or `unknown` + +If the service is `unknown`, the system must not take destructive action until identity is clarified. + +### 3.2 Lock Protocol + +Per-service locking must follow these rules: + +- lock scope is one managed service identifier +- lock owner records command type and acquisition timestamp +- lock acquisition is exclusive +- stale locks must be recoverable by timeout or explicit verification that the owner is gone +- batch operations acquire and release one service lock at a time unless a higher-level planner is explicitly introduced + +If a lock cannot be acquired safely, return `blocked` and do not continue optimistically. + +### 3.3 Persist Only Confirmed Facts + +Write registry metadata only after a fact has been confirmed: + +- do not record a PID before the child is proven alive +- do not mark a service running before readiness passes +- do not clear stop metadata until the process is confirmed gone + +### 3.4 Identity Verification Algorithm + +Identity verification must use ordered evidence, not ad hoc matching. + +Evidence chain (ordered by strength): + +1. **PID + start time**: stored PID matches a live process with the same OS-reported start time +1b. **Stored LastPID + path corroboration**: stored PID matches a live process whose CWD or project root matches +2. **Declared port**: a uniquely-declared port matches a live process, with CWD/root as corroboration (not requirement) +3. **CWD + resolved command**: both working directory and the OS-resolved command match +4. **Exact CWD match**: working directory matches and is unique among all managed services +5. **Exact project root match**: project root matches and is unique among all managed services + +Verification rules: + +- at least one path-based or uniquely-owned port-based signal must exist +- PID alone is never sufficient +- command string alone is never sufficient +- stored LastPID with path corroboration takes precedence over port matching — a previously confirmed identity is more reliable than a port match which could be a conflict +- if multiple managed services remain plausible after matching, classify as `unknown` +- if evidence conflicts, prefer safety over convenience and classify as `unknown` +- a process on a port that no managed service declares is irrelevant and must not poison identity checks + +--- + +## 4. Start + +### 4.1 Start Flow + +```mermaid +flowchart TD + A[Resolve and lock] --> B[Reconcile live state] + B --> C{Already running} + C -- Yes --> Z1[No-op: already running] + C -- No --> D[Run preflight] + D --> E{Preflight passed} + E -- No --> Z2[Outcome: invalid or blocked] + E -- Yes --> F[Spawn process] + F --> G[Verify process identity] + G --> H[Wait for readiness] + H --> I{Ready} + I -- Yes --> J[Record confirmed run] + J --> Z3[Success: started] + I -- No --> K[Collect diagnostics] + K --> L[Cleanup failed start] + L --> Z4[Failed: start did not complete] +``` + +### 4.2 Start Rules + +- `start` is end-state oriented: its job is to ensure the service is running +- if a verified instance is already running, return `noop` +- if a stale registry entry exists, clear it during reconciliation before any fork +- if identity is ambiguous, return `blocked` +- never spawn a second instance just because the registry is stale + +### 4.3 Preflight Requirements + +Before any fork: + +- working directory exists and is a directory +- command parses into an executable and arguments +- executable can be resolved +- all declared ports are free, or are already owned by the same verified instance +- required files or env assumptions are present when the service contract requires them + +Preflight failures caused by invalid service definition return `invalid`. + +Preflight failures caused by external contention, such as port conflicts, return `blocked`. + +### 4.3.1 Resolved Command Capture + +After a successful start, the system reads the OS-reported command line from the spawned process (`ps -p -o command=`) and stores it as `resolved_command` in the registry. This learned mapping (e.g., `bunx vite` → `node .../vite`) enables reliable identity matching during future reconciles without fuzzy heuristics. + +The declared command is used for spawning. The resolved command is used for identity only. + +### 4.4 Readiness Policy + +Readiness is a service policy, not an ad hoc runtime guess. + +Allowed readiness modes: + +- **process-only**: child remains alive for the startup window +- **port-bound**: declared port is bound by the verified child +- **http-health**: HTTP readiness endpoint returns success +- **log-signal**: a declared log pattern appears +- **multi-check**: more than one condition must pass + +If the service model supports explicit readiness configuration, the service definition must declare which mode applies. + +If no explicit readiness policy exists yet, the fallback policy is: + +- `port-bound` for services with declared ports +- `process-only` for services without declared ports + +This fallback is transitional. A future richer service contract may replace it. + +### 4.5 Start Failure Handling + +If start fails: + +- collect a short diagnostic summary +- include log tail when available +- kill the child if it is still alive but not ready +- do not write unconfirmed PID data +- return `failed` + +### 4.6 Required Message Format + +Start messages must use decisive operator language and must state the resolved outcome. + +- `Success: started "api" on port 3000 (PID 4821).` +- `No-op: "api" is already running on port 3000 (PID 4821).` +- `Blocked: port 3000 is in use by PID 4821 (python). Stop it or change the service port.` +- `Invalid: "api" has a missing working directory: /path/to/project.` +- `Failed: "api" did not become ready within 20s. Check logs with devpt logs api.` + +--- + +## 5. Stop + +### 5.1 Stop Flow + +```mermaid +flowchart TD + A[Resolve and lock] --> B[Reconcile live state] + B --> C{Already stopped} + C -- Yes --> Z1[No-op: already stopped] + C -- No --> D{Identity verified} + D -- No --> Z2[Blocked: unsafe to kill] + D -- Yes --> E[Send SIGTERM] + E --> F{Exited in time} + F -- Yes --> G[Confirm resource release] + F -- No --> H[Send SIGKILL] + H --> I{Exited} + I -- No --> Z3[Failed: process still alive] + I -- Yes --> G[Confirm resource release] + G --> J[Clear confirmed run metadata] + J --> Z4[Success: stopped] +``` + +### 5.2 Stop Rules + +- `stop` is idempotent: if the service is already stopped, return `noop` +- if the registry contains stale metadata and no verified live instance exists, clear the stale data and return `noop` +- never kill a process when service identity is ambiguous +- terminate gracefully first, then escalate +- confirm that the process is gone before clearing ownership metadata +- if service status is `unknown`, refuse destructive action and return `blocked` + +### 5.3 Stop Failure Handling + +If forced kill fails: + +- report the PID and why termination failed +- tell the operator whether elevated permissions may be required +- leave the service in `blocked` or `failed`, not falsely `stopped` + +### 5.4 Required Message Format + +Stop messages must state whether the final state is already satisfied, blocked, or failed. + +- `Success: stopped "worker" (PID 3105).` +- `No-op: "worker" is already stopped.` +- `No-op: stale PID 3105 was cleared for "worker".` +- `Blocked: PID 3105 cannot be proven to belong to "worker"; refusing to kill.` +- `Failed: PID 3105 did not exit after SIGTERM and SIGKILL. Sudo may be required.` + +--- + +## 6. Restart + +### 6.1 Restart Flow + +```mermaid +flowchart TD + A[Resolve and lock] --> B[Reconcile live state] + B --> C{Running now} + C -- Yes --> D[Stop verified instance] + C -- No --> E[Clear stale metadata] + D --> F{Stopped cleanly} + F -- No --> Z1[Blocked: old instance remains] + F -- Yes --> G[Wait for resources to clear] + E --> G[Wait for resources to clear] + G --> H{Preflight passed} + H -- No --> Z2[Blocked: cannot restart safely] + H -- Yes --> I[Spawn new instance] + I --> J[Verify identity and readiness] + J --> K{Ready} + K -- Yes --> L[Record confirmed run] + L --> Z3[Success: restarted] + K -- No --> Z4[Failed: old instance gone, new instance not ready] +``` + +### 6.2 Restart Rules + +- `restart` means replace the current instance with a fresh verified instance +- the old instance must be confirmed gone before the new one is accepted +- if the old instance cannot be stopped, return `blocked` +- if the old instance is already gone, clean stale metadata and continue +- if start fails after stop succeeds, report that the service is now stopped, not running +- if the service was already stopped, the operator-facing message must say that restart resolved as a fresh start +- restart must run the same preflight checks as start — declared ports must be free before spawning +- if a declared port is held by another process after stopping the old instance, return `blocked` with the conflict details +- never silently accept a process on a fallback port as a successful restart + +### 6.3 Freshness Rule + +When a previous instance existed, the new confirmed run must differ by identity from the old one. A restart that simply rediscovers the same old instance is not a valid restart. + +### 6.4 Required Message Format + +- `Success: restarted "api" with a fresh instance (old PID 3105, new PID 4821).` +- `Success: started "worker" because no verified instance was running.` +- `Blocked: could not restart "web" because the old instance still owns port 3000.` +- `Failed: "api" was stopped, but the replacement instance did not become ready.` + +--- + +## 7. Batch Operations + +Batch commands must optimize operator clarity, not just throughput. + +### 7.1 Batch Flow + +```mermaid +flowchart TD + A[Expand identifiers] --> B[Show execution plan] + B --> C[Process services in stable order] + C --> D[Run per-service workflow] + D --> E[Collect outcome] + E --> F{More services} + F -- Yes --> C + F -- No --> G[Print summary] +``` + +### 7.2 Batch Rules + +- expand patterns before execution +- deduplicate matches +- process services in a stable and predictable order +- continue after per-service failures unless the command explicitly declares fail-fast behavior +- return non-zero if any service failed +- distinguish `success`, `noop`, `blocked`, `failed`, `invalid`, and `not_found` in the summary + +### 7.3 Dependency-Aware UX + +If services have declared dependencies, the batch planner must: + +- start dependencies before dependents +- stop dependents before dependencies +- restart in dependency-aware order + +If dependency data is unavailable, the batch planner must use a stable deterministic order and report that dependency ordering was unavailable. + +Dependency ordering is an extension policy. If the service model does not yet carry dependency data, the batch system must not invent it. + +### 7.4 Summary Format + +The batch summary must report: + +- total matched +- succeeded +- noop +- blocked +- failed +- invalid +- not found +- per-service reason for every non-success outcome + +Example: + +```text +Matched 4 services +2 succeeded, 1 noop, 1 blocked + +- api: started +- worker: started +- web: already running +- redis: port 6379 is in use by PID 4821 +``` + +--- + +## 8. Error Reporting + +All lifecycle messages must answer three questions: + +- what was attempted +- what actually happened +- what the operator must do next + +Bad: + +- `failed to start` +- `process error` + +Good: + +- `Blocked: port 9055 is in use by PID 4821 (python). Stop that process or change the service port.` +- `Failed: "api" exited during startup before binding port 9055. Recent logs are available via devpt logs api.` +- `Invalid: "worker" has an invalid command definition.` +- `Blocked: another restart is already in progress for "worker". Retry after it completes.` + +--- + +## 9. Non-Negotiable Rules + +- never trust registry PID data without live reconciliation +- never identify a service by PID alone +- never record a run before identity and readiness are confirmed +- never kill a process whose identity is ambiguous +- never report `running` unless observed state proves it +- never report `stopped` until shutdown is confirmed +- never hide stale metadata cleanup +- never let concurrent operations mutate the same service without a lock +- never present transient command phase as durable service state unless operation records exist +- never silently accept a process on the wrong port as the managed instance +- never skip port preflight checks during restart +- never let a process on an undeclared port block identity checks for services with declared ports + +These rules exist to protect operator trust. Once the tool lies about lifecycle state, every downstream command becomes unreliable. diff --git a/QUICKSTART.md b/QUICKSTART.md index 03c1c8b..1e04bd6 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,13 +1,5 @@ # Dev Process Tracker - Quick Start Guide -## What is Dev Process Tracker? - -Dev Process Tracker is a macOS CLI tool that helps you discover, track, and manage local development servers and ports. It answers three key questions: - -1. **What servers are running?** - Lists all TCP listening ports on your machine -2. **Which project owns each server?** - Associates ports with their project roots -3. **Who started each server?** - Detects if an AI agent started the server - ## Installation Build from source: @@ -25,41 +17,34 @@ Then use from anywhere: ```bash devpt ls ``` +## First steps -## First Steps - -### See what's currently running +### See running services ```bash devpt ls ``` -Shows all discovered listening ports with their PID, project, and source. +Shows listening ports with PID, project, and source. -### Register a service you manage +### Register a managed service ```bash devpt add myapp ~/myapp "npm start" 3000 ``` -This stores `myapp` in your registry so you can control it with devpt. - ### List with details ```bash devpt ls --details ``` -Shows the full command that each process is running. - ### Check your registered services ```bash cat ~/.config/devpt/registry.json ``` -Your services are stored here and can be edited manually. - ## Common Workflows ### Start a managed service @@ -68,7 +53,25 @@ Your services are stored here and can be edited manually. devpt start myapp ``` -Logs are captured to: `~/.config/devpt/logs/myapp/.log` +Logs are written to `~/.config/devpt/logs/myapp/.log` + +### Start multiple services at once + +```bash +# Start multiple specific services +devpt start api frontend worker + +# Use glob patterns to match services (quote to prevent shell expansion) +devpt start 'web-*' # Starts all services matching 'web-*' +devpt start '*-test' # Starts all services ending with '-test' + +# Target a specific service by name:port +devpt start web-api:3000 # Start web-api on port 3000 only +devpt stop "some:thing" # Literal service name containing a colon + +# Mix patterns and specific names +devpt start api 'web-*' worker +``` ### Stop a service by name @@ -76,6 +79,20 @@ Logs are captured to: `~/.config/devpt/logs/myapp/.log` devpt stop myapp ``` +### Stop multiple services at once + +```bash +# Stop multiple specific services +devpt stop api frontend + +# Use glob patterns (quote to prevent shell expansion) +devpt stop 'web-*' # Stops all services matching 'web-*' + +# Target a specific service by name:port +devpt stop web-api:3000 # Stop web-api on port 3000 only +devpt stop *-test # Stops all services ending with '-test' +``` + ### Stop a service by port ```bash @@ -88,6 +105,17 @@ devpt stop --port 3000 devpt restart myapp ``` +### Restart multiple services at once + +```bash +# Restart multiple specific services +devpt restart api frontend worker + +# Use glob patterns +devpt restart web-* # Restarts all services matching 'web-*' +devpt restart claude-* # Restarts all services starting with 'claude-' +``` + ### View logs ```bash @@ -95,36 +123,19 @@ devpt logs myapp devpt logs myapp --lines 100 ``` -## Key Concepts - -### Server Sources - -Each server is tagged with a source: - -- **manual** - Running but not in your managed registry -- **managed** - In your registry (may or may not be running) -- **agent:xxx** - Started by an AI coding agent - -### Project Detection +### Use the TUI -Dev Process Tracker walks up the directory tree looking for: -- `.git` (Git repos) -- `package.json` (Node.js) -- `go.mod` (Go) -- `Gemfile` (Ruby) -- `composer.json` (PHP) -- And more... - -### Agent Detection - -Detects servers likely started by: -- OpenCode -- Cursor -- Claude -- Gemini -- Copilot +```bash +devpt +``` -Uses heuristics like parent process name, TTY attachment, and environment variables. +Key interactions: +- `Tab` switches between the running-services table and the managed-services list +- `Enter` opens logs from the top table and starts the selected service from the bottom list +- `/` opens inline filter editing in the footer +- `?` opens the help modal +- mouse click selects rows and mouse wheel scrolls the active pane +- logs header shows `Logs: | Port: | PID: ` ## File Locations @@ -139,12 +150,13 @@ Uses heuristics like parent process name, TTY attachment, and environment variab └── 2026-02-09T16-10-00.log ``` -## Tips & Tricks +## Notes 1. **Edit registry manually** - `~/.config/devpt/registry.json` is just JSON 2. **Check what's using a port** - `devpt ls --details | grep :3000` 3. **Find projects** - `devpt ls | grep "my-project"` 4. **See processes without names** - `devpt ls --details | grep -v "^-"` +5. **Quote glob patterns** - use `'web-*'` instead of `web-*` to avoid shell expansion ## Troubleshooting @@ -168,25 +180,8 @@ devpt ls | grep myapp kill -9 ``` -## Performance - -- `devpt ls` typically completes in 1-2 seconds -- No background daemon (everything is on-demand) -- Results are fresh on each run - -## What's Next? - -- Register your frequently-used dev servers -- Check the `README.md` for full documentation -- Explore the `--details` flag to see more info -- Set up the servers you manage with `devpt add` - -## Need Help? +## Help ```bash devpt help -devpt ls --help -devpt add --help ``` - -Or see the full README.md for detailed documentation. diff --git a/README.md b/README.md index fff5378..37620ca 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,18 @@ ![Dev Process Tracker hero](devpttitle.png) -Dev Process Tracker (`devpt`) helps you track and control local dev services from one place. +Dev Process Tracker (`devpt`) tracks and controls local dev services. ## What it does - Opens an interactive TUI by default (`devpt`) -- Shows running services with name, port, pid, project, command, and health +- Shows running services with name, port, pid, project, command, health, and memory usage - Tracks managed services you register with `devpt add` - Lets you start, restart, stop, remove, and inspect services - Provides logs for managed services and best-effort logs for unmanaged processes - Marks managed services as `crashed` when they exit unexpectedly and shows an inferred crash reason +- Displays per-process memory usage in the details pane with color-coded thresholds +- Shows a universal details pane for both running and managed services when selected ## Install @@ -27,7 +29,7 @@ go test ./... ## Challenge smoke test -Run a full checklist-oriented smoke flow in an isolated temp home: +Run a smoke flow in an isolated temp home: ```bash ./scripts/challenge_smoke_test.sh @@ -51,6 +53,11 @@ devpt restart my-app # Logs devpt logs my-app --lines 100 + +# Batch operations +devpt start api frontend worker +devpt restart 'web-*' +devpt stop web-api:3000 ``` ## CLI commands @@ -61,19 +68,42 @@ devpt logs my-app --lines 100 devpt ``` -Opens the interactive monitor. +Opens the TUI. ### Manage services ```bash devpt add "" [ports...] -devpt start -devpt stop +devpt start [...] # Start one or more services +devpt stop [...] # Stop one or more services devpt stop --port -devpt restart +devpt restart [...] # Restart one or more services devpt logs [--lines N] ``` +### Batch operations + +Start, stop, or restart multiple services at once: + +```bash +# Start multiple specific services +devpt start api frontend worker + +# Use glob patterns to match service names +devpt start 'web-*' # Starts all services matching 'web-*' +devpt stop '*-test' # Stops all services ending with '-test' +devpt restart 'claude-*' # Restarts all services starting with 'claude-*' + +# Target specific service by name:port +devpt start web-api:3000 # Start web-api on port 3000 only +devpt stop "some:thing" # Service with colon in literal name + +# Mix patterns and specific names +devpt start api 'web-*' worker +``` + +Batch operations run sequentially, print per-service status, continue on failure, and return exit code `1` if any service fails. + ### Inspect ```bash @@ -81,7 +111,7 @@ devpt ls [--details] devpt status ``` -`devpt status ` now includes a `CRASH DETAILS` section for crashed managed services, including an inferred reason and recent log lines. +`devpt status ` includes `CRASH DETAILS` for crashed managed services with an inferred reason and recent log lines. ### Meta @@ -96,22 +126,38 @@ devpt --version - `Enter`: - running list: open logs - managed list: start selected service +- mouse click: select rows in either list +- mouse wheel / page keys: scroll the active viewport - `Ctrl+E`: stop selected running service (with confirm) - `Ctrl+R`: restart selected running managed service - `Ctrl+A`: open command input (`add ...` prefilled) - `x` / `Delete` / `Ctrl+D`: remove selected managed service (with confirm) -- `/`: open filter input +- `/`: edit the inline filter in the footer - `Ctrl+L`: clear filter - `s`: cycle sort mode - `h`: toggle health detail -- `?`: open help +- `?`: open help modal - `b`: back from logs/command - `f`: toggle log follow mode (in logs view) - `q`: quit +## TUI layout + +- Running services are shown in the top table. The active sort column header is bold. +- Managed services are shown in a separate section below with the total count in the section title. +- A universal details pane on the right shows information for whichever service is currently selected (running or managed). +- Memory usage is displayed per-process in the details pane with color-coded thresholds (gray <50MB, default <200MB, yellow <500MB, orange <1GB, red >1GB). +- A copy-to-clipboard icon (⧉) appears next to command text in logs and the details pane. +- Filter state lives in the footer help row: + - default: `/ filter` + - editing: `/ >query` + - applied: `/ query` +- Help and confirmation are rendered as centered modals over the table. +- Logs view header is rendered as `Logs: | Port: | PID: `. + ## TUI command input -Inside TUI command mode (`:` or `Ctrl+A`), supported commands: +TUI command mode (`:` or `Ctrl+A`) supports: ```text add "" [ports...] @@ -125,16 +171,16 @@ help ## AI Agent Detection -Dev Process Tracker can identify servers started by AI agents (Claude, Cursor, Copilot, etc.). Detected servers show `agent:name` in the source column instead of `manual`. +Detected AI-started servers show `agent:name` in the source column instead of `manual`. ### Detection methods -1. **Parent process name** - If parent process is named `claude`, `cursor`, `copilot`, etc., it's detected as AI-started -2. **Environment variables** - Detects `CLAUDE_*`, `CURSOR_*`, `COPILOT_*` env var prefixes (Linux only; macOS uses parent process check only) +1. **Parent process name**: `claude`, `cursor`, `copilot`, and similar names +2. **Environment variables**: `CLAUDE_*`, `CURSOR_*`, `COPILOT_*` prefixes on platforms where available -### Naming convention for AI-managed services +### Naming convention -When registering managed services with `devpt add`, use a naming prefix to indicate ownership: +Use a naming prefix if you want ownership to be obvious in the registry: ```bash # Services started by Claude @@ -148,11 +194,7 @@ devpt add cursor-worker ~/projects/worker "npm start" 4000 devpt add copilot-service ~/projects/service "python app.py" 5000 ``` -When you use `devpt start` on these services, the naming makes it clear which AI agent manages them in the registry. - -### Example: Testing with built-in test servers - -The `sandbox/servers/` directory includes test servers for experimenting: +### Example with built-in test servers ```bash # From repo root, register test servers with AI owner names @@ -175,12 +217,14 @@ devpt start cursor-node-warnings devpt ``` -Each test server exposes `/health` (JSON) and `/` (plain text) endpoints. +Each test server exposes `/health` and `/`. ## Notes - Managed services are registry entries you control via `devpt`. - Running list is process-driven. Managed services can appear even before a port is bound. +- `name:port` is supported for CLI targeting where multiple services share a base name. +- Quote glob patterns like `'web-*'` so your shell does not expand them first. - If stop needs elevated permissions, TUI asks for confirmation to run `sudo kill -9 `. - Service names can include a prefix (e.g., `claude-`, `cursor-`, `copilot-`) to indicate AI agent ownership in your registry. - No login or API credentials are required for judges to run this project locally. diff --git a/cmd/devpt/main.go b/cmd/devpt/main.go index 9d552d2..559e803 100644 --- a/cmd/devpt/main.go +++ b/cmd/devpt/main.go @@ -6,6 +6,7 @@ import ( "os" "strconv" + "github.com/devports/devpt/pkg/buildinfo" "github.com/devports/devpt/pkg/cli" ) @@ -36,6 +37,8 @@ func main() { err = handleStop(app, os.Args[2:]) case "restart": err = handleRestart(app, os.Args[2:]) + case "remove", "rm": + err = handleRemove(app, os.Args[2:]) case "logs": err = handleLogs(app, os.Args[2:]) case "status": @@ -44,7 +47,7 @@ func main() { printUsage() os.Exit(0) case "--version", "-v": - fmt.Println("devpt version 0.1.0") + fmt.Printf("devpt version %s\n", buildinfo.Version) os.Exit(0) default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) @@ -92,36 +95,48 @@ func handleAdd(app *cli.App, args []string) error { func handleStart(app *cli.App, args []string) error { if len(args) < 1 { - fmt.Println("Usage: devpt start ") + fmt.Println("Usage: devpt start [name...]") return fmt.Errorf("service name required") } - return app.StartCmd(args[0]) + return app.BatchStartCmd(args) } func handleStop(app *cli.App, args []string) error { if len(args) < 1 { - fmt.Println("Usage: devpt stop ") + fmt.Println("Usage: devpt stop [name...]") return fmt.Errorf("service name or port required") } + // Check if --port flag is used (not supported with batch mode yet) if args[0] == "--port" { + if len(args) > 2 { + return fmt.Errorf("--port flag only supports single service") + } if len(args) < 2 { return fmt.Errorf("port required after --port") } return app.StopCmd(args[1]) } - return app.StopCmd(args[0]) + return app.BatchStopCmd(args) } func handleRestart(app *cli.App, args []string) error { if len(args) < 1 { - fmt.Println("Usage: devpt restart ") + fmt.Println("Usage: devpt restart [name...]") return fmt.Errorf("service name required") } - return app.RestartCmd(args[0]) + return app.BatchRestartCmd(args) +} + +func handleRemove(app *cli.App, args []string) error { + if len(args) < 1 { + fmt.Println("Usage: devpt remove ") + return fmt.Errorf("service name required") + } + return app.RemoveCmd(args[0]) } func handleLogs(app *cli.App, args []string) error { @@ -147,11 +162,11 @@ func handleLogs(app *cli.App, args []string) error { func handleStatus(app *cli.App, args []string) error { if len(args) < 1 { - fmt.Println("Usage: devpt status ") - return fmt.Errorf("service name or port required") + fmt.Println("Usage: devpt status [name|port|pattern...]") + return fmt.Errorf("service name, port, or pattern required") } - return app.StatusCmd(args[0]) + return app.StatusCmd(args) } func printUsage() { @@ -162,15 +177,25 @@ Default: Manage services: devpt add "" [ports...] - devpt start - devpt stop - devpt stop --port - devpt restart + devpt start [name...] + devpt stop [name...] + devpt restart [name...] + devpt remove devpt logs [--lines N] +Patterns (quote to prevent shell expansion): + '*' Match any sequence of characters + 'service*' Match services starting with "service" + '*-api' Match services ending with "-api" + '*web*' Match services containing "web" + +name:port format: + web-api:3000 Target service "web-api" on port 3000 + "some:thing" Literal service name containing a colon + Inspect: devpt ls [--details] - devpt status + devpt status [name|port|pattern...] Meta: devpt help @@ -186,6 +211,12 @@ Quick start: devpt start my-app devpt stop my-app +Batch operations: + devpt start api worker frontend + devpt stop 'web-*' # Quote patterns to prevent shell expansion + devpt restart '*-api' worker + devpt stop web-api:3000 # Target specific port + Top UI tips: Tab switch lists, Enter actions/start, / filter, ? help, ^A add ` diff --git a/go.mod b/go.mod index c3642d0..ec34beb 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,30 @@ module github.com/devports/devpt go 1.25.7 require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.2 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/mattn/go-runewidth v0.0.21 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cc7a40a..ce24743 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,54 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..9fb199a --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,26 @@ +# Lefthook configuration for dev-process-tracker +# Install: go install github.com/evilmartians/lefthook@latest && lefthook install + +pre-push: + parallel: false + commands: + validate-version: + name: Validate code version matches git tag + run: | + TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -n "$TAG" ]; then + # Strip 'v' prefix for comparison + TAG_VERSION="${TAG#v}" + CODE_VERSION=$(sed -n 's/const Version = "\([^"]*\)"/\1/p' pkg/buildinfo/version.go) + if [ "$CODE_VERSION" != "$TAG_VERSION" ]; then + echo "" + echo "❌ Version mismatch!" + echo " pkg/buildinfo/version.go: $CODE_VERSION" + echo " Latest git tag: $TAG" + echo "" + echo "Fix: Either update pkg/buildinfo/version.go to \"$TAG_VERSION\"" + echo " or delete the tag: git tag -d $TAG && git push --delete origin $TAG" + exit 1 + fi + echo "✅ Version matches: $TAG" + fi diff --git a/pkg/buildinfo/version.go b/pkg/buildinfo/version.go new file mode 100644 index 0000000..8347a86 --- /dev/null +++ b/pkg/buildinfo/version.go @@ -0,0 +1,3 @@ +package buildinfo + +const Version = "0.5.1" diff --git a/pkg/cli/app.go b/pkg/cli/app.go index b76fd09..b58d23a 100644 --- a/pkg/cli/app.go +++ b/pkg/cli/app.go @@ -9,9 +9,11 @@ import ( "sync" "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/lifecycle" "github.com/devports/devpt/pkg/models" "github.com/devports/devpt/pkg/process" "github.com/devports/devpt/pkg/registry" + "github.com/devports/devpt/pkg/resource" "github.com/devports/devpt/pkg/scanner" ) @@ -24,12 +26,19 @@ type App struct { scanner *scanner.ProcessScanner resolver *scanner.ProjectResolver detector *scanner.AgentDetector - processManager *process.Manager - healthChecker *health.Checker + processManager *process.Manager + healthChecker *health.Checker + resourceCollector *resource.Collector + stdout io.Writer + stderr io.Writer } // NewApp creates and initializes the application func NewApp() (*App, error) { + if err := scanner.CheckPrereqs(); err != nil { + return nil, err + } + config, err := models.GetConfigPaths() if err != nil { return nil, fmt.Errorf("failed to get config paths: %w", err) @@ -53,22 +62,46 @@ func NewApp() (*App, error) { scanner: scanner.NewProcessScanner(), resolver: scanner.NewProjectResolver(), detector: scanner.NewAgentDetector(), - processManager: process.NewManager(config.LogsDir), - healthChecker: health.NewChecker(0), + processManager: process.NewManager(config.LogsDir), + healthChecker: health.NewChecker(0), + resourceCollector: resource.NewCollector(), + stdout: os.Stdout, + stderr: os.Stderr, }, nil } +func (a *App) outWriter() io.Writer { + if a != nil && a.stdout != nil { + return a.stdout + } + return io.Discard +} + +func (a *App) errWriter() io.Writer { + if a != nil && a.stderr != nil { + return a.stderr + } + return io.Discard +} + +func (a *App) withOutput(stdout, stderr io.Writer) *App { + if a == nil { + return nil + } + clone := *a + clone.stdout = stdout + clone.stderr = stderr + return &clone +} + // discoverServers combines scanning and detection into complete server info func (a *App) discoverServers() ([]*models.ServerInfo, error) { - processes, err := a.scanner.ScanListeningPorts() + processes, err := (&appDeps{app: a}).ScanProcesses() if err != nil { return nil, fmt.Errorf("failed to scan processes: %w", err) } - // Filter to keep only development processes - commandMap := a.getCommandMap(processes) - processes = scanner.FilterDevProcesses(processes, commandMap) - + managedServices := a.registry.ListServices() for _, proc := range processes { if proc.CWD != "" { proc.ProjectRoot = a.resolver.FindProjectRoot(proc.CWD) @@ -76,127 +109,73 @@ func (a *App) discoverServers() ([]*models.ServerInfo, error) { a.detector.EnrichProcessRecord(proc) } + return a.buildServerInfos(processes, managedServices), nil +} + +func (a *App) buildServerInfos(processes []*models.ProcessRecord, managedServices []*models.ManagedService) []*models.ServerInfo { + commandMap := a.getCommandMap(processes) var servers []*models.ServerInfo + matchedServices := make(map[*models.ManagedService]*models.ProcessRecord, len(managedServices)) + matchedProcesses := make(map[*models.ProcessRecord]*models.ManagedService, len(managedServices)) + reconciledServices := make(map[*models.ManagedService]lifecycle.ReconciledService, len(managedServices)) + for _, svc := range managedServices { + reconciled := lifecycle.ReconcileWithResolver(svc, processes, managedServices, a.resolver.FindProjectRoot) + reconciledServices[svc] = reconciled + if reconciled.Status == string(models.StatusRunning) && reconciled.Verified && reconciled.Process != nil { + matchedServices[svc] = reconciled.Process + matchedProcesses[reconciled.Process] = svc + } + } + for _, proc := range processes { + if proc == nil { + continue + } + + matchedSvc := matchedProcesses[proc] + if matchedSvc == nil && !scanner.IsDevProcess(proc, commandMap[proc.PID]) { + continue + } + source := models.SourceManual if proc.AgentTag != nil { source = proc.AgentTag.Source } servers = append(servers, &models.ServerInfo{ - ProcessRecord: proc, - Source: source, - Status: "running", + ManagedService: matchedSvc, + ProcessRecord: proc, + Source: source, + Status: "running", }) } - type managedIdentity struct { - cwd string - root string - } - - managedServices := a.registry.ListServices() - portOwners := make(map[int][]*models.ManagedService) - rootOwners := make(map[string]int) - cwdOwners := make(map[string]int) - identities := make(map[*models.ManagedService]managedIdentity, len(managedServices)) for _, svc := range managedServices { - svcCWD := normalizePath(svc.CWD) - svcRoot := normalizePath(a.resolver.FindProjectRoot(svc.CWD)) - identities[svc] = managedIdentity{ - cwd: svcCWD, - root: svcRoot, - } - if svcCWD != "" { - cwdOwners[svcCWD]++ - } - if svcRoot != "" { - rootOwners[svcRoot]++ - } - for _, port := range svc.Ports { - portOwners[port] = append(portOwners[port], svc) - } - } - for _, svc := range managedServices { - found := false - identity := identities[svc] - svcCWD := identity.cwd - svcRoot := identity.root - - for _, server := range servers { - if server.ProcessRecord == nil || server.ManagedService != nil { - continue - } - procCWD := normalizePath(server.ProcessRecord.CWD) - procRoot := normalizePath(server.ProcessRecord.ProjectRoot) - if canMatchByPath(svcRoot, svcCWD, procRoot, procCWD, rootOwners, cwdOwners) { - server.ManagedService = svc - found = true - break - } - } - - if !found && len(svc.Ports) > 0 { - for _, port := range svc.Ports { - if owners := portOwners[port]; len(owners) != 1 { - continue - } - for _, server := range servers { - if server.ProcessRecord != nil && server.ProcessRecord.Port == port && server.ManagedService == nil { - procCWD := normalizePath(server.ProcessRecord.CWD) - procRoot := normalizePath(server.ProcessRecord.ProjectRoot) - if svcRoot != "" && procRoot != "" && svcRoot != procRoot { - continue - } - if svcCWD != "" && procCWD != "" && svcCWD != procCWD { - continue - } - server.ManagedService = svc - found = true - break - } - } - if found { - break - } - } + if matchedServices[svc] != nil { + continue } - if !found && svc.LastPID != nil && *svc.LastPID > 0 { - for _, server := range servers { - if server.ProcessRecord == nil || server.ManagedService != nil || server.ProcessRecord.PID != *svc.LastPID { - continue - } - procCWD := normalizePath(server.ProcessRecord.CWD) - procRoot := normalizePath(server.ProcessRecord.ProjectRoot) - if serviceMatchesProcess(svc, server.ProcessRecord, svcRoot, procRoot, procCWD) { - server.ManagedService = svc - found = true - break - } - } + reconciled := reconciledServices[svc] + status := reconciled.Status + if status == "" { + status = string(models.StatusStopped) } - - if !found { - status := "stopped" - crashReason := "" - crashLogTail := []string(nil) - if svc.LastPID != nil && *svc.LastPID > 0 { - status = "crashed" - crashReason, crashLogTail = a.getCrashReport(svc.Name, 12) - } - servers = append(servers, &models.ServerInfo{ - ManagedService: svc, - Source: models.SourceManaged, - Status: status, - CrashReason: crashReason, - CrashLogTail: crashLogTail, - }) + crashReason := "" + crashLogTail := []string(nil) + if status == string(models.StatusCrashed) { + crashReason, crashLogTail = a.getCrashReport(svc.Name, 12) } + servers = append(servers, &models.ServerInfo{ + ManagedService: svc, + Source: models.SourceManaged, + Status: status, + CrashReason: crashReason, + CrashLogTail: crashLogTail, + }) } - return servers, nil + return servers } func (a *App) getCrashReport(serviceName string, lines int) (string, []string) { @@ -262,42 +241,6 @@ func (a *App) getCommandMap(processes []*models.ProcessRecord) map[int]string { return cmdMap } -func normalizePath(p string) string { - p = strings.TrimSpace(p) - p = strings.TrimRight(p, "/") - return p -} - -func canMatchByPath(svcRoot, svcCWD, procRoot, procCWD string, rootOwners, cwdOwners map[string]int) bool { - if svcRoot != "" && procRoot != "" && svcRoot == procRoot && rootOwners[svcRoot] == 1 { - return true - } - if svcCWD != "" && procCWD != "" && svcCWD == procCWD && cwdOwners[svcCWD] == 1 { - return true - } - return false -} - -func serviceMatchesProcess(svc *models.ManagedService, proc *models.ProcessRecord, svcRoot, procRoot, procCWD string) bool { - if svc == nil || proc == nil { - return false - } - - svcCWD := normalizePath(svc.CWD) - if svcCWD != "" && procCWD != "" && svcCWD == procCWD { - return true - } - if svcRoot != "" && procRoot != "" && svcRoot == procRoot { - return true - } - for _, port := range svc.Ports { - if port > 0 && proc.Port == port { - return true - } - } - return false -} - func warnLegacyManagedCommands(reg *registry.Registry, out io.Writer) { if reg == nil || out == nil { return diff --git a/pkg/cli/app_matching_test.go b/pkg/cli/app_matching_test.go index 0aa4b97..d56cc83 100644 --- a/pkg/cli/app_matching_test.go +++ b/pkg/cli/app_matching_test.go @@ -6,94 +6,77 @@ import ( "github.com/devports/devpt/pkg/models" ) -func TestCanMatchByPathRequiresUniqueOwner(t *testing.T) { +func TestBuildServerInfosKeepsManagedNonDevProcess(t *testing.T) { t.Parallel() - if !canMatchByPath( - "/workspace/app", - "/workspace/app", - "/workspace/app", - "/workspace/app", - map[string]int{"/workspace/app": 1}, - map[string]int{"/workspace/app": 1}, - ) { - t.Fatal("expected unique path ownership to match") + app, _, _ := newTestApp(t) + lastPID := 1234 + svc := &models.ManagedService{ + Name: "postgres", + CWD: "/workspace/db", + Ports: []int{5432}, + LastPID: &lastPID, + } + proc := &models.ProcessRecord{ + PID: 1234, + Port: 5432, + Command: "/usr/local/bin/postgres", + CWD: "/workspace/db", + ProjectRoot: "/workspace/db", } - if canMatchByPath( - "/workspace/app", - "/workspace/app", - "/workspace/app", - "/workspace/app", - map[string]int{"/workspace/app": 2}, - map[string]int{"/workspace/app": 2}, - ) { - t.Fatal("expected ambiguous path ownership to be rejected") + servers := app.buildServerInfos([]*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + got := findServerForManagedService(servers, svc) + + if got == nil { + t.Fatal("expected managed service to be listed") + } + if got.ProcessRecord != proc { + t.Fatalf("expected managed process match, got %#v", got.ProcessRecord) + } + if got.Status != string(models.StatusRunning) { + t.Fatalf("expected running managed status, got %q", got.Status) } } -func TestServiceMatchesProcessRequiresStrongerSignalThanPID(t *testing.T) { +func TestBuildServerInfosRejectsPIDOnlyMatch(t *testing.T) { t.Parallel() + app, _, _ := newTestApp(t) + lastPID := 4242 svc := &models.ManagedService{ - Name: "api", - CWD: "/workspace/api", - Ports: []int{3000}, + Name: "api", + CWD: "/workspace/api", + Ports: []int{3000}, + LastPID: &lastPID, } - - if !serviceMatchesProcess( - svc, - &models.ProcessRecord{PID: 1234, Port: 3000}, - "/workspace/api", - "", - "", - ) { - t.Fatal("expected declared port to validate the process") + proc := &models.ProcessRecord{ + PID: 4242, + Port: 9999, + Command: "/usr/sbin/unrelated", + CWD: "/tmp/other", + ProjectRoot: "/tmp/other", } - if !serviceMatchesProcess( - svc, - &models.ProcessRecord{PID: 1234, Port: 9999, CWD: "/workspace/api"}, - "/workspace/api", - "/workspace/api", - "/workspace/api", - ) { - t.Fatal("expected matching cwd/project root to validate the process") - } + servers := app.buildServerInfos([]*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + got := findServerForManagedService(servers, svc) - if serviceMatchesProcess( - svc, - &models.ProcessRecord{PID: 1234, Port: 9999, CWD: "/tmp/other"}, - "/workspace/api", - "/tmp/other", - "/tmp/other", - ) { - t.Fatal("expected PID-only match without path/port agreement to be rejected") + if got == nil { + t.Fatal("expected managed service to be listed") } -} - -func TestManagedServicePIDReturnsMatchedProcess(t *testing.T) { - t.Parallel() - - servers := []*models.ServerInfo{ - { - ProcessRecord: &models.ProcessRecord{PID: 2001}, - ManagedService: &models.ManagedService{ - Name: "api", - }, - }, - { - ProcessRecord: &models.ProcessRecord{PID: 2002}, - ManagedService: &models.ManagedService{ - Name: "worker", - }, - }, + if got.ProcessRecord != nil { + t.Fatalf("expected PID-only candidate to be rejected, got %#v", got.ProcessRecord) } - - if got := managedServicePID(servers, "worker"); got != 2002 { - t.Fatalf("managedServicePID(..., worker) = %d, want 2002", got) + if got.Status != string(models.StatusCrashed) { + t.Fatalf("expected stale PID to be reported as crashed, got %q", got.Status) } - if got := managedServicePID(servers, "missing"); got != 0 { - t.Fatalf("managedServicePID(..., missing) = %d, want 0", got) +} + +func findServerForManagedService(servers []*models.ServerInfo, svc *models.ManagedService) *models.ServerInfo { + for _, srv := range servers { + if srv.ManagedService == svc { + return srv + } } + return nil } diff --git a/pkg/cli/batch_executor.go b/pkg/cli/batch_executor.go new file mode 100644 index 0000000..39c1b17 --- /dev/null +++ b/pkg/cli/batch_executor.go @@ -0,0 +1,172 @@ +package cli + +import ( + "fmt" + "sort" + "strings" + + "github.com/devports/devpt/pkg/lifecycle" + "github.com/devports/devpt/pkg/models" +) + +// serviceLister provides access to the list of managed services. +type serviceLister interface { + ListServices() []*models.ManagedService +} + +// LifecycleBatchResult holds the outcome of a single lifecycle batch operation. +type LifecycleBatchResult struct { + Name string + Outcome lifecycle.Outcome + Message string + PID int +} + +// BatchSummary holds the aggregate summary of a batch operation (contract §7.4). +type BatchSummary struct { + Total int + Succeeded int + Noop int + Blocked int + Failed int + Invalid int + NotFound int + Results []LifecycleBatchResult +} + +// RunLifecycleBatch executes a batch operation using the lifecycle manager. +// It processes services in stable order and returns a structured summary. +func RunLifecycleBatch( + names []string, + op func(svc *models.ManagedService) lifecycle.Result, + reg serviceLister, +) BatchSummary { + summary := BatchSummary{} + + if len(names) == 0 { + summary.Results = []LifecycleBatchResult{ + {Name: "", Outcome: lifecycle.OutcomeInvalid, Message: "no service names provided"}, + } + summary.Total = 1 + summary.Invalid = 1 + return summary + } + + // Expand glob patterns + services := reg.ListServices() + expanded := ExpandPatterns(names, services) + + if len(expanded) == 0 { + summary.Results = []LifecycleBatchResult{ + {Name: "", Outcome: lifecycle.OutcomeNotFound, Message: "no services found matching patterns"}, + } + summary.Total = 1 + summary.NotFound = 1 + return summary + } + + // Sort for stable, deterministic order + sort.Strings(expanded) + + summary.Results = make([]LifecycleBatchResult, 0, len(expanded)) + summary.Total = len(expanded) + + for _, name := range expanded { + allServices := reg.ListServices() + svc, errs := LookupServiceWithFallback(name, allServices) + if svc == nil { + summary.Results = append(summary.Results, LifecycleBatchResult{ + Name: name, + Outcome: lifecycle.OutcomeNotFound, + Message: fmt.Sprintf("service %q not found: %s", name, joinErrs(errs)), + }) + summary.NotFound++ + continue + } + + result := op(svc) + batchResult := LifecycleBatchResult{ + Name: name, + Outcome: result.Outcome, + Message: result.Message, + PID: result.PID, + } + summary.Results = append(summary.Results, batchResult) + + switch result.Outcome { + case lifecycle.OutcomeSuccess: + summary.Succeeded++ + case lifecycle.OutcomeNoop: + summary.Noop++ + case lifecycle.OutcomeBlocked: + summary.Blocked++ + case lifecycle.OutcomeFailed: + summary.Failed++ + case lifecycle.OutcomeInvalid: + summary.Invalid++ + case lifecycle.OutcomeNotFound: + summary.NotFound++ + } + } + + return summary +} + +// FormatBatchSummary formats a BatchSummary as a human-readable string +// following the contract §7.4 summary format. +func FormatBatchSummary(summary BatchSummary) string { + var sb strings.Builder + + fmt.Fprintf(&sb, "Matched %d services\n", summary.Total) + + parts := []string{} + if summary.Succeeded > 0 { + parts = append(parts, fmt.Sprintf("%d succeeded", summary.Succeeded)) + } + if summary.Noop > 0 { + parts = append(parts, fmt.Sprintf("%d noop", summary.Noop)) + } + if summary.Blocked > 0 { + parts = append(parts, fmt.Sprintf("%d blocked", summary.Blocked)) + } + if summary.Failed > 0 { + parts = append(parts, fmt.Sprintf("%d failed", summary.Failed)) + } + if summary.Invalid > 0 { + parts = append(parts, fmt.Sprintf("%d invalid", summary.Invalid)) + } + if summary.NotFound > 0 { + parts = append(parts, fmt.Sprintf("%d not found", summary.NotFound)) + } + fmt.Fprintln(&sb, strings.Join(parts, ", ")) + + // Per-service details + for _, r := range summary.Results { + if r.Outcome == lifecycle.OutcomeSuccess { + action := extractAction(r.Message) + fmt.Fprintf(&sb, "- %s: %s\n", r.Name, action) + } else { + fmt.Fprintf(&sb, "- %s: %s\n", r.Name, r.Message) + } + } + + return sb.String() +} + +func extractAction(message string) string { + if idx := strings.Index(message, ": "); idx >= 0 { + return message[idx+2:] + } + return message +} + +func joinErrs(errs []string) string { + joined := "" + for i, e := range errs { + if i > 0 { + joined += "; " + } + joined += e + } + return joined +} diff --git a/pkg/cli/batch_executor_test.go b/pkg/cli/batch_executor_test.go new file mode 100644 index 0000000..cda991c --- /dev/null +++ b/pkg/cli/batch_executor_test.go @@ -0,0 +1,135 @@ +package cli + +import ( + "testing" + + "github.com/devports/devpt/pkg/lifecycle" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +// --------------------------------------------------------------------------- +// RunLifecycleBatch +// --------------------------------------------------------------------------- + +func TestRunLifecycleBatch_EmptyInput(t *testing.T) { + t.Parallel() + + registry := newMockRegistry() + summary := RunLifecycleBatch([]string{}, func(svc *models.ManagedService) lifecycle.Result { + return lifecycle.Result{Outcome: lifecycle.OutcomeSuccess} + }, registry) + + assert.Equal(t, 1, summary.Total) + assert.Equal(t, 1, summary.Invalid) +} + +func TestRunLifecycleBatch_AllSuccess(t *testing.T) { + t.Parallel() + + registry := newMockRegistry( + &models.ManagedService{Name: "api", Ports: []int{3000}}, + &models.ManagedService{Name: "worker", Ports: []int{4000}}, + ) + + summary := RunLifecycleBatch([]string{"api", "worker"}, func(svc *models.ManagedService) lifecycle.Result { + return lifecycle.Result{Outcome: lifecycle.OutcomeSuccess, Message: "started", PID: 1234} + }, registry) + + assert.Equal(t, 2, summary.Total) + assert.Equal(t, 2, summary.Succeeded) +} + +func TestRunLifecycleBatch_MixedOutcomes(t *testing.T) { + t.Parallel() + + registry := newMockRegistry( + &models.ManagedService{Name: "api", Ports: []int{3000}}, + &models.ManagedService{Name: "worker", Ports: []int{4000}}, + &models.ManagedService{Name: "web", Ports: []int{5000}}, + ) + + i := 0 + outcomes := []lifecycle.Outcome{lifecycle.OutcomeSuccess, lifecycle.OutcomeNoop, lifecycle.OutcomeBlocked} + summary := RunLifecycleBatch([]string{"api", "worker", "web"}, func(svc *models.ManagedService) lifecycle.Result { + outcome := outcomes[i] + i++ + return lifecycle.Result{Outcome: outcome, Message: string(outcome)} + }, registry) + + assert.Equal(t, 3, summary.Total) + assert.Equal(t, 1, summary.Succeeded) + assert.Equal(t, 1, summary.Noop) + assert.Equal(t, 1, summary.Blocked) +} + +func TestRunLifecycleBatch_NotFound(t *testing.T) { + t.Parallel() + + registry := newMockRegistry() + summary := RunLifecycleBatch([]string{"nonexistent"}, func(svc *models.ManagedService) lifecycle.Result { + return lifecycle.Result{Outcome: lifecycle.OutcomeSuccess} + }, registry) + + assert.Equal(t, 1, summary.Total) + assert.Equal(t, 1, summary.NotFound) +} + +func TestRunLifecycleBatch_StableOrder(t *testing.T) { + t.Parallel() + + registry := newMockRegistry( + &models.ManagedService{Name: "c", Ports: []int{3}}, + &models.ManagedService{Name: "a", Ports: []int{1}}, + &models.ManagedService{Name: "b", Ports: []int{2}}, + ) + + summary := RunLifecycleBatch([]string{"c", "a", "b"}, func(svc *models.ManagedService) lifecycle.Result { + return lifecycle.Result{Outcome: lifecycle.OutcomeSuccess, Message: "ok"} + }, registry) + + names := make([]string, len(summary.Results)) + for i, r := range summary.Results { + names[i] = r.Name + } + assert.Equal(t, []string{"a", "b", "c"}, names, "lifecycle batch should process in sorted order") +} + +func TestFormatBatchSummary(t *testing.T) { + t.Parallel() + + summary := BatchSummary{ + Total: 4, + Succeeded: 2, + Noop: 1, + Blocked: 1, + Results: []LifecycleBatchResult{ + {Name: "api", Outcome: lifecycle.OutcomeSuccess, Message: "Success: started"}, + {Name: "worker", Outcome: lifecycle.OutcomeSuccess, Message: "Success: started"}, + {Name: "web", Outcome: lifecycle.OutcomeNoop, Message: "No-op: already running"}, + {Name: "redis", Outcome: lifecycle.OutcomeBlocked, Message: "Blocked: port 6379 is in use"}, + }, + } + + formatted := FormatBatchSummary(summary) + assert.Contains(t, formatted, "Matched 4 services") + assert.Contains(t, formatted, "2 succeeded") + assert.Contains(t, formatted, "1 noop") + assert.Contains(t, formatted, "1 blocked") +} + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +type mockRegistry struct { + services []*models.ManagedService +} + +func newMockRegistry(services ...*models.ManagedService) *mockRegistry { + return &mockRegistry{services: services} +} + +func (m *mockRegistry) ListServices() []*models.ManagedService { + return m.services +} diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index a42affd..abf3945 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -1,407 +1,170 @@ package cli import ( - "errors" "fmt" - "os" "strconv" "strings" - "text/tabwriter" - "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/lifecycle" "github.com/devports/devpt/pkg/models" "github.com/devports/devpt/pkg/process" ) -// ListCmd handles the 'ls' command func (a *App) ListCmd(detailed bool) error { servers, err := a.discoverServers() - if err != nil { - return err - } - - return a.printServerTable(servers, detailed) + if err != nil { return err } + return PrintServerTable(a.outWriter(), servers, detailed) } - -// printServerTable prints servers in tabular format -func (a *App) printServerTable(servers []*models.ServerInfo, detailed bool) error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - - if detailed { - fmt.Fprintln(w, "Name\tPort\tPID\tProject\tCommand\tSource\tStatus") - for _, srv := range servers { - fmt.Fprintln(w, a.formatServerRow(srv, true)) - } - } else { - fmt.Fprintln(w, "Name\tPort\tPID\tProject\tSource\tStatus") - for _, srv := range servers { - fmt.Fprintln(w, a.formatServerRow(srv, false)) - } - } - - return w.Flush() -} - -// formatServerRow formats a server as a table row -func (a *App) formatServerRow(srv *models.ServerInfo, detailed bool) string { - name := "-" - port := "-" - pid := "-" - project := "-" - command := "-" - source := string(srv.Source) - status := srv.Status - - if srv.ManagedService != nil { - name = srv.ManagedService.Name - if len(srv.ManagedService.Ports) > 0 { - port = fmt.Sprintf("%d", srv.ManagedService.Ports[0]) - } - command = srv.ManagedService.Command - } - - if srv.ProcessRecord != nil { - pid = fmt.Sprintf("%d", srv.ProcessRecord.PID) - port = fmt.Sprintf("%d", srv.ProcessRecord.Port) - project = srv.ProcessRecord.ProjectRoot - if command == "-" { - command = srv.ProcessRecord.Command - } - - // Determine source - if srv.ProcessRecord.AgentTag != nil { - source = fmt.Sprintf("%s:%s", srv.ProcessRecord.AgentTag.Source, srv.ProcessRecord.AgentTag.AgentName) - } else { - source = string(models.SourceManual) - } - } - - if detailed { - return fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s", name, port, pid, project, command, source, status) - } - - return fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s", name, port, pid, project, source, status) -} - -// AddCmd registers a new managed service func (a *App) AddCmd(name, cwd, command string, ports []int) error { - if err := validateManagedCommand(command); err != nil { - return err - } - - svc := &models.ManagedService{ - Name: name, - CWD: cwd, - Command: command, - Ports: ports, - } - - if err := a.registry.AddService(svc); err != nil { - return err - } - - fmt.Printf("Service %q registered successfully\n", name) + if err := validateManagedCommand(command); err != nil { return err } + svc := &models.ManagedService{Name: name, CWD: cwd, Command: command, Ports: ports} + if err := a.registry.AddService(svc); err != nil { return err } + fmt.Fprintf(a.outWriter(), "Service %q registered successfully\n", name) return nil } +func (a *App) RemoveCmd(name string) error { return a.registry.RemoveService(name) } -// RemoveCmd removes a managed service -func (a *App) RemoveCmd(name string) error { - return a.registry.RemoveService(name) +// lifecycleManager returns a lifecycle.LifecycleManager wired to the App's dependencies. +func (a *App) lifecycleManager() *lifecycle.LifecycleManager { + return lifecycle.NewLifecycleManager(&appDeps{app: a}) } -// StartCmd starts a managed service func (a *App) StartCmd(name string) error { - svc := a.registry.GetService(name) - if svc == nil { - return fmt.Errorf("service %q not found", name) - } + svc, errs := LookupServiceWithFallback(name, a.registry.ListServices()) + if svc == nil { return fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) } - fmt.Printf("Starting service %q...\n", name) - pid, err := a.processManager.Start(svc) - if err != nil { - return fmt.Errorf("failed to start service: %w", err) - } + mgr := a.lifecycleManager() + result := mgr.Start(svc) - // Update registry with new PID - if err := a.registry.UpdateServicePID(name, pid); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to update registry: %v\n", err) - } + fmt.Fprintln(a.outWriter(), result.Message) - fmt.Printf("Service %q started with PID %d\n", name, pid) + if result.Outcome == lifecycle.OutcomeFailed || result.Outcome == lifecycle.OutcomeInvalid || result.Outcome == lifecycle.OutcomeBlocked { + return fmt.Errorf("%s", result.Message) + } return nil } -// StopCmd stops a service by name or port func (a *App) StopCmd(identifier string) error { - var targetPID int - targetServiceName := "" + // Try to resolve as a managed service first + if svc, _ := LookupServiceWithFallback(identifier, a.registry.ListServices()); svc != nil { + mgr := a.lifecycleManager() + result := mgr.Stop(svc) - // Check if identifier is a service name - if svc := a.registry.GetService(identifier); svc != nil { - targetServiceName = svc.Name - servers, err := a.discoverServers() - if err != nil { - return err - } - targetPID = managedServicePID(servers, svc.Name) - if targetPID == 0 && svc.LastPID != nil && *svc.LastPID > 0 && a.processManager.IsRunning(*svc.LastPID) { - return fmt.Errorf("cannot safely determine PID for service %q; stored PID is no longer validated against a live managed process", identifier) - } - } else { - // Try parsing as port number - port, err := strconv.Atoi(identifier) - if err != nil { - return fmt.Errorf("invalid service name or port: %s", identifier) - } - - // Find process by port - servers, err := a.discoverServers() - if err != nil { - return err - } - - for _, srv := range servers { - if srv.ProcessRecord != nil && srv.ProcessRecord.Port == port { - targetPID = srv.ProcessRecord.PID - if srv.ManagedService != nil { - targetServiceName = srv.ManagedService.Name - } - break - } - } + fmt.Fprintln(a.outWriter(), result.Message) - if targetPID == 0 { - return fmt.Errorf("no process found on port %d", port) + if result.Outcome == lifecycle.OutcomeFailed || result.Outcome == lifecycle.OutcomeInvalid || result.Outcome == lifecycle.OutcomeBlocked { + return fmt.Errorf("%s", result.Message) } + return nil } - if targetPID == 0 { - return fmt.Errorf("cannot determine PID to stop") - } + // Fall back to raw PID stop by port (for unmanaged/manual processes) + port, err := strconv.Atoi(identifier) + if err != nil { return fmt.Errorf("invalid service name or port: %s", identifier) } - // Stop the process - fmt.Printf("Stopping PID %d...\n", targetPID) - if err := a.processManager.Stop(targetPID, 5000000000); err != nil { // 5 second timeout - if errors.Is(err, process.ErrNeedSudo) { - return fmt.Errorf("requires sudo to terminate PID %d", targetPID) - } - if isProcessFinishedErr(err) { - if targetServiceName != "" { - if clrErr := a.registry.ClearServicePID(targetServiceName); clrErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clear PID for %q: %v\n", targetServiceName, clrErr) - } - } - return nil - } - return fmt.Errorf("failed to stop process: %w", err) - } + servers, err := a.discoverServers() + if err != nil { return err } - fmt.Printf("Process %d stopped\n", targetPID) - if targetServiceName != "" { - if err := a.registry.ClearServicePID(targetServiceName); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clear PID for %q: %v\n", targetServiceName, err) + var targetPID int + for _, srv := range servers { + if srv.ProcessRecord != nil && srv.ProcessRecord.Port == port { + targetPID = srv.ProcessRecord.PID + break } } - return nil + if targetPID == 0 { return fmt.Errorf("no process found on port %d", port) } + + fmt.Fprintf(a.outWriter(), "Stopping PID %d...\n", targetPID) + result := StopProcess(a.processManager, targetPID, defaultStopTimeout) + if result.SudoRequired { return fmt.Errorf("requires sudo to terminate PID %d", targetPID) } + if result.AlreadyDead { return nil } + if result.Stopped { fmt.Fprintf(a.outWriter(), "Process %d stopped\n", targetPID); return nil } + if result.ClearError != nil { return result.ClearError } + return fmt.Errorf("failed to stop process PID %d", targetPID) } -// RestartCmd restarts a managed service func (a *App) RestartCmd(name string) error { - svc := a.registry.GetService(name) - if svc == nil { - return fmt.Errorf("service %q not found", name) - } + svc, errs := LookupServiceWithFallback(name, a.registry.ListServices()) + if svc == nil { return fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) } - // Stop if running - if pid, err := a.validatedManagedPID(svc); err != nil { - return err - } else if pid > 0 { - fmt.Printf("Stopping service %q...\n", name) - if err := a.processManager.Stop(pid, 5000000000); err != nil { // 5 second timeout - fmt.Fprintf(os.Stderr, "Warning: failed to stop service: %v\n", err) - } - } + mgr := a.lifecycleManager() + result := mgr.Restart(svc) - // Start - fmt.Printf("Starting service %q...\n", name) - pid, err := a.processManager.Start(svc) - if err != nil { - return fmt.Errorf("failed to start service: %w", err) - } + fmt.Fprintln(a.outWriter(), result.Message) - // Update registry - if err := a.registry.UpdateServicePID(name, pid); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to update registry: %v\n", err) + if result.Outcome == lifecycle.OutcomeFailed || result.Outcome == lifecycle.OutcomeInvalid || result.Outcome == lifecycle.OutcomeBlocked { + return fmt.Errorf("%s", result.Message) } - - fmt.Printf("Service %q restarted with PID %d\n", name, pid) return nil } -// LogsCmd displays recent logs for a service -func (a *App) LogsCmd(name string, lines int) error { - svc := a.registry.GetService(name) - if svc == nil { - return fmt.Errorf("service %q not found", name) +func (a *App) BatchStartCmd(names []string) error { + mgr := a.lifecycleManager() + summary := RunLifecycleBatch(names, mgr.Start, a.registry) + fmt.Fprint(a.outWriter(), FormatBatchSummary(summary)) + if summary.Failed > 0 || summary.Invalid > 0 || summary.NotFound > 0 { + return fmt.Errorf("batch start completed with %d failure(s)", summary.Failed+summary.Invalid+summary.NotFound) } - - logLines, err := a.processManager.Tail(svc.Name, lines) - if err != nil { - return err - } - - fmt.Printf("Logs for service %q:\n", name) - for _, line := range logLines { - fmt.Println(line) - } - return nil } -func isProcessFinishedErr(err error) bool { - if err == nil { - return false +func (a *App) BatchStopCmd(names []string) error { + mgr := a.lifecycleManager() + summary := RunLifecycleBatch(names, mgr.Stop, a.registry) + fmt.Fprint(a.outWriter(), FormatBatchSummary(summary)) + if summary.Failed > 0 || summary.Invalid > 0 || summary.NotFound > 0 { + return fmt.Errorf("batch stop completed with %d failure(s)", summary.Failed+summary.Invalid+summary.NotFound) } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "process already finished") || strings.Contains(msg, "no such process") + return nil } -func managedServicePID(servers []*models.ServerInfo, serviceName string) int { - for _, srv := range servers { - if srv == nil || srv.ManagedService == nil || srv.ProcessRecord == nil { - continue - } - if srv.ManagedService.Name == serviceName { - return srv.ProcessRecord.PID - } +func (a *App) BatchRestartCmd(names []string) error { + mgr := a.lifecycleManager() + summary := RunLifecycleBatch(names, mgr.Restart, a.registry) + fmt.Fprint(a.outWriter(), FormatBatchSummary(summary)) + if summary.Failed > 0 || summary.Invalid > 0 || summary.NotFound > 0 { + return fmt.Errorf("batch restart completed with %d failure(s)", summary.Failed+summary.Invalid+summary.NotFound) } - return 0 + return nil } -func (a *App) validatedManagedPID(svc *models.ManagedService) (int, error) { - if svc == nil { - return 0, nil - } - servers, err := a.discoverServers() - if err != nil { - return 0, err - } - pid := managedServicePID(servers, svc.Name) - if pid != 0 { - return pid, nil - } - if svc.LastPID != nil && *svc.LastPID > 0 && a.processManager.IsRunning(*svc.LastPID) { - return 0, fmt.Errorf("cannot safely determine PID for service %q; stored PID is no longer validated against a live managed process", svc.Name) - } - return 0, nil +func (a *App) LogsCmd(name string, lines int) error { + svc, errs := LookupServiceWithFallback(name, a.registry.ListServices()) + if svc == nil { return fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) } + logLines, err := a.processManager.Tail(svc.Name, lines) + if err != nil { return err } + fmt.Printf("Logs for service %q:\n", svc.Name) + for _, line := range logLines { fmt.Println(line) } + return nil } - -// StatusCmd shows detailed info for a specific server -func (a *App) StatusCmd(identifier string) error { +func (a *App) StatusCmd(identifiers []string) error { servers, err := a.discoverServers() - if err != nil { - return err - } - - var target *models.ServerInfo - - // Find by name or port - for _, srv := range servers { - if srv.ManagedService != nil && srv.ManagedService.Name == identifier { - target = srv - break - } - if srv.ProcessRecord != nil && fmt.Sprintf("%d", srv.ProcessRecord.Port) == identifier { - target = srv - break - } - } - - if target == nil { - return fmt.Errorf("server %q not found", identifier) - } - - return a.printServerStatus(target) -} - -// printServerStatus prints detailed status for a server -func (a *App) printServerStatus(srv *models.ServerInfo) error { - line := "============================================================" - fmt.Println("\n" + line) - fmt.Println("SERVER DETAILS") - fmt.Println(line) - - if srv.ManagedService != nil { - fmt.Printf("Name: %s\n", srv.ManagedService.Name) - fmt.Printf("Command: %s\n", srv.ManagedService.Command) - fmt.Printf("CWD: %s\n", srv.ManagedService.CWD) - fmt.Printf("Ports: ") - for i, p := range srv.ManagedService.Ports { - if i > 0 { - fmt.Print(", ") + if err != nil { return err } + allServices := a.registry.ListServices() + var matched []*models.ServerInfo + for _, id := range identifiers { + if strings.Contains(id, "*") { + for _, name := range ExpandPatterns([]string{id}, allServices) { + for _, srv := range servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name { + matched = append(matched, srv); break + } + } } - fmt.Printf("%d", p) - } - fmt.Println() - } - - if srv.ProcessRecord != nil { - fmt.Printf("\nPort: %d\n", srv.ProcessRecord.Port) - fmt.Printf("PID: %d\n", srv.ProcessRecord.PID) - fmt.Printf("PPID: %d\n", srv.ProcessRecord.PPID) - fmt.Printf("User: %s\n", srv.ProcessRecord.User) - fmt.Printf("Command: %s\n", srv.ProcessRecord.Command) - fmt.Printf("CWD: %s\n", srv.ProcessRecord.CWD) - if srv.ProcessRecord.ProjectRoot != "" { - fmt.Printf("Project: %s\n", srv.ProcessRecord.ProjectRoot) - } - - // Health check - dashes := "------------------------------------------------------------" - fmt.Println("\n" + dashes) - fmt.Println("HEALTH STATUS") - fmt.Println(dashes) - check := a.healthChecker.Check(srv.ProcessRecord.Port) - icon := health.StatusIcon(check.Status) - fmt.Printf("Status: %s %s\n", icon, check.Status) - fmt.Printf("Response: %dms\n", check.ResponseMs) - fmt.Printf("Message: %s\n", check.Message) - - // Agent detection - if srv.ProcessRecord.AgentTag != nil { - fmt.Println("\n" + dashes) - fmt.Println("AI AGENT DETECTION") - fmt.Println(dashes) - fmt.Printf("Source: %s\n", srv.ProcessRecord.AgentTag.Source) - fmt.Printf("Agent: %s\n", srv.ProcessRecord.AgentTag.AgentName) - fmt.Printf("Confidence: %s\n", srv.ProcessRecord.AgentTag.Confidence) - } - } - - if srv.Status == "crashed" { - dashes := "------------------------------------------------------------" - fmt.Println("\n" + dashes) - fmt.Println("CRASH DETAILS") - fmt.Println(dashes) - if srv.CrashReason != "" { - fmt.Printf("Reason: %s\n", srv.CrashReason) } else { - fmt.Println("Reason: unavailable") - } - if len(srv.CrashLogTail) > 0 { - fmt.Println("Recent logs:") - for _, line := range srv.CrashLogTail { - if strings.TrimSpace(line) == "" { - continue - } - fmt.Printf(" %s\n", line) + for _, srv := range servers { + if srv.ManagedService != nil && srv.ManagedService.Name == id { matched = append(matched, srv); break } + if srv.ProcessRecord != nil && fmt.Sprintf("%d", srv.ProcessRecord.Port) == id { matched = append(matched, srv); break } } } } - - fmt.Printf("\nStatus: %s\n", srv.Status) - fmt.Printf("Source: %s\n", srv.Source) - fmt.Println(line + "\n") - + if len(matched) == 0 { return fmt.Errorf("no servers found matching %s", strings.Join(identifiers, ", ")) } + for _, srv := range matched { + var hc *health.HealthCheck + if srv.ProcessRecord != nil { hc = a.healthChecker.Check(srv.ProcessRecord.Port) } + if err := PrintServerStatus(a.outWriter(), srv, hc); err != nil { return err } + } return nil } + +var _ = process.ErrNeedSudo diff --git a/pkg/cli/commands_status_test.go b/pkg/cli/commands_status_test.go new file mode 100644 index 0000000..fe535e5 --- /dev/null +++ b/pkg/cli/commands_status_test.go @@ -0,0 +1,887 @@ +package cli + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/models" + "github.com/devports/devpt/pkg/process" + "github.com/devports/devpt/pkg/registry" + "github.com/devports/devpt/pkg/scanner" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// newTestApp creates a fully-initialized App backed by a temp-dir registry. +// The scanner is real but will find no listening processes in a test environment, +// so only managed services with Status "stopped" / "crashed" show up via discoverServers. +func newTestApp(t *testing.T) (*App, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + + tmp := t.TempDir() + reg := registry.NewRegistry(filepath.Join(tmp, "registry.json")) + require.NoError(t, reg.Load(), "load registry") + + var stdout, stderr bytes.Buffer + app := &App{ + config: models.ConfigPaths{RegistryFile: filepath.Join(tmp, "registry.json"), LogsDir: filepath.Join(tmp, "logs")}, + registry: reg, + scanner: scanner.NewProcessScanner(), + resolver: scanner.NewProjectResolver(), + detector: scanner.NewAgentDetector(), + processManager: process.NewManager(filepath.Join(tmp, "logs")), + healthChecker: health.NewChecker(0), + stdout: &stdout, + stderr: &stderr, + } + return app, &stdout, &stderr +} + +// addManagedService is a test helper that registers a managed service. +func addManagedService(t *testing.T, reg *registry.Registry, name, command string, ports []int) { + t.Helper() + + svc := &models.ManagedService{ + Name: name, + CWD: t.TempDir(), + Command: command, + Ports: ports, + } + require.NoError(t, reg.AddService(svc), "add service %q", name) +} + +// withCrashedService creates a managed service with a LastPID to simulate a crash. +func withCrashedService(t *testing.T, reg *registry.Registry, name, command string, ports []int, lastPID int) { + t.Helper() + + svc := &models.ManagedService{ + Name: name, + CWD: t.TempDir(), + Command: command, + Ports: ports, + LastPID: &lastPID, + } + require.NoError(t, reg.AddService(svc), "add crashed service %q", name) +} + +// captureStatusOutput runs fn then returns the app's stdout buffer. +func captureStatusOutput(app *App, fn func()) string { + fn() + if buf, ok := app.stdout.(*bytes.Buffer); ok { + return buf.String() + } + return "" +} + +// --------------------------------------------------------------------------- +// 1. Exact name match (backward compat) +// --------------------------------------------------------------------------- + +func TestStatusCmd_ExactNameMatch(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "offgrid-api", "node server.js", []int{3000}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"offgrid-api"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "offgrid-api", "output should mention service name") + assert.Contains(t, output, "SERVER DETAILS", "output should contain details header") +} + +// --------------------------------------------------------------------------- +// 2. Port match (backward compat) — unit test of matching logic +// --------------------------------------------------------------------------- + +func TestStatusCmd_PortMatch(t *testing.T) { + t.Parallel() + + servers := []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1234, Port: 8080}, + ManagedService: &models.ManagedService{Name: "web", Command: "nginx", Ports: []int{8080}}, + Source: models.SourceManaged, + Status: "running", + }, + } + + // Verify port string matching works as in StatusCmd + var found bool + identifier := "8080" + for _, srv := range servers { + if srv.ProcessRecord != nil && fmt.Sprintf("%d", srv.ProcessRecord.Port) == identifier { + found = true + break + } + } + assert.True(t, found, "port '8080' should match ProcessRecord with Port 8080") + + // Verify it does NOT match wrong ports + var wrongMatch bool + for _, srv := range servers { + if srv.ProcessRecord != nil && fmt.Sprintf("%d", srv.ProcessRecord.Port) == "9090" { + wrongMatch = true + break + } + } + assert.False(t, wrongMatch, "port '9090' should not match server on 8080") +} + +// --------------------------------------------------------------------------- +// 3. Not found — error when no service matches exact name +// --------------------------------------------------------------------------- + +func TestStatusCmd_NotFound(t *testing.T) { + t.Parallel() + + app, _, _ := newTestApp(t) + // No services registered + + err := app.StatusCmd([]string{"nonexistent"}) + require.Error(t, err, "StatusCmd should return error for unknown service") + assert.Contains(t, err.Error(), "no servers found", "error message should mention no servers found") + assert.Contains(t, err.Error(), "nonexistent", "error should include the identifier") +} + +// --------------------------------------------------------------------------- +// 4. Glob pattern single match +// --------------------------------------------------------------------------- + +func TestStatusCmd_GlobPatternSingleMatch(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "offgrid-api", "node server.js", []int{3000}) + addManagedService(t, app.registry, "worker", "ruby worker.rb", []int{4000}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"offg*"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "offgrid-api", "output should include matching service") + assert.NotContains(t, output, "worker", "output should not include non-matching service") +} + +// --------------------------------------------------------------------------- +// 5. Glob pattern multiple matches +// --------------------------------------------------------------------------- + +func TestStatusCmd_GlobPatternMultipleMatches(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "web-api", "node api.js", []int{3000}) + addManagedService(t, app.registry, "web-frontend", "npm start", []int{3001}) + addManagedService(t, app.registry, "worker", "ruby worker.rb", []int{4000}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"web-*"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "web-api", "output should include web-api") + assert.Contains(t, output, "web-frontend", "output should include web-frontend") + assert.NotContains(t, output, "worker", "output should not include non-matching worker") +} + +// --------------------------------------------------------------------------- +// 6. Glob pattern no match +// --------------------------------------------------------------------------- + +func TestStatusCmd_GlobPatternNoMatch(t *testing.T) { + t.Parallel() + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "api", "node api.js", []int{3000}) + + err := app.StatusCmd([]string{"nonexistent-*"}) + require.Error(t, err, "StatusCmd with unmatched glob should return error") + assert.Contains(t, err.Error(), "no servers found", "error should mention no servers found") +} + +// --------------------------------------------------------------------------- +// 7. Multiple identifiers +// --------------------------------------------------------------------------- + +func TestStatusCmd_MultipleIdentifiers(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "svc1", "cmd1", []int{3001}) + addManagedService(t, app.registry, "svc2", "cmd2", []int{3002}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"svc1", "svc2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "svc1", "output should include svc1") + assert.Contains(t, output, "svc2", "output should include svc2") +} + +// --------------------------------------------------------------------------- +// 8. Mixed pattern and exact identifiers +// --------------------------------------------------------------------------- + +func TestStatusCmd_MixedPatternAndExact(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "web-api", "node api.js", []int{3000}) + addManagedService(t, app.registry, "web-frontend", "npm start", []int{3001}) + addManagedService(t, app.registry, "worker", "ruby worker.rb", []int{4000}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"web-*", "worker"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "web-api", "output should include web-api") + assert.Contains(t, output, "web-frontend", "output should include web-frontend") + assert.Contains(t, output, "worker", "output should include worker") +} + +// --------------------------------------------------------------------------- +// 9. Empty args — error +// --------------------------------------------------------------------------- + +func TestStatusCmd_EmptyArgs(t *testing.T) { + t.Parallel() + + app, _, _ := newTestApp(t) + + err := app.StatusCmd([]string{}) + require.Error(t, err, "StatusCmd with no identifiers should return error") + assert.Contains(t, err.Error(), "no servers found", "error should mention no servers found") +} + +// --------------------------------------------------------------------------- +// 10. Crashed service status +// --------------------------------------------------------------------------- + +func TestStatusCmd_CrashedServiceStatus(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + withCrashedService(t, app.registry, "crashed-svc", "node crashing-app.js", []int{5555}, 9999) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"crashed-svc"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "crashed-svc", "output should mention service name") + assert.Contains(t, output, "crashed", "output should show crashed status") +} + +func TestDiscoverServers_UsesLifecycleIdentityForPIDStartTimeMismatch(t *testing.T) { + t.Parallel() + + app, _, _ := newTestApp(t) + pid := 1234 + cwd := t.TempDir() + storedStart := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC) + actualStart := storedStart.Add(time.Minute) + svc := &models.ManagedService{ + Name: "api", + CWD: cwd, + Command: "node server.js", + Ports: []int{3000}, + LastPID: &pid, + LastProcessStartTime: &storedStart, + } + proc := &models.ProcessRecord{ + PID: pid, + Port: 3000, + Command: "node server.js", + CWD: cwd, + StartTime: &actualStart, + } + + servers := app.buildServerInfos([]*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + + var managed *models.ServerInfo + for _, srv := range servers { + if srv.ManagedService == svc { + managed = srv + break + } + } + + require.NotNil(t, managed, "managed service should still be listed") + assert.Equal(t, string(models.StatusUnknown), managed.Status) + assert.Nil(t, managed.ProcessRecord, "mismatched PID must not be shown as the managed process") +} + +func TestDiscoverServers_LegacyRegistryKeepsExistingEvidenceChain(t *testing.T) { + t.Parallel() + + app, _, _ := newTestApp(t) + pid := 1234 + cwd := t.TempDir() + startTime := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC) + svc := &models.ManagedService{ + Name: "api", + CWD: cwd, + Command: "node server.js", + Ports: []int{3000}, + LastPID: &pid, + } + proc := &models.ProcessRecord{ + PID: pid, + Port: 3000, + Command: "node server.js", + CWD: cwd, + StartTime: &startTime, + } + + servers := app.buildServerInfos([]*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + + var managed *models.ServerInfo + for _, srv := range servers { + if srv.ManagedService == svc { + managed = srv + break + } + } + + require.NotNil(t, managed, "managed service should be listed") + require.NotNil(t, managed.ProcessRecord, "legacy PID should still use path evidence") + assert.Equal(t, string(models.StatusRunning), managed.Status) + assert.Equal(t, pid, managed.ProcessRecord.PID) +} + +// --------------------------------------------------------------------------- +// Additional edge-case tests +// --------------------------------------------------------------------------- + +func TestStatusCmd_DuplicateIdentifiers(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "svc1", "cmd1", []int{3001}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"svc1", "svc1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "svc1", "output should include svc1 at least once") +} + +func TestStatusCmd_ExactNameNotGlob(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "api", "cmd1", []int{3001}) + addManagedService(t, app.registry, "api-v2", "cmd2", []int{3002}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"api"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "api", "output should include exact match 'api'") + assert.NotContains(t, output, "api-v2", "exact 'api' should not match 'api-v2'") +} + +func TestStatusCmd_WildcardMatchesAll(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "api", "cmd1", []int{3001}) + addManagedService(t, app.registry, "worker", "cmd2", []int{3002}) + addManagedService(t, app.registry, "frontend", "cmd3", []int{3003}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"*"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "api", "should match api") + assert.Contains(t, output, "worker", "should match worker") + assert.Contains(t, output, "frontend", "should match frontend") +} + +func TestStatusCmd_SuffixPattern(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "prod-api", "cmd1", []int{3001}) + addManagedService(t, app.registry, "staging-api", "cmd2", []int{3002}) + addManagedService(t, app.registry, "prod-worker", "cmd3", []int{3003}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"*-api"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "prod-api", "should match prod-api") + assert.Contains(t, output, "staging-api", "should match staging-api") + assert.NotContains(t, output, "prod-worker", "should not match prod-worker") +} + +func TestStatusCmd_OneExactOneNotFound(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "existing", "cmd", []int{3000}) + + output := captureStatusOutput(app, func() { + err := app.StatusCmd([]string{"existing", "missing"}) + // "existing" matches, "missing" doesn't. Since at least one match is found, + // the command should succeed. + require.NoError(t, err) + }) + + assert.Contains(t, output, "existing", "should show the found service") +} + +func TestStatusCmd_SourceFieldInOutput(t *testing.T) { + // NOT parallel: uses os.Stdout capture + + app, _, _ := newTestApp(t) + addManagedService(t, app.registry, "managed-svc", "cmd", []int{3000}) + + output := captureStatusOutput(app, func() { + if err := app.StatusCmd([]string{"managed-svc"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "Source:", "output should contain source field") + assert.Contains(t, output, "managed", "output should show managed source") +} + +// --------------------------------------------------------------------------- +// printServerStatus unit tests (output formatting) +// These test printServerStatus directly with constructed ServerInfo objects. +// NOT parallel because printServerStatus writes to os.Stdout. +// --------------------------------------------------------------------------- + +func TestPrintServerStatus_ManagedRunning(t *testing.T) { + app, _, _ := newTestApp(t) + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{ + Name: "test-api", + Command: "node server.js", + CWD: "/home/user/project", + Ports: []int{3000, 3001}, + }, + ProcessRecord: &models.ProcessRecord{ + PID: 1234, + PPID: 1, + Port: 3000, + User: "user", + Command: "node server.js", + CWD: "/home/user/project", + }, + Source: models.SourceManaged, + Status: "running", + } + + output := captureStatusOutput(app, func() { + if err := PrintServerStatus(app.outWriter(), srv, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "test-api", "should show service name") + assert.Contains(t, output, "1234", "should show PID") + assert.Contains(t, output, "3000", "should show port") + assert.Contains(t, output, "running", "should show running status") + assert.Contains(t, output, "SERVER DETAILS", "should show details header") + assert.Contains(t, output, "HEALTH STATUS", "should show health section for running service") +} + +func TestPrintServerStatus_CrashedWithReason(t *testing.T) { + app, _, _ := newTestApp(t) + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{ + Name: "crashed-app", + Command: "python app.py", + CWD: "/home/user/project", + Ports: []int{5000}, + }, + Source: models.SourceManaged, + Status: "crashed", + CrashReason: "Error: EADDRINUSE address already in use", + CrashLogTail: []string{ + "Starting server on port 5000...", + "Error: EADDRINUSE address already in use :::5000", + }, + } + + output := captureStatusOutput(app, func() { + if err := PrintServerStatus(app.outWriter(), srv, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "CRASH DETAILS", "should show crash section") + assert.Contains(t, output, "EADDRINUSE", "should show crash reason") + assert.Contains(t, output, "Starting server", "should show crash log tail") + assert.Contains(t, output, "crashed", "should show crashed status") +} + +func TestPrintServerStatus_CrashedNoLogs(t *testing.T) { + app, _, _ := newTestApp(t) + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{ + Name: "ghost", + Command: "./start.sh", + CWD: "/opt/ghost", + Ports: []int{2368}, + }, + Source: models.SourceManaged, + Status: "crashed", + CrashReason: "", + CrashLogTail: nil, + } + + output := captureStatusOutput(app, func() { + if err := PrintServerStatus(app.outWriter(), srv, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "CRASH DETAILS", "should show crash section") + assert.Contains(t, output, "unavailable", "should show unavailable reason when no crash reason") +} + +func TestPrintServerStatus_StoppedNoProcess(t *testing.T) { + app, _, _ := newTestApp(t) + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{ + Name: "idle-svc", + Command: "sleep infinity", + CWD: "/tmp", + Ports: []int{9999}, + }, + Source: models.SourceManaged, + Status: "stopped", + } + + output := captureStatusOutput(app, func() { + if err := PrintServerStatus(app.outWriter(), srv, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "idle-svc", "should show service name") + assert.Contains(t, output, "stopped", "should show stopped status") + assert.NotContains(t, output, "HEALTH STATUS", "stopped service should not show health section") +} + +func TestPrintServerStatus_WithAgentTag(t *testing.T) { + app, _, _ := newTestApp(t) + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{ + Name: "ai-started", + Command: "npm run dev", + CWD: "/home/user/project", + Ports: []int{4000}, + }, + ProcessRecord: &models.ProcessRecord{ + PID: 5555, + PPID: 1, + Port: 4000, + User: "user", + Command: "npm run dev", + CWD: "/home/user/project", + AgentTag: &models.AgentTag{ + Source: models.SourceAgent, + AgentName: "pi", + Confidence: models.ConfidenceHigh, + }, + }, + Source: models.SourceAgent, + Status: "running", + } + + output := captureStatusOutput(app, func() { + if err := PrintServerStatus(app.outWriter(), srv, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + assert.Contains(t, output, "AI AGENT DETECTION", "should show agent detection section") + assert.Contains(t, output, "pi", "should show agent name") + assert.Contains(t, output, "high", "should show confidence level") +} + +// --------------------------------------------------------------------------- +// Matching logic unit tests (mirrors StatusCmd's matching loop) +// These are pure logic tests — safe for t.Parallel(). +// --------------------------------------------------------------------------- + +func TestStatusMatching_ExactName(t *testing.T) { + t.Parallel() + + servers := []*models.ServerInfo{ + {ManagedService: &models.ManagedService{Name: "api"}, Status: "running"}, + {ManagedService: &models.ManagedService{Name: "worker"}, Status: "running"}, + } + + var matched []*models.ServerInfo + id := "api" + for _, srv := range servers { + if srv.ManagedService != nil && srv.ManagedService.Name == id { + matched = append(matched, srv) + break + } + } + + require.Len(t, matched, 1) + assert.Equal(t, "api", matched[0].ManagedService.Name) +} + +func TestStatusMatching_PortString(t *testing.T) { + t.Parallel() + + servers := []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 100, Port: 8080}, + ManagedService: &models.ManagedService{Name: "web"}, + Status: "running", + }, + { + ProcessRecord: &models.ProcessRecord{PID: 101, Port: 9090}, + ManagedService: &models.ManagedService{Name: "admin"}, + Status: "running", + }, + } + + var matched []*models.ServerInfo + id := "9090" + for _, srv := range servers { + if srv.ProcessRecord != nil && fmt.Sprintf("%d", srv.ProcessRecord.Port) == id { + matched = append(matched, srv) + break + } + } + + require.Len(t, matched, 1) + assert.Equal(t, "admin", matched[0].ManagedService.Name) +} + +func TestStatusMatching_GlobExpandsCorrectly(t *testing.T) { + t.Parallel() + + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker"}, + } + + expanded := ExpandPatterns([]string{"web-*"}, services) + assert.Len(t, expanded, 2) + assert.Contains(t, expanded, "web-api") + assert.Contains(t, expanded, "web-frontend") + assert.NotContains(t, expanded, "worker") +} + +func TestStatusMatching_GlobNoMatchReturnsOriginal(t *testing.T) { + t.Parallel() + + services := []*models.ManagedService{ + {Name: "api"}, + {Name: "worker"}, + } + + expanded := ExpandPatterns([]string{"zzz-*"}, services) + assert.Equal(t, []string{"zzz-*"}, expanded, "no-match glob should return original pattern") +} + +func TestStatusMatching_MultipleArgsExpandIndependently(t *testing.T) { + t.Parallel() + + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker"}, + } + + expanded := ExpandPatterns([]string{"web-*", "worker"}, services) + assert.Len(t, expanded, 3) + assert.Contains(t, expanded, "web-api") + assert.Contains(t, expanded, "web-frontend") + assert.Contains(t, expanded, "worker") +} + +func TestStatusMatching_DuplicateExpansion(t *testing.T) { + t.Parallel() + + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + } + + expanded := ExpandPatterns([]string{"web-*", "web-api"}, services) + assert.Contains(t, expanded, "web-api") + assert.Contains(t, expanded, "web-frontend") + + // web-api appears twice (from glob expansion + literal arg) + count := 0 + for _, name := range expanded { + if name == "web-api" { + count++ + } + } + assert.Equal(t, 2, count, "web-api should appear twice: once from glob, once from literal") +} + +func TestStatusMatching_EmptyArgsReturnsEmpty(t *testing.T) { + t.Parallel() + + services := []*models.ManagedService{{Name: "api"}} + expanded := ExpandPatterns([]string{}, services) + assert.Empty(t, expanded, "empty args should return empty result") +} + +func TestStatusMatching_EmptyRegistryReturnsArgs(t *testing.T) { + t.Parallel() + + services := []*models.ManagedService{} + expanded := ExpandPatterns([]string{"api", "web-*"}, services) + assert.Equal(t, []string{"api", "web-*"}, expanded, "with empty registry, args return unchanged") +} + +// --------------------------------------------------------------------------- +// Full StatusCmd matching loop simulation (pure logic, no I/O) +// --------------------------------------------------------------------------- + +func TestStatusMatching_FullLoop_MultiplePatternsAndExact(t *testing.T) { + t.Parallel() + + servers := []*models.ServerInfo{ + {ManagedService: &models.ManagedService{Name: "web-api"}, Status: "running"}, + {ManagedService: &models.ManagedService{Name: "web-frontend"}, Status: "running"}, + {ManagedService: &models.ManagedService{Name: "worker"}, Status: "running"}, + } + + allServices := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker"}, + } + + identifiers := []string{"web-*", "worker"} + + var matched []*models.ServerInfo + for _, id := range identifiers { + if strings.Contains(id, "*") { + expanded := ExpandPatterns([]string{id}, allServices) + for _, name := range expanded { + for _, srv := range servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name { + matched = append(matched, srv) + break + } + } + } + } else { + for _, srv := range servers { + if srv.ManagedService != nil && srv.ManagedService.Name == id { + matched = append(matched, srv) + break + } + } + } + } + + assert.Len(t, matched, 3, "should match web-api, web-frontend, and worker") + names := make(map[string]bool) + for _, srv := range matched { + names[srv.ManagedService.Name] = true + } + assert.True(t, names["web-api"]) + assert.True(t, names["web-frontend"]) + assert.True(t, names["worker"]) +} + +func TestStatusMatching_FullLoop_NoServers(t *testing.T) { + t.Parallel() + + servers := []*models.ServerInfo{} + allServices := []*models.ManagedService{} + + identifiers := []string{"anything"} + + var matched []*models.ServerInfo + for _, id := range identifiers { + if strings.Contains(id, "*") { + _ = allServices // allServices unused when no wildcard + expanded := ExpandPatterns([]string{id}, allServices) + for _, name := range expanded { + for _, srv := range servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name { + matched = append(matched, srv) + break + } + } + } + } else { + for _, srv := range servers { + if srv.ManagedService != nil && srv.ManagedService.Name == id { + matched = append(matched, srv) + break + } + } + } + } + + assert.Empty(t, matched, "no servers means no matches") +} + +func TestStatusMatching_FullLoop_CaseSensitive(t *testing.T) { + t.Parallel() + + servers := []*models.ServerInfo{ + {ManagedService: &models.ManagedService{Name: "API"}, Status: "running"}, + {ManagedService: &models.ManagedService{Name: "api"}, Status: "running"}, + } + + identifiers := []string{"api"} + + var matched []*models.ServerInfo + for _, id := range identifiers { + for _, srv := range servers { + if srv.ManagedService != nil && srv.ManagedService.Name == id { + matched = append(matched, srv) + break + } + } + } + + require.Len(t, matched, 1) + assert.Equal(t, "api", matched[0].ManagedService.Name, "should match only lowercase 'api', not 'API'") +} diff --git a/pkg/cli/display.go b/pkg/cli/display.go new file mode 100644 index 0000000..a505c25 --- /dev/null +++ b/pkg/cli/display.go @@ -0,0 +1,156 @@ +package cli + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/models" +) + +// PrintServerTable prints servers in tabular format. +func PrintServerTable(w io.Writer, servers []*models.ServerInfo, detailed bool) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + if detailed { + fmt.Fprintln(tw, "Name\tPort\tPID\tProject\tCommand\tSource\tStatus") + for _, srv := range servers { + fmt.Fprintln(tw, FormatServerRow(srv, true)) + } + } else { + fmt.Fprintln(tw, "Name\tPort\tPID\tProject\tSource\tStatus") + for _, srv := range servers { + fmt.Fprintln(tw, FormatServerRow(srv, false)) + } + } + + return tw.Flush() +} + +// FormatServerRow formats a server as a table row string. +func FormatServerRow(srv *models.ServerInfo, detailed bool) string { + name := "-" + port := "-" + pid := "-" + project := "-" + command := "-" + source := string(srv.Source) + status := srv.Status + + if srv.ManagedService != nil { + name = srv.ManagedService.Name + if len(srv.ManagedService.Ports) > 0 { + port = fmt.Sprintf("%d", srv.ManagedService.Ports[0]) + } + command = srv.ManagedService.Command + } + + if srv.ProcessRecord != nil { + pid = fmt.Sprintf("%d", srv.ProcessRecord.PID) + port = fmt.Sprintf("%d", srv.ProcessRecord.Port) + project = srv.ProcessRecord.ProjectRoot + if command == "-" { + command = srv.ProcessRecord.Command + } + + if srv.ProcessRecord.AgentTag != nil { + source = fmt.Sprintf("%s:%s", srv.ProcessRecord.AgentTag.Source, srv.ProcessRecord.AgentTag.AgentName) + } else { + source = string(models.SourceManual) + } + } + + if detailed { + return fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s", name, port, pid, project, command, source, status) + } + + return fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s", name, port, pid, project, source, status) +} + +// PrintServerStatus prints detailed status for a server. +func PrintServerStatus(w io.Writer, srv *models.ServerInfo, hc *health.HealthCheck) error { + line := "============================================================" + fmt.Fprintln(w, "\n"+line) + fmt.Fprintln(w, "SERVER DETAILS") + fmt.Fprintln(w, line) + + if srv.ManagedService != nil { + fmt.Fprintf(w, "Name: %s\n", srv.ManagedService.Name) + fmt.Fprintf(w, "Command: %s\n", srv.ManagedService.Command) + fmt.Fprintf(w, "CWD: %s\n", srv.ManagedService.CWD) + fmt.Fprintf(w, "Ports: ") + for i, p := range srv.ManagedService.Ports { + if i > 0 { + fmt.Fprint(w, ", ") + } + fmt.Fprintf(w, "%d", p) + } + fmt.Fprintln(w) + } + + if srv.ProcessRecord != nil { + fmt.Fprintf(w, "\nPort: %d\n", srv.ProcessRecord.Port) + fmt.Fprintf(w, "PID: %d\n", srv.ProcessRecord.PID) + fmt.Fprintf(w, "PPID: %d\n", srv.ProcessRecord.PPID) + fmt.Fprintf(w, "User: %s\n", srv.ProcessRecord.User) + fmt.Fprintf(w, "Command: %s\n", srv.ProcessRecord.Command) + fmt.Fprintf(w, "CWD: %s\n", srv.ProcessRecord.CWD) + if srv.ProcessRecord.ProjectRoot != "" { + fmt.Fprintf(w, "Project: %s\n", srv.ProcessRecord.ProjectRoot) + } + + // Health check + dashes := "------------------------------------------------------------" + fmt.Fprintln(w, "\n"+dashes) + fmt.Fprintln(w, "HEALTH STATUS") + fmt.Fprintln(w, dashes) + + if hc != nil { + icon := health.StatusIcon(hc.Status) + fmt.Fprintf(w, "Status: %s %s\n", icon, hc.Status) + fmt.Fprintf(w, "Response: %dms\n", hc.ResponseMs) + fmt.Fprintf(w, "Message: %s\n", hc.Message) + } else { + fmt.Fprintln(w, "Status: (not checked)") + } + + // Agent detection + if srv.ProcessRecord.AgentTag != nil { + fmt.Fprintln(w, "\n"+dashes) + fmt.Fprintln(w, "AI AGENT DETECTION") + fmt.Fprintln(w, dashes) + fmt.Fprintf(w, "Source: %s\n", srv.ProcessRecord.AgentTag.Source) + fmt.Fprintf(w, "Agent: %s\n", srv.ProcessRecord.AgentTag.AgentName) + fmt.Fprintf(w, "Confidence: %s\n", srv.ProcessRecord.AgentTag.Confidence) + } + } + + if srv.Status == "crashed" { + dashes := "------------------------------------------------------------" + fmt.Fprintln(w, "\n"+dashes) + fmt.Fprintln(w, "CRASH DETAILS") + fmt.Fprintln(w, dashes) + if srv.CrashReason != "" { + fmt.Fprintf(w, "Reason: %s\n", srv.CrashReason) + } else { + fmt.Fprintln(w, "Reason: unavailable") + } + if len(srv.CrashLogTail) > 0 { + fmt.Fprintln(w, "Recent logs:") + for _, l := range srv.CrashLogTail { + if strings.TrimSpace(l) == "" { + continue + } + fmt.Fprintf(w, " %s\n", l) + } + } + } + + fmt.Fprintf(w, "\nStatus: %s\n", srv.Status) + fmt.Fprintf(w, "Source: %s\n", srv.Source) + fmt.Fprintln(w, line+"\n") + + return nil +} diff --git a/pkg/cli/display_test.go b/pkg/cli/display_test.go new file mode 100644 index 0000000..ef6cb34 --- /dev/null +++ b/pkg/cli/display_test.go @@ -0,0 +1,252 @@ +package cli + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// PrintServerTable +// --------------------------------------------------------------------------- + +func TestPrintServerTable_EmptyServers(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := PrintServerTable(&buf, nil, false) + require.NoError(t, err) + + // Should contain at least the header line + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + assert.GreaterOrEqual(t, len(lines), 1, "header must be written even with empty servers") +} + +func TestPrintServerTable_MultipleServers(t *testing.T) { + t.Parallel() + + servers := []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "api", Command: "go run main.go", Ports: []int{3000}}, + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000}, + Status: "running", + }, + { + ManagedService: &models.ManagedService{Name: "worker", Command: "node server.js", Ports: []int{4000}}, + ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 4000}, + Status: "running", + }, + } + + var buf bytes.Buffer + err := PrintServerTable(&buf, servers, false) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "api") + assert.Contains(t, output, "worker") + assert.Contains(t, output, "3000") + assert.Contains(t, output, "4000") +} + +func TestPrintServerTable_DetailedMode(t *testing.T) { + t.Parallel() + + servers := []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "api", Command: "go run main.go", Ports: []int{3000}}, + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000}, + Status: "running", + }, + } + + // Detailed mode includes Command column + var detailed bytes.Buffer + err := PrintServerTable(&detailed, servers, true) + require.NoError(t, err) + assert.Contains(t, detailed.String(), "Command") + + // Non-detailed mode does not include Command column header (only 6 columns) + var normal bytes.Buffer + err = PrintServerTable(&normal, servers, false) + require.NoError(t, err) + normalOutput := normal.String() + // Verify non-detailed header has expected columns + assert.Contains(t, normalOutput, "Name") + assert.Contains(t, normalOutput, "Port") + assert.Contains(t, normalOutput, "PID") + assert.Contains(t, normalOutput, "Project") + assert.Contains(t, normalOutput, "Source") + assert.Contains(t, normalOutput, "Status") + assert.NotContains(t, normalOutput, "Command\t", "non-detailed header should not have Command column") +} + +// --------------------------------------------------------------------------- +// FormatServerRow +// --------------------------------------------------------------------------- + +func TestFormatServerRow_NilManagedService(t *testing.T) { + t.Parallel() + + srv := &models.ServerInfo{ + ProcessRecord: &models.ProcessRecord{PID: 9999, Port: 8080}, + Status: "running", + Source: models.SourceManual, + } + + row := FormatServerRow(srv, false) + assert.Contains(t, row, "-") // name should be dash when no ManagedService +} + +func TestFormatServerRow_FullProcessRecord(t *testing.T) { + t.Parallel() + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{ + Name: "db", + CWD: "/workspace/db", + Command: "postgres", + Ports: []int{5432}, + }, + ProcessRecord: &models.ProcessRecord{ + PID: 2001, + Port: 5432, + ProjectRoot: "/workspace/db", + }, + Status: "running", + } + + row := FormatServerRow(srv, false) + assert.Contains(t, row, "db") + assert.Contains(t, row, "5432") + assert.Contains(t, row, "2001") + assert.Contains(t, row, "/workspace/db") +} + +func TestFormatServerRow_DetailedMode(t *testing.T) { + t.Parallel() + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{ + Name: "api", + Command: "go run main.go", + Ports: []int{3000}, + }, + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000}, + Status: "running", + } + + rowDetailed := FormatServerRow(srv, true) + rowNormal := FormatServerRow(srv, false) + + // Detailed should include Command + assert.Contains(t, rowDetailed, "go run main.go") + // Detailed should have 7 columns vs 6 in normal + detailedFields := strings.Split(rowDetailed, "\t") + normalFields := strings.Split(rowNormal, "\t") + assert.Equal(t, 7, len(detailedFields)) + assert.Equal(t, 6, len(normalFields)) +} + +// --------------------------------------------------------------------------- +// PrintServerStatus +// --------------------------------------------------------------------------- + +func TestPrintServerStatus_ManagedService(t *testing.T) { + t.Parallel() + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{ + Name: "api", + Command: "go run main.go", + CWD: "/workspace/api", + Ports: []int{3000, 3001}, + }, + Status: "stopped", + } + + var buf bytes.Buffer + err := PrintServerStatus(&buf, srv, nil) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "api") + assert.Contains(t, output, "go run main.go") + assert.Contains(t, output, "/workspace/api") + assert.Contains(t, output, "3000") +} + +func TestPrintServerStatus_WithProcessRecord(t *testing.T) { + t.Parallel() + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{Name: "worker", Command: "node", CWD: "/app", Ports: []int{4000}}, + ProcessRecord: &models.ProcessRecord{PID: 5000, Port: 4000, PPID: 1, User: "dev", Command: "node server.js", CWD: "/app"}, + Status: "running", + } + + var buf bytes.Buffer + err := PrintServerStatus(&buf, srv, nil) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "5000") + assert.Contains(t, output, "4000") + assert.Contains(t, output, "dev") +} + +func TestPrintServerStatus_DisplayCrashedWithReason(t *testing.T) { + t.Parallel() + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{Name: "flaky"}, + Status: "crashed", + CrashReason: "panic: runtime error", + CrashLogTail: []string{"panic: runtime error", "goroutine 1 [running]", "main.main()"}, + } + + var buf bytes.Buffer + err := PrintServerStatus(&buf, srv, nil) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "CRASH DETAILS") + assert.Contains(t, output, "panic: runtime error") +} + +func TestPrintServerStatus_CrashedWithoutReason(t *testing.T) { + t.Parallel() + + srv := &models.ServerInfo{ + ManagedService: &models.ManagedService{Name: "mystery"}, + Status: "crashed", + CrashReason: "", + } + + var buf bytes.Buffer + err := PrintServerStatus(&buf, srv, nil) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "unavailable") +} + +// --------------------------------------------------------------------------- +// Interface contract: no App receiver +// --------------------------------------------------------------------------- + +// Compile-time check: PrintServerTable, FormatServerRow, PrintServerStatus +// are package-level functions, not methods on *App. +// PrintServerStatus accepts io.Writer and a health check result (may be nil). +// If anyone adds an App receiver, the compile-time checks below will fail. +var _ = func(w io.Writer, servers []*models.ServerInfo, detailed bool) error { + return PrintServerTable(w, servers, detailed) +} +var _ = func(srv *models.ServerInfo, detailed bool) string { + return FormatServerRow(srv, detailed) +} diff --git a/pkg/cli/lifecycle_adapter.go b/pkg/cli/lifecycle_adapter.go new file mode 100644 index 0000000..b084be3 --- /dev/null +++ b/pkg/cli/lifecycle_adapter.go @@ -0,0 +1,121 @@ +package cli + +import ( + "os" + "path/filepath" + "time" + + "github.com/devports/devpt/pkg/lifecycle" + "github.com/devports/devpt/pkg/models" +) + +// appDeps adapts the CLI App's existing infrastructure to the lifecycle.Deps interface. +type appDeps struct { + app *App +} + +func (d *appDeps) GetService(name string) *models.ManagedService { + return d.app.registry.GetService(name) +} + +func (d *appDeps) UpdateServicePID(name string, pid int) error { + return d.app.registry.UpdateServicePID(name, pid) +} + +func (d *appDeps) UpdateServiceProcessIdentity(name string, pid int, processStartTime time.Time) error { + return d.app.registry.UpdateServiceProcessIdentity(name, pid, processStartTime) +} + +func (d *appDeps) ClearServicePID(name string) error { + return d.app.registry.ClearServicePID(name) +} + +func (d *appDeps) StartProcess(svc *models.ManagedService) (int, error) { + return d.app.processManager.Start(svc) +} + +func (d *appDeps) StopProcess(pid int) error { + result := StopProcess(d.app.processManager, pid, defaultStopTimeout) + if result.ClearError != nil { + return result.ClearError + } + return nil +} + +func (d *appDeps) IsRunning(pid int) bool { + return d.app.processManager.IsRunning(pid) +} + +func (d *appDeps) GetProcessStartTime(pid int) (time.Time, error) { + return d.app.processManager.GetProcessStartTime(pid) +} + +func (d *appDeps) GetProcessCommand(pid int) (string, error) { + return d.app.processManager.GetProcessCommand(pid) +} + +func (d *appDeps) UpdateServiceResolvedCommand(name, resolvedCommand string) error { + return d.app.registry.UpdateServiceResolvedCommand(name, resolvedCommand) +} + +func (d *appDeps) ScanProcesses() ([]*models.ProcessRecord, error) { + processes, err := d.app.scanner.ScanListeningPorts() + if err != nil { + return nil, err + } + for _, proc := range processes { + if proc == nil || proc.PID <= 0 || proc.StartTime != nil { + continue + } + startTime, startErr := d.app.processManager.GetProcessStartTime(proc.PID) + if startErr == nil { + proc.StartTime = &startTime + } + } + return processes, nil +} + +func (d *appDeps) ListServices() []*models.ManagedService { + return d.app.registry.ListServices() +} + +func (d *appDeps) CheckHealth(port int) bool { + hc := d.app.healthChecker.Check(port) + return hc.Status == "ok" || hc.Status == "slow" +} + +func (d *appDeps) GetLogTail(name string, lines int) []string { + logs, err := d.app.processManager.Tail(name, lines) + if err != nil { + return nil + } + return logs +} + +func (d *appDeps) AcquireLock(serviceName string) error { + lk := lifecycle.NewFileLock(d.lockDir()) + return lk.Acquire(serviceName, os.Getpid()) +} + +func (d *appDeps) ReleaseLock(serviceName string) { + lk := lifecycle.NewFileLock(d.lockDir()) + _ = lk.Release(serviceName) +} + +func (d *appDeps) ResolveProjectRoot(cwd string) string { + return d.app.resolver.FindProjectRoot(cwd) +} + +// lockDir returns the directory for lock files. +// Uses the config dir when available; otherwise derives from the registry +// file path so that tests with unique temp dirs get unique lock dirs. +func (d *appDeps) lockDir() string { + if d.app.config.ConfigDir != "" { + return d.app.config.ConfigDir + } + // Try to derive from registry file path + if fp := d.app.registry.FilePath(); fp != "" { + return filepath.Dir(fp) + } + return os.TempDir() +} diff --git a/pkg/cli/parser.go b/pkg/cli/parser.go new file mode 100644 index 0000000..ee7a77e --- /dev/null +++ b/pkg/cli/parser.go @@ -0,0 +1,85 @@ +package cli + +import ( + "fmt" + "regexp" + "strconv" + + "github.com/devports/devpt/pkg/models" +) + +// ParseNamePortIdentifier parses "name:port" format +// Returns (name, port, hasPort) tuple +// Examples: +// - "web-api:3000" → ("web-api", 3000, true) +// - "some:thing:1234" → ("some:thing", 1234, true) - last colon is port separator +// - "web-api" → ("web-api", 0, false) +func ParseNamePortIdentifier(arg string) (name string, port int, hasPort bool) { + if arg == "" { + return "", 0, false + } + + // Regex to find the last colon followed by digits (port) + // This handles service names with colons in them (e.g., "some:thing") + // Also handles edge case of just ":port" (empty name) + re := regexp.MustCompile(`^(.*):(\d+)$`) + matches := re.FindStringSubmatch(arg) + + if matches == nil { + return arg, 0, false + } + + port, err := strconv.Atoi(matches[2]) + if err != nil { + return arg, 0, false + } + + return matches[1], port, true +} + +// LookupServiceWithFallback tries name+port match, then exact name match +// Returns (service, errorMessages) where errorMessages contains details of failed attempts +// Examples: +// - "web-api:3000" with web-api on port 3000 → (service, nil) +// - "some:thing" with service named "some:thing" → (service, nil) - literal name match +// - "foo:5678" with no matches → (nil, ["tried name=foo port=5678 (not found)", "tried name=foo:5678 (not found)"]) +func LookupServiceWithFallback(identifier string, services []*models.ManagedService) (*models.ManagedService, []string) { + if identifier == "" { + return nil, []string{"empty identifier"} + } + + name, port, hasPort := ParseNamePortIdentifier(identifier) + errors := []string{} + + if hasPort { + // Try: name + port match + for _, svc := range services { + if svc.Name == name { + for _, p := range svc.Ports { + if p == port { + return svc, nil + } + } + } + } + errors = append(errors, fmt.Sprintf("tried name=%s port=%d (not found)", name, port)) + + // Try: exact name match (for services with colons in literal names) + for _, svc := range services { + if svc.Name == identifier { + return svc, nil + } + } + errors = append(errors, fmt.Sprintf("tried name=%s (not found)", identifier)) + return nil, errors + } + + // No port: try exact name match only + for _, svc := range services { + if svc.Name == identifier { + return svc, nil + } + } + errors = append(errors, fmt.Sprintf("tried name=%s (not found)", identifier)) + return nil, errors +} diff --git a/pkg/cli/parser_test.go b/pkg/cli/parser_test.go new file mode 100644 index 0000000..6c39885 --- /dev/null +++ b/pkg/cli/parser_test.go @@ -0,0 +1,222 @@ +package cli + +import ( + "testing" + + "github.com/devports/devpt/pkg/models" +) + +func TestParseNamePortIdentifier(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantPort int + wantHasPort bool + }{ + { + name: "simple name:port", + input: "web-api:3000", + wantName: "web-api", + wantPort: 3000, + wantHasPort: true, + }, + { + name: "name with colon in it", + input: "some:thing:1234", + wantName: "some:thing", + wantPort: 1234, + wantHasPort: true, + }, + { + name: "name only - no colon", + input: "web-api", + wantName: "web-api", + wantPort: 0, + wantHasPort: false, + }, + { + name: "empty string", + input: "", + wantName: "", + wantPort: 0, + wantHasPort: false, + }, + { + name: "single port number", + input: ":8080", + wantName: "", + wantPort: 8080, + wantHasPort: true, + }, + { + name: "name:port with leading zeros", + input: "web-api:0300", + wantName: "web-api", + wantPort: 300, + wantHasPort: true, + }, + { + name: "invalid port - not a number after colon", + input: "web-api:abc", + wantName: "web-api:abc", + wantPort: 0, + wantHasPort: false, + }, + { + name: "multiple colons but last is not port", + input: "some:thing:else", + wantName: "some:thing:else", + wantPort: 0, + wantHasPort: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotPort, gotHasPort := ParseNamePortIdentifier(tt.input) + if gotName != tt.wantName { + t.Errorf("ParseNamePortIdentifier() name = %v, want %v", gotName, tt.wantName) + } + if gotPort != tt.wantPort { + t.Errorf("ParseNamePortIdentifier() port = %v, want %v", gotPort, tt.wantPort) + } + if gotHasPort != tt.wantHasPort { + t.Errorf("ParseNamePortIdentifier() hasPort = %v, want %v", gotHasPort, tt.wantHasPort) + } + }) + } +} + +func TestLookupServiceWithFallback(t *testing.T) { + services := []*models.ManagedService{ + {Name: "web-api", Ports: []int{3000, 3001}}, + {Name: "worker", Ports: []int{5000}}, + {Name: "some:thing", Ports: []int{4000}}, // Service with colon in literal name + {Name: "database", Ports: []int{5432}}, + } + + tests := []struct { + name string + identifier string + wantServiceName string + wantErrors bool + errorCount int + }{ + { + name: "name:port exact match", + identifier: "web-api:3000", + wantServiceName: "web-api", + wantErrors: false, + }, + { + name: "name:port second port match", + identifier: "web-api:3001", + wantServiceName: "web-api", + wantErrors: false, + }, + { + name: "literal name with colon", + identifier: "some:thing", + wantServiceName: "some:thing", + wantErrors: false, + }, + { + name: "name:port with literal name fallback", + identifier: "some:thing:4000", + wantServiceName: "some:thing", + wantErrors: false, + }, + { + name: "simple name match", + identifier: "worker", + wantServiceName: "worker", + wantErrors: false, + }, + { + name: "name:port not found - both attempts fail", + identifier: "foo:5678", + wantServiceName: "", + wantErrors: true, + errorCount: 2, // name+port attempt + literal name attempt + }, + { + name: "name only not found", + identifier: "nonexistent", + wantServiceName: "", + wantErrors: true, + errorCount: 1, + }, + { + name: "empty identifier", + identifier: "", + wantServiceName: "", + wantErrors: true, + errorCount: 1, + }, + { + name: "name:port with wrong port number", + identifier: "web-api:9999", + wantServiceName: "", + wantErrors: true, + errorCount: 2, // name+port attempt fails + literal name attempt fails (no service named "web-api:9999") + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotService, gotErrors := LookupServiceWithFallback(tt.identifier, services) + + if tt.wantServiceName != "" { + if gotService == nil { + t.Errorf("LookupServiceWithFallback() returned nil service, want %q", tt.wantServiceName) + return + } + if gotService.Name != tt.wantServiceName { + t.Errorf("LookupServiceWithFallback() service = %q, want %q", gotService.Name, tt.wantServiceName) + } + } else { + if gotService != nil { + t.Errorf("LookupServiceWithFallback() returned service %q, want nil", gotService.Name) + } + } + + if tt.wantErrors { + if len(gotErrors) == 0 { + t.Errorf("LookupServiceWithFallback() returned no errors, expected %d", tt.errorCount) + } + if tt.errorCount > 0 && len(gotErrors) != tt.errorCount { + t.Errorf("LookupServiceWithFallback() error count = %d, want %d", len(gotErrors), tt.errorCount) + } + } else { + if len(gotErrors) != 0 { + t.Errorf("LookupServiceWithFallback() returned errors: %v", gotErrors) + } + } + }) + } +} + +func TestLookupServiceWithFallback_EmptyServices(t *testing.T) { + services := []*models.ManagedService{} + + t.Run("empty service list with name:port", func(t *testing.T) { + gotService, gotErrors := LookupServiceWithFallback("web-api:3000", services) + if gotService != nil { + t.Errorf("expected nil service, got %q", gotService.Name) + } + if len(gotErrors) != 2 { + t.Errorf("expected 2 errors, got %d: %v", len(gotErrors), gotErrors) + } + }) + + t.Run("empty service list with name only", func(t *testing.T) { + gotService, gotErrors := LookupServiceWithFallback("web-api", services) + if gotService != nil { + t.Errorf("expected nil service, got %q", gotService.Name) + } + if len(gotErrors) != 1 { + t.Errorf("expected 1 error, got %d: %v", len(gotErrors), gotErrors) + } + }) +} diff --git a/pkg/cli/pattern.go b/pkg/cli/pattern.go new file mode 100644 index 0000000..54ad587 --- /dev/null +++ b/pkg/cli/pattern.go @@ -0,0 +1,69 @@ +package cli + +import ( + "path/filepath" + "sort" + "strings" + + "github.com/devports/devpt/pkg/models" +) + +// ExpandPatterns expands glob patterns against service names. +// Only supports '*' wildcard (no regex or tag patterns). +// Returns patterns with no matches unchanged for error detection. +// Preserves argument order and duplicates. +func ExpandPatterns(args []string, services []*models.ManagedService) []string { + if len(args) == 0 { + return []string{} + } + + // Build a set of all service names for quick lookup + serviceNames := make(map[string]bool) + for _, svc := range services { + serviceNames[svc.Name] = true + } + + var result []string + + for _, arg := range args { + // If no wildcard, treat as literal + if !strings.Contains(arg, "*") { + result = append(result, arg) + continue + } + + // Expand pattern + matches := expandPattern(arg, serviceNames) + if len(matches) == 0 { + // No matches: return original pattern for error detection + result = append(result, arg) + } else { + // Add all matches in sorted order for consistency + result = append(result, matches...) + } + } + + return result +} + +// expandPattern expands a single glob pattern against service names. +// Returns sorted matches for consistent ordering within a pattern. +func expandPattern(pattern string, serviceNames map[string]bool) []string { + var matches []string + + for name := range serviceNames { + matched, err := filepath.Match(pattern, name) + if err != nil { + // Invalid pattern: treat as no match + continue + } + if matched { + matches = append(matches, name) + } + } + + // Sort matches for consistent ordering + sort.Strings(matches) + + return matches +} diff --git a/pkg/cli/pattern_test.go b/pkg/cli/pattern_test.go new file mode 100644 index 0000000..d11013e --- /dev/null +++ b/pkg/cli/pattern_test.go @@ -0,0 +1,225 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +// TestExpandPatterns_NoPattern returns literal arguments unchanged +func TestExpandPatterns_NoPattern(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + {Name: "worker"}, + {Name: "frontend"}, + } + + args := []string{"api", "worker"} + result := ExpandPatterns(args, services) + + assert.Equal(t, []string{"api", "worker"}, result, "Literal service names should pass through unchanged") +} + +// TestExpandPatterns_SingleWildcard matches prefix pattern +func TestExpandPatterns_SingleWildcard(t *testing.T) { + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker"}, + } + + args := []string{"web-*"} + result := ExpandPatterns(args, services) + + // Should match web-api and web-frontend + assert.Len(t, result, 2, "Pattern 'web-*' should match 2 services") + assert.Contains(t, result, "web-api", "Should match web-api") + assert.Contains(t, result, "web-frontend", "Should match web-frontend") + assert.NotContains(t, result, "worker", "Should not match worker") +} + +// TestExpandPatterns_SuffixWildcard matches suffix pattern +func TestExpandPatterns_SuffixWildcard(t *testing.T) { + services := []*models.ManagedService{ + {Name: "frontend-api"}, + {Name: "backend-api"}, + {Name: "api-gateway"}, + } + + args := []string{"*-api"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 2, "Pattern '*-api' should match 2 services") + assert.Contains(t, result, "frontend-api", "Should match frontend-api") + assert.Contains(t, result, "backend-api", "Should match backend-api") + assert.NotContains(t, result, "api-gateway", "Should not match api-gateway") +} + +// TestExpandPatterns_ContainsWildcard matches anywhere in string +func TestExpandPatterns_ContainsWildcard(t *testing.T) { + services := []*models.ManagedService{ + {Name: "frontend-api"}, + {Name: "backend-api"}, + {Name: "api-gateway"}, + } + + args := []string{"*api*"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 3, "Pattern '*api*' should match all 3 services") + assert.Contains(t, result, "frontend-api", "Should match frontend-api") + assert.Contains(t, result, "backend-api", "Should match backend-api") + assert.Contains(t, result, "api-gateway", "Should match api-gateway") +} + +// TestExpandPatterns_WildcardMatchesAll matches everything +func TestExpandPatterns_WildcardMatchesAll(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + {Name: "worker"}, + {Name: "frontend"}, + } + + args := []string{"*"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 3, "Pattern '*' should match all services") + assert.Contains(t, result, "api") + assert.Contains(t, result, "worker") + assert.Contains(t, result, "frontend") +} + +// TestExpandPatterns_NoMatches returns original pattern for error handling +func TestExpandPatterns_NoMatches(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + {Name: "worker"}, + } + + args := []string{"nonexistent-*"} + result := ExpandPatterns(args, services) + + // Pattern with no matches should return original for error detection + assert.Equal(t, []string{"nonexistent-*"}, result, "Pattern with no matches should return original") +} + +// TestExpandPatterns_CombinedPatternsAndLiteral expands patterns then combines with literals +func TestExpandPatterns_CombinedPatternsAndLiteral(t *testing.T) { + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker"}, + {Name: "database"}, + } + + args := []string{"web-*", "worker", "database"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 4, "Should combine pattern matches with literal names") + assert.Contains(t, result, "web-api") + assert.Contains(t, result, "web-frontend") + assert.Contains(t, result, "worker") + assert.Contains(t, result, "database") +} + +// TestExpandPatterns_EmptyArgs returns empty result +func TestExpandPatterns_EmptyArgs(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + } + + args := []string{} + result := ExpandPatterns(args, services) + + assert.Empty(t, result, "Empty args should return empty result") +} + +// TestExpandPatterns_MultiplePatterns each expands independently +func TestExpandPatterns_MultiplePatterns(t *testing.T) { + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker-api"}, + {Name: "database"}, + } + + args := []string{"web-*", "*-api"} + result := ExpandPatterns(args, services) + + // Should have: web-api, web-frontend (from web-*) and web-api, worker-api (from *-api) + // Duplicates should be preserved for now (order matters for batch execution) + assert.Contains(t, result, "web-api") + assert.Contains(t, result, "web-frontend") + assert.Contains(t, result, "worker-api") +} + +// TestExpandPatterns_PreservesOrder maintains argument order +func TestExpandPatterns_PreservesOrder(t *testing.T) { + services := []*models.ManagedService{ + {Name: "a-service"}, + {Name: "b-service"}, + {Name: "c-service"}, + } + + args := []string{"b-*", "a-*", "c-*"} + result := ExpandPatterns(args, services) + + // Order should be: b matches first, then a matches, then c matches + firstB := -1 + firstA := -1 + firstC := -1 + + for i, name := range result { + if strings.HasPrefix(name, "b") && firstB == -1 { + firstB = i + } + if strings.HasPrefix(name, "a") && firstA == -1 { + firstA = i + } + if strings.HasPrefix(name, "c") && firstC == -1 { + firstC = i + } + } + + assert.Less(t, firstB, firstA, "b-service should appear before a-service") + assert.Less(t, firstA, firstC, "a-service should appear before c-service") +} + +// TestExpandPatterns_EmptyRegistry returns patterns unchanged when no services exist +func TestExpandPatterns_EmptyRegistry(t *testing.T) { + services := []*models.ManagedService{} + + args := []string{"api", "web-*"} + result := ExpandPatterns(args, services) + + assert.Equal(t, []string{"api", "web-*"}, result, "With empty registry, patterns should return unchanged") +} + +// TestExpandPatterns_DuplicateArgs preserves duplicates +func TestExpandPatterns_DuplicateArgs(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + } + + args := []string{"api", "api"} + result := ExpandPatterns(args, services) + + assert.Equal(t, []string{"api", "api"}, result, "Duplicate arguments should be preserved") +} + +// TestExpandPatterns_CaseSensitive performs case-sensitive matching +func TestExpandPatterns_CaseSensitive(t *testing.T) { + services := []*models.ManagedService{ + {Name: "API"}, + {Name: "api"}, + {Name: "Api"}, + } + + args := []string{"API"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 1, "Should match exact case only") + assert.Equal(t, "API", result[0], "Should match only API (uppercase)") +} diff --git a/pkg/cli/process_ops.go b/pkg/cli/process_ops.go new file mode 100644 index 0000000..5368795 --- /dev/null +++ b/pkg/cli/process_ops.go @@ -0,0 +1,55 @@ +package cli + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/devports/devpt/pkg/process" +) + +// defaultStopTimeout is the sole source of truth for stop operation timeouts. +const defaultStopTimeout time.Duration = 5 * time.Second + +// StopResult holds the outcome of a StopProcess call. +type StopResult struct { + Stopped bool + AlreadyDead bool + SudoRequired bool + ClearedPID bool + ClearError error +} + +// StopProcess stops a process by PID using the given process manager. +// This is the low-level PID kill used by the lifecycle adapter and +// the TUI for raw (unmanaged) process termination. +func StopProcess(pm *process.Manager, pid int, timeout time.Duration) StopResult { + err := pm.Stop(pid, timeout) + + if err == nil { + return StopResult{Stopped: true} + } + + if errors.Is(err, process.ErrNeedSudo) { + return StopResult{SudoRequired: true} + } + + if isProcessFinishedErr(err) { + return StopResult{AlreadyDead: true} + } + + return StopResult{ + Stopped: false, + ClearError: fmt.Errorf("failed to stop process: %w", err), + } +} + +// isProcessFinishedErr reports whether err indicates the process had already exited. +func isProcessFinishedErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "process already finished") || strings.Contains(msg, "no such process") +} diff --git a/pkg/cli/process_ops_test.go b/pkg/cli/process_ops_test.go new file mode 100644 index 0000000..7ae287c --- /dev/null +++ b/pkg/cli/process_ops_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// --------------------------------------------------------------------------- +// defaultStopTimeout +// --------------------------------------------------------------------------- + +func TestDefaultStopTimeout_IsFiveSeconds(t *testing.T) { + t.Parallel() + + assert.Equal(t, 5*time.Second, defaultStopTimeout, "defaultStopTimeout must be exactly 5 seconds") +} + +// --------------------------------------------------------------------------- +// StopProcess / StopResult +// --------------------------------------------------------------------------- + +func TestStopProcess_ResultFields(t *testing.T) { + t.Parallel() + + var result StopResult + assert.IsType(t, result, StopResult{}, "StopResult must be a struct") + assert.Equal(t, false, result.Stopped) + assert.Equal(t, false, result.AlreadyDead) + assert.Equal(t, false, result.SudoRequired) + assert.Equal(t, false, result.ClearedPID) + assert.Nil(t, result.ClearError) + + // Verify all field combinations + sr := StopResult{Stopped: true, ClearedPID: true} + assert.True(t, sr.Stopped) + assert.True(t, sr.ClearedPID) + assert.Nil(t, sr.ClearError) + + sr = StopResult{AlreadyDead: true} + assert.True(t, sr.AlreadyDead) + + sr = StopResult{SudoRequired: true} + assert.True(t, sr.SudoRequired) + + sr = StopResult{Stopped: true, ClearError: assert.AnError} + assert.Equal(t, assert.AnError, sr.ClearError) +} diff --git a/pkg/cli/tui.go b/pkg/cli/tui.go index 78e386e..8a43772 100644 --- a/pkg/cli/tui.go +++ b/pkg/cli/tui.go @@ -1,1343 +1,8 @@ package cli -import ( - "errors" - "fmt" - "sort" - "strconv" - "strings" - "time" +import tuipkg "github.com/devports/devpt/pkg/cli/tui" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" - - "github.com/devports/devpt/pkg/health" - "github.com/devports/devpt/pkg/models" - "github.com/devports/devpt/pkg/process" -) - -// TopCmd starts the interactive TUI mode (like 'top') +// TopCmd starts the interactive TUI mode (like 'top'). func (a *App) TopCmd() error { - model := newTopModel(a) - p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) - _, err := p.Run() - return err -} - -type viewMode int -type viewFocus int -type sortMode int -type confirmKind int - -const ( - viewModeTable viewMode = iota - viewModeLogs - viewModeCommand - viewModeSearch - viewModeHelp - viewModeConfirm -) - -const ( - focusRunning viewFocus = iota - focusManaged -) - -const ( - sortRecent sortMode = iota - sortName - sortProject - sortPort - sortHealth - sortModeCount -) - -const ( - confirmStopPID confirmKind = iota - confirmRemoveService - confirmSudoKill -) - -type confirmState struct { - kind confirmKind - prompt string - pid int - name string - serviceName string -} - -// topModel represents the TUI state. -type topModel struct { - app *App - servers []*models.ServerInfo - width int - height int - lastUpdate time.Time - lastInput time.Time - err error - - selected int - managedSel int - focus viewFocus - mode viewMode - - logLines []string - logErr error - logSvc *models.ManagedService - logPID int - followLogs bool - - cmdInput string - searchQuery string - cmdStatus string - - health map[int]string - healthDetails map[int]*health.HealthCheck - showHealthDetail bool - healthBusy bool - healthLast time.Time - healthChk *health.Checker - - sortBy sortMode - - starting map[string]time.Time - removed map[string]*models.ManagedService - - confirm *confirmState -} - -func newTopModel(app *App) topModel { - m := topModel{ - app: app, - lastUpdate: time.Now(), - lastInput: time.Now(), - mode: viewModeTable, - focus: focusRunning, - followLogs: true, - health: make(map[int]string), - healthDetails: make(map[int]*health.HealthCheck), - healthChk: health.NewChecker(800 * time.Millisecond), - sortBy: sortRecent, - starting: make(map[string]time.Time), - removed: make(map[string]*models.ManagedService), - } - if servers, err := app.discoverServers(); err == nil { - m.servers = servers - } - return m -} - -func (m topModel) Init() tea.Cmd { - return tickCmd() -} - -func (m topModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - m.lastInput = time.Now() - if m.mode == viewModeCommand { - switch msg.String() { - case "esc": - m.mode = viewModeTable - m.cmdInput = "" - return m, nil - case "enter": - m.cmdStatus = m.runCommand(strings.TrimSpace(m.cmdInput)) - m.cmdInput = "" - m.mode = viewModeTable - m.refresh() - return m, nil - case "backspace": - if len(m.cmdInput) > 0 { - m.cmdInput = m.cmdInput[:len(m.cmdInput)-1] - } - return m, nil - } - for _, r := range msg.Runes { - if r >= 32 && r != 127 { - m.cmdInput += string(r) - } - } - return m, nil - } - if m.mode == viewModeSearch { - switch msg.String() { - case "esc": - m.mode = viewModeTable - m.searchQuery = "" - return m, nil - case "enter": - m.mode = viewModeTable - return m, nil - case "backspace": - if len(m.searchQuery) > 0 { - m.searchQuery = m.searchQuery[:len(m.searchQuery)-1] - } - return m, nil - } - for _, r := range msg.Runes { - if r >= 32 && r != 127 { - m.searchQuery += string(r) - } - } - return m, nil - } - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "tab": - if m.mode == viewModeTable { - if m.focus == focusRunning { - m.focus = focusManaged - } else { - m.focus = focusRunning - } - } - return m, nil - case "?", "f1": - if m.mode == viewModeTable { - m.mode = viewModeHelp - } - return m, nil - case "/": - if m.mode == viewModeTable { - m.mode = viewModeSearch - } - return m, nil - case "ctrl+l": - if m.mode == viewModeTable { - m.searchQuery = "" - m.cmdStatus = "Filter cleared" - } - return m, nil - case "s": - if m.mode == viewModeTable { - m.sortBy = (m.sortBy + 1) % sortModeCount - } - return m, nil - case "h": - if m.mode == viewModeTable { - m.showHealthDetail = !m.showHealthDetail - } - return m, nil - case "f": - if m.mode == viewModeLogs { - m.followLogs = !m.followLogs - } - return m, nil - case "ctrl+a": - if m.mode == viewModeTable { - m.mode = viewModeCommand - m.cmdInput = "add " - } - return m, nil - case "ctrl+r": - if m.mode == viewModeTable { - m.cmdStatus = m.restartSelected() - m.refresh() - } - return m, nil - case "ctrl+e": - if m.mode == viewModeTable { - m.prepareStopConfirm() - } - return m, nil - case "x", "delete", "ctrl+d": - if m.mode == viewModeTable && m.focus == focusManaged { - managed := m.managedServices() - if m.managedSel >= 0 && m.managedSel < len(managed) { - name := managed[m.managedSel].Name - m.confirm = &confirmState{ - kind: confirmRemoveService, - prompt: fmt.Sprintf("Remove %q from registry?", name), - name: name, - } - m.mode = viewModeConfirm - } else { - m.cmdStatus = "No managed service selected" - } - } - return m, nil - case ":", "shift+;", ";", "c": - if m.mode == viewModeTable { - m.mode = viewModeCommand - m.cmdInput = "" - } - return m, nil - case "esc": - switch m.mode { - case viewModeLogs: - m.mode = viewModeTable - m.logLines = nil - m.logErr = nil - m.logSvc = nil - m.logPID = 0 - case viewModeHelp, viewModeConfirm: - m.mode = viewModeTable - m.confirm = nil - } - return m, nil - case "b": - if m.mode == viewModeLogs { - m.mode = viewModeTable - m.logLines = nil - m.logErr = nil - m.logSvc = nil - m.logPID = 0 - return m, nil - } - return m, nil - case "backspace": - return m, nil - case "up", "k": - if m.mode == viewModeTable { - if m.focus == focusRunning && m.selected > 0 { - m.selected-- - } - if m.focus == focusManaged && m.managedSel > 0 { - m.managedSel-- - } - } - return m, nil - case "down", "j": - if m.mode == viewModeTable { - if m.focus == focusRunning { - if m.selected < len(m.visibleServers())-1 { - m.selected++ - } - } - if m.focus == focusManaged { - if m.managedSel < len(m.managedServices())-1 { - m.managedSel++ - } - } - } - return m, nil - case "y": - if m.mode == viewModeConfirm { - cmd := m.executeConfirm(true) - return m, cmd - } - return m, nil - case "n": - if m.mode == viewModeConfirm { - cmd := m.executeConfirm(false) - return m, cmd - } - return m, nil - case "enter": - switch m.mode { - case viewModeConfirm: - cmd := m.executeConfirm(true) - return m, cmd - case viewModeTable: - if m.focus == focusManaged { - managed := m.managedServices() - if m.managedSel >= 0 && m.managedSel < len(managed) { - if err := m.app.StartCmd(managed[m.managedSel].Name); err != nil { - m.cmdStatus = err.Error() - } else { - name := managed[m.managedSel].Name - m.cmdStatus = fmt.Sprintf("Started %q", name) - m.starting[name] = time.Now() - } - m.refresh() - return m, nil - } - } - if m.focus == focusRunning { - visible := m.visibleServers() - if m.selected >= 0 && m.selected < len(visible) { - srv := visible[m.selected] - if srv.ManagedService == nil { - m.mode = viewModeLogs - m.logSvc = nil - m.logPID = srv.ProcessRecord.PID - return m, m.tailLogsCmd() - } - m.mode = viewModeLogs - m.logSvc = srv.ManagedService - m.logPID = 0 - return m, m.tailLogsCmd() - } - } - return m, nil - } - return m, nil - default: - if m.mode == viewModeCommand && len(msg.Runes) == 1 { - r := msg.Runes[0] - if r >= 32 && r != 127 { - m.cmdInput += string(r) - } - return m, nil - } - if m.mode == viewModeSearch && len(msg.Runes) == 1 { - r := msg.Runes[0] - if r >= 32 && r != 127 { - m.searchQuery += string(r) - } - return m, nil - } - return m, nil - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - case tickMsg: - m.refresh() - if m.mode == viewModeLogs && m.followLogs { - return m, m.tailLogsCmd() - } - if m.mode == viewModeTable && !m.healthBusy && time.Since(m.healthLast) > 2*time.Second && time.Since(m.lastInput) > 900*time.Millisecond { - m.healthBusy = true - return m, m.healthCmd() - } - return m, tickCmd() - case logMsg: - m.logLines = msg.lines - m.logErr = msg.err - return m, tickCmd() - case healthMsg: - m.healthBusy = false - if msg.err == nil { - m.health = msg.icons - m.healthDetails = msg.details - m.healthLast = time.Now() - } - return m, tickCmd() - } - return m, nil -} - -func (m *topModel) refresh() { - if servers, err := m.app.discoverServers(); err == nil { - m.servers = servers - m.lastUpdate = time.Now() - if m.selected >= len(m.visibleServers()) && len(m.visibleServers()) > 0 { - m.selected = len(m.visibleServers()) - 1 - } - if m.managedSel >= len(m.managedServices()) && len(m.managedServices()) > 0 { - m.managedSel = len(m.managedServices()) - 1 - } - for name, at := range m.starting { - if m.isServiceRunning(name) || time.Since(at) > 45*time.Second { - delete(m.starting, name) - } - } - } else { - m.err = err - } -} - -func (m topModel) View() string { - if m.err != nil { - return fmt.Sprintf("Error: %v\nPress 'q' to quit\n", m.err) - } - - width := m.width - if width <= 0 { - width = 120 - } - - var b strings.Builder - headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) - - // Ensure stale lines are removed when viewport shrinks/resizes. - b.WriteString("\x1b[H\x1b[2J") - b.WriteString("\n") - if m.mode == viewModeLogs { - name := "-" - if m.logSvc != nil { - name = m.logSvc.Name - } else if m.logPID > 0 { - name = fmt.Sprintf("pid:%d", m.logPID) - } - b.WriteString(headerStyle.Render(fmt.Sprintf("Logs: %s (b back, f follow:%t)", name, m.followLogs))) - } else { - b.WriteString(headerStyle.Render("Dev Process Tracker - Health Monitor (q quit)")) - } - b.WriteString("\n\n") - if m.mode == viewModeTable || m.mode == viewModeCommand || m.mode == viewModeSearch || m.mode == viewModeConfirm { - focus := "running" - if m.focus == focusManaged { - focus = "managed" - } - filter := m.searchQuery - if strings.TrimSpace(filter) == "" { - filter = "none" - } - ctx := fmt.Sprintf("Focus: %s | Sort: %s | Filter: %s", focus, sortModeLabel(m.sortBy), filter) - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine(ctx, width))) - b.WriteString("\n\n") - } - - switch m.mode { - case viewModeHelp: - b.WriteString(m.renderHelp(width)) - case viewModeLogs: - b.WriteString(m.renderLogs(width)) - default: - rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - b.WriteString(rowStyle.Render(m.renderTable(width))) - b.WriteString("\n\n") - b.WriteString(m.renderManaged(width)) - } - - if m.mode == viewModeCommand { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(fitLine(":"+m.cmdInput, width))) - b.WriteString("\n") - hint := `Example: add my-app ~/projects/my-app "npm run dev" 3000` - if strings.HasPrefix(strings.TrimSpace(m.cmdInput), "add") { - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine(hint, width))) - b.WriteString("\n") - } - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine("Esc to go back", width))) - b.WriteString("\n") - } - if m.mode == viewModeSearch { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(fitLine("/"+m.searchQuery, width))) - b.WriteString("\n") - } - if m.mode == viewModeConfirm && m.confirm != nil { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true).Render(fitLine(m.confirm.prompt+" [y/N]", width))) - b.WriteString("\n") - } - if m.cmdStatus != "" { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine(m.cmdStatus, width))) - b.WriteString("\n") - } - - b.WriteString("\n") - footer := fmt.Sprintf("Last updated: %s | Services: %d | Tab switch | Enter logs/start | x remove managed | / filter | ^L clear filter | s sort | ? help | ^A add ^R restart ^E stop", m.lastUpdate.Format("15:04:05"), m.countVisible()) - footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) - for _, line := range wrapWords(footer, width) { - b.WriteString(footerStyle.Render(fitLine(line, width))) - b.WriteString("\n") - } - return b.String() -} - -func (m topModel) renderTable(width int) string { - visible := m.visibleServers() - displayNames := m.displayNames(visible) - nameW, portW, pidW, projectW, healthW := 14, 6, 7, 14, 7 - sep := 2 - used := nameW + sep + portW + sep + pidW + sep + projectW + sep + healthW + sep - cmdW := width - used - if cmdW < 12 { - cmdW = 12 - } - - var lines []string - header := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", - fixedCell("Name", nameW), strings.Repeat(" ", sep), - fixedCell("Port", portW), strings.Repeat(" ", sep), - fixedCell("PID", pidW), strings.Repeat(" ", sep), - fixedCell("Project", projectW), strings.Repeat(" ", sep), - fixedCell("Command", cmdW), strings.Repeat(" ", sep), - fixedCell("Health", healthW), - ) - divider := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", - fixedCell(strings.Repeat("─", nameW), nameW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", portW), portW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", pidW), pidW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", projectW), projectW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", cmdW), cmdW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", healthW), healthW), - ) - lines = append(lines, fitLine(header, width)) - lines = append(lines, fitLine(divider, width)) - - rowFirstLineIdx := make([]int, len(visible)) - for i, srv := range visible { - project := "-" - if srv.ProcessRecord != nil { - if srv.ProcessRecord.ProjectRoot != "" { - project = pathBase(srv.ProcessRecord.ProjectRoot) - } else if srv.ProcessRecord.CWD != "" { - project = pathBase(srv.ProcessRecord.CWD) - } - } - if project == "-" && srv.ManagedService != nil && srv.ManagedService.CWD != "" { - project = pathBase(srv.ManagedService.CWD) - } - - port := "-" - pid := 0 - cmd := "-" - icon := "…" - if srv.ProcessRecord != nil { - pid = srv.ProcessRecord.PID - cmd = srv.ProcessRecord.Command - if srv.ProcessRecord.Port > 0 { - port = fmt.Sprintf("%d", srv.ProcessRecord.Port) - if cached := m.health[srv.ProcessRecord.Port]; cached != "" { - icon = cached - } - } - } - - cmdLines := wrapRunes(cmd, cmdW) - if len(cmdLines) == 0 { - cmdLines = []string{"-"} - } - rowFirstLineIdx[i] = len(lines) - for j, c := range cmdLines { - if j == 0 { - line := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", - fixedCell(displayNames[i], nameW), strings.Repeat(" ", sep), - fixedCell(port, portW), strings.Repeat(" ", sep), - fixedCell(fmt.Sprintf("%d", pid), pidW), strings.Repeat(" ", sep), - fixedCell(project, projectW), strings.Repeat(" ", sep), - fixedCell(c, cmdW), strings.Repeat(" ", sep), - fixedCell(icon, healthW), - ) - lines = append(lines, fitLine(line, width)) - } else { - line := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", - fixedCell("", nameW), strings.Repeat(" ", sep), - fixedCell("", portW), strings.Repeat(" ", sep), - fixedCell("", pidW), strings.Repeat(" ", sep), - fixedCell("", projectW), strings.Repeat(" ", sep), - fixedCell(c, cmdW), strings.Repeat(" ", sep), - fixedCell("", healthW), - ) - lines = append(lines, fitLine(line, width)) - } - } - } - - if len(visible) == 0 { - if m.searchQuery != "" { - return fitLine("(no matching servers for filter)", width) - } - return fitLine("(no matching servers)", width) - } - - selectedLine := rowFirstLineIdx[m.selected] - if selectedLine >= 2 && selectedLine < len(lines) { - lines[selectedLine] = lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("15")).Render(lines[selectedLine]) - } - - out := strings.Join(lines, "\n") - if m.showHealthDetail { - if m.selected >= 0 && m.selected < len(visible) { - port := 0 - if visible[m.selected].ProcessRecord != nil { - port = visible[m.selected].ProcessRecord.Port - } - if d := m.healthDetails[port]; d != nil { - out += "\n" + fitLine(fmt.Sprintf("Health detail: %s %dms %s", health.StatusIcon(d.Status), d.ResponseMs, d.Message), width) - } - } - } - return out -} - -func fixedCell(s string, width int) string { - if width <= 0 { - return "" - } - if runewidth.StringWidth(s) > width { - return runewidth.Truncate(s, width, "") - } - return s + strings.Repeat(" ", width-runewidth.StringWidth(s)) -} - -func wrapRunes(s string, width int) []string { - if width <= 0 { - return []string{s} - } - if s == "" { - return []string{""} - } - var out []string - rest := s - for runewidth.StringWidth(rest) > width { - chunk := runewidth.Truncate(rest, width, "") - if chunk == "" { - break - } - out = append(out, chunk) - rest = strings.TrimPrefix(rest, chunk) - } - if rest != "" { - out = append(out, rest) - } - return out -} - -func wrapWords(s string, width int) []string { - if width <= 0 { - return []string{s} - } - words := strings.Fields(s) - if len(words) == 0 { - return []string{""} - } - lines := make([]string, 0, 4) - cur := words[0] - for _, w := range words[1:] { - candidate := cur + " " + w - if runewidth.StringWidth(candidate) <= width { - cur = candidate - continue - } - lines = append(lines, cur) - // If a single word is longer than width, fall back to rune wrapping. - if runewidth.StringWidth(w) > width { - chunks := wrapRunes(w, width) - if len(chunks) > 0 { - lines = append(lines, chunks[:len(chunks)-1]...) - cur = chunks[len(chunks)-1] - } else { - cur = w - } - } else { - cur = w - } - } - lines = append(lines, cur) - return lines -} - -func (m topModel) renderManaged(width int) string { - managed := m.managedServices() - if len(managed) == 0 { - return fitLine(`No managed services yet. Use ^A then: add myapp /path/to/app "npm run dev" 3000`, width) - } - - portOwners := make(map[int]int) - for _, svc := range managed { - for _, p := range svc.Ports { - portOwners[p]++ - } - } - - var b strings.Builder - b.WriteString(fitLine("Managed Services (Tab focus, Enter start)", width)) - b.WriteString("\n") - for i, svc := range managed { - state := m.serviceStatus(svc.Name) - if state == "stopped" { - if _, ok := m.starting[svc.Name]; ok { - state = "starting" - } - } - line := fmt.Sprintf("%s [%s]", svc.Name, state) - - conflicting := false - for _, p := range svc.Ports { - if portOwners[p] > 1 { - conflicting = true - break - } - } - if conflicting { - line = fmt.Sprintf("%s (port conflict)", line) - } else if len(svc.Ports) > 1 { - line = fmt.Sprintf("%s (ports: %v)", line, svc.Ports) - } - - line = fitLine(line, width) - if m.focus == focusManaged && i == m.managedSel { - line = lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("15")).Render(line) - } - b.WriteString(line) - b.WriteString("\n") - } - if m.focus == focusManaged && m.managedSel >= 0 && m.managedSel < len(managed) { - svc := managed[m.managedSel] - if reason := m.crashReasonForService(svc.Name); reason != "" { - b.WriteString(fitLine("Crash reason: "+reason, width)) - b.WriteString("\n") - } - } - return b.String() -} - -func (m topModel) renderLogs(width int) string { - if m.logErr != nil { - if errors.Is(m.logErr, process.ErrNoLogs) { - return "No devpt logs for this service yet.\nLogs are only captured when started by devpt.\n" - } - if errors.Is(m.logErr, process.ErrNoProcessLogs) { - return "No accessible logs for this process.\nIf it writes only to a terminal, there may be nothing to tail here.\n" - } - return fmt.Sprintf("Error: %v\n", m.logErr) - } - if len(m.logLines) == 0 { - return "(no logs yet)\n" - } - var b strings.Builder - for _, line := range m.logLines { - b.WriteString(fitLine(line, width)) - b.WriteString("\n") - } - return b.String() -} - -func (m topModel) renderHelp(width int) string { - lines := []string{ - "Keymap", - "q quit, Tab switch list, Enter logs/start, / filter, Ctrl+L clear filter, s sort, h health detail, ? help", - "Ctrl+A add command, Ctrl+R restart selected, Ctrl+E stop selected", - "Logs: b back, f toggle follow", - "Managed list: x remove selected service", - "Commands: add, start, stop, remove, restore, list, help", - } - var out []string - for _, l := range lines { - out = append(out, fitLine(l, width)) - } - return strings.Join(out, "\n") -} - -func (m topModel) countVisible() int { return len(m.visibleServers()) } - -func (m topModel) visibleServers() []*models.ServerInfo { - var visible []*models.ServerInfo - q := strings.ToLower(strings.TrimSpace(m.searchQuery)) - for _, srv := range m.servers { - if srv == nil || srv.ProcessRecord == nil { - continue - } - if srv.ManagedService == nil { - if srv.ProcessRecord.Port == 0 || !isRuntimeCommand(srv.ProcessRecord.Command) { - continue - } - } - if q != "" { - hay := strings.ToLower(fmt.Sprintf("%s %s %s %d %s %s", - m.serviceNameFor(srv), projectOf(srv), srv.ProcessRecord.Command, srv.ProcessRecord.Port, srv.ProcessRecord.CWD, srv.ProcessRecord.ProjectRoot)) - if !strings.Contains(hay, q) { - continue - } - } - visible = append(visible, srv) - } - m.sortServers(visible) - return visible -} - -func (m topModel) managedServices() []*models.ManagedService { - services := m.app.registry.ListServices() - q := strings.ToLower(strings.TrimSpace(m.searchQuery)) - var filtered []*models.ManagedService - for _, svc := range services { - if q == "" || strings.Contains(strings.ToLower(svc.Name+" "+svc.CWD+" "+svc.Command), q) { - filtered = append(filtered, svc) - } - } - sort.Slice(filtered, func(i, j int) bool { return strings.ToLower(filtered[i].Name) < strings.ToLower(filtered[j].Name) }) - return filtered -} - -func (m topModel) displayNames(servers []*models.ServerInfo) []string { - base := make([]string, len(servers)) - projectToSvc := make(map[string]string) - for _, svc := range m.app.registry.ListServices() { - cwd := strings.TrimRight(strings.TrimSpace(svc.CWD), "/") - if cwd != "" { - projectToSvc[cwd] = svc.Name - } - } - for i, srv := range servers { - base[i] = m.serviceNameFor(srv) - if base[i] == "-" && srv.ProcessRecord != nil { - root := strings.TrimRight(strings.TrimSpace(srv.ProcessRecord.ProjectRoot), "/") - cwd := strings.TrimRight(strings.TrimSpace(srv.ProcessRecord.CWD), "/") - if mapped := projectToSvc[root]; mapped != "" { - base[i] = mapped - } else if mapped := projectToSvc[cwd]; mapped != "" { - base[i] = mapped - } - } - } - - count := make(map[string]int) - for _, n := range base { - count[n]++ - } - type row struct{ idx, pid int } - group := make(map[string][]row) - for i, n := range base { - group[n] = append(group[n], row{idx: i, pid: pidOf(servers[i])}) - } - out := make([]string, len(base)) - for name, rows := range group { - if count[name] <= 1 || name == "-" { - for _, r := range rows { - out[r.idx] = name - } - continue - } - sort.Slice(rows, func(i, j int) bool { return rows[i].pid < rows[j].pid }) - for i, r := range rows { - out[r.idx] = fmt.Sprintf("%s~%d", name, i+1) - } - } - return out -} - -func (m topModel) sortServers(servers []*models.ServerInfo) { - switch m.sortBy { - case sortName: - sort.Slice(servers, func(i, j int) bool { - return strings.ToLower(m.serviceNameFor(servers[i])) < strings.ToLower(m.serviceNameFor(servers[j])) - }) - case sortProject: - sort.Slice(servers, func(i, j int) bool { - return strings.ToLower(projectOf(servers[i])) < strings.ToLower(projectOf(servers[j])) - }) - case sortPort: - sort.Slice(servers, func(i, j int) bool { return portOf(servers[i]) < portOf(servers[j]) }) - case sortHealth: - sort.Slice(servers, func(i, j int) bool { - return strings.Compare(m.health[portOf(servers[i])], m.health[portOf(servers[j])]) < 0 - }) - default: - sort.Slice(servers, func(i, j int) bool { return pidOf(servers[i]) > pidOf(servers[j]) }) - } -} - -func (m topModel) serviceNameFor(srv *models.ServerInfo) string { - if srv == nil { - return "-" - } - if srv.ManagedService != nil && srv.ManagedService.Name != "" { - return srv.ManagedService.Name - } - if srv.ProcessRecord != nil { - if srv.ProcessRecord.ProjectRoot != "" { - return pathBase(srv.ProcessRecord.ProjectRoot) - } - if srv.ProcessRecord.CWD != "" { - return pathBase(srv.ProcessRecord.CWD) - } - if srv.ProcessRecord.Command != "" { - return pathBase(srv.ProcessRecord.Command) - } - } - return "-" -} - -func (m topModel) runCommand(input string) string { - if input == "" { - return "" - } - args, err := parseArgs(input) - if err != nil || len(args) == 0 { - return "Invalid command" - } - switch args[0] { - case "help": - m.mode = viewModeHelp - return "" - case "list": - services := m.app.registry.ListServices() - if len(services) == 0 { - return "No managed services" - } - names := make([]string, 0, len(services)) - for _, svc := range services { - names = append(names, svc.Name) - } - sort.Strings(names) - return "Managed services: " + strings.Join(names, ", ") - case "add": - if len(args) < 4 { - return "Usage: add \"\" [ports...]" - } - name, cwd, cmd := args[1], args[2], args[3] - var ports []int - for _, p := range args[4:] { - port, perr := strconv.Atoi(p) - if perr != nil { - return "Invalid port: " + p - } - ports = append(ports, port) - } - if err := m.app.AddCmd(name, cwd, cmd, ports); err != nil { - return err.Error() - } - return fmt.Sprintf("Added %q", name) - case "remove", "rm": - if len(args) < 2 { - return "Usage: remove " - } - svc := m.app.registry.GetService(args[1]) - if svc == nil { - return fmt.Sprintf("service %q not found", args[1]) - } - m.confirm = &confirmState{kind: confirmRemoveService, prompt: fmt.Sprintf("Remove %q from registry?", svc.Name), name: svc.Name} - m.mode = viewModeConfirm - return "" - case "restore": - if len(args) < 2 { - return "Usage: restore " - } - svc := m.removed[args[1]] - if svc == nil { - return fmt.Sprintf("no removed service %q in this session", args[1]) - } - if err := m.app.AddCmd(svc.Name, svc.CWD, svc.Command, svc.Ports); err != nil { - return err.Error() - } - delete(m.removed, args[1]) - return fmt.Sprintf("Restored %q", args[1]) - case "start": - if len(args) < 2 { - return "Usage: start " - } - if err := m.app.StartCmd(args[1]); err != nil { - return err.Error() - } - m.starting[args[1]] = time.Now() - return fmt.Sprintf("Started %q", args[1]) - case "stop": - if len(args) < 2 { - return "Usage: stop " - } - if args[1] == "--port" { - if len(args) < 3 { - return "Usage: stop --port PORT" - } - if err := m.app.StopCmd(args[2]); err != nil { - return err.Error() - } - return fmt.Sprintf("Stopped port %s", args[2]) - } - if err := m.app.StopCmd(args[1]); err != nil { - return err.Error() - } - return fmt.Sprintf("Stopped %q", args[1]) - default: - return "Unknown command (type :help)" - } -} - -func (m topModel) startSelected() string { - visible := m.visibleServers() - if m.selected < 0 || m.selected >= len(visible) { - return "No service selected" - } - srv := visible[m.selected] - if srv.ManagedService == nil { - return "Selected process is not a managed service" - } - if err := m.app.StartCmd(srv.ManagedService.Name); err != nil { - return err.Error() - } - m.starting[srv.ManagedService.Name] = time.Now() - return fmt.Sprintf("Started %q", srv.ManagedService.Name) -} - -func (m topModel) restartSelected() string { - visible := m.visibleServers() - if m.selected < 0 || m.selected >= len(visible) { - return "No service selected" - } - srv := visible[m.selected] - if srv.ManagedService == nil { - return "Selected process is not a managed service" - } - if err := m.app.RestartCmd(srv.ManagedService.Name); err != nil { - return err.Error() - } - m.starting[srv.ManagedService.Name] = time.Now() - return fmt.Sprintf("Restarted %q", srv.ManagedService.Name) -} - -func (m *topModel) prepareStopConfirm() { - visible := m.visibleServers() - if m.selected < 0 || m.selected >= len(visible) { - m.cmdStatus = "No service selected" - return - } - srv := visible[m.selected] - if srv.ProcessRecord == nil || srv.ProcessRecord.PID == 0 { - m.cmdStatus = "No PID to stop" - return - } - prompt := fmt.Sprintf("Stop PID %d?", srv.ProcessRecord.PID) - serviceName := "" - if srv.ManagedService != nil { - prompt = fmt.Sprintf("Stop %q (PID %d)?", srv.ManagedService.Name, srv.ProcessRecord.PID) - serviceName = srv.ManagedService.Name - } - m.confirm = &confirmState{kind: confirmStopPID, prompt: prompt, pid: srv.ProcessRecord.PID, serviceName: serviceName} - m.mode = viewModeConfirm -} - -func (m *topModel) executeConfirm(yes bool) tea.Cmd { - if m.confirm == nil { - m.mode = viewModeTable - return nil - } - c := *m.confirm - m.confirm = nil - m.mode = viewModeTable - if !yes { - m.cmdStatus = "Cancelled" - return nil - } - switch c.kind { - case confirmStopPID: - if err := m.app.processManager.Stop(c.pid, 5*time.Second); err != nil { - if errors.Is(err, process.ErrNeedSudo) { - m.confirm = &confirmState{kind: confirmSudoKill, prompt: fmt.Sprintf("Run sudo kill -9 %d now?", c.pid), pid: c.pid} - m.mode = viewModeConfirm - return nil - } - if isProcessFinishedErr(err) { - m.cmdStatus = fmt.Sprintf("Process %d already exited", c.pid) - if c.serviceName != "" { - _ = m.app.registry.ClearServicePID(c.serviceName) - } - } else { - m.cmdStatus = err.Error() - } - } else { - m.cmdStatus = fmt.Sprintf("Stopped PID %d", c.pid) - if c.serviceName != "" { - if clrErr := m.app.registry.ClearServicePID(c.serviceName); clrErr != nil { - m.cmdStatus = fmt.Sprintf("Stopped PID %d (warning: %v)", c.pid, clrErr) - } - } - } - case confirmRemoveService: - svc := m.app.registry.GetService(c.name) - if svc != nil { - copySvc := *svc - m.removed[c.name] = ©Svc - } - if err := m.app.RemoveCmd(c.name); err != nil { - m.cmdStatus = err.Error() - } else { - m.cmdStatus = fmt.Sprintf("Removed %q (use :restore %s)", c.name, c.name) - } - case confirmSudoKill: - m.cmdStatus = fmt.Sprintf("Run manually: sudo kill -9 %d", c.pid) - } - m.refresh() - return nil -} - -func (m topModel) tailLogsCmd() tea.Cmd { - return func() tea.Msg { - if m.logSvc != nil { - lines, err := m.app.processManager.Tail(m.logSvc.Name, 200) - return logMsg{lines: lines, err: err} - } - if m.logPID > 0 { - lines, err := m.app.processManager.TailProcess(m.logPID, 200) - return logMsg{lines: lines, err: err} - } - return logMsg{err: fmt.Errorf("no service selected")} - } -} - -func (m topModel) healthCmd() tea.Cmd { - visible := m.visibleServers() - return func() tea.Msg { - icons := make(map[int]string) - details := make(map[int]*health.HealthCheck) - for _, srv := range visible { - if srv.ProcessRecord == nil || srv.ProcessRecord.Port <= 0 { - continue - } - check := m.healthChk.Check(srv.ProcessRecord.Port) - icons[srv.ProcessRecord.Port] = health.StatusIcon(check.Status) - details[srv.ProcessRecord.Port] = check - } - return healthMsg{icons: icons, details: details} - } -} - -type tickMsg time.Time -type logMsg struct { - lines []string - err error -} -type healthMsg struct { - icons map[int]string - details map[int]*health.HealthCheck - err error -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) -} - -func parseArgs(input string) ([]string, error) { - var args []string - var buf strings.Builder - inQuotes := false - var quote rune - escaped := false - for _, r := range input { - if escaped { - buf.WriteRune(r) - escaped = false - continue - } - switch r { - case '\\': - escaped = true - case '"', '\'': - if inQuotes && r == quote { - inQuotes = false - quote = 0 - } else if !inQuotes { - inQuotes = true - quote = r - } else { - buf.WriteRune(r) - } - case ' ', '\t': - if inQuotes { - buf.WriteRune(r) - } else if buf.Len() > 0 { - args = append(args, buf.String()) - buf.Reset() - } - default: - buf.WriteRune(r) - } - } - if buf.Len() > 0 { - args = append(args, buf.String()) - } - return args, nil -} - -func fitLine(line string, width int) string { - if width <= 0 { - return line - } - lineWidth := runewidth.StringWidth(line) - if lineWidth == width { - return line - } - if lineWidth > width { - // Let the terminal wrap long lines to the viewport instead of truncating. - return line - } - return line + strings.Repeat(" ", width-lineWidth) -} - -func pathBase(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "-" - } - if strings.Contains(raw, " ") { - raw = strings.Fields(raw)[0] - } - raw = strings.TrimRight(raw, "/") - parts := strings.Split(raw, "/") - if len(parts) == 0 { - return "-" - } - base := parts[len(parts)-1] - if base == "" { - return "-" - } - return base -} - -func projectOf(srv *models.ServerInfo) string { - if srv == nil || srv.ProcessRecord == nil { - return "" - } - if srv.ProcessRecord.ProjectRoot != "" { - return pathBase(srv.ProcessRecord.ProjectRoot) - } - return pathBase(srv.ProcessRecord.CWD) -} - -func portOf(srv *models.ServerInfo) int { - if srv == nil || srv.ProcessRecord == nil { - return 0 - } - return srv.ProcessRecord.Port -} - -func pidOf(srv *models.ServerInfo) int { - if srv == nil || srv.ProcessRecord == nil { - return 0 - } - return srv.ProcessRecord.PID -} - -func isRuntimeCommand(raw string) bool { - base := strings.ToLower(pathBase(raw)) - switch base { - case "node", "nodejs", "npm", "npx", "pnpm", "yarn", "bun", "bunx", "deno", - "vite", "webpack", "webpack-dev-server", "next", "next-server", "nuxt", "ts-node", "tsx", - "python", "python3", "pip", "pipenv", "poetry", - "ruby", "rails", - "go", - "java", "javac", "gradle", "mvn", - "dotnet", - "php": - return true - default: - return false - } -} - -func sortModeLabel(s sortMode) string { - switch s { - case sortName: - return "name" - case sortProject: - return "project" - case sortPort: - return "port" - case sortHealth: - return "health" - default: - return "recent" - } -} - -func (m topModel) isServiceRunning(name string) bool { - for _, srv := range m.servers { - if srv.ManagedService != nil && srv.ManagedService.Name == name && srv.ProcessRecord != nil && srv.ProcessRecord.PID > 0 { - return true - } - } - return false -} - -func (m topModel) serviceStatus(name string) string { - for _, srv := range m.servers { - if srv.ManagedService != nil && srv.ManagedService.Name == name { - if srv.Status != "" { - return srv.Status - } - } - } - if m.isServiceRunning(name) { - return "running" - } - return "stopped" -} - -func (m topModel) crashReasonForService(name string) string { - for _, srv := range m.servers { - if srv.ManagedService != nil && srv.ManagedService.Name == name && srv.Status == "crashed" { - return srv.CrashReason - } - } - return "" + return tuipkg.Run(NewTUIAdapter(a)) } diff --git a/pkg/cli/tui/cache_test.go b/pkg/cli/tui/cache_test.go new file mode 100644 index 0000000..5475a79 --- /dev/null +++ b/pkg/cli/tui/cache_test.go @@ -0,0 +1,239 @@ +package tui + +import ( + "testing" + + "github.com/devports/devpt/pkg/models" +) + +func TestVisibleServersCachesByQueryAndSort(t *testing.T) { + app := &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node api.js", CWD: "/tmp/api", ProjectRoot: "/tmp/api"}, + ManagedService: &models.ManagedService{Name: "api"}, + }, + { + ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "node web.js", CWD: "/tmp/web", ProjectRoot: "/tmp/web"}, + ManagedService: &models.ManagedService{Name: "web"}, + }, + }, + } + m := newTopModel(app) + + first := m.visibleServers() + second := m.visibleServers() + if len(first) != 2 || len(second) != 2 { + t.Fatalf("expected 2 visible servers, got %d and %d", len(first), len(second)) + } + if &first[0] != &second[0] && len(first) > 0 && len(second) > 0 { + // defensive no-op: slice identity is not required, behavior is validated below + } + if m.cachedVisible == nil { + t.Fatalf("expected visible servers cache to be populated") + } + + m.searchQuery = "web" + filtered := m.visibleServers() + if len(filtered) != 1 || m.serviceNameFor(filtered[0]) != "web" { + t.Fatalf("expected filtered visible server to be web, got %#v", filtered) + } + + m.searchQuery = "" + m.sortBy = sortName + m.sortReverse = true + sorted := m.visibleServers() + if len(sorted) != 2 { + t.Fatalf("expected 2 visible servers after sort change, got %d", len(sorted)) + } + if m.serviceNameFor(sorted[0]) != "web" { + t.Fatalf("expected reverse name sort to put web first, got %s", m.serviceNameFor(sorted[0])) + } +} + +func TestManagedServicesCachesUntilVersionChanges(t *testing.T) { + app := &fakeAppDeps{ + services: []*models.ManagedService{ + {Name: "web", CWD: "/tmp/web", Command: "npm run dev"}, + {Name: "api", CWD: "/tmp/api", Command: "go run ."}, + }, + } + m := newTopModel(app) + + services := m.managedServices() + if len(services) != 2 { + t.Fatalf("expected 2 managed services, got %d", len(services)) + } + if app.listServicesCalls != 1 { + t.Fatalf("expected 1 ListServices call after first read, got %d", app.listServicesCalls) + } + + _ = m.managedServices() + if app.listServicesCalls != 1 { + t.Fatalf("expected cached managed services on second read, got %d calls", app.listServicesCalls) + } + + m.searchQuery = "web" + filtered := m.managedServices() + if len(filtered) != 1 || filtered[0].Name != "web" { + t.Fatalf("expected filtered managed services to contain only web, got %#v", filtered) + } + if app.listServicesCalls != 2 { + t.Fatalf("expected query change to refresh managed cache, got %d calls", app.listServicesCalls) + } + + m.searchQuery = "" + m.servicesVersion++ + m.invalidateCachedLists() + _ = m.managedServices() + if app.listServicesCalls != 3 { + t.Fatalf("expected version change to refresh managed cache, got %d calls", app.listServicesCalls) + } +} + +func TestRefreshRepopulatesCachedListsWithLatestData(t *testing.T) { + app := &fakeAppDeps{ + servers: []*models.ServerInfo{{ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node api.js", CWD: "/tmp/api", ProjectRoot: "/tmp/api"}}}, + services: []*models.ManagedService{{Name: "api", CWD: "/tmp/api", Command: "node api.js"}}, + } + m := newTopModel(app) + + beforeServersVersion := m.serversVersion + beforeServicesVersion := m.servicesVersion + _ = m.visibleServers() + _ = m.managedServices() + if m.cachedVisible == nil || m.cachedManaged == nil { + t.Fatalf("expected caches to be populated before refresh") + } + + app.servers = []*models.ServerInfo{{ProcessRecord: &models.ProcessRecord{PID: 2002, Port: 4000, Command: "node web.js", CWD: "/tmp/web", ProjectRoot: "/tmp/web"}}} + app.services = []*models.ManagedService{{Name: "web", CWD: "/tmp/web", Command: "node web.js"}} + m.refresh() + + if m.serversVersion <= beforeServersVersion || m.servicesVersion <= beforeServicesVersion { + t.Fatalf("expected refresh to bump cache versions") + } + if m.cachedVisible == nil || m.cachedManaged == nil { + t.Fatalf("expected refresh to repopulate visible and managed caches") + } + if len(m.cachedVisible) != 1 || m.cachedVisible[0].ProcessRecord.PID != 2002 { + t.Fatalf("expected refreshed visible cache to contain PID 2002, got %#v", m.cachedVisible) + } + if len(m.cachedManaged) != 1 || m.cachedManaged[0].Name != "web" { + t.Fatalf("expected refreshed managed cache to contain web, got %#v", m.cachedManaged) + } +} + +func TestDisplayNamesCacheTracksQuerySortAndServices(t *testing.T) { + app := &fakeAppDeps{ + servers: []*models.ServerInfo{ + {ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node api.js", CWD: "/tmp/shared", ProjectRoot: "/tmp/shared"}}, + {ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "node web.js", CWD: "/tmp/shared", ProjectRoot: "/tmp/shared"}}, + }, + services: []*models.ManagedService{ + {Name: "shared", CWD: "/tmp/shared", Command: "npm run dev"}, + }, + } + m := newTopModel(app) + + visible := m.visibleServers() + names := m.displayNames(visible) + if len(names) != 2 { + t.Fatalf("expected 2 display names, got %d", len(names)) + } + listCalls := app.listServicesCalls + + again := m.displayNames(m.visibleServers()) + if len(again) != 2 { + t.Fatalf("expected cached display names, got %d", len(again)) + } + if app.listServicesCalls != listCalls { + t.Fatalf("expected displayNames cache hit, got extra ListServices call count %d -> %d", listCalls, app.listServicesCalls) + } + + m.searchQuery = "web" + filteredVisible := m.visibleServers() + filteredNames := m.displayNames(filteredVisible) + if len(filteredNames) != 1 { + t.Fatalf("expected 1 filtered display name, got %d", len(filteredNames)) + } + if app.listServicesCalls <= listCalls { + t.Fatalf("expected query change to invalidate displayNames cache") + } + + m.searchQuery = "" + m.servicesVersion++ + m.invalidateCachedLists() + _ = m.displayNames(m.visibleServers()) + if app.listServicesCalls <= listCalls+1 { + t.Fatalf("expected service version change to invalidate displayNames cache") + } +} + +func TestDisplayNamesCachesUntilVersionChanges(t *testing.T) { + app := &fakeAppDeps{ + servers: []*models.ServerInfo{ + {ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node api.js", CWD: "/tmp/api", ProjectRoot: "/tmp/api"}, ManagedService: &models.ManagedService{Name: "api"}}, + {ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "node api.js", CWD: "/tmp/api2", ProjectRoot: "/tmp/api2"}, ManagedService: &models.ManagedService{Name: "api"}}, + }, + services: []*models.ManagedService{{Name: "api", CWD: "/tmp/api", Command: "node api.js"}}, + } + m := newTopModel(app) + + visible := m.visibleServers() + if len(visible) != 2 { + t.Fatalf("expected 2 visible servers, got %d", len(visible)) + } + + // First call computes and caches + names1 := m.displayNames(visible) + if m.cachedDisplayNames == nil { + t.Fatal("expected cachedDisplayNames to be populated after first call") + } + if len(names1) != 2 { + t.Fatalf("expected 2 display names, got %d", len(names1)) + } + // Duplicate "api" names should get ~1 and ~2 suffixes + found1, found2 := false, false + for _, n := range names1 { + if n == "api~1" { + found1 = true + } + if n == "api~2" { + found2 = true + } + } + if !found1 || !found2 { + t.Fatalf("expected api~1 and api~2 for duplicate names, got %v", names1) + } + + // Second call returns cache (same version) + names2 := m.displayNames(visible) + if len(names1) != len(names2) { + t.Fatal("expected cached display names to match") + } + for i := range names1 { + if names1[i] != names2[i] { + t.Fatalf("display name mismatch at %d: %q vs %q", i, names1[i], names2[i]) + } + } + + // Invalidate via refresh + app.servers = []*models.ServerInfo{ + {ProcessRecord: &models.ProcessRecord{PID: 2001, Port: 4000, Command: "node web.js", CWD: "/tmp/web", ProjectRoot: "/tmp/web"}, ManagedService: &models.ManagedService{Name: "web"}}, + } + m.refresh() + if m.cachedDisplayNames != nil { + t.Fatal("expected refresh to invalidate cachedDisplayNames") + } + + // New visible servers get new display names + newVisible := m.visibleServers() + if len(newVisible) != 1 { + t.Fatalf("expected 1 visible server after refresh, got %d", len(newVisible)) + } + names3 := m.displayNames(newVisible) + if len(names3) != 1 || names3[0] != "web" { + t.Fatalf("expected single web display name, got %v", names3) + } +} diff --git a/pkg/cli/tui/commands.go b/pkg/cli/tui/commands.go new file mode 100644 index 0000000..b98c0dc --- /dev/null +++ b/pkg/cli/tui/commands.go @@ -0,0 +1,676 @@ +package tui + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/models" + "github.com/devports/devpt/pkg/process" +) + +func (m *topModel) countVisible() int { return len(m.visibleServers()) } + +func (m *topModel) currentFilterQuery() string { + if m.mode == viewModeSearch { + return m.searchInput.Value() + } + return m.searchQuery +} + +func (m *topModel) visibleServers() []*models.ServerInfo { + q := strings.ToLower(strings.TrimSpace(m.currentFilterQuery())) + if m.cachedVisible != nil && + m.cachedVisibleQuery == q && + m.cachedVisibleSortBy == m.sortBy && + m.cachedVisibleReverse == m.sortReverse && + m.cachedVisibleVersion == m.serversVersion { + return m.cachedVisible + } + + visible := make([]*models.ServerInfo, 0, len(m.servers)) + for _, srv := range m.servers { + if srv == nil || srv.ProcessRecord == nil { + continue + } + if srv.ManagedService == nil { + if srv.ProcessRecord.Port == 0 || !isRuntimeCommand(srv.ProcessRecord.Command) { + continue + } + } + if q != "" && !matchesServerQuery(m, srv, q) { + continue + } + visible = append(visible, srv) + } + m.sortServers(visible) + m.cachedVisible = visible + m.cachedVisibleQuery = q + m.cachedVisibleSortBy = m.sortBy + m.cachedVisibleReverse = m.sortReverse + m.cachedVisibleVersion = m.serversVersion + return visible +} + +func (m *topModel) managedServices() []*models.ManagedService { + q := strings.ToLower(strings.TrimSpace(m.currentFilterQuery())) + if m.cachedManaged != nil && + m.cachedManagedQuery == q && + m.cachedManagedVersion == m.servicesVersion { + return m.cachedManaged + } + + services := m.app.ListServices() + filtered := make([]*models.ManagedService, 0, len(services)) + for _, svc := range services { + if q == "" || strings.Contains(strings.ToLower(svc.Name+" "+svc.CWD+" "+svc.Command), q) { + filtered = append(filtered, svc) + } + } + sort.Slice(filtered, func(i, j int) bool { return strings.ToLower(filtered[i].Name) < strings.ToLower(filtered[j].Name) }) + m.cachedManaged = filtered + m.cachedManagedQuery = q + m.cachedManagedVersion = m.servicesVersion + return filtered +} + +func matchesServerQuery(m *topModel, srv *models.ServerInfo, q string) bool { + var b strings.Builder + name := strings.ToLower(m.serviceNameFor(srv)) + project := strings.ToLower(projectOf(srv)) + command := strings.ToLower(srv.ProcessRecord.Command) + cwd := strings.ToLower(srv.ProcessRecord.CWD) + projectRoot := strings.ToLower(srv.ProcessRecord.ProjectRoot) + port := strconv.Itoa(srv.ProcessRecord.Port) + + b.Grow(len(name) + len(project) + len(command) + len(port) + len(cwd) + len(projectRoot) + 5) + b.WriteString(name) + b.WriteByte(' ') + b.WriteString(project) + b.WriteByte(' ') + b.WriteString(command) + b.WriteByte(' ') + b.WriteString(port) + b.WriteByte(' ') + b.WriteString(cwd) + b.WriteByte(' ') + b.WriteString(projectRoot) + return strings.Contains(b.String(), q) +} + +func (m *topModel) serviceNameFor(srv *models.ServerInfo) string { + if srv == nil { + return "-" + } + if srv.ManagedService != nil && srv.ManagedService.Name != "" { + return srv.ManagedService.Name + } + if srv.ProcessRecord != nil { + if srv.ProcessRecord.ProjectRoot != "" { + return pathBase(srv.ProcessRecord.ProjectRoot) + } + if srv.ProcessRecord.CWD != "" { + return pathBase(srv.ProcessRecord.CWD) + } + if srv.ProcessRecord.Command != "" { + return pathBase(srv.ProcessRecord.Command) + } + } + return "-" +} + +func (m *topModel) runCommand(input string) string { + if input == "" { + return "" + } + args, err := parseArgs(input) + if err != nil || len(args) == 0 { + return "Invalid command" + } + switch args[0] { + case "help": + m.openHelpModal() + return "" + case "list": + services := m.app.ListServices() + if len(services) == 0 { + return "No managed services" + } + names := make([]string, 0, len(services)) + for _, svc := range services { + names = append(names, svc.Name) + } + sort.Strings(names) + return "Managed services: " + strings.Join(names, ", ") + case "add": + if len(args) < 4 { + return "Usage: add \"\" [ports...]" + } + name, cwd, cmd := args[1], args[2], args[3] + var ports []int + for _, p := range args[4:] { + port, perr := strconv.Atoi(p) + if perr != nil { + return "Invalid port: " + p + } + ports = append(ports, port) + } + if err := m.app.RegisterService(name, cwd, cmd, ports); err != nil { + return err.Error() + } + return fmt.Sprintf("Added %q", name) + case "remove", "rm": + if len(args) < 2 { + return "Usage: remove " + } + svc := m.app.GetService(args[1]) + if svc == nil { + return fmt.Sprintf("service %q not found", args[1]) + } + m.openConfirmModal(&confirmState{kind: confirmRemoveService, prompt: fmt.Sprintf("Remove %q from registry?", svc.Name), name: svc.Name}) + return "" + case "restore": + if len(args) < 2 { + return "Usage: restore " + } + svc := m.removed[args[1]] + if svc == nil { + return fmt.Sprintf("no removed service %q in this session", args[1]) + } + if err := m.app.RegisterService(svc.Name, svc.CWD, svc.Command, svc.Ports); err != nil { + return err.Error() + } + delete(m.removed, args[1]) + return fmt.Sprintf("Restored %q", args[1]) + case "start": + if len(args) < 2 { + return "Usage: start " + } + if err := m.app.StartService(args[1]); err != nil { + return err.Error() + } + m.starting[args[1]] = time.Now() + return fmt.Sprintf("Started %q", args[1]) + case "stop": + if len(args) < 2 { + return "Usage: stop " + } + if args[1] == "--port" { + if len(args) < 3 { + return "Usage: stop --port PORT" + } + if err := m.app.StopService(args[2]); err != nil { + return err.Error() + } + return fmt.Sprintf("Stopped port %s", args[2]) + } + if err := m.app.StopService(args[1]); err != nil { + return err.Error() + } + return fmt.Sprintf("Stopped %q", args[1]) + default: + return "Unknown command (type :help)" + } +} + +func (m topModel) startSelected() string { + visible := m.visibleServers() + if m.selected < 0 || m.selected >= len(visible) { + return "No service selected" + } + srv := visible[m.selected] + if srv.ManagedService == nil { + return "Selected process is not a managed service" + } + if err := m.app.StartService(srv.ManagedService.Name); err != nil { + return err.Error() + } + m.starting[srv.ManagedService.Name] = time.Now() + return fmt.Sprintf("Started %q", srv.ManagedService.Name) +} + +func (m topModel) restartSelected() string { + visible := m.visibleServers() + if m.selected < 0 || m.selected >= len(visible) { + return "No service selected" + } + srv := visible[m.selected] + if srv.ManagedService == nil { + return "Selected process is not a managed service" + } + if err := m.app.RestartService(srv.ManagedService.Name); err != nil { + return err.Error() + } + m.starting[srv.ManagedService.Name] = time.Now() + return fmt.Sprintf("Restarted %q", srv.ManagedService.Name) +} + +func (m *topModel) restartManaged() string { + managed := m.managedServices() + if m.managedSel < 0 || m.managedSel >= len(managed) { + return "No managed service selected" + } + name := managed[m.managedSel].Name + if err := m.app.RestartService(name); err != nil { + return err.Error() + } + m.starting[name] = time.Now() + return fmt.Sprintf("Restarted %q", name) +} + +func (m *topModel) prepareManagedStopConfirm() { + managed := m.managedServices() + if m.managedSel < 0 || m.managedSel >= len(managed) { + m.cmdStatus = "No managed service selected" + return + } + svc := managed[m.managedSel] + if svc.LastPID != nil && *svc.LastPID != 0 { + prompt := fmt.Sprintf("Stop %q (PID %d)?", svc.Name, *svc.LastPID) + m.openConfirmModal(&confirmState{kind: confirmStopPID, prompt: prompt, pid: *svc.LastPID, serviceName: svc.Name}) + } else { + prompt := fmt.Sprintf("Stop %q?", svc.Name) + m.openConfirmModal(&confirmState{kind: confirmStopPID, prompt: prompt, serviceName: svc.Name}) + } +} + +func (m *topModel) prepareStopConfirm() { + visible := m.visibleServers() + if m.selected < 0 || m.selected >= len(visible) { + m.cmdStatus = "No service selected" + return + } + srv := visible[m.selected] + if srv.ProcessRecord == nil || srv.ProcessRecord.PID == 0 { + m.cmdStatus = "No PID to stop" + return + } + prompt := fmt.Sprintf("Stop PID %d?", srv.ProcessRecord.PID) + serviceName := "" + if srv.ManagedService != nil { + prompt = fmt.Sprintf("Stop %q (PID %d)?", srv.ManagedService.Name, srv.ProcessRecord.PID) + serviceName = srv.ManagedService.Name + } + m.openConfirmModal(&confirmState{kind: confirmStopPID, prompt: prompt, pid: srv.ProcessRecord.PID, serviceName: serviceName}) +} + +func (m *topModel) executeConfirm(yes bool) tea.Cmd { + if m.confirm == nil { + m.closeModal() + return nil + } + c := *m.confirm + m.closeModal() + if !yes { + m.groupHighlightNamespace = nil + m.cmdStatus = "Cancelled" + return nil + } + switch c.kind { + case confirmGroupStop, confirmGroupRestart, confirmGroupStart, confirmGroupRemove: + m.groupHighlightNamespace = nil + m.executeGroupConfirm(c) + case confirmStopPID: + if c.serviceName != "" { + // Managed service stop: go through lifecycle layer so it + // reconciles the live PID instead of trusting registry LastPID. + if err := m.app.StopService(c.serviceName); err != nil { + if isProcessFinishedErr(err) { + m.cmdStatus = fmt.Sprintf("%q already exited", c.serviceName) + _ = m.app.ClearServicePID(c.serviceName) + } else { + m.cmdStatus = err.Error() + } + } else { + m.cmdStatus = fmt.Sprintf("Stopped %q", c.serviceName) + } + } else { + // Raw process stop (discovered list, no managed service). + if err := m.app.StopProcess(c.pid, 5*time.Second); err != nil { + if errors.Is(err, process.ErrNeedSudo) { + m.openConfirmModal(&confirmState{kind: confirmSudoKill, prompt: fmt.Sprintf("Run sudo kill -9 %d now?", c.pid), pid: c.pid}) + return nil + } + if isProcessFinishedErr(err) { + m.cmdStatus = fmt.Sprintf("Process %d already exited", c.pid) + } else { + m.cmdStatus = err.Error() + } + } else { + m.cmdStatus = fmt.Sprintf("Stopped PID %d", c.pid) + } + } + case confirmRemoveService: + svc := m.app.GetService(c.name) + if svc != nil { + copySvc := *svc + m.removed[c.name] = ©Svc + } + if err := m.app.RemoveService(c.name); err != nil { + m.cmdStatus = err.Error() + } else { + m.cmdStatus = fmt.Sprintf("Removed %q (use :restore %s)", c.name, c.name) + } + case confirmSudoKill: + m.cmdStatus = fmt.Sprintf("Run manually: sudo kill -9 %d", c.pid) + } + m.refresh() + return nil +} + +func (m topModel) tailLogsCmd() tea.Cmd { + return func() tea.Msg { + if m.logSvc != nil { + lines, err := m.app.TailServiceLogs(m.logSvc.Name, 200) + return logMsg{lines: lines, err: err} + } + if m.logPID > 0 { + lines, err := m.app.TailProcessLogs(m.logPID, 200) + return logMsg{lines: lines, err: err} + } + return logMsg{err: fmt.Errorf("no service selected")} + } +} + +func (m topModel) healthCmd() tea.Cmd { + visible := m.visibleServers() + return func() tea.Msg { + icons := make(map[int]string) + details := make(map[int]*health.HealthCheck) + for _, srv := range visible { + if srv.ProcessRecord == nil || srv.ProcessRecord.Port <= 0 { + continue + } + check := m.healthChk.Check(srv.ProcessRecord.Port) + icons[srv.ProcessRecord.Port] = health.StatusIcon(check.Status) + details[srv.ProcessRecord.Port] = check + } + return healthMsg{icons: icons, details: details} + } +} + +func (m topModel) memoryCmd() tea.Cmd { + visible := m.visibleServers() + return func() tea.Msg { + pids := make([]int, 0, len(visible)) + for _, srv := range visible { + if srv.ProcessRecord != nil && srv.ProcessRecord.PID > 0 { + pids = append(pids, srv.ProcessRecord.PID) + } + } + return memoryMsg{memory: m.app.GetProcessMemory(pids)} + } +} + +// --------------------------------------------------------------------------- +// Group actions (namespace-based process clustering) +// --------------------------------------------------------------------------- + +func (m *topModel) prepareGroupStopConfirm() { + if m.mode != viewModeTable { + return + } + namespace := namespaceOfSelected(m) + m.groupHighlightNamespace = &namespace + if namespace == "-" { + return + } + group := groupForNamespace(m, namespace) + if len(group) == 0 { + m.cmdStatus = "No group members found for namespace \"" + namespace + "\"" + return + } + names := groupServiceNames(group) + pids := groupPIDs(group) + prompt := fmt.Sprintf("Stop %d process(es) in namespace \"%s\"?\n%s", len(group), namespace, strings.Join(names, ", ")) + m.openConfirmModal(&confirmState{ + kind: confirmGroupStop, + prompt: prompt, + namespace: namespace, + serviceNames: names, + pids: pids, + }) +} + +func (m *topModel) prepareGroupRestartConfirm() { + if m.mode != viewModeTable { + return + } + namespace := namespaceOfSelected(m) + m.groupHighlightNamespace = &namespace + if namespace == "-" { + return + } + + // Find all namespace members: managed services (running, crashed, stopped) + // plus any unmanaged running servers in the namespace. + managed := m.managedServices() + managedSet := make(map[string]bool) + var toRestart []string + var toStart []string + var pids []int + for _, svc := range managed { + if extractNamespace(svc.Name) != namespace { + continue + } + managedSet[svc.Name] = true + if m.isServiceRunning(svc.Name) { + toRestart = append(toRestart, svc.Name) + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == svc.Name && srv.ProcessRecord != nil && srv.ProcessRecord.PID > 0 { + pids = append(pids, srv.ProcessRecord.PID) + } + } + } else { + toStart = append(toStart, svc.Name) + } + } + + // Also include unmanaged running servers in the namespace + for _, srv := range m.visibleServers() { + if srv == nil || srv.ProcessRecord == nil { + continue + } + name := m.serviceNameFor(srv) + if extractNamespace(name) != namespace { + continue + } + if srv.ManagedService != nil { + continue // already handled above + } + toRestart = append(toRestart, name) + pids = append(pids, srv.ProcessRecord.PID) + } + + if len(toRestart) == 0 && len(toStart) == 0 { + m.cmdStatus = "No group members found for namespace \"" + namespace + "\"" + return + } + + // Build descriptive prompt + var parts []string + allNames := append(toRestart, toStart...) + if len(toRestart) > 0 { + parts = append(parts, fmt.Sprintf("restart %d", len(toRestart))) + } + if len(toStart) > 0 { + parts = append(parts, fmt.Sprintf("start %d stopped", len(toStart))) + } + prompt := fmt.Sprintf("%s service(s) in namespace \"%s\"?\n%s", + strings.Join(parts, " and "), + namespace, + strings.Join(allNames, ", ")) + + m.openConfirmModal(&confirmState{ + kind: confirmGroupRestart, + prompt: prompt, + namespace: namespace, + serviceNames: allNames, + pids: pids, + }) +} + +func (m *topModel) prepareGroupStartConfirm() { + if m.mode != viewModeTable { + return + } + if m.focus == focusRunning { + // C-1.5 / C-1.8: Shift+Enter on running list is no-op (view logs not groupable) + return + } + namespace := namespaceOfSelected(m) + m.groupHighlightNamespace = &namespace + if namespace == "-" { + return + } + + // Group start targets only stopped managed services in the namespace + managed := m.managedServices() + var stopped []string + for _, svc := range managed { + if extractNamespace(svc.Name) != namespace { + continue + } + if !m.isServiceRunning(svc.Name) { + stopped = append(stopped, svc.Name) + } + } + + if len(stopped) == 0 { + m.cmdStatus = "All services in namespace \"" + namespace + "\" are already running" + return + } + + prompt := fmt.Sprintf("Start %d stopped service(s) in namespace \"%s\"?\n%s", len(stopped), namespace, strings.Join(stopped, ", ")) + m.openConfirmModal(&confirmState{ + kind: confirmGroupStart, + prompt: prompt, + namespace: namespace, + serviceNames: stopped, + }) +} + +func (m *topModel) prepareGroupRemoveConfirm() { + if m.mode != viewModeTable { + return + } + if m.focus != focusManaged { + return + } + namespace := namespaceOfSelected(m) + m.groupHighlightNamespace = &namespace + if namespace == "-" { + return + } + + // Group remove targets all managed services in the namespace + managed := m.managedServices() + var targets []string + for _, svc := range managed { + if extractNamespace(svc.Name) == namespace { + targets = append(targets, svc.Name) + } + } + + if len(targets) == 0 { + m.cmdStatus = "No managed services found for namespace \"" + namespace + "\"" + return + } + + prompt := fmt.Sprintf("Remove %d service(s) from registry in namespace \"%s\"?\n%s", len(targets), namespace, strings.Join(targets, ", ")) + m.openConfirmModal(&confirmState{ + kind: confirmGroupRemove, + prompt: prompt, + namespace: namespace, + serviceNames: targets, + }) +} + +// executeGroupConfirm handles the confirmed group action by iterating over +// each member and calling the existing single-item functions. +func (m *topModel) executeGroupConfirm(c confirmState) { + switch c.kind { + case confirmGroupStop: + var results []string + for i, pid := range c.pids { + name := "" + if i < len(c.serviceNames) { + name = c.serviceNames[i] + } + if err := m.app.StopProcess(pid, 5*time.Second); err != nil { + if isProcessFinishedErr(err) { + results = append(results, fmt.Sprintf("PID %d already exited", pid)) + if name != "" { + _ = m.app.ClearServicePID(name) + } + } else { + results = append(results, fmt.Sprintf("PID %d: %v", pid, err)) + } + } else { + results = append(results, fmt.Sprintf("Stopped PID %d", pid)) + if name != "" { + _ = m.app.ClearServicePID(name) + } + } + } + m.cmdStatus = strings.Join(results, "; ") + + case confirmGroupRestart: + var results []string + for _, name := range c.serviceNames { + if m.isServiceRunning(name) { + if err := m.app.RestartService(name); err != nil { + results = append(results, fmt.Sprintf("%s: %v", name, err)) + } else { + results = append(results, fmt.Sprintf("Restarted %q", name)) + m.starting[name] = time.Now() + } + } else { + // Stopped/crashed service — start it instead + if err := m.app.StartService(name); err != nil { + results = append(results, fmt.Sprintf("%s: %v", name, err)) + } else { + results = append(results, fmt.Sprintf("Started %q", name)) + m.starting[name] = time.Now() + } + } + } + m.cmdStatus = strings.Join(results, "; ") + + case confirmGroupStart: + var results []string + for _, name := range c.serviceNames { + if err := m.app.StartService(name); err != nil { + results = append(results, fmt.Sprintf("%s: %v", name, err)) + } else { + results = append(results, fmt.Sprintf("Started %q", name)) + m.starting[name] = time.Now() + } + } + m.cmdStatus = strings.Join(results, "; ") + + case confirmGroupRemove: + var results []string + for _, name := range c.serviceNames { + svc := m.app.GetService(name) + if svc != nil { + copySvc := *svc + m.removed[name] = ©Svc + } + if err := m.app.RemoveService(name); err != nil { + results = append(results, fmt.Sprintf("%s: %v", name, err)) + } else { + results = append(results, fmt.Sprintf("Removed %q", name)) + } + } + m.cmdStatus = strings.Join(results, "; ") + } + + m.refresh() +} diff --git a/pkg/cli/tui/deps.go b/pkg/cli/tui/deps.go new file mode 100644 index 0000000..ef77daf --- /dev/null +++ b/pkg/cli/tui/deps.go @@ -0,0 +1,28 @@ +package tui + +import ( + "time" + + "github.com/devports/devpt/pkg/models" +) + +// AppDeps is the narrow surface the TUI needs from the CLI application layer. +type AppDeps interface { + DiscoverServers() ([]*models.ServerInfo, error) + ListServices() []*models.ManagedService + GetService(name string) *models.ManagedService + ClearServicePID(name string) error + RegisterService(name, cwd, command string, ports []int) error + RemoveService(name string) error + StartService(name string) error + StopService(identifier string) error + RestartService(name string) error + StopProcess(pid int, timeout time.Duration) error + TailServiceLogs(name string, lines int) ([]string, error) + TailProcessLogs(pid int, lines int) ([]string, error) + LatestServiceLogPath(name string) (string, error) + + // GetProcessMemory returns RSS memory in KB for each live PID. + // Dead or inaccessible PIDs are silently omitted. + GetProcessMemory(pids []int) map[int]int64 +} diff --git a/pkg/cli/tui/helpers.go b/pkg/cli/tui/helpers.go new file mode 100644 index 0000000..adf6857 --- /dev/null +++ b/pkg/cli/tui/helpers.go @@ -0,0 +1,539 @@ +package tui + +import ( + "regexp" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" + + "github.com/devports/devpt/pkg/models" +) + +// pythonVersionedRe matches versioned python binaries: python3, python3.12, python2.7, etc. +var pythonVersionedRe = regexp.MustCompile(`^python\d.*`) + +// copyIcon is the clipboard icon rendered next to copiable command text. +const copyIcon = "⧉" + +func fixedCell(s string, width int) string { + if width <= 0 { + return "" + } + w := runewidth.StringWidth(s) + if w > width { + return runewidth.Truncate(s, width, "") + } + return s + strings.Repeat(" ", width-w) +} + +// osc8Link wraps text in an OSC 8 hyperlink escape sequence. +// Terminals that support OSC 8 will make the text clickable, opening the given URL. +// Unsupported terminals silently display the plain text. +func osc8Link(text, url string) string { + return ansi.SetHyperlink(url) + text + ansi.ResetHyperlink() +} + +// fixedHyperlinkCell wraps text in an OSC 8 hyperlink and pads it to the given +// visible width. Uses ansi.StringWidth which correctly strips escape sequences +// for width calculation (unlike runewidth.StringWidth which does not). +func fixedHyperlinkCell(text, url string, width int) string { + if width <= 0 { + return "" + } + linked := osc8Link(text, url) + visibleWidth := ansi.StringWidth(linked) + if visibleWidth >= width { + // Text exceeds cell width — truncate the plain text (strip escapes for display) + truncated := ansi.Truncate(text, width, "") + return truncated + strings.Repeat(" ", width-ansi.StringWidth(truncated)) + } + return linked + strings.Repeat(" ", width-visibleWidth) +} + +func wrapRunes(s string, width int) []string { + if width <= 0 { + return []string{s} + } + if s == "" { + return []string{""} + } + var out []string + rest := s + for runewidth.StringWidth(rest) > width { + chunk := runewidth.Truncate(rest, width, "") + if chunk == "" { + break + } + out = append(out, chunk) + rest = strings.TrimPrefix(rest, chunk) + } + if rest != "" { + out = append(out, rest) + } + return out +} + +func wrapWords(s string, width int) []string { + if width <= 0 { + return []string{s} + } + words := strings.Fields(s) + if len(words) == 0 { + return []string{""} + } + lines := make([]string, 0, 4) + cur := words[0] + for _, w := range words[1:] { + candidate := cur + " " + w + if runewidth.StringWidth(candidate) <= width { + cur = candidate + continue + } + lines = append(lines, cur) + if runewidth.StringWidth(w) > width { + chunks := wrapRunes(w, width) + if len(chunks) > 0 { + lines = append(lines, chunks[:len(chunks)-1]...) + cur = chunks[len(chunks)-1] + } else { + cur = w + } + } else { + cur = w + } + } + lines = append(lines, cur) + return lines +} + +func parseArgs(input string) ([]string, error) { + var args []string + var buf strings.Builder + inQuotes := false + var quote rune + escaped := false + for _, r := range input { + if escaped { + buf.WriteRune(r) + escaped = false + continue + } + switch r { + case '\\': + escaped = true + case '"', '\'': + if inQuotes && r == quote { + inQuotes = false + quote = 0 + } else if !inQuotes { + inQuotes = true + quote = r + } else { + buf.WriteRune(r) + } + case ' ', '\t': + if inQuotes { + buf.WriteRune(r) + } else if buf.Len() > 0 { + args = append(args, buf.String()) + buf.Reset() + } + default: + buf.WriteRune(r) + } + } + if buf.Len() > 0 { + args = append(args, buf.String()) + } + return args, nil +} + +func fitLine(line string, width int) string { + if width <= 0 { + return line + } + lineWidth := runewidth.StringWidth(line) + if lineWidth > width { + if width <= 3 { + return runewidth.Truncate(line, width, "") + } + return runewidth.Truncate(line, width, "...") + } + return line + strings.Repeat(" ", width-lineWidth) +} + +func formatPorts(ports []int) string { + if len(ports) == 0 { + return "" + } + strs := make([]string, len(ports)) + for i, p := range ports { + strs[i] = strconv.Itoa(p) + } + return strings.Join(strs, ", ") +} + +func pathBase(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "-" + } + if strings.Contains(raw, " ") { + raw = strings.Fields(raw)[0] + } + raw = strings.TrimRight(raw, "/") + parts := strings.Split(raw, "/") + if len(parts) == 0 { + return "-" + } + base := parts[len(parts)-1] + if base == "" { + return "-" + } + return base +} + +func projectOf(srv *models.ServerInfo) string { + if srv == nil || srv.ProcessRecord == nil { + return "" + } + if srv.ProcessRecord.ProjectRoot != "" { + return pathBase(srv.ProcessRecord.ProjectRoot) + } + return pathBase(srv.ProcessRecord.CWD) +} + +func portOf(srv *models.ServerInfo) int { + if srv == nil || srv.ProcessRecord == nil { + return 0 + } + return srv.ProcessRecord.Port +} + +func pidOf(srv *models.ServerInfo) int { + if srv == nil || srv.ProcessRecord == nil { + return 0 + } + return srv.ProcessRecord.PID +} + +func isRuntimeCommand(raw string) bool { + base := strings.ToLower(pathBase(raw)) + switch base { + case "node", "nodejs", "npm", "npx", "pnpm", "yarn", "bun", "bunx", "deno", + "vite", "webpack", "webpack-dev-server", "next", "next-server", "nuxt", "ts-node", "tsx", + "python", "pip", "pipenv", "poetry", + "ruby", "rails", + "go", + "java", "javac", "gradle", "mvn", + "dotnet", + "php": + return true + default: + // Match versioned binaries like python3, python3.12, python2.7 + if pythonVersionedRe.MatchString(base) { + return true + } + return false + } +} + +func isProcessFinishedErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "process already finished") || strings.Contains(msg, "no such process") +} + +func (m topModel) isServiceRunning(name string) bool { + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name && srv.ProcessRecord != nil && srv.ProcessRecord.PID > 0 { + return true + } + } + return false +} + +func (m topModel) serviceStatus(name string) string { + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name { + if srv.Status != "" { + return srv.Status + } + } + } + if m.isServiceRunning(name) { + return "running" + } + return "stopped" +} + +func (m topModel) crashReasonForService(name string) string { + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name && srv.Status == "crashed" { + return srv.CrashReason + } + } + return "" +} + +func (m topModel) serverInfoForService(name string) *models.ServerInfo { + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name { + return srv + } + } + return nil +} + +func (m topModel) selectedManagedService() *models.ManagedService { + managed := m.managedServices() + if m.managedSel < 0 || m.managedSel >= len(managed) { + return nil + } + return managed[m.managedSel] +} + +// confirmTargetName returns the service name targeted by the active confirm dialog, if any. +func (m *topModel) confirmTargetName() string { + if m.confirm == nil { + return "" + } + if m.confirm.serviceName != "" { + return m.confirm.serviceName + } + if m.confirm.name != "" { + return m.confirm.name + } + return "" +} + +func managedStatusSymbol(state string) string { + switch state { + case "running": + return "▶" + case "crashed": + return "✘" + case "starting": + return "…" + default: + return "■" + } +} + +func managedStatusColor(state string) string { + switch state { + case "running": + return "10" + case "crashed": + return "9" + case "starting": + return "11" + default: + return "8" + } +} + +func nonEmptyTail(lines []string, n int) []string { + if n <= 0 || len(lines) == 0 { + return nil + } + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.TrimSpace(line) != "" { + filtered = append(filtered, line) + } + } + if len(filtered) <= n { + return filtered + } + return filtered[len(filtered)-n:] +} + +func (m topModel) calculateGutterWidth() int { + totalLines := m.viewport.TotalLineCount() + if totalLines <= 0 { + return 0 + } + width := len(strconv.Itoa(totalLines)) + return width + 1 +} + +func (m *topModel) handleMouseClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + mouse := msg.Mouse() + if mouse.Button != tea.MouseLeft { + return m, nil + } + if len(m.logLines) == 0 { + return m, nil + } + + gutterWidth := m.calculateGutterWidth() + clickedInGutter := mouse.X < gutterWidth + clickedLine := mouse.Y + absoluteLine := clickedLine + m.viewport.YOffset() + + if absoluteLine < 0 || absoluteLine >= len(m.logLines) { + return m, nil + } + + if clickedInGutter { + m.viewport.SetYOffset(absoluteLine) + } else { + visibleLines := m.viewport.VisibleLineCount() + if visibleLines > 0 { + centerOffset := absoluteLine - (visibleLines / 2) + if centerOffset < 0 { + centerOffset = 0 + } + m.viewport.SetYOffset(centerOffset) + } + } + + return m, nil +} + +func (m *topModel) handleEnterKey() (tea.Model, tea.Cmd) { + if m.focus == focusManaged { + managed := m.managedServices() + if m.managedSel >= 0 && m.managedSel < len(managed) { + if err := m.app.StartService(managed[m.managedSel].Name); err != nil { + m.cmdStatus = err.Error() + } else { + name := managed[m.managedSel].Name + m.cmdStatus = "Started " + strconv.Quote(name) + m.starting[name] = time.Now() + } + m.refresh() + return m, nil + } + } + if m.focus == focusRunning { + visible := m.visibleServers() + if m.selected >= 0 && m.selected < len(visible) { + srv := visible[m.selected] + m.mode = viewModeLogs + if srv.ManagedService == nil { + m.logSvc = nil + m.logPID = srv.ProcessRecord.PID + } else { + m.logSvc = srv.ManagedService + m.logPID = 0 + } + m.viewportNeedsTop = true + return m, m.tailLogsCmd() + } + } + return m, nil +} + +// mouseCoordOffset compensates for Bubble Tea's mouse coordinate system, +// which reports row coordinates one line below our internal table math. +const mouseCoordOffset = 1 + +func (m *topModel) handleTableMouseClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + visible := m.visibleServers() + managed := m.managedServices() + mouse := msg.Mouse() + + headerOffset := m.tableTopLines(m.width) + viewportY := mouse.Y - headerOffset + mouseCoordOffset + if viewportY < 0 { + return m, nil + } + + // Check if click is on the header row (line 0 in running viewport) + if viewportY < m.table.lastRunningHeight { + absoluteLine := viewportY + m.table.runningYOffset() + if absoluteLine == 0 { + if col := m.columnAtX(mouse.X); col >= 0 { + m.cycleSort(col) + m.lastInput = time.Now() + return m, nil + } + } + } + + runningDataStart := 2 + + const doubleClickThreshold = 500 * time.Millisecond + isDoubleClick := !m.lastClickTime.IsZero() && + time.Since(m.lastClickTime) < doubleClickThreshold && + m.lastClickY == mouse.Y + + m.lastClickTime = time.Now() + m.lastClickY = mouse.Y + + if viewportY < m.table.lastRunningHeight { + absoluteLine := viewportY + m.table.runningYOffset() + runningDataEnd := runningDataStart + len(visible) - 1 + if absoluteLine < runningDataStart || absoluteLine > runningDataEnd { + return m, nil + } + newSelected := absoluteLine - runningDataStart + if newSelected >= 0 && newSelected < len(visible) { + if isDoubleClick && m.selected == newSelected { + m.focus = focusRunning + m.tableFollowSelection = true + m.lastInput = time.Now() + return m.handleEnterKey() + } + m.focus = focusRunning + m.selected = newSelected + m.tableFollowSelection = true + m.groupHighlightNamespace = nil + m.lastInput = time.Now() + } + return m, nil + } + + // Managed header sits directly above the managed viewport content. + if viewportY == m.table.lastRunningHeight { + return m, nil + } + + managedViewportY := viewportY - m.table.lastRunningHeight - 1 + + switch m.table.managedClickRegion(managedViewportY, mouse.X) { + case managedRegionDetails: + // Check if click is on the copy icon in the details Cmd line. + if m.detailsCommand != "" && m.detailsCmdLineIdx >= 0 { + absoluteDetailsLine := managedViewportY + m.table.selectedDetailsVP.YOffset() + if absoluteDetailsLine == m.detailsCmdLineIdx && mouse.X-m.table.lastListWidth < 4 { + return m, tea.SetClipboard(m.detailsCommand) + } + } + return m, nil + case managedRegionList: + // fall through to list selection below + default: + return m, nil + } + + absoluteManagedLine := managedViewportY + m.table.managedYOffset() + newManagedSel := absoluteManagedLine + if newManagedSel >= 0 && newManagedSel < len(managed) { + if isDoubleClick && m.managedSel == newManagedSel { + m.focus = focusManaged + m.tableFollowSelection = true + m.lastInput = time.Now() + if mouse.Mod&tea.ModShift != 0 { + m.prepareGroupStartConfirm() + return m, nil + } + return m.handleEnterKey() + } + m.groupHighlightNamespace = nil + m.focus = focusManaged + m.managedSel = newManagedSel + m.tableFollowSelection = true + m.lastInput = time.Now() + } + + return m, nil +} diff --git a/pkg/cli/tui/keymap.go b/pkg/cli/tui/keymap.go new file mode 100644 index 0000000..d123017 --- /dev/null +++ b/pkg/cli/tui/keymap.go @@ -0,0 +1,156 @@ +package tui + +import "charm.land/bubbles/v2/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + Tab key.Binding + Enter key.Binding + Search key.Binding + ClearFilter key.Binding + Sort key.Binding + SortReverse key.Binding + Health key.Binding + Help key.Binding + Add key.Binding + Restart key.Binding + Stop key.Binding + Remove key.Binding + Debug key.Binding + Back key.Binding + Follow key.Binding + NextMatch key.Binding + PrevMatch key.Binding + Confirm key.Binding + Cancel key.Binding + Quit key.Binding + GroupStop key.Binding + GroupRestart key.Binding + GroupRemove key.Binding + GroupToggle key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("up/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("down/j", "move down"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch list"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "logs/start"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + ClearFilter: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("^L", "clear filter"), + ), + Sort: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "sort"), + ), + SortReverse: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "sort reverse"), + ), + Health: key.NewBinding( + key.WithKeys("h"), + key.WithHelp("h", "health detail"), + ), + Help: key.NewBinding( + key.WithKeys("?", "f1"), + key.WithHelp("?", "toggle help"), + ), + Add: key.NewBinding( + key.WithKeys("ctrl+a"), + key.WithHelp("^A", "add"), + ), + Restart: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("^R", "restart"), + ), + Stop: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("^E", "stop"), + ), + Remove: key.NewBinding( + key.WithKeys("x", "delete", "ctrl+d"), + key.WithHelp("x", "remove managed"), + ), + Debug: key.NewBinding( + key.WithKeys("D"), + key.WithHelp("D", "debug"), + ), + Back: key.NewBinding( + key.WithKeys("esc", "b"), + key.WithHelp("esc/b", "back"), + ), + Follow: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "toggle follow"), + ), + NextMatch: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "next match"), + ), + PrevMatch: key.NewBinding( + key.WithKeys("N"), + key.WithHelp("N", "prev match"), + ), + Confirm: key.NewBinding( + key.WithKeys("enter", "y"), + key.WithHelp("enter/y", "confirm"), + ), + Cancel: key.NewBinding( + key.WithKeys("n", "esc"), + key.WithHelp("n/esc", "cancel"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + GroupStop: key.NewBinding( + key.WithKeys("ctrl+shift+e"), + key.WithHelp("^⇧E", "group stop"), + ), + GroupRestart: key.NewBinding( + key.WithKeys("ctrl+shift+r"), + key.WithHelp("^⇧R", "group restart"), + ), + + GroupRemove: key.NewBinding( + key.WithKeys("shift+x"), + key.WithHelp("⇧X", "group remove"), + ), + GroupToggle: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "group mode"), + ), + } +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Tab, k.Enter, k.Search, k.Help, k.GroupToggle} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Tab, k.Enter, k.Search, k.ClearFilter}, + {k.Sort, k.SortReverse, k.Health, k.Help, k.Add, k.Restart, k.Stop}, + {k.Remove, k.Debug, k.Back, k.Follow, k.NextMatch, k.PrevMatch}, + {k.Confirm, k.Cancel, k.Quit}, + {k.GroupToggle, k.GroupStop, k.GroupRestart, k.GroupRemove}, + } +} diff --git a/pkg/cli/tui/memory_test.go b/pkg/cli/tui/memory_test.go new file mode 100644 index 0000000..4269b43 --- /dev/null +++ b/pkg/cli/tui/memory_test.go @@ -0,0 +1,108 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestDetailsPane_MemoryDisplay(t *testing.T) { + t.Parallel() + + t.Run("memory shown in running service details", func(t *testing.T) { + m := newTestModel() + m.width = 120 + m.height = 40 + m.focus = focusRunning + m.selected = 0 + // Simulate memory data for PID 1001 (the test server) + m.memory[1001] = 128 * 1024 // 128 MB + + visible := m.visibleServers() + managed := m.managedServices() + details := m.renderSelectedServiceDetails(60, visible, managed) + + if !strings.Contains(details, "Memory:") { + t.Fatal("details pane should contain 'Memory:' line") + } + if !strings.Contains(details, "128 MB") { + t.Fatalf("details pane should show '128 MB', got:\n%s", details) + } + if !strings.Contains(details, "Memory:") { + t.Fatal("details pane should contain Memory line") + } + }) + + t.Run("memory omitted when no data available", func(t *testing.T) { + m := newTestModel() + m.width = 120 + m.height = 40 + m.focus = focusRunning + m.selected = 0 + // No memory data — should not show Memory line + + visible := m.visibleServers() + managed := m.managedServices() + details := m.renderSelectedServiceDetails(60, visible, managed) + + if strings.Contains(details, "Memory:") { + t.Fatal("details pane should not show Memory when no data available") + } + }) + + t.Run("memory color thresholds", func(t *testing.T) { + tests := []struct { + kb int64 + contains string + }{ + {8 * 1024, "8.0 MB"}, // gray (under 50 MB, fractional) + {128 * 1024, "128 MB"}, // default + {312 * 1024, "312 MB"}, // yellow (200-500 MB) + {780 * 1024, "780 MB"}, // orange (500 MB - 1 GB) + {2 * 1024 * 1024, "2.0 GB"}, // red (>1 GB) + } + + for _, tt := range tests { + m := newTestModel() + m.width = 120 + m.height = 40 + m.focus = focusRunning + m.selected = 0 + m.memory[1001] = tt.kb + + visible := m.visibleServers() + managed := m.managedServices() + details := m.renderSelectedServiceDetails(60, visible, managed) + + if !strings.Contains(details, tt.contains) { + t.Errorf("for %d KB, expected details to contain %q, got:\n%s", tt.kb, tt.contains, details) + } + } + }) +} + +func TestMemoryMsg_UpdatesMemoryMap(t *testing.T) { + m := newTestModel() + m.memoryBusy = true + + msg := memoryMsg{memory: map[int]int64{ + 1001: 256 * 1024, + 2002: 512 * 1024, + }} + + model, cmd := m.Update(msg) + _ = model + + if m.memoryBusy { + t.Fatal("memoryBusy should be false after memoryMsg") + } + // Async handlers return nil — the main tick loop drives the heartbeat + if cmd != nil { + t.Fatalf("memoryMsg should return nil cmd, got %v", cmd) + } + if m.memory[1001] != 256*1024 { + t.Fatalf("memory[1001] = %d, want %d", m.memory[1001], 256*1024) + } + if m.memory[2002] != 512*1024 { + t.Fatalf("memory[2002] = %d, want %d", m.memory[2002], 512*1024) + } +} diff --git a/pkg/cli/tui/modal.go b/pkg/cli/tui/modal.go new file mode 100644 index 0000000..5a90ce9 --- /dev/null +++ b/pkg/cli/tui/modal.go @@ -0,0 +1,202 @@ +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +type modalBounds struct { + x int + y int + width int + height int +} + +func (m *topModel) openHelpModal() { + m.modal = &modalState{kind: modalHelp} +} + +func (m *topModel) openConfirmModal(confirm *confirmState) { + m.confirm = confirm + m.modal = &modalState{kind: modalConfirm} +} + +func (m *topModel) closeModal() { + m.modal = nil + m.confirm = nil +} + +func (m *topModel) activeModalKind() modalKind { + if m.modal == nil { + return 0 + } + return m.modal.kind +} + +func renderModal(title, body, hint string, width, maxWidth int, accent string) string { + boxWidth := width - 8 + if maxWidth > 0 && boxWidth > maxWidth { + boxWidth = maxWidth + } + if boxWidth < 24 { + boxWidth = width + } + + bodyWidth := boxWidth - 4 + if bodyWidth < 8 { + bodyWidth = boxWidth + } + + lines := []string{ + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(accent)).Render(title), + } + for _, line := range strings.Split(body, "\n") { + lines = append(lines, fitAnsiLine(line, bodyWidth)) + } + if hint != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitAnsiLine(hint, bodyWidth))) + } + content := strings.Join(lines, "\n") + + return lipgloss.NewStyle(). + Width(boxWidth). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(accent)). + Padding(0, 1). + Render(content) +} + +func (m *topModel) renderConfirmModal(width int) string { + if m.confirm == nil { + return "" + } + return renderModal("Confirm", m.confirm.prompt, "Enter/y confirm, n/Esc cancel", width, 72, "11") +} + +func (m *topModel) renderHelpModal(width int) string { + h := m.help + boxWidth := width - 12 + if boxWidth > 96 { + boxWidth = 96 + } + if boxWidth < 36 { + boxWidth = width + } + h.ShowAll = true + h.SetWidth(boxWidth - 4) + + body := strings.Join([]string{ + h.View(m.keys), + "", + "Commands: add, start, stop, remove, restore, list, help", + }, "\n") + return renderModal("Help", body, "Esc/? closes", width, boxWidth, "12") +} + +func (m *topModel) activeModalOverlay(width int) string { + switch m.activeModalKind() { + case modalHelp: + return m.renderHelpModal(width) + case modalConfirm: + if m.confirm != nil && isGroupConfirmKind(m.confirm.kind) { + return m.renderGroupConfirmModal(width) + } + return m.renderConfirmModal(width) + default: + return "" + } +} + +func isGroupConfirmKind(k confirmKind) bool { + return k == confirmGroupStop || k == confirmGroupRestart || k == confirmGroupStart || k == confirmGroupRemove +} + +func (m *topModel) renderGroupConfirmModal(width int) string { + if m.confirm == nil { + return "" + } + return renderModal("Group Action", m.confirm.prompt, "Enter/y confirm, n/Esc cancel", width, 72, "11") +} + +func overlayModal(background, overlay string, width int) string { + bgLines := strings.Split(strings.TrimRight(background, "\n"), "\n") + ovLines := strings.Split(overlay, "\n") + if len(bgLines) == 0 || len(ovLines) == 0 { + return background + } + + bounds := calculateModalBounds(bgLines, ovLines, width) + + for i, line := range ovLines { + targetY := bounds.y + i + if targetY < 0 || targetY >= len(bgLines) { + continue + } + left := ansi.Cut(bgLines[targetY], 0, bounds.x) + rightStart := bounds.x + ansi.StringWidth(line) + right := "" + if rightStart < width { + right = ansi.Cut(bgLines[targetY], rightStart, width) + } + bgLines[targetY] = padAnsiLine(left, bounds.x) + line + padAnsiLine(right, width-rightStart) + } + + return strings.Join(bgLines, "\n") + "\n" +} + +func (m *topModel) activeModalBounds(width int, background string) modalBounds { + overlay := m.activeModalOverlay(width) + bgLines := strings.Split(strings.TrimRight(background, "\n"), "\n") + ovLines := strings.Split(overlay, "\n") + return calculateModalBounds(bgLines, ovLines, width) +} + +func calculateModalBounds(bgLines, ovLines []string, width int) modalBounds { + bounds := modalBounds{} + if len(bgLines) == 0 || len(ovLines) == 0 { + return bounds + } + + bounds.height = len(ovLines) + bounds.y = (len(bgLines) - bounds.height) / 2 + if bounds.y < 0 { + bounds.y = 0 + } + + for _, line := range ovLines { + if w := ansi.StringWidth(line); w > bounds.width { + bounds.width = w + } + } + + bounds.x = (width - bounds.width) / 2 + if bounds.x < 0 { + bounds.x = 0 + } + + return bounds +} + +func (b modalBounds) contains(x, y int) bool { + return x >= b.x && x < b.x+b.width && y >= b.y && y < b.y+b.height +} + +func padAnsiLine(line string, targetWidth int) string { + width := ansi.StringWidth(line) + if width >= targetWidth { + return line + } + return line + strings.Repeat(" ", targetWidth-width) +} + +func fitAnsiLine(line string, targetWidth int) string { + if targetWidth <= 0 { + return line + } + if ansi.StringWidth(line) > targetWidth { + return ansi.Truncate(line, targetWidth, "...") + } + return padAnsiLine(line, targetWidth) +} diff --git a/pkg/cli/tui/model.go b/pkg/cli/tui/model.go new file mode 100644 index 0000000..7cba164 --- /dev/null +++ b/pkg/cli/tui/model.go @@ -0,0 +1,261 @@ +package tui + +import ( + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/models" +) + +type viewMode int +type viewFocus int +type confirmKind int +type modalKind int + +const ( + viewModeTable viewMode = iota + viewModeLogs + viewModeLogsDebug + viewModeCommand + viewModeSearch +) + +const ( + focusRunning viewFocus = iota + focusManaged +) + +const ( + confirmStopPID confirmKind = iota + confirmRemoveService + confirmSudoKill + confirmGroupStop + confirmGroupRestart + confirmGroupStart + confirmGroupRemove +) + +const ( + modalHelp modalKind = iota + 1 + modalConfirm +) + +type confirmState struct { + kind confirmKind + prompt string + pid int + name string + serviceName string + namespace string + serviceNames []string + pids []int +} + +type modalState struct { + kind modalKind +} + +type topModel struct { + app AppDeps + servers []*models.ServerInfo + width int + height int + lastUpdate time.Time + lastInput time.Time + err error + + serversVersion int + servicesVersion int + cachedVisible []*models.ServerInfo + cachedVisibleQuery string + cachedVisibleSortBy sortMode + cachedVisibleReverse bool + cachedVisibleVersion int + cachedManaged []*models.ManagedService + cachedManagedQuery string + cachedManagedVersion int + + selected int + managedSel int + focus viewFocus + mode viewMode + + logLines []string + logErr error + logSvc *models.ManagedService + logPID int + followLogs bool + + cmdInput string + searchQuery string + cmdStatus string + searchInput textinput.Model + + health map[int]string + healthDetails map[int]*health.HealthCheck + showHealthDetail bool + healthBusy bool + healthLast time.Time + healthChk *health.Checker + + memory map[int]int64 // PID → RSS in KB + memoryBusy bool + memoryLast time.Time + + sortBy sortMode + sortReverse bool + lastSortBy sortMode // track last sorted column for 3-state cycle + + starting map[string]time.Time + removed map[string]*models.ManagedService + + modal *modalState + confirm *confirmState + table processTable + + keys keyMap + help help.Model + viewport viewport.Model + viewportNeedsTop bool + highlightIndex int + highlightMatches []int + + lastClickTime time.Time + lastClickY int + tableFollowSelection bool + + // Toggle-based visual group selection (g key) + groupHighlightNamespace *string + + // Command text available for clipboard copy in the current view. + logCommand string + detailsCommand string + detailsCmdLineIdx int // 1-based line index of the Cmd line in the details pane (-1 if none) + + // Render caches — invalidated by refresh(), sort changes, and filter changes. + cachedDisplayNames []string + cachedDisplayNamesQuery string + cachedDisplayNamesSortBy sortMode + cachedDisplayNamesReverse bool + cachedDisplayNamesVersion int + cachedDisplayNamesSvcVer int +} + +type tickMsg time.Time + +type logMsg struct { + lines []string + err error +} + +type healthMsg struct { + icons map[int]string + details map[int]*health.HealthCheck + err error +} + +type memoryMsg struct { + memory map[int]int64 // PID → RSS KB +} + +func Run(app AppDeps) error { + model := newTopModel(app) + p := tea.NewProgram(model) + _, err := p.Run() + return err +} + +func newTopModel(app AppDeps) *topModel { + searchInput := textinput.New() + searchInput.Prompt = ">" + searchInput.Placeholder = "" + searchInput.CharLimit = 256 + searchInput.SetVirtualCursor(true) + searchStyles := textinput.DefaultStyles(false) + searchStyles.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) + searchStyles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) + searchStyles.Blurred.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + searchStyles.Blurred.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + searchInput.SetStyles(searchStyles) + + m := &topModel{ + app: app, + lastUpdate: time.Now(), + lastInput: time.Now(), + mode: viewModeTable, + focus: focusRunning, + followLogs: false, + health: make(map[int]string), + healthDetails: make(map[int]*health.HealthCheck), + healthChk: health.NewChecker(800 * time.Millisecond), + memory: make(map[int]int64), + sortBy: sortRecent, + starting: make(map[string]time.Time), + removed: make(map[string]*models.ManagedService), + keys: defaultKeyMap(), + help: help.New(), + searchInput: searchInput, + tableFollowSelection: true, + detailsCmdLineIdx: -1, + serversVersion: 1, + servicesVersion: 1, + } + if servers, err := app.DiscoverServers(); err == nil { + m.servers = servers + } + m.invalidateCachedLists() + + m.viewport = viewport.New() + m.table = newProcessTable() + m.highlightIndex = 0 + + return m +} + +func (m topModel) Init() tea.Cmd { + return tickCmd() +} + +func (m *topModel) refresh() { + if servers, err := m.app.DiscoverServers(); err == nil { + m.servers = servers + m.serversVersion++ + m.servicesVersion++ + m.invalidateCachedLists() + m.lastUpdate = time.Now() + if m.selected >= len(m.visibleServers()) && len(m.visibleServers()) > 0 { + m.selected = len(m.visibleServers()) - 1 + } + if m.managedSel >= len(m.managedServices()) && len(m.managedServices()) > 0 { + m.managedSel = len(m.managedServices()) - 1 + } + for name, at := range m.starting { + if m.isServiceRunning(name) || time.Since(at) > 45*time.Second { + delete(m.starting, name) + } + } + } else { + m.err = err + } +} + +func (m *topModel) invalidateCachedLists() { + m.cachedVisible = nil + m.cachedManaged = nil + m.cachedDisplayNames = nil + m.cachedDisplayNamesQuery = "" + m.cachedDisplayNamesSortBy = sortRecent + m.cachedDisplayNamesReverse = false + m.cachedDisplayNamesVersion = 0 + m.cachedDisplayNamesSvcVer = 0 +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} diff --git a/pkg/cli/tui/namespace.go b/pkg/cli/tui/namespace.go new file mode 100644 index 0000000..a87ad63 --- /dev/null +++ b/pkg/cli/tui/namespace.go @@ -0,0 +1,131 @@ +package tui + +import ( + "fmt" + "regexp" + + "github.com/devports/devpt/pkg/models" +) + +// namespaceRegex matches: leading non-alphanumeric chars + first alphanumeric sequence +// Examples: "_offgrid-be" matches "_offgrid", "api-gateway" matches "api" +var namespaceRegex = regexp.MustCompile(`^([^a-zA-Z0-9]*[a-zA-Z0-9]+)[^a-zA-Z0-9]`) + +// extractNamespace returns the namespace prefix of a service name, +// including any leading special characters (e.g., _). The namespace is +// everything from start up to the first separator (non-alphanumeric) +// after the first alphanumeric character. +// Examples: +// "_offgrid-api" → "_offgrid" +// "offgrid-be" → "offgrid" +// "api-gateway" → "api" +// Returns "-" for empty, whitespace-only, or strings with no alphanumeric characters. +func extractNamespace(name string) string { + if name == "" { + return "-" + } + // Try to match the pattern: [leading specials][alphanumerics][separator] + matches := namespaceRegex.FindStringSubmatch(name) + if len(matches) < 2 { + // No separator found, check if string has any alphanumerics at all + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return name // Entire string is namespace + } + } + return "-" // No alphanumeric characters + } + return matches[1] +} + +// groupForNamespace returns all visible servers matching the given namespace prefix. +// The function uses the current focus and search filter to determine visibility: +// - In focusRunning: returns visible servers whose service name shares the namespace. +// - In focusManaged: returns visible servers for managed services matching the namespace. +func groupForNamespace(m *topModel, namespace string) []*models.ServerInfo { + if namespace == "" || namespace == "-" { + return nil + } + + var group []*models.ServerInfo + + switch m.focus { + case focusRunning: + for _, srv := range m.visibleServers() { + if srv == nil || srv.ProcessRecord == nil { + continue + } + name := m.serviceNameFor(srv) + if extractNamespace(name) == namespace { + group = append(group, srv) + } + } + case focusManaged: + // For managed focus, we return running ServerInfo entries that + // correspond to managed services matching the namespace and visible + // under the current search filter. + managed := m.managedServices() + managedSet := make(map[string]bool) + for _, svc := range managed { + if extractNamespace(svc.Name) == namespace { + managedSet[svc.Name] = true + } + } + for _, srv := range m.visibleServers() { + if srv == nil || srv.ManagedService == nil { + continue + } + if managedSet[srv.ManagedService.Name] { + group = append(group, srv) + } + } + } + + return group +} + +// namespaceOfSelected returns the namespace of the currently selected service. +func namespaceOfSelected(m *topModel) string { + switch m.focus { + case focusRunning: + visible := m.visibleServers() + if m.selected < 0 || m.selected >= len(visible) { + return "-" + } + srv := visible[m.selected] + name := m.serviceNameFor(srv) + return extractNamespace(name) + case focusManaged: + managed := m.managedServices() + if m.managedSel < 0 || m.managedSel >= len(managed) { + return "-" + } + return extractNamespace(managed[m.managedSel].Name) + default: + return "-" + } +} + +// groupServiceNames extracts service names from a group of ServerInfo. +func groupServiceNames(group []*models.ServerInfo) []string { + names := make([]string, 0, len(group)) + for _, srv := range group { + if srv != nil && srv.ManagedService != nil { + names = append(names, srv.ManagedService.Name) + } else if srv != nil && srv.ProcessRecord != nil { + names = append(names, fmt.Sprintf("pid:%d", srv.ProcessRecord.PID)) + } + } + return names +} + +// groupPIDs extracts PIDs from a group of ServerInfo. +func groupPIDs(group []*models.ServerInfo) []int { + pids := make([]int, 0, len(group)) + for _, srv := range group { + if srv != nil && srv.ProcessRecord != nil && srv.ProcessRecord.PID > 0 { + pids = append(pids, srv.ProcessRecord.PID) + } + } + return pids +} diff --git a/pkg/cli/tui/namespace_test.go b/pkg/cli/tui/namespace_test.go new file mode 100644 index 0000000..e12e104 --- /dev/null +++ b/pkg/cli/tui/namespace_test.go @@ -0,0 +1,212 @@ +package tui + +import ( + "testing" + + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +// --------------------------------------------------------------------------- +// TEST-namespace-extraction +// Covers: BR-1.1, C-1.3, Edge-1.1, Edge-1.2 +// --------------------------------------------------------------------------- + +func TestExtractNamespace(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + // BR-1.1: dashed service names + {"dashed name", "api-gateway", "api"}, + {"dashed multi-segment", "web-frontend-v2", "web"}, + {"dashed single segment", "redis", "redis"}, + + // BR-1.1: dot-separated names + {"dot name", "pg.migrator", "pg"}, + {"dot multi-segment", "cache.redis.writer", "cache"}, + + // BR-1.1: pure alphanumeric + {"pure alnum", "redis", "redis"}, + {"pure alnum numeric", "app1", "app1"}, + + // Edge-1.1: empty or dash + {"empty string", "", "-"}, + {"single dash", "-", "-"}, + {"whitespace only", " ", "-"}, + + // Edge-1.2: collision / ambiguity (leading dash is part of namespace) + {"leading dash", "-gateway", "-gateway"}, + {"trailing dash", "api-", "api"}, + {"multiple dashes", "api---gateway", "api"}, + {"multiple dots", "pg...migrator", "pg"}, + {"mixed separators", "api.gateway-v2", "api"}, + + // Leading underscore handling: underscore is part of namespace for grouping + {"leading underscore service", "_mdt-api", "_mdt"}, + {"leading underscore service 2", "_offgrid-worker", "_offgrid"}, + {"multiple leading underscores", "___test-api", "___test"}, + {"mixed leading special chars", "_.-redis-cache", "_.-redis"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractNamespace(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +// --------------------------------------------------------------------------- +// TEST-group-membership +// Covers: BR-1.3, C-1.7 +// --------------------------------------------------------------------------- + +func TestGroupForNamespace(t *testing.T) { + t.Run("managed focus returns all managed services with matching namespace", func(t *testing.T) { + deps := &fakeAppDeps{ + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3002}}, + {Name: "redis", CWD: "/tmp/redis", Command: "redis-server", Ports: []int{6379}}, + }, + servers: []*models.ServerInfo{}, + } + m := newTopModel(deps) + m.focus = focusManaged + m.managedSel = 0 + + group := groupForNamespace(m, "web") + assert.Len(t, group, 0) // managed services don't appear as ServerInfo in group + }) + + t.Run("running focus returns visible servers with matching namespace", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/web-frontend", ProjectRoot: "/tmp/web-frontend"}, + Status: "running", + }, + { + ManagedService: &models.ManagedService{Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "go run .", CWD: "/tmp/web-backend", ProjectRoot: "/tmp/web-backend"}, + Status: "running", + }, + { + ProcessRecord: &models.ProcessRecord{PID: 1003, Port: 3002, Command: "python app.py", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.focus = focusRunning + m.selected = 0 + + group := groupForNamespace(m, "web") + assert.Len(t, group, 2) + names := make([]string, len(group)) + for i, srv := range group { + names[i] = srv.ManagedService.Name + } + assert.ElementsMatch(t, []string{"web-frontend", "web-backend"}, names) + }) + + t.Run("no match returns empty group", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js"}, + Status: "running", + }, + }, + } + m := newTopModel(deps) + m.focus = focusRunning + + group := groupForNamespace(m, "nonexistent") + assert.Len(t, group, 0) + }) + + t.Run("filter respects visibility — only visible (filter-passing) services included", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/api-gateway", ProjectRoot: "/tmp/api-gateway"}, + Status: "running", + }, + { + ManagedService: &models.ManagedService{Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "go run .", CWD: "/tmp/api-auth", ProjectRoot: "/tmp/api-auth"}, + Status: "running", + }, + { + ManagedService: &models.ManagedService{Name: "api-cron", CWD: "/tmp/api-cron", Command: "python cron.py", Ports: []int{3002}}, + ProcessRecord: &models.ProcessRecord{PID: 1003, Port: 3002, Command: "python cron.py", CWD: "/tmp/api-cron", ProjectRoot: "/tmp/api-cron"}, + Status: "running", + }, + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + {Name: "api-cron", CWD: "/tmp/api-cron", Command: "python cron.py", Ports: []int{3002}}, + }, + } + m := newTopModel(deps) + m.focus = focusRunning + m.selected = 0 + // Set a search filter that only shows gateway and auth (not cron) + m.searchQuery = "gateway" + m.searchInput.SetValue("gateway") + + group := groupForNamespace(m, "api") + // Only api-gateway should be visible (search filter: "gateway") + assert.Len(t, group, 1) + assert.Equal(t, "api-gateway", group[0].ManagedService.Name) + }) + + t.Run("managed focus returns managed services filtered by current search", func(t *testing.T) { + deps := &fakeAppDeps{ + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + {Name: "web-worker", CWD: "/tmp/web-worker", Command: "python worker.py", Ports: []int{3002}}, + }, + servers: []*models.ServerInfo{}, + } + m := newTopModel(deps) + m.focus = focusManaged + m.managedSel = 0 + m.searchQuery = "frontend" + m.searchInput.SetValue("frontend") + + group := groupForNamespace(m, "web") + // Only web-frontend is visible due to search filter + // For managed focus, groupForNamespace returns ServerInfo but + // managed services may not have running ServerInfo entries + assert.Len(t, group, 0) + }) + + t.Run("empty namespace returns empty group", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/api-gateway", ProjectRoot: "/tmp/api-gateway"}, + Status: "running", + }, + }, + } + m := newTopModel(deps) + m.focus = focusRunning + + group := groupForNamespace(m, "") + assert.Len(t, group, 0) + }) +} diff --git a/pkg/cli/tui/osc8_test.go b/pkg/cli/tui/osc8_test.go new file mode 100644 index 0000000..ba4b057 --- /dev/null +++ b/pkg/cli/tui/osc8_test.go @@ -0,0 +1,141 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- TEST-osc8-helper: OSC 8 helper function produces correct escape sequences --- + +func TestOsc8Link_Format(t *testing.T) { + link := osc8Link("3000", "http://localhost:3000") + + // Must contain the visible text + assert.Contains(t, link, "3000") + + // Must start with OSC 8 sequence + assert.True(t, strings.HasPrefix(link, "\x1b]8;;http://localhost:3000\x07"), + "link should start with OSC 8 hyperlink escape") + + // Must end with OSC 8 reset sequence + assert.True(t, strings.HasSuffix(link, "\x1b]8;;\x07"), + "link should end with OSC 8 reset escape") + + // The visible width must be just the text (4 for "3000") + assert.Equal(t, 4, ansi.StringWidth(link)) +} + +func TestOsc8Link_ZeroVisibleWidthForEscapes(t *testing.T) { + // Verify that the escape sequences themselves have zero visible width + open := ansi.SetHyperlink("http://localhost:3000") + close_ := ansi.ResetHyperlink() + assert.Equal(t, 0, ansi.StringWidth(open)) + assert.Equal(t, 0, ansi.StringWidth(close_)) +} + +// --- TEST-no-port-plain: Port dash renders as plain text without OSC 8 wrapping --- + +func TestPortCell_DashRendersPlain(t *testing.T) { + cell := portCell("-", 6) + + // Must be plain "-" with padding, no escape sequences + assert.Equal(t, "- ", cell) + assert.Equal(t, 6, ansi.StringWidth(cell)) + assert.Equal(t, 6, len(cell)) // plain ASCII, no escapes + assert.NotContains(t, cell, "\x1b]") +} + +// --- TEST-layout-dimensions: Table column widths and layout remain unchanged --- + +func TestFixedHyperlinkCell_Width(t *testing.T) { + cell := fixedHyperlinkCell("3000", "http://localhost:3000", 6) + + // Visible width must be exactly the requested width + assert.Equal(t, 6, ansi.StringWidth(cell)) + + // Must contain the port number + assert.Contains(t, cell, "3000") + + // Must contain OSC 8 escape sequences + assert.Contains(t, cell, "\x1b]8;;") +} + +func TestFixedHyperlinkCell_LongText(t *testing.T) { + // If text exceeds width, it falls back to truncation without hyperlink + cell := fixedHyperlinkCell("12345678", "http://localhost:12345678", 6) + assert.Equal(t, 6, ansi.StringWidth(cell)) + // Truncated plain text, no OSC 8 escapes since it overflows + assert.Equal(t, "123456", cell) +} + +func TestFixedHyperlinkCell_ZeroWidth(t *testing.T) { + cell := fixedHyperlinkCell("3000", "http://localhost:3000", 0) + assert.Equal(t, "", cell) +} + +func TestFixedHyperlinkCell_MatchesFixedCellForPlainText(t *testing.T) { + // When there's no hyperlink, fixedCell and fixedHyperlinkCell should + // produce the same visible result for the text portion + plain := fixedCell("3000", 6) + linked := fixedHyperlinkCell("3000", "http://localhost:3000", 6) + + // Both should have the same visible width + assert.Equal(t, ansi.StringWidth(plain), ansi.StringWidth(linked)) + + // The linked version should have escapes + assert.True(t, len(linked) > len(plain)) + assert.Contains(t, linked, "\x1b]8;;") +} + +// --- TEST-osc8-port-render: Port cell contains valid OSC 8 escape with correct URI --- + +func TestPortCell_NumericPort(t *testing.T) { + cell := portCell("3000", 6) + + // Visible width must be correct + assert.Equal(t, 6, ansi.StringWidth(cell)) + + // Must contain OSC 8 with correct URL + assert.Contains(t, cell, "http://localhost:3000") + + // Must contain the visible port number + assert.Contains(t, cell, "3000") + + // Must have opening and closing OSC 8 sequences + assert.True(t, strings.Contains(cell, "\x1b]8;;http://localhost:3000\x07")) + assert.True(t, strings.Contains(cell, "\x1b]8;;\x07")) +} + +func TestPortCell_SingleDigitPort(t *testing.T) { + cell := portCell("8", 6) + assert.Equal(t, 6, ansi.StringWidth(cell)) + assert.Contains(t, cell, "http://localhost:8") +} + +func TestPortCell_FiveDigitPort(t *testing.T) { + cell := portCell("65535", 6) + assert.Equal(t, 6, ansi.StringWidth(cell)) + assert.Contains(t, cell, "http://localhost:65535") +} + +func TestPortCell_DashNoEscape(t *testing.T) { + cell := portCell("-", 6) + // No escape sequences for dash + assert.Equal(t, "- ", cell) + require.Equal(t, 6, len(cell)) + for _, ch := range cell { + // All characters should be printable ASCII (no escape chars) + assert.True(t, ch >= 32 && ch <= 126, "unexpected non-printable char: %U", ch) + } +} + +func TestPortCell_HTTPSchemeOnly(t *testing.T) { + // Verify constraint C-1: only http scheme, only localhost + cell := portCell("3000", 6) + assert.Contains(t, cell, "http://localhost:3000") + assert.NotContains(t, cell, "https://") +} diff --git a/pkg/cli/tui/sort.go b/pkg/cli/tui/sort.go new file mode 100644 index 0000000..c5d9ea5 --- /dev/null +++ b/pkg/cli/tui/sort.go @@ -0,0 +1,144 @@ +package tui + +import ( + "sort" + "strings" + + "github.com/devports/devpt/pkg/models" +) + +type sortMode int + +const ( + sortRecent sortMode = iota + sortName + sortProject + sortPort + sortHealth + sortModeCount +) + +// sortModeLabel returns a human-readable label for the sort mode. +func sortModeLabel(s sortMode) string { + switch s { + case sortName: + return "name" + case sortProject: + return "project" + case sortPort: + return "port" + case sortHealth: + return "health" + default: + return "recent" + } +} + +// sortServers sorts the given servers slice according to the current sort mode. +func (m topModel) sortServers(servers []*models.ServerInfo) { + switch m.sortBy { + case sortName: + sort.Slice(servers, func(i, j int) bool { + cmp := strings.Compare(strings.ToLower(m.serviceNameFor(servers[i])), strings.ToLower(m.serviceNameFor(servers[j]))) + if m.sortReverse { + return cmp > 0 + } + return cmp < 0 + }) + case sortProject: + sort.Slice(servers, func(i, j int) bool { + cmp := strings.Compare(strings.ToLower(projectOf(servers[i])), strings.ToLower(projectOf(servers[j]))) + if m.sortReverse { + return cmp > 0 + } + return cmp < 0 + }) + case sortPort: + sort.Slice(servers, func(i, j int) bool { + if m.sortReverse { + return portOf(servers[i]) > portOf(servers[j]) + } + return portOf(servers[i]) < portOf(servers[j]) + }) + case sortHealth: + sort.Slice(servers, func(i, j int) bool { + cmp := strings.Compare(m.health[portOf(servers[i])], m.health[portOf(servers[j])]) + if m.sortReverse { + return cmp > 0 + } + return cmp < 0 + }) + default: + sort.Slice(servers, func(i, j int) bool { return pidOf(servers[i]) > pidOf(servers[j]) }) + } +} + +// columnAtX returns the sortMode for the column at the given X coordinate. +// Returns -1 if the X is not within a clickable column header. +func (m *topModel) columnAtX(x int) sortMode { + nameW, portW, pidW, projectW, healthW := 14, 6, 7, 14, 7 + sep := 2 + used := nameW + sep + portW + sep + pidW + sep + projectW + sep + healthW + sep + cmdW := m.width - used + if cmdW < 12 { + cmdW = 12 + } + + // Column positions (start, end) + nameEnd := nameW + portStart := nameW + sep + portEnd := portStart + portW + pidStart := portEnd + sep + pidEnd := pidStart + pidW + projectStart := pidEnd + sep + projectEnd := projectStart + projectW + cmdStart := projectEnd + sep + cmdEnd := cmdStart + cmdW + healthStart := cmdEnd + sep + healthEnd := healthStart + healthW + + switch { + case x >= 0 && x < nameEnd: + return sortName + case x >= portStart && x < portEnd: + return sortPort + case x >= pidStart && x < pidEnd: + return sortRecent // PID sorts by recent (default) + case x >= projectStart && x < projectEnd: + return sortProject + case x >= cmdStart && x < cmdEnd: + return sortRecent // Command column - no specific sort, use recent + case x >= healthStart && x < healthEnd: + return sortHealth + default: + return -1 + } +} + +// toggleSortDirection flips the sort direction between ascending and descending. +// No effect when in "Recent" mode (natural order only). +func (m *topModel) toggleSortDirection() { + if m.sortBy == sortRecent { + return + } + m.sortReverse = !m.sortReverse +} + +// cycleSort implements 3-state sort cycling: ascending (yellow) → reverse (orange) → reset to recent +func (m *topModel) cycleSort(col sortMode) { + // If clicking the same column that's currently sorted + if m.sortBy == col && m.sortBy != sortRecent { + if !m.sortReverse { + // State 1 → State 2: same column, now reverse + m.sortReverse = true + } else { + // State 2 → State 3: reset to recent + m.sortBy = sortRecent + m.sortReverse = false + } + } else { + // Different column or clicking recent: go to State 1 (ascending) + m.sortBy = col + m.sortReverse = false + } +} diff --git a/pkg/cli/tui/table.go b/pkg/cli/tui/table.go new file mode 100644 index 0000000..ec84c59 --- /dev/null +++ b/pkg/cli/tui/table.go @@ -0,0 +1,822 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/mattn/go-runewidth" + + "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/models" + "github.com/devports/devpt/pkg/resource" +) + +type processTable struct { + runningVP viewport.Model + managedListVP viewport.Model + selectedDetailsVP viewport.Model + + lastRunningHeight int + lastManagedHeight int + lastListWidth int + lastRunningContent string + lastListContent string + lastDetailsContent string +} + +func newProcessTable() processTable { + return processTable{ + runningVP: viewport.New(), + managedListVP: viewport.New(), + selectedDetailsVP: viewport.New(), + } +} + +func (t *processTable) heightFor(termHeight, aboveLines, belowLines int) int { + h := termHeight - aboveLines - belowLines + if h < 3 { + h = 3 + } + return h +} + +func (t *processTable) Render(m *topModel, width int) string { + visible := m.visibleServers() + managed := m.managedServices() + displayNames := m.displayNames(visible) + + topLines := m.tableTopLines(width) + bottomLines := m.tableBottomLines(width) + totalHeight := t.heightFor(m.height, topLines, bottomLines) + runningContent := m.renderRunningTable(width, visible, displayNames) + managedHeader := m.renderManagedHeader(width, managed) + listContent := m.renderManagedList(width/2, managed) + detailsContent := m.renderSelectedServiceDetails(width-width/2, visible, managed) + runningLines := 1 + strings.Count(runningContent, "\n") + listLines := 1 + strings.Count(listContent, "\n") + detailsLines := 1 + strings.Count(detailsContent, "\n") + managedLines := max(listLines, detailsLines) + runningHeight, managedHeight := t.sectionHeights(totalHeight, runningLines, managedLines) + + t.lastRunningHeight = runningHeight + t.lastManagedHeight = managedHeight + t.lastListWidth = width / 2 + + t.runningVP.SetWidth(width) + t.runningVP.SetHeight(runningHeight) + if t.lastRunningContent != runningContent { + t.runningVP.SetContent(runningContent) + t.lastRunningContent = runningContent + } + + t.managedListVP.SetWidth(width / 2) + t.managedListVP.SetHeight(managedHeight) + if t.lastListContent != listContent { + t.managedListVP.SetContent(listContent) + t.lastListContent = listContent + } + + t.selectedDetailsVP.SetWidth(width - width/2) + t.selectedDetailsVP.SetHeight(managedHeight) + if t.lastDetailsContent != detailsContent { + t.selectedDetailsVP.SetContent(detailsContent) + t.lastDetailsContent = detailsContent + } + + if m.tableFollowSelection { + t.scrollToSelection(m, visible, managed) + } + + listView := t.managedListVP.View() + detailsView := t.selectedDetailsVP.View() + + return t.runningVP.View() + "\n" + managedHeader + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, listView, detailsView) +} + +func (m *topModel) tableTopLines(width int) int { + // Header line + blank line before the table content. + return 2 +} + +func (m *topModel) tableBottomLines(width int) int { + lines := renderedLineCount(m.renderFooter(width)) + if sl := m.renderStatusLine(width); sl != "" { + lines += renderedLineCount(sl) + } + return lines +} + +func (m *topModel) hasStatusLine() bool { + if m.cmdStatus != "" { + return true + } + // With split view, details pane shows service context - no need for status line + return false +} + +func (m *topModel) renderStatusLine(width int) string { + text := "" + if m.cmdStatus != "" { + text = m.cmdStatus + } + // With split view, the details pane shows service state - no duplication in status line + if text == "" { + return "" + } + s := lipgloss.NewStyle().Foreground(lipgloss.Color("208")) + return s.Render(fitLine(text, width)) +} + +func (m *topModel) renderFooter(width int) string { + s := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) + h := m.help + h.SetWidth(width) + return strings.TrimRight(s.Render(h.View(m.footerKeyMap())), "\n") +} + +func (m *topModel) footerKeyMap() keyMap { + k := m.keys + k.Search = key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", m.footerFilterLabel()), + ) + if m.groupHighlightNamespace != nil { + green := lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true).Render("group mode") + k.GroupToggle = key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", green), + ) + } + return k +} + +func (m *topModel) footerFilterLabel() string { + switch { + case m.mode == viewModeSearch: + inputWidth := runewidth.StringWidth(m.searchInput.Value()) + 1 + if inputWidth < 1 { + inputWidth = 1 + } + if inputWidth > 24 { + inputWidth = 24 + } + m.searchInput.SetWidth(inputWidth) + return m.searchInput.View() + case strings.TrimSpace(m.searchQuery) != "": + return lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(m.searchQuery) + default: + return "filter" + } +} + +func (t *processTable) sectionHeights(totalHeight, runningLines, managedLines int) (int, int) { + if totalHeight < 3 { + return 1, 1 + } + + separator := 1 + minManaged := 3 + maxRunning := totalHeight - separator - minManaged + if maxRunning < 1 { + maxRunning = 1 + } + + runningHeight := runningLines + if runningHeight > maxRunning { + runningHeight = maxRunning + } + if runningHeight < 1 { + runningHeight = 1 + } + + managedHeight := totalHeight - separator - runningHeight + if managedHeight < 1 { + managedHeight = 1 + } + if managedLines > 0 && managedHeight > managedLines { + managedHeight = managedLines + } + + return runningHeight, managedHeight +} + +func (t *processTable) scrollToSelection(m *topModel, visible []*models.ServerInfo, managed []*models.ManagedService) { + if m.focus == focusRunning && m.selected >= 0 && m.selected < len(visible) { + selectedLine := 2 + m.selected + t.scrollViewportToLine(&t.runningVP, selectedLine) + } else if m.focus == focusManaged && m.managedSel >= 0 && m.managedSel < len(managed) { + selectedLine := m.managedSel + t.scrollViewportToLine(&t.managedListVP, selectedLine) + } +} + +func (t *processTable) scrollViewportToLine(vp *viewport.Model, selectedLine int) { + totalLines := vp.TotalLineCount() + visibleLines := vp.VisibleLineCount() + currentOffset := vp.YOffset() + + if selectedLine < currentOffset || selectedLine >= currentOffset+visibleLines { + desired := selectedLine - visibleLines/3 + if desired < 0 { + desired = 0 + } + if desired > totalLines-visibleLines { + desired = totalLines - visibleLines + } + if desired < 0 { + desired = 0 + } + vp.SetYOffset(desired) + } +} + +func (m *topModel) renderRunningTable(width int, visible []*models.ServerInfo, displayNames []string) string { + headerStyle := lipgloss.NewStyle() + yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true) // yellow for ascending + orangeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true) // orange for reverse + + nameW, portW, pidW, projectW, healthW := 14, 6, 7, 14, 7 + sep := 2 + used := nameW + sep + portW + sep + pidW + sep + projectW + sep + healthW + sep + cmdW := width - used + if cmdW < 12 { + cmdW = 12 + } + + // Compute styles first based on sort state + nameStyle := headerStyle + portStyle := headerStyle + projectStyle := headerStyle + healthStyle := headerStyle + + switch m.sortBy { + case sortName: + if m.sortReverse { + nameStyle = orangeStyle + } else { + nameStyle = yellowStyle + } + case sortPort: + if m.sortReverse { + portStyle = orangeStyle + } else { + portStyle = yellowStyle + } + case sortProject: + if m.sortReverse { + projectStyle = orangeStyle + } else { + projectStyle = yellowStyle + } + case sortHealth: + if m.sortReverse { + healthStyle = orangeStyle + } else { + healthStyle = yellowStyle + } + } + + nameHeader := nameStyle.Render(fixedCell(fmt.Sprintf("Name (%d)", len(visible)), nameW)) + portHeader := portStyle.Render(fixedCell("Port", portW)) + pidHeader := headerStyle.Render(fixedCell("PID", pidW)) + projectHeader := projectStyle.Render(fixedCell("Project", projectW)) + commandHeader := headerStyle.Render(fixedCell("Command", cmdW)) + healthHeader := healthStyle.Render(fixedCell("Health", healthW)) + + header := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", + nameHeader, pad(sep), + portHeader, pad(sep), + pidHeader, pad(sep), + projectHeader, pad(sep), + commandHeader, pad(sep), + healthHeader, + ) + divider := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", + fixedCell(strings.Repeat("─", nameW), nameW), pad(sep), + fixedCell(strings.Repeat("─", portW), portW), pad(sep), + fixedCell(strings.Repeat("─", pidW), pidW), pad(sep), + fixedCell(strings.Repeat("─", projectW), projectW), pad(sep), + fixedCell(strings.Repeat("─", cmdW), cmdW), pad(sep), + fixedCell(strings.Repeat("─", healthW), healthW), + ) + + if len(visible) == 0 { + if m.searchQuery != "" { + return fitLine("(no matching servers for filter)", width) + } + return fitLine("(no matching servers)", width) + } + + var lines []string + lines = append(lines, fitAnsiLine(header, width)) + lines = append(lines, fitLine(divider, width)) + + rowIndices := make([]int, len(visible)) + for i, srv := range visible { + rowIndices[i] = len(lines) + + project := projectOf(srv) + port := "-" + pid := 0 + cmd := "-" + icon := "…" + if srv.ProcessRecord != nil { + pid = srv.ProcessRecord.PID + cmd = srv.ProcessRecord.Command + if srv.ProcessRecord.Port > 0 { + port = fmt.Sprintf("%d", srv.ProcessRecord.Port) + if cached := m.health[srv.ProcessRecord.Port]; cached != "" { + icon = cached + } + } + } + + truncatedCmd := cmd + if runewidth.StringWidth(cmd) > cmdW { + truncatedCmd = runewidth.Truncate(cmd, cmdW, "...") + } + + line := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", + fixedCell(displayNames[i], nameW), pad(sep), + portCell(port, portW), pad(sep), + fixedCell(fmt.Sprintf("%d", pid), pidW), pad(sep), + fixedCell(project, projectW), pad(sep), + fixedCell(truncatedCmd, cmdW), pad(sep), + fixedCell(icon, healthW), + ) + // Use fitAnsiLine because portCell may contain OSC8 hyperlinks + // (runewidth.StringWidth in fitLine doesn't understand escape sequences) + lines = append(lines, fitAnsiLine(line, width)) + } + + // Apply row styles using shared color logic: group members, selection, confirm target. + confirmActive := m.activeModalKind() == modalConfirm + confirmTarget := m.confirmTargetName() + confirmPID := 0 + if m.confirm != nil && m.confirm.kind == confirmStopPID { + confirmPID = m.confirm.pid + } + for i, srv := range visible { + name := m.serviceNameFor(srv) + isGroup := m.groupHighlightNamespace != nil && extractNamespace(name) == *m.groupHighlightNamespace + isConfirm := confirmActive && ((confirmTarget != "" && name == confirmTarget) || + (confirmPID != 0 && srv.ProcessRecord != nil && srv.ProcessRecord.PID == confirmPID)) + c := rowColorsFor(m.focus == focusRunning, i == m.selected, isConfirm, isGroup, confirmActive) + if c.bg == "" { + continue + } + style := lipgloss.NewStyle().Background(lipgloss.Color(c.bg)) + if c.fg != "" { + style = style.Foreground(lipgloss.Color(c.fg)) + } + lines[rowIndices[i]] = style.Render(lines[rowIndices[i]]) + } + + out := strings.Join(lines, "\n") + if m.showHealthDetail && m.selected >= 0 && m.selected < len(visible) { + port := 0 + if visible[m.selected].ProcessRecord != nil { + port = visible[m.selected].ProcessRecord.Port + } + if d := m.healthDetails[port]; d != nil { + out += "\n" + fitLine(fmt.Sprintf("Health detail: %s %dms %s", health.StatusIcon(d.Status), d.ResponseMs, d.Message), width) + } + } + + return out +} + +func (m *topModel) renderManagedHeader(width int, managed []*models.ManagedService) string { + text := fmt.Sprintf("Managed Services (%d) ", len(managed)) + fillW := width - runewidth.StringWidth(text) + if fillW < 0 { + fillW = 0 + } + header := text + strings.Repeat("─", fillW) + return lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render(fitLine(header, width)) +} + +func (m *topModel) renderManagedList(width int, managed []*models.ManagedService) string { + if len(managed) == 0 { + return fitLine(`No managed services yet. Use ^A then: add myapp /path/to/app "npm run dev" 3000`, width) + } + + portOwners := make(map[int]int) + for _, svc := range managed { + for _, p := range svc.Ports { + portOwners[p]++ + } + } + + var lines []string + for i, svc := range managed { + state := m.serviceStatus(svc.Name) + if state == "stopped" { + if _, ok := m.starting[svc.Name]; ok { + state = "starting" + } + } + + // Build plain text first, then apply styling + symbolChar := managedStatusSymbol(state) + symbolColor := managedStatusColor(state) + plainLine := fmt.Sprintf("%s %s [%s]", symbolChar, svc.Name, state) + + conflicting := false + for _, p := range svc.Ports { + if portOwners[p] > 1 { + conflicting = true + break + } + } + if conflicting { + plainLine = fmt.Sprintf("%s (port conflict)", plainLine) + } else if len(svc.Ports) > 1 { + plainLine = fmt.Sprintf("%s (ports: %v)", plainLine, svc.Ports) + } + + // Determine background for this row via shared color logic + confirmActive := m.activeModalKind() == modalConfirm + isConfirm := m.confirmTargetName() == svc.Name && confirmActive + isGroup := m.groupHighlightNamespace != nil && extractNamespace(svc.Name) == *m.groupHighlightNamespace + c := rowColorsFor(m.focus == focusManaged, i == m.managedSel, isConfirm, isGroup, confirmActive) + rowBg := c.bg + rowFg := c.fg + + var line string + if rowBg != "" { + // Single render path for any row with background — no strings.Replace, no ANSI breakage. + style := lipgloss.NewStyle().Background(lipgloss.Color(rowBg)).Width(width) + if rowFg != "" { + style = style.Foreground(lipgloss.Color(rowFg)) + } + line = style.Render(fitLine(plainLine, width)) + } else { + // No background — safe to color symbol separately. + symbolStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(symbolColor)).Bold(true).Render(symbolChar) + line = strings.Replace(plainLine, symbolChar, symbolStyled, 1) + line = fitAnsiLine(line, width) + } + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +func (m *topModel) renderSelectedServiceDetails(width int, visible []*models.ServerInfo, managed []*models.ManagedService) string { + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + header := headerStyle.Render("Selected service details") + + // Reset details command on each render; will be set if a Cmd line is produced. + m.detailsCommand = "" + m.detailsCmdLineIdx = -1 + + // If focus is on running services, show details for the selected running service + if m.focus == focusRunning { + if m.selected < 0 || m.selected >= len(visible) { + placeholder := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("Select a running service to inspect details") + return header + "\n" + fitLine(placeholder, width) + } + + srv := visible[m.selected] + var lines []string + lines = append(lines, fitLine(header, width)) + + // Service name + name := m.serviceNameFor(srv) + if name != "-" { + lines = append(lines, fitLine(fmt.Sprintf(" Name: %s", name), width)) + } + + // Source + if srv.Source != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Source: %s", srv.Source), width)) + } + + // Status + if srv.Status != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Status: %s", srv.Status), width)) + } + + // Process details + if srv.ProcessRecord != nil { + lines = append(lines, fitLine(fmt.Sprintf(" PID: %d", srv.ProcessRecord.PID), width)) + if srv.ProcessRecord.Port > 0 { + lines = append(lines, fitLine(fmt.Sprintf(" Port: %d (%s)", srv.ProcessRecord.Port, srv.ProcessRecord.Protocol), width)) + } + if kb, ok := m.memory[srv.ProcessRecord.PID]; ok && kb > 0 { + memText := resource.FormatMemory(kb) + memColor := resource.MemoryColor(kb) + if memColor != "" { + memText = lipgloss.NewStyle().Foreground(lipgloss.Color(memColor)).Render(memText) + } + lines = append(lines, fitLine(fmt.Sprintf(" Memory: %s", memText), width)) + } + if srv.ProcessRecord.Command != "" { + m.detailsCommand = srv.ProcessRecord.Command + m.detailsCmdLineIdx = len(lines) + lines = append(lines, fitLine(fmt.Sprintf(" %s Cmd: %s", copyIcon, srv.ProcessRecord.Command), width)) + } + if srv.ProcessRecord.CWD != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Dir: %s", srv.ProcessRecord.CWD), width)) + } + if srv.ProcessRecord.ProjectRoot != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Project: %s", srv.ProcessRecord.ProjectRoot), width)) + } + if srv.ProcessRecord.StartTime != nil { + lines = append(lines, fitLine(fmt.Sprintf(" Started: %s", srv.ProcessRecord.StartTime.Format("2006-01-02 15:04:05")), width)) + } + if srv.ProcessRecord.AgentTag != nil { + lines = append(lines, fitLine(fmt.Sprintf(" Agent: %s (%s)", srv.ProcessRecord.AgentTag.AgentName, srv.ProcessRecord.AgentTag.Source), width)) + } + } + + // Managed service reference + if srv.ManagedService != nil { + lines = append(lines, fitLine(fmt.Sprintf(" Managed: %s", srv.ManagedService.Name), width)) + } + + // Health check details + if srv.ProcessRecord != nil && srv.ProcessRecord.Port > 0 { + if d := m.healthDetails[srv.ProcessRecord.Port]; d != nil { + lines = append(lines, fitLine(fmt.Sprintf(" Health: %s (%dms) %s", health.StatusIcon(d.Status), d.ResponseMs, d.Message), width)) + } + } + + // Crash info + if srv.Status == "crashed" { + if srv.CrashReason != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Headline: %s", srv.CrashReason), width)) + } + for _, logLine := range nonEmptyTail(srv.CrashLogTail, 3) { + lines = append(lines, fitLine(" "+strings.TrimSpace(logLine), width)) + } + if srv.ManagedService != nil { + if logPath, err := m.app.LatestServiceLogPath(srv.ManagedService.Name); err == nil && strings.TrimSpace(logPath) != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Log: %s", logPath), width)) + } + } + } + + return strings.Join(lines, "\n") + } + + // Otherwise, show details for the selected managed service + if m.managedSel < 0 || m.managedSel >= len(managed) { + placeholder := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("Select a managed service to inspect status") + return header + "\n" + fitLine(placeholder, width) + } + + svc := managed[m.managedSel] + state := m.serviceStatus(svc.Name) + if state == "stopped" { + if _, ok := m.starting[svc.Name]; ok { + state = "starting" + } + } + + symbol := lipgloss.NewStyle().Foreground(lipgloss.Color(managedStatusColor(state))).Bold(true).Render(managedStatusSymbol(state)) + + var lines []string + lines = append(lines, fitLine(header, width)) + lines = append(lines, fitLine(fmt.Sprintf(" %s %s [%s]", symbol, svc.Name, state), width)) + + if srv := m.serverInfoForService(svc.Name); srv != nil && srv.Source != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Source: %s", srv.Source), width)) + } + + // Service metadata: CWD, ports, command (rendered after source, before crash context) + if svc.CWD != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Dir: %s", svc.CWD), width)) + } + if len(svc.Ports) > 0 { + lines = append(lines, fitLine(fmt.Sprintf(" Port: %s", formatPorts(svc.Ports)), width)) + } + if svc.Command != "" { + m.detailsCommand = svc.Command + m.detailsCmdLineIdx = len(lines) + lines = append(lines, fitLine(fmt.Sprintf(" %s Cmd: %s", copyIcon, svc.Command), width)) + } + + // Show current process info if service is running + if srv := m.serverInfoForService(svc.Name); srv != nil && srv.ProcessRecord != nil { + lines = append(lines, fitLine(fmt.Sprintf(" PID: %d", srv.ProcessRecord.PID), width)) + if srv.ProcessRecord.StartTime != nil { + lines = append(lines, fitLine(fmt.Sprintf(" Started: %s", srv.ProcessRecord.StartTime.Format("2006-01-02 15:04:05")), width)) + } + if kb, ok := m.memory[srv.ProcessRecord.PID]; ok && kb > 0 { + memText := resource.FormatMemory(kb) + memColor := resource.MemoryColor(kb) + if memColor != "" { + memText = lipgloss.NewStyle().Foreground(lipgloss.Color(memColor)).Render(memText) + } + lines = append(lines, fitLine(fmt.Sprintf(" Memory: %s", memText), width)) + } + if d := m.healthDetails[srv.ProcessRecord.Port]; d != nil { + lines = append(lines, fitLine(fmt.Sprintf(" Health: %s (%dms) %s", health.StatusIcon(d.Status), d.ResponseMs, d.Message), width)) + } + } + + if state == "crashed" { + if reason := m.crashReasonForService(svc.Name); reason != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Headline: %s", reason), width)) + } + if logPath, err := m.app.LatestServiceLogPath(svc.Name); err == nil && strings.TrimSpace(logPath) != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Log: %s", logPath), width)) + } + if srv := m.serverInfoForService(svc.Name); srv != nil { + for _, logLine := range nonEmptyTail(srv.CrashLogTail, 3) { + lines = append(lines, fitLine(" "+strings.TrimSpace(logLine), width)) + } + } + } + + return strings.Join(lines, "\n") +} + +func (t *processTable) updateFocusedViewport(focus viewFocus, msg tea.Msg) tea.Cmd { + if focus == focusManaged { + var cmd tea.Cmd + t.managedListVP, cmd = t.managedListVP.Update(msg) + return cmd + } + var cmd tea.Cmd + t.runningVP, cmd = t.runningVP.Update(msg) + return cmd +} + +func (t *processTable) updateViewportForTableY(viewportY int, viewportX int, msg tea.Msg) tea.Cmd { + if viewportY < 0 { + return nil + } + if viewportY < t.lastRunningHeight { + var cmd tea.Cmd + t.runningVP, cmd = t.runningVP.Update(msg) + return cmd + } + if viewportY == t.lastRunningHeight { + return nil + } + + localManagedY := viewportY - t.lastRunningHeight - 1 + if localManagedY >= 0 && localManagedY < t.lastManagedHeight { + // Route scroll to list or details viewport based on X position + if viewportX < t.lastListWidth { + var cmd tea.Cmd + t.managedListVP, cmd = t.managedListVP.Update(msg) + return cmd + } + var cmd tea.Cmd + t.selectedDetailsVP, cmd = t.selectedDetailsVP.Update(msg) + return cmd + } + return nil +} + +// rowColors holds the foreground and background ANSI color codes for a table row. +type rowColors struct { + bg string // empty means no background + fg string // empty means default foreground +} + +// rowColorsFor computes the visual style for a table row based on its state. +// Parameters: +// - isFocusedPanel: this row's panel has keyboard focus +// - isSelected: this row is the cursor selection in its panel +// - isConfirmTarget: this row is the target of an active confirm dialog +// - isGroupMember: this row belongs to the active group highlight namespace +// - confirmActive: a confirm modal is currently shown +// +// Priority (first match wins): +// 1. Confirm target or selected+group during confirm → amber/orange (178/0) +// 2. Focused select → bright blue (57/15) +// 3. Group member → dimmed orange (94) during confirm, blue (61) otherwise +// 4. Unfocused select → gray (8/15) +func rowColorsFor(isFocusedPanel, isSelected, isConfirmTarget, isGroupMember, confirmActive bool) rowColors { + switch { + case isConfirmTarget || (confirmActive && isSelected && isGroupMember): + return rowColors{bg: "178", fg: "0"} + case isSelected && isFocusedPanel: + return rowColors{bg: "57", fg: "15"} + case isGroupMember: + if confirmActive { + return rowColors{bg: "94"} + } + return rowColors{bg: "61"} + case isSelected: + return rowColors{bg: "8", fg: "15"} + default: + return rowColors{} + } +} + +// managedClickRegion reports which managed sub-region a click falls in. +// It mirrors the X-based routing in updateViewportForTableY. +type managedRegion int + +const ( + managedRegionList managedRegion = iota // left pane: selectable items + managedRegionDetails // right pane: read-only details + managedRegionOutside // header separator or outside managed area +) + +func (t *processTable) managedClickRegion(managedViewportY, clickX int) managedRegion { + if managedViewportY < 0 || managedViewportY >= t.lastManagedHeight { + return managedRegionOutside + } + if clickX < t.lastListWidth { + return managedRegionList + } + return managedRegionDetails +} + +func (t *processTable) runningYOffset() int { + return t.runningVP.YOffset() +} + +func (t *processTable) managedYOffset() int { + return t.managedListVP.YOffset() +} + +func pad(n int) string { + return strings.Repeat(" ", n) +} + +// portCell renders a port value as a fixed-width cell. +// When the port is a number, it wraps it in an OSC 8 hyperlink to http://localhost:. +// When the port is "-" (no port), it renders as plain text. +// Uses ansi.StringWidth for correct width calculation with escape sequences. +func portCell(port string, width int) string { + if port == "-" { + return fixedCell(port, width) + } + return fixedHyperlinkCell(port, "http://localhost:"+port, width) +} + +func (m *topModel) displayNames(servers []*models.ServerInfo) []string { + q := strings.ToLower(strings.TrimSpace(m.currentFilterQuery())) + if m.cachedDisplayNames != nil && + m.cachedDisplayNamesVersion == m.serversVersion && + m.cachedDisplayNamesSvcVer == m.servicesVersion && + m.cachedDisplayNamesQuery == q && + m.cachedDisplayNamesSortBy == m.sortBy && + m.cachedDisplayNamesReverse == m.sortReverse { + return m.cachedDisplayNames + } + + base := make([]string, len(servers)) + projectToSvc := make(map[string]string) + for _, svc := range m.app.ListServices() { + cwd := strings.TrimRight(strings.TrimSpace(svc.CWD), "/") + if cwd != "" { + projectToSvc[cwd] = svc.Name + } + } + for i, srv := range servers { + base[i] = m.serviceNameFor(srv) + if base[i] == "-" && srv.ProcessRecord != nil { + root := strings.TrimRight(strings.TrimSpace(srv.ProcessRecord.ProjectRoot), "/") + cwd := strings.TrimRight(strings.TrimSpace(srv.ProcessRecord.CWD), "/") + if mapped := projectToSvc[root]; mapped != "" { + base[i] = mapped + } else if mapped := projectToSvc[cwd]; mapped != "" { + base[i] = mapped + } + } + } + + count := make(map[string]int) + for _, n := range base { + count[n]++ + } + type row struct{ idx, pid int } + group := make(map[string][]row) + for i, n := range base { + group[n] = append(group[n], row{idx: i, pid: pidOf(servers[i])}) + } + out := make([]string, len(base)) + for name, rows := range group { + if count[name] <= 1 || name == "-" { + for _, r := range rows { + out[r.idx] = name + } + continue + } + sort.Slice(rows, func(i, j int) bool { return rows[i].pid < rows[j].pid }) + for i, r := range rows { + out[r.idx] = fmt.Sprintf("%s~%d", name, i+1) + } + } + m.cachedDisplayNames = out + m.cachedDisplayNamesQuery = q + m.cachedDisplayNamesSortBy = m.sortBy + m.cachedDisplayNamesReverse = m.sortReverse + m.cachedDisplayNamesVersion = m.serversVersion + m.cachedDisplayNamesSvcVer = m.servicesVersion + return out +} diff --git a/pkg/cli/tui/test_group_color_test.go b/pkg/cli/tui/test_group_color_test.go new file mode 100644 index 0000000..ecd5d6c --- /dev/null +++ b/pkg/cli/tui/test_group_color_test.go @@ -0,0 +1,92 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestGroupConfirmSelectedRowColor(t *testing.T) { + t.Parallel() + + t.Run("managed list: selected row amber, other group members dimmed orange", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "node auth.js", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + m.width = 120 + m.height = 30 + + m.Update(tea.KeyPressMsg{Code: 'g'}) + m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl}) + assert.NotNil(t, m.confirm) + + content := m.renderManagedList(60, m.managedServices()) + lines := strings.Split(content, "\n") + + // Selected row (managedSel=0, alphabetically api-auth) → amber + assert.Contains(t, lines[0], "48;5;178", "selected row in group confirm should be amber") + // Other group member → dimmed orange + assert.Contains(t, lines[1], "48;5;94", "other group member should be dimmed orange") + }) + + t.Run("running table: selected row amber, other group members dimmed orange", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "node auth.js", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + m.width = 120 + m.height = 30 + + m.Update(tea.KeyPressMsg{Code: 'g'}) + // Switch to managed to trigger group stop (group actions require managed focus) + m.focus = focusManaged + m.managedSel = 0 + m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl}) + assert.NotNil(t, m.confirm) + + // Now switch back to running and render + m.focus = focusRunning + visible := m.visibleServers() + displayNames := m.displayNames(visible) + content := m.renderRunningTable(120, visible, displayNames) + lines := strings.Split(content, "\n") + + // Find the data lines (skip header + divider) + for _, line := range lines[2:] { + stripped := ansi.Strip(line) + // Selected row should be amber + if m.selected >= 0 && strings.Contains(stripped, "api-auth") { + assert.Contains(t, line, "48;5;178", "selected running row in group confirm should be amber") + } + // Non-selected group member should be dimmed orange + if strings.Contains(stripped, "api-gateway") && !strings.Contains(stripped, "api-auth") { + assert.Contains(t, line, "48;5;94", "non-selected group member should be dimmed orange") + } + } + }) +} diff --git a/pkg/cli/tui/test_helpers_test.go b/pkg/cli/tui/test_helpers_test.go new file mode 100644 index 0000000..66d58b4 --- /dev/null +++ b/pkg/cli/tui/test_helpers_test.go @@ -0,0 +1,114 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/devports/devpt/pkg/models" +) + +type fakeAppDeps struct { + servers []*models.ServerInfo + services []*models.ManagedService + logPaths map[string]string + listServicesCalls int + discoverCalls int +} + +func newTestModel() *topModel { + return newTopModel(&fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{ + PID: 1001, + Port: 3000, + Command: "node server.js", + CWD: "/tmp/app", + ProjectRoot: "/tmp/app", + }, + Status: "running", + Source: models.SourceManual, + }, + }, + }) +} + +func (f *fakeAppDeps) DiscoverServers() ([]*models.ServerInfo, error) { + f.discoverCalls++ + return f.servers, nil +} + +func (f *fakeAppDeps) ListServices() []*models.ManagedService { + f.listServicesCalls++ + return f.services +} + +func (f *fakeAppDeps) GetService(name string) *models.ManagedService { + for _, svc := range f.services { + if svc.Name == name { + return svc + } + } + return nil +} + +func (f *fakeAppDeps) ClearServicePID(string) error { + return nil +} + +func (f *fakeAppDeps) RegisterService(name, cwd, command string, ports []int) error { + f.services = append(f.services, &models.ManagedService{Name: name, CWD: cwd, Command: command, Ports: ports}) + return nil +} + +func (f *fakeAppDeps) RemoveService(name string) error { + for i, svc := range f.services { + if svc.Name == name { + f.services = append(f.services[:i], f.services[i+1:]...) + return nil + } + } + return fmt.Errorf("service %q not found", name) +} + +func (f *fakeAppDeps) StartService(string) error { + return nil +} + +func (f *fakeAppDeps) StopService(string) error { + return nil +} + +func (f *fakeAppDeps) RestartService(string) error { + return nil +} + +func (f *fakeAppDeps) StopProcess(int, time.Duration) error { + return nil +} + +func (f *fakeAppDeps) TailServiceLogs(string, int) ([]string, error) { + return nil, nil +} + +func (f *fakeAppDeps) TailProcessLogs(int, int) ([]string, error) { + return nil, nil +} + +func (f *fakeAppDeps) LatestServiceLogPath(name string) (string, error) { + if path, ok := f.logPaths[name]; ok { + return path, nil + } + return "", fmt.Errorf("no logs for %q", name) +} + +func (f *fakeAppDeps) GetProcessMemory(pids []int) map[int]int64 { + result := make(map[int]int64, len(pids)) + for _, pid := range pids { + // Return a plausible value for known test PIDs + if pid == 1001 { + result[pid] = 128 * 1024 // 128 MB + } + } + return result +} diff --git a/pkg/cli/tui/tui_group_test.go b/pkg/cli/tui/tui_group_test.go new file mode 100644 index 0000000..308c248 --- /dev/null +++ b/pkg/cli/tui/tui_group_test.go @@ -0,0 +1,1200 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +// --------------------------------------------------------------------------- +// Local mock structs — embed fakeAppDeps and override specific methods +// for call-counting and error injection. +// --------------------------------------------------------------------------- + +type mockStopper struct { + fakeAppDeps + stopFn func(pid int, timeout time.Duration) error +} + +func (m *mockStopper) StopProcess(pid int, timeout time.Duration) error { + if m.stopFn != nil { + return m.stopFn(pid, timeout) + } + return nil +} + +type mockStarter struct { + fakeAppDeps + startFn func(name string) error +} + +func (m *mockStarter) StartService(name string) error { + if m.startFn != nil { + return m.startFn(name) + } + return nil +} + +type mockRestarter struct { + fakeAppDeps + restartFn func(name string) error +} + +func (m *mockRestarter) RestartService(name string) error { + if m.restartFn != nil { + return m.restartFn(name) + } + return nil +} + +type mockRemover struct { + fakeAppDeps + removeFn func(name string) error +} + +func (m *mockRemover) RemoveService(name string) error { + if m.removeFn != nil { + return m.removeFn(name) + } + return m.fakeAppDeps.RemoveService(name) +} + +// --------------------------------------------------------------------------- +// TEST-group-stop +// Covers: BR-1.4, BR-1.9, C-1.2, C-1.4, C-1.6, Edge-1.5 +// --------------------------------------------------------------------------- + +func TestGroupStop(t *testing.T) { + t.Parallel() + + t.Run("confirmation modal shows group service list", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + makeRunningServer("api-cron", 1003, 3002), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + {Name: "api-cron", CWD: "/tmp/api-cron", Command: "python cron.py", Ports: []int{3002}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // Trigger group stop + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + // Should open group confirm modal + assert.NotNil(t, updated.confirm) + assert.Equal(t, confirmGroupStop, updated.confirm.kind) + // Prompt should mention group + assert.Contains(t, updated.confirm.prompt, "api") + // Should show member count + assert.Contains(t, updated.confirm.prompt, "3") + }) + + t.Run("confirmed stop executes on all group members", func(t *testing.T) { + stopCount := 0 + deps := &mockStopper{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + makeRunningServer("api-cron", 1003, 3002), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + {Name: "api-cron", CWD: "/tmp/api-cron", Command: "python cron.py", Ports: []int{3002}}, + }, + }, + stopFn: func(pid int, timeout time.Duration) error { + stopCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // Trigger group stop + m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift}) + // Confirm + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + // All 3 processes should be stopped + assert.Equal(t, 3, stopCount) + // cmdStatus should show per-service results + assert.Contains(t, m.cmdStatus, "Stopped") + }) + + t.Run("cancelled stop does not stop any process", func(t *testing.T) { + stopCount := 0 + deps := &mockStopper{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + }, + stopFn: func(pid int, timeout time.Duration) error { + stopCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // Trigger group stop + m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift}) + // Cancel with 'n' + m.Update(tea.KeyPressMsg{Code: 'n'}) + + assert.Equal(t, 0, stopCount) + assert.Equal(t, "Cancelled", m.cmdStatus) + }) + + t.Run("cancelled stop with escape does not stop any process", func(t *testing.T) { + stopCount := 0 + deps := &mockStopper{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + }, + }, + stopFn: func(pid int, timeout time.Duration) error { + stopCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + + assert.Equal(t, 0, stopCount) + assert.Equal(t, "Cancelled", m.cmdStatus) + }) + + t.Run("partial failure continues remaining members", func(t *testing.T) { + stopCount := 0 + deps := &mockStopper{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + makeRunningServer("api-cron", 1003, 3002), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + {Name: "api-cron", CWD: "/tmp/api-cron", Command: "python cron.py", Ports: []int{3002}}, + }, + }, + stopFn: func(pid int, timeout time.Duration) error { + stopCount++ + if pid == 1002 { + return fmt.Errorf("process %d: permission denied", pid) + } + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + // All 3 should be attempted + assert.Equal(t, 3, stopCount) + // cmdStatus should show partial result + assert.Contains(t, m.cmdStatus, "permission denied") + // Should also show successes + assert.Contains(t, m.cmdStatus, "1001") + }) + + t.Run("single member group stop works", func(t *testing.T) { + stopCount := 0 + deps := &mockStopper{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("redis", 1001, 6379), + }, + services: []*models.ManagedService{ + {Name: "redis", CWD: "/tmp/redis", Command: "redis-server", Ports: []int{6379}}, + }, + }, + stopFn: func(pid int, timeout time.Duration) error { + stopCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + assert.Equal(t, 1, stopCount) + }) + + t.Run("Edge-1.5: all already stopped shows message", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + // No running servers — group stop should be a no-op or show message + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + // No modal should open if there are no group members to stop + if updated.confirm != nil { + assert.Contains(t, updated.confirm.prompt, "0") + } + }) +} + +// --------------------------------------------------------------------------- +// TEST-group-restart +// Covers: BR-1.5, C-1.6 +// --------------------------------------------------------------------------- + +func TestGroupRestart(t *testing.T) { + t.Parallel() + + t.Run("group restart with confirmation", func(t *testing.T) { + restartCount := 0 + deps := &mockRestarter{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("web-frontend", 1001, 3000), + makeRunningServer("web-backend", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + }, + }, + restartFn: func(name string) error { + restartCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl | tea.ModShift}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + assert.Equal(t, 2, restartCount) + }) + + t.Run("group restart partial failure continues remaining", func(t *testing.T) { + restartCount := 0 + deps := &mockRestarter{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("web-frontend", 1001, 3000), + makeRunningServer("web-backend", 1002, 3001), + makeRunningServer("web-worker", 1003, 3002), + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + {Name: "web-worker", CWD: "/tmp/web-worker", Command: "python worker.py", Ports: []int{3002}}, + }, + }, + restartFn: func(name string) error { + restartCount++ + if name == "web-backend" { + return fmt.Errorf("restart failed for %s", name) + } + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl | tea.ModShift}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + // All 3 attempted + assert.Equal(t, 3, restartCount) + // Status shows partial failure + assert.Contains(t, m.cmdStatus, "web-backend") + assert.Contains(t, m.cmdStatus, "failed") + }) + + t.Run("group restart cancelled", func(t *testing.T) { + restartCount := 0 + deps := &mockRestarter{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("web-frontend", 1001, 3000), + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + }, + }, + restartFn: func(name string) error { + restartCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl | tea.ModShift}) + m.Update(tea.KeyPressMsg{Code: 'n'}) + + assert.Equal(t, 0, restartCount) + assert.Equal(t, "Cancelled", m.cmdStatus) + }) + + t.Run("group restart with crashed/stopped services starts them", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("web-backend", 1002, 3001), + // web-worker is NOT running (stopped/crashed) + }, + services: []*models.ManagedService{ + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + {Name: "web-worker", CWD: "/tmp/web-worker", Command: "python worker.py", Ports: []int{3002}}, + }, + } + + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl | tea.ModShift}) + assert.Equal(t, confirmGroupRestart, m.confirm.kind) + // Prompt should mention both restart and start + assert.Contains(t, m.confirm.prompt, "restart") + assert.Contains(t, m.confirm.prompt, "start") + // Both services should be listed + assert.Contains(t, m.confirm.prompt, "web-backend") + assert.Contains(t, m.confirm.prompt, "web-worker") + + // Confirm the action + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + // cmdStatus should show both a restart and a start + assert.Contains(t, m.cmdStatus, "Restarted") + assert.Contains(t, m.cmdStatus, "Started") + }) +} + +// --------------------------------------------------------------------------- +// TEST-group-start +// Covers: BR-1.6, C-1.1, Edge-1.6 +// --------------------------------------------------------------------------- + +func TestGroupStart(t *testing.T) { + t.Parallel() + + t.Run("starts only stopped managed services", func(t *testing.T) { + startCount := 0 + deps := &mockStarter{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + // web-frontend is running + makeRunningServer("web-frontend", 1001, 3000), + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + {Name: "web-worker", CWD: "/tmp/web-worker", Command: "python worker.py", Ports: []int{3002}}, + }, + }, + startFn: func(name string) error { + startCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + m.prepareGroupStartConfirm() + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + // Only 2 stopped services should be started + assert.Equal(t, 2, startCount) + }) + + t.Run("Edge-1.6: all already running shows message", func(t *testing.T) { + startCount := 0 + deps := &mockStarter{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("web-frontend", 1001, 3000), + makeRunningServer("web-backend", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + }, + }, + startFn: func(name string) error { + startCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + m.prepareGroupStartConfirm() + + // Should show message that all are already running + assert.Equal(t, 0, startCount) + assert.Contains(t, m.cmdStatus, "already running") + }) + + t.Run("group start with confirmation", func(t *testing.T) { + startCount := 0 + deps := &mockStarter{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + {Name: "web-worker", CWD: "/tmp/web-worker", Command: "python worker.py", Ports: []int{3002}}, + }, + }, + startFn: func(name string) error { + startCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + // Open confirm (via mouse-only path — call directly for test) + m.prepareGroupStartConfirm() + // Confirm + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + assert.Equal(t, 2, startCount) + }) + + t.Run("group start cancelled", func(t *testing.T) { + startCount := 0 + deps := &mockStarter{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + }, + }, + startFn: func(name string) error { + startCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + m.prepareGroupStartConfirm() + m.Update(tea.KeyPressMsg{Code: 'n'}) + + assert.Equal(t, 0, startCount) + assert.Equal(t, "Cancelled", m.cmdStatus) + }) +} + +// --------------------------------------------------------------------------- +// TEST-group-remove +// Covers: BR-1.7, C-1.4 +// --------------------------------------------------------------------------- + +func TestGroupRemove(t *testing.T) { + t.Parallel() + + t.Run("group remove with confirmation", func(t *testing.T) { + removeCount := 0 + deps := &mockRemover{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + {Name: "api-cron", CWD: "/tmp/api-cron", Command: "python cron.py", Ports: []int{3002}}, + }, + }, + removeFn: func(name string) error { + removeCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + // Open confirm + m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModShift}) + assert.Equal(t, confirmGroupRemove, m.confirm.kind) + + // Confirm + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + assert.Equal(t, 3, removeCount) + assert.Contains(t, m.cmdStatus, "Removed") + }) + + t.Run("group remove cancelled", func(t *testing.T) { + removeCount := 0 + deps := &mockRemover{ + fakeAppDeps: fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + }, + }, + removeFn: func(name string) error { + removeCount++ + return nil + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModShift}) + m.Update(tea.KeyPressMsg{Code: 'n'}) + + assert.Equal(t, 0, removeCount) + assert.Equal(t, "Cancelled", m.cmdStatus) + }) +} + +// --------------------------------------------------------------------------- +// TEST-shift-double-click +// Covers: BR-1.8, Edge-1.4 +// --------------------------------------------------------------------------- + +func TestShiftDoubleClickGroupStart(t *testing.T) { + t.Parallel() + + t.Run("shift+double-click starts namespace group", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + {Name: "web-worker", CWD: "/tmp/web-worker", Command: "python worker.py", Ports: []int{3002}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.width = 100 + m.height = 30 + m.focus = focusManaged + m.managedSel = 0 + + // Find the Y position of the web-backend row + _ = m.View() + clickY := findManagedRowClickY(m, "web-backend") + if clickY < 0 { + t.Skip("could not find managed row for click") + } + + // First click selects the row + m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY}) + assert.Equal(t, focusManaged, m.focus) + + // Second click with shift modifier triggers group start + m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY, Mod: tea.ModShift}) + + // Should open group start confirmation + if m.confirm != nil { + assert.Equal(t, confirmGroupStart, m.confirm.kind) + } + }) + + t.Run("Edge-1.4: shift release between clicks prevents group action", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.width = 100 + m.height = 30 + m.focus = focusManaged + m.managedSel = 0 + + _ = m.View() + clickY := findManagedRowClickY(m, "web-backend") + if clickY < 0 { + t.Skip("could not find managed row for click") + } + + // First click (no shift) + m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY}) + // Wait beyond double-click threshold + m.lastClickTime = time.Now().Add(-600 * time.Millisecond) + // Second click (with shift) — should NOT trigger group action due to timing gap + m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY, Mod: tea.ModShift}) + + // No group confirm modal should open + assert.Nil(t, m.confirm) + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func makeRunningServer(name string, pid, port int) *models.ServerInfo { + return &models.ServerInfo{ + ManagedService: &models.ManagedService{Name: name, CWD: "/tmp/" + name, Command: "run", Ports: []int{port}}, + ProcessRecord: &models.ProcessRecord{PID: pid, Port: port, Command: "run", CWD: "/tmp/" + name, ProjectRoot: "/tmp/" + name}, + Status: "running", + } +} + +// --------------------------------------------------------------------------- +// TEST-group-key-remap +// Covers: BR-1.11 — Group mode remaps e/r/x to group actions +// --------------------------------------------------------------------------- + +func TestGroupModeRemapsActions(t *testing.T) { + t.Parallel() + + t.Run("g then ctrl+e triggers group stop (not single stop)", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // Activate group mode + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // Press ctrl+e (normally single stop, should remap to group stop) + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl}) + updated := newModel.(*topModel) + + // Should open group stop confirm, not single stop + assertGroupConfirmKind(t, updated, confirmGroupStop) + }) + + t.Run("g then ctrl+r triggers group restart (not single restart)", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("web-frontend", 1001, 3000), + makeRunningServer("web-backend", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // Activate group mode + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // Press ctrl+r (normally single restart, should remap to group restart) + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) + updated := newModel.(*topModel) + + assertGroupConfirmKind(t, updated, confirmGroupRestart) + }) + + t.Run("g then x triggers group remove (not single remove)", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + // Activate group mode + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // Press x (normally single remove, should remap to group remove) + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'x'}) + updated := newModel.(*topModel) + + assertGroupConfirmKind(t, updated, confirmGroupRemove) + }) + + t.Run("without g, ctrl+e still does single stop", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // No group mode activated + assert.Nil(t, m.groupHighlightNamespace) + + // Press ctrl+e — should do single stop + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl}) + updated := newModel.(*topModel) + + // Should be single-item stop confirm (confirmStopPID), not group stop + if updated.confirm != nil { + assert.Equal(t, confirmStopPID, updated.confirm.kind) + } + assert.NotEqual(t, confirmGroupStop, updated.confirm.kind) + }) + + t.Run("without g, ctrl+r still does single restart", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("web-frontend", 1001, 3000), + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + assert.Nil(t, m.groupHighlightNamespace) + + // Press ctrl+r — single restart (no confirm modal, direct execution) + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) + updated := newModel.(*topModel) + + // Single restart does NOT open a group confirm modal + assert.Nil(t, updated.confirm) + assert.Contains(t, updated.cmdStatus, "Restarted") + }) + + t.Run("ctrl+shift+e works regardless of group mode", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // Activate group mode first + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // ctrl+shift+e should still trigger group stop (explicit binding) + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift}) + updated := newModel.(*topModel) + + assertGroupConfirmKind(t, updated, confirmGroupStop) + }) + + t.Run("ctrl+shift+r works regardless of group mode", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("web-frontend", 1001, 3000), + makeRunningServer("web-backend", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3000}}, + {Name: "web-backend", CWD: "/tmp/web-backend", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // Activate group mode first + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // ctrl+shift+r should still trigger group restart (explicit binding) + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl | tea.ModShift}) + updated := newModel.(*topModel) + + assertGroupConfirmKind(t, updated, confirmGroupRestart) + }) + + t.Run("shift+x works regardless of group mode", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + // Activate group mode first + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // shift+x should still trigger group remove (explicit binding) + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModShift}) + updated := newModel.(*topModel) + + assertGroupConfirmKind(t, updated, confirmGroupRemove) + }) +} + +// --------------------------------------------------------------------------- +// TEST-group-highlight +// Covers: BR-1.10 — Toggle-based group highlighting via g key +// --------------------------------------------------------------------------- + +func TestManagedListGroupHighlight(t *testing.T) { + t.Parallel() + + t.Run("group highlight covers full managed service row (not just symbol)", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3002}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + m.width = 120 + m.height = 30 + + // Toggle group highlight on + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + assert.Equal(t, "api", *m.groupHighlightNamespace) + + // Render the managed list pane + managedContent := m.renderManagedList(60, m.managedServices()) + lines := strings.Split(managedContent, "\n") + + // Find the api-gateway row (non-selected, should have group highlight) + var gatewayRow string + for _, line := range lines { + stripped := ansi.Strip(line) + if strings.Contains(stripped, "api-gateway") { + gatewayRow = line + break + } + } + assert.NotEmpty(t, gatewayRow, "api-gateway row should be present") + + // The group highlight background (color 61) should be present in the row. + // With Inline(true), the styled symbol does not emit a full reset, so + // the parent group background extends across the entire line. + assert.Contains(t, gatewayRow, "48;5;61", "group highlight background should cover full row") + + // The row should NOT contain a bare reset after the symbol that would + // kill the background. With Inline(true), lipgloss only emits + // foreground/bold codes without a closing \x1b[0m. + assert.NotContains(t, gatewayRow, "\x1b[0m api-gateway", "no full reset should appear between symbol and name") + }) + + t.Run("non-group managed rows have no group highlight background", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3002}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + m.width = 120 + m.height = 30 + + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.Equal(t, "api", *m.groupHighlightNamespace) + + managedContent := m.renderManagedList(60, m.managedServices()) + lines := strings.Split(managedContent, "\n") + + // Find the web-frontend row (different namespace — should NOT have group highlight) + var webRow string + for _, line := range lines { + stripped := ansi.Strip(line) + if strings.Contains(stripped, "web-frontend") { + webRow = line + break + } + } + assert.NotEmpty(t, webRow, "web-frontend row should be present") + assert.NotContains(t, webRow, "48;5;61", "non-group row should not have group highlight background") + }) +} + +func TestGroupToggleHighlight(t *testing.T) { + t.Parallel() + + t.Run("g key toggles group highlight on", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'g'}) + updated := newModel.(*topModel) + + assert.NotNil(t, updated.groupHighlightNamespace) + assert.Equal(t, "api", *updated.groupHighlightNamespace) + }) + + t.Run("g key toggles group highlight off", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + // Toggle on + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // Toggle off + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'g'}) + updated := newModel.(*topModel) + assert.Nil(t, updated.groupHighlightNamespace) + }) + + t.Run("navigation clears group highlight", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // Navigate down clears highlight + m.Update(tea.KeyPressMsg{Code: 'j'}) + assert.Nil(t, m.groupHighlightNamespace) + }) + + t.Run("tab switch clears group highlight", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + assert.Nil(t, m.groupHighlightNamespace) + }) + + t.Run("no-op in non-table mode", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeLogs + + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'g'}) + updated := newModel.(*topModel) + assert.Nil(t, updated.groupHighlightNamespace) + }) + + t.Run("no-op when no valid selection", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{}, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = -1 + + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'g'}) + updated := newModel.(*topModel) + assert.Nil(t, updated.groupHighlightNamespace) + }) + + t.Run("managed focus computes namespace from managed list", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3002}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + newModel, _ := m.Update(tea.KeyPressMsg{Code: 'g'}) + updated := newModel.(*topModel) + + assert.NotNil(t, updated.groupHighlightNamespace) + assert.Equal(t, "api", *updated.groupHighlightNamespace) + }) + + t.Run("highlight renders namespace members in running table", func(t *testing.T) { + deps := &fakeAppDeps{ + servers: []*models.ServerInfo{ + makeRunningServer("api-gateway", 1001, 3000), + makeRunningServer("api-auth", 1002, 3001), + makeRunningServer("web-frontend", 1003, 3002), + }, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + {Name: "web-frontend", CWD: "/tmp/web-frontend", Command: "npm run dev", Ports: []int{3002}}, + }, + } + m := newTopModel(deps) + m.mode = viewModeTable + m.focus = focusRunning + m.selected = 0 + m.width = 100 + m.height = 30 + + // Toggle group highlight + m.Update(tea.KeyPressMsg{Code: 'g'}) + assert.NotNil(t, m.groupHighlightNamespace) + + // Render and verify all services appear + output := m.View().Content + assert.Contains(t, output, "api-gateway") + assert.Contains(t, output, "api-auth") + assert.Contains(t, output, "web-frontend") + }) +} diff --git a/pkg/cli/tui/tui_key_input_test.go b/pkg/cli/tui/tui_key_input_test.go new file mode 100644 index 0000000..61277f6 --- /dev/null +++ b/pkg/cli/tui/tui_key_input_test.go @@ -0,0 +1,234 @@ +package tui + +import ( + "testing" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +// --------------------------------------------------------------------------- +// TEST-shift-keybinding +// Covers: BR-1.2, Edge-1.3, C-1.5, C-1.8 +// --------------------------------------------------------------------------- + +func TestShiftModifierDetection(t *testing.T) { + t.Parallel() + + t.Run("ctrl+shift+e triggers group stop branch", func(t *testing.T) { + m := newTestModel() + m.mode = viewModeTable + m.selected = 0 + + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + // Should open group confirmation modal (not single-item stop) + assertGroupConfirmKind(t, updated, confirmGroupStop) + }) + + t.Run("ctrl+shift+r triggers group restart branch", func(t *testing.T) { + m := newTestModel() + m.mode = viewModeTable + m.selected = 0 + + msg := tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + assertGroupConfirmKind(t, updated, confirmGroupRestart) + }) + + t.Run("shift+x triggers group remove branch", func(t *testing.T) { + m := newTopModel(&fakeAppDeps{ + servers: []*models.ServerInfo{}, + services: []*models.ManagedService{ + {Name: "api-gateway", CWD: "/tmp/api-gateway", Command: "node server.js", Ports: []int{3000}}, + {Name: "api-auth", CWD: "/tmp/api-auth", Command: "go run .", Ports: []int{3001}}, + }, + }) + m.mode = viewModeTable + m.focus = focusManaged + m.managedSel = 0 + + msg := tea.KeyPressMsg{Code: 'x', Mod: tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + assertGroupConfirmKind(t, updated, confirmGroupRemove) + }) + +} + +func TestShiftNoOpGuards(t *testing.T) { + t.Parallel() + + t.Run("C-1.5: group action with no group members is no-op", func(t *testing.T) { + m := newTopModel(&fakeAppDeps{servers: []*models.ServerInfo{}}) + m.mode = viewModeTable + m.selected = -1 + + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + // No modal should open when there's no selection + assert.Nil(t, updated.modal) + assert.Nil(t, updated.confirm) + }) + + t.Run("C-1.8: group action with single member falls back to single action", func(t *testing.T) { + m := newTestModel() + m.mode = viewModeTable + m.selected = 0 + + // Only one server exists, so group stop should fall back to single stop + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + // Should still open a confirm modal (even for single-member group) + assert.NotNil(t, updated.confirm) + }) + + t.Run("shift modifier ignored in logs mode", func(t *testing.T) { + m := newTestModel() + m.mode = viewModeLogs + + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + // Should not open group modal while in logs mode + assert.Nil(t, updated.confirm) + }) + + t.Run("shift modifier ignored in search mode", func(t *testing.T) { + m := newTestModel() + m.mode = viewModeSearch + + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + assert.Nil(t, updated.confirm) + }) + + t.Run("shift modifier ignored in command mode", func(t *testing.T) { + m := newTestModel() + m.mode = viewModeCommand + + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + assert.Nil(t, updated.confirm) + }) +} + +func TestShiftKeyStringVariants(t *testing.T) { + t.Parallel() + + t.Run("Edge-1.3: ctrl+shift+e string representation matches", func(t *testing.T) { + m := newTestModel() + m.mode = viewModeTable + m.selected = 0 + + // Simulate the key string that bubbletea would generate + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift} + str := msg.String() + assert.Contains(t, str, "ctrl") + assert.Contains(t, str, "shift") + }) + + t.Run("ctrl+e without shift takes single-item path", func(t *testing.T) { + m := newTestModel() + m.mode = viewModeTable + m.selected = 0 + + msg := tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl} + newModel, _ := m.Update(msg) + updated := newModel.(*topModel) + + // Without shift, should trigger single-item stop confirm (not group) + // Single-item stop uses confirmStopPID kind + if updated.confirm != nil { + assert.Equal(t, confirmStopPID, updated.confirm.kind) + } + }) +} + +func TestShiftKeybindingsRegistered(t *testing.T) { + t.Parallel() + + t.Run("group stop binding exists in keymap", func(t *testing.T) { + m := newTestModel() + assert.True(t, key.Matches(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl | tea.ModShift}, m.keys.GroupStop)) + }) + + t.Run("group restart binding exists in keymap", func(t *testing.T) { + m := newTestModel() + assert.True(t, key.Matches(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl | tea.ModShift}, m.keys.GroupRestart)) + }) + + t.Run("group remove binding exists in keymap", func(t *testing.T) { + m := newTestModel() + assert.True(t, key.Matches(tea.KeyPressMsg{Code: 'x', Mod: tea.ModShift}, m.keys.GroupRemove)) + }) + + t.Run("group bindings do not match without shift modifier", func(t *testing.T) { + m := newTestModel() + assert.False(t, key.Matches(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl}, m.keys.GroupStop)) + assert.False(t, key.Matches(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}, m.keys.GroupRestart)) + assert.False(t, key.Matches(tea.KeyPressMsg{Code: 'x', Mod: 0}, m.keys.GroupRemove)) + + }) +} + +// assertGroupConfirmKind is a test helper that checks the confirm state has the expected group action kind. +func assertGroupConfirmKind(t *testing.T, m *topModel, expected confirmKind) { + t.Helper() + if m.confirm == nil { + t.Fatalf("expected confirm modal with kind %v, got nil confirm", expected) + } + assert.Equal(t, expected, m.confirm.kind) +} + +func TestCommandModeAcceptsRuneKeys(t *testing.T) { + t.Parallel() + + for _, key := range []string{"b", "q", "s", "n"} { + m := &topModel{mode: viewModeCommand} + next, _ := m.Update(tea.KeyPressMsg{Text: key, Code: rune(key[0])}) + updated, ok := next.(*topModel) + if !ok { + t.Fatalf("expected *topModel, got %T", next) + } + if updated.cmdInput != key { + t.Fatalf("expected command input to include rune key %q, got %q", key, updated.cmdInput) + } + } +} + +func TestSearchModeAcceptsRuneKeys(t *testing.T) { + t.Parallel() + + m := newTopModel(&fakeAppDeps{}) + next, _ := m.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + updated, ok := next.(*topModel) + if !ok { + t.Fatalf("expected *topModel, got %T", next) + } + next, _ = updated.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated, ok = next.(*topModel) + if !ok { + t.Fatalf("expected *topModel, got %T", next) + } + if updated.searchInput.Value() != "s" { + t.Fatalf("expected search input to include rune key, got %q", updated.searchInput.Value()) + } +} diff --git a/pkg/cli/tui/tui_managed_split_test.go b/pkg/cli/tui/tui_managed_split_test.go new file mode 100644 index 0000000..c5d508e --- /dev/null +++ b/pkg/cli/tui/tui_managed_split_test.go @@ -0,0 +1,210 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +func managedSplitTestModel() *topModel { + stoppedAt := time.Date(2026, 3, 27, 21, 54, 25, 0, time.UTC) + deps := &fakeAppDeps{ + services: []*models.ManagedService{ + { + Name: "test-go-basic-fake", + CWD: "/Users/kirby/.config/dev-process-tracker/sandbox/servers/go-basic", + Command: "go run .", + Ports: []int{3401}, + LastStop: &stoppedAt, + }, + { + Name: "docs-preview", + CWD: "/tmp/docs-preview", + Command: "npm run dev", + Ports: []int{3001}, + }, + }, + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "test-go-basic-fake", CWD: "/Users/kirby/.config/dev-process-tracker/sandbox/servers/go-basic", Command: "go run .", Ports: []int{3401}}, + Status: "crashed", + Source: models.SourceManaged, + CrashReason: "exit status 1", + CrashLogTail: []string{ + "2026/03/27 21:54:25 [go-basic] listening on http://localhost:3400", + "2026/03/27 21:54:25 listen tcp :3400: bind: address already in use", + "exit status 1", + }, + }, + }, + logPaths: map[string]string{ + "test-go-basic-fake": "~/.config/devpt/logs/test-go-basic-fake/2026-03-12T22-14-37.log", + }, + } + + model := newTopModel(deps) + model.width = 120 + model.height = 30 + model.mode = viewModeTable + model.focus = focusManaged + model.managedSel = 0 + return model +} + +func TestManagedSplitView_SelectedServiceShowsDedicatedDetailsPane(t *testing.T) { + model := managedSplitTestModel() + // Services are sorted alphabetically, so test-go-basic-fake is at index 1 + model.managedSel = 1 + + output := model.View().Content + assert.Contains(t, output, "Managed Services") + assert.Contains(t, output, "Selected service details") + assert.Contains(t, output, "Headline: exit status 1") + assert.Contains(t, output, "test-go-basic-fake") +} + +func TestManagedSplitView_NoSelectionShowsPlaceholderPane(t *testing.T) { + model := managedSplitTestModel() + model.managedSel = -1 + + output := model.View().Content + assert.Contains(t, output, "Selected service details") + assert.Contains(t, output, "Select a managed service to inspect status") +} + +func TestManagedSplitView_StoppedServiceRemainsStopped(t *testing.T) { + model := managedSplitTestModel() + model.managedSel = 0 + + output := model.View().Content + assert.Contains(t, output, "docs-preview [stopped]") + assert.NotContains(t, output, "docs-preview crashed") +} + +func TestManagedSplitView_NarrowWidthPreservesPrimarySignals(t *testing.T) { + model := managedSplitTestModel() + model.width = 72 + model.managedSel = 1 + + output := model.View().Content + assert.Contains(t, output, "✘") + assert.Contains(t, output, "exit status 1") +} + +func TestManagedSplitView_ServiceMetadataShowsCWDPortsCommand(t *testing.T) { + model := managedSplitTestModel() + model.managedSel = 0 // docs-preview (stopped, not crashed) + + output := model.View().Content + assert.Contains(t, output, "docs-preview") + assert.Contains(t, output, "/tmp/docs-preview") + assert.Contains(t, output, "npm run dev") + assert.Contains(t, output, "3001") +} + +func TestManagedSplitView_CrashedServiceShowsMetadataBeforeCrashContext(t *testing.T) { + model := managedSplitTestModel() + // Services sorted alphabetically, test-go-basic-fake at index 1 + model.managedSel = 1 + + output := model.View().Content + + // Metadata must be visible (may be truncated by fitLine) + assert.Contains(t, output, "go-basic") + assert.Contains(t, output, "go run .") + assert.Contains(t, output, "3401") + + // Crash context must also be visible + assert.Contains(t, output, "Headline: exit status 1") + + // Verify render order: Dir/Port/Cmd appear before Headline in the output + stripped := ansi.Strip(output) + dirPos := strings.Index(stripped, "Dir:") + headlinePos := strings.Index(stripped, "Headline:") + assert.Greater(t, headlinePos, dirPos, "crash headline must appear after metadata (Dir)") + + portPos := strings.Index(stripped, "Port:") + assert.Greater(t, headlinePos, portPos, "crash headline must appear after metadata (Port)") + + cmdPos := strings.Index(stripped, "Cmd:") + assert.Greater(t, headlinePos, cmdPos, "crash headline must appear after metadata (Cmd)") +} + +func TestManagedSplitView_MissingMetadataFieldsNoBlankLines(t *testing.T) { + deps := &fakeAppDeps{ + services: []*models.ManagedService{ + { + Name: "empty-meta-svc", + CWD: "", + Command: "", + Ports: []int{}, + }, + }, + } + model := newTopModel(deps) + model.width = 120 + model.height = 30 + model.mode = viewModeTable + model.focus = focusManaged + model.managedSel = 0 + + output := model.View().Content + stripped := ansi.Strip(output) + + // Service name should be visible + assert.Contains(t, stripped, "empty-meta-svc") + + // No Dir:/Port:/Cmd: labels should appear for empty fields + assert.NotContains(t, stripped, "Dir:") + assert.NotContains(t, stripped, "Port:") + assert.NotContains(t, stripped, "Cmd:") +} + +func TestManagedSplitView_MultiPortMetadataCompact(t *testing.T) { + deps := &fakeAppDeps{ + services: []*models.ManagedService{ + { + Name: "multi-port-svc", + CWD: "/app/service", + Command: "node server.js", + Ports: []int{3000, 3001, 3443}, + }, + }, + } + model := newTopModel(deps) + model.width = 120 + model.height = 30 + model.mode = viewModeTable + model.focus = focusManaged + model.managedSel = 0 + + output := model.View().Content + assert.Contains(t, output, "/app/service") + assert.Contains(t, output, "node server.js") + // All ports should be visible somewhere + assert.Contains(t, output, "3000") + assert.Contains(t, output, "3001") + assert.Contains(t, output, "3443") +} + +func TestManagedSplitView_SelectedManagedRowHighlightsWholeLine(t *testing.T) { + model := managedSplitTestModel() + model.managedSel = 0 + _ = model.View() + + var selectedLine string + for _, line := range strings.Split(model.table.managedListVP.View(), "\n") { + if strings.Contains(ansi.Strip(line), "docs-preview [stopped]") { + selectedLine = line + break + } + } + + assert.NotEmpty(t, selectedLine) + assert.Contains(t, selectedLine, "48;5;57") + assert.NotContains(t, selectedLine, "\x1b[m docs-preview") +} diff --git a/pkg/cli/tui/tui_state_test.go b/pkg/cli/tui/tui_state_test.go new file mode 100644 index 0000000..7e0b3b6 --- /dev/null +++ b/pkg/cli/tui/tui_state_test.go @@ -0,0 +1,428 @@ +package tui + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestTUISimpleUpdate(t *testing.T) { + model := newTestModel() + + t.Run("tab switches focus between running and managed", func(t *testing.T) { + initialFocus := model.focus + newModel, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.NotEqual(t, initialFocus, updatedModel.focus) + if initialFocus == focusRunning { + assert.Equal(t, focusManaged, updatedModel.focus) + } else { + assert.Equal(t, focusRunning, updatedModel.focus) + } + }) + + t.Run("escape key in logs mode returns to table", func(t *testing.T) { + model.mode = viewModeLogs + newModel, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, viewModeTable, updatedModel.mode) + }) + + t.Run("forward slash enters search mode", func(t *testing.T) { + model.mode = viewModeTable + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + assert.NotNil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, viewModeSearch, updatedModel.mode) + }) + + t.Run("question mark enters help mode", func(t *testing.T) { + model.mode = viewModeTable + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "?", Code: '?'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, modalHelp, updatedModel.activeModalKind()) + }) + + t.Run("s key cycles through sort modes", func(t *testing.T) { + model.mode = viewModeTable + initialSort := model.sortBy + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.NotEqual(t, initialSort, updatedModel.sortBy) + }) + + t.Run("enter opens logs for running selection", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.focus = focusRunning + model.selected = 0 + + newModel, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.NotNil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.Equal(t, viewModeLogs, updatedModel.mode) + assert.Equal(t, 1001, updatedModel.logPID) + }) + + t.Run("enter starts service for managed selection", func(t *testing.T) { + model := newTopModel(&fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "test-svc", CWD: "/tmp/app", Command: "npm run dev", Ports: []int{3000}}, + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + }, + }, + services: []*models.ManagedService{ + {Name: "test-svc", CWD: "/tmp/app", Command: "npm run dev", Ports: []int{3000}}, + }, + }) + model.mode = viewModeTable + model.focus = focusManaged + model.managedSel = 0 + + newModel, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.Equal(t, viewModeTable, updatedModel.mode) + assert.Contains(t, updatedModel.cmdStatus, `Started "test-svc"`) + }) +} + +func TestTUIKeySequence(t *testing.T) { + t.Run("navigate and return to table", func(t *testing.T) { + model := newTestModel() + initialMode := model.mode + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + model = newModel.(*topModel) + assert.Equal(t, viewModeSearch, model.mode) + + newModel, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + model = newModel.(*topModel) + assert.Equal(t, initialMode, model.mode) + }) + + t.Run("help mode and exit", func(t *testing.T) { + model := newTestModel() + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "?", Code: '?'}) + model = newModel.(*topModel) + assert.Equal(t, modalHelp, model.activeModalKind()) + + newModel, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + model = newModel.(*topModel) + assert.Equal(t, viewModeTable, model.mode) + assert.Nil(t, model.modal) + }) +} + +func TestTUIQuitKey(t *testing.T) { + model := newTestModel() + + t.Run("q key returns quit command", func(t *testing.T) { + _, cmd := model.Update(tea.KeyPressMsg{Text: "q", Code: 'q'}) + assert.NotNil(t, cmd) + }) + + t.Run("ctrl+c returns quit command", func(t *testing.T) { + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + assert.NotNil(t, cmd) + }) +} + +func TestTUIViewRendering(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 40 + + t.Run("table view contains expected elements", func(t *testing.T) { + model.mode = viewModeTable + output := model.View() + assert.Contains(t, output.Content, "Dev Process Tracker") + assert.Contains(t, output.Content, "Name") + assert.Contains(t, output.Content, "Port") + assert.Contains(t, output.Content, "PID") + }) + + t.Run("help view contains help text", func(t *testing.T) { + model.openHelpModal() + output := model.View() + assert.Contains(t, output.Content, "Help") + assert.Contains(t, output.Content, "switch list") + }) +} + +func TestViewportStateTransitions(t *testing.T) { + t.Run("viewport state initialization", func(t *testing.T) { + model := newTestModel() + _ = model + t.Skip("TODO: Verify viewport state fields exist - OBL-highlight-state") + }) + + t.Run("highlight index boundary conditions", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30} + model.highlightIndex = 0 + model.highlightIndex = len(model.highlightMatches) - 1 + _ = model + t.Skip("TODO: Test boundary conditions - Edge-2") + }) + + t.Run("highlight index with empty matches", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{} + model.highlightIndex = 0 + _ = model + t.Skip("TODO: Handle empty highlights - Edge case") + }) +} + +func TestSortCycling(t *testing.T) { + model := newTestModel() + + t.Run("cycleSort ascending to reverse to recent", func(t *testing.T) { + // Start with recent (default) + assert.Equal(t, sortRecent, model.sortBy) + assert.False(t, model.sortReverse) + + // Click name column -> ascending (yellow) + model.cycleSort(sortName) + assert.Equal(t, sortName, model.sortBy) + assert.False(t, model.sortReverse) + + // Click same column again -> reverse (orange) + model.cycleSort(sortName) + assert.Equal(t, sortName, model.sortBy) + assert.True(t, model.sortReverse) + + // Click same column again -> reset to recent + model.cycleSort(sortName) + assert.Equal(t, sortRecent, model.sortBy) + assert.False(t, model.sortReverse) + }) + + t.Run("clicking different column resets to ascending", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = true + + // Click different column -> ascending + model.cycleSort(sortPort) + assert.Equal(t, sortPort, model.sortBy) + assert.False(t, model.sortReverse) + }) + + t.Run("s key cycles sort modes without reverse", func(t *testing.T) { + model.sortBy = sortRecent + model.sortReverse = false + + // 's' key should cycle through modes and reset reverse + newModel, _ := model.Update(tea.KeyPressMsg{Code: 's'}) + updated := newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.False(t, updated.sortReverse) + + newModel, _ = updated.Update(tea.KeyPressMsg{Code: 's'}) + updated = newModel.(*topModel) + assert.Equal(t, sortProject, updated.sortBy) + assert.False(t, updated.sortReverse) + }) +} + +func TestSortDirectionToggle(t *testing.T) { + model := newTestModel() + + t.Run("toggle flips reverse without changing column", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = false + + model.toggleSortDirection() + assert.Equal(t, sortName, model.sortBy) + assert.True(t, model.sortReverse) + + model.toggleSortDirection() + assert.Equal(t, sortName, model.sortBy) + assert.False(t, model.sortReverse) + }) + + t.Run("toggle is no-op in recent mode", func(t *testing.T) { + model.sortBy = sortRecent + model.sortReverse = false + + model.toggleSortDirection() + assert.Equal(t, sortRecent, model.sortBy) + assert.False(t, model.sortReverse) + }) + + t.Run("toggle preserves column across multiple flips", func(t *testing.T) { + model.sortBy = sortPort + model.sortReverse = false + + model.toggleSortDirection() + model.toggleSortDirection() + model.toggleSortDirection() + + assert.Equal(t, sortPort, model.sortBy) + assert.True(t, model.sortReverse) + }) + + t.Run("toggle works on every sortable column", func(t *testing.T) { + columns := []sortMode{sortName, sortProject, sortPort, sortHealth} + for _, col := range columns { + model.sortBy = col + model.sortReverse = false + + model.toggleSortDirection() + assert.Equal(t, col, model.sortBy, "column changed after toggle for %s", sortModeLabel(col)) + assert.True(t, model.sortReverse, "reverse not set for %s", sortModeLabel(col)) + } + }) +} + +func TestSortDirectionToggleViaKey(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + + t.Run("S key toggles direction for current column", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = false + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "S", Code: 'S'}) + updated := newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.True(t, updated.sortReverse) + }) + + t.Run("S key preserves column", func(t *testing.T) { + model.sortBy = sortProject + model.sortReverse = false + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "S", Code: 'S'}) + updated := newModel.(*topModel) + assert.Equal(t, sortProject, updated.sortBy) + assert.True(t, updated.sortReverse) + }) + + t.Run("S key is no-op in recent mode", func(t *testing.T) { + model.sortBy = sortRecent + model.sortReverse = false + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "S", Code: 'S'}) + updated := newModel.(*topModel) + assert.Equal(t, sortRecent, updated.sortBy) + assert.False(t, updated.sortReverse) + }) + + t.Run("S and s are independent operations", func(t *testing.T) { + model.sortBy = sortRecent + model.sortReverse = false + + // s -> Name ascending + newModel, _ := model.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated := newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.False(t, updated.sortReverse) + + // S -> Name descending + newModel, _ = updated.Update(tea.KeyPressMsg{Text: "S", Code: 'S'}) + updated = newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.True(t, updated.sortReverse) + + // s -> Project ascending (column switch resets reverse) + newModel, _ = updated.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated = newModel.(*topModel) + assert.Equal(t, sortProject, updated.sortBy) + assert.False(t, updated.sortReverse) + }) +} + +func TestSortColumnSwitchResetsDirection(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + + t.Run("s key resets reverse when switching columns", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = true + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated := newModel.(*topModel) + assert.Equal(t, sortProject, updated.sortBy) + assert.False(t, updated.sortReverse) + }) + + t.Run("s key wraps around to recent and resets reverse", func(t *testing.T) { + model.sortBy = sortHealth + model.sortReverse = true + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated := newModel.(*topModel) + assert.Equal(t, sortRecent, updated.sortBy) + assert.False(t, updated.sortReverse) + }) +} + +func TestSortPersistenceAcrossRefresh(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 40 + model.mode = viewModeTable + + t.Run("sort state survives tick refresh", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = true + + newModel, _ := model.Update(tickMsg(time.Now())) + updated := newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.True(t, updated.sortReverse) + }) + + t.Run("sort state survives multiple refreshes", func(t *testing.T) { + model.sortBy = sortPort + model.sortReverse = true + + for i := 0; i < 5; i++ { + newModel, _ := model.Update(tickMsg(time.Now())) + model = newModel.(*topModel) + } + assert.Equal(t, sortPort, model.sortBy) + assert.True(t, model.sortReverse) + }) +} + +func TestColumnAtX(t *testing.T) { + model := newTestModel() + model.width = 120 + + tests := []struct { + name string + x int + wantSort sortMode + }{ + {"name column", 5, sortName}, + {"port column", 18, sortPort}, + {"pid column", 26, sortRecent}, + {"project column", 40, sortProject}, + {"health column", 115, sortHealth}, + {"out of bounds", 200, sortMode(-1)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := model.columnAtX(tt.x) + assert.Equal(t, tt.wantSort, got) + }) + } +} diff --git a/pkg/cli/tui/tui_ui_test.go b/pkg/cli/tui/tui_ui_test.go new file mode 100644 index 0000000..ed35e5d --- /dev/null +++ b/pkg/cli/tui/tui_ui_test.go @@ -0,0 +1,750 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/devports/devpt/pkg/buildinfo" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestView_EscapeSequences(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 40 + + t.Run("no raw screen clear escape", func(t *testing.T) { + output := model.View().Content + assert.NotContains(t, output, "\x1b[2J") + }) + + t.Run("output is non-empty", func(t *testing.T) { + output := model.View().Content + assert.NotEmpty(t, output) + }) +} + +func TestView_HeaderContent(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeTable + + t.Run("header text is present", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Dev Process Tracker") + assert.Contains(t, output, "Health Monitor") + }) + + t.Run("header shows current version", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, buildinfo.Version) + }) + + t.Run("header omits quit hint", func(t *testing.T) { + output := model.View().Content + assert.NotContains(t, output, "q quit") + }) +} + +func TestView_StatusBar(t *testing.T) { + model := newTestModel() + model.width = 120 + + t.Run("footer contains keybinding hints", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + assert.Contains(t, output, "logs/start") + assert.Contains(t, output, "filter") + assert.Contains(t, output, "toggle help") + }) + + t.Run("footer shows service count", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Name (1)") + }) + + t.Run("footer stays compact", func(t *testing.T) { + output := model.View().Content + assert.NotContains(t, output, "D for debug") + }) +} + +func TestView_CommandMode(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeCommand + + t.Run("command prompt shows colon", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, ":") + }) + + t.Run("command mode shows hint", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Esc to go back") + }) + + t.Run("command mode shows example", func(t *testing.T) { + model.cmdInput = "add" + output := model.View().Content + assert.Contains(t, output, "Example:") + }) +} + +func TestView_ConfirmDialog(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 24 + model.openConfirmModal(&confirmState{kind: confirmStopPID, prompt: "Stop PID 123?", pid: 123}) + + t.Run("confirm prompt includes [y/N]", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Enter/y confirm, n/Esc cancel") + }) + + t.Run("confirm shows prompt text", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Stop PID 123?") + }) + + t.Run("confirm keeps table visible behind modal", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "app") + assert.Contains(t, output, "No managed") + assert.Contains(t, output, "Confirm") + }) + + t.Run("click outside confirm closes modal", func(t *testing.T) { + clickModel := newTestModel() + clickModel.width = 100 + clickModel.height = 24 + clickModel.openConfirmModal(&confirmState{kind: confirmStopPID, prompt: "Stop PID 123?", pid: 123}) + + newModel, cmd := clickModel.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 0, Y: 0}) + assert.Nil(t, cmd) + + updated := newModel.(*topModel) + assert.Equal(t, viewModeTable, updated.mode) + assert.Nil(t, updated.modal) + assert.Nil(t, updated.confirm) + assert.Equal(t, "Cancelled", updated.cmdStatus) + }) + + t.Run("enter confirms action in confirm mode", func(t *testing.T) { + enterModel := newTestModel() + enterModel.width = 100 + enterModel.height = 24 + enterModel.openConfirmModal(&confirmState{kind: confirmRemoveService, prompt: "Remove test?", name: "missing"}) + + newModel, cmd := enterModel.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.Nil(t, cmd) + + updated := newModel.(*topModel) + assert.Equal(t, viewModeTable, updated.mode) + assert.Nil(t, updated.modal) + assert.Nil(t, updated.confirm) + assert.NotEmpty(t, updated.cmdStatus) + }) +} + +func TestView_TableStructure(t *testing.T) { + model := newTestModel() + model.width = 120 + model.mode = viewModeTable + + t.Run("table has all required column headers", func(t *testing.T) { + output := model.View().Content + lines := strings.Split(output, "\n") + headerLine := findLineContaining(lines, "Name") + + assert.NotEmpty(t, headerLine) + assert.Contains(t, headerLine, "Name (1)") + assert.Contains(t, headerLine, "Port") + assert.Contains(t, headerLine, "PID") + assert.Contains(t, headerLine, "Project") + assert.Contains(t, headerLine, "Command") + assert.Contains(t, headerLine, "Health") + }) + + t.Run("table has divider line", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "─") + }) +} + +func TestView_ManagedServicesSection(t *testing.T) { + model := newTestModel() + model.width = 120 + model.mode = viewModeTable + + t.Run("context line shows focus state", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + }) + + t.Run("tab switch hint in footer", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + }) +} + +func TestView_ContextLine(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeTable + + t.Run("context line shows focus", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + }) + + t.Run("context line omits service count", func(t *testing.T) { + output := model.View().Content + assert.NotContains(t, output, "Services: 1 |") + }) +} + +func TestView_LogsMode(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeLogs + model.logPID = 1234 + + t.Run("logs header shows service name", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Logs:") + assert.Contains(t, output, "PID: 1234") + }) + + t.Run("logs header shows port field", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Port:") + }) + + t.Run("logs footer shows back hint", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "b back") + }) +} + +func TestView_HelpMode(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 24 + model.openHelpModal() + + t.Run("help shows keymap header", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Help") + }) + + t.Run("help shows keybindings", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + assert.Contains(t, output, "toggle help") + assert.Contains(t, output, "/") + }) + + t.Run("help shows command hints", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "add") + assert.Contains(t, output, "logs/start") + assert.Contains(t, output, "toggle follow") + }) + + t.Run("help keeps table visible behind modal", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "app") + assert.Contains(t, output, "Manage") + assert.Contains(t, output, "Help") + }) + + t.Run("click outside help closes modal", func(t *testing.T) { + clickModel := newTestModel() + clickModel.width = 100 + clickModel.height = 24 + clickModel.openHelpModal() + + newModel, cmd := clickModel.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 0, Y: 0}) + assert.Nil(t, cmd) + + updated := newModel.(*topModel) + assert.Equal(t, viewModeTable, updated.mode) + assert.Nil(t, updated.modal) + }) +} + +func TestView_SearchMode(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeSearch + model.searchQuery = "node" + model.searchInput.SetValue("node") + model.searchInput.Focus() + + t.Run("search prompt shows query", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "node") + assert.Contains(t, output, ">") + assert.Contains(t, output, "Name (1)") + }) + + t.Run("empty search shows inline input", func(t *testing.T) { + model.searchQuery = "" + model.searchInput.SetValue("") + output := model.View().Content + assert.Contains(t, output, ">") + }) +} + +func TestView_SelectedRow(t *testing.T) { + model := newTestModel() + model.width = 120 + model.mode = viewModeTable + model.selected = 0 + + t.Run("view renders without error", func(t *testing.T) { + assert.NotPanics(t, func() { + _ = model.View() + }) + }) + + t.Run("output is not empty", func(t *testing.T) { + output := model.View().Content + assert.NotEmpty(t, output) + }) +} + +func TestView_ManagedServiceSelection(t *testing.T) { + model := newTestModel() + model.width = 120 + model.mode = viewModeTable + model.focus = focusManaged + + t.Run("managed focus shows in context", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Managed Services") + }) + + t.Run("tab switch hint available for focus change", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + }) +} + +func TestView_ResponsiveWidth(t *testing.T) { + tests := []struct { + name string + width int + shouldPanic bool + }{ + {"narrow terminal 80", 80, false}, + {"standard terminal 100", 100, false}, + {"wide terminal 120", 120, false}, + {"very wide 200", 200, false}, + {"edge case zero", 0, false}, + {"edge case small", 40, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := newTestModel() + model.width = tt.width + model.height = 40 + + if tt.shouldPanic { + assert.Panics(t, func() { model.View() }) + } else { + assert.NotPanics(t, func() { + output := model.View().Content + assert.NotEmpty(t, output) + }) + } + }) + } +} + +func TestView_ResponsiveHeight(t *testing.T) { + tests := []struct { + name string + height int + }{ + {"short terminal 10", 10}, + {"standard terminal 24", 24}, + {"tall terminal 40", 40}, + {"very tall 100", 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = tt.height + + assert.NotPanics(t, func() { + output := model.View().Content + assert.NotEmpty(t, output) + }) + }) + } +} + +func TestView_TextWrapping(t *testing.T) { + model := newTestModel() + model.width = 80 + + t.Run("long footer wraps to width", func(t *testing.T) { + output := model.View().Content + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "switch list") || strings.Contains(line, "filter") || strings.Contains(line, ">") { + visibleWidth := calculateVisibleWidth(line) + assert.LessOrEqual(t, visibleWidth, model.width+10) + } + } + }) +} + +func TestView_EmptyStates(t *testing.T) { + t.Run("empty servers list shows message", func(t *testing.T) { + model := newTestModel() + model.servers = []*models.ServerInfo{} + model.width = 100 + output := model.View().Content + assert.Contains(t, output, "(no matching servers") + }) + + t.Run("empty filter shows message", func(t *testing.T) { + model := newTestModel() + model.servers = []*models.ServerInfo{} + model.searchQuery = "nonexistent" + model.width = 100 + output := model.View().Content + assert.Contains(t, output, "(no matching servers for filter") + }) +} + +func TestView_ModeTransitions(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 40 + + t.Run("table mode renders", func(t *testing.T) { + model.mode = viewModeTable + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, "Dev Process Tracker") + assert.Contains(t, output, "Name (1)") + }) + + t.Run("logs mode renders", func(t *testing.T) { + model.mode = viewModeLogs + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, "Logs:") + }) + + t.Run("command mode renders", func(t *testing.T) { + model.mode = viewModeCommand + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, ":") + }) + + t.Run("search mode renders", func(t *testing.T) { + model.mode = viewModeSearch + model.searchInput.SetValue("") + model.searchInput.Focus() + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, ">") + assert.Contains(t, output, "Name (1)") + }) + + t.Run("help mode renders", func(t *testing.T) { + model.openHelpModal() + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, "Help") + assert.Contains(t, output, "switch list") + }) +} + +func TestView_StatusMessage(t *testing.T) { + model := newTestModel() + model.width = 100 + + t.Run("status message appears", func(t *testing.T) { + model.cmdStatus = "Service started" + output := model.View().Content + assert.Contains(t, output, "Service started") + }) + + t.Run("empty status does not appear", func(t *testing.T) { + model.cmdStatus = "" + output := model.View().Content + assert.NotEmpty(t, output) + }) +} + +func TestView_StatusAndFooterClampToWidth(t *testing.T) { + model := newTestModel() + model.width = 40 + model.height = 20 + model.mode = viewModeTable + model.cmdStatus = `Restarted "mdt-be" because the previous health check timed out on localhost:3001` + + output := model.View().Content + lines := strings.Split(output, "\n") + var statusLine, footerLine string + + for _, line := range lines { + if strings.Contains(line, `Restarted "mdt-be"`) { + statusLine = line + } + if strings.Contains(line, "switch list") { + footerLine = line + } + } + + assert.NotEmpty(t, statusLine) + assert.NotEmpty(t, footerLine) + assert.LessOrEqual(t, calculateVisibleWidth(statusLine), model.width) + assert.LessOrEqual(t, calculateVisibleWidth(footerLine), model.width) + assert.Contains(t, statusLine, `Restarted "mdt-be" because the previo`) + assert.NotContains(t, statusLine, "localhost:3001") +} + +func TestView_SortModeDisplay(t *testing.T) { + model := newTestModel() + model.width = 100 + + tests := []struct { + name string + sortMode sortMode + }{ + {"sort by recent", sortRecent}, + {"sort by name", sortName}, + {"sort by project", sortProject}, + {"sort by port", sortPort}, + {"sort by health", sortHealth}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model.sortBy = tt.sortMode + output := model.View().Content + assert.Contains(t, output, "switch list") + assert.Contains(t, output, "Name (1)") + }) + } +} + +func TestView_ManagedCrashContextAndSymbols(t *testing.T) { + stoppedAt := time.Date(2026, 3, 27, 21, 54, 25, 0, time.UTC) + deps := &fakeAppDeps{ + services: []*models.ManagedService{ + { + Name: "test-go-basic-fake", + CWD: "/Users/kirby/.config/dev-process-tracker/sandbox/servers/go-basic", + Command: "go run .", + Ports: []int{3401}, + LastStop: &stoppedAt, + }, + }, + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "test-go-basic-fake", CWD: "/Users/kirby/.config/dev-process-tracker/sandbox/servers/go-basic", Command: "go run .", Ports: []int{3401}}, + Status: "crashed", + Source: models.SourceManaged, + CrashReason: "exit status 1", + CrashLogTail: []string{ + "2026/03/27 21:54:25 [go-basic] listening on http://localhost:3400", + "2026/03/27 21:54:25 listen tcp :3400: bind: address already in use", + "exit status 1", + }, + }, + }, + logPaths: map[string]string{ + "test-go-basic-fake": "~/.config/devpt/logs/test-go-basic-fake/2026-03-12T22-14-37.log", + }, + } + + model := newTopModel(deps) + model.width = 180 + model.height = 30 + model.mode = viewModeTable + model.focus = focusManaged + model.managedSel = 0 + + output := model.View().Content + assert.Contains(t, output, "✘") + assert.Contains(t, output, "test-go-basic-fake [crashed]") + assert.Contains(t, output, "Headline: exit status 1") + assert.Contains(t, output, "Log: ~/.config/devpt/logs/test-go-basic-fake/2026-03-12T22-14-37.log") + assert.Contains(t, output, "listen tcp :3400: bind: address already in use") + assert.Contains(t, output, "Source: managed") +} + +func findLineContaining(lines []string, pattern string) string { + for _, line := range lines { + if strings.Contains(line, pattern) { + return line + } + } + return "" +} + +func TestView_CommandColumnTruncation(t *testing.T) { + // Regression test: command column should use full cmdW for content. + // Old bug: runewidth.Truncate(cmd, cmdW-3, "...") produced a cmdW-3 wide string, + // then fixedCell padded with 3 dead spaces. The "..." was already counted in the + // Truncate output, so cmdW-3 wasted 3 chars of visible command path. + // Fix: runewidth.Truncate(cmd, cmdW, "...") uses the full width budget. + longCmd := "/Users/kirby/home/yt-offline/backend/node /very/long/path/to/some/javascript/server/file/that/needs/truncation/server.js" + + for _, terminalWidth := range []int{80, 100, 120} { + t.Run(fmt.Sprintf("width_%d", terminalWidth), func(t *testing.T) { + model := newTopModel(&fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{ + PID: 33489, + Port: 9055, + Command: longCmd, + CWD: "/Users/kirby/home/yt-offline/backend", + ProjectRoot: "/Users/kirby/home/yt-offline/backend", + }, + Status: "running", + Source: models.SourceManual, + }, + }, + }) + model.width = terminalWidth + model.height = 24 + model.mode = viewModeTable + model.refresh() + + output := model.View().Content + lines := strings.Split(output, "\n") + + // Find a data row containing the command path (use stripped output for matching) + var dataLineStripped string + for _, l := range lines { + s := stripANSI(l) + if strings.Contains(s, "yt-offline") || strings.Contains(s, "Users/kirby") { + dataLineStripped = s + break + } + } + assert.NotEmpty(t, dataLineStripped, "should find a row with the command path") + + // Calculate expected cmdW + nameW, portW, pidW, projectW, healthW := 14, 6, 7, 14, 7 + sep := 2 + used := nameW + sep + portW + sep + pidW + sep + projectW + sep + healthW + sep + cmdW := terminalWidth - used + if cmdW < 12 { + cmdW = 12 + } + + // Only test truncation cases (command longer than column) + if cmdW >= len(longCmd) { + return + } + + // Extract the command cell from the stripped (no-ANSI) line + // Command cell starts after: name(14) + sep(2) + port(6) + sep(2) + pid(7) + sep(2) + project(14) + sep(2) = 49 + cmdStart := nameW + sep + portW + sep + pidW + sep + projectW + sep + + // dataLineStripped already has ANSI stripped + runes := []rune(dataLineStripped) + if cmdStart+cmdW > len(runes) { + // Emoji may cause rune/width mismatch; extract approximate + return + } + cmdCell := string(runes[cmdStart : cmdStart+cmdW]) + + // The command cell should end with "..." from truncation, not spaces + assert.True(t, strings.HasSuffix(cmdCell, "..."), + "command cell should end with ..., got: %q", cmdCell) + + // Old bug symptom: cell ends with "... " (ellipsis + dead space padding) + assert.False(t, strings.Contains(cmdCell, "... "), + "command cell should NOT have dead space after ... (old cmdW-3 bug), got: %q", cmdCell) + + // Content before "..." should be longer than the old bug would allow + // Old bug: cmdW-3 total width means only cmdW-6 chars of actual path + // Fix: cmdW total width means cmdW-3 chars of actual path + pathPart := strings.TrimSuffix(cmdCell, "...") + assert.Greater(t, len(pathPart), 0, "should have path content before ...") + + // Verify we're showing at least cmdW-3 chars of content (the maximum possible) + assert.GreaterOrEqual(t, len(pathPart), cmdW-3, + "should use nearly full cmdW for path content, got %d chars in %q", len(pathPart), cmdCell) + }) + } +} + +// stripANSI removes ANSI escape sequences and OSC hyperlinks from a string. +func stripANSI(s string) string { + var result strings.Builder + i := 0 + for i < len(s) { + if s[i] == '\x1b' { + // Skip escape sequence + i++ + if i < len(s) && s[i] == '[' { + i++ + for i < len(s) { + if (s[i] >= '0' && s[i] <= '9') || s[i] == ';' || s[i] == '?' { + i++ + } else { + i++ + break + } + } + } else if i < len(s) && s[i] == ']' { + // OSC sequence: \x1b]...\x07 or \x1b]...\x1b\\ + i++ + for i < len(s) { + if s[i] == '\x07' { + i++ + break + } + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' { + i += 2 + break + } + i++ + } + } + } else { + result.WriteByte(s[i]) + i++ + } + } + return result.String() +} + +func calculateVisibleWidth(s string) int { + inEscape := false + visible := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if c == 0x1b { + inEscape = true + } else if inEscape { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + inEscape = false + } + } else { + visible++ + } + } + return visible +} diff --git a/pkg/cli/tui/tui_viewport_test.go b/pkg/cli/tui/tui_viewport_test.go new file mode 100644 index 0000000..6fe67a7 --- /dev/null +++ b/pkg/cli/tui/tui_viewport_test.go @@ -0,0 +1,608 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + + "github.com/devports/devpt/pkg/models" +) + +func TestViewportMouseClickNavigation(t *testing.T) { + model := newTestModel() + + t.Run("gutter click jumps to clicked line", func(t *testing.T) { + model.mode = viewModeLogs + model.logLines = make([]string, 1000) + for i := 0; i < 1000; i++ { + model.logLines[i] = fmt.Sprintf("Log line %d", i) + } + + model.viewport = viewport.New() + model.viewport.SetWidth(80) + model.viewport.SetHeight(24) + model.viewport.SetContent(strings.Join(model.logLines, "\n")) + initialOffset := model.viewport.YOffset() + clickedLine := 5 + gutterWidth := model.calculateGutterWidth() + + mouseMsg := tea.MouseClickMsg{Button: tea.MouseLeft, X: gutterWidth - 1, Y: clickedLine} + newModel, cmd := model.Update(mouseMsg) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.Equal(t, clickedLine, updatedModel.viewport.YOffset()) + assert.NotEqual(t, initialOffset, updatedModel.viewport.YOffset()) + }) + + t.Run("text click repositions viewport to center", func(t *testing.T) { + model.mode = viewModeLogs + model.logLines = make([]string, 1000) + for i := 0; i < 1000; i++ { + model.logLines[i] = fmt.Sprintf("Log line %d", i) + } + + model.viewport = viewport.New() + model.viewport.SetWidth(80) + model.viewport.SetHeight(24) + model.viewport.SetContent(strings.Join(model.logLines, "\n")) + + initialOffset := model.viewport.YOffset() + visibleLines := model.viewport.VisibleLineCount() + gutterWidth := model.calculateGutterWidth() + clickedAbsoluteLine := 100 + model.viewport.SetYOffset(clickedAbsoluteLine - 5) + + mouseMsg := tea.MouseClickMsg{Button: tea.MouseLeft, X: gutterWidth + 10, Y: 5} + newModel, cmd := model.Update(mouseMsg) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + expectedOffset := clickedAbsoluteLine - (visibleLines / 2) + if expectedOffset < 0 { + expectedOffset = 0 + } + + assert.Equal(t, expectedOffset, updatedModel.viewport.YOffset()) + assert.NotEqual(t, initialOffset, updatedModel.viewport.YOffset()) + }) + + t.Run("click with no content is no-op", func(t *testing.T) { + model.mode = viewModeLogs + model.logLines = nil + model.viewport = viewport.New() + initialOffset := model.viewport.YOffset() + + mouseMsg := tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: 10} + newModel, cmd := model.Update(mouseMsg) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.NotNil(t, updatedModel) + assert.Equal(t, initialOffset, updatedModel.viewport.YOffset()) + }) +} + +func TestViewportHighlightCycling(t *testing.T) { + model := newTestModel() + + t.Run("n key advances to next highlight", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 0 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "n", Code: 'n'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 1, updatedModel.highlightIndex) + }) + + t.Run("N key moves to previous highlight", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 3 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "N", Code: 'N'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 2, updatedModel.highlightIndex) + }) + + t.Run("highlight cycling wraps from last to first", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30} + model.highlightIndex = 2 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "n", Code: 'n'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 0, updatedModel.highlightIndex) + }) + + t.Run("highlight cycling wraps from first to last", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30} + model.highlightIndex = 0 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "N", Code: 'N'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 2, updatedModel.highlightIndex) + }) + + t.Run("highlight keys ignored when no highlights exist", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{} + model.highlightIndex = 0 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "n", Code: 'n'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 0, updatedModel.highlightIndex) + }) +} + +func TestViewportMatchCounter(t *testing.T) { + t.Run("footer shows match counter when highlights active", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 2 + view := model.View().Content + assert.Contains(t, view, "Match 3/5") + }) + + t.Run("footer shows correct format for first match", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30} + model.highlightIndex = 0 + view := model.View().Content + assert.Contains(t, view, "Match 1/3") + }) +} + +func TestViewportResizePersistence(t *testing.T) { + t.Run("terminal resize preserves highlight index", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 3 + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + updatedModel := newModel.(*topModel) + assert.Equal(t, 3, updatedModel.highlightIndex) + }) + + t.Run("terminal resize preserves highlight matches", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 3 + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + updatedModel := newModel.(*topModel) + assert.Equal(t, 3, updatedModel.highlightIndex) + assert.Equal(t, []int{10, 20, 30, 40, 50}, updatedModel.highlightMatches) + }) + + t.Run("terminal resize with no highlights is safe", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{} + model.highlightIndex = 0 + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + updatedModel := newModel.(*topModel) + assert.NotNil(t, updatedModel) + assert.Equal(t, 0, updatedModel.highlightIndex) + assert.Equal(t, []int{}, updatedModel.highlightMatches) + }) + + t.Run("terminal resize updates width and height", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 100 + model.height = 30 + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + updatedModel := newModel.(*topModel) + assert.Equal(t, 120, updatedModel.width) + assert.Equal(t, 40, updatedModel.height) + }) +} + +func TestViewportIntegration(t *testing.T) { + t.Run("viewport component is initialized in topModel", func(t *testing.T) { + model := newTestModel() + assert.Equal(t, 0, model.viewport.YOffset()) + }) + + t.Run("viewport receives updates when in logs mode", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 80 + model.height = 24 + model.logLines = []string{"Line 1", "Line 2", "Line 3"} + model.viewport.SetContent(strings.Join(model.logLines, "\n")) + + newModel, cmd := model.Update(tickMsg(time.Now())) + updatedModel := newModel.(*topModel) + assert.NotNil(t, updatedModel) + assert.NotNil(t, cmd) + + _ = updatedModel.View() + viewOutput := model.viewport.View() + assert.Contains(t, viewOutput, "Line 1") + }) + + t.Run("viewport sizing responds to terminal resize", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + updatedModel := newModel.(*topModel) + assert.Equal(t, 100, updatedModel.width) + assert.Equal(t, 40, updatedModel.height) + _ = updatedModel.View() + }) + + t.Run("viewport content is updated from log messages", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 80 + model.height = 24 + + newModel, _ := model.Update(logMsg{lines: []string{"Log line 1", "Log line 2", "Log line 3"}}) + updatedModel := newModel.(*topModel) + assert.Equal(t, []string{"Log line 1", "Log line 2", "Log line 3"}, updatedModel.logLines) + assert.NoError(t, updatedModel.logErr) + assert.True(t, strings.Contains(updatedModel.viewport.View(), "Log line 1") || len(updatedModel.logLines) > 0) + }) + + t.Run("viewport handles empty log content gracefully", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 80 + model.height = 24 + + newModel, _ := model.Update(logMsg{lines: []string{}, err: nil}) + updatedModel := newModel.(*topModel) + _ = updatedModel.View() + viewOutput := updatedModel.viewport.View() + assert.Contains(t, viewOutput, "(no logs yet)") + }) + + t.Run("viewport handles log errors gracefully", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 80 + model.height = 24 + + newModel, _ := model.Update(logMsg{lines: nil, err: fmt.Errorf("test error")}) + updatedModel := newModel.(*topModel) + _ = updatedModel.View() + assert.Error(t, updatedModel.logErr) + viewOutput := updatedModel.viewport.View() + assert.Contains(t, viewOutput, "Error:") + }) +} + +func TestMouseModeEnabled(t *testing.T) { + t.Run("TopCmd enables mouse cell motion", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.logLines = []string{"Line 1", "Line 2", "Line 3"} + model.viewport.SetContent(strings.Join(model.logLines, "\n")) + + newModel, cmd := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 5, Y: 5}) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + }) + + t.Run("mouse messages in non-logs mode are ignored", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + + newModel, cmd := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 5, Y: 5}) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + }) +} + +func findRunningRowClickY(model *topModel, needle string) int { + _ = model.View() + viewportLines := strings.Split(model.table.runningVP.View(), "\n") + for i, line := range viewportLines { + if strings.Contains(line, needle) { + return model.tableTopLines(model.width) + i - 1 + } + } + return -1 +} + +func findManagedRowClickY(model *topModel, needle string) int { + _ = model.View() + viewportLines := strings.Split(model.table.managedListVP.View(), "\n") + for i, line := range viewportLines { + if strings.Contains(line, needle) { + return model.tableTopLines(model.width) + model.table.lastRunningHeight + i + } + } + return -1 +} + +func clickTableAt(model *topModel, y int) *topModel { + newModel, _ := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: y}) + return newModel.(*topModel) +} + +func TestTableMouseClickSelection(t *testing.T) { + t.Run("click on running service row selects it", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.servers = []*models.ServerInfo{ + {ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js"}}, + {ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "go run ."}}, + {ProcessRecord: &models.ProcessRecord{PID: 1003, Port: 3002, Command: "python app.py"}}, + } + + model.viewport = viewport.New() + _ = model.View() + model.selected = 0 + model.focus = focusRunning + + viewportLines := strings.Split(model.table.runningVP.View(), "\n") + clickY := -1 + for i, line := range viewportLines { + if strings.Contains(line, "3001") { + clickY = model.tableTopLines(model.width) + i - 1 + break + } + } + assert.NotEqual(t, -1, clickY) + mouseMsg := tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY} + newModel, cmd := model.Update(mouseMsg) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + + m := newModel.(*topModel) + assert.Equal(t, 1, m.selected) + assert.Equal(t, focusRunning, m.focus) + }) + + t.Run("click with viewport offset adjusts selection correctly", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.servers = make([]*models.ServerInfo, 20) + for i := 0; i < 20; i++ { + model.servers[i] = &models.ServerInfo{ + ProcessRecord: &models.ProcessRecord{PID: 1000 + i, Port: 3000 + i, Command: fmt.Sprintf("node server%d.js", i)}, + } + } + + model.table.runningVP = viewport.New() + model.table.runningVP.SetWidth(80) + model.table.runningVP.SetHeight(10) + _ = model.View() + model.table.runningVP.SetYOffset(5) + + targetAbsoluteLine := 2 + 5 + clickY := model.tableTopLines(model.width) + (targetAbsoluteLine - model.table.runningVP.YOffset()) - 1 + newModel, _ := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY}) + m := newModel.(*topModel) + assert.Equal(t, 5, m.selected) + }) + + t.Run("click on managed service row selects it and activates managed focus", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.width = 100 + model.height = 20 + model.focus = focusRunning + model.selected = 0 + model.managedSel = 0 + model.app = &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + }, + services: []*models.ManagedService{ + {Name: "alpha", CWD: "/tmp/alpha", Command: "npm run dev", Ports: []int{4100}}, + {Name: "beta", CWD: "/tmp/beta", Command: "npm run dev", Ports: []int{4200}}, + {Name: "gamma", CWD: "/tmp/gamma", Command: "npm run dev", Ports: []int{4300}}, + }, + } + model.servers = []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + } + + _ = model.View() + viewportLines := strings.Split(model.table.managedListVP.View(), "\n") + clickY := -1 + for i, line := range viewportLines { + if strings.Contains(line, "beta [stopped]") { + clickY = model.tableTopLines(model.width) + model.table.lastRunningHeight + i + break + } + } + assert.NotEqual(t, -1, clickY) + + newModel, cmd := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY}) + assert.Nil(t, cmd) + + m := newModel.(*topModel) + assert.Equal(t, focusManaged, m.focus) + assert.Equal(t, 1, m.managedSel) + }) + + t.Run("red-green running rows map to clicked visible server", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.servers = []*models.ServerInfo{ + {ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js"}}, + {ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "go run ."}}, + {ProcessRecord: &models.ProcessRecord{PID: 1003, Port: 3002, Command: "python app.py"}}, + } + + cases := []struct { + needle string + wantPort int + }{ + {needle: "3000", wantPort: 3000}, + {needle: "3001", wantPort: 3001}, + {needle: "3002", wantPort: 3002}, + } + + for _, tc := range cases { + t.Run(tc.needle, func(t *testing.T) { + y := findRunningRowClickY(model, tc.needle) + assert.NotEqual(t, -1, y) + m := clickTableAt(model, y) + assert.Equal(t, focusRunning, m.focus) + visible := m.visibleServers() + if assert.Greater(t, len(visible), m.selected) { + assert.Equal(t, tc.wantPort, visible[m.selected].ProcessRecord.Port) + } + }) + } + }) + + t.Run("red-green managed rows map to exact selected index", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.width = 100 + model.height = 20 + model.focus = focusRunning + model.selected = 0 + model.managedSel = 0 + model.app = &fakeAppDeps{ + servers: []*models.ServerInfo{{ + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }}, + services: []*models.ManagedService{ + {Name: "alpha", CWD: "/tmp/alpha", Command: "npm run dev", Ports: []int{4100}}, + {Name: "beta", CWD: "/tmp/beta", Command: "npm run dev", Ports: []int{4200}}, + {Name: "gamma", CWD: "/tmp/gamma", Command: "npm run dev", Ports: []int{4300}}, + }, + } + model.servers = []*models.ServerInfo{{ + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }} + + cases := []struct { + needle string + want int + }{ + {needle: "alpha [stopped]", want: 0}, + {needle: "beta [stopped]", want: 1}, + {needle: "gamma [stopped]", want: 2}, + } + + for _, tc := range cases { + t.Run(tc.needle, func(t *testing.T) { + y := findManagedRowClickY(model, tc.needle) + assert.NotEqual(t, -1, y) + m := clickTableAt(model, y) + assert.Equal(t, focusManaged, m.focus) + assert.Equal(t, tc.want, m.managedSel) + }) + } + }) + + t.Run("wheel events are passed to viewport for scrolling", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.width = 80 + model.height = 12 + model.focus = focusManaged + model.app = &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + }, + } + model.servers = []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + } + fakeDeps := model.app.(*fakeAppDeps) + for i := 0; i < 30; i++ { + fakeDeps.services = append(fakeDeps.services, &models.ManagedService{ + Name: fmt.Sprintf("svc-%02d", i), + CWD: fmt.Sprintf("/tmp/svc-%02d", i), + Command: "npm run dev", + Ports: []int{4000 + i}, + }) + } + + _ = model.View() + initialManagedOffset := model.table.managedListVP.YOffset() + runningOffset := model.table.runningVP.YOffset() + mouseY := 2 + model.table.lastRunningHeight + 2 + + newModel, cmd := model.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 10, Y: mouseY}) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.False(t, updatedModel.tableFollowSelection) + + _ = updatedModel.View() + assert.Greater(t, updatedModel.table.managedListVP.YOffset(), initialManagedOffset) + assert.Equal(t, runningOffset, updatedModel.table.runningVP.YOffset()) + }) + + t.Run("wheel scrolling in top grid only moves running section", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.width = 80 + model.height = 12 + model.focus = focusRunning + model.selected = 0 + model.servers = make([]*models.ServerInfo, 30) + for i := 0; i < 30; i++ { + model.servers[i] = &models.ServerInfo{ + ProcessRecord: &models.ProcessRecord{ + PID: 1001 + i, + Port: 3000 + i, + Command: fmt.Sprintf("node server-%d.js", i), + }, + } + } + model.app = &fakeAppDeps{ + servers: model.servers, + services: []*models.ManagedService{ + {Name: "alpha", CWD: "/tmp/alpha", Command: "npm run dev", Ports: []int{4100}}, + {Name: "beta", CWD: "/tmp/beta", Command: "npm run dev", Ports: []int{4200}}, + }, + } + + _ = model.View() + initialRunningOffset := model.table.runningVP.YOffset() + managedOffset := model.table.managedListVP.YOffset() + mouseY := 4 + + newModel, cmd := model.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 10, Y: mouseY}) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.False(t, updatedModel.tableFollowSelection) + + _ = updatedModel.View() + assert.Greater(t, updatedModel.table.runningVP.YOffset(), initialRunningOffset) + assert.Equal(t, managedOffset, updatedModel.table.managedListVP.YOffset()) + }) +} diff --git a/pkg/cli/tui/update.go b/pkg/cli/tui/update.go new file mode 100644 index 0000000..a629291 --- /dev/null +++ b/pkg/cli/tui/update.go @@ -0,0 +1,463 @@ +package tui + +import ( + "errors" + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/devports/devpt/pkg/process" +) + +func (m *topModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + return m.handleKeyPress(msg) + case tea.MouseMsg: + return m.handleMouse(msg) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.help.SetWidth(msg.Width) + case tickMsg: + m.refresh() + if m.mode == viewModeLogs && m.followLogs { + return m, m.tailLogsCmd() + } + cmds := []tea.Cmd{tickCmd()} + if m.mode == viewModeTable && !m.healthBusy && time.Since(m.healthLast) > 2*time.Second && time.Since(m.lastInput) > 900*time.Millisecond { + m.healthBusy = true + cmds = append(cmds, m.healthCmd()) + } + if m.mode == viewModeTable && !m.memoryBusy && time.Since(m.memoryLast) > 2*time.Second && time.Since(m.lastInput) > 900*time.Millisecond { + m.memoryBusy = true + cmds = append(cmds, m.memoryCmd()) + } + return m, tea.Batch(cmds...) + case logMsg: + m.handleLogMsg(msg) + return m, tickCmd() + case healthMsg: + m.healthBusy = false + if msg.err == nil { + m.health = msg.icons + m.healthDetails = msg.details + m.healthLast = time.Now() + } + return m, nil + case memoryMsg: + m.memoryBusy = false + if msg.memory != nil { + m.memory = msg.memory + m.memoryLast = time.Now() + } + return m, nil + } + + if m.mode == viewModeLogs || m.mode == viewModeLogsDebug { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + if cmd != nil { + return m, cmd + } + } + + return m, nil +} + +// handleKeyPress processes all non-shift key presses. +func (m *topModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + m.lastInput = time.Now() + + if m.mode == viewModeCommand { + switch msg.String() { + case "esc": + m.mode = viewModeTable + m.cmdInput = "" + return m, nil + case "ctrl+c": + m.mode = viewModeTable + m.cmdInput = "" + return m, nil + case "ctrl+u": + m.cmdInput = "" + return m, nil + case "enter": + m.cmdStatus = m.runCommand(strings.TrimSpace(m.cmdInput)) + m.cmdInput = "" + m.mode = viewModeTable + m.refresh() + return m, nil + case "backspace": + if len(m.cmdInput) > 0 { + m.cmdInput = m.cmdInput[:len(m.cmdInput)-1] + } + return m, nil + } + for _, r := range []rune(msg.Text) { + if r >= 32 && r != 127 { + m.cmdInput += string(r) + } + } + return m, nil + } + + if m.mode == viewModeSearch { + switch msg.String() { + case "esc": + m.searchInput.SetValue(m.searchQuery) + m.searchInput.Blur() + m.mode = viewModeTable + return m, nil + case "enter": + m.searchQuery = m.searchInput.Value() + m.searchInput.Blur() + m.mode = viewModeTable + return m, nil + } + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd + } + + if m.mode == viewModeLogs { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + m.clearLogsView() + return m, nil + case key.Matches(msg, m.keys.Follow): + m.followLogs = !m.followLogs + return m, nil + case key.Matches(msg, m.keys.NextMatch): + if len(m.highlightMatches) > 0 { + m.highlightIndex = (m.highlightIndex + 1) % len(m.highlightMatches) + } + return m, nil + case key.Matches(msg, m.keys.PrevMatch): + if len(m.highlightMatches) > 0 { + m.highlightIndex = (m.highlightIndex - 1 + len(m.highlightMatches)) % len(m.highlightMatches) + } + return m, nil + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + + if m.mode == viewModeLogsDebug { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + m.mode = viewModeTable + return m, nil + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + + // viewModeTable key handling + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + // Group action key bindings (shift modifier) + case key.Matches(msg, m.keys.GroupStop): + m.prepareGroupStopConfirm() + return m, nil + case key.Matches(msg, m.keys.GroupRestart): + m.prepareGroupRestartConfirm() + return m, nil + case key.Matches(msg, m.keys.GroupRemove): + m.prepareGroupRemoveConfirm() + return m, nil + case key.Matches(msg, m.keys.GroupToggle): + if m.mode != viewModeTable { + return m, nil + } + if m.groupHighlightNamespace != nil { + m.groupHighlightNamespace = nil + } else { + ns := namespaceOfSelected(m) + if ns != "-" { + m.groupHighlightNamespace = &ns + } + } + return m, nil + case m.modal != nil && key.Matches(msg, m.keys.Help): + m.closeModal() + return m, nil + case key.Matches(msg, m.keys.Tab): + m.groupHighlightNamespace = nil + if m.focus == focusRunning { + m.focus = focusManaged + m.tableFollowSelection = true + managed := m.managedServices() + if m.managedSel < 0 && len(managed) > 0 { + m.managedSel = 0 + } + } else { + m.focus = focusRunning + m.tableFollowSelection = true + visible := m.visibleServers() + if m.selected < 0 && len(visible) > 0 { + m.selected = 0 + } + } + return m, nil + case key.Matches(msg, m.keys.Help): + m.openHelpModal() + return m, nil + case key.Matches(msg, m.keys.Search): + m.searchInput.SetValue(m.searchQuery) + m.searchInput.CursorEnd() + m.mode = viewModeSearch + return m, m.searchInput.Focus() + case key.Matches(msg, m.keys.ClearFilter): + m.searchQuery = "" + m.searchInput.SetValue("") + m.cmdStatus = "Filter cleared" + return m, nil + case key.Matches(msg, m.keys.Sort): + // Cycle to next sort mode, reset reverse + m.sortBy = (m.sortBy + 1) % sortModeCount + m.sortReverse = false + return m, nil + case key.Matches(msg, m.keys.SortReverse): + m.toggleSortDirection() + return m, nil + case key.Matches(msg, m.keys.Health): + m.showHealthDetail = !m.showHealthDetail + return m, nil + case key.Matches(msg, m.keys.Debug): + m.mode = viewModeLogsDebug + m.initDebugViewport() + return m, nil + case key.Matches(msg, m.keys.Add): + m.mode = viewModeCommand + m.cmdInput = "add " + return m, nil + case key.Matches(msg, m.keys.Restart): + if m.groupHighlightNamespace != nil { + m.prepareGroupRestartConfirm() + } else if m.focus == focusManaged { + m.cmdStatus = m.restartManaged() + m.refresh() + } else { + m.cmdStatus = m.restartSelected() + m.refresh() + } + return m, nil + case key.Matches(msg, m.keys.Stop): + if m.groupHighlightNamespace != nil { + m.prepareGroupStopConfirm() + } else if m.focus == focusManaged { + m.prepareManagedStopConfirm() + } else { + m.prepareStopConfirm() + } + return m, nil + case key.Matches(msg, m.keys.Remove): + if m.groupHighlightNamespace != nil { + m.prepareGroupRemoveConfirm() + } else if m.focus == focusManaged { + managed := m.managedServices() + if m.managedSel >= 0 && m.managedSel < len(managed) { + name := managed[m.managedSel].Name + m.openConfirmModal(&confirmState{ + kind: confirmRemoveService, + prompt: fmt.Sprintf("Remove %q from registry?", name), + name: name, + }) + } else { + m.cmdStatus = "No managed service selected" + } + } + return m, nil + case msg.String() == ":" || msg.String() == "shift+;" || msg.String() == ";" || msg.String() == "c": + m.mode = viewModeCommand + m.cmdInput = "" + return m, nil + case msg.String() == "esc": + if m.modal != nil { + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(false) + return m, cmd + } + m.closeModal() + return m, nil + } + switch m.mode { + case viewModeTable: + return m, tea.Quit + case viewModeLogs: + m.clearLogsView() + } + return m, nil + case msg.String() == "b": + if m.mode == viewModeLogs { + m.clearLogsView() + } + return m, nil + case msg.String() == "backspace": + return m, nil + case key.Matches(msg, m.keys.Up): + m.groupHighlightNamespace = nil + if m.focus == focusRunning && m.selected > 0 { + m.selected-- + m.tableFollowSelection = true + } + if m.focus == focusManaged && m.managedSel > 0 { + m.managedSel-- + m.tableFollowSelection = true + } + return m, nil + case key.Matches(msg, m.keys.Down): + m.groupHighlightNamespace = nil + if m.focus == focusRunning { + if m.selected < len(m.visibleServers())-1 { + m.selected++ + m.tableFollowSelection = true + } + } + if m.focus == focusManaged { + if m.managedSel < len(m.managedServices())-1 { + m.managedSel++ + m.tableFollowSelection = true + } + } + return m, nil + case key.Matches(msg, m.keys.Enter): + switch m.mode { + case viewModeTable: + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(true) + return m, cmd + } + return m.handleEnterKey() + } + return m, nil + case key.Matches(msg, m.keys.Confirm): + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(true) + return m, cmd + } + return m, nil + case key.Matches(msg, m.keys.Cancel): + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(false) + return m, cmd + } + if m.mode == viewModeLogs && len(m.highlightMatches) > 0 { + m.highlightIndex = (m.highlightIndex + 1) % len(m.highlightMatches) + } + return m, nil + case msg.String() == "pgup" || msg.String() == "pgdown" || msg.String() == "home" || msg.String() == "end": + m.tableFollowSelection = false + cmd := m.table.updateFocusedViewport(m.focus, msg) + return m, cmd + default: + return m, nil + } +} + +// handleMouse processes mouse messages. +func (m *topModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + mouse := msg.Mouse() + if m.modal != nil { + if _, ok := msg.(tea.MouseClickMsg); ok && mouse.Button == tea.MouseLeft { + bounds := m.activeModalBounds(m.width, m.baseViewContent(m.width)) + if !bounds.contains(mouse.X, mouse.Y) { + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(false) + return m, cmd + } + m.closeModal() + return m, nil + } + return m, nil + } + return m, nil + } + if m.mode == viewModeTable { + if _, ok := msg.(tea.MouseClickMsg); ok && mouse.Button == tea.MouseLeft { + return m.handleTableMouseClick(msg) + } + m.tableFollowSelection = false + viewportY := mouse.Y - m.tableTopLines(m.width) + 1 + cmd := m.table.updateViewportForTableY(viewportY, mouse.X, msg) + return m, cmd + } + if m.mode == viewModeLogs { + if _, ok := msg.(tea.MouseClickMsg); ok { + // Check if click is on the copy icon in the header command line. + // Header is line 0 ("Logs: ..."), command is line 1 ("📋 cmd..."). + if m.logCommand != "" && mouse.Y == 1 && mouse.X < 4 { + return m, tea.SetClipboard(m.logCommand) + } + return m.handleMouseClick(msg) + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + if m.mode == viewModeLogsDebug { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + return m, nil +} + +// handleLogMsg processes log messages from the tail command. +func (m *topModel) handleLogMsg(msg logMsg) { + oldYOffset := m.viewport.YOffset() + totalLines := m.viewport.TotalLineCount() + visibleLines := m.viewport.VisibleLineCount() + wasAtBottom := (oldYOffset+visibleLines >= totalLines) || totalLines == 0 + + m.logLines = msg.lines + m.logErr = msg.err + if m.logErr != nil { + var content string + if errors.Is(m.logErr, process.ErrNoLogs) { + content = "No devpt logs for this service yet.\nLogs are only captured when started by devpt.\n" + } else if errors.Is(m.logErr, process.ErrNoProcessLogs) { + content = "No accessible logs for this process.\nIf it writes only to a terminal, there may be nothing to tail here.\n" + } else { + content = fmt.Sprintf("Error: %v\n", m.logErr) + } + m.viewport.SetContent(content) + m.viewport.GotoTop() + } else if len(m.logLines) == 0 { + m.viewport.SetContent("(no logs yet)\n") + m.viewport.GotoTop() + } else { + content := strings.Join(m.logLines, "\n") + m.viewport.SetContent(content) + if m.followLogs || wasAtBottom { + newTotalLines := m.viewport.TotalLineCount() + newVisibleLines := m.viewport.VisibleLineCount() + if newTotalLines > newVisibleLines { + m.viewport.SetYOffset(newTotalLines - newVisibleLines) + } + } else { + m.viewport.SetYOffset(oldYOffset) + } + } +} + +func (m *topModel) clearLogsView() { + m.mode = viewModeTable + m.logLines = nil + m.logErr = nil + m.logSvc = nil + m.logPID = 0 +} diff --git a/pkg/cli/tui/view.go b/pkg/cli/tui/view.go new file mode 100644 index 0000000..7f2fb6f --- /dev/null +++ b/pkg/cli/tui/view.go @@ -0,0 +1,226 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/devports/devpt/pkg/buildinfo" +) + +func (m *topModel) View() tea.View { + if m.err != nil { + return tea.NewView(fmt.Sprintf("Error: %v\nPress 'q' to quit\n", m.err)) + } + + width := m.width + if width <= 0 { + width = 120 + } + if m.height <= 0 { + m.height = 24 + } + + content := m.baseViewContent(width) + if m.modal != nil { + content = overlayModal(content, m.activeModalOverlay(width), width) + } + + v := tea.NewView(content) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} + +func (m *topModel) baseViewContent(width int) string { + var b strings.Builder + headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) + versionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + switch m.mode { + case viewModeLogs: + b.WriteString(headerStyle.Render(m.logsHeaderView())) + b.WriteString("\n") + case viewModeLogsDebug: + b.WriteString(headerStyle.Render("Viewport Debug Mode (b back, q quit)")) + b.WriteString("\n") + default: + b.WriteString(headerStyle.Render("Dev Process Tracker - Health Monitor")) + b.WriteString(" ") + b.WriteString(versionStyle.Render(buildinfo.Version)) + } + + switch m.mode { + case viewModeLogs: + b.WriteString(m.renderLogs(width)) + b.WriteString("\n") + case viewModeLogsDebug: + b.WriteString(m.renderLogsDebug(width)) + b.WriteString("\n") + case viewModeTable, viewModeSearch: + b.WriteString("\n") + b.WriteString(m.table.Render(m, width)) + b.WriteString("\n") + } + + if m.mode == viewModeCommand { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(fitLine(":"+m.cmdInput, width))) + b.WriteString("\n") + hint := `Example: add my-app ~/projects/my-app "npm run dev" 3000` + if strings.HasPrefix(strings.TrimSpace(m.cmdInput), "add") { + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine(hint, width))) + b.WriteString("\n") + } + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine("Esc to go back", width))) + b.WriteString("\n") + } + if m.mode == viewModeTable || m.mode == viewModeSearch { + if sl := m.renderStatusLine(width); sl != "" { + b.WriteString(sl) + b.WriteString("\n") + } + b.WriteString(m.renderFooter(width)) + b.WriteString("\n") + } else { + var footer string + var statusLine string + + if m.cmdStatus != "" { + statusLine = m.cmdStatus + } + + if m.mode == viewModeLogs && len(m.highlightMatches) > 0 { + matchCounter := fmt.Sprintf("Match %d/%d", m.highlightIndex+1, len(m.highlightMatches)) + footer = fmt.Sprintf("%s | b back | f follow:%t | n/N next/prev highlight", matchCounter, m.followLogs) + } else if m.mode == viewModeLogs { + footer = fmt.Sprintf("b back | f follow:%t | ↑↓ scroll | Page Up/Down", m.followLogs) + } else if m.mode == viewModeLogsDebug { + footer = "b back | q quit | ↑↓ scroll | Page Up/Down" + } else { + footer = fmt.Sprintf("Last updated: %s | Services: %d | Tab switch | Enter logs/start | x remove managed | / filter | ^L clear filter | s sort | ? help | ^A add ^R restart ^E stop | D debug", m.lastUpdate.Format("15:04:05"), m.countVisible()) + } + footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) + + if statusLine != "" { + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")) + b.WriteString(statusStyle.Render(fitLine(statusLine, width))) + b.WriteString("\n") + } + + b.WriteString(footerStyle.Render(fitLine(footer, width))) + b.WriteString("\n") + } + + return b.String() +} + +func (m *topModel) renderLogs(width int) string { + headerLines := renderedLineCount(m.logsHeaderView()) + footerLines := renderedLineCount(m.logsFooterView()) + availableHeight := m.height - headerLines - footerLines + if availableHeight < 5 { + availableHeight = 5 + } + + m.viewport.SetWidth(width) + m.viewport.SetHeight(availableHeight) + + if m.viewportNeedsTop { + m.viewport.GotoTop() + m.viewportNeedsTop = false + } + + return m.viewport.View() +} + +func (m *topModel) initDebugViewport() { + var lines []string + for i := 1; i <= 100; i++ { + lines = append(lines, fmt.Sprintf("Debug Line %d: This is test content for viewport scrolling. Use arrow keys, page up/down, or mouse wheel to scroll. Press 'b' to exit debug mode.", i)) + } + content := strings.Join(lines, "\n") + m.viewport.SetContent(content) + m.viewport.GotoTop() +} + +func (m *topModel) renderLogsDebug(width int) string { + headerHeight := renderedLineCount("Viewport Debug Mode (b back, q quit)") + footerHeight := renderedLineCount("b back | q quit | ↑↓ scroll | Page Up/Down") + m.viewport.SetWidth(width) + height := m.height - headerHeight - footerHeight + if height < 5 { + height = 5 + } + m.viewport.SetHeight(height) + return m.viewport.View() +} + +func (m *topModel) logsHeaderView() string { + name := "-" + port := "-" + pid := "-" + cmd := "" + if m.logSvc != nil { + name = m.logSvc.Name + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == m.logSvc.Name && srv.ProcessRecord != nil { + if srv.ProcessRecord.Port > 0 { + port = fmt.Sprintf("%d", srv.ProcessRecord.Port) + } + if srv.ProcessRecord.PID > 0 { + pid = fmt.Sprintf("%d", srv.ProcessRecord.PID) + } + break + } + } + if port == "-" && len(m.logSvc.Ports) > 0 && m.logSvc.Ports[0] > 0 { + port = fmt.Sprintf("%d", m.logSvc.Ports[0]) + } + cmd = m.logSvc.Command + } else if m.logPID > 0 { + pid = fmt.Sprintf("%d", m.logPID) + for _, srv := range m.servers { + if srv.ProcessRecord != nil && srv.ProcessRecord.PID == m.logPID { + if srv.ProcessRecord.Port > 0 { + port = fmt.Sprintf("%d", srv.ProcessRecord.Port) + } + if srv.ManagedService != nil && srv.ManagedService.Name != "" { + name = srv.ManagedService.Name + } + if srv.ProcessRecord.Command != "" { + cmd = srv.ProcessRecord.Command + } + break + } + } + if name == "-" { + name = fmt.Sprintf("pid:%d", m.logPID) + } + } + header := fmt.Sprintf("Logs: %s | Port: %s | PID: %s", name, port, pid) + if cmd != "" { + m.logCommand = cmd + header += "\n" + copyIcon + " " + cmd + } else { + m.logCommand = "" + } + return header +} + +func (m *topModel) logsFooterView() string { + if len(m.highlightMatches) > 0 { + matchCounter := fmt.Sprintf("Match %d/%d", m.highlightIndex+1, len(m.highlightMatches)) + return fmt.Sprintf("%s | b back | f follow:%t | n/N next/prev highlight", matchCounter, m.followLogs) + } + return fmt.Sprintf("b back | f follow:%t | ↑↓ scroll | Page Up/Down", m.followLogs) +} + +func renderedLineCount(s string) int { + if s == "" { + return 0 + } + return 1 + strings.Count(s, "\n") +} diff --git a/pkg/cli/tui_adapter.go b/pkg/cli/tui_adapter.go new file mode 100644 index 0000000..4f4cc84 --- /dev/null +++ b/pkg/cli/tui_adapter.go @@ -0,0 +1,73 @@ +package cli + +import ( + "io" + "time" + + tuipkg "github.com/devports/devpt/pkg/cli/tui" + "github.com/devports/devpt/pkg/models" +) + +type tuiAdapter struct { + app *App +} + +func NewTUIAdapter(app *App) tuipkg.AppDeps { + return tuiAdapter{app: app.withOutput(io.Discard, io.Discard)} +} + +func (a tuiAdapter) DiscoverServers() ([]*models.ServerInfo, error) { + return a.app.discoverServers() +} + +func (a tuiAdapter) ListServices() []*models.ManagedService { + return a.app.registry.ListServices() +} + +func (a tuiAdapter) GetService(name string) *models.ManagedService { + return a.app.registry.GetService(name) +} + +func (a tuiAdapter) ClearServicePID(name string) error { + return a.app.registry.ClearServicePID(name) +} + +func (a tuiAdapter) RegisterService(name, cwd, command string, ports []int) error { + return a.app.AddCmd(name, cwd, command, ports) +} + +func (a tuiAdapter) RemoveService(name string) error { + return a.app.RemoveCmd(name) +} + +func (a tuiAdapter) StartService(name string) error { + return a.app.StartCmd(name) +} + +func (a tuiAdapter) StopService(identifier string) error { + return a.app.StopCmd(identifier) +} + +func (a tuiAdapter) RestartService(name string) error { + return a.app.RestartCmd(name) +} + +func (a tuiAdapter) StopProcess(pid int, timeout time.Duration) error { + return a.app.processManager.Stop(pid, timeout) +} + +func (a tuiAdapter) TailServiceLogs(name string, lines int) ([]string, error) { + return a.app.processManager.Tail(name, lines) +} + +func (a tuiAdapter) TailProcessLogs(pid int, lines int) ([]string, error) { + return a.app.processManager.TailProcess(pid, lines) +} + +func (a tuiAdapter) LatestServiceLogPath(name string) (string, error) { + return a.app.processManager.LatestLogPath(name) +} + +func (a tuiAdapter) GetProcessMemory(pids []int) map[int]int64 { + return a.app.resourceCollector.CollectMemory(pids) +} diff --git a/pkg/cli/tui_adapter_test.go b/pkg/cli/tui_adapter_test.go new file mode 100644 index 0000000..9348427 --- /dev/null +++ b/pkg/cli/tui_adapter_test.go @@ -0,0 +1,194 @@ +package cli + +import ( + "bytes" + "fmt" + "net" + "path/filepath" + "testing" + "time" + + "github.com/devports/devpt/pkg/models" + "github.com/devports/devpt/pkg/process" + "github.com/devports/devpt/pkg/registry" + "github.com/devports/devpt/pkg/scanner" +) + +func TestTUIAdapterLatestServiceLogPath_ReturnsManagedLogFile(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + reg := registry.NewRegistry(filepath.Join(tmp, "registry.json")) + if err := reg.Load(); err != nil { + t.Fatalf("load registry: %v", err) + } + + now := time.Now() + port := reserveTestPort(t) + if err := reg.AddService(&models.ManagedService{ + Name: "worker", + CWD: tmp, + Command: fmt.Sprintf("/usr/bin/python3 -m http.server %d --bind 127.0.0.1", port), + Ports: []int{port}, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("add service: %v", err) + } + + app := &App{ + registry: reg, + scanner: scanner.NewProcessScanner(), + resolver: scanner.NewProjectResolver(), + detector: scanner.NewAgentDetector(), + processManager: process.NewManager(filepath.Join(tmp, "logs")), + } + + // Ensure cleanup runs even if test fails mid-flight + t.Cleanup(func() { + svc := reg.GetService("worker") + if svc != nil && svc.LastPID != nil && *svc.LastPID > 0 { + if err := app.processManager.Stop(*svc.LastPID, 2*time.Second); err != nil && err != process.ErrNeedSudo { + t.Logf("cleanup stop pid %d: %v", *svc.LastPID, err) + } + } + }) + + if err := app.StartCmd("worker"); err != nil { + t.Fatalf("start service: %v", err) + } + waitForTCPListener(t, port) + + adapter, ok := NewTUIAdapter(app).(tuiAdapter) + if !ok { + t.Fatalf("expected tuiAdapter type") + } + + logPath, err := adapter.LatestServiceLogPath("worker") + if err != nil { + t.Fatalf("latest log path: %v", err) + } + if logPath == "" { + t.Fatalf("expected non-empty log path") + } + + svc := reg.GetService("worker") + if svc == nil || svc.LastPID == nil || *svc.LastPID <= 0 { + t.Fatalf("expected started service PID, got %#v", svc) + } +} + +func TestTUIAdapterRestartCmd_SuppressesCLIProgressOutput(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + reg := registry.NewRegistry(filepath.Join(tmp, "registry.json")) + if err := reg.Load(); err != nil { + t.Fatalf("load registry: %v", err) + } + + now := time.Now() + port := reserveTestPort(t) + if err := reg.AddService(&models.ManagedService{ + Name: "worker", + CWD: tmp, + Command: fmt.Sprintf("/usr/bin/python3 -m http.server %d --bind 127.0.0.1", port), + Ports: []int{port}, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("add service: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + app := &App{ + registry: reg, + scanner: scanner.NewProcessScanner(), + resolver: scanner.NewProjectResolver(), + detector: scanner.NewAgentDetector(), + processManager: process.NewManager(filepath.Join(tmp, "logs")), + stdout: &stdout, + stderr: &stderr, + } + + // Ensure cleanup runs even if test fails mid-flight + t.Cleanup(func() { + svc := reg.GetService("worker") + if svc != nil && svc.LastPID != nil && *svc.LastPID > 0 { + if err := app.processManager.Stop(*svc.LastPID, 2*time.Second); err != nil && err != process.ErrNeedSudo { + t.Logf("cleanup stop pid %d: %v", *svc.LastPID, err) + } + } + }) + + if err := app.StartCmd("worker"); err != nil { + t.Fatalf("start service: %v", err) + } + waitForTCPListener(t, port) + + svc := reg.GetService("worker") + if svc == nil || svc.LastPID == nil || *svc.LastPID <= 0 { + t.Fatalf("expected started service PID, got %#v", svc) + } + startPID := *svc.LastPID + + stdout.Reset() + stderr.Reset() + + adapter, ok := NewTUIAdapter(app).(tuiAdapter) + if !ok { + t.Fatalf("expected tuiAdapter type") + } + if err := adapter.RestartService("worker"); err != nil { + t.Fatalf("restart via TUI adapter: %v", err) + } + + if stdout.Len() != 0 { + t.Fatalf("expected no stdout leakage during TUI restart, got: %q", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr leakage during TUI restart, got: %q", stderr.String()) + } + + svc = reg.GetService("worker") + if svc == nil || svc.LastPID == nil || *svc.LastPID <= 0 { + t.Fatalf("expected restarted service PID, got %#v", svc) + } + if *svc.LastPID == startPID { + t.Fatalf("expected restart to update PID, still %d", *svc.LastPID) + } +} + +func reserveTestPort(t *testing.T) int { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + defer ln.Close() + + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected listener address type: %T", ln.Addr()) + } + return addr.Port +} + +func waitForTCPListener(t *testing.T, port int) { + t.Helper() + + deadline := time.Now().Add(8 * time.Second) + address := fmt.Sprintf("127.0.0.1:%d", port) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond) + if err == nil { + _ = conn.Close() + return + } + time.Sleep(50 * time.Millisecond) + } + + t.Fatalf("listener on %s did not become ready", address) +} diff --git a/pkg/cli/tui_key_input_test.go b/pkg/cli/tui_key_input_test.go deleted file mode 100644 index 489fd4b..0000000 --- a/pkg/cli/tui_key_input_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package cli - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -func TestCommandModeAcceptsRuneKeys(t *testing.T) { - t.Parallel() - - for _, key := range []string{"b", "q", "s", "n"} { - m := topModel{ - mode: viewModeCommand, - } - - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) - updated, ok := next.(topModel) - if !ok { - t.Fatalf("expected topModel, got %T", next) - } - if updated.cmdInput != key { - t.Fatalf("expected command input to include rune key %q, got %q", key, updated.cmdInput) - } - } -} - -func TestSearchModeAcceptsRuneKeys(t *testing.T) { - t.Parallel() - - m := topModel{ - mode: viewModeSearch, - } - - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) - updated, ok := next.(topModel) - if !ok { - t.Fatalf("expected topModel, got %T", next) - } - if updated.searchQuery != "s" { - t.Fatalf("expected search query to include rune key, got %q", updated.searchQuery) - } -} diff --git a/pkg/health/checker.go b/pkg/health/checker.go index 67b43d6..12f9ca1 100644 --- a/pkg/health/checker.go +++ b/pkg/health/checker.go @@ -1,132 +1,132 @@ package health import ( -"fmt" -"net" -"net/http" -"time" + "fmt" + "net" + "net/http" + "time" ) // Health status levels type HealthStatus string const ( -HealthOK HealthStatus = "ok" -HealthSlow HealthStatus = "slow" -HealthTimeout HealthStatus = "timeout" -HealthDown HealthStatus = "down" -HealthUnknown HealthStatus = "unknown" + HealthOK HealthStatus = "ok" + HealthSlow HealthStatus = "slow" + HealthTimeout HealthStatus = "timeout" + HealthDown HealthStatus = "down" + HealthUnknown HealthStatus = "unknown" ) // HealthCheck represents the result of a health check type HealthCheck struct { -Port int -Status HealthStatus -ResponseMs int -Message string -LastCheck time.Time + Port int + Status HealthStatus + ResponseMs int + Message string + LastCheck time.Time } // Checker performs health checks on services type Checker struct { -timeout time.Duration + timeout time.Duration } // NewChecker creates a new health checker func NewChecker(timeout time.Duration) *Checker { -if timeout == 0 { -timeout = 5 * time.Second -} -return &Checker{timeout: timeout} + if timeout == 0 { + timeout = 5 * time.Second + } + return &Checker{timeout: timeout} } // Check performs a health check on a port func (c *Checker) Check(port int) *HealthCheck { -result := &HealthCheck{ -Port: port, -LastCheck: time.Now(), -} - -// Try HTTP first -if ok, ms := c.checkHTTP(port); ok { -result.Status = categorizeResponse(ms) -result.ResponseMs = ms -result.Message = fmt.Sprintf("HTTP responding in %dms", ms) -return result -} - -// Fall back to TCP -if ok, ms := c.checkTCP(port); ok { -result.Status = categorizeResponse(ms) -result.ResponseMs = ms -result.Message = fmt.Sprintf("TCP responding in %dms", ms) -return result -} - -// Port is listening but not responding -result.Status = HealthDown -result.Message = "Port listening but no response" -return result + result := &HealthCheck{ + Port: port, + LastCheck: time.Now(), + } + + // Try HTTP first + if ok, ms := c.checkHTTP(port); ok { + result.Status = categorizeResponse(ms) + result.ResponseMs = ms + result.Message = fmt.Sprintf("HTTP responding in %dms", ms) + return result + } + + // Fall back to TCP + if ok, ms := c.checkTCP(port); ok { + result.Status = categorizeResponse(ms) + result.ResponseMs = ms + result.Message = fmt.Sprintf("TCP responding in %dms", ms) + return result + } + + // Port is listening but not responding + result.Status = HealthDown + result.Message = "Port listening but no response" + return result } // checkHTTP attempts an HTTP connection func (c *Checker) checkHTTP(port int) (bool, int) { -url := fmt.Sprintf("http://localhost:%d", port) -client := &http.Client{ -Timeout: c.timeout, -} + url := fmt.Sprintf("http://localhost:%d", port) + client := &http.Client{ + Timeout: c.timeout, + } -start := time.Now() -resp, err := client.Get(url) -elapsed := int(time.Since(start).Milliseconds()) + start := time.Now() + resp, err := client.Get(url) + elapsed := int(time.Since(start).Milliseconds()) -if err != nil { -return false, 0 -} -defer resp.Body.Close() + if err != nil { + return false, 0 + } + defer resp.Body.Close() -return true, elapsed + return true, elapsed } // checkTCP attempts a TCP connection func (c *Checker) checkTCP(port int) (bool, int) { -addr := fmt.Sprintf("localhost:%d", port) + addr := fmt.Sprintf("localhost:%d", port) -start := time.Now() -conn, err := net.DialTimeout("tcp", addr, c.timeout) -elapsed := int(time.Since(start).Milliseconds()) + start := time.Now() + conn, err := net.DialTimeout("tcp", addr, c.timeout) + elapsed := int(time.Since(start).Milliseconds()) -if err != nil { -return false, 0 -} -defer conn.Close() + if err != nil { + return false, 0 + } + defer conn.Close() -return true, elapsed + return true, elapsed } // categorizeResponse categorizes response time into status func categorizeResponse(ms int) HealthStatus { -if ms > 2000 { -return HealthSlow -} -if ms > 5000 { -return HealthTimeout -} -return HealthOK + if ms > 5000 { + return HealthTimeout + } + if ms > 2000 { + return HealthSlow + } + return HealthOK } // StatusIcon returns an emoji for the health status func StatusIcon(status HealthStatus) string { -switch status { -case HealthOK: -return "✅" -case HealthSlow: -return "⚠️" -case HealthTimeout: -return "🐢" -case HealthDown: -return "❌" -default: -return "❓" -} + switch status { + case HealthOK: + return "✅" + case HealthSlow: + return "⚠️" + case HealthTimeout: + return "🐢" + case HealthDown: + return "❌" + default: + return "❓" + } } diff --git a/pkg/lifecycle/file_preservation_test.go b/pkg/lifecycle/file_preservation_test.go new file mode 100644 index 0000000..15bff23 --- /dev/null +++ b/pkg/lifecycle/file_preservation_test.go @@ -0,0 +1,47 @@ +package lifecycle + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestLifecycleFilePreservation(t *testing.T) { + t.Parallel() + + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not resolve lifecycle package directory") + } + dir := filepath.Dir(currentFile) + + requiredFiles := []string{ + "identity.go", + "identity_test.go", + "start.go", + "start_test.go", + "stop.go", + "stop_test.go", + "restart.go", + "restart_test.go", + "reconciler.go", + "reconciler_test.go", + "manager.go", + "manager_test.go", + } + + for _, name := range requiredFiles { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("required lifecycle file is missing: %s", name) + } + if info.IsDir() { + t.Fatalf("required lifecycle file path is a directory: %s", name) + } + }) + } +} diff --git a/pkg/lifecycle/identity.go b/pkg/lifecycle/identity.go new file mode 100644 index 0000000..86d1985 --- /dev/null +++ b/pkg/lifecycle/identity.go @@ -0,0 +1,311 @@ +package lifecycle + +import ( + "path/filepath" + "strings" + + "github.com/devports/devpt/pkg/models" +) + +// IdentityResult holds the result of an identity verification. +type IdentityResult struct { + Verified bool + Process *models.ProcessRecord + Status string // "verified", "unknown", "not_found" +} + +// ProjectResolver resolves a project root from a CWD path. +// Returns the project root, or empty string if unresolvable. +type ProjectResolver func(cwd string) string + +// VerifyIdentity checks whether a live process matches a managed service +// using the ordered evidence chain: +// 1. PID + start time (definitive, if stored) +// 1b. Stored LastPID + path corroboration +// 2. Declared port (strong, if unique among managed services) +// 3. CWD + resolved command (grouping key) +// 4. Exact CWD match (unique CWD) +// 5. Exact project root match (unique root) +func VerifyIdentity( + svc *models.ManagedService, + processes []*models.ProcessRecord, + allServices []*models.ManagedService, +) IdentityResult { + return VerifyIdentityWithResolver(svc, processes, allServices, nil) +} + +// VerifyIdentityWithResolver is like VerifyIdentity but accepts an optional +// project root resolver for more accurate project root matching. +// +// Evidence chain (ordered by strength): +// 1. PID + start time (definitive, if stored) +// 1b. Stored LastPID + path corroboration (strong, even without start time) +// 2. Declared port (strong, if unique among managed services) +// 3. CWD + resolved command (grouping key for related processes) +// 4. Exact CWD match (unique CWD, fallback for portless services) +// 5. Exact project root match (unique root, fallback) +func VerifyIdentityWithResolver( + svc *models.ManagedService, + processes []*models.ProcessRecord, + allServices []*models.ManagedService, + resolver ProjectResolver, +) IdentityResult { + if svc == nil { + return IdentityResult{Status: "not_found"} + } + + // Precompute per-service identity data across all services + type svcIdentity struct { + cwd string + root string + ports map[int]bool + } + + resolve := resolver + if resolve == nil { + resolve = func(cwd string) string { return cwd } + } + + identities := make(map[*models.ManagedService]svcIdentity, len(allServices)) + cwdCount := make(map[string]int) + rootCount := make(map[string]int) + portCount := make(map[int]int) // how many managed services declare this port + portOwner := make(map[int]*models.ManagedService) // port -> owning service (if unique) + + for _, s := range allServices { + if s == nil { + continue + } + svcCWD := normalizePath(s.CWD) + svcRoot := normalizePath(resolve(s.CWD)) + ports := make(map[int]bool, len(s.Ports)) + for _, p := range s.Ports { + ports[p] = true + } + identities[s] = svcIdentity{ + cwd: svcCWD, + root: svcRoot, + ports: ports, + } + if identities[s].cwd != "" { + cwdCount[identities[s].cwd]++ + } + if identities[s].root != "" { + rootCount[identities[s].root]++ + } + for p := range ports { + portCount[p]++ + portOwner[p] = s + } + } + + myID := identities[svc] + + // Evidence 1: PID + start time (definitive) + if svc.LastPID != nil && *svc.LastPID > 0 && svc.LastProcessStartTime != nil { + for _, proc := range processes { + if proc == nil || proc.PID != *svc.LastPID { + continue + } + if proc.StartTime == nil { + return IdentityResult{ + Verified: false, + Process: proc, + Status: "unknown", + } + } + if proc.StartTime.Equal(*svc.LastProcessStartTime) { + return IdentityResult{ + Verified: true, + Process: proc, + Status: "verified", + } + } + return IdentityResult{ + Verified: false, + Process: proc, + Status: "unknown", + } + } + } + + // Evidence 1b: Stored LastPID + path corroboration (strong, even without start time) + // This takes precedence over port matching because a previously confirmed + // process identity is more reliable than a port match (which could be a conflict). + if svc.LastPID != nil && *svc.LastPID > 0 { + for _, proc := range processes { + if proc == nil || proc.PID != *svc.LastPID { + continue + } + procCWD := normalizePath(proc.CWD) + if myID.cwd != "" && procCWD != "" && myID.cwd == procCWD { + return IdentityResult{ + Verified: true, + Process: proc, + Status: "verified", + } + } + procRoot := normalizePath(proc.ProjectRoot) + if myID.root != "" && procRoot != "" && myID.root == procRoot { + return IdentityResult{ + Verified: true, + Process: proc, + Status: "verified", + } + } + break // PID matched but no path evidence + } + } + + // Evidence 2: Declared port owned by exactly one plausible managed service. + // Port is the primary runtime signal for services that declare one. + // For shared-CWD services, port uniquely distinguishes them. + for _, port := range svc.Ports { + if port <= 0 { + continue + } + if portCount[port] != 1 { + continue // Not uniquely owned + } + for _, proc := range processes { + if proc == nil || proc.Port != port { + continue + } + // If both service and process have CWD info that conflicts, skip + procCWD := normalizePath(proc.CWD) + if myID.cwd != "" && procCWD != "" && myID.cwd != procCWD { + continue + } + // If both have root info that conflicts, skip + procRoot := normalizePath(proc.ProjectRoot) + if myID.root != "" && procRoot != "" && myID.root != procRoot { + continue + } + return IdentityResult{ + Verified: true, + Process: proc, + Status: "verified", + } + } + } + + // Evidence 3: CWD + resolved command match. + // When a service has a learned resolved command (captured after first start), + // matching both CWD and the resolved command is strong evidence. + if myID.cwd != "" && svc.ResolvedCommand != "" { + for _, proc := range processes { + if proc == nil { + continue + } + procCWD := normalizePath(proc.CWD) + if procCWD == "" || procCWD != myID.cwd { + continue + } + if proc.Command != "" && commandMatches(svc.ResolvedCommand, proc.Command) { + // CWD + command match — but only if the process isn't on a + // declared port of another service (that would be a conflict). + if proc.Port > 0 { + if owner, ok := portOwner[proc.Port]; ok && owner != svc { + continue // Belongs to another service + } + } + return IdentityResult{ + Verified: true, + Process: proc, + Status: "verified", + } + } + } + } + + // Evidence 4: Exact CWD match (must be unique among managed services) + // Fallback for portless services with unique CWDs. + if myID.cwd != "" && cwdCount[myID.cwd] == 1 { + for _, proc := range processes { + if proc == nil { + continue + } + procCWD := normalizePath(proc.CWD) + if procCWD != "" && procCWD == myID.cwd { + return IdentityResult{ + Verified: true, + Process: proc, + Status: "verified", + } + } + } + } + + // Evidence 5: Exact project root match (must be unique among managed services) + if myID.root != "" && rootCount[myID.root] == 1 { + for _, proc := range processes { + if proc == nil { + continue + } + procRoot := normalizePath(proc.ProjectRoot) + if procRoot != "" && procRoot == myID.root { + return IdentityResult{ + Verified: true, + Process: proc, + Status: "verified", + } + } + } + } + + return IdentityResult{ + Verified: false, + Status: "not_found", + } +} + +func normalizePath(p string) string { + p = strings.TrimSpace(p) + p = strings.TrimRight(p, "/") + return p +} + +// resolveProjectRoot returns the CWD itself as a simplistic project root. +// In production, this would use scanner.ProjectResolver, but we avoid that +// dependency here to keep the function pure and testable. +func resolveProjectRoot(cwd string) string { + return cwd +} + +// commandMatches checks whether a stored resolved command matches a live process command. +// Since the resolved command is captured from `ps` at spawn time, the strings should +// match exactly in most cases. The function handles minor path differences by +// comparing the entry point and remaining arguments. +func commandMatches(resolvedCmd, procCmd string) bool { + r := strings.TrimSpace(resolvedCmd) + p := strings.TrimSpace(procCmd) + if r == p { + return true + } + // When both start with the same interpreter (node, bun, python), + // compare entry-point basename and remaining arguments. + // e.g. "node /long/path/vite" matches "node /other/path/vite" + // but "node /long/path/vite" does NOT match "node /path/vite preview --port 3070" + resolvedParts := strings.Fields(r) + procParts := strings.Fields(p) + if len(resolvedParts) < 2 || len(procParts) < 2 { + return false + } + if resolvedParts[0] != procParts[0] { + return false + } + // Entry-point basename must match (handles path differences) + if filepath.Base(resolvedParts[1]) != filepath.Base(procParts[1]) { + return false + } + // Remaining arguments must match exactly (prevents vite matching vite preview) + if len(resolvedParts) != len(procParts) { + return false + } + for i := 2; i < len(resolvedParts); i++ { + if resolvedParts[i] != procParts[i] { + return false + } + } + return true +} diff --git a/pkg/lifecycle/identity_test.go b/pkg/lifecycle/identity_test.go new file mode 100644 index 0000000..5e90074 --- /dev/null +++ b/pkg/lifecycle/identity_test.go @@ -0,0 +1,373 @@ +package lifecycle + +import ( + "testing" + "time" + + "github.com/devports/devpt/pkg/models" +) + +func TestVerifyIdentity_CWDMatch(t *testing.T) { + t.Parallel() + + // Exact CWD match returns verified (highest priority) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/project/app", + Port: 3000, + } + services := []*models.ManagedService{svc} + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, services) + if result.Verified { + t.Log("CWD match correctly verified") + } else { + t.Log("Identity verification returned non-verified for CWD match - may need implementation") + } +} + +func TestVerifyIdentity_ProjectRootMatch(t *testing.T) { + t.Parallel() + + // Exact project root match returns verified (second priority) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app/src", + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/project/app/src/server", + ProjectRoot: "/project/app", + Port: 3000, + } + services := []*models.ManagedService{svc} + + // Use resolver that maps /project/app/src → /project/app + resolver := func(cwd string) string { + if cwd == "/project/app/src" { + return "/project/app" + } + return cwd + } + + result := VerifyIdentityWithResolver(svc, []*models.ProcessRecord{proc}, services, resolver) + if !result.Verified { + t.Error("Project root match should verify identity") + } +} + +func TestVerifyIdentity_UniquePortOwnership(t *testing.T) { + t.Parallel() + + // Unique port ownership returns verified (third priority) + // Process has no CWD but is on the service's unique port + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + Ports: []int{3000}, + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "", + Port: 3000, + } + services := []*models.ManagedService{svc} + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, services) + if !result.Verified { + t.Error("Unique port ownership with no CWD conflict should verify identity") + } +} + +func TestVerifyIdentity_SharedCWDUniquePortsRemainNonAmbiguous(t *testing.T) { + t.Parallel() + + api := &models.ManagedService{ + Name: "api", + CWD: "/shared/project", + Ports: []int{3000}, + } + worker := &models.ManagedService{ + Name: "worker", + CWD: "/shared/project", + Ports: []int{4000}, + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/shared/project", + Port: 3000, + } + services := []*models.ManagedService{api, worker} + + apiResult := VerifyIdentity(api, []*models.ProcessRecord{proc}, services) + if !apiResult.Verified { + t.Fatalf("service with uniquely declared process port should verify even when CWD is shared") + } + if apiResult.Process != proc { + t.Fatalf("verified service should point at the matching process") + } + + workerResult := VerifyIdentity(worker, []*models.ProcessRecord{proc}, services) + if workerResult.Verified { + t.Fatalf("service with a different unique port should not own the process") + } +} + +func TestVerifyIdentity_PIDPlusPath(t *testing.T) { + t.Parallel() + + // Stored PID + matching path evidence returns verified (fourth priority) + pid := 1234 + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/project/app", + Port: 3000, + } + services := []*models.ManagedService{svc} + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, services) + if result.Verified { + t.Log("PID + path match correctly verified") + } else { + t.Log("Identity verification returned non-verified for PID+path - may need implementation") + } +} + +func TestVerifyIdentity_CommandFingerprintAlone(t *testing.T) { + t.Parallel() + + // Command fingerprint alone does NOT verify (supporting signal only) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + Command: "npm start", + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/other/path", + Command: "npm start", + Port: 3000, + } + services := []*models.ManagedService{svc} + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, services) + if result.Verified { + t.Error("Command fingerprint alone should NOT verify identity (supporting signal only)") + } +} + +func TestVerifyIdentity_NoMatch(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + } + proc := &models.ProcessRecord{ + PID: 9999, + CWD: "/completely/different", + Port: 8080, + } + services := []*models.ManagedService{svc} + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, services) + if result.Verified { + t.Error("No matching evidence should not verify identity") + } +} + +func TestVerifyIdentity_AmbiguousMultiMatch(t *testing.T) { + t.Parallel() + + // Multiple managed services match same CWD → unknown for all + svc1 := &models.ManagedService{ + Name: "api", + CWD: "/shared/project", + } + svc2 := &models.ManagedService{ + Name: "worker", + CWD: "/shared/project", + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/shared/project", + Port: 3000, + } + services := []*models.ManagedService{svc1, svc2} + + result1 := VerifyIdentity(svc1, []*models.ProcessRecord{proc}, services) + result2 := VerifyIdentity(svc2, []*models.ProcessRecord{proc}, services) + + if result1.Verified || result2.Verified { + t.Error("Ambiguous identity should NOT verify either service") + } +} + +func TestVerifyIdentity_PIDReuse(t *testing.T) { + t.Parallel() + + // Edge-1: Registry PID reused by unrelated process + pid := 1234 + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + } + // Same PID but completely different process (different CWD, different command) + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/other/app", + Command: "python server.py", + Port: 5000, + } + services := []*models.ManagedService{svc} + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, services) + if result.Verified { + t.Error("PID reuse by unrelated process should be detected and classified as unknown") + } + // Should NOT be classified as running + if result.Verified { + t.Error("PID reuse should not result in verified/running status") + } +} + +func TestVerifyIdentity_PIDStartTimeMatchOverridesWeakerEvidence(t *testing.T) { + t.Parallel() + + pid := 1234 + startTime := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + LastProcessStartTime: &startTime, + } + proc := &models.ProcessRecord{ + PID: pid, + CWD: "/other/path", + Port: 5000, + StartTime: &startTime, + } + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if !result.Verified { + t.Fatalf("PID + process start time should verify ownership, got status %q", result.Status) + } + if result.Process == nil || result.Process.PID != pid { + t.Fatalf("expected verified process PID %d, got %#v", pid, result.Process) + } +} + +func TestVerifyIdentity_PIDStartTimeMismatchBlocksLegacyEvidence(t *testing.T) { + t.Parallel() + + pid := 1234 + storedStart := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + actualStart := storedStart.Add(5 * time.Second) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + Ports: []int{3000}, + LastPID: &pid, + LastProcessStartTime: &storedStart, + } + proc := &models.ProcessRecord{ + PID: pid, + CWD: "/project/app", + ProjectRoot: "/project", + Port: 3000, + StartTime: &actualStart, + } + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if result.Verified { + t.Fatal("start-time mismatch must not be rescued by CWD, root, port, or PID-path evidence") + } + if result.Status != "unknown" { + t.Fatalf("start-time mismatch status = %q, want unknown", result.Status) + } +} + +func TestVerifyIdentity_PIDStartTimeMissingBlocksStoredPIDOwnership(t *testing.T) { + t.Parallel() + + pid := 1234 + storedStart := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + LastProcessStartTime: &storedStart, + } + proc := &models.ProcessRecord{ + PID: pid, + CWD: "/project/app", + } + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if result.Verified { + t.Fatal("stored PID ownership should not verify when live process start time is unavailable") + } + if result.Status != "unknown" { + t.Fatalf("missing live start-time status = %q, want unknown", result.Status) + } +} + +func TestVerifyIdentity_LegacyPIDPathFallbackWithoutProcessStartTime(t *testing.T) { + t.Parallel() + + pid := 1234 + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + } + proc := &models.ProcessRecord{ + PID: pid, + CWD: "/project/app", + } + + result := VerifyIdentity(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if !result.Verified { + t.Fatalf("legacy PID + path evidence should verify when LastProcessStartTime is absent, got %q", result.Status) + } +} + +func TestVerifyIdentity_MultiMatchUnknownForAll(t *testing.T) { + t.Parallel() + + // Edge-3: Single process matches multiple managed services + svc1 := &models.ManagedService{ + Name: "api", + CWD: "/app1", + Ports: []int{3000}, + } + svc2 := &models.ManagedService{ + Name: "web", + CWD: "/app2", + Ports: []int{3000}, + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/shared", + Port: 3000, + } + services := []*models.ManagedService{svc1, svc2} + + result1 := VerifyIdentity(svc1, []*models.ProcessRecord{proc}, services) + result2 := VerifyIdentity(svc2, []*models.ProcessRecord{proc}, services) + + if result1.Verified || result2.Verified { + t.Error("Multi-match should result in unknown for ALL affected services") + } +} diff --git a/pkg/lifecycle/lock.go b/pkg/lifecycle/lock.go new file mode 100644 index 0000000..028db9a --- /dev/null +++ b/pkg/lifecycle/lock.go @@ -0,0 +1,131 @@ +package lifecycle + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// FileLock implements per-service exclusive locks using file-based primitives. +// Locks are daemonless and recoverable by timeout. +type FileLock struct { + lockDir string + timeout time.Duration +} + +// NewFileLock creates a new FileLock with the given base directory. +func NewFileLock(dir string) *FileLock { + return &FileLock{ + lockDir: dir, + timeout: 30 * time.Second, + } +} + +// Acquire attempts to acquire an exclusive lock for the given service. +// Returns an error if the lock is already held by another process. +func (lk *FileLock) Acquire(serviceName string, pid int) error { + lockDir := filepath.Join(lk.lockDir, "locks") + if err := os.MkdirAll(lockDir, 0755); err != nil { + return err + } + + lockPath := filepath.Join(lockDir, serviceName+".lock") + + // Try atomic creation + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err == nil { + // Successfully created - we own the lock + lk.writeLockFile(file, pid) + return nil + } + + // Lock file exists — check if it's stale by timeout or dead owner + if lk.isStaleLock(lockPath) { + // Stale — reclaim + os.Remove(lockPath) + file, err = os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err == nil { + lk.writeLockFile(file, pid) + return nil + } + return err + } + + // Lock is actively held — blocked + return ErrLockBlocked +} + +// writeLockFile writes the lock file content with timestamp and PID. +func (lk *FileLock) writeLockFile(file *os.File, pid int) { + content := fmt.Sprintf("%s\nPID=%d", time.Now().Format(time.RFC3339), pid) + file.WriteString(content) + file.Close() +} + +// isStaleLock returns true if the lock file's owner is dead +// or the lock has exceeded the configured timeout. +func (lk *FileLock) isStaleLock(lockPath string) bool { + // Check timeout first — if lock file is older than timeout, it's stale + info, err := os.Stat(lockPath) + if err != nil { + return true + } + if lk.timeout > 0 && time.Since(info.ModTime()) > lk.timeout { + return true + } + + // Check if owner process is alive + return !lk.isOwnerAlive(lockPath) +} + +// Release releases the lock for the given service. +// Returns nil if the lock was not held (idempotent). +func (lk *FileLock) Release(serviceName string) error { + lockPath := filepath.Join(lk.lockDir, "locks", serviceName+".lock") + err := os.Remove(lockPath) + if err != nil && os.IsNotExist(err) { + return nil + } + return err +} + +// IsLocked checks whether a lock exists for the given service. +func (lk *FileLock) IsLocked(serviceName string) bool { + lockPath := filepath.Join(lk.lockDir, "locks", serviceName+".lock") + _, err := os.Stat(lockPath) + return err == nil +} + +func (lk *FileLock) isOwnerAlive(lockPath string) bool { + data, err := os.ReadFile(lockPath) + if err != nil { + return false + } + // Parse PID from lock file + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "PID=") { + pidStr := strings.TrimPrefix(line, "PID=") + pid, err := strconv.Atoi(pidStr) + if err != nil { + return false + } + // Check if process is alive + return lockProcessAlive(pid) + } + } + return true // Conservative: assume alive if we can't determine +} + +func isProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + return lockProcessAlive(pid) +} + +// ErrLockBlocked is returned when a lock cannot be acquired. +var ErrLockBlocked = fmt.Errorf("operation blocked: another operation is already in progress for this service") diff --git a/pkg/lifecycle/lock_test.go b/pkg/lifecycle/lock_test.go new file mode 100644 index 0000000..5d5c7c6 --- /dev/null +++ b/pkg/lifecycle/lock_test.go @@ -0,0 +1,184 @@ +package lifecycle + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestAcquireLock_Fresh(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + + err := lk.Acquire("test-service", os.Getpid()) + if err != nil { + t.Fatalf("Acquire() error = %v", err) + } + defer lk.Release("test-service") + + if !lk.IsLocked("test-service") { + t.Error("IsLocked() should return true after acquire") + } +} + +func TestAcquireLock_Concurrent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + + err := lk.Acquire("test-service", os.Getpid()) + if err != nil { + t.Fatalf("first Acquire() error = %v", err) + } + defer lk.Release("test-service") + + // Second acquire on same service should fail + err = lk.Acquire("test-service", os.Getpid()+99999) + if err == nil { + t.Error("second Acquire() should return error (blocked)") + } +} + +func TestAcquireLock_DifferentServices(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + + err1 := lk.Acquire("service-a", os.Getpid()) + if err1 != nil { + t.Fatalf("Acquire(service-a) error = %v", err1) + } + defer lk.Release("service-a") + + err2 := lk.Acquire("service-b", os.Getpid()) + if err2 != nil { + t.Fatalf("Acquire(service-b) error = %v", err2) + } + defer lk.Release("service-b") +} + +func TestReleaseLock(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + + lk.Acquire("test-service", os.Getpid()) + + err := lk.Release("test-service") + if err != nil { + t.Fatalf("Release() error = %v", err) + } + + if lk.IsLocked("test-service") { + t.Error("IsLocked() should return false after release") + } +} + +func TestReleaseLock_NotHeld(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + + // Releasing a non-held lock should be a no-op + err := lk.Release("nonexistent-service") + if err != nil { + t.Fatalf("Release() on non-held lock should be no-op, got error = %v", err) + } +} + +func TestIsLocked_NotLocked(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + + if lk.IsLocked("nonexistent-service") { + t.Error("IsLocked() should return false for non-existent lock") + } +} + +func TestLockFileContents(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + pid := os.Getpid() + + lk.Acquire("test-service", pid) + defer lk.Release("test-service") + + lockPath := filepath.Join(dir, "locks", "test-service.lock") + data, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("failed to read lock file: %v", err) + } + + if len(data) == 0 { + t.Error("lock file should contain PID and timestamp") + } +} + +func TestStaleLockRecovery_DeadOwner(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + + // Create a stale lock with a PID that doesn't exist + stalePID := 999999 // Very unlikely to be running + lk.Acquire("test-service", stalePID) + + // Attempt to acquire with a different PID should succeed after timeout recovery + err := lk.Acquire("test-service", os.Getpid()) + if err != nil { + t.Fatalf("Acquire() on stale lock with dead owner should succeed, got error = %v", err) + } + defer lk.Release("test-service") +} + +func TestStaleLockRecovery_AliveOwner(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := NewFileLock(dir) + + // Hold lock with current PID + lk.Acquire("test-service", os.Getpid()) + defer lk.Release("test-service") + + // Attempt to acquire with a different (fake) PID should fail + // because the owner (current process) is still alive + err := lk.Acquire("test-service", os.Getpid()+99999) + if err == nil { + t.Error("Acquire() should fail when owner PID is still alive") + } +} + +func TestLockTimeoutRecovery(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lk := &FileLock{ + lockDir: dir, + timeout: 1 * time.Second, + } + + // Create stale lock with dead PID + stalePID := 999999 + lk.Acquire("test-service", stalePID) + + // Wait briefly then try to reclaim + time.Sleep(100 * time.Millisecond) + err := lk.Acquire("test-service", os.Getpid()) + if err != nil { + t.Fatalf("Acquire() should succeed after timeout with dead owner, got error = %v", err) + } + defer lk.Release("test-service") +} diff --git a/pkg/lifecycle/lock_unix.go b/pkg/lifecycle/lock_unix.go new file mode 100644 index 0000000..ad64acf --- /dev/null +++ b/pkg/lifecycle/lock_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package lifecycle + +import "syscall" + +func lockProcessAlive(pid int) bool { + return syscall.Kill(pid, syscall.Signal(0)) == nil +} diff --git a/pkg/lifecycle/lock_windows.go b/pkg/lifecycle/lock_windows.go new file mode 100644 index 0000000..0b1a5d6 --- /dev/null +++ b/pkg/lifecycle/lock_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package lifecycle + +import ( + "os/exec" + "strconv" +) + +func lockProcessAlive(pid int) bool { + err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid)).Run() + return err == nil +} diff --git a/pkg/lifecycle/manager.go b/pkg/lifecycle/manager.go new file mode 100644 index 0000000..71b23fe --- /dev/null +++ b/pkg/lifecycle/manager.go @@ -0,0 +1,31 @@ +package lifecycle + +import ( + "github.com/devports/devpt/pkg/models" +) + +// LifecycleManager is the facade that orchestrates lifecycle operations. +// It holds dependencies and delegates to the individual flow functions. +type LifecycleManager struct { + deps Deps +} + +// NewLifecycleManager creates a new LifecycleManager with the given dependencies. +func NewLifecycleManager(deps Deps) *LifecycleManager { + return &LifecycleManager{deps: deps} +} + +// Start executes the start lifecycle command. +func (m *LifecycleManager) Start(svc *models.ManagedService) Result { + return StartService(m.deps, svc) +} + +// Stop executes the stop lifecycle command. +func (m *LifecycleManager) Stop(svc *models.ManagedService) Result { + return StopService(m.deps, svc) +} + +// Restart executes the restart lifecycle command. +func (m *LifecycleManager) Restart(svc *models.ManagedService) Result { + return RestartService(m.deps, svc) +} diff --git a/pkg/lifecycle/manager_test.go b/pkg/lifecycle/manager_test.go new file mode 100644 index 0000000..6364a93 --- /dev/null +++ b/pkg/lifecycle/manager_test.go @@ -0,0 +1,144 @@ +package lifecycle + +import ( + "testing" + + "github.com/devports/devpt/pkg/models" +) + +func TestLifecycleManager_HoldsDependencies(t *testing.T) { + t.Parallel() + + deps := newMockDeps() + mgr := NewLifecycleManager(deps) + if mgr == nil { + t.Error("LifecycleManager should be creatable") + } +} + +func TestLifecycleManager_StartDelegates(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "echo hi", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + mgr := NewLifecycleManager(deps) + result := mgr.Start(svc) + if result.Outcome != OutcomeSuccess { + t.Errorf("Manager.Start should succeed, got %q: %s", result.Outcome, result.Message) + } +} + +func TestLifecycleManager_StopDelegates(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{Name: "api", CWD: "/project"} + proc := &models.ProcessRecord{PID: 1234, CWD: "/project", Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + mgr := NewLifecycleManager(deps) + result := mgr.Stop(svc) + if result.Outcome != OutcomeSuccess { + t.Errorf("Manager.Stop should succeed for running service, got %q: %s", result.Outcome, result.Message) + } +} + +func TestLifecycleManager_RestartDelegates(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "echo hi", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + mgr := NewLifecycleManager(deps) + result := mgr.Restart(svc) + if result.Outcome != OutcomeSuccess { + t.Errorf("Manager.Restart should succeed, got %q: %s", result.Outcome, result.Message) + } +} + +func TestLifecycleManager_NilDeps(t *testing.T) { + t.Parallel() + + mgr := NewLifecycleManager(nil) + svc := &models.ManagedService{Name: "api", CWD: "/project", Command: "echo hi"} + + startResult := mgr.Start(svc) + if startResult.Outcome != OutcomeInvalid { + t.Errorf("Manager.Start with nil deps should return invalid, got %q", startResult.Outcome) + } + + stopResult := mgr.Stop(svc) + if stopResult.Outcome != OutcomeInvalid { + t.Errorf("Manager.Stop with nil deps should return invalid, got %q", stopResult.Outcome) + } + + restartResult := mgr.Restart(svc) + if restartResult.Outcome != OutcomeInvalid { + t.Errorf("Manager.Restart with nil deps should return invalid, got %q", restartResult.Outcome) + } +} + +func TestLifecycleManager_ConcurrentLockBlocked(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "echo hi", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + deps.locked["api"] = true + + mgr := NewLifecycleManager(deps) + + result := mgr.Start(svc) + if result.Outcome != OutcomeBlocked { + t.Errorf("concurrent lock should block start, got %q", result.Outcome) + } + + result = mgr.Stop(svc) + if result.Outcome != OutcomeBlocked { + t.Errorf("concurrent lock should block stop, got %q", result.Outcome) + } + + result = mgr.Restart(svc) + if result.Outcome != OutcomeBlocked { + t.Errorf("concurrent lock should block restart, got %q", result.Outcome) + } +} diff --git a/pkg/lifecycle/outcome.go b/pkg/lifecycle/outcome.go new file mode 100644 index 0000000..a650400 --- /dev/null +++ b/pkg/lifecycle/outcome.go @@ -0,0 +1,26 @@ +package lifecycle + +// Outcome represents the result of a lifecycle command. +type Outcome string + +const ( + OutcomeSuccess Outcome = "success" + OutcomeNoop Outcome = "noop" + OutcomeBlocked Outcome = "blocked" + OutcomeFailed Outcome = "failed" + OutcomeInvalid Outcome = "invalid" + OutcomeNotFound Outcome = "not_found" +) + +// Result holds the outcome of a lifecycle operation. +type Result struct { + Outcome Outcome + Message string + PID int + Diagnostics []string +} + +// IsSuccess returns true if the outcome is success. +func (r Result) IsSuccess() bool { + return r.Outcome == OutcomeSuccess +} diff --git a/pkg/lifecycle/outcome_test.go b/pkg/lifecycle/outcome_test.go new file mode 100644 index 0000000..0ad160a --- /dev/null +++ b/pkg/lifecycle/outcome_test.go @@ -0,0 +1,109 @@ +package lifecycle + +import "testing" + +func TestOutcomeTypeValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + outcome Outcome + want string + }{ + {"success", OutcomeSuccess, "success"}, + {"noop", OutcomeNoop, "noop"}, + {"blocked", OutcomeBlocked, "blocked"}, + {"failed", OutcomeFailed, "failed"}, + {"invalid", OutcomeInvalid, "invalid"}, + {"not_found", OutcomeNotFound, "not_found"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := string(tt.outcome); got != tt.want { + t.Errorf("Outcome %q = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + +func TestResultZeroValue(t *testing.T) { + t.Parallel() + + var r Result + if r.Outcome != "" { + t.Errorf("zero-value Result.Outcome = %q, want empty string", r.Outcome) + } + if r.Message != "" { + t.Errorf("zero-value Result.Message = %q, want empty string", r.Message) + } + if r.PID != 0 { + t.Errorf("zero-value Result.PID = %d, want 0", r.PID) + } +} + +func TestResultFields(t *testing.T) { + t.Parallel() + + r := Result{ + Outcome: OutcomeSuccess, + Message: "started", + PID: 1234, + Diagnostics: []string{"log line 1", "log line 2"}, + } + if r.Outcome != OutcomeSuccess { + t.Errorf("Result.Outcome = %q, want %q", r.Outcome, OutcomeSuccess) + } + if r.Message != "started" { + t.Errorf("Result.Message = %q, want %q", r.Message, "started") + } + if r.PID != 1234 { + t.Errorf("Result.PID = %d, want 1234", r.PID) + } + if len(r.Diagnostics) != 2 { + t.Errorf("Result.Diagnostics length = %d, want 2", len(r.Diagnostics)) + } +} + +func TestResultIsSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + r Result + want bool + }{ + {"success", Result{Outcome: OutcomeSuccess}, true}, + {"noop", Result{Outcome: OutcomeNoop}, false}, + {"blocked", Result{Outcome: OutcomeBlocked}, false}, + {"failed", Result{Outcome: OutcomeFailed}, false}, + {"invalid", Result{Outcome: OutcomeInvalid}, false}, + {"not_found", Result{Outcome: OutcomeNotFound}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.r.IsSuccess(); got != tt.want { + t.Errorf("Result{%q}.IsSuccess() = %v, want %v", tt.r.Outcome, got, tt.want) + } + }) + } +} + +func TestResultMessageFormat(t *testing.T) { + t.Parallel() + + r := Result{ + Outcome: OutcomeBlocked, + Message: "port 3000 is in use by PID 4821 (python). Stop it or change the service port.", + PID: 4821, + } + msg := r.Message + if msg == "" { + t.Error("Result.Message should not be empty") + } + // Verify message answers: what happened, what to do next + if r.Outcome == OutcomeBlocked && r.Message == "" { + t.Error("blocked outcome must have a message") + } +} diff --git a/pkg/lifecycle/readiness.go b/pkg/lifecycle/readiness.go new file mode 100644 index 0000000..d3bd676 --- /dev/null +++ b/pkg/lifecycle/readiness.go @@ -0,0 +1,216 @@ +package lifecycle + +import ( + "fmt" + "net" + "strings" + "time" + + "github.com/devports/devpt/pkg/models" +) + +// ErrReadinessTimeout is returned when a service does not become ready within the timeout. +var ErrReadinessTimeout = fmt.Errorf("service did not become ready within the timeout") + +// Default readiness timeouts. Package-level constants so tests and +// production code share a single source of truth. +const ( + defaultPortBoundTimeout = 20 * time.Second + defaultProcessOnlyTimeout = 3 * time.Second +) + +// ProcessChecker checks if a process is alive. +type ProcessChecker interface { + IsRunning(pid int) bool +} + +// HealthChecker checks health endpoints. +type HealthChecker interface { + Check(port int) bool +} + +// ReadinessPolicy defines how to wait for a service to become ready. +type ReadinessPolicy struct { + Mode models.ReadinessMode + Timeout time.Duration + Endpoint string + LogPattern string +} + +// Wait blocks until the service is ready or the timeout expires. +// Ports are used for port-bound, http-health, and multi-check modes. +// The processChk parameter checks process liveness (may be nil). +// The healthChk parameter checks HTTP health (may be nil). +// The logsTail parameter returns recent log lines (may be nil). +func (p *ReadinessPolicy) Wait( + pid int, + ports []int, + processChk ProcessChecker, + healthChk HealthChecker, + logsTail func() []string, +) error { + if p.Timeout <= 0 { + p.Timeout = defaultPortBoundTimeout + } + + deadline := time.Now().Add(p.Timeout) + interval := 100 * time.Millisecond + + for time.Now().Before(deadline) { + switch p.Mode { + case models.ReadinessProcessOnly: + if processChk != nil && processChk.IsRunning(pid) { + return nil + } + + case models.ReadinessPortBound: + for _, port := range ports { + if port > 0 && checkTCPPort(fmt.Sprintf("127.0.0.1:%d", port)) { + return nil + } + } + + case models.ReadinessHTTPHealth: + if healthChk != nil { + for _, port := range ports { + if port > 0 && healthChk.Check(port) { + return nil + } + } + } + + case models.ReadinessLogSignal: + if logsTail != nil && p.LogPattern != "" { + lines := logsTail() + for _, line := range lines { + if containsPattern(line, p.LogPattern) { + return nil + } + } + } + + case models.ReadinessMultiCheck: + allPass := true + if processChk != nil && !processChk.IsRunning(pid) { + allPass = false + } + if len(ports) > 0 { + portBound := false + for _, port := range ports { + if port > 0 && checkTCPPort(fmt.Sprintf("localhost:%d", port)) { + portBound = true + break + } + } + if !portBound { + allPass = false + } + } + if logsTail != nil && p.LogPattern != "" { + found := false + lines := logsTail() + for _, line := range lines { + if containsPattern(line, p.LogPattern) { + found = true + break + } + } + if !found { + allPass = false + } + } + if allPass { + return nil + } + } + + time.Sleep(interval) + } + + return ErrReadinessTimeout +} + +// SelectReadinessPolicy returns the appropriate readiness policy. +// If the service has an explicit config, use it. +// Otherwise, fall back to port-bound for services with ports, process-only for those without. +func SelectReadinessPolicy(cfg *models.ReadinessConfig, ports []int) ReadinessPolicy { + if cfg != nil && cfg.Mode != "" { + return ReadinessPolicy{ + Mode: cfg.Mode, + Timeout: time.Duration(cfg.Timeout) * time.Second, + Endpoint: cfg.Endpoint, + LogPattern: cfg.LogPattern, + } + } + + if len(ports) > 0 { + return ReadinessPolicy{ + Mode: models.ReadinessPortBound, + Timeout: defaultPortBoundTimeout, + } + } + + return ReadinessPolicy{ + Mode: models.ReadinessProcessOnly, + Timeout: defaultProcessOnlyTimeout, + } +} + +func checkTCPPort(addr string) bool { + // If addr is "localhost:port", also try "127.0.0.1:port" + // to handle macOS where localhost may resolve to IPv6 first. + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err != nil { + // Try 127.0.0.1 as fallback + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + fallback := "127.0.0.1" + addr[i:] + conn, err = net.DialTimeout("tcp", fallback, 200*time.Millisecond) + break + } + } + } + if err != nil { + return false + } + conn.Close() + return true +} + +func parsePortFromEndpoint(endpoint string) int { + if endpoint == "" { + return 0 + } + // Find the last colon that precedes a port number + // Handle "localhost:3000", ":3000", "http://localhost:3000/health" + lastColon := -1 + for i := len(endpoint) - 1; i >= 0; i-- { + if endpoint[i] == ':' { + lastColon = i + break + } + } + if lastColon < 0 { + return 0 + } + portStr := endpoint[lastColon+1:] + // Trim any path suffix + for i, c := range portStr { + if c == '/' { + portStr = portStr[:i] + break + } + } + port := 0 + for _, c := range portStr { + if c < '0' || c > '9' { + return 0 + } + port = port*10 + int(c-'0') + } + return port +} + +func containsPattern(line, pattern string) bool { + return pattern != "" && strings.Contains(line, pattern) +} diff --git a/pkg/lifecycle/readiness_test.go b/pkg/lifecycle/readiness_test.go new file mode 100644 index 0000000..6fb9af0 --- /dev/null +++ b/pkg/lifecycle/readiness_test.go @@ -0,0 +1,343 @@ +package lifecycle + +import ( + "fmt" + "testing" + "time" + + "github.com/devports/devpt/pkg/models" +) + +// mockProcessChecker implements ProcessChecker for testing. +type mockProcessChecker struct { + alive bool +} + +func (m *mockProcessChecker) IsRunning(pid int) bool { + return m.alive +} + +// mockHealthChecker implements HealthChecker for testing. +type mockHealthChecker struct { + healthy bool +} + +func (m *mockHealthChecker) Check(port int) bool { + return m.healthy +} + +func TestWaitForReadiness_ProcessOnly(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessProcessOnly, + Timeout: 2 * time.Second, + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: true}, nil, nil) + if err != nil { + t.Errorf("WaitForReadiness(process-only) should succeed for alive process, got error: %v", err) + } +} + +func TestWaitForReadiness_PortBound(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessPortBound, + Timeout: 2 * time.Second, + Endpoint: "localhost:19999", // unlikely to be listening + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: true}, nil, nil) + // Port 19999 is unlikely to be bound, so this should timeout + if err == nil { + t.Log("Port-bound succeeded (port was actually bound)") + } else { + if err != ErrReadinessTimeout { + t.Errorf("expected ErrReadinessTimeout, got %v", err) + } + } +} + +func TestWaitForReadiness_HTTPHealth(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessHTTPHealth, + Timeout: 2 * time.Second, + Endpoint: "http://localhost:19999/health", + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: true}, &mockHealthChecker{healthy: false}, nil) + // No server running, should timeout + if err == nil { + t.Log("HTTP health check succeeded (server was running)") + } else if err != ErrReadinessTimeout { + t.Errorf("expected ErrReadinessTimeout, got %v", err) + } +} + +func TestWaitForReadiness_LogSignal(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessLogSignal, + Timeout: 2 * time.Second, + LogPattern: "Server started", + } + + logs := func() []string { + return []string{"listening on port 3000", "Server started on port 3000"} + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: true}, nil, logs) + if err != nil { + t.Errorf("WaitForReadiness(log-signal) should succeed when pattern found in logs, got error: %v", err) + } +} + +func TestWaitForReadiness_MultiCheck(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessMultiCheck, + Timeout: 2 * time.Second, + LogPattern: "ready", + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: true}, nil, func() []string { + return []string{"ready"} + }) + if err != nil { + t.Errorf("WaitForReadiness(multi-check) should succeed when all checks pass, got error: %v", err) + } +} + +func TestWaitForReadiness_Timeout(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessProcessOnly, + Timeout: 200 * time.Millisecond, + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: false}, nil, nil) + if err == nil { + t.Error("WaitForReadiness should return error when process is dead and timeout exceeded") + } + if err != ErrReadinessTimeout { + t.Errorf("expected ErrReadinessTimeout, got %v", err) + } +} + +func TestFallbackPolicy_NilWithPorts(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + Ports: []int{3000}, + } + + policy := SelectReadinessPolicy(svc.Readiness, svc.Ports) + if policy.Mode != models.ReadinessPortBound { + t.Errorf("fallback for service with ports should be port-bound, got %q", policy.Mode) + } +} + +func TestFallbackPolicy_NilWithoutPorts(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "worker", + Ports: []int{}, + } + + policy := SelectReadinessPolicy(svc.Readiness, svc.Ports) + if policy.Mode != models.ReadinessProcessOnly { + t.Errorf("fallback for service without ports should be process-only, got %q", policy.Mode) + } +} + +func TestExplicitReadinessPolicy(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessHTTPHealth, + Timeout: 5, + Endpoint: "http://localhost:3000/health", + }, + } + + policy := SelectReadinessPolicy(svc.Readiness, svc.Ports) + if policy.Mode != models.ReadinessHTTPHealth { + t.Errorf("explicit policy should override fallback, got %q", policy.Mode) + } +} + +func TestWait_PortBound(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessPortBound, + Timeout: 200 * time.Millisecond, + } + + err := policy.Wait(1234, []int{19998, 19999}, &mockProcessChecker{alive: true}, nil, nil) + // Ports unlikely to be bound + if err == nil { + t.Log("Port-bound with ports succeeded (port was actually bound)") + } else if err != ErrReadinessTimeout { + t.Errorf("expected ErrReadinessTimeout, got %v", err) + } +} + +func TestParsePortFromEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected int + }{ + {"localhost:3000", 3000}, + {":8080", 8080}, + {"", 0}, + {"invalid", 0}, + {"http://localhost:3000/health", 3000}, + } + + for _, tt := range tests { + got := parsePortFromEndpoint(tt.input) + if got != tt.expected { + t.Errorf("parsePortFromEndpoint(%q) = %d, want %d", tt.input, got, tt.expected) + } + } +} + +func TestContainsPattern(t *testing.T) { + t.Parallel() + + tests := []struct { + line string + pattern string + want bool + }{ + {"Server started on port 3000", "Server started", true}, + {"listening on :3000", "ready", false}, + {"", "anything", false}, + {"ready", "", false}, + } + + for _, tt := range tests { + got := containsPattern(tt.line, tt.pattern) + if got != tt.want { + t.Errorf("containsPattern(%q, %q) = %v, want %v", tt.line, tt.pattern, got, tt.want) + } + } +} + +func TestSelectReadinessPolicy_CustomTimeout(t *testing.T) { + t.Parallel() + + cfg := &models.ReadinessConfig{ + Mode: models.ReadinessPortBound, + Timeout: 10, + } + + policy := SelectReadinessPolicy(cfg, []int{3000}) + if policy.Timeout != 10*time.Second { + t.Errorf("expected timeout 10s, got %v", policy.Timeout) + } +} + +func TestWaitForReadiness_ProcessOnlyDead(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessProcessOnly, + Timeout: 500 * time.Millisecond, + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: false}, nil, nil) + if err == nil { + t.Error("should timeout when process is dead") + } +} + +func TestWait_LogSignalNoMatch(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessLogSignal, + Timeout: 500 * time.Millisecond, + LogPattern: "NEVER_MATCH_THIS", + } + + logs := func() []string { + return []string{"listening on port 3000"} + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: true}, nil, logs) + if err == nil { + t.Error("should timeout when log pattern is never found") + } +} + +func TestWait_MultiCheckPartialFail(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessMultiCheck, + Timeout: 500 * time.Millisecond, + LogPattern: "NEVER_MATCH", + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: true}, nil, func() []string { + return []string{"other stuff"} + }) + if err == nil { + t.Error("multi-check should fail when one check fails") + } +} + +func TestWait_MultiCheckAllPass(t *testing.T) { + t.Parallel() + + policy := &ReadinessPolicy{ + Mode: models.ReadinessMultiCheck, + Timeout: 2 * time.Second, + LogPattern: "ready", + } + + err := policy.Wait(1234, nil, &mockProcessChecker{alive: true}, nil, func() []string { + return []string{"ready"} + }) + if err != nil { + t.Errorf("multi-check should pass when all checks succeed, got: %v", err) + } +} + +func TestSelectReadinessPolicy_DefaultTimeout(t *testing.T) { + t.Parallel() + + policy := SelectReadinessPolicy(nil, []int{3000}) + if policy.Timeout != defaultPortBoundTimeout { + t.Errorf("default port-bound timeout should be %v, got %v", defaultPortBoundTimeout, policy.Timeout) + } + + policy2 := SelectReadinessPolicy(nil, nil) + if policy2.Timeout != defaultProcessOnlyTimeout { + t.Errorf("default process-only timeout should be %v, got %v", defaultProcessOnlyTimeout, policy2.Timeout) + } +} + +func TestErrReadinessTimeout(t *testing.T) { + t.Parallel() + + if ErrReadinessTimeout == nil { + t.Error("ErrReadinessTimeout should not be nil") + } + _ = fmt.Sprintf("timeout error: %v", ErrReadinessTimeout) +} diff --git a/pkg/lifecycle/reconciler.go b/pkg/lifecycle/reconciler.go new file mode 100644 index 0000000..d254f8e --- /dev/null +++ b/pkg/lifecycle/reconciler.go @@ -0,0 +1,167 @@ +package lifecycle + +import ( + "github.com/devports/devpt/pkg/models" +) + +// ReconciledService holds the result of reconciling a service against live state. +type ReconciledService struct { + Status string // "running", "stopped", "crashed", "unknown" + Verified bool + Process *models.ProcessRecord + HasStaleMetadata bool // true when LastPID exists but no verified process was found +} + +// Reconcile scans live processes, matches against managed services by identity, +// classifies status, and clears stale metadata. +func Reconcile( + svc *models.ManagedService, + processes []*models.ProcessRecord, + allServices []*models.ManagedService, +) ReconciledService { + return ReconcileWithResolver(svc, processes, allServices, nil) +} + +// ReconcileWithResolver is like Reconcile but accepts an optional project root resolver. +func ReconcileWithResolver( + svc *models.ManagedService, + processes []*models.ProcessRecord, + allServices []*models.ManagedService, + resolver ProjectResolver, +) ReconciledService { + if svc == nil { + return ReconciledService{Status: string(models.StatusUnknown)} + } + + // Use identity verification to determine status + identity := VerifyIdentityWithResolver(svc, processes, allServices, resolver) + + if identity.Verified { + return ReconciledService{ + Status: string(models.StatusRunning), + Verified: true, + Process: identity.Process, + } + } + if identity.Status == string(models.StatusUnknown) { + return ReconciledService{ + Status: string(models.StatusUnknown), + Verified: false, + Process: identity.Process, + HasStaleMetadata: svc.LastPID != nil && *svc.LastPID > 0, + } + } + + // Check if identity is ambiguous (multiple services match) + if isAmbiguousWithResolver(svc, processes, allServices, resolver) { + return ReconciledService{ + Status: string(models.StatusUnknown), + Verified: false, + } + } + + // No verified process found — check for stale metadata + if svc.LastPID != nil && *svc.LastPID > 0 { + // Had a PID but no verified process now + return ReconciledService{ + Status: string(models.StatusCrashed), + Verified: false, + HasStaleMetadata: true, + } + } + + return ReconciledService{ + Status: string(models.StatusStopped), + Verified: false, + } +} + +// isAmbiguous checks whether multiple managed services could plausibly +// own the same live process, making identity unresolvable. +func isAmbiguous( + svc *models.ManagedService, + processes []*models.ProcessRecord, + allServices []*models.ManagedService, +) bool { + return isAmbiguousWithResolver(svc, processes, allServices, nil) +} + +func isAmbiguousWithResolver( + svc *models.ManagedService, + processes []*models.ProcessRecord, + allServices []*models.ManagedService, + resolver ProjectResolver, +) bool { + svcCWD := normalizePath(svc.CWD) + cwdCount := make(map[string]int) + rootCount := make(map[string]int) + portCount := make(map[int]int) + + // portOwner maps a uniquely-declared port to the service that owns it. + portOwner := make(map[int]*models.ManagedService) + + resolve := resolver + if resolve == nil { + resolve = func(cwd string) string { return cwd } + } + + for _, s := range allServices { + if s == nil { + continue + } + c := normalizePath(s.CWD) + if c != "" { + cwdCount[c]++ + } + r := normalizePath(resolve(s.CWD)) + if r != "" { + rootCount[r]++ + } + for _, p := range s.Ports { + portCount[p]++ + portOwner[p] = s + } + } + + // Check if any process matches this service in an ambiguous way + for _, proc := range processes { + if proc == nil { + continue + } + procCWD := normalizePath(proc.CWD) + procRoot := normalizePath(proc.ProjectRoot) + + // If this process is uniquely claimed by another service via port, + // it cannot create ambiguity for the current service. + if proc.Port > 0 && portCount[proc.Port] == 1 { + if owner, ok := portOwner[proc.Port]; ok && owner != svc { + continue + } + } + + // If this process is on a port that no managed service declares, + // and the current service has its own declared ports, the process + // is irrelevant noise — it cannot be a duplicate of this service. + if proc.Port > 0 && portCount[proc.Port] == 0 && len(svc.Ports) > 0 { + continue + } + + // CWD match but not unique + if svcCWD != "" && procCWD == svcCWD && cwdCount[svcCWD] > 1 { + return true + } + // Root match but not unique + svcRoot := normalizePath(resolve(svc.CWD)) + if svcRoot != "" && procRoot == svcRoot && rootCount[svcRoot] > 1 { + return true + } + // Port match but not unique + for _, port := range svc.Ports { + if port > 0 && proc.Port == port && portCount[port] > 1 { + return true + } + } + } + + return false +} diff --git a/pkg/lifecycle/reconciler_test.go b/pkg/lifecycle/reconciler_test.go new file mode 100644 index 0000000..f7e8c7a --- /dev/null +++ b/pkg/lifecycle/reconciler_test.go @@ -0,0 +1,276 @@ +package lifecycle + +import ( + "testing" + "time" + + "github.com/devports/devpt/pkg/models" +) + +func TestReconcile_VerifiedRunning_CWD(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/project/app", + Port: 3000, + } + + result := Reconcile(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if result.Status != "running" { + t.Errorf("expected status running for CWD match, got %q", result.Status) + } +} + +func TestReconcile_VerifiedRunning_ProjectRoot(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app/src", + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/project/app/src/server", + ProjectRoot: "/project/app", + Port: 3000, + } + + resolver := func(cwd string) string { + if cwd == "/project/app/src" { + return "/project/app" + } + return cwd + } + + result := ReconcileWithResolver(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}, resolver) + if result.Status != "running" { + t.Errorf("expected status running for project root match, got %q", result.Status) + } +} + +func TestReconcile_VerifiedRunning_UniquePort(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + Ports: []int{3000}, + } + // Process has no CWD info (common with lsof), but is on the service's unique port + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "", + Port: 3000, + } + + result := Reconcile(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if result.Status != "running" { + t.Errorf("expected status running for unique port match, got %q", result.Status) + } +} + +func TestReconcile_Stopped(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + } + + result := Reconcile(svc, []*models.ProcessRecord{}, []*models.ManagedService{svc}) + if result.Status != "stopped" { + t.Errorf("expected status stopped, got %q", result.Status) + } +} + +func TestReconcile_Crashed_StalePID(t *testing.T) { + t.Parallel() + + pid := 9999 // Not running + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + } + + result := Reconcile(svc, []*models.ProcessRecord{}, []*models.ManagedService{svc}) + if result.Status != "crashed" { + t.Errorf("expected status crashed for stale PID with no live process, got %q", result.Status) + } +} + +func TestReconcile_Unknown_AmbiguousIdentity(t *testing.T) { + t.Parallel() + + svc1 := &models.ManagedService{ + Name: "api", + CWD: "/shared", + } + svc2 := &models.ManagedService{ + Name: "worker", + CWD: "/shared", + } + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/shared", + Port: 3000, + } + + result := Reconcile(svc1, []*models.ProcessRecord{proc}, []*models.ManagedService{svc1, svc2}) + if result.Status != "unknown" { + t.Errorf("expected status unknown for ambiguous identity, got %q", result.Status) + } +} + +func TestReconcile_ClearsStaleMetadata(t *testing.T) { + t.Parallel() + + pid := 9999 + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + } + + result := Reconcile(svc, []*models.ProcessRecord{}, []*models.ManagedService{svc}) + if !result.HasStaleMetadata { + t.Error("Reconcile should clear stale metadata when PID no longer exists") + } +} + +func TestReconcile_Ambiguous_SkippedWhenPortUniquelyOwned(t *testing.T) { + t.Parallel() + + svc1 := &models.ManagedService{ + Name: "api", + CWD: "/shared", + Ports: []int{3000}, + } + svc2 := &models.ManagedService{ + Name: "worker", + CWD: "/shared", + Ports: []int{4000}, + } + // Process is on port 4000, uniquely owned by worker. + // It should NOT cause ambiguity for api. + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/shared", + Port: 4000, + } + + result := Reconcile(svc1, []*models.ProcessRecord{proc}, []*models.ManagedService{svc1, svc2}) + if result.Status == "unknown" { + t.Errorf("expected status != unknown when process port is uniquely owned by another service, got %q", result.Status) + } +} + +func TestReconcile_Ambiguous_WhenPortShared(t *testing.T) { + t.Parallel() + + svc1 := &models.ManagedService{ + Name: "api", + CWD: "/shared", + Ports: []int{3000}, + } + svc2 := &models.ManagedService{ + Name: "worker", + CWD: "/shared", + Ports: []int{3000}, + } + // Port 3000 declared by both services, CWD also shared → ambiguous. + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/shared", + Port: 3000, + } + + result := Reconcile(svc1, []*models.ProcessRecord{proc}, []*models.ManagedService{svc1, svc2}) + if result.Status != "unknown" { + t.Errorf("expected status unknown when port is shared and CWD matches both services, got %q", result.Status) + } +} + +func TestReconcile_PIDReuse_Unknown(t *testing.T) { + t.Parallel() + + pid := 1234 + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + } + // Same PID but completely different process + proc := &models.ProcessRecord{ + PID: 1234, + CWD: "/other/app", + Command: "python server.py", + Port: 5000, + } + + result := Reconcile(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if result.Verified { + t.Error("PID reuse should NOT verify the service") + } +} + +func TestReconcile_PIDStartTimeMatch_Running(t *testing.T) { + t.Parallel() + + pid := 1234 + startTime := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + LastProcessStartTime: &startTime, + } + proc := &models.ProcessRecord{ + PID: pid, + CWD: "/other/path", + StartTime: &startTime, + } + + result := Reconcile(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if result.Status != "running" { + t.Fatalf("expected running for matching PID + process start time, got %q", result.Status) + } + if !result.Verified { + t.Fatal("expected verified reconcile result") + } +} + +func TestReconcile_PIDStartTimeMismatch_Unknown(t *testing.T) { + t.Parallel() + + pid := 1234 + storedStart := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + actualStart := storedStart.Add(5 * time.Second) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project/app", + LastPID: &pid, + LastProcessStartTime: &storedStart, + } + proc := &models.ProcessRecord{ + PID: pid, + CWD: "/project/app", + StartTime: &actualStart, + } + + result := Reconcile(svc, []*models.ProcessRecord{proc}, []*models.ManagedService{svc}) + if result.Status != "unknown" { + t.Fatalf("expected unknown for PID start-time mismatch, got %q", result.Status) + } + if result.Verified { + t.Fatal("mismatched process start time must not verify") + } + if !result.HasStaleMetadata { + t.Fatal("mismatched process start time should flag stale metadata") + } +} diff --git a/pkg/lifecycle/restart.go b/pkg/lifecycle/restart.go new file mode 100644 index 0000000..c661234 --- /dev/null +++ b/pkg/lifecycle/restart.go @@ -0,0 +1,226 @@ +package lifecycle + +import ( + "fmt" + "time" + + "github.com/devports/devpt/pkg/models" +) + +// RestartService executes the restart flow: +// resolve → lock → reconcile → stop old → confirm gone → preflight → spawn new → verify identity+readiness → persist → release. +func RestartService(deps Deps, svc *models.ManagedService) Result { + if deps == nil || svc == nil { + return Result{Outcome: OutcomeInvalid, Message: "invalid: nil dependencies or service"} + } + + // Acquire lock + if err := deps.AcquireLock(svc.Name); err != nil { + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: another operation is already in progress for %q. Retry after it completes.", svc.Name), + } + } + defer deps.ReleaseLock(svc.Name) + + // Scan live processes + processes, err := deps.ScanProcesses() + if err != nil { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: could not scan live processes for %q: %v", svc.Name, err), + } + } + + allServices := deps.ListServices() + + // Reconcile + reconciled := ReconcileWithResolver(svc, processes, allServices, deps.ResolveProjectRoot) + + oldPID := 0 + hadOldInstance := false + + switch reconciled.Status { + case string(models.StatusRunning): + if !reconciled.Verified || reconciled.Process == nil { + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: identity for %q is ambiguous; refusing to restart.", svc.Name), + } + } + oldPID = reconciled.Process.PID + hadOldInstance = true + + // Stop the old instance + if err := deps.StopProcess(oldPID); err != nil { + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: could not stop old instance (PID %d) of %q: %v", oldPID, svc.Name, err), + PID: oldPID, + } + } + + // Confirm old instance is gone + if deps.IsRunning(oldPID) { + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: old instance of %q still owns resources (PID %d).", svc.Name, oldPID), + PID: oldPID, + } + } + + case string(models.StatusUnknown): + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: identity for %q is ambiguous; refusing to restart.", svc.Name), + } + + case string(models.StatusCrashed): + // Clear stale metadata + _ = deps.ClearServicePID(svc.Name) + // Fall through to start fresh + + case string(models.StatusStopped): + // No old instance — fall through to start fresh + } + + // Clear any remaining stale metadata before fresh start + if !hadOldInstance { + _ = deps.ClearServicePID(svc.Name) + } + + // Wait briefly for resources to be released after stopping old instance + if hadOldInstance { + portReleasePause() + } + + // Re-scan live processes after stop and require the declared ports to be clear. + // This prevents "restart" from succeeding while the old service keeps the same + // port and a new process is auto-bound to a fallback port. + processesAfterStop, err := deps.ScanProcesses() + if err != nil { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: could not scan live processes for %q: %v", svc.Name, err), + } + } + if err := preflightCheck(svc, processesAfterStop); err != nil { + outcome := OutcomeInvalid + if isPortConflict(err) { + outcome = OutcomeBlocked + } + return Result{ + Outcome: outcome, + Message: fmt.Sprintf("%s: %s", capitalizeOutcome(string(outcome)), err.Error()), + } + } + + // Spawn new instance + newPID, err := deps.StartProcess(svc) + if err != nil { + msg := fmt.Sprintf("Failed: could not start new instance of %q: %v", svc.Name, err) + if hadOldInstance { + msg = fmt.Sprintf("Failed: %q was stopped, but the replacement instance could not start: %v", svc.Name, err) + } + return Result{ + Outcome: OutcomeFailed, + Message: msg, + } + } + + // Verify process is alive + if !deps.IsRunning(newPID) { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: new instance of %q exited immediately. Check logs with devpt logs %s.", svc.Name, svc.Name), + Diagnostics: deps.GetLogTail(svc.Name, 10), + } + } + + // Freshness rule: new PID must differ from old + if hadOldInstance && newPID == oldPID { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: new instance of %q has the same PID as the old one (PID %d); restart is not valid.", svc.Name, newPID), + } + } + + // Wait for readiness + policy := SelectReadinessPolicy(svc.Readiness, svc.Ports) + readinessErr := policy.Wait( + newPID, + svc.Ports, + &depsProcessChecker{deps: deps}, + &depsHealthChecker{deps: deps}, + func() []string { return deps.GetLogTail(svc.Name, 5) }, + ) + + if readinessErr != nil { + diagnostics := deps.GetLogTail(svc.Name, 20) + _ = deps.StopProcess(newPID) + msg := fmt.Sprintf("Failed: %q was stopped, but the replacement instance did not become ready within %v.", svc.Name, policy.Timeout) + if !hadOldInstance { + msg = fmt.Sprintf("Failed: %q did not become ready within %v. Check logs with devpt logs %s.", svc.Name, policy.Timeout, svc.Name) + } + return Result{ + Outcome: OutcomeFailed, + Message: msg, + PID: newPID, + Diagnostics: diagnostics, + } + } + + processStartTime, err := deps.GetProcessStartTime(newPID) + if err != nil { + diagnostics := deps.GetLogTail(svc.Name, 20) + _ = deps.StopProcess(newPID) + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: could not confirm process identity for %q (PID %d): %v", svc.Name, newPID, err), + PID: newPID, + Diagnostics: diagnostics, + } + } + + // Persist confirmed run + if err := deps.UpdateServiceProcessIdentity(svc.Name, newPID, processStartTime); err != nil { + return Result{ + Outcome: OutcomeSuccess, + Message: fmt.Sprintf("Success: started %q (PID %d), but failed to update registry: %v", svc.Name, newPID, err), + PID: newPID, + } + } + + // Capture the OS-resolved command for future identity matching. + if resolvedCmd, err := deps.GetProcessCommand(newPID); err == nil && resolvedCmd != "" { + _ = deps.UpdateServiceResolvedCommand(svc.Name, resolvedCmd) + } + + // Format message based on whether we had an old instance + var message string + if hadOldInstance { + portMsg := "" + if len(svc.Ports) > 0 { + portMsg = fmt.Sprintf(" on port %d", svc.Ports[0]) + } + message = fmt.Sprintf("Success: restarted %q%s (old PID %d, new PID %d).", svc.Name, portMsg, oldPID, newPID) + } else { + portMsg := "" + if len(svc.Ports) > 0 { + portMsg = fmt.Sprintf(" on port %d", svc.Ports[0]) + } + message = fmt.Sprintf("Success: started %q because no verified instance was running%s (PID %d).", svc.Name, portMsg, newPID) + } + + return Result{ + Outcome: OutcomeSuccess, + Message: message, + PID: newPID, + } +} + +// portReleasePause waits briefly for the OS to release resources +// (e.g., TCP ports in TIME_WAIT) after stopping a process. +func portReleasePause() { + time.Sleep(500 * time.Millisecond) +} diff --git a/pkg/lifecycle/restart_test.go b/pkg/lifecycle/restart_test.go new file mode 100644 index 0000000..299fa50 --- /dev/null +++ b/pkg/lifecycle/restart_test.go @@ -0,0 +1,382 @@ +package lifecycle + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/devports/devpt/pkg/models" +) + +func TestRestart_VerifiedRunning(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + oldPID := 1234 + oldStart := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC) + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + LastPID: &oldPID, + LastProcessStartTime: &oldStart, + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + proc := &models.ProcessRecord{PID: oldPID, CWD: tmpDir, Port: 3000, StartTime: &oldStart} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[oldPID] = true + + result := RestartService(deps, svc) + if result.Outcome != OutcomeSuccess { + t.Errorf("restart of running service should succeed, got %q: %s", result.Outcome, result.Message) + } + if result.PID == 0 { + t.Error("success should include new PID") + } + if svc.LastPID == nil || *svc.LastPID != result.PID { + t.Fatalf("restart should persist new LastPID %d, got %v", result.PID, svc.LastPID) + } + if svc.LastProcessStartTime == nil { + t.Fatal("restart should persist new process start time") + } + if svc.LastProcessStartTime.Equal(oldStart) { + t.Fatal("restart should replace old process start time") + } +} + +func TestRestart_AlreadyStopped(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := RestartService(deps, svc) + // Should report as fresh start + if result.Outcome != OutcomeSuccess { + t.Errorf("restart of stopped service should succeed as fresh start, got %q: %s", result.Outcome, result.Message) + } + // Message should indicate fresh start + if result.Message != "" { + // Should say "started" not "restarted" for a service that was already stopped + t.Logf("Restart message: %q", result.Message) + } +} + +func TestRestart_OldCannotStop(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + } + proc := &models.ProcessRecord{PID: 1234, CWD: tmpDir, Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + deps.stopErr = fmt.Errorf("cannot stop process") // Simulate stop failure + + result := RestartService(deps, svc) + if result.Outcome != OutcomeBlocked { + t.Errorf("old instance cannot stop should return blocked, got %q", result.Outcome) + } +} + +func TestRestart_NewFailsReadiness(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "sleep 100", + Ports: []int{3000}, + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessPortBound, + Timeout: 1, + }, + } + proc := &models.ProcessRecord{PID: 1234, CWD: tmpDir, Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + result := RestartService(deps, svc) + // New instance won't become ready (port-bound timeout) + if result.Outcome == OutcomeSuccess { + t.Error("readiness failure should not return success") + } + if result.Outcome == OutcomeFailed { + t.Logf("Correctly reported failure: %s", result.Message) + } +} + +func TestRestart_FreshnessRule(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + proc := &models.ProcessRecord{PID: 1234, CWD: tmpDir, Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + result := RestartService(deps, svc) + if result.Outcome == OutcomeSuccess { + // New PID should differ from old + if result.PID == 1234 { + t.Error("restart should produce a different PID than the old instance") + } + } +} + +func TestRestart_StoppedReportsFreshStart(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := RestartService(deps, svc) + if result.Outcome == OutcomeSuccess && result.Message != "" { + // Message should mention "started" not "restarted" for a stopped service + contains := false + for i := 0; i <= len(result.Message)-7; i++ { + if result.Message[i:i+7] == "started" { + contains = true + break + } + } + if !contains { + t.Errorf("message should mention 'started' for fresh start, got: %s", result.Message) + } + } +} + +func TestRestart_AmbiguousIdentity(t *testing.T) { + t.Parallel() + + svc1 := &models.ManagedService{Name: "api", CWD: "/shared"} + svc2 := &models.ManagedService{Name: "worker", CWD: "/shared"} + proc := &models.ProcessRecord{PID: 1234, CWD: "/shared", Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc1 + deps.services["worker"] = svc2 + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + result := RestartService(deps, svc1) + if result.Outcome != OutcomeBlocked { + t.Errorf("ambiguous identity should return blocked, got %q", result.Outcome) + } +} + +func TestRestart_PIDStartTimeMismatchBlocked(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + pid := 1234 + storedStart := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC) + actualStart := storedStart.Add(time.Minute) + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + LastPID: &pid, + LastProcessStartTime: &storedStart, + } + proc := &models.ProcessRecord{PID: pid, CWD: tmpDir, Port: 3000, StartTime: &actualStart} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[pid] = true + + result := RestartService(deps, svc) + if result.Outcome != OutcomeBlocked { + t.Fatalf("start-time mismatch should block restart, got %q: %s", result.Outcome, result.Message) + } + if !deps.IsRunning(pid) { + t.Fatal("mismatched PID must not be stopped") + } + if svc.LastPID == nil || *svc.LastPID != pid { + t.Fatal("blocked restart should preserve LastPID") + } +} + +func TestRestart_ProcessStartTimeUnavailableFailsWithoutPersisting(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + deps.startTimeErr = fmt.Errorf("unsupported platform") + + result := RestartService(deps, svc) + if result.Outcome != OutcomeFailed { + t.Fatalf("start-time lookup failure should return failed, got %q: %s", result.Outcome, result.Message) + } + if svc.LastPID != nil { + t.Fatalf("failed identity confirmation should not persist LastPID, got %d", *svc.LastPID) + } + if svc.LastProcessStartTime != nil { + t.Fatal("failed identity confirmation should not persist process start time") + } + if deps.IsRunning(result.PID) { + t.Fatalf("failed identity confirmation should stop PID %d", result.PID) + } +} + +func TestRestart_LockContention(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{Name: "api", CWD: "/project", Command: "echo hi"} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + deps.locked["api"] = true + + result := RestartService(deps, svc) + if result.Outcome != OutcomeBlocked { + t.Errorf("lock contention should return blocked, got %q", result.Outcome) + } +} + +func TestRestart_NilDeps(t *testing.T) { + t.Parallel() + + result := RestartService(nil, &models.ManagedService{Name: "api"}) + if result.Outcome != OutcomeInvalid { + t.Errorf("nil deps should return invalid, got %q", result.Outcome) + } +} + +func TestRestart_CrashedService(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + pid := 9999 + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "echo hi", + LastPID: &pid, + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := RestartService(deps, svc) + // Crashed service should be treated as fresh start + if result.Outcome != OutcomeSuccess { + t.Errorf("restart of crashed service should succeed as fresh start, got %q: %s", result.Outcome, result.Message) + } +} + +func TestRestart_BlockOnPortConflictAfterStoppingWrongPID(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + oldPID := 4000 + conflictPID := 5000 + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + LastPID: &oldPID, + LastProcessStartTime: nil, + Ports: []int{3000}, + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + // Old PID is still running but not bound to service port. + // Another process owns the service port; restart should block instead of starting on fallback. + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{ + {PID: oldPID, CWD: tmpDir, Port: 4000}, + {PID: conflictPID, CWD: tmpDir, Port: 3000}, + } + deps.runningPIDs[oldPID] = true + deps.runningPIDs[conflictPID] = true + + result := RestartService(deps, svc) + if result.Outcome != OutcomeBlocked { + t.Fatalf("restart with stale owner on service port should be blocked, got %q: %s", result.Outcome, result.Message) + } + if !strings.Contains(result.Message, "port 3000 is in use") { + t.Fatalf("expected port-conflict message, got %q", result.Message) + } + if deps.IsRunning(oldPID) { + t.Fatal("stop should attempt to stop the reconciled old PID before blocking") + } + if result.Message == "" { + t.Fatal("blocked restart should include a message") + } +} diff --git a/pkg/lifecycle/start.go b/pkg/lifecycle/start.go new file mode 100644 index 0000000..c144cbb --- /dev/null +++ b/pkg/lifecycle/start.go @@ -0,0 +1,241 @@ +package lifecycle + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/devports/devpt/pkg/models" +) + +// Deps provides the external dependencies needed by lifecycle flows. +// Using an interface allows testing without real process spawning. +type Deps interface { + // Registry operations + GetService(name string) *models.ManagedService + UpdateServicePID(name string, pid int) error + UpdateServiceProcessIdentity(name string, pid int, processStartTime time.Time) error + UpdateServiceResolvedCommand(name, resolvedCommand string) error + ClearServicePID(name string) error + + // Process operations + StartProcess(svc *models.ManagedService) (int, error) + StopProcess(pid int) error + IsRunning(pid int) bool + GetProcessStartTime(pid int) (time.Time, error) + GetProcessCommand(pid int) (string, error) + + // Scanning + ScanProcesses() ([]*models.ProcessRecord, error) + ListServices() []*models.ManagedService + + // Health checking + CheckHealth(port int) bool + + // Log access + GetLogTail(name string, lines int) []string + + // Locking + AcquireLock(serviceName string) error + ReleaseLock(serviceName string) + + // Identity resolution + ResolveProjectRoot(cwd string) string +} + +// StartService executes the start flow: +// resolve → lock → reconcile → preflight → spawn → verify identity → wait readiness → persist → release. +func StartService(deps Deps, svc *models.ManagedService) Result { + if deps == nil || svc == nil { + return Result{Outcome: OutcomeInvalid, Message: "invalid: nil dependencies or service"} + } + + // Acquire lock + if err := deps.AcquireLock(svc.Name); err != nil { + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: another operation is already in progress for %q. Retry after it completes.", svc.Name), + } + } + defer deps.ReleaseLock(svc.Name) + + // Scan live processes + processes, err := deps.ScanProcesses() + if err != nil { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: could not scan live processes for %q: %v", svc.Name, err), + } + } + + allServices := deps.ListServices() + + // Reconcile + reconciled := ReconcileWithResolver(svc, processes, allServices, deps.ResolveProjectRoot) + + switch reconciled.Status { + case string(models.StatusRunning): + if reconciled.Verified && reconciled.Process != nil { + return Result{ + Outcome: OutcomeNoop, + Message: fmt.Sprintf("No-op: %q is already running (PID %d).", svc.Name, reconciled.Process.PID), + PID: reconciled.Process.PID, + } + } + case string(models.StatusUnknown): + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: identity for %q is ambiguous; refusing to start a potentially duplicate instance.", svc.Name), + } + case string(models.StatusCrashed): + // Stale metadata detected — proceed with fresh start (callers clear it) + } + + // Preflight checks + if err := preflightCheck(svc, processes); err != nil { + outcome := OutcomeInvalid + if isPortConflict(err) { + outcome = OutcomeBlocked + } + return Result{ + Outcome: outcome, + Message: fmt.Sprintf("%s: %s", capitalizeOutcome(string(outcome)), err.Error()), + } + } + + // Spawn process + pid, err := deps.StartProcess(svc) + if err != nil { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: could not start %q: %v", svc.Name, err), + } + } + + // Verify process is alive + if !deps.IsRunning(pid) { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: %q exited immediately after start. Check logs with devpt logs %s.", svc.Name, svc.Name), + Diagnostics: deps.GetLogTail(svc.Name, 10), + } + } + + // Wait for readiness + policy := SelectReadinessPolicy(svc.Readiness, svc.Ports) + readinessErr := policy.Wait( + pid, + svc.Ports, + &depsProcessChecker{deps: deps}, + &depsHealthChecker{deps: deps}, + func() []string { return deps.GetLogTail(svc.Name, 5) }, + ) + + if readinessErr != nil { + // Readiness failed — collect diagnostics and kill the child + diagnostics := deps.GetLogTail(svc.Name, 20) + _ = deps.StopProcess(pid) + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: %q did not become ready within %v. Check logs with devpt logs %s.", + svc.Name, policy.Timeout, svc.Name), + PID: pid, + Diagnostics: diagnostics, + } + } + + processStartTime, err := deps.GetProcessStartTime(pid) + if err != nil { + diagnostics := deps.GetLogTail(svc.Name, 20) + _ = deps.StopProcess(pid) + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: could not confirm process identity for %q (PID %d): %v", svc.Name, pid, err), + PID: pid, + Diagnostics: diagnostics, + } + } + + // Persist confirmed run (C6: only after identity and readiness confirmed) + if err := deps.UpdateServiceProcessIdentity(svc.Name, pid, processStartTime); err != nil { + return Result{ + Outcome: OutcomeSuccess, + Message: fmt.Sprintf("Success: started %q (PID %d), but failed to update registry: %v", svc.Name, pid, err), + PID: pid, + } + } + + // Capture the OS-resolved command for future identity matching. + // This learns the mapping from "bunx vite" -> "node .../vite" once, + // then uses it for all subsequent reconciles. + if resolvedCmd, err := deps.GetProcessCommand(pid); err == nil && resolvedCmd != "" { + _ = deps.UpdateServiceResolvedCommand(svc.Name, resolvedCmd) + } + + portMsg := "" + if len(svc.Ports) > 0 { + portMsg = fmt.Sprintf(" on port %d", svc.Ports[0]) + } + return Result{ + Outcome: OutcomeSuccess, + Message: fmt.Sprintf("Success: started %q%s (PID %d).", svc.Name, portMsg, pid), + PID: pid, + } +} + +func preflightCheck(svc *models.ManagedService, processes []*models.ProcessRecord) error { + // Check working directory exists and is a directory + if fi, err := os.Stat(svc.CWD); err != nil { + return fmt.Errorf("%q has a missing working directory: %s", svc.Name, svc.CWD) + } else if !fi.IsDir() { + return fmt.Errorf("%q has an invalid working directory: %s is not a directory", svc.Name, svc.CWD) + } + + // Check command is not empty + cmd := strings.TrimSpace(svc.Command) + if cmd == "" { + return fmt.Errorf("%q has an empty command definition", svc.Name) + } + + // Check declared ports are free + for _, port := range svc.Ports { + for _, proc := range processes { + if proc != nil && proc.Port == port { + return fmt.Errorf("port %d is in use by PID %d (%s). Stop it or change the service port.", + port, proc.PID, proc.Command) + } + } + } + + return nil +} + +func isPortConflict(err error) bool { + return err != nil && strings.Contains(err.Error(), "port ") +} + +func capitalizeOutcome(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// depsProcessChecker adapts Deps to ProcessChecker interface. +type depsProcessChecker struct { + deps Deps +} + +func (d *depsProcessChecker) IsRunning(pid int) bool { + return d.deps.IsRunning(pid) +} + +// depsHealthChecker adapts Deps to HealthChecker interface. +type depsHealthChecker struct { + deps Deps +} + +func (d *depsHealthChecker) Check(port int) bool { + return d.deps.CheckHealth(port) +} diff --git a/pkg/lifecycle/start_test.go b/pkg/lifecycle/start_test.go new file mode 100644 index 0000000..df26a36 --- /dev/null +++ b/pkg/lifecycle/start_test.go @@ -0,0 +1,537 @@ +package lifecycle + +import ( + "fmt" + "testing" + "time" + + "github.com/devports/devpt/pkg/models" +) + +// mockDeps implements Deps for testing. +type mockDeps struct { + services map[string]*models.ManagedService + processes []*models.ProcessRecord + runningPIDs map[int]bool + startTimes map[int]time.Time + nextPID int + healthPorts map[int]bool + logTail []string + locked map[string]bool + projectRoots map[string]string + updateErr error + clearErr error + scanErr error + startErr error + startFn func(svc *models.ManagedService) (int, error) + startTimeErr error + stopErr error + crashOnStart bool // if true, started process is not running +} + +func newMockDeps() *mockDeps { + return &mockDeps{ + services: make(map[string]*models.ManagedService), + runningPIDs: make(map[int]bool), + startTimes: make(map[int]time.Time), + healthPorts: make(map[int]bool), + locked: make(map[string]bool), + projectRoots: make(map[string]string), + nextPID: 50000, + } +} + +func (m *mockDeps) GetService(name string) *models.ManagedService { + return m.services[name] +} + +func (m *mockDeps) UpdateServicePID(name string, pid int) error { + if m.updateErr != nil { + return m.updateErr + } + if svc, ok := m.services[name]; ok { + svc.LastPID = &pid + svc.LastProcessStartTime = nil + } + return nil +} + +func (m *mockDeps) UpdateServiceProcessIdentity(name string, pid int, processStartTime time.Time) error { + if m.updateErr != nil { + return m.updateErr + } + if svc, ok := m.services[name]; ok { + svc.LastPID = &pid + t := processStartTime + svc.LastProcessStartTime = &t + } + return nil +} + +func (m *mockDeps) ClearServicePID(name string) error { + if m.clearErr != nil { + return m.clearErr + } + if svc, ok := m.services[name]; ok { + svc.LastPID = nil + svc.LastProcessStartTime = nil + } + return nil +} + +func (m *mockDeps) StartProcess(svc *models.ManagedService) (int, error) { + if m.startFn != nil { + return m.startFn(svc) + } + if m.startErr != nil { + return 0, m.startErr + } + pid := m.nextPID + m.nextPID++ + if !m.crashOnStart { + m.runningPIDs[pid] = true + m.startTimes[pid] = time.Date(2026, time.May, 27, 12, 0, pid%60, 0, time.UTC) + } + return pid, nil +} + +func (m *mockDeps) StopProcess(pid int) error { + delete(m.runningPIDs, pid) + delete(m.startTimes, pid) + return m.stopErr +} + +func (m *mockDeps) IsRunning(pid int) bool { + return m.runningPIDs[pid] +} + +func (m *mockDeps) GetProcessStartTime(pid int) (time.Time, error) { + if m.startTimeErr != nil { + return time.Time{}, m.startTimeErr + } + if t, ok := m.startTimes[pid]; ok { + return t, nil + } + return time.Time{}, fmt.Errorf("process start time unavailable") +} + +func (m *mockDeps) GetProcessCommand(pid int) (string, error) { + return "mock-command", nil +} + +func (m *mockDeps) UpdateServiceResolvedCommand(name, resolvedCommand string) error { + if svc, ok := m.services[name]; ok { + svc.ResolvedCommand = resolvedCommand + } + return nil +} + +func (m *mockDeps) ScanProcesses() ([]*models.ProcessRecord, error) { + if m.scanErr != nil { + return nil, m.scanErr + } + return m.processes, nil +} + +func (m *mockDeps) ListServices() []*models.ManagedService { + var svcs []*models.ManagedService + for _, svc := range m.services { + svcs = append(svcs, svc) + } + return svcs +} + +func (m *mockDeps) CheckHealth(port int) bool { + return m.healthPorts[port] +} + +func (m *mockDeps) GetLogTail(name string, lines int) []string { + return m.logTail +} + +func (m *mockDeps) AcquireLock(serviceName string) error { + if m.locked[serviceName] { + return ErrLockBlocked + } + m.locked[serviceName] = true + return nil +} + +func (m *mockDeps) ReleaseLock(serviceName string) { + delete(m.locked, serviceName) +} + +func (m *mockDeps) ResolveProjectRoot(cwd string) string { + if r, ok := m.projectRoots[cwd]; ok { + return r + } + return cwd +} + +func TestStart_AlreadyRunning(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{Name: "api", CWD: "/project", Ports: []int{3000}} + proc := &models.ProcessRecord{PID: 1234, CWD: "/project", Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + result := StartService(deps, svc) + if result.Outcome != OutcomeNoop { + t.Errorf("already running should return noop, got %q", result.Outcome) + } + if result.PID != 1234 { + t.Errorf("noop should include running PID, got %d", result.PID) + } +} + +func TestStart_AmbiguousIdentity(t *testing.T) { + t.Parallel() + + svc1 := &models.ManagedService{Name: "api", CWD: "/shared"} + svc2 := &models.ManagedService{Name: "worker", CWD: "/shared"} + proc := &models.ProcessRecord{PID: 1234, CWD: "/shared", Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc1 + deps.services["worker"] = svc2 + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + result := StartService(deps, svc1) + if result.Outcome != OutcomeBlocked { + t.Errorf("ambiguous identity should return blocked, got %q", result.Outcome) + } +} + +func TestStart_PreflightInvalid_MissingCWD(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + CWD: "/nonexistent/path/that/does/not/exist", + Command: "npm start", + } + + deps := newMockDeps() + deps.services["api"] = svc + + result := StartService(deps, svc) + if result.Outcome != OutcomeInvalid { + t.Errorf("missing CWD should return invalid, got %q", result.Outcome) + } +} + +func TestStart_PortConflict(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "npm start", + Ports: []int{3000}, + } + + existingProc := &models.ProcessRecord{PID: 9999, CWD: "/other", Port: 3000, Command: "python"} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{existingProc} + deps.runningPIDs[9999] = true + + result := StartService(deps, svc) + if result.Outcome != OutcomeBlocked { + t.Errorf("port conflict should return blocked, got %q", result.Outcome) + } + if result.Message == "" { + t.Error("blocked result should have a message") + } +} + +func TestStart_StaleRegistry(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + pid := 9999 + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "echo hi", + LastPID: &pid, + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := StartService(deps, svc) + // Stale PID means crashed status, then should attempt fresh start + if result.Outcome == OutcomeNoop { + t.Error("stale PID should not cause noop - should attempt fresh start") + } +} + +func TestStart_Success(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "echo hi", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := StartService(deps, svc) + if result.Outcome != OutcomeSuccess { + t.Errorf("expected success, got %q: %s", result.Outcome, result.Message) + } + if result.PID == 0 { + t.Error("success should include PID") + } + if svc.LastPID == nil || *svc.LastPID != result.PID { + t.Fatalf("success should persist LastPID %d, got %v", result.PID, svc.LastPID) + } + if svc.LastProcessStartTime == nil { + t.Fatal("success should persist process start time") + } +} + +func TestStart_ProcessStartTimeUnavailableFailsWithoutPersisting(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "echo hi", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + deps.startTimeErr = fmt.Errorf("unsupported platform") + + result := StartService(deps, svc) + if result.Outcome != OutcomeFailed { + t.Fatalf("start-time lookup failure should return failed, got %q: %s", result.Outcome, result.Message) + } + if svc.LastPID != nil { + t.Fatalf("failed identity confirmation should not persist LastPID, got %d", *svc.LastPID) + } + if svc.LastProcessStartTime != nil { + t.Fatal("failed identity confirmation should not persist process start time") + } + if deps.IsRunning(result.PID) { + t.Fatalf("failed identity confirmation should stop PID %d", result.PID) + } +} + +func TestStart_ReadinessTimeout(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "sleep 100", + Ports: []int{3000}, + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessPortBound, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := StartService(deps, svc) + if result.Outcome == OutcomeSuccess { + t.Error("readiness timeout should not return success") + } + if result.Outcome == OutcomeFailed { + t.Logf("Readiness timeout correctly reported failure: %s", result.Message) + } +} + +func TestStart_ReadinessTimeoutPreservesLogDiagnostics(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "sleep 100", + Ports: []int{3000}, + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessPortBound, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + deps.logTail = []string{"listening check timed out", "last known log line"} + + result := StartService(deps, svc) + if result.Outcome != OutcomeFailed { + t.Fatalf("readiness timeout should fail, got %q: %s", result.Outcome, result.Message) + } + if len(result.Diagnostics) != len(deps.logTail) { + t.Fatalf("expected diagnostics to include log tail, got %#v", result.Diagnostics) + } + for i, line := range deps.logTail { + if result.Diagnostics[i] != line { + t.Fatalf("diagnostic line %d = %q, want %q", i, result.Diagnostics[i], line) + } + } +} + +func TestStart_NoUnconfirmedPID(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{ + Name: "api", + CWD: "/nonexistent", + Command: "npm start", + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := StartService(deps, svc) + if result.Outcome == OutcomeFailed || result.Outcome == OutcomeInvalid { + if result.PID != 0 { + t.Error("failed/invalid start should not report a PID") + } + } +} + +func TestStart_LockContention(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{Name: "api", CWD: "/project", Command: "echo hi"} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + deps.locked["api"] = true + + result := StartService(deps, svc) + if result.Outcome != OutcomeBlocked { + t.Errorf("lock contention should return blocked, got %q", result.Outcome) + } +} + +func TestStart_NilDeps(t *testing.T) { + t.Parallel() + + result := StartService(nil, &models.ManagedService{Name: "api"}) + if result.Outcome != OutcomeInvalid { + t.Errorf("nil deps should return invalid, got %q", result.Outcome) + } +} + +func TestStart_NilService(t *testing.T) { + t.Parallel() + + deps := newMockDeps() + result := StartService(deps, nil) + if result.Outcome != OutcomeInvalid { + t.Errorf("nil service should return invalid, got %q", result.Outcome) + } +} + +func TestStart_PreflightEmptyCommand(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "", + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := StartService(deps, svc) + if result.Outcome != OutcomeInvalid { + t.Errorf("empty command should return invalid, got %q", result.Outcome) + } +} + +func TestStart_CrashImmediately(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "exit 1", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + deps.crashOnStart = true + + result := StartService(deps, svc) + if result.Outcome == OutcomeSuccess { + t.Error("crashed process should not return success") + } +} + +func TestStart_MessageFormat(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + svc := &models.ManagedService{ + Name: "api", + CWD: tmpDir, + Command: "echo hi", + Readiness: &models.ReadinessConfig{ + Mode: models.ReadinessProcessOnly, + Timeout: 1, + }, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := StartService(deps, svc) + if result.Outcome == OutcomeSuccess { + if result.Message == "" { + t.Error("success result should have a message") + } + _ = fmt.Sprintf("Message: %s", result.Message) + } +} diff --git a/pkg/lifecycle/stop.go b/pkg/lifecycle/stop.go new file mode 100644 index 0000000..89b27e4 --- /dev/null +++ b/pkg/lifecycle/stop.go @@ -0,0 +1,99 @@ +package lifecycle + +import ( + "fmt" + + "github.com/devports/devpt/pkg/models" +) + +// StopService executes the stop flow: +// resolve → lock → reconcile → verify identity → SIGTERM → wait → SIGKILL if needed → confirm gone → clear metadata → release. +func StopService(deps Deps, svc *models.ManagedService) Result { + if deps == nil || svc == nil { + return Result{Outcome: OutcomeInvalid, Message: "invalid: nil dependencies or service"} + } + + // Acquire lock + if err := deps.AcquireLock(svc.Name); err != nil { + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: another operation is already in progress for %q. Retry after it completes.", svc.Name), + } + } + defer deps.ReleaseLock(svc.Name) + + // Scan live processes + processes, err := deps.ScanProcesses() + if err != nil { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: could not scan live processes for %q: %v", svc.Name, err), + } + } + + allServices := deps.ListServices() + + // Reconcile + reconciled := ReconcileWithResolver(svc, processes, allServices, deps.ResolveProjectRoot) + + switch reconciled.Status { + case string(models.StatusStopped): + return Result{ + Outcome: OutcomeNoop, + Message: fmt.Sprintf("No-op: %q is already stopped.", svc.Name), + } + case string(models.StatusUnknown): + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: PID cannot be proven to belong to %q; refusing to kill.", svc.Name), + } + case string(models.StatusCrashed): + // Stale metadata — clear it + _ = deps.ClearServicePID(svc.Name) + return Result{ + Outcome: OutcomeNoop, + Message: fmt.Sprintf("No-op: stale PID was cleared for %q.", svc.Name), + } + case string(models.StatusRunning): + if !reconciled.Verified || reconciled.Process == nil { + return Result{ + Outcome: OutcomeBlocked, + Message: fmt.Sprintf("Blocked: PID cannot be proven to belong to %q; refusing to kill.", svc.Name), + } + } + // Proceed to stop + default: + return Result{ + Outcome: OutcomeInvalid, + Message: fmt.Sprintf("Invalid: %q has unrecognized status %q.", svc.Name, reconciled.Status), + } + } + + // We have a verified process — stop it + pid := reconciled.Process.PID + if err := deps.StopProcess(pid); err != nil { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: PID %d did not exit after SIGTERM and SIGKILL. Sudo may be required.", pid), + PID: pid, + } + } + + // Confirm process is gone + if deps.IsRunning(pid) { + return Result{ + Outcome: OutcomeFailed, + Message: fmt.Sprintf("Failed: PID %d did not exit after SIGTERM and SIGKILL. Sudo may be required.", pid), + PID: pid, + } + } + + // Clear confirmed run metadata (C6: only after confirmed gone) + _ = deps.ClearServicePID(svc.Name) + + return Result{ + Outcome: OutcomeSuccess, + Message: fmt.Sprintf("Success: stopped %q (PID %d).", svc.Name, pid), + PID: pid, + } +} diff --git a/pkg/lifecycle/stop_test.go b/pkg/lifecycle/stop_test.go new file mode 100644 index 0000000..323b2ef --- /dev/null +++ b/pkg/lifecycle/stop_test.go @@ -0,0 +1,200 @@ +package lifecycle + +import ( + "fmt" + "testing" + "time" + + "github.com/devports/devpt/pkg/models" +) + +func TestStop_VerifiedRunning(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{Name: "api", CWD: "/project"} + proc := &models.ProcessRecord{PID: 1234, CWD: "/project", Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + result := StopService(deps, svc) + if result.Outcome != OutcomeSuccess { + t.Errorf("verified running should return success, got %q: %s", result.Outcome, result.Message) + } + if result.PID != 1234 { + t.Errorf("success should include stopped PID, got %d", result.PID) + } +} + +func TestStop_AlreadyStopped(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{Name: "api", CWD: "/project"} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := StopService(deps, svc) + if result.Outcome != OutcomeNoop { + t.Errorf("already stopped should return noop, got %q", result.Outcome) + } +} + +func TestStop_AmbiguousIdentity(t *testing.T) { + t.Parallel() + + svc1 := &models.ManagedService{Name: "api", CWD: "/shared"} + svc2 := &models.ManagedService{Name: "worker", CWD: "/shared"} + proc := &models.ProcessRecord{PID: 1234, CWD: "/shared", Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc1 + deps.services["worker"] = svc2 + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + result := StopService(deps, svc1) + if result.Outcome != OutcomeBlocked { + t.Errorf("ambiguous identity should return blocked, got %q", result.Outcome) + } +} + +func TestStop_StaleMetadata(t *testing.T) { + t.Parallel() + + pid := 9999 + svc := &models.ManagedService{ + Name: "api", + CWD: "/project", + LastPID: &pid, + } + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + + result := StopService(deps, svc) + if result.Outcome != OutcomeNoop { + t.Errorf("stale metadata should return noop, got %q", result.Outcome) + } +} + +func TestStop_SigkillFailure(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{Name: "api", CWD: "/project"} + proc := &models.ProcessRecord{PID: 1234, CWD: "/project", Port: 3000} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + deps.stopErr = fmt.Errorf("process still alive") + + result := StopService(deps, svc) + if result.Outcome == OutcomeSuccess { + t.Error("SIGKILL failure should not return success") + } +} + +func TestStop_LockContention(t *testing.T) { + t.Parallel() + + svc := &models.ManagedService{Name: "api", CWD: "/project"} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{} + deps.locked["api"] = true + + result := StopService(deps, svc) + if result.Outcome != OutcomeBlocked { + t.Errorf("lock contention should return blocked, got %q", result.Outcome) + } +} + +func TestStop_NilDeps(t *testing.T) { + t.Parallel() + + result := StopService(nil, &models.ManagedService{Name: "api"}) + if result.Outcome != OutcomeInvalid { + t.Errorf("nil deps should return invalid, got %q", result.Outcome) + } +} + +func TestStop_NilService(t *testing.T) { + t.Parallel() + + deps := newMockDeps() + result := StopService(deps, nil) + if result.Outcome != OutcomeInvalid { + t.Errorf("nil service should return invalid, got %q", result.Outcome) + } +} + +func TestStop_MetadataClearedOnSuccess(t *testing.T) { + t.Parallel() + + pid := 1234 + startTime := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project", + LastPID: &pid, + LastProcessStartTime: &startTime, + } + proc := &models.ProcessRecord{PID: 1234, CWD: "/project", Port: 3000, StartTime: &startTime} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[1234] = true + + result := StopService(deps, svc) + if result.Outcome == OutcomeSuccess { + // Verify PID was cleared + if svc.LastPID != nil { + t.Error("LastPID should be cleared after successful stop") + } + if svc.LastProcessStartTime != nil { + t.Error("LastProcessStartTime should be cleared after successful stop") + } + } +} + +func TestStop_PIDStartTimeMismatchBlocked(t *testing.T) { + t.Parallel() + + pid := 1234 + storedStart := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC) + actualStart := storedStart.Add(time.Minute) + svc := &models.ManagedService{ + Name: "api", + CWD: "/project", + LastPID: &pid, + LastProcessStartTime: &storedStart, + } + proc := &models.ProcessRecord{PID: pid, CWD: "/project", Port: 3000, StartTime: &actualStart} + + deps := newMockDeps() + deps.services["api"] = svc + deps.processes = []*models.ProcessRecord{proc} + deps.runningPIDs[pid] = true + + result := StopService(deps, svc) + if result.Outcome != OutcomeBlocked { + t.Fatalf("start-time mismatch should block stop, got %q: %s", result.Outcome, result.Message) + } + if !deps.IsRunning(pid) { + t.Fatal("mismatched PID must not be stopped") + } + if svc.LastPID == nil || *svc.LastPID != pid { + t.Fatal("blocked stop should preserve LastPID") + } + if svc.LastProcessStartTime == nil || !svc.LastProcessStartTime.Equal(storedStart) { + t.Fatal("blocked stop should preserve LastProcessStartTime") + } +} diff --git a/pkg/models/config.go b/pkg/models/config.go index 1e102ca..1a12403 100644 --- a/pkg/models/config.go +++ b/pkg/models/config.go @@ -7,9 +7,9 @@ import ( // ConfigPaths provides paths for config and data directories type ConfigPaths struct { - ConfigDir string + ConfigDir string RegistryFile string - LogsDir string + LogsDir string } // GetConfigPaths returns paths for devpt configuration diff --git a/pkg/models/lifecycle.go b/pkg/models/lifecycle.go new file mode 100644 index 0000000..44bd099 --- /dev/null +++ b/pkg/models/lifecycle.go @@ -0,0 +1,33 @@ +package models + +// Additive types for lifecycle support — zero-value defaults preserve backward compatibility + +// ServiceStatus represents the persistent status of a managed service. +type ServiceStatus string + +const ( + StatusRunning ServiceStatus = "running" + StatusStopped ServiceStatus = "stopped" + StatusCrashed ServiceStatus = "crashed" + StatusUnknown ServiceStatus = "unknown" +) + +// ReadinessMode defines how to check if a service is ready. +type ReadinessMode string + +const ( + ReadinessProcessOnly ReadinessMode = "process-only" + ReadinessPortBound ReadinessMode = "port-bound" + ReadinessHTTPHealth ReadinessMode = "http-health" + ReadinessLogSignal ReadinessMode = "log-signal" + ReadinessMultiCheck ReadinessMode = "multi-check" +) + +// ReadinessConfig defines per-service readiness policy. +// Zero-value defaults preserve backward compatibility. +type ReadinessConfig struct { + Mode ReadinessMode + Timeout int // seconds + Endpoint string // for http-health mode + LogPattern string // for log-signal mode +} diff --git a/pkg/models/lifecycle_test.go b/pkg/models/lifecycle_test.go new file mode 100644 index 0000000..b634693 --- /dev/null +++ b/pkg/models/lifecycle_test.go @@ -0,0 +1,165 @@ +package models + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestLifecycleStatusConstants(t *testing.T) { + t.Parallel() + + if StatusRunning == "" { + t.Error("StatusRunning should not be empty") + } + if StatusStopped == "" { + t.Error("StatusStopped should not be empty") + } + if StatusCrashed == "" { + t.Error("StatusCrashed should not be empty") + } + if StatusUnknown == "" { + t.Error("StatusUnknown should not be empty") + } +} + +func TestReadinessModeConstants(t *testing.T) { + t.Parallel() + + if ReadinessProcessOnly == "" { + t.Error("ReadinessProcessOnly should not be empty") + } + if ReadinessPortBound == "" { + t.Error("ReadinessPortBound should not be empty") + } + if ReadinessHTTPHealth == "" { + t.Error("ReadinessHTTPHealth should not be empty") + } + if ReadinessLogSignal == "" { + t.Error("ReadinessLogSignal should not be empty") + } + if ReadinessMultiCheck == "" { + t.Error("ReadinessMultiCheck should not be empty") + } +} + +func TestReadinessConfigZeroValues(t *testing.T) { + t.Parallel() + + var cfg ReadinessConfig + if cfg.Mode != "" { + t.Errorf("zero-value Mode = %q, want empty", cfg.Mode) + } + if cfg.Timeout != 0 { + t.Errorf("zero-value Timeout = %v, want 0", cfg.Timeout) + } + if cfg.Endpoint != "" { + t.Errorf("zero-value Endpoint = %q, want empty", cfg.Endpoint) + } + if cfg.LogPattern != "" { + t.Errorf("zero-value LogPattern = %q, want empty", cfg.LogPattern) + } +} + +func TestManagedServiceReadinessBackwardCompat(t *testing.T) { + t.Parallel() + + svc := &ManagedService{ + Name: "test", + CWD: "/tmp", + Command: "echo hi", + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + } + if svc.Readiness != nil { + t.Error("new ManagedService should have nil Readiness by default") + } +} + +func TestManagedServiceWithReadinessConfig(t *testing.T) { + t.Parallel() + + svc := &ManagedService{ + Name: "api", + CWD: "/app", + Command: "npm start", + Ports: []int{3000}, + Readiness: &ReadinessConfig{ + Mode: ReadinessHTTPHealth, + Timeout: 5, + Endpoint: "http://localhost:3000/health", + }, + } + if svc.Readiness == nil { + t.Fatal("Readiness should not be nil") + } + if svc.Readiness.Mode != ReadinessHTTPHealth { + t.Errorf("Mode = %q, want %q", svc.Readiness.Mode, ReadinessHTTPHealth) + } + if svc.Readiness.Timeout != 5 { + t.Errorf("Timeout = %v, want 5", svc.Readiness.Timeout) + } +} + +func TestManagedServiceProcessStartTimeMetadata(t *testing.T) { + t.Parallel() + + lifecycleStart := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + processStart := lifecycleStart.Add(-2 * time.Second) + pid := 4321 + svc := ManagedService{ + Name: "api", + CWD: "/app", + Command: "npm start", + LastPID: &pid, + LastStart: &lifecycleStart, + LastProcessStartTime: &processStart, + } + + if svc.LastProcessStartTime == nil { + t.Fatal("LastProcessStartTime should be set") + } + if svc.LastStart == nil { + t.Fatal("LastStart should be set") + } + if svc.LastProcessStartTime.Equal(*svc.LastStart) { + t.Fatal("LastProcessStartTime must be distinct from LastStart event time") + } + + data, err := json.Marshal(svc) + if err != nil { + t.Fatalf("marshal ManagedService: %v", err) + } + if !json.Valid(data) { + t.Fatalf("invalid json: %s", data) + } + if got := string(data); !contains(got, `"last_process_start_time"`) { + t.Fatalf("expected last_process_start_time json field, got %s", got) + } +} + +func TestManagedServiceProcessStartTimeOptional(t *testing.T) { + t.Parallel() + + svc := ManagedService{ + Name: "legacy", + CWD: "/app", + Command: "npm start", + } + if svc.LastProcessStartTime != nil { + t.Fatal("LastProcessStartTime should be nil by default for legacy compatibility") + } + + data, err := json.Marshal(svc) + if err != nil { + t.Fatalf("marshal ManagedService: %v", err) + } + if got := string(data); contains(got, "last_process_start_time") { + t.Fatalf("optional LastProcessStartTime should be omitted when nil, got %s", got) + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 07775c1..88bca57 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -44,16 +44,19 @@ type AgentTag struct { // ManagedService represents an explicitly registered server type ManagedService struct { - Name string `json:"name"` - CWD string `json:"cwd"` - Command string `json:"command"` - Ports []int `json:"ports"` - LastPID *int `json:"last_pid,omitempty"` - LastStart *time.Time `json:"last_start,omitempty"` - LastStop *time.Time `json:"last_stop,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + CWD string `json:"cwd"` + Command string `json:"command"` // declared command (what we spawn) + ResolvedCommand string `json:"resolved_command,omitempty"` // actual command after OS resolution (what ps shows) + Ports []int `json:"ports"` + LastPID *int `json:"last_pid,omitempty"` + LastStart *time.Time `json:"last_start,omitempty"` + LastStop *time.Time `json:"last_stop,omitempty"` + LastProcessStartTime *time.Time `json:"last_process_start_time,omitempty"` + Tags []string `json:"tags,omitempty"` + Readiness *ReadinessConfig `json:"readiness,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Registry holds all managed services diff --git a/pkg/process/manager.go b/pkg/process/manager.go index 599f8c0..bed37ed 100644 --- a/pkg/process/manager.go +++ b/pkg/process/manager.go @@ -7,10 +7,10 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strconv" "strings" - "syscall" "time" "github.com/devports/devpt/pkg/models" @@ -24,6 +24,7 @@ type Manager struct { var ErrNoLogs = errors.New("no logs available") var ErrNeedSudo = errors.New("requires sudo to terminate process") var ErrNoProcessLogs = errors.New("no process logs available") +var ErrProcessStartTimeUnavailable = errors.New("process start time unavailable") // NewManager creates a new process manager func NewManager(logsDir string) *Manager { @@ -60,10 +61,8 @@ func (m *Manager) Start(service *models.ManagedService) (int, error) { cmd := exec.Command(argv[0], argv[1:]...) cmd.Dir = service.CWD - // Set up process group to manage all child processes - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setpgid: true, - } + // Set up process group to manage all child processes (platform-specific) + setProcessGroup(cmd) // Redirect output to log file cmd.Stdout = logFile @@ -88,34 +87,33 @@ func (m *Manager) Stop(pid int, timeout time.Duration) error { // First attempt graceful termination. For non-child processes we cannot use Wait(), // so we send signals and poll for liveness. - if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { - return fmt.Errorf("failed to send SIGTERM: %w", err) + if err := terminateProcess(pid); err != nil { + if err := terminateProcessFallback(pid); err != nil { + return fmt.Errorf("failed to send termination signal: %w", err) } } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - if !m.isAlive(pid) { + if !isProcessAlive(pid) { return nil } time.Sleep(120 * time.Millisecond) } // Escalate to hard kill. - if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { - _ = syscall.Kill(pid, syscall.SIGKILL) + if err := killProcess(pid); err != nil { + _ = killProcessFallback(pid) } time.Sleep(200 * time.Millisecond) - if m.isAlive(pid) { + if isProcessAlive(pid) { return ErrNeedSudo } return nil } func (m *Manager) isAlive(pid int) bool { - err := syscall.Kill(pid, syscall.Signal(0)) - if err != nil { + if !isProcessAlive(pid) { return false } if st, stateErr := m.processState(pid); stateErr == nil { @@ -146,6 +144,29 @@ func (m *Manager) IsRunning(pid int) bool { return m.isAlive(pid) } +// GetProcessStartTime returns the OS-reported start time for a live process. +func (m *Manager) GetProcessStartTime(pid int) (time.Time, error) { + if pid <= 0 { + return time.Time{}, fmt.Errorf("invalid pid: %d", pid) + } + if !m.IsRunning(pid) { + return time.Time{}, fmt.Errorf("process %d is not running", pid) + } + return getProcessStartTime(pid) +} + +// GetProcessCommand returns the OS-reported command line for a live process. +// This is the resolved form after OS interpretation (e.g. "bunx vite" -> "node .../vite"). +func (m *Manager) GetProcessCommand(pid int) (string, error) { + if pid <= 0 { + return "", fmt.Errorf("invalid pid: %d", pid) + } + if !m.IsRunning(pid) { + return "", fmt.Errorf("process %d is not running", pid) + } + return getProcessCommand(pid) +} + // createLogFile creates a new log file for a service func (m *Manager) createLogFile(serviceName string) (*os.File, error) { // Create service log directory @@ -161,11 +182,6 @@ func (m *Manager) createLogFile(serviceName string) (*os.File, error) { return os.Create(logPath) } -// GetLogs retrieves recent logs for a service -func (m *Manager) GetLogs(serviceName string, lines int) ([]string, error) { - return m.Tail(serviceName, lines) -} - // LatestLogPath returns the most recent log file path for a service. func (m *Manager) LatestLogPath(serviceName string) (string, error) { serviceLogDir := filepath.Join(m.logsDir, serviceName) @@ -197,29 +213,7 @@ func (m *Manager) Tail(serviceName string, lines int) ([]string, error) { return nil, err } - file, err := os.Open(logPath) - if err != nil { - return nil, fmt.Errorf("failed to open log file: %w", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - buf := make([]byte, 0, 1024*1024) - scanner.Buffer(buf, 1024*1024) - - linesBuf := make([]string, 0, lines) - for scanner.Scan() { - if len(linesBuf) < lines { - linesBuf = append(linesBuf, scanner.Text()) - } else { - copy(linesBuf, linesBuf[1:]) - linesBuf[len(linesBuf)-1] = scanner.Text() - } - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("failed to read log file: %w", err) - } - return linesBuf, nil + return m.tailFile(logPath, lines) } // TailProcess tries to retrieve logs for a non-managed process. @@ -255,31 +249,58 @@ func (m *Manager) TailProcess(pid int, lines int) ([]string, error) { } func (m *Manager) pickProcessLogFile(pid int) (string, bool) { - cmd := exec.Command("lsof", "-nP", "-p", strconv.Itoa(pid), "-Fn") - output, err := cmd.Output() - if err != nil { - return "", false - } - var candidates []string - for _, line := range strings.Split(string(output), "\n") { - if !strings.HasPrefix(line, "n") { - continue - } - path := strings.TrimSpace(strings.TrimPrefix(line, "n")) - if path == "" { - continue + + // On Linux, read /proc//fd/ directly — works without lsof/root + if runtime.GOOS == "linux" { + fdDir := filepath.Join("/proc", strconv.Itoa(pid), "fd") + entries, err := os.ReadDir(fdDir) + if err == nil { + for _, ent := range entries { + link, err := os.Readlink(filepath.Join(fdDir, ent.Name())) + if err != nil { + continue + } + lower := strings.ToLower(link) + if !strings.Contains(lower, ".log") && !strings.Contains(lower, "/log") { + continue + } + fi, statErr := os.Stat(link) + if statErr != nil || fi.IsDir() { + continue + } + candidates = append(candidates, link) + } } - lower := strings.ToLower(path) - if !strings.Contains(lower, ".log") && !strings.Contains(lower, "/log") { - continue + } + + // If no candidates from /proc (or not Linux), try lsof + if len(candidates) == 0 { + cmd := exec.Command("lsof", "-nP", "-p", strconv.Itoa(pid), "-Fn") + output, err := cmd.Output() + if err != nil { + return "", false } - fi, statErr := os.Stat(path) - if statErr != nil || fi.IsDir() { - continue + for _, line := range strings.Split(string(output), "\n") { + if !strings.HasPrefix(line, "n") { + continue + } + path := strings.TrimSpace(strings.TrimPrefix(line, "n")) + if path == "" { + continue + } + lower := strings.ToLower(path) + if !strings.Contains(lower, ".log") && !strings.Contains(lower, "/log") { + continue + } + fi, statErr := os.Stat(path) + if statErr != nil || fi.IsDir() { + continue + } + candidates = append(candidates, path) } - candidates = append(candidates, path) } + if len(candidates) == 0 { return "", false } diff --git a/pkg/process/proc_unix.go b/pkg/process/proc_unix.go new file mode 100644 index 0000000..37766db --- /dev/null +++ b/pkg/process/proc_unix.go @@ -0,0 +1,73 @@ +//go:build !windows + +package process + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + "syscall" + "time" +) + +func setProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } +} + +func terminateProcess(pid int) error { + return syscall.Kill(-pid, syscall.SIGTERM) +} + +func terminateProcessFallback(pid int) error { + return syscall.Kill(pid, syscall.SIGTERM) +} + +func killProcess(pid int) error { + return syscall.Kill(-pid, syscall.SIGKILL) +} + +func killProcessFallback(pid int) error { + return syscall.Kill(pid, syscall.SIGKILL) +} + +func isProcessAlive(pid int) bool { + return syscall.Kill(pid, syscall.Signal(0)) == nil +} + +func getProcessStartTime(pid int) (time.Time, error) { + cmd := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "lstart=") + out, err := cmd.Output() + if err != nil { + return time.Time{}, fmt.Errorf("%w: ps start time for pid %d: %v", ErrProcessStartTimeUnavailable, pid, err) + } + + raw := strings.TrimSpace(string(out)) + if raw == "" { + return time.Time{}, fmt.Errorf("%w: empty ps start time for pid %d", ErrProcessStartTimeUnavailable, pid) + } + + layouts := []string{ + "Mon Jan _2 15:04:05 2006", + "Mon Jan 2 15:04:05 2006", + } + for _, layout := range layouts { + startTime, parseErr := time.ParseInLocation(layout, raw, time.Local) + if parseErr == nil { + return startTime, nil + } + } + + return time.Time{}, fmt.Errorf("%w: cannot parse %q for pid %d", ErrProcessStartTimeUnavailable, raw, pid) +} + +func getProcessCommand(pid int) (string, error) { + cmd := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("ps command for pid %d: %v", pid, err) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/pkg/process/proc_windows.go b/pkg/process/proc_windows.go new file mode 100644 index 0000000..df0337c --- /dev/null +++ b/pkg/process/proc_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package process + +import ( + "os/exec" + "strconv" + "time" +) + +func setProcessGroup(cmd *exec.Cmd) { + // Windows: no special process group setup needed for basic use + // The process will be managed by its PID +} + +func terminateProcess(pid int) error { + return terminateProcessFallback(pid) +} + +func terminateProcessFallback(pid int) error { + // On Windows, use taskkill for graceful termination + return exec.Command("taskkill", "/PID", strconv.Itoa(pid)).Run() +} + +func killProcess(pid int) error { + return killProcessFallback(pid) +} + +func killProcessFallback(pid int) error { + // On Windows, use taskkill /F for forceful termination + return exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)).Run() +} + +func isProcessAlive(pid int) bool { + // Check if process exists using tasklist + err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid)).Run() + return err == nil +} + +func getProcessStartTime(pid int) (time.Time, error) { + return time.Time{}, ErrProcessStartTimeUnavailable +} diff --git a/pkg/process/starttime_test.go b/pkg/process/starttime_test.go new file mode 100644 index 0000000..563ac95 --- /dev/null +++ b/pkg/process/starttime_test.go @@ -0,0 +1,47 @@ +package process + +import ( + "errors" + "os" + "testing" + "time" +) + +func TestGetProcessStartTimeCurrentProcess(t *testing.T) { + t.Parallel() + + m := NewManager(t.TempDir()) + startTime, err := m.GetProcessStartTime(os.Getpid()) + if errors.Is(err, ErrProcessStartTimeUnavailable) { + t.Skipf("process start-time lookup unsupported on this platform: %v", err) + } + if err != nil { + t.Fatalf("GetProcessStartTime current pid: %v", err) + } + if startTime.IsZero() { + t.Fatal("start time should not be zero") + } + if startTime.After(time.Now().Add(1 * time.Second)) { + t.Fatalf("start time %v should not be in the future", startTime) + } + + startTime2, err := m.GetProcessStartTime(os.Getpid()) + if err != nil { + t.Fatalf("GetProcessStartTime second call: %v", err) + } + if !startTime.Equal(startTime2) { + t.Fatalf("start time should be stable, got %v then %v", startTime, startTime2) + } +} + +func TestGetProcessStartTimeInvalidPID(t *testing.T) { + t.Parallel() + + m := NewManager(t.TempDir()) + if _, err := m.GetProcessStartTime(0); err == nil { + t.Fatal("expected invalid PID error") + } + if _, err := m.GetProcessStartTime(-1); err == nil { + t.Fatal("expected invalid PID error") + } +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index e29fd6a..39ca68a 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -30,6 +30,11 @@ func NewRegistry(filePath string) *Registry { } // Load reads the registry from disk +// FilePath returns the registry file path. +func (r *Registry) FilePath() string { + return r.filePath +} + func (r *Registry) Load() error { r.mu.Lock() defer r.mu.Unlock() @@ -61,31 +66,6 @@ func (r *Registry) Load() error { return nil } -// Save writes the registry to disk -func (r *Registry) Save() error { - r.mu.RLock() - defer r.mu.RUnlock() - - // Ensure directory exists - dir := filepath.Dir(r.filePath) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create registry directory: %w", err) - } - - // Marshal to JSON - content, err := json.MarshalIndent(r.data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal registry: %w", err) - } - - // Write file with mode 0644 - if err := os.WriteFile(r.filePath, content, 0644); err != nil { - return fmt.Errorf("failed to write registry file: %w", err) - } - - return nil -} - // AddService registers a new managed service func (r *Registry) AddService(service *models.ManagedService) error { r.mu.Lock() @@ -162,7 +142,28 @@ func (r *Registry) UpdateServicePID(name string, pid int) error { } svc.LastPID = &pid + svc.LastProcessStartTime = nil + now := time.Now() + svc.LastStart = &now + svc.LastStop = nil + svc.UpdatedAt = now + + return r.save() +} + +// UpdateServiceProcessIdentity updates the last confirmed process identity for a service. +func (r *Registry) UpdateServiceProcessIdentity(name string, pid int, processStartTime time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + + svc, exists := r.data.Services[name] + if !exists { + return fmt.Errorf("service %q not found", name) + } + now := time.Now() + svc.LastPID = &pid + svc.LastProcessStartTime = &processStartTime svc.LastStart = &now svc.LastStop = nil svc.UpdatedAt = now @@ -182,11 +183,29 @@ func (r *Registry) ClearServicePID(name string) error { now := time.Now() svc.LastPID = nil + svc.LastProcessStartTime = nil svc.LastStop = &now svc.UpdatedAt = now return r.save() } +// UpdateServiceResolvedCommand records the OS-resolved command for a service. +// This is the actual command visible via ps after the process starts, which may differ +// from the declared command (e.g. "bunx vite" -> "node .../vite"). +func (r *Registry) UpdateServiceResolvedCommand(name, resolvedCommand string) error { + r.mu.Lock() + defer r.mu.Unlock() + + svc, exists := r.data.Services[name] + if !exists { + return fmt.Errorf("service %q not found", name) + } + + svc.ResolvedCommand = resolvedCommand + svc.UpdatedAt = time.Now() + return r.save() +} + // save (internal) writes the registry without taking locks func (r *Registry) save() error { dir := filepath.Dir(r.filePath) diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go new file mode 100644 index 0000000..539a559 --- /dev/null +++ b/pkg/registry/registry_test.go @@ -0,0 +1,111 @@ +package registry + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/devports/devpt/pkg/models" +) + +func TestRegistryUpdateServiceProcessIdentityPersistsPIDAndStartTime(t *testing.T) { + t.Parallel() + + reg := NewRegistry(filepath.Join(t.TempDir(), "registry.json")) + svc := &models.ManagedService{Name: "api", CWD: "/tmp", Command: "sleep 10"} + if err := reg.AddService(svc); err != nil { + t.Fatalf("AddService: %v", err) + } + + processStart := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + if err := reg.UpdateServiceProcessIdentity("api", 1234, processStart); err != nil { + t.Fatalf("UpdateServiceProcessIdentity: %v", err) + } + + got := reg.GetService("api") + if got.LastPID == nil || *got.LastPID != 1234 { + t.Fatalf("LastPID = %#v, want 1234", got.LastPID) + } + if got.LastProcessStartTime == nil || !got.LastProcessStartTime.Equal(processStart) { + t.Fatalf("LastProcessStartTime = %#v, want %v", got.LastProcessStartTime, processStart) + } + if got.LastStart == nil { + t.Fatal("LastStart should still record lifecycle event time") + } + if got.LastStop != nil { + t.Fatalf("LastStop = %#v, want nil", got.LastStop) + } + + raw, err := os.ReadFile(reg.FilePath()) + if err != nil { + t.Fatalf("read registry: %v", err) + } + var persisted models.Registry + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatalf("unmarshal registry: %v", err) + } + persistedSvc := persisted.Services["api"] + if persistedSvc.LastPID == nil || *persistedSvc.LastPID != 1234 { + t.Fatalf("persisted LastPID = %#v, want 1234", persistedSvc.LastPID) + } + if persistedSvc.LastProcessStartTime == nil || !persistedSvc.LastProcessStartTime.Equal(processStart) { + t.Fatalf("persisted LastProcessStartTime = %#v, want %v", persistedSvc.LastProcessStartTime, processStart) + } +} + +func TestRegistryClearServicePIDClearsProcessStartTime(t *testing.T) { + t.Parallel() + + reg := NewRegistry(filepath.Join(t.TempDir(), "registry.json")) + svc := &models.ManagedService{Name: "api", CWD: "/tmp", Command: "sleep 10"} + if err := reg.AddService(svc); err != nil { + t.Fatalf("AddService: %v", err) + } + processStart := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + if err := reg.UpdateServiceProcessIdentity("api", 1234, processStart); err != nil { + t.Fatalf("UpdateServiceProcessIdentity: %v", err) + } + + if err := reg.ClearServicePID("api"); err != nil { + t.Fatalf("ClearServicePID: %v", err) + } + + got := reg.GetService("api") + if got.LastPID != nil { + t.Fatalf("LastPID = %#v, want nil", got.LastPID) + } + if got.LastProcessStartTime != nil { + t.Fatalf("LastProcessStartTime = %#v, want nil", got.LastProcessStartTime) + } + if got.LastStop == nil { + t.Fatal("LastStop should be set") + } +} + +func TestRegistryUpdateServicePIDClearsExistingProcessStartTime(t *testing.T) { + t.Parallel() + + reg := NewRegistry(filepath.Join(t.TempDir(), "registry.json")) + svc := &models.ManagedService{Name: "api", CWD: "/tmp", Command: "sleep 10"} + if err := reg.AddService(svc); err != nil { + t.Fatalf("AddService: %v", err) + } + processStart := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + if err := reg.UpdateServiceProcessIdentity("api", 1234, processStart); err != nil { + t.Fatalf("UpdateServiceProcessIdentity: %v", err) + } + + if err := reg.UpdateServicePID("api", 5678); err != nil { + t.Fatalf("UpdateServicePID: %v", err) + } + + got := reg.GetService("api") + if got.LastPID == nil || *got.LastPID != 5678 { + t.Fatalf("LastPID = %#v, want 5678", got.LastPID) + } + if got.LastProcessStartTime != nil { + t.Fatalf("legacy UpdateServicePID should clear LastProcessStartTime, got %#v", got.LastProcessStartTime) + } +} diff --git a/pkg/resource/memory.go b/pkg/resource/memory.go new file mode 100644 index 0000000..ed359f3 --- /dev/null +++ b/pkg/resource/memory.go @@ -0,0 +1,153 @@ +package resource + +import ( + "os/exec" + "runtime" + "strconv" + "strings" +) + +// Collector provides batch runtime resource metrics for processes. +type Collector struct{} + +// NewCollector creates a new resource Collector. +func NewCollector() *Collector { + return &Collector{} +} + +// CollectMemory fetches RSS memory in KB for each live PID via the Collector. +func (c *Collector) CollectMemory(pids []int) map[int]int64 { + return CollectMemory(pids) +} + +// CollectMemory returns RSS memory in kilobytes for each live PID. +// Uses a single ps invocation for batch efficiency. +// Dead or inaccessible PIDs are silently omitted. +func CollectMemory(pids []int) map[int]int64 { + if len(pids) == 0 { + return nil + } + + result := make(map[int]int64, len(pids)) + + // Build comma-separated PID list for a single ps call + pidStrs := make([]string, 0, len(pids)) + for _, pid := range pids { + if pid > 0 { + pidStrs = append(pidStrs, strconv.Itoa(pid)) + } + } + if len(pidStrs) == 0 { + return result + } + + output, err := psMemoryBatch(pidStrs) + if err != nil { + // Fallback: try individual lookups for each PID + for _, pid := range pids { + if kb := psMemorySingle(pid); kb > 0 { + result[pid] = kb + } + } + return result + } + + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + kb, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + continue + } + result[pid] = kb + } + + return result +} + +// FormatMemory renders kilobytes as a human-readable string (e.g. "128 MB", "2.4 GB"). +func FormatMemory(kb int64) string { + const ( + mb = 1024 + gb = 1024 * 1024 + ) + switch { + case kb >= gb: + return strconv.FormatFloat(float64(kb)/float64(gb), 'f', 1, 64) + " GB" + case kb >= mb: + val := kb / mb + // Show fractional MB for values under 10 MB + if val < 10 { + return strconv.FormatFloat(float64(kb)/float64(mb), 'f', 1, 64) + " MB" + } + return strconv.FormatInt(val, 10) + " MB" + default: + return strconv.FormatInt(kb, 10) + " KB" + } +} + +// MemoryColor returns an ANSI color code for the given memory size in KB. +// Thresholds: +// - gray ("8") for < 50 MB +// - default ("") for 50–200 MB +// - yellow ("11") for 200–500 MB +// - orange ("208") for 500 MB–1 GB +// - red ("9") for > 1 GB +func MemoryColor(kb int64) string { + const ( + mb50 = 50 * 1024 + mb200 = 200 * 1024 + mb500 = 500 * 1024 + gb1 = 1024 * 1024 + ) + switch { + case kb >= gb1: + return "9" // red + case kb >= mb500: + return "208" // orange + case kb >= mb200: + return "11" // yellow + case kb >= mb50: + return "" // default + default: + return "8" // gray + } +} + +func psMemoryBatch(pidStrs []string) ([]byte, error) { + pidArg := strings.Join(pidStrs, ",") + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + // Windows: tasklist /FI "PID eq 123" /FO CSV — batch not practical, fallback + return nil, exec.ErrNotFound + } + cmd = exec.Command("ps", "-p", pidArg, "-o", "pid=,rss=") + return cmd.Output() +} + +func psMemorySingle(pid int) int64 { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + return 0 + } + cmd = exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "rss=") + out, err := cmd.Output() + if err != nil { + return 0 + } + kb, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) + if err != nil { + return 0 + } + return kb +} diff --git a/pkg/resource/memory_test.go b/pkg/resource/memory_test.go new file mode 100644 index 0000000..f2d746d --- /dev/null +++ b/pkg/resource/memory_test.go @@ -0,0 +1,110 @@ +package resource + +import ( + "os" + "testing" +) + +func TestFormatMemory(t *testing.T) { + t.Parallel() + + tests := []struct { + kb int64 + want string + }{ + {0, "0 KB"}, + {512, "512 KB"}, + {1024, "1.0 MB"}, + {5 * 1024, "5.0 MB"}, + {50 * 1024, "50 MB"}, + {128 * 1024, "128 MB"}, + {200 * 1024, "200 MB"}, + {500 * 1024, "500 MB"}, + {1024 * 1024, "1.0 GB"}, + {2560 * 1024, "2.5 GB"}, + } + + for _, tt := range tests { + got := FormatMemory(tt.kb) + if got != tt.want { + t.Errorf("FormatMemory(%d) = %q, want %q", tt.kb, got, tt.want) + } + } +} + +func TestMemoryColor(t *testing.T) { + t.Parallel() + + tests := []struct { + kb int64 + want string + }{ + {0, "8"}, // gray + {10 * 1024, "8"}, // gray (under 50 MB) + {50 * 1024, ""}, // default (50 MB exactly) + {100 * 1024, ""}, // default (under 200 MB) + {200 * 1024, "11"}, // yellow (200 MB exactly) + {300 * 1024, "11"}, // yellow + {500 * 1024, "208"}, // orange (500 MB exactly) + {750 * 1024, "208"}, // orange + {1024 * 1024, "9"}, // red (1 GB exactly) + {5 * 1024 * 1024, "9"}, // red + } + + for _, tt := range tests { + got := MemoryColor(tt.kb) + if got != tt.want { + t.Errorf("MemoryColor(%d) = %q, want %q", tt.kb, got, tt.want) + } + } +} + +func TestCollectMemoryEmpty(t *testing.T) { + t.Parallel() + + result := CollectMemory(nil) + if result != nil { + t.Errorf("CollectMemory(nil) = %v, want nil", result) + } + + result = CollectMemory([]int{}) + if result != nil { + t.Errorf("CollectMemory([]) = %v, want nil", result) + } +} + +func TestCollectMemoryInvalidPIDs(t *testing.T) { + t.Parallel() + + result := CollectMemory([]int{0, -1}) + if len(result) != 0 { + t.Errorf("CollectMemory with invalid PIDs = %v, want empty", result) + } +} + +func TestCollectMemoryCurrentProcess(t *testing.T) { + pid := os.Getpid() + result := CollectMemory([]int{pid}) + if len(result) == 0 { + t.Fatal("CollectMemory should return memory for current process") + } + kb, ok := result[pid] + if !ok { + t.Fatal("CollectMemory should include current PID") + } + if kb <= 0 { + t.Fatalf("memory for current process should be positive, got %d", kb) + } +} + +func TestCollectMemoryBatchMultiple(t *testing.T) { + pid := os.Getpid() + // Use current PID twice — should still work + result := CollectMemory([]int{pid, pid}) + if len(result) == 0 { + t.Fatal("CollectMemory should return results") + } + if _, ok := result[pid]; !ok { + t.Fatal("CollectMemory should include current PID") + } +} diff --git a/pkg/scanner/detector_framework.go b/pkg/scanner/detector_framework.go deleted file mode 100644 index 8580187..0000000 --- a/pkg/scanner/detector_framework.go +++ /dev/null @@ -1,280 +0,0 @@ -package scanner - -import ( -"os" -"os/exec" -"path/filepath" -"strings" -) - -// FrameworkInfo holds detected framework/language information -type FrameworkInfo struct { -Language string // "Node", "Python", "Go", "Ruby", "PHP", "Java", "Rust", etc. -Framework string // "Express", "Django", "Gin", "Rails", "Laravel", etc. -Version string // e.g., "18.12.0", "3.9.1" -PackageJson string // Path to package.json if found -Confidence string // "high", "medium", "low" -} - -// DetectFramework analyzes a process to identify its framework and language -func DetectFramework(pid int, command string, cwd string) *FrameworkInfo { -info := &FrameworkInfo{Confidence: "low"} - -// Try to detect from command line first -cmdLower := strings.ToLower(command) - -// Node.js detection -if strings.Contains(cmdLower, "node") || strings.Contains(cmdLower, "npm") || strings.Contains(cmdLower, "yarn") { -info.Language = "Node.js" -info.Framework = detectNodeFramework(command, cwd) -info.Version = extractNodeVersion(pid) -info.Confidence = "high" -return info -} - -// Python detection -if strings.Contains(cmdLower, "python") { -info.Language = "Python" -info.Framework = detectPythonFramework(command, cwd) -info.Version = extractPythonVersion(pid) -info.Confidence = "high" -return info -} - -// Go detection -if strings.Contains(cmdLower, "go run") { -info.Language = "Go" -info.Framework = "Go (custom)" -info.Version = extractGoVersion() -info.Confidence = "high" -return info -} - -// Ruby detection -if strings.Contains(cmdLower, "ruby") || strings.Contains(cmdLower, "rails") { -info.Language = "Ruby" -info.Framework = detectRubyFramework(command) -info.Version = extractRubyVersion(pid) -info.Confidence = "high" -return info -} - -// Java detection -if strings.Contains(cmdLower, "java") { -info.Language = "Java" -info.Framework = detectJavaFramework(command) -info.Version = extractJavaVersion(pid) -info.Confidence = "medium" -return info -} - -// PHP detection -if strings.Contains(cmdLower, "php") { -info.Language = "PHP" -info.Framework = "PHP" -info.Version = extractPHPVersion(pid) -info.Confidence = "high" -return info -} - -// Rust detection -if strings.Contains(cmdLower, "cargo") { -info.Language = "Rust" -info.Framework = "Rust (custom)" -info.Version = extractRustVersion() -info.Confidence = "high" -return info -} - -// If we couldn't identify, set to unknown -info.Language = "Unknown" -info.Confidence = "low" -return info -} - -func detectNodeFramework(command string, cwd string) string { -cmdLower := strings.ToLower(command) - -// Check for known frameworks in command -if strings.Contains(cmdLower, "express") { -return "Express" -} -if strings.Contains(cmdLower, "next") { -return "Next.js" -} -if strings.Contains(cmdLower, "nuxt") { -return "Nuxt" -} -if strings.Contains(cmdLower, "vue") { -return "Vue" -} -if strings.Contains(cmdLower, "react") { -return "React" -} -if strings.Contains(cmdLower, "gatsby") { -return "Gatsby" -} -if strings.Contains(cmdLower, "vite") { -return "Vite" -} -if strings.Contains(cmdLower, "webpack") { -return "Webpack" -} - -// Check package.json for dependencies -pkgPath := filepath.Join(cwd, "package.json") -if data, err := os.ReadFile(pkgPath); err == nil { -content := string(data) -if strings.Contains(content, "express") { -return "Express" -} -if strings.Contains(content, "next") { -return "Next.js" -} -if strings.Contains(content, "nuxt") { -return "Nuxt" -} -if strings.Contains(content, "fastify") { -return "Fastify" -} -if strings.Contains(content, "koa") { -return "Koa" -} -if strings.Contains(content, "hapi") { -return "Hapi" -} -} - -return "Node.js (generic)" -} - -func detectPythonFramework(command string, cwd string) string { -cmdLower := strings.ToLower(command) - -// Check for known frameworks -if strings.Contains(cmdLower, "django") || strings.Contains(cmdLower, "manage.py") { -return "Django" -} -if strings.Contains(cmdLower, "flask") { -return "Flask" -} -if strings.Contains(cmdLower, "fastapi") { -return "FastAPI" -} -if strings.Contains(cmdLower, "uvicorn") { -return "FastAPI (uvicorn)" -} -if strings.Contains(cmdLower, "gunicorn") { -return "Gunicorn" -} -if strings.Contains(cmdLower, "pyramid") { -return "Pyramid" -} -if strings.Contains(cmdLower, "starlette") { -return "Starlette" -} - -// Check for requirements.txt -if _, err := os.Stat(filepath.Join(cwd, "requirements.txt")); err == nil { -if data, err := os.ReadFile(filepath.Join(cwd, "requirements.txt")); err == nil { -content := string(data) -if strings.Contains(content, "django") { -return "Django" -} -if strings.Contains(content, "flask") { -return "Flask" -} -if strings.Contains(content, "fastapi") { -return "FastAPI" -} -} -} - -return "Python (generic)" -} - -func detectRubyFramework(command string) string { -cmdLower := strings.ToLower(command) - -if strings.Contains(cmdLower, "rails") { -return "Rails" -} -if strings.Contains(cmdLower, "sinatra") { -return "Sinatra" -} -if strings.Contains(cmdLower, "hanami") { -return "Hanami" -} - -return "Ruby (generic)" -} - -func detectJavaFramework(command string) string { -cmdLower := strings.ToLower(command) - -if strings.Contains(cmdLower, "spring") { -return "Spring" -} -if strings.Contains(cmdLower, "quarkus") { -return "Quarkus" -} -if strings.Contains(cmdLower, "micronaut") { -return "Micronaut" -} -if strings.Contains(cmdLower, "dropwizard") { -return "Dropwizard" -} - -return "Java (generic)" -} - -// Version extraction helpers -func extractNodeVersion(pid int) string { -out, _ := exec.Command("node", "--version").Output() -return strings.TrimSpace(string(out)) -} - -func extractPythonVersion(pid int) string { -out, _ := exec.Command("python3", "--version").Output() -if len(out) == 0 { -out, _ = exec.Command("python", "--version").Output() -} -return strings.TrimSpace(string(out)) -} - -func extractGoVersion() string { -out, _ := exec.Command("go", "version").Output() -parts := strings.Fields(string(out)) -if len(parts) >= 3 { -return parts[2] -} -return "" -} - -func extractRubyVersion(pid int) string { -out, _ := exec.Command("ruby", "--version").Output() -parts := strings.Fields(string(out)) -if len(parts) > 0 { -return parts[1] -} -return "" -} - -func extractJavaVersion(pid int) string { -out, _ := exec.Command("java", "-version").CombinedOutput() -return strings.TrimSpace(string(out)) -} - -func extractPHPVersion(pid int) string { -out, _ := exec.Command("php", "--version").Output() -parts := strings.Fields(string(out)) -if len(parts) > 0 { -return parts[1] -} -return "" -} - -func extractRustVersion() string { -out, _ := exec.Command("rustc", "--version").Output() -return strings.TrimSpace(string(out)) -} diff --git a/pkg/scanner/filter.go b/pkg/scanner/filter.go index b32ce96..20183c7 100644 --- a/pkg/scanner/filter.go +++ b/pkg/scanner/filter.go @@ -67,6 +67,7 @@ func IsDevProcess(record *models.ProcessRecord, commandInfo string) bool { "pytest", "jest", "vitest", + "cloudflared", // Cloudflare tunnel for dev exposure } for _, pattern := range devPatterns { @@ -78,8 +79,9 @@ func IsDevProcess(record *models.ProcessRecord, commandInfo string) bool { return false } -// FilterDevProcesses keeps only development-related processes -func FilterDevProcesses(records []*models.ProcessRecord, commandMap map[int]string) []*models.ProcessRecord { +// FilterDevProcesses keeps only development-related processes. +// Processes with PIDs in managedPIDs are always kept (they belong to managed services). +func FilterDevProcesses(records []*models.ProcessRecord, commandMap map[int]string, managedPIDs map[int]bool) []*models.ProcessRecord { filtered := make([]*models.ProcessRecord, 0) for _, record := range records { @@ -87,6 +89,12 @@ func FilterDevProcesses(records []*models.ProcessRecord, commandMap map[int]stri continue } + // Always keep processes that belong to managed services + if managedPIDs[record.PID] { + filtered = append(filtered, record) + continue + } + cmd := commandMap[record.PID] if IsDevProcess(record, cmd) { filtered = append(filtered, record) diff --git a/pkg/scanner/scanner.go b/pkg/scanner/scanner.go index 7f7fdff..fb27f2d 100644 --- a/pkg/scanner/scanner.go +++ b/pkg/scanner/scanner.go @@ -4,119 +4,340 @@ import ( "bufio" "context" "fmt" + "os" "os/exec" + "path/filepath" + "runtime" "strconv" "strings" "sync" "time" -"github.com/devports/devpt/pkg/models" + "github.com/devports/devpt/pkg/models" ) +// PrereqError is returned when required external tools are missing. +type PrereqError struct { + Missing []string + Hint string +} + +func (e *PrereqError) Error() string { + var sb strings.Builder + fmt.Fprintf(&sb, "missing required tool(s): %s\n", strings.Join(e.Missing, ", ")) + if e.Hint != "" { + sb.WriteString(e.Hint) + } + return sb.String() +} + +// CheckPrereqs verifies that all required external tools are available. +// Returns nil if everything is present, or a PrereqError with install hints. +// On Linux, /proc/net/tcp is accepted as an alternative to lsof. +func CheckPrereqs() error { + missing := make([]string, 0, 2) + + if _, err := exec.LookPath("lsof"); err != nil { + // On Linux, /proc/net/tcp can replace lsof for port scanning + if runtime.GOOS != "linux" || !procNetTCPAvailable() { + missing = append(missing, "lsof") + } + } + + if len(missing) == 0 { + return nil + } + + hint := prereqHint(missing) + return &PrereqError{Missing: missing, Hint: hint} +} + +func procNetTCPAvailable() bool { + _, err := os.Stat("/proc/net/tcp") + return err == nil +} + +func prereqHint(missing []string) string { + switch runtime.GOOS { + case "linux": + var sb strings.Builder + fmt.Fprintln(&sb, "") + fmt.Fprintln(&sb, "Install with:") + // Debian/Ubuntu + fmt.Fprintln(&sb, " sudo apt install lsof") + // Fedora/RHEL + fmt.Fprintln(&sb, " # or: sudo dnf install lsof") + // Arch + fmt.Fprintln(&sb, " # or: sudo pacman -S lsof") + fmt.Fprintln(&sb, "") + fmt.Fprintln(&sb, "devpt uses lsof to discover listening ports and match them to your services.") + return sb.String() + case "darwin": + return "\nlsof should be pre-installed on macOS. If missing, reinstall Xcode Command Line Tools:\n xcode-select --install\n" + default: + return fmt.Sprintf("\nPlease install %s and ensure it is in your PATH.\n", strings.Join(missing, " and ")) + } +} + // ProcessScanner discovers listening ports using macOS tools type ProcessScanner struct { -cwdCache map[int]string -mu sync.RWMutex + cwdCache map[int]string + mu sync.RWMutex } // NewProcessScanner creates a new scanner instance func NewProcessScanner() *ProcessScanner { -return &ProcessScanner{ -cwdCache: make(map[int]string), -} + return &ProcessScanner{ + cwdCache: make(map[int]string), + } } -// ScanListeningPorts discovers all TCP listening ports +// ScanListeningPorts discovers all TCP listening ports. +// Uses lsof first; on Linux falls back to /proc/net/tcp if lsof is unavailable or fails. func (ps *ProcessScanner) ScanListeningPorts() ([]*models.ProcessRecord, error) { -cmd := exec.Command("lsof", "-nP", "-iTCP", "-sTCP:LISTEN") -output, err := cmd.Output() -if err != nil { -return nil, fmt.Errorf("failed to run lsof: %w", err) + // Try lsof first (works on macOS and Linux with root) + if _, err := exec.LookPath("lsof"); err == nil { + cmd := exec.Command("lsof", "-nP", "-iTCP", "-sTCP:LISTEN") + output, err := cmd.Output() + if err == nil { + records, parseErr := ps.parseLsofOutput(string(output)) + if parseErr == nil { + ps.enrichWithCommands(records) + return records, nil + } + // parse failed but we got output — return what we have + if len(records) > 0 { + ps.enrichWithCommands(records) + return records, nil + } + } + // lsof failed — fall through to /proc on Linux + } + + if runtime.GOOS == "linux" { + records, err := ps.scanListeningPortsProc() + if err != nil { + return nil, fmt.Errorf("lsof failed and /proc/net/tcp fallback failed: %w", err) + } + return records, nil + } + + return nil, fmt.Errorf("failed to run lsof") } -records, err := ps.parseLsofOutput(string(output)) -if err != nil { -return records, err +// scanListeningPortsProc reads /proc/net/tcp (and tcp6) to find LISTEN sockets. +// Works without root for all users on Linux. +func (ps *ProcessScanner) scanListeningPortsProc() ([]*models.ProcessRecord, error) { + inodeMap, err := buildInodeToPID() + if err != nil { + // Non-fatal: we'll have ports but no PIDs + inodeMap = make(map[uint64]int) + } + + records := make([]*models.ProcessRecord, 0) + seen := make(map[string]bool) + + for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { + file, err := os.Open(path) + if err != nil { + continue + } + scanner := bufio.NewScanner(file) + scanner.Scan() // skip header + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 10 { + continue + } + + // State 0A = LISTEN + if fields[3] != "0A" { + continue + } + + addrPort := strings.Split(fields[1], ":") + if len(addrPort) != 2 { + continue + } + + port, err := strconv.ParseInt(addrPort[1], 16, 32) + if err != nil || port == 0 { + continue + } + + inode, _ := strconv.ParseUint(fields[9], 10, 64) + + pid := 0 + command := "" + if inode > 0 { + if p, ok := inodeMap[inode]; ok { + pid = p + command = getProcCommand(p) + } + } + + key := fmt.Sprintf("%d:%d", pid, port) + if !seen[key] { + seen[key] = true + records = append(records, &models.ProcessRecord{ + PID: pid, + Port: int(port), + Command: command, + Protocol: "tcp", + }) + } + } + file.Close() + } + + // Enrich with CWD where possible + ps.enrichWithCommands(records) + return records, nil } -// Enrich records with command information -ps.enrichWithCommands(records) -return records, nil +// buildInodeToPID scans /proc//fd/ to map socket inodes to PIDs. +// Only works for processes owned by the current user. +func buildInodeToPID() (map[uint64]int, error) { + result := make(map[uint64]int) + + procDir, err := os.Open("/proc") + if err != nil { + return nil, err + } + defer procDir.Close() + + entries, err := procDir.Readdirnames(-1) + if err != nil { + return nil, err + } + + for _, name := range entries { + pid, err := strconv.Atoi(name) + if err != nil { + continue + } + + fdDir := filepath.Join("/proc", name, "fd") + fdEntries, err := os.ReadDir(fdDir) + if err != nil { + // Permission denied for other users' processes — skip silently + continue + } + + for _, fd := range fdEntries { + link, err := os.Readlink(filepath.Join(fdDir, fd.Name())) + if err != nil { + continue + } + // Socket links look like: socket:[12345] + if !strings.HasPrefix(link, "socket:[") { + continue + } + inodeStr := strings.TrimSuffix(strings.TrimPrefix(link, "socket:["), "]") + inode, err := strconv.ParseUint(inodeStr, 10, 64) + if err != nil { + continue + } + result[inode] = pid + } + } + + return result, nil +} + +// getProcCommand reads /proc//cmdline to get the process command. +func getProcCommand(pid int) string { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) + if err != nil { + return "" + } + // cmdline is null-byte separated + parts := strings.Split(string(data), "\x00") + if len(parts) == 0 || parts[0] == "" { + return "" + } + return parts[0] } // parseLsofOutput parses lsof output into ProcessRecords func (ps *ProcessScanner) parseLsofOutput(output string) ([]*models.ProcessRecord, error) { -scanner := bufio.NewScanner(strings.NewReader(output)) -records := make([]*models.ProcessRecord, 0) -seen := make(map[string]bool) + scanner := bufio.NewScanner(strings.NewReader(output)) + records := make([]*models.ProcessRecord, 0) + seen := make(map[string]bool) -// Skip header -if !scanner.Scan() { -return records, nil -} + // Skip header + if !scanner.Scan() { + return records, nil + } -for scanner.Scan() { -line := scanner.Text() -record, err := ps.parseLsofLine(line) -if err != nil { -continue -} + for scanner.Scan() { + line := scanner.Text() + record, err := ps.parseLsofLine(line) + if err != nil { + continue + } -if record != nil { -key := fmt.Sprintf("%d:%d", record.PID, record.Port) -if !seen[key] { -seen[key] = true -records = append(records, record) -} -} -} + if record != nil { + key := fmt.Sprintf("%d:%d", record.PID, record.Port) + if !seen[key] { + seen[key] = true + records = append(records, record) + } + } + } -return records, nil + return records, nil } // parseLsofLine parses a single lsof output line func (ps *ProcessScanner) parseLsofLine(line string) (*models.ProcessRecord, error) { -fields := strings.Fields(line) -if len(fields) < 9 { -return nil, fmt.Errorf("insufficient fields") -} + fields := strings.Fields(line) + if len(fields) < 9 { + return nil, fmt.Errorf("insufficient fields") + } -pidStr := fields[1] -nameField := fields[8] + command := fields[0] + pidStr := fields[1] + nameField := fields[8] -pid, err := strconv.Atoi(pidStr) -if err != nil { -return nil, fmt.Errorf("invalid pid") -} + pid, err := strconv.Atoi(pidStr) + if err != nil { + return nil, fmt.Errorf("invalid pid") + } -port, err := extractPort(nameField) -if err != nil { -return nil, fmt.Errorf("no port") -} + port, err := extractPort(nameField) + if err != nil { + return nil, fmt.Errorf("no port") + } -return &models.ProcessRecord{ -PID: pid, -Port: port, -Command: "", // Will be enriched later -CWD: "", // Skip for now - was causing hangs -Protocol: "tcp", -}, nil + return &models.ProcessRecord{ + PID: pid, + Port: port, + Command: command, // Preserve lsof command name as fallback if ps lookup fails + CWD: "", // Skip for now - was causing hangs + Protocol: "tcp", + }, nil } // extractPort extracts port from NAME field func extractPort(name string) (int, error) { -parts := strings.Split(name, ":") -if len(parts) < 2 { -return 0, fmt.Errorf("no port") -} + parts := strings.Split(name, ":") + if len(parts) < 2 { + return 0, fmt.Errorf("no port") + } -portStr := parts[len(parts)-1] -port, err := strconv.Atoi(portStr) -if err != nil { -return 0, fmt.Errorf("invalid port") -} + portStr := parts[len(parts)-1] + port, err := strconv.Atoi(portStr) + if err != nil { + return 0, fmt.Errorf("invalid port") + } -return port, nil + return port, nil } // enrichWithCommands fetches command information for each PID @@ -129,7 +350,9 @@ func (ps *ProcessScanner) enrichWithCommands(records []*models.ProcessRecord) { cmd := exec.Command("ps", "-p", fmt.Sprintf("%d", record.PID), "-o", "command=") output, err := cmd.Output() if err == nil { - record.Command = strings.TrimSpace(string(output)) + if fullCmd := strings.TrimSpace(string(output)); fullCmd != "" { + record.Command = fullCmd + } } if record.CWD == "" { @@ -151,6 +374,17 @@ func (ps *ProcessScanner) getCWD(pid int) (string, bool) { } ps.mu.RUnlock() + // On Linux, read /proc//cwd symlink directly — no lsof needed + if runtime.GOOS == "linux" { + link, err := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "cwd")) + if err == nil && link != "" { + ps.mu.Lock() + ps.cwdCache[pid] = link + ps.mu.Unlock() + return link, true + } + } + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) defer cancel() @@ -180,8 +414,3 @@ func (ps *ProcessScanner) getCWD(pid int) (string, bool) { } return cwd, true } - -// DetectFrameworkInfo detects the framework and language of a process -func (ps *ProcessScanner) DetectFrameworkInfo(pid int, command string, cwd string) *FrameworkInfo { - return DetectFramework(pid, command, cwd) -} diff --git a/pkg/scanner/scanner_test.go b/pkg/scanner/scanner_test.go new file mode 100644 index 0000000..4114508 --- /dev/null +++ b/pkg/scanner/scanner_test.go @@ -0,0 +1,21 @@ +package scanner + +import "testing" + +func TestParseLsofLine_PreservesCommandFallback(t *testing.T) { + ps := NewProcessScanner() + + record, err := ps.parseLsofLine("node 12345 kirby 22u IPv4 0x1234567890 0t0 TCP *:5173 (LISTEN)") + if err != nil { + t.Fatalf("parseLsofLine returned error: %v", err) + } + if record == nil { + t.Fatal("expected record") + } + if record.Command != "node" { + t.Fatalf("expected command fallback %q, got %q", "node", record.Command) + } + if record.Port != 5173 { + t.Fatalf("expected port 5173, got %d", record.Port) + } +} diff --git a/plan-fixes.md b/plan-fixes.md new file mode 100644 index 0000000..a42ba6d --- /dev/null +++ b/plan-fixes.md @@ -0,0 +1,116 @@ +Found it. Now I can answer all three questions. + +--- + +## Question 2: Why "invalid pid: 0" in TUI but CLI works + +Two completely different code paths: + +**CLI `devpt stop pmdt-fe`** → `lifecycleManager().Stop(svc)` → reconciles live state → finds the running PID → stops it → works. + +**TUI Ctrl+E on managed list** → `prepareManagedStopConfirm()` → reads `svc.LastPID` from registry → if nil/0, opens confirm modal with **no PID** → `executeConfirm` calls `m.app.StopProcess(c.pid, 5*time.Second)` → `processManager.Stop(0, ...)` → `if pid <= 0 { return "invalid pid: 0" }`. + +The TUI stop path for managed services **bypasses lifecycle entirely**. It reads the registry PID directly and passes it raw to the process manager. No reconciliation. If the registry has no `LastPID` (cleared after a previous stop, or never set for a discovered process), the TUI sends PID 0 to the process manager. + +**This is an arch issue:** the TUI has its own stop/restart logic that doesn't use the lifecycle layer. Two behaviors, one product. + +--- + +## Question 1: What hotfix now + +Three hotfixes, ranked by urgency: + +| # | Fix | Why now | +|---|-----|---------| +| H1 | TUI managed stop must use lifecycle, not raw PID | "invalid pid: 0" is a broken user action | +| H2 | `isAmbiguous` — skip processes on undeclared ports | Blocks legitimate starts on shared-CWD projects | +| H3 | Restart port preflight (already done) | Prevents silent success on wrong port | + +H2 and H3 are done. H1 is the remaining hotfix. + +## Question 3: The plan + +Here's the full plan with must-haves vs postponable. + +--- + +### Phase 0 — Hotfixes ✅ DONE + +All three hotfixes shipped: +- H1: TUI managed stop uses lifecycle (commit 4db59e0) +- H2: isAmbiguous skips processes on undeclared ports (commit 60bc8a8) +- H3: Restart port preflight (commit 60bc8a8) + +--- + +### Phase 1 — Identity foundation ✅ DONE + +**1.1 Record resolved command at spawn time** ✅ (commit 60bc8a8) + +**1.2 Promote port to primary identity signal for services with declared ports** ✅ (commit 60bc8a8) + +**1.3 Redefine ambiguity as "conflict"** ✅ (commit 60bc8a8) + +**1.4 Update PROCESS_MANAGEMENT.md** ✅ (commit 96cc2f7) + +--- + +### Phase 2 — Related processes (must-have, the grouping model) + +**2.1 Discover related processes** + +During scan/reconcile, after identifying primaries, find processes that share CWD+resolved-command with a managed service but are on a different port. Tag them as "related" in the service record. + +**Business value:** Orphans and duplicate instances become visible. No more invisible processes running under the radar. + +**2.2 Display related processes in TUI** + +Collapsed row with `+N` badge. Expandable with Enter or `e`. Related rows show port, PID, and "(related)" status. + +**Business value:** Operators see the full picture without clutter. Clean services look clean; noisy services show the noise. + +**Investigation needed:** How expand/collapse works in the Bubble Tea grid. The managed list is currently a flat table. Expansion means inserting sub-rows that belong to a parent row. This affects selection logic, scrolling, and key handling. Need to prototype before committing to an approach. + +**2.3 Display related processes in CLI** + +`devpt ls` shows `+N` in a new column. `devpt ls --related` shows expanded. `devpt status ` always shows the full group. + +**Business value:** CLI parity with TUI. + +**2.4 Prune command** + +`devpt prune` — stops related processes for all services. `devpt prune ` — stops related for one service. Primary is untouched. + +**Business value:** One-command orphan cleanup. The operator doesn't need to find and kill PIDs manually. + +--- + +### Phase 3 — Polish (postponable) + +| Item | Business value | Why postponable | +|------|---------------|-----------------| +| `devpt stop --related` | Batch cleanup with primary | `prune` covers the common case | +| Related count in batch summary | Visibility during batch ops | Phase 2 display covers single-service view | +| Configurable identity signals per service | Flexibility for edge cases | Default strategy covers 99% of cases | +| `resolved_command` migration for existing entries | Backward compatibility | First start of each service teaches it automatically | +| TUI color-coding for related vs primary | Visual clarity | Text labels work initially | + +--- + +### Dependency graph + +``` +H1 (TUI stop fix) ← standalone, ship now +H2 (ambiguity fix) ← already done +H3 (restart preflight) ← already done + +1.1 (resolved command) ← enables 2.1 +1.2 (port-primary identity) ← standalone +1.3 (conflict replaces ambiguity) ← depends on 1.2 +1.4 (doc update) ← depends on 1.2, 1.3 + +2.1 (discover related) ← depends on 1.1, 1.2 +2.2 (TUI expand/collapse) ← depends on 2.1 +2.3 (CLI related display) ← depends on 2.1 +2.4 (prune command) ← depends on 2.1 +``` diff --git a/scripts/set-version.sh b/scripts/set-version.sh new file mode 100755 index 0000000..5d93936 --- /dev/null +++ b/scripts/set-version.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Set version, commit, and create tag +# Usage: ./scripts/set-version.sh 0.2.1 + +set -e + +VERSION_FILE="pkg/buildinfo/version.go" + +if [ -z "$1" ]; then + echo "Usage: $0 " + echo " Example: $0 0.2.1" + exit 1 +fi + +NEW_VERSION="$1" +TAG="v$NEW_VERSION" + +# Validate version format (semver) +if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Invalid version format. Use: X.Y.Z (e.g., 0.2.1)" + exit 1 +fi + +# Check for uncommitted changes +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "❌ You have uncommitted changes. Commit or stash them first." + exit 1 +fi + +# Check if tag already exists +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ Tag $TAG already exists." + echo " Delete it first: git tag -d $TAG && git push --delete origin $TAG" + exit 1 +fi + +# Update version file +sed -i '' "s/const Version = \"[^\"]*\"/const Version = \"$NEW_VERSION\"/" "$VERSION_FILE" + +echo "📝 Updated $VERSION_FILE to $NEW_VERSION" + +# Commit +git add "$VERSION_FILE" +git commit -m "chore: bump version to $NEW_VERSION" + +echo "✅ Committed version bump" + +# Create tag +git tag "$TAG" + +echo "🏷️ Created tag $TAG" +echo "" +echo "Next steps:" +echo " git push && git push origin $TAG"