Skip to content

feat(tui): add project enrollment toggles#610

Open
mgaldamez wants to merge 9 commits into
Gentleman-Programming:mainfrom
mgaldamez:feat/tui-cloud-config-04-enrollment
Open

feat(tui): add project enrollment toggles#610
mgaldamez wants to merge 9 commits into
Gentleman-Programming:mainfrom
mgaldamez:feat/tui-cloud-config-04-enrollment

Conversation

@mgaldamez

@mgaldamez mgaldamez commented Jul 14, 2026

Copy link
Copy Markdown

Summary

Adds the Project Enrollment screen: lists local projects with enrolled/not-enrolled badges, toggles enrollment with space via Store.EnrollProject / UnenrollProject.

Spec

  • REQ-ENROLL-01 (Project enrollment toggles)

What's included

  • fca5aa5 feat(tui): add project enrollment toggles

Design notes

  • Reuses existing Store.EnrollProject and Store.UnenrollProject (the same methods the CLI uses).
  • Empty-state and error surfaces tested.
  • Purely local — no network calls (enroll is just a SQLite INSERT/DELETE, by design).

Test evidence

  • go test ./... passes
  • go vet ./... clean

Merge order

This is PR 4 of 6. Depends on PR 3 (merged first). After PR 3 merges, click "Update branch" on this PR.

Order:

  1. PR 1 — nav + docs (feat(tui): add cloud settings screen and docs #607)
  2. PR 2 — config form + ping + save feat(tui): add cloud config form, ping command, and save flow #608
  3. PR 3 — status view feat(tui): add cloud status view #609
  4. PR 4 (this) — enrollment toggles feat(tui): add project enrollment toggles #610
  5. PR 5 — test fixes test(tui): close coverage gaps and harden env-var tests #611
  6. PR 6 — CLI parity feat(tui): add CLI parity fields to cloud status view #612

Issue link

Part of #577

Label request

Please add type:feature per CONTRIBUTING.md (contributor cannot add labels).

Summary by CodeRabbit

  • New Features

    • Added Cloud sync settings to the terminal interface.
    • Configure and test a cloud server connection with URL validation and connection status feedback.
    • View cloud health, last sync details, pending items, and errors.
    • Enroll or unenroll projects for cloud synchronization.
    • Display whether authentication uses an environment variable or local configuration.
  • Documentation

    • Clarified cloud token precedence and local configuration fallback behavior.

Copilot AI review requested due to automatic review settings July 14, 2026 03:31
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mgaldamez, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3304dca1-d9fb-4af7-a0a1-894759703b22

📥 Commits

Reviewing files that changed from the base of the PR and between fca5aa5 and b129225.

📒 Files selected for processing (1)
  • internal/tui/model.go
📝 Walkthrough

Walkthrough

The PR adds cloud sync settings, configuration persistence, health checks, status and enrollment screens to the TUI. It also updates pending mutation counting, token fallback documentation, and related test coverage.

Changes

Cloud sync functionality

Layer / File(s) Summary
Store sync data contracts
internal/store/store.go, internal/store/store_test.go
Adds DataDir() and replaces per-project non-enrolled mutation counts with a total eligible pending sync count.
Cloud configuration and connectivity
internal/tui/cloud.go, internal/tui/cloud_test.go, docs/engram-cloud/troubleshooting.md
Adds cloud.json loading/saving, environment-over-file token precedence, URL validation, health checks, and documentation for token fallback behavior.
Cloud state and interaction flow
internal/tui/model.go, internal/tui/update.go, internal/tui/*_test.go
Adds cloud screens, asynchronous loaders, dashboard navigation, configuration save/ping handling, and project enrollment toggling.
Cloud screen rendering
internal/tui/view.go, internal/tui/view_test.go
Renders cloud settings, configuration, status, enrollment state, token sources, health, errors, and enrollment badges.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TUI
  participant pingCloudServer
  participant CloudServer
  participant cloud.json
  TUI->>pingCloudServer: Validate URL and send effective token
  pingCloudServer->>CloudServer: GET /health
  CloudServer-->>pingCloudServer: Return HTTP health status
  pingCloudServer-->>TUI: Return ping result
  TUI->>cloud.json: Save reachable or unauthorized server URL
Loading

Possibly related issues

  • Issue 577: Covers the cloud sync settings screens, token precedence, status and enrollment flows, and health-check configuration added here.

Suggested labels: type:feature

Suggested reviewers: copilot, alan-thegentleman

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately highlights the main user-facing change: project enrollment toggles in the TUI.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the TUI “Cloud sync settings” flow (settings menu → config/status/enrollment screens) so users can configure the cloud server URL, view sync status, and enroll/unenroll projects directly from the terminal UI. This integrates with existing store enrollment methods and adds small store helpers to support the status screen.

Changes:

  • Adds new TUI screens: Cloud Settings menu, Cloud Config form (ping + save), Cloud Status, and Project Enrollment toggles.
  • Introduces TUI cloud config persistence helpers (cloud.json server_url write while preserving any existing token) and a health ping helper.
  • Extends store with DataDir() and CountPendingSyncMutations() to support TUI status rendering; updates troubleshooting docs to clarify token fallback behavior.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
internal/tui/view.go Adds view router cases and renders new cloud settings/config/status/enrollment screens.
internal/tui/update.go Adds key handling and message update flows for cloud screens (navigation, ping/save, enrollment toggles).
internal/tui/model.go Adds new screen constants, cloud-specific model fields, and load commands.
internal/tui/cloud.go Implements cloud.json load/save, token source resolution, URL validation, and /health ping.
internal/tui/view_test.go Adds coverage for cloud screen rendering branches and router coverage.
internal/tui/update_test.go Adds navigation + behavior tests for cloud flows (save/ping/enrollment toggles).
internal/tui/model_test.go Adds tests for new screen constants and cloud load commands.
internal/tui/cloud_test.go Adds unit tests for cloud.json behavior, token source precedence, ping behavior, and URL validation.
internal/store/store.go Adds DataDir() and CountPendingSyncMutations() for TUI status/pending counts.
internal/store/store_test.go Adds tests for DataDir() and CountPendingSyncMutations().
docs/engram-cloud/troubleshooting.md Documents intentional cloud.json.token fallback and env precedence.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/tui/view.go
b.WriteString("\n")
}

b.WriteString(helpStyle.Render("\n tab/shift+tab cycle • enter select • esc/q back"))
Comment thread internal/tui/view.go
Comment on lines +248 to +253
if m.CloudStatusServerURL == "" {
b.WriteString(noResultsStyle.Render("Cloud status: not configured"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("\n esc/q back"))
return b.String()
}
Comment thread internal/tui/view.go
Comment on lines +277 to +286
if len(m.CloudEnrollmentItems) == 0 {
b.WriteString(noResultsStyle.Render("No projects found."))
b.WriteString("\n\n")
if m.CloudEnrollmentError != "" {
b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError))
b.WriteString("\n")
}
b.WriteString(helpStyle.Render(" esc back"))
return b.String()
}
Comment thread internal/tui/update.go
Comment on lines +710 to +711
case "enter", " ":
switch m.Cursor {
Comment thread internal/tui/update.go
Comment on lines +909 to +918
m.CloudConfigError = ""
m.CloudConfigPingStatus = ""
validatedURL, err := validateCloudServerURL(m.CloudConfigInput.Value())
if err != nil {
m.CloudConfigError = err.Error()
return m, nil
}
m.CloudConfigTest = testOnly
m.CloudConfigSaving = !testOnly
return m, pingCloudServer(validatedURL, effectiveCloudToken(m.store.DataDir()))
Comment thread internal/tui/cloud.go
return "unreachable", err
}

req, err := http.NewRequest(http.MethodGet, validatedURL+cloudHealthPath, nil)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/store/store_test.go`:
- Around line 1392-1436: Extend TestCountPendingSyncMutations to create a
mutation for a project that is not enrolled, then verify
CountPendingSyncMutations excludes it while still counting the enrolled “engram”
mutations. Keep the existing enrolled-project assertions intact and use the
available session/observation creation helpers to exercise the enrollment
filter.

In `@internal/tui/cloud_test.go`:
- Around line 48-75: Extend the token-source tests with a malformed cloud
configuration case using tokenSourceMessage: write invalid JSON via
writeCloudJSON, clear ENGRAM_CLOUD_TOKEN, and assert the result is
TokenSourceNone, covering the loadCloudConfig error fallback.

In `@internal/tui/cloud.go`:
- Around line 132-154: Update validateCloudServerURL to normalize the parsed
path by removing a trailing slash before returning the canonical URL, so a root
URL such as https://cloud.example.com/ becomes https://cloud.example.com.
Preserve non-root paths and existing query/fragment validation, and ensure
pingCloudServerStatus no longer receives a URL that creates a double slash
before cloudHealthPath.
- Around line 53-67: Update saveCloudConfig to create dataDir with owner-only
permissions (0700) and write cloud.json with owner-only permissions (0600). Also
explicitly tighten permissions on an existing cloudConfigPath before or after
writing, so previously world-readable configuration files containing tokens are
migrated to owner-only access.

In `@internal/tui/update.go`:
- Around line 154-162: Remove the unconditional CloudEnrollmentError reset from
the successful cloudEnrollmentLoadedMsg branch in the update handler, while
retaining item updates and loading-state changes. Ensure the existing
screen-entry reset in handleCloudSettingsKeys remains responsible for clearing
stale errors so enrollment/unenrollment failures survive a successful reload.

In `@internal/tui/view.go`:
- Around line 242-319: Update viewCloudStatus and viewCloudEnrollment to check
CloudStatusLoading and CloudEnrollmentLoading before rendering their empty or
configured content, respectively. While either corresponding flag is true,
render the existing spinner/loading indicator pattern used by the
CloudConfigSaving view (for example, the “Pinging server...” state) and retain
the current content rendering once loading completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9ec4dfbe-fc3c-46b7-9a76-bcd5d5896ad9

📥 Commits

Reviewing files that changed from the base of the PR and between be4b613 and fca5aa5.

📒 Files selected for processing (11)
  • docs/engram-cloud/troubleshooting.md
  • internal/store/store.go
  • internal/store/store_test.go
  • internal/tui/cloud.go
  • internal/tui/cloud_test.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/update.go
  • internal/tui/update_test.go
  • internal/tui/view.go
  • internal/tui/view_test.go

Comment on lines +1392 to +1436
func TestCountPendingSyncMutations(t *testing.T) {
s := newTestStore(t)

count, err := s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations empty: %v", err)
}
if count != 0 {
t.Fatalf("count = %d, want 0", count)
}

if err := s.EnrollProject("engram"); err != nil {
t.Fatalf("enroll: %v", err)
}
if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil {
t.Fatalf("create session: %v", err)
}

count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations after session: %v", err)
}
if count != 1 {
t.Fatalf("count = %d, want 1", count)
}

if _, err := s.AddObservation(AddObservationParams{
SessionID: "s1",
Type: "bugfix",
Title: "one",
Content: "content",
Project: "engram",
Scope: "project",
}); err != nil {
t.Fatalf("add observation: %v", err)
}

count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations after observation: %v", err)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing negative-path coverage for the enrollment exclusion filter.

The test only exercises mutations for the enrolled "engram" project; it never creates a mutation for a project that is not enrolled, so the core new gate (sm.project = '' OR sep.project IS NOT NULL) in CountPendingSyncMutations is never verified to actually exclude non-enrolled projects.

As per path instructions, **/*_test.go should "Verify coverage of happy path, error paths, and edge cases."

🧪 Suggested addition
 	count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
 	if err != nil {
 		t.Fatalf("CountPendingSyncMutations after observation: %v", err)
 	}
 	if count != 2 {
 		t.Fatalf("count = %d, want 2", count)
 	}
+
+	// A mutation for a non-enrolled project must not be counted.
+	if err := s.CreateSession("s2", "other-project", "/tmp/other"); err != nil {
+		t.Fatalf("create session for unenrolled project: %v", err)
+	}
+	count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
+	if err != nil {
+		t.Fatalf("CountPendingSyncMutations after unenrolled mutation: %v", err)
+	}
+	if count != 2 {
+		t.Fatalf("count = %d, want 2 (unenrolled project mutation should be excluded)", count)
+	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestCountPendingSyncMutations(t *testing.T) {
s := newTestStore(t)
count, err := s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations empty: %v", err)
}
if count != 0 {
t.Fatalf("count = %d, want 0", count)
}
if err := s.EnrollProject("engram"); err != nil {
t.Fatalf("enroll: %v", err)
}
if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil {
t.Fatalf("create session: %v", err)
}
count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations after session: %v", err)
}
if count != 1 {
t.Fatalf("count = %d, want 1", count)
}
if _, err := s.AddObservation(AddObservationParams{
SessionID: "s1",
Type: "bugfix",
Title: "one",
Content: "content",
Project: "engram",
Scope: "project",
}); err != nil {
t.Fatalf("add observation: %v", err)
}
count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations after observation: %v", err)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
}
func TestCountPendingSyncMutations(t *testing.T) {
s := newTestStore(t)
count, err := s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations empty: %v", err)
}
if count != 0 {
t.Fatalf("count = %d, want 0", count)
}
if err := s.EnrollProject("engram"); err != nil {
t.Fatalf("enroll: %v", err)
}
if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil {
t.Fatalf("create session: %v", err)
}
count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations after session: %v", err)
}
if count != 1 {
t.Fatalf("count = %d, want 1", count)
}
if _, err := s.AddObservation(AddObservationParams{
SessionID: "s1",
Type: "bugfix",
Title: "one",
Content: "content",
Project: "engram",
Scope: "project",
}); err != nil {
t.Fatalf("add observation: %v", err)
}
count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations after observation: %v", err)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
// A mutation for a non-enrolled project must not be counted.
if err := s.CreateSession("s2", "other-project", "/tmp/other"); err != nil {
t.Fatalf("create session for unenrolled project: %v", err)
}
count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey)
if err != nil {
t.Fatalf("CountPendingSyncMutations after unenrolled mutation: %v", err)
}
if count != 2 {
t.Fatalf("count = %d, want 2 (unenrolled project mutation should be excluded)", count)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/store_test.go` around lines 1392 - 1436, Extend
TestCountPendingSyncMutations to create a mutation for a project that is not
enrolled, then verify CountPendingSyncMutations excludes it while still counting
the enrolled “engram” mutations. Keep the existing enrolled-project assertions
intact and use the available session/observation creation helpers to exercise
the enrollment filter.

Source: Path instructions

Comment on lines +48 to +75
func TestTokenSourceEnvOverridesFile(t *testing.T) {
dir := t.TempDir()
writeCloudJSON(t, dir, `{"token":"file-token"}`)

t.Setenv("ENGRAM_CLOUD_TOKEN", "env-token")
if got := tokenSourceMessage(dir); got != TokenSourceEnv {
t.Fatalf("source = %q, want %q", got, TokenSourceEnv)
}
}

func TestTokenSourceFileFallback(t *testing.T) {
dir := t.TempDir()
writeCloudJSON(t, dir, `{"token":"file-token"}`)

t.Setenv("ENGRAM_CLOUD_TOKEN", "")
if got := tokenSourceMessage(dir); got != TokenSourceFile {
t.Fatalf("source = %q, want %q", got, TokenSourceFile)
}
}

func TestTokenSourceNone(t *testing.T) {
dir := t.TempDir()

t.Setenv("ENGRAM_CLOUD_TOKEN", "")
if got := tokenSourceMessage(dir); got != TokenSourceNone {
t.Fatalf("source = %q, want %q", got, TokenSourceNone)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing edge-case test: malformed cloud.json.

loadCloudConfig returns an error on invalid JSON, which tokenSourceMessage/effectiveCloudToken silently swallow (falling back to TokenSourceNone/""). No test exercises this path. As per path instructions, tests should verify error paths and edge cases.

✅ Suggested test
func TestTokenSourceCorruptJSONFallsBackToNone(t *testing.T) {
	dir := t.TempDir()
	writeCloudJSON(t, dir, `{invalid`)

	t.Setenv("ENGRAM_CLOUD_TOKEN", "")
	if got := tokenSourceMessage(dir); got != TokenSourceNone {
		t.Fatalf("source = %q, want %q", got, TokenSourceNone)
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/cloud_test.go` around lines 48 - 75, Extend the token-source
tests with a malformed cloud configuration case using tokenSourceMessage: write
invalid JSON via writeCloudJSON, clear ENGRAM_CLOUD_TOKEN, and assert the result
is TokenSourceNone, covering the loadCloudConfig error fallback.

Source: Path instructions

Comment thread internal/tui/cloud.go
Comment on lines +53 to +67
func saveCloudConfig(dataDir, serverURL string) error {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return err
}
cc, err := loadCloudConfig(dataDir)
if err != nil {
return err
}
cc.ServerURL = serverURL
b, err := json.MarshalIndent(cc, "", " ")
if err != nil {
return err
}
return os.WriteFile(cloudConfigPath(dataDir), b, 0o644)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

cloud.json is written world-readable, exposing the stored token.

saveCloudConfig persists the config (which may contain token, per the fallback documented in docs/engram-cloud/troubleshooting.md) via os.WriteFile(..., 0o644), and the directory is created with os.MkdirAll(dataDir, 0o755). On multi-user systems this makes the cloud token readable by any local user. Credential files should use owner-only permissions (0600/0700), matching conventions used by tools like AWS CLI and SSH.

Note: since os.WriteFile does not change permissions of an already-existing file, an existing world-readable cloud.json won't be tightened by this fix alone; a first-run migration or explicit os.Chmod may be needed for pre-existing files.

🔒 Proposed fix to restrict permissions
 func saveCloudConfig(dataDir, serverURL string) error {
-	if err := os.MkdirAll(dataDir, 0o755); err != nil {
+	if err := os.MkdirAll(dataDir, 0o700); err != nil {
 		return err
 	}
 	cc, err := loadCloudConfig(dataDir)
 	if err != nil {
 		return err
 	}
 	cc.ServerURL = serverURL
 	b, err := json.MarshalIndent(cc, "", "  ")
 	if err != nil {
 		return err
 	}
-	return os.WriteFile(cloudConfigPath(dataDir), b, 0o644)
+	return os.WriteFile(cloudConfigPath(dataDir), b, 0o600)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func saveCloudConfig(dataDir, serverURL string) error {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return err
}
cc, err := loadCloudConfig(dataDir)
if err != nil {
return err
}
cc.ServerURL = serverURL
b, err := json.MarshalIndent(cc, "", " ")
if err != nil {
return err
}
return os.WriteFile(cloudConfigPath(dataDir), b, 0o644)
}
func saveCloudConfig(dataDir, serverURL string) error {
if err := os.MkdirAll(dataDir, 0o700); err != nil {
return err
}
cc, err := loadCloudConfig(dataDir)
if err != nil {
return err
}
cc.ServerURL = serverURL
b, err := json.MarshalIndent(cc, "", " ")
if err != nil {
return err
}
return os.WriteFile(cloudConfigPath(dataDir), b, 0o600)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/cloud.go` around lines 53 - 67, Update saveCloudConfig to create
dataDir with owner-only permissions (0700) and write cloud.json with owner-only
permissions (0600). Also explicitly tighten permissions on an existing
cloudConfigPath before or after writing, so previously world-readable
configuration files containing tokens are migrated to owner-only access.

Comment thread internal/tui/cloud.go
Comment on lines +132 to +154
func validateCloudServerURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
parsed, err := url.ParseRequestURI(trimmed)
if err != nil {
return "", err
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
if scheme != "http" && scheme != "https" {
return "", fmt.Errorf("scheme must be http or https")
}
if strings.TrimSpace(parsed.Host) == "" || strings.TrimSpace(parsed.Hostname()) == "" {
return "", fmt.Errorf("host is required")
}
if strings.TrimSpace(parsed.RawQuery) != "" {
return "", fmt.Errorf("query is not allowed")
}
if strings.TrimSpace(parsed.Fragment) != "" {
return "", fmt.Errorf("fragment is not allowed")
}
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Trailing slash in serverURL produces a malformed health-check URL.

If the user configures a server URL with a trailing slash (e.g. https://cloud.example.com/), parsed.Path becomes "/", which is not stripped here. pingCloudServerStatus then concatenates cloudHealthPath directly, producing https://cloud.example.com//health — likely a 404/unreachable result even though the server is up. This isn't covered by the existing TestValidateCloudServerURL cases.

🐛 Proposed fix to normalize trailing slash
 	parsed.RawQuery = ""
 	parsed.Fragment = ""
+	parsed.Path = strings.TrimRight(parsed.Path, "/")
 	return parsed.String(), nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func validateCloudServerURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
parsed, err := url.ParseRequestURI(trimmed)
if err != nil {
return "", err
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
if scheme != "http" && scheme != "https" {
return "", fmt.Errorf("scheme must be http or https")
}
if strings.TrimSpace(parsed.Host) == "" || strings.TrimSpace(parsed.Hostname()) == "" {
return "", fmt.Errorf("host is required")
}
if strings.TrimSpace(parsed.RawQuery) != "" {
return "", fmt.Errorf("query is not allowed")
}
if strings.TrimSpace(parsed.Fragment) != "" {
return "", fmt.Errorf("fragment is not allowed")
}
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
func validateCloudServerURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
parsed, err := url.ParseRequestURI(trimmed)
if err != nil {
return "", err
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
if scheme != "http" && scheme != "https" {
return "", fmt.Errorf("scheme must be http or https")
}
if strings.TrimSpace(parsed.Host) == "" || strings.TrimSpace(parsed.Hostname()) == "" {
return "", fmt.Errorf("host is required")
}
if strings.TrimSpace(parsed.RawQuery) != "" {
return "", fmt.Errorf("query is not allowed")
}
if strings.TrimSpace(parsed.Fragment) != "" {
return "", fmt.Errorf("fragment is not allowed")
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
return parsed.String(), nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/cloud.go` around lines 132 - 154, Update validateCloudServerURL
to normalize the parsed path by removing a trailing slash before returning the
canonical URL, so a root URL such as https://cloud.example.com/ becomes
https://cloud.example.com. Preserve non-root paths and existing query/fragment
validation, and ensure pingCloudServerStatus no longer receives a URL that
creates a double slash before cloudHealthPath.

Comment thread internal/tui/update.go
Comment on lines +154 to +162
case cloudEnrollmentLoadedMsg:
m.CloudEnrollmentLoading = false
if msg.err != nil {
m.CloudEnrollmentError = msg.err.Error()
return m, nil
}
m.CloudEnrollmentItems = msg.items
m.CloudEnrollmentError = ""
return m, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Enrollment/unenrollment errors are wiped by the immediately-following successful reload.

In the space-key handler, on EnrollProject/UnenrollProject failure, m.CloudEnrollmentError is set (line 789/793) and then loadCloudEnrollmentCmd(m.store) is unconditionally returned (line 796). If that reload succeeds — plausible whenever the toggle fails for a reason unrelated to store health (e.g. a validation error) — the cloudEnrollmentLoadedMsg success branch (lines 160-161) unconditionally clears CloudEnrollmentError, so the error is visible for at most one render frame before being erased. TestCloudEnrollmentToggleErrorSurfaced only covers the case where the store is closed (so both the toggle and the reload fail), which doesn't exercise this path.

🐛 Proposed fix
 	case cloudEnrollmentLoadedMsg:
 		m.CloudEnrollmentLoading = false
 		if msg.err != nil {
 			m.CloudEnrollmentError = msg.err.Error()
 			return m, nil
 		}
 		m.CloudEnrollmentItems = msg.items
-		m.CloudEnrollmentError = ""
 		return m, nil

This is safe: every screen-entry path (e.g. handleCloudSettingsKeys case 2) already resets CloudEnrollmentError = "" before firing the load command, so this line was only ever clobbering a legitimate toggle error, never providing a needed reset.

Also applies to: 783-797

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/update.go` around lines 154 - 162, Remove the unconditional
CloudEnrollmentError reset from the successful cloudEnrollmentLoadedMsg branch
in the update handler, while retaining item updates and loading-state changes.
Ensure the existing screen-entry reset in handleCloudSettingsKeys remains
responsible for clearing stale errors so enrollment/unenrollment failures
survive a successful reload.

Comment thread internal/tui/view.go
Comment on lines +242 to +319
func (m Model) viewCloudStatus() string {
var b strings.Builder

b.WriteString(headerStyle.Render(" Cloud status"))
b.WriteString("\n\n")

if m.CloudStatusServerURL == "" {
b.WriteString(noResultsStyle.Render("Cloud status: not configured"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("\n esc/q back"))
return b.String()
}

b.WriteString(renderCloudStatusRow("Server URL:", m.CloudStatusServerURL))
b.WriteString(renderCloudStatusRow("Health:", renderCloudHealth(m.CloudStatusHealth)))
b.WriteString(renderCloudStatusRow("Token source:", m.CloudStatusTokenSource))
b.WriteString(renderCloudStatusRow("Last sync:", m.CloudStatusLastSync))
b.WriteString(renderCloudStatusRow("Pending:", fmt.Sprintf("%d", m.CloudStatusPendingCount)))
lastError := m.CloudStatusLastError
if lastError == "" {
lastError = "none"
}
b.WriteString(renderCloudStatusRow("Last error:", lastError))

b.WriteString(helpStyle.Render("\n esc/q back"))

return b.String()
}

func (m Model) viewCloudEnrollment() string {
var b strings.Builder

b.WriteString(headerStyle.Render(" Enroll projects"))
b.WriteString("\n\n")

if len(m.CloudEnrollmentItems) == 0 {
b.WriteString(noResultsStyle.Render("No projects found."))
b.WriteString("\n\n")
if m.CloudEnrollmentError != "" {
b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError))
b.WriteString("\n")
}
b.WriteString(helpStyle.Render(" esc back"))
return b.String()
}

for i, item := range m.CloudEnrollmentItems {
cursor := " "
style := listItemStyle
if i == m.Cursor {
cursor = "▸ "
style = listSelectedStyle
}

badge := "[not enrolled]"
badgeStyle := timestampStyle
if item.enrolled {
badge = "[enrolled]"
badgeStyle = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
}

line := fmt.Sprintf("%s%s %s",
cursor,
style.Render(truncateStr(item.project, 50)),
badgeStyle.Render(badge))
b.WriteString(line)
b.WriteString("\n")
}

if m.CloudEnrollmentError != "" {
b.WriteString("\n")
b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError))
}

b.WriteString(helpStyle.Render("\n j/k navigate • space toggle • esc/q back"))

return b.String()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

CloudStatusLoading/CloudEnrollmentLoading are tracked but never rendered.

Both flags are set to true on screen entry and cleared when the corresponding *LoadedMsg arrives, but viewCloudStatus and viewCloudEnrollment never check them. Until the async load completes, users briefly see the misleading "Cloud status: not configured" / "No projects found." messages instead of a loading indicator — even though the screen for CloudConfigSaving correctly renders a spinner ("Pinging server...") for the analogous state at lines 226-230, showing the pattern was simply missed here.

💡 Proposed fix
 func (m Model) viewCloudStatus() string {
 	var b strings.Builder
 
 	b.WriteString(headerStyle.Render("  Cloud status"))
 	b.WriteString("\n\n")
 
+	if m.CloudStatusLoading {
+		b.WriteString(noResultsStyle.Render("Loading cloud status..."))
+		b.WriteString("\n")
+		b.WriteString(helpStyle.Render("\n  esc/q back"))
+		return b.String()
+	}
+
 	if m.CloudStatusServerURL == "" {
 func (m Model) viewCloudEnrollment() string {
 	var b strings.Builder
 
 	b.WriteString(headerStyle.Render("  Enroll projects"))
 	b.WriteString("\n\n")
 
+	if m.CloudEnrollmentLoading {
+		b.WriteString(noResultsStyle.Render("Loading projects..."))
+		b.WriteString("\n\n")
+		b.WriteString(helpStyle.Render("  esc back"))
+		return b.String()
+	}
+
 	if len(m.CloudEnrollmentItems) == 0 {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (m Model) viewCloudStatus() string {
var b strings.Builder
b.WriteString(headerStyle.Render(" Cloud status"))
b.WriteString("\n\n")
if m.CloudStatusServerURL == "" {
b.WriteString(noResultsStyle.Render("Cloud status: not configured"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("\n esc/q back"))
return b.String()
}
b.WriteString(renderCloudStatusRow("Server URL:", m.CloudStatusServerURL))
b.WriteString(renderCloudStatusRow("Health:", renderCloudHealth(m.CloudStatusHealth)))
b.WriteString(renderCloudStatusRow("Token source:", m.CloudStatusTokenSource))
b.WriteString(renderCloudStatusRow("Last sync:", m.CloudStatusLastSync))
b.WriteString(renderCloudStatusRow("Pending:", fmt.Sprintf("%d", m.CloudStatusPendingCount)))
lastError := m.CloudStatusLastError
if lastError == "" {
lastError = "none"
}
b.WriteString(renderCloudStatusRow("Last error:", lastError))
b.WriteString(helpStyle.Render("\n esc/q back"))
return b.String()
}
func (m Model) viewCloudEnrollment() string {
var b strings.Builder
b.WriteString(headerStyle.Render(" Enroll projects"))
b.WriteString("\n\n")
if len(m.CloudEnrollmentItems) == 0 {
b.WriteString(noResultsStyle.Render("No projects found."))
b.WriteString("\n\n")
if m.CloudEnrollmentError != "" {
b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError))
b.WriteString("\n")
}
b.WriteString(helpStyle.Render(" esc back"))
return b.String()
}
for i, item := range m.CloudEnrollmentItems {
cursor := " "
style := listItemStyle
if i == m.Cursor {
cursor = "▸ "
style = listSelectedStyle
}
badge := "[not enrolled]"
badgeStyle := timestampStyle
if item.enrolled {
badge = "[enrolled]"
badgeStyle = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
}
line := fmt.Sprintf("%s%s %s",
cursor,
style.Render(truncateStr(item.project, 50)),
badgeStyle.Render(badge))
b.WriteString(line)
b.WriteString("\n")
}
if m.CloudEnrollmentError != "" {
b.WriteString("\n")
b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError))
}
b.WriteString(helpStyle.Render("\n j/k navigate • space toggle • esc/q back"))
return b.String()
}
func (m Model) viewCloudStatus() string {
var b strings.Builder
b.WriteString(headerStyle.Render(" Cloud status"))
b.WriteString("\n\n")
if m.CloudStatusLoading {
b.WriteString(noResultsStyle.Render("Loading cloud status..."))
b.WriteString("\n")
b.WriteString(helpStyle.Render("\n esc/q back"))
return b.String()
}
if m.CloudStatusServerURL == "" {
b.WriteString(noResultsStyle.Render("Cloud status: not configured"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("\n esc/q back"))
return b.String()
}
b.WriteString(renderCloudStatusRow("Server URL:", m.CloudStatusServerURL))
b.WriteString(renderCloudStatusRow("Health:", renderCloudHealth(m.CloudStatusHealth)))
b.WriteString(renderCloudStatusRow("Token source:", m.CloudStatusTokenSource))
b.WriteString(renderCloudStatusRow("Last sync:", m.CloudStatusLastSync))
b.WriteString(renderCloudStatusRow("Pending:", fmt.Sprintf("%d", m.CloudStatusPendingCount)))
lastError := m.CloudStatusLastError
if lastError == "" {
lastError = "none"
}
b.WriteString(renderCloudStatusRow("Last error:", lastError))
b.WriteString(helpStyle.Render("\n esc/q back"))
return b.String()
}
func (m Model) viewCloudEnrollment() string {
var b strings.Builder
b.WriteString(headerStyle.Render(" Enroll projects"))
b.WriteString("\n\n")
if m.CloudEnrollmentLoading {
b.WriteString(noResultsStyle.Render("Loading projects..."))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render(" esc back"))
return b.String()
}
if len(m.CloudEnrollmentItems) == 0 {
b.WriteString(noResultsStyle.Render("No projects found."))
b.WriteString("\n\n")
if m.CloudEnrollmentError != "" {
b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError))
b.WriteString("\n")
}
b.WriteString(helpStyle.Render(" esc back"))
return b.String()
}
for i, item := range m.CloudEnrollmentItems {
cursor := " "
style := listItemStyle
if i == m.Cursor {
cursor = "▸ "
style = listSelectedStyle
}
badge := "[not enrolled]"
badgeStyle := timestampStyle
if item.enrolled {
badge = "[enrolled]"
badgeStyle = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
}
line := fmt.Sprintf("%s%s %s",
cursor,
style.Render(truncateStr(item.project, 50)),
badgeStyle.Render(badge))
b.WriteString(line)
b.WriteString("\n")
}
if m.CloudEnrollmentError != "" {
b.WriteString("\n")
b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError))
}
b.WriteString(helpStyle.Render("\n j/k navigate • space toggle • esc/q back"))
return b.String()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/view.go` around lines 242 - 319, Update viewCloudStatus and
viewCloudEnrollment to check CloudStatusLoading and CloudEnrollmentLoading
before rendering their empty or configured content, respectively. While either
corresponding flag is true, render the existing spinner/loading indicator
pattern used by the CloudConfigSaving view (for example, the “Pinging server...”
state) and retain the current content rendering once loading completes.

@mgaldamez

Copy link
Copy Markdown
Author

@coderabbitai re-review please — just added godoc comments to new exported functions (commit 6dde3ce on this branch's base, and b129225 on the enrollment branch). Want to confirm the docstring coverage warning is now resolved. Thanks!

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
git fetch origin pull/610/head:pr610 2>&1 | tail -5
git checkout pr610 2>&1 | tail -5
git log --oneline -10
echo "---- check for commits ----"
git log --oneline | grep -E "6dde3ce|b129225" || echo "not found in log"
echo "---- store.go exported funcs ----"
ast-grep run --pattern 'func (s *Store) $NAME($$$) $$$' --lang go internal/store/store.go
echo "---- view context around DataDir and CountPendingSyncMutations ----"
rg -n -B3 "func \(s \*Store\) DataDir" internal/store/store.go
rg -n -B3 "func \(s \*Store\) CountPendingSyncMutations" internal/store/store.go

Length of output: 150596


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cd /tmp 2>/dev/null || true
cd - >/dev/null

echo "=== cloud.go exported symbols with preceding comment check ==="
awk '
/^func [A-Z]/ || /^func \([a-zA-Z]+ \*?[A-Za-z]+\) [A-Z]/ || /^type [A-Z]/ || /^var [A-Z]/ || /^const [A-Z]/ {
  print NR": "$0
}
' internal/tui/cloud.go

echo "=== check comment line immediately before each ==="
grep -n "^func\|^type\|^var\|^const" internal/tui/cloud.go | grep -E '\b[A-Z]'

Length of output: 822


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check exported func/type/const/var across all files in this PR (comment line before check) ==="
for f in internal/store/store.go internal/tui/cloud.go internal/tui/model.go internal/tui/update.go internal/tui/view.go; do
  echo "--- $f ---"
  awk '
  /^func / || /^type / || /^var / || /^const /{
    line=$0
    # find identifier
    print NR": "line
  }' "$f" | grep -E '(func|type|var|const) (\([^)]*\) )?[A-Z]'
done

Length of output: 9590


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "=== model.go new types/consts context ==="
rg -n -B2 "ScreenCloudSettings|ScreenCloudConfig|ScreenCloudStatus|ScreenCloudEnrollment|CloudConfigLoaded|CloudStatusLoaded|CloudEnrollmentLoaded|CloudPingResult|cloudEnrollmentItem" internal/tui/model.go | head -150

Length of output: 1235


@mgaldamez Confirmed — I re-checked the branch after the latest commits.

  • internal/store/store.go: Store.DataDir() now has // DataDir returns the configured data directory for the store. immediately above it.
  • internal/store/store.go: Store.CountPendingSyncMutations now has a proper godoc comment explaining it returns the count of unacknowledged mutations eligible to sync (global/project-empty or enrolled projects).
  • internal/tui/cloud.go: all new declarations (pingCloudServer, pingCloudTransport, validateCloudServerURL, etc.) are unexported, so they weren't subject to the exported-doc check to begin with.
  • internal/tui/model.go: the new ScreenCloudSettings/Config/Status/Enrollment constants and message types (cloudEnrollmentItem, cloudEnrollmentLoadedMsg, etc.) are also unexported/part of an existing iota block, so no additional godoc is required there.

The docstring coverage warning is resolved for this PR. Nice work — thanks for following up quickly!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants