Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
01d7d78
merge: sync with upstream main (be4b613 — feat(setup): add --protocol…
mgaldamez Jul 14, 2026
8cde2e3
Merge branch 'Gentleman-Programming:main' into main
mgaldamez Jul 20, 2026
600b46d
feat(tui): add cloud config form scaffold and read-only token status
mgaldamez Jul 8, 2026
6ae6818
feat(tui): add ping command with injectable transport
mgaldamez Jul 8, 2026
d0d2cf2
feat(tui): wire ping result to cloud config save flow
mgaldamez Jul 8, 2026
0f8c7e9
docs(tui): add godoc comments to cloud config and config load functions
mgaldamez Jul 14, 2026
2266969
feat(cloudconfig): add internal/cloudconfig package with config IO
Jul 23, 2026
08eec38
feat(cloudconfig): add EffectiveToken with file/env precedence
Jul 23, 2026
85f6f7f
feat(cloudconfig): add ValidateServerURL
Jul 23, 2026
bc7b44e
feat(cloudconfig): add LocalDaemonProbe with timeout var seam
Jul 24, 2026
d77e66d
feat(cloudconfig): add TUI/CLI token source agreement test
Jul 24, 2026
3390d71
refactor(cli): migrate cloud.go:342 to cloudconfig.ValidateServerURL
Jul 24, 2026
b222342
refactor(cli): migrate cloud.go:496 (cloudUpgradeBootstrap) to cloudc…
Jul 24, 2026
d065832
refactor(cli): migrate cmdCloudConfig to cloudconfig (T-608.8)
Jul 24, 2026
7cb61d9
refactor(cli): migrate snapshot writeback to cloudconfig.Path (T-608.9)
Jul 24, 2026
fc3e81a
test(cli): pin cmdCloudStatus nil-vs-zero-value contract (T-608.10)
Jul 24, 2026
72eb3e0
refactor(cli): migrate resolveCloudRuntimeConfig to cloudconfig (T-60…
Jul 24, 2026
a0184cf
test(cli): audit autosync caller of resolveCloudRuntimeConfig (T-608.13)
Jul 24, 2026
a9f0e30
refactor(cli): make cloud_daemon_probe.go a thin wrapper (T-608.14)
Jul 24, 2026
343e620
refactor(cli): port daemon probe test + delete legacy helpers (T-608.15)
Jul 24, 2026
cd6971a
refactor(tui): delete forked cloud config helpers, use cloudconfig (T…
Jul 24, 2026
38f9128
refactor(tui): split local-daemon probe into independent tea.Cmd (T-6…
Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 25 additions & 66 deletions cmd/engram/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"
Expand All @@ -19,6 +17,7 @@ import (
"github.com/Gentleman-Programming/engram/internal/cloud/constants"
"github.com/Gentleman-Programming/engram/internal/cloud/dashboard"
"github.com/Gentleman-Programming/engram/internal/cloud/remote"
"github.com/Gentleman-Programming/engram/internal/cloudconfig"
"github.com/Gentleman-Programming/engram/internal/store"
engramsync "github.com/Gentleman-Programming/engram/internal/sync"
)
Expand Down Expand Up @@ -193,7 +192,7 @@ func backfillAllowedProjectMutationChunks(ctx context.Context, cs *cloudstore.Cl
return nil
}

var runUpgradeBootstrap = func(s *store.Store, project string, cc *cloudConfig) (*engramsync.UpgradeBootstrapResult, error) {
var runUpgradeBootstrap = func(s *store.Store, project string, cc *cloudconfig.Config) (*engramsync.UpgradeBootstrapResult, error) {
transport, err := remote.NewRemoteTransport(cc.ServerURL, cc.Token, project)
if err != nil {
return nil, err
Expand Down Expand Up @@ -339,7 +338,7 @@ func cmdCloudUpgradeDoctor(cfg store.Config) {
cloudConfigured := false
if cc, cfgErr := resolveCloudRuntimeConfig(cfg); cfgErr == nil {
if cc != nil {
if validated, err := validateCloudServerURL(cc.ServerURL); err == nil && strings.TrimSpace(validated) != "" {
if validated, err := cloudconfig.ValidateServerURL(cc.ServerURL); err == nil && strings.TrimSpace(validated) != "" {
cloudConfigured = true
}
}
Expand Down Expand Up @@ -492,7 +491,7 @@ func cmdCloudUpgradeBootstrap(cfg store.Config) {
fatal(fmt.Errorf("cloud upgrade bootstrap requires configured cloud server"))
return
}
validatedURL, err := validateCloudServerURL(cc.ServerURL)
validatedURL, err := cloudconfig.ValidateServerURL(cc.ServerURL)
if err != nil {
fatal(fmt.Errorf("invalid cloud runtime server URL: %w", err))
return
Expand Down Expand Up @@ -546,7 +545,16 @@ func captureUpgradeSnapshotBeforeBootstrap(s *store.Store, cfg store.Config, pro
}

var snapshot store.CloudUpgradeSnapshot
configBytes, err := os.ReadFile(cloudConfigPath(cfg))
// Snapshot the raw bytes of cloud.json verbatim. The
// snapshot is later used to ROLLBACK the upgrade (writing
// snapshot.CloudConfigJSON back to disk). Decoding via
// cloudconfig.Load would lose any field the package's
// Config struct does not declare; raw bytes preserve the
// exact on-disk state. The path is read from
// cloudconfig.Path to align with the rest of the migration,
// but the read call stays os.ReadFile so the bytes are
// preserved unchanged.
configBytes, err := os.ReadFile(cloudconfig.Path(cfg.DataDir))
if err == nil {
snapshot.CloudConfigPresent = true
snapshot.CloudConfigJSON = string(configBytes)
Expand Down Expand Up @@ -631,12 +639,12 @@ func cmdCloudUpgradeRollback(cfg store.Config) {
return
}
if state.Snapshot.CloudConfigPresent {
if err := os.WriteFile(cloudConfigPath(cfg), []byte(state.Snapshot.CloudConfigJSON), 0o644); err != nil {
if err := os.WriteFile(cloudconfig.Path(cfg.DataDir), []byte(state.Snapshot.CloudConfigJSON), 0o644); err != nil {
fatal(err)
return
}
} else {
_ = os.Remove(cloudConfigPath(cfg))
_ = os.Remove(cloudconfig.Path(cfg.DataDir))
}
rolledBack, err := engramsync.RollbackProject(s, engramsync.UpgradeRollbackOptions{Project: project})
if err != nil {
Expand Down Expand Up @@ -667,7 +675,7 @@ func cmdCloudStatus(cfg store.Config) {
fmt.Println("Cloud status: not configured")
return
}
validatedURL, err := validateCloudServerURL(cc.ServerURL)
validatedURL, err := cloudconfig.ValidateServerURL(cc.ServerURL)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid cloud runtime server URL: %v\n", err)
exitFunc(1)
Expand Down Expand Up @@ -768,43 +776,25 @@ func cmdCloudConfig(cfg store.Config) {
fmt.Fprintln(os.Stderr, "error: server URL is required")
exitFunc(1)
}
validatedURL, err := validateCloudServerURL(cc.ServerURL)
validatedURL, err := cloudconfig.ValidateServerURL(cc.ServerURL)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid server URL: %v\n", err)
exitFunc(1)
}
cc.ServerURL = validatedURL
if err := saveCloudConfig(cfg, cc); err != nil {
// The local cloudConfig and cloudconfig.Config have
// identical underlying types (same field names, types, and
// JSON tags), so a Go type conversion preserves the JSON
// schema on disk. The migration replaces saveCloudConfig
// with the package's Save, which adds an os.Chmod
// normalization (per T-608.1's spec: 0o644 on every write).
if err := cloudconfig.Save(cfg.DataDir, (*cloudconfig.Config)(cc)); err != nil {
fatal(err)
return
}
fmt.Printf("✓ Cloud server set to %s\n", cc.ServerURL)
}

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 cmdCloudServe() {
runtimeCfg := cloud.ConfigFromEnv()
if err := validateCloudServeAuthConfig(); err != nil {
Expand Down Expand Up @@ -873,34 +863,3 @@ func normalizeAllowedProjects(projects []string) []string {
}
return normalized
}

func cloudConfigPath(cfg store.Config) string {
return filepath.Join(cfg.DataDir, "cloud.json")
}

func loadCloudConfig(cfg store.Config) (*cloudConfig, error) {
path := cloudConfigPath(cfg)
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var cc cloudConfig
if err := json.Unmarshal(b, &cc); err != nil {
return nil, err
}
return &cc, nil
}

func saveCloudConfig(cfg store.Config, cc *cloudConfig) error {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(cc, "", " ")
if err != nil {
return err
}
return os.WriteFile(cloudConfigPath(cfg), b, 0o644)
}
102 changes: 36 additions & 66 deletions cmd/engram/cloud_daemon_probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,94 +2,64 @@ package main

import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
)

// daemonProbeStatus describes the outcome of probing the local engram daemon.
type daemonProbeStatus string

const (
daemonProbeRunning daemonProbeStatus = "running"
daemonProbeNotRunning daemonProbeStatus = "not_running"
daemonProbeUnreachable daemonProbeStatus = "unreachable"
"github.com/Gentleman-Programming/engram/internal/cloudconfig"
)

// daemonProbeResult captures the outcome of a single probe.
type daemonProbeResult struct {
Status daemonProbeStatus
Port int
Err error
}

const defaultDaemonProbePort = 7437

// daemonProbeTimeout is a var (not const) so tests can shorten it when
// exercising the "server accepts but never replies" path.
var daemonProbeTimeout = time.Second

// cloudDaemonProbe issues a short timeout GET to /health on the local engram
// HTTP server. Exposed as a variable so tests can stub it.
var cloudDaemonProbe = defaultCloudDaemonProbe

// defaultCloudDaemonProbe performs a real HTTP GET against the local daemon.
// A dial error to 127.0.0.1 is interpreted as "not running"; any other error
// (timeout, non-2xx response, malformed reply) maps to "unreachable" so the
// user can distinguish "the daemon is gone" from "the daemon is misbehaving".
func defaultCloudDaemonProbe(ctx context.Context, port int) daemonProbeResult {
url := fmt.Sprintf("http://127.0.0.1:%d/health", port)
client := &http.Client{Timeout: daemonProbeTimeout}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return daemonProbeResult{Status: daemonProbeUnreachable, Port: port, Err: err}
}
resp, err := client.Do(req)
if err != nil {
var opErr *net.OpError
if errors.As(err, &opErr) && opErr.Op == "dial" {
return daemonProbeResult{Status: daemonProbeNotRunning, Port: port, Err: err}
}
return daemonProbeResult{Status: daemonProbeUnreachable, Port: port, Err: err}
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return daemonProbeResult{Status: daemonProbeRunning, Port: port}
}
return daemonProbeResult{Status: daemonProbeUnreachable, Port: port}
// cloudDaemonProbe is a thin wrapper that calls
// cloudconfig.LocalDaemonProbe at invocation time so test
// stubs of the cloudconfig package's var seam (per ADR-1)
// reach printCloudStatusDaemonProbe without needing a
// separate local var seam. The wrapper is a function (not
// a var assigned at package init) so a later assignment to
// cloudconfig.LocalDaemonProbe is observed by the next call
// to printCloudStatusDaemonProbe.
func cloudDaemonProbe(ctx context.Context, port int) cloudconfig.Result {
return cloudconfig.LocalDaemonProbe(ctx, port)
}

// resolveDaemonProbePort mirrors the port resolution used by cmdServe so the
// probe targets the same address the user's serve process is bound to.
// resolveDaemonProbePort mirrors the port resolution used by
// cmdServe so the probe targets the same address the user's
// serve process is bound to. The function delegates to
// cloudconfig.ResolvePort (added in T-608.4) but is kept here
// as a thin wrapper to preserve the existing call site in
// printCloudStatusDaemonProbe. The default port (7437) and
// the env var name ("ENGRAM_PORT") are exported from the
// cloudconfig package as DefaultProbePort and EnvProbePort.
func resolveDaemonProbePort() int {
if p := strings.TrimSpace(os.Getenv("ENGRAM_PORT")); p != "" {
if p := strings.TrimSpace(os.Getenv(cloudconfig.EnvProbePort)); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 && n < 65536 {
return n
}
}
return defaultDaemonProbePort
return cloudconfig.DefaultProbePort
}

// printCloudStatusDaemonProbe prints a single line describing whether the
// local engram daemon answers /health, plus a short hint when it is down.
// Exit code is unchanged: this is informational so cloud status remains a
// non-failing diagnostic surface.
// printCloudStatusDaemonProbe prints a single line describing
// whether the local engram daemon answers /health, plus a
// short hint when it is down. Exit code is unchanged: this is
// informational so cloud status remains a non-failing
// diagnostic surface.
//
// The function uses cloudconfig.ProbeStatus to classify the
// result; the labels rendered to stdout ("running", "not
// running", "unreachable") match ProbeStatus.String() so the
// output is byte-identical to the pre-migration behavior. The
// per-state hint lines (recovery hint for ProbeNotRunning,
// probe-error message for ProbeUnreachable) are also preserved.
func printCloudStatusDaemonProbe() {
port := resolveDaemonProbePort()
ctx, cancel := context.WithTimeout(context.Background(), daemonProbeTimeout)
ctx, cancel := context.WithTimeout(context.Background(), cloudconfig.ProbeTimeout)
defer cancel()
res := cloudDaemonProbe(ctx, port)
switch res.Status {
case daemonProbeRunning:
case cloudconfig.ProbeRunning:
fmt.Printf("Local daemon: running on port %d\n", res.Port)
case daemonProbeNotRunning:
case cloudconfig.ProbeNotRunning:
fmt.Printf("Local daemon: not running on port %d\n", res.Port)
fmt.Println("Hint: run `engram serve` to resume autosync; on macOS see DOCS.md launchd template to keep it alive across upgrades")
default:
Expand Down
Loading
Loading