diff --git a/apps/docs/content/docs/cli/index.mdx b/apps/docs/content/docs/cli/index.mdx index 5b027d55..1d463d64 100644 --- a/apps/docs/content/docs/cli/index.mdx +++ b/apps/docs/content/docs/cli/index.mdx @@ -48,7 +48,6 @@ stack \ --help | [`services`](./services) | Manage development services | | [`status`](./status) | Show status of all services and resources | | [`users`](./users) | Manage project users | -| [`users`](./users) | Manage project users | | [`vars`](./vars) | Manage workspace variables | diff --git a/apps/docs/content/docs/cli/project/default.mdx b/apps/docs/content/docs/cli/project/default.mdx index fd967b59..f9704517 100644 --- a/apps/docs/content/docs/cli/project/default.mdx +++ b/apps/docs/content/docs/cli/project/default.mdx @@ -8,8 +8,8 @@ icon: Terminal Set or show the default project. -The default project is used by the agent when no project is specified -in API requests (via X-Stackpanel-Project header or 'project' query param). +The default project is used by the agent for v1 API requests that send no +X-Stackpanel-Project header, when the agent has no current project. Without arguments, shows the current default project. With a path argument, sets that project as the default. diff --git a/apps/docs/content/docs/cli/project/index.mdx b/apps/docs/content/docs/cli/project/index.mdx index 0f0e14e0..6467fb61 100644 --- a/apps/docs/content/docs/cli/project/index.mdx +++ b/apps/docs/content/docs/cli/project/index.mdx @@ -9,8 +9,8 @@ icon: Terminal Manage Stackpanel projects across your system. Projects are stored in ~/.config/stackpanel/stackpanel.yaml and can be -accessed from any directory. Each project has a unique ID that can be -used in API requests via the X-Stackpanel-Project header. +accessed from any directory. Each project has a unique ID; v1 API +requests select their project by sending it in the X-Stackpanel-Project header. Examples: stack project list # List all known projects diff --git a/apps/stackpanel-go/README.md b/apps/stackpanel-go/README.md index 90e39a5d..a940d52d 100644 --- a/apps/stackpanel-go/README.md +++ b/apps/stackpanel-go/README.md @@ -83,7 +83,6 @@ apps/stack-go/ │ ├── nixconfig/ # Nix configuration helpers │ └── ... └── pkg/ # Shared packages (can be imported) - ├── common/ # Common utilities ├── envvars/ # Environment variable definitions ├── exec/ # Command execution ├── nix/ # Nix serialization diff --git a/apps/stackpanel-go/apps/stackpanel-go/internal/flakeedit/dump_test.go b/apps/stackpanel-go/apps/stackpanel-go/internal/flakeedit/dump_test.go deleted file mode 100644 index 755346d9..00000000 --- a/apps/stackpanel-go/apps/stackpanel-go/internal/flakeedit/dump_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package flakeedit - -import ( - "fmt" - "os" - "strings" - "testing" - - tree_sitter_nix "github.com/darkmatter/stackpanel/stackpanel-go/internal/treesitter/nix" - tree_sitter "github.com/tree-sitter/go-tree-sitter" -) - -func printTree(node *tree_sitter.Node, source []byte, indent int) { - prefix := strings.Repeat(" ", indent) - text := "" - if node.ChildCount() == 0 { - t := string(source[node.StartByte():node.EndByte()]) - if len(t) > 60 { - t = t[:60] + "..." - } - text = fmt.Sprintf(" %q", t) - } - fmt.Printf("%s(%s [%d:%d-%d:%d]%s)\n", prefix, node.Kind(), - node.StartPosition().Row, node.StartPosition().Column, - node.EndPosition().Row, node.EndPosition().Column, - text) - for i := uint(0); i < uint(node.ChildCount()); i++ { - child := node.Child(i) - printTree(child, source, indent+1) - } -} - -func TestDumpAST(t *testing.T) { - source, err := os.ReadFile("../../flake.nix") - if err != nil { - source, err = os.ReadFile("../../../../flake.nix") - if err != nil { - t.Skip("flake.nix not found") - } - } - parser := tree_sitter.NewParser() - defer parser.Close() - parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_nix.Language())) - tree := parser.Parse(source, nil) - defer tree.Close() - printTree(tree.RootNode(), source, 0) -} diff --git a/apps/stackpanel-go/cmd/cli/deploy.go b/apps/stackpanel-go/cmd/cli/deploy.go index 8e1ca0f5..dea8bc1a 100644 --- a/apps/stackpanel-go/cmd/cli/deploy.go +++ b/apps/stackpanel-go/cmd/cli/deploy.go @@ -17,7 +17,6 @@ import ( "time" "github.com/darkmatter/stackpanel/stackpanel-go/internal/output" - "github.com/darkmatter/stackpanel/stackpanel-go/pkg/common" "github.com/darkmatter/stackpanel/stackpanel-go/pkg/nixeval" "github.com/spf13/cobra" ) @@ -224,8 +223,6 @@ func loadDeployConfig(ctx context.Context) (*DeployStackpanelConfig, error) { return &cfg, nil } - common.L().Debug("using deploy config", "jsonStr", jsonStr) - var cfg DeployStackpanelConfig if err := json.Unmarshal([]byte(jsonStr), &cfg); err != nil { return nil, fmt.Errorf("failed to parse deploy config: %w", err) diff --git a/apps/stackpanel-go/cmd/cli/project.go b/apps/stackpanel-go/cmd/cli/project.go index e9ee4369..41af4867 100644 --- a/apps/stackpanel-go/cmd/cli/project.go +++ b/apps/stackpanel-go/cmd/cli/project.go @@ -2,10 +2,10 @@ // ~/.config/stackpanel/stackpanel.yaml. // // The agent serves multiple projects and needs a way to route API requests. -// Each project gets a deterministic ID (hash of its path) that clients pass -// via the X-Stackpanel-Project header. The "default" project is used when -// no header is provided, avoiding the need to specify a project for -// single-project setups. +// Each project gets a deterministic ID (hash of its path) that v1 API clients +// pass via the X-Stackpanel-Project header. Without the header the agent uses +// its current project, then the "default" project, so single-project setups +// never need to name one. package cmd import ( @@ -29,8 +29,8 @@ var projectCmd = &cobra.Command{ Long: `Manage Stackpanel projects across your system. Projects are stored in ~/.config/stackpanel/stackpanel.yaml and can be -accessed from any directory. Each project has a unique ID that can be -used in API requests via the X-Stackpanel-Project header. +accessed from any directory. Each project has a unique ID; v1 API +requests select their project by sending it in the X-Stackpanel-Project header. Examples: stack project list # List all known projects @@ -109,12 +109,12 @@ a project directory, or when you use 'stack project add'.`, } fmt.Println( - color.New(color.Faint).Sprint("Use project ID or name in API requests:"), + color.New(color.Faint).Sprint("Select a project in v1 API requests by its ID:"), ) fmt.Printf( " %s\n", color.New(color.Faint). - Sprint("curl -H 'X-Stackpanel-Project: ' http://localhost:9876/api/..."), + Sprint("curl -H 'X-Stackpanel-Project: ' ..."), ) }, } @@ -201,7 +201,7 @@ With an argument, shows information about the specified project (by ID, name, or // Usage hint fmt.Println() - fmt.Println(color.New(color.Faint).Sprint("Use in API requests:")) + fmt.Println(color.New(color.Faint).Sprint("Select this project in v1 API requests:")) fmt.Printf( " %s\n", color.New(color.Faint).Sprintf("curl -H 'X-Stackpanel-Project: %s' ...", id), @@ -214,8 +214,8 @@ var projectDefaultCmd = &cobra.Command{ Short: "Set or show the default project", Long: `Set or show the default project. -The default project is used by the agent when no project is specified -in API requests (via X-Stackpanel-Project header or 'project' query param). +The default project is used by the agent for v1 API requests that send no +X-Stackpanel-Project header, when the agent has no current project. Without arguments, shows the current default project. With a path argument, sets that project as the default. diff --git a/apps/stackpanel-go/cmd/cli/root.go b/apps/stackpanel-go/cmd/cli/root.go index e1343e4b..11b8bda3 100644 --- a/apps/stackpanel-go/cmd/cli/root.go +++ b/apps/stackpanel-go/cmd/cli/root.go @@ -90,7 +90,6 @@ func init() { rootCmd.AddCommand(deployCmd) rootCmd.AddCommand(provisionCmd) rootCmd.AddCommand(direnvCmd) - // rootCmd.AddCommand(secretsCmd) // Handle --no-color flag and optional auto-register rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { diff --git a/apps/stackpanel-go/cmd/cli/secrets.go b/apps/stackpanel-go/cmd/cli/secrets.go deleted file mode 100644 index 0f75c13f..00000000 --- a/apps/stackpanel-go/cmd/cli/secrets.go +++ /dev/null @@ -1,242 +0,0 @@ -// secrets.go implements `stack secrets`, which provides CLI access to -// SOPS-encrypted secrets management. All operations delegate to the agent's -// REST API — the CLI itself never touches encryption keys or SOPS files -// directly, keeping the crypto logic centralized in one place. - -package cmd - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "strings" - "text/tabwriter" - - "github.com/darkmatter/stackpanel/stackpanel-go/internal/tui" - secretsview "github.com/darkmatter/stackpanel/stackpanel-go/internal/tui/views/secrets" - "github.com/fatih/color" - "github.com/spf13/cobra" -) - -var secretsCmd = &cobra.Command{ - Use: "secrets", - Short: "Manage secrets and encryption", - Long: "Interactive dashboard for managing SOPS-encrypted secrets, groups, recipients, and keys.", - Aliases: []string{"sec"}, - RunE: func(cmd *cobra.Command, args []string) error { - noTUI, _ := cmd.Root().PersistentFlags().GetBool("no-tui") - if noTUI || !tui.IsInteractive() { - return secretsStatusPlain() - } - return secretsview.RunSecretsDashboard() - }, -} - -var secretsStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show secrets configuration status", - RunE: func(cmd *cobra.Command, args []string) error { - return secretsStatusPlain() - }, -} - -var secretsGroupsCmd = &cobra.Command{ - Use: "groups", - Short: "List configured secret groups", - Aliases: []string{"g"}, - RunE: func(cmd *cobra.Command, args []string) error { - return secretsGroupsPlain() - }, -} - -var secretsRecipientsCmd = &cobra.Command{ - Use: "recipients", - Short: "List recipients (team members with access)", - Aliases: []string{"r"}, - RunE: func(cmd *cobra.Command, args []string) error { - return secretsRecipientsPlain() - }, -} - -func init() { - secretsCmd.AddCommand(secretsStatusCmd) - secretsCmd.AddCommand(secretsGroupsCmd) - secretsCmd.AddCommand(secretsRecipientsCmd) -} - -// --- Plain CLI output (non-TUI fallback) --- - -func secretsStatusPlain() error { - bold := color.New(color.Bold) - dim := color.New(color.FgHiBlack) - green := color.New(color.FgGreen) - yellow := color.New(color.FgYellow) - red := color.New(color.FgRed) - - bold.Println("Secrets Status") - fmt.Println() - - // Groups - groups, err := fetchAgentJSON("/api/secrets/group/list") - if err != nil { - red.Printf(" Agent not reachable: %v\n", err) - dim.Println(" Start the agent with: stack agent") - return nil - } - - groupMap, _ := groups["groups"].(map[string]interface{}) - if len(groupMap) == 0 { - yellow.Println(" No secrets groups found.") - dim.Println(" Initialize a group: secrets:init-group dev") - } else { - bold.Println("Groups:") - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - for name, data := range groupMap { - dataMap, _ := data.(map[string]interface{}) - keys, _ := dataMap["keys"].([]interface{}) - fmt.Fprintf(w, " %s\t%d secrets\n", green.Sprint(name), len(keys)) - } - w.Flush() - } - - fmt.Println() - - // Recipients - recipientsResp, err := fetchAgentJSON("/api/secrets/recipients") - if err == nil { - recipientList, _ := recipientsResp["recipients"].([]interface{}) - bold.Println("Recipients:") - if len(recipientList) == 0 { - dim.Println(" No recipients registered yet.") - } else { - for _, r := range recipientList { - rMap, _ := r.(map[string]interface{}) - name, _ := rMap["name"].(string) - pubKey, _ := rMap["publicKey"].(string) - shortKey := pubKey - if len(shortKey) > 20 { - shortKey = shortKey[:12] + "..." + shortKey[len(shortKey)-6:] - } - fmt.Printf(" %s %s\n", green.Sprint(name), dim.Sprint(shortKey)) - } - } - } - - fmt.Println() - - // Rekey workflow - workflowResp, err := fetchAgentJSON("/api/secrets/rekey-workflow") - if err == nil { - exists, _ := workflowResp["exists"].(bool) - bold.Println("Rekey Workflow:") - if exists { - green.Println(" GitHub Actions workflow exists") - } else { - yellow.Println(" Not configured") - dim.Println(" Run: secrets:init-group --force-gh") - } - } - - return nil -} - -func secretsGroupsPlain() error { - groups, err := fetchAgentJSON("/api/secrets/group/list") - if err != nil { - return fmt.Errorf("agent not reachable: %w", err) - } - - groupMap, _ := groups["groups"].(map[string]interface{}) - if len(groupMap) == 0 { - fmt.Println("No groups found. Run: secrets:init-group dev") - return nil - } - - bold := color.New(color.Bold) - dim := color.New(color.FgHiBlack) - - for name, data := range groupMap { - dataMap, _ := data.(map[string]interface{}) - keys, _ := dataMap["keys"].([]interface{}) - bold.Printf("%s ", name) - dim.Printf("(%d secrets)\n", len(keys)) - for _, k := range keys { - fmt.Printf(" %s\n", k) - } - if len(keys) == 0 { - dim.Println(" (empty)") - } - } - - return nil -} - -func secretsRecipientsPlain() error { - resp, err := fetchAgentJSON("/api/secrets/recipients") - if err != nil { - return fmt.Errorf("agent not reachable: %w", err) - } - - recipientList, _ := resp["recipients"].([]interface{}) - if len(recipientList) == 0 { - fmt.Println("No recipients registered.") - fmt.Println("Recipients are auto-registered when team members enter the devshell.") - return nil - } - - bold := color.New(color.Bold) - dim := color.New(color.FgHiBlack) - bold.Printf("Recipients (%d):\n", len(recipientList)) - for _, r := range recipientList { - rMap, _ := r.(map[string]interface{}) - name, _ := rMap["name"].(string) - pubKey, _ := rMap["publicKey"].(string) - fmt.Printf(" %-20s %s\n", name, dim.Sprint(pubKey)) - } - - return nil -} - -// fetchAgentJSON makes a GET request to the local agent API and returns parsed JSON. -// The agent must be running for any secrets command to work; if not, the error -// message guides the user to start it. -// -// Default agent URL is localhost:21234, overridable via STACKPANEL_AGENT_URL -// for remote-agent or non-standard port setups. -// -// The agent wraps responses in a { success, data } envelope — this function -// automatically unwraps it so callers get the inner payload directly. -func fetchAgentJSON(path string) (map[string]interface{}, error) { - agentURL := os.Getenv("STACKPANEL_AGENT_URL") - if agentURL == "" { - agentURL = "http://localhost:21234" - } - agentToken := os.Getenv("STACKPANEL_AGENT_TOKEN") - - url := strings.TrimRight(agentURL, "/") + path - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - if agentToken != "" { - req.Header.Set("Authorization", "Bearer "+agentToken) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - // Unwrap the { success, data } envelope - if data, ok := result["data"].(map[string]interface{}); ok { - return data, nil - } - return result, nil -} diff --git a/apps/stackpanel-go/cmd/cli/setup.go b/apps/stackpanel-go/cmd/cli/setup.go index f6430dd5..0d1b0853 100644 --- a/apps/stackpanel-go/cmd/cli/setup.go +++ b/apps/stackpanel-go/cmd/cli/setup.go @@ -22,7 +22,6 @@ import ( "strings" "time" - "github.com/darkmatter/stackpanel/stackpanel-go/internal/fileops" "github.com/darkmatter/stackpanel/stackpanel-go/internal/output" "github.com/darkmatter/stackpanel/stackpanel-go/internal/reconcile" "github.com/darkmatter/stackpanel/stackpanel-go/internal/setupsession" @@ -751,6 +750,3 @@ func sortedKeys[V any](m map[string]V) []string { sort.Strings(keys) return keys } - -// ensure fileops stays referenced for the manifest type used by the apply path. -var _ = fileops.Manifest{} diff --git a/apps/stackpanel-go/cmd/cli/templates.go b/apps/stackpanel-go/cmd/cli/templates.go deleted file mode 100644 index f80e3f66..00000000 --- a/apps/stackpanel-go/cmd/cli/templates.go +++ /dev/null @@ -1,99 +0,0 @@ -// templates.go provides embedded Go templates for generating MDX documentation -// pages. Templates are compiled once at init time via go:embed, so the binary -// is self-contained — no external template files needed at runtime. -package cmd - -import ( - "bytes" - "embed" - "strings" - "text/template" -) - -// escapeYAMLString escapes a string for use in a YAML frontmatter value. -// MDX documentation pages use YAML frontmatter for title/description metadata. -func escapeYAMLString(s string) string { - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - return "\"" + s + "\"" -} - -//go:embed templates/*.tmpl -var templateFS embed.FS - -var templates *template.Template - -func init() { - var err error - templates, err = template.ParseFS(templateFS, "templates/*.tmpl") - if err != nil { - panic("failed to parse embedded templates: " + err.Error()) - } -} - -// CategoryData holds data for category.mdx.tmpl -type CategoryData struct { - Title string - Category string - Icon string -} - -// IndexData holds data for index.mdx.tmpl -type IndexData struct { - CategoryLinks string -} - -// ModuleData holds data for module.mdx.tmpl -type ModuleData struct { - Title string - Description string - Icon string - Content string -} - -// ModulesIndexData holds data for modules_index.mdx.tmpl -type ModulesIndexData struct { - ModuleLinks string -} - -// renderTemplate renders a template by name with the given data -func renderTemplate(name string, data interface{}) (string, error) { - var buf bytes.Buffer - if err := templates.ExecuteTemplate(&buf, name, data); err != nil { - return "", err - } - return buf.String(), nil -} - -// RenderCategoryHeader renders the category page header -func RenderCategoryHeader(title, category, icon string) (string, error) { - return renderTemplate("category.mdx.tmpl", CategoryData{ - Title: title, - Category: category, - Icon: icon, - }) -} - -// RenderIndex renders the options index page -func RenderIndex(categoryLinks string) (string, error) { - return renderTemplate("index.mdx.tmpl", IndexData{ - CategoryLinks: categoryLinks, - }) -} - -// RenderModule renders a module documentation page -func RenderModule(title, description, icon, content string) (string, error) { - return renderTemplate("module.mdx.tmpl", ModuleData{ - Title: escapeYAMLString(title), - Description: escapeYAMLString(description), - Icon: icon, - Content: content, - }) -} - -// RenderModulesIndex renders the modules index page -func RenderModulesIndex(moduleLinks string) (string, error) { - return renderTemplate("modules_index.mdx.tmpl", ModulesIndexData{ - ModuleLinks: moduleLinks, - }) -} diff --git a/apps/stackpanel-go/cmd/cli/templates/category.mdx.tmpl b/apps/stackpanel-go/cmd/cli/templates/category.mdx.tmpl deleted file mode 100644 index 3ef97437..00000000 --- a/apps/stackpanel-go/cmd/cli/templates/category.mdx.tmpl +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: {{.Category}} -description: Configuration options for stackpanel.{{.Category}} -icon: {{.Icon}} ---- - -# {{.Title}} Options - diff --git a/apps/stackpanel-go/cmd/cli/templates/index.mdx.tmpl b/apps/stackpanel-go/cmd/cli/templates/index.mdx.tmpl deleted file mode 100644 index 553efc6a..00000000 --- a/apps/stackpanel-go/cmd/cli/templates/index.mdx.tmpl +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Options Reference -description: Complete reference for all stackpanel configuration options ---- - -# Options Reference - -This section documents all available configuration options for stackpanel. - -## Categories - -{{.CategoryLinks}} -## Quick Start - -```nix -# In .stack/config.nix -{ - stackpanel = { - enable = true; - - # Port management - ports.projectName = "myproject"; - - # Services - globalServices.postgres.enable = true; - globalServices.redis.enable = true; - - # Theme - theme.enable = true; - }; -} -``` diff --git a/apps/stackpanel-go/cmd/cli/templates/module.mdx.tmpl b/apps/stackpanel-go/cmd/cli/templates/module.mdx.tmpl deleted file mode 100644 index d1ba02ca..00000000 --- a/apps/stackpanel-go/cmd/cli/templates/module.mdx.tmpl +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: {{.Title}} -description: {{.Description}} -{{- if .Icon}} -icon: {{.Icon}} -{{- end}} ---- - -{{.Content}} diff --git a/apps/stackpanel-go/cmd/cli/templates/modules_index.mdx.tmpl b/apps/stackpanel-go/cmd/cli/templates/modules_index.mdx.tmpl deleted file mode 100644 index 2b3be4be..00000000 --- a/apps/stackpanel-go/cmd/cli/templates/modules_index.mdx.tmpl +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Module Documentation -description: In-depth documentation for stackpanel modules ---- - -# Module Documentation - -This section contains detailed documentation for individual stackpanel modules. - -## Modules - -{{.ModuleLinks}} \ No newline at end of file diff --git a/apps/stackpanel-go/cmd/cli/users.go b/apps/stackpanel-go/cmd/cli/users.go index 87f2d17c..e9af56db 100644 --- a/apps/stackpanel-go/cmd/cli/users.go +++ b/apps/stackpanel-go/cmd/cli/users.go @@ -57,7 +57,6 @@ var ( func init() { usersCmd.AddCommand(usersSyncCmd) - rootCmd.AddCommand(usersCmd) // Flags for sync command usersSyncCmd.Flags(). diff --git a/apps/stackpanel-go/go.mod b/apps/stackpanel-go/go.mod index 468b11fe..5f50a5bd 100644 --- a/apps/stackpanel-go/go.mod +++ b/apps/stackpanel-go/go.mod @@ -9,7 +9,6 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/log v0.4.2 github.com/darkmatter/stackpanel/packages/proto/gen/gopb v0.0.0-00010101000000-000000000000 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 @@ -24,7 +23,6 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/yuin/goldmark v1.7.13 - go.uber.org/zap v1.27.1 google.golang.org/protobuf v1.36.9 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/gofumpt v0.9.2 @@ -90,7 +88,6 @@ require ( github.com/fzipp/gocyclo v0.6.0 // indirect github.com/ghostiam/protogetter v0.3.9 // indirect github.com/go-critic/go-critic v0.12.0 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -224,7 +221,7 @@ require ( go-simpler.org/sloglint v0.9.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + go.uber.org/zap v1.27.1 // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.46.0 // indirect @@ -233,6 +230,8 @@ require ( golang.org/x/term v0.36.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/tools v0.38.0 // indirect + golang.org/x/tools/go/expect v0.1.1-deprecated // indirect + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect honnef.co/go/tools v0.6.1 // indirect diff --git a/apps/stackpanel-go/go.sum b/apps/stackpanel-go/go.sum index 185c28d9..233cdf2f 100644 --- a/apps/stackpanel-go/go.sum +++ b/apps/stackpanel-go/go.sum @@ -171,8 +171,6 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= -github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 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 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= @@ -249,8 +247,6 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= diff --git a/apps/stackpanel-go/gomod2nix.toml b/apps/stackpanel-go/gomod2nix.toml index c49dba3b..68e1c0b0 100644 --- a/apps/stackpanel-go/gomod2nix.toml +++ b/apps/stackpanel-go/gomod2nix.toml @@ -189,10 +189,6 @@ schema = 3 version = 'v1.1.1-0.20250404203927-76690c660834' hash = 'sha256-CwS1AyoRsPOFA4SrC0CTtwLnbg57FmmSW/mvVQUFYcc=' - [mod.'github.com/charmbracelet/log'] - version = 'v0.4.2' - hash = 'sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I=' - [mod.'github.com/charmbracelet/x/ansi'] version = 'v0.10.1' hash = 'sha256-nY4zkUGnuD+Lczwt+NMXdQ38cAsy5mtxzXrFSJmR0E4=' @@ -273,10 +269,6 @@ schema = 3 version = 'v0.12.0' hash = 'sha256-Wj0UXVJ3HLqgKKuF2bxVxqcfmpzIqRoqljT7WpWW7Ck=' - [mod.'github.com/go-logfmt/logfmt'] - version = 'v0.6.0' - hash = 'sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=' - [mod.'github.com/go-toolsmith/astcast'] version = 'v1.1.0' hash = 'sha256-Rv3tAJMtJMeKGWmRB5ZzWQXEdK2XIVRa3moJy/7Dzfw=' @@ -857,10 +849,6 @@ schema = 3 version = 'v1.27.1' hash = 'sha256-bn/MMu7X3GkUuW12Xwn9JYbOJeEu9+yoQtkmO+36xlQ=' - [mod.'golang.org/x/exp'] - version = 'v0.0.0-20250819193227-8b4c13bb791b' - hash = 'sha256-rp8WHdfnVSbFi4ENtReQ0qLzfXlUHuC58cwtKqEbwG8=' - [mod.'golang.org/x/exp/typeparams'] version = 'v0.0.0-20250210185358-939b2ce775ac' hash = 'sha256-rwnrzYkeyMB+E3Q+CjrXRoEQRhRwQmVv5FVB/Rv2oCg=' @@ -893,6 +881,14 @@ schema = 3 version = 'v0.38.0' hash = 'sha256-GeIkpuKtUfxxrOZLikNlZA3lMKhdyO++PVvXppBGmbw=' + [mod.'golang.org/x/tools/go/expect'] + version = 'v0.1.1-deprecated' + hash = 'sha256-7JLwLMSzp9Eovnq7YBTv4Lgx8/FZnfMlx6ZkUdaW4dc=' + + [mod.'golang.org/x/tools/go/packages/packagestest'] + version = 'v0.1.1-deprecated' + hash = 'sha256-K3ljJg+gzsY/MAvrVSpWTzKlxOnXMCcsPHLQvH7JRbU=' + [mod.'google.golang.org/protobuf'] version = 'v1.36.9' hash = 'sha256-i4zQhGFR6KprA5elLw33bA1VdMLAnL+Du3gBmrea+3Y=' diff --git a/apps/stackpanel-go/internal/agent/README.md b/apps/stackpanel-go/internal/agent/README.md index 823014cf..5557cc54 100644 --- a/apps/stackpanel-go/internal/agent/README.md +++ b/apps/stackpanel-go/internal/agent/README.md @@ -86,7 +86,7 @@ err := store.PatchConsolidatedData("deployment.fly.organization", "my-org") | File | Purpose | |------|---------| -| `store.go` | `Store` struct and all read/write/patch methods. Accepts a `NixRunner` interface (satisfied by `*exec.Executor`). `ReadEntity`, `ReadEntityJSON`, `WriteEntity`, `WriteEntityJSON`, `SetKey`, `DeleteKey`, `ReadConsolidatedData`, `WriteConsolidatedData`, `PatchConsolidatedData`, `DeleteEntity`. | +| `store.go` | `Store` struct and all read/write/patch methods. Accepts a `NixRunner` interface (satisfied by `*exec.Executor`). `ReadEntity`, `ReadEntityJSON`, `WriteEntity`, `SetKey`, `DeleteKey`, `ReadConsolidatedData`, `WriteConsolidatedData`, `PatchConsolidatedData`, `DeleteEntity`. | | `paths.go` | `Paths` struct for filesystem layout resolution. Handles legacy per-entity files (`.stack/data/.nix`) vs consolidated (`.stack/config.nix`). Also holds `ConfigNixHeader`, `SectionHeaders()`, and `ParseConfigPath()`. | | `entities.go` | Pure functions: `ValidateEntityName`, `IsExternalEntity`, `IsMapEntity`, `IsEvaluatedEntity`, `MapFieldNames`. | | `transform.go` | JSON key transforms: `KebabToCamel`, `CamelToKebab`, `TransformKeysToCamel`, `TransformKeysToKebab`, `NixJSONToCamelCase`, `CamelCaseToNixJSON`. Map-field-aware — user-defined keys (variable IDs, app names, etc.) are preserved verbatim. | @@ -139,7 +139,6 @@ This is the HTTP layer. Files are grouped by domain below. | `cors_auth.go` | `withCORS()`, `withLogging()`, `requireAuth()` middleware. Origin allow-list logic, token extraction from `Authorization` header. | | `jwt.go` | `JWTManager` — generates and validates agent JWT tokens. Tokens are created during pairing and last 30 days. | | `pair.go` | `GET /pair` — serves the browser pairing page. The page receives a one-time token via `postMessage` so the web UI can authenticate future requests. | -| `project_context.go` | Context key helpers for attaching the resolved `Project` to a request context. | ### Nix Data (HTTP Handlers) @@ -151,8 +150,6 @@ handling, FlakeWatcher integration, and evaluated-entity merging. | `nix_data.go` | **Main data handler.** HTTP endpoints for `/api/nix/data` (GET/POST/DELETE) and `/api/nix/data/list`. Delegates to `s.store` (`*nixdata.Store`) for all filesystem operations. Also provides thin wrapper methods (`readNixEntityJSON`, `readConsolidatedData`, `writeConsolidatedData`, `patchConsolidatedData`) so that Connect handlers and other server files compile unchanged. Server-specific logic (FlakeWatcher lookup, evaluated entity merging) stays here. | | `nix_config.go` | `GET/POST /api/nix/config` — returns the fully-evaluated flake config (`stackpanelConfig`). Caches aggressively; POST forces a re-eval. | | `nix_files.go` | `GET /api/nix/files` — lists generated files declared in the Nix config, enriched with on-disk status (`existsOnDisk`, `isStale`). | -| `nix_ui.go` | `GET /api/nix/ui/runtime` and `/api/nix/ui/extensions` — lightweight JSON snapshots of the runtime config and extension metadata for the UI. Cached with short TTL. | -| `json_transform.go` | Thin wrappers over `pkg/nixdata` transform functions. Exists so older server code continues to compile; new code should use `nixdata.*` directly. | #### Data Write Flow (UI → disk) @@ -202,7 +199,7 @@ Three backends, dispatched based on the project's `variables.backend` setting. | File | Purpose | |------|---------| | `chamber.go` | **Backend dispatch.** Routes `/api/secrets/{write,read,delete,list}` to the appropriate backend (agenix or chamber). | -| `agenix.go` | Agenix (age-encrypted) secret backend. Writes `.age` files and updates `secrets.nix`. | +| `agenix.go` | Agenix-named secret handlers; values live in SOPS group files (`vars/.sops.yaml`), not `.age` files. | | `sops.go` | SOPS backend for per-environment YAML secret files (`/api/sops/*`). | | `secrets_groups.go` | Group-based secrets — SOPS files partitioned by access-control group (`/api/secrets/group/*`). | @@ -213,7 +210,7 @@ Three backends, dispatched based on the project's `variables.backend` setting. | `sse.go` | `GET /api/events` — Server-Sent Events. Broadcasts `config-changed`, `shell-status`, `packages-changed` events. `watchConfigFiles()` watches the filesystem and triggers events. | | `flake_watcher.go` | `FlakeWatcher` — watches `.nix` files via fsnotify, re-evaluates `stackpanelConfig` and `stackpanelPackages` flake outputs on change, updates caches, and fires SSE events. | | `shell_manager.go` | `ShellManager` — tracks devshell rebuild state. Detects when Nix files change after the last shell build and exposes staleness info to the UI. | -| `ws.go` | `GET /ws` — legacy WebSocket endpoint. Prefer HTTP + SSE for new work. | +| `ws.go` | WebSocket upgrader used by the process-compose log stream. | ### External Integrations @@ -221,7 +218,7 @@ Three backends, dispatched based on the project's `variables.backend` setting. |------|---------| | `process_compose.go` | Proxy to the local `process-compose` API: list processes, start/stop/restart, stream logs (including WebSocket log streaming). | | `sst.go` | SST (Serverless Stack) integration: config, deploy, status, resources, outputs, remove. | -| `nixpkgs_search.go` | `POST /api/nixpkgs/search` — searches nixpkgs for packages. Also handles installed-package listing and package metadata. | +| `nixpkgs_search.go` | `POST /api/nixpkgs/search` — searches nixpkgs for packages. Also handles installed-package listing. | | `registry.go` | Module registry client — fetches the remote module catalog, supports search, install, and update. | | `security_status.go` | `GET /api/security/*` — AWS session validity, step-ca certificate status. | | `healthchecks.go` | `GET /api/healthchecks` — aggregated health status for all configured modules (port checks, HTTP probes, etc). | @@ -230,7 +227,7 @@ Three backends, dispatched based on the project's `variables.backend` setting. | File | Purpose | |------|---------| -| `project_handlers.go` | REST handlers for `/api/project/*`: open, close, list, validate, remove, set default. | +| `project_handlers.go` | REST handlers for `/api/project/*`: open, close, list, validate, remove. | ### Utilities @@ -252,7 +249,7 @@ withCORS → requireAuth → requireProject → handler - **Public** (no auth): `/health`, `/status`, `/pair`, `/api/project/current`, `/api/project/list` - **Auth only** (no project needed): `/api/auth/validate`, `/api/security/*`, `/api/nixpkgs/*`, `/api/registry` -- **Auth + project**: everything else (`/api/nix/*`, `/api/secrets/*`, `/api/files/*`, `/ws`, etc.) +- **Auth + project**: everything else (`/api/nix/*`, `/api/secrets/*`, `/api/files/*`, etc.) --- diff --git a/apps/stackpanel-go/internal/agent/project/project.go b/apps/stackpanel-go/internal/agent/project/project.go index 3c8b813c..d290e8ec 100644 --- a/apps/stackpanel-go/internal/agent/project/project.go +++ b/apps/stackpanel-go/internal/agent/project/project.go @@ -74,15 +74,6 @@ func DefaultValidationOptions() ValidationOptions { } } -// QuickValidationOptions returns options for fast validation (no nix eval) -func QuickValidationOptions() ValidationOptions { - return ValidationOptions{ - Level: ValidationNormal, - SkipNixEval: true, - NixTimeout: 5 * time.Second, - } -} - // ValidationResult contains detailed validation information type ValidationResult struct { Valid bool @@ -257,20 +248,6 @@ func ValidateProject(projectPath string) error { return result.Error } -// ValidateProjectStrict performs strict validation requiring .stack/config.nix -func ValidateProjectStrict(projectPath string) error { - opts := DefaultValidationOptions() - opts.Level = ValidationStrict - result := ValidateProjectWithOptions(projectPath, opts) - return result.Error -} - -// ValidateProjectFast performs validation without nix evaluation (faster) -func ValidateProjectFast(projectPath string) error { - result := ValidateProjectWithOptions(projectPath, QuickValidationOptions()) - return result.Error -} - // ValidateProjectDetailed performs detailed validation and returns full results. // Deprecated: Use ValidateProjectWithOptions instead func ValidateProjectDetailed( @@ -495,15 +472,6 @@ func validateStackpanelConfig(configPath string) error { return nil } -// checkFlakeForStackpanel checks if a flake has stackpanel as an input or output. -// Detection methods (in order of accuracy, each more expensive): -// 1. `nix eval .#stackpanelConfig.name` — most reliable, proves the output exists -// 2. `nix flake metadata --json` — checks flake.lock for stackpanel input nodes -// 3. Text grep in flake.nix — fast fallback when Nix isn't installed or flake unlocked -func checkFlakeForStackpanel(flakePath string) (bool, []string) { - return checkFlakeForStackpanelWithOptions(flakePath, DefaultValidationOptions()) -} - // checkFlakeForStackpanelWithOptions checks if a flake has stackpanel with configurable options func checkFlakeForStackpanelWithOptions( flakePath string, @@ -545,12 +513,6 @@ func checkFlakeForStackpanelWithOptions( return hasStackpanelText, warnings } -// checkFlakeForStackpanelOutput checks if the flake exposes a stackpanelConfig output -// This is the most reliable way to detect a stackpanel project -func checkFlakeForStackpanelOutput(projectDir string) (bool, []string) { - return checkFlakeForStackpanelOutputWithTimeout(projectDir, 5*time.Second) -} - // checkFlakeForStackpanelOutputWithTimeout probes multiple flake output paths where // stackpanelConfig might be exposed. The paths differ based on how the project // integrates stackpanel: @@ -705,11 +667,6 @@ type flakeMetadata struct { } `json:"locks"` } -// checkFlakeMetadataForStackpanel uses `nix flake metadata` to check for stackpanel input -func checkFlakeMetadataForStackpanel(projectDir string) (bool, []string) { - return checkFlakeMetadataForStackpanelWithTimeout(projectDir, 10*time.Second) -} - // checkFlakeMetadataForStackpanelWithTimeout uses `nix flake metadata` with custom timeout func checkFlakeMetadataForStackpanelWithTimeout( projectDir string, @@ -862,13 +819,6 @@ func QuickValidate(projectPath string) error { return nil } -// GetValidationWarnings returns warnings for a project without failing validation. -// Useful for showing users potential issues with their project. -func GetValidationWarnings(projectPath string) []string { - result := ValidateProjectDetailed(projectPath, ValidationLenient) - return result.Warnings -} - // DetectProject walks up from the current directory looking for a Stackpanel project. // It stops at the first directory containing .stack/config.nix or (flake.nix + .git). // Returns empty string (not error) if no project is found — this is a normal case diff --git a/apps/stackpanel-go/internal/agent/server/agenix.go b/apps/stackpanel-go/internal/agent/server/agenix.go index daa4532c..78adc488 100644 --- a/apps/stackpanel-go/internal/agent/server/agenix.go +++ b/apps/stackpanel-go/internal/agent/server/agenix.go @@ -102,15 +102,6 @@ func variableNameFromID(id string) string { return parts[len(parts)-1] } -// secretFileStemFromID converts a variable name into a safe filename stem. -func secretFileStemFromID(id string) string { - name := variableNameFromID(id) - name = strings.ReplaceAll(name, "/", "-") - name = strings.ReplaceAll(name, `\`, "-") - name = strings.ReplaceAll(name, " ", "-") - return name -} - // secretYAMLKeyFromID converts a variable name to a YAML-safe key using underscores. func secretYAMLKeyFromID(id string) string { name := variableNameFromID(id) @@ -360,44 +351,6 @@ func (s *Server) getVariableValue(id string) (string, error) { return v.Value, nil } -// findAgeIdentity looks for an AGE identity file in common locations -func (s *Server) findAgeIdentity() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - - candidates := []string{ - filepath.Join(home, ".config", "age", "key.txt"), - filepath.Join(home, ".age", "key.txt"), - filepath.Join(home, ".config", "sops", "age", "keys.txt"), - } - - for _, path := range candidates { - if _, err := os.Stat(path); err == nil { - return path - } - } - - return "" -} - -// decryptAgeSecret decrypts an age-encrypted file using the given identity -func (s *Server) decryptAgeSecret(agePath, identityPath string) (string, error) { - args := []string{"-d", "-i", identityPath, agePath} - - res, err := s.exec.Run("age", args...) - if err != nil { - return "", fmt.Errorf("failed to run age: %w", err) - } - - if res.ExitCode != 0 { - return "", fmt.Errorf("age decryption failed: %s", strings.TrimSpace(res.Stderr)) - } - - return res.Stdout, nil -} - // handleAgenixSecretWrite handles writing a secret using the group-based SOPS system. // Legacy individual .age files are no longer supported - secrets are stored in // group YAML files (vars/dev.sops.yaml, vars/staging.sops.yaml, vars/prod.sops.yaml). @@ -743,14 +696,6 @@ func (s *Server) getAgenixRecipients(environments []string) ([]string, error) { return recipients, nil } -// getAgenixRecipientsFromYAML is deprecated - users are now read from Nix files only. -// Kept as a stub for backwards compatibility with secrets_groups.go fallback. -func (s *Server) getAgenixRecipientsFromYAML() ([]string, error) { - return nil, fmt.Errorf( - "legacy users.yaml is no longer supported - use .stack/data/users.nix", - ) -} - // getSystemKeys returns system-level AGE keys (CI, deploy servers, etc.) func (s *Server) getSystemKeys() []string { // Read from secrets config @@ -772,42 +717,6 @@ func (s *Server) getSystemKeys() []string { return config.SystemKeys } -// writeAgeSecret encrypts and writes a secret using the age CLI -func (s *Server) writeAgeSecret(path string, value string, recipients []string) error { - // Create temp file with plaintext - tmpFile, err := os.CreateTemp("", "stackpanel-secret-*.txt") - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - tmpPath := tmpFile.Name() - defer os.Remove(tmpPath) - - if _, err := tmpFile.WriteString(value); err != nil { - tmpFile.Close() - return fmt.Errorf("failed to write temp file: %w", err) - } - tmpFile.Close() - - // Build age command with recipients - args := []string{"-e"} - for _, r := range recipients { - args = append(args, "-r", r) - } - args = append(args, "-o", path, tmpPath) - - // Run age command - res, err := s.exec.Run("age", args...) - if err != nil { - return fmt.Errorf("failed to run age: %w", err) - } - - if res.ExitCode != 0 { - return fmt.Errorf("age encryption failed: %s", strings.TrimSpace(res.Stderr)) - } - - return nil -} - // updateVariableEntry writes secret metadata to variables.nix so the Nix module // system knows about the secret (its key, type, environments) even though the // actual encrypted value lives in the SOPS file. @@ -890,12 +799,6 @@ func (s *Server) removeVariableEntry(id string) error { return os.WriteFile(dataPath, []byte(nixExpr+"\n"), 0o644) } -// updateSecretsNix is deprecated - individual .age files and secrets.nix are no longer used. -// Secrets are now stored in group YAML files encrypted via SOPS. -func (s *Server) updateSecretsNix() error { - return nil -} - // sanitizeSecretID ensures the secret ID is safe for use as a filename func sanitizeSecretID(id string) string { id = strings.TrimSpace(id) @@ -1805,35 +1708,6 @@ func parseGitRemote(remote string) (host string, owner string, repo string) { return } -// GetConfiguredIdentityPath returns the identity path for use by other handlers -// Returns empty string if not configured -func (s *Server) GetConfiguredIdentityPath() string { - identityFile := s.getAgeIdentityPath() - data, err := os.ReadFile(identityFile) - if err != nil { - return "" - } - - value := strings.TrimSpace(string(data)) - if value == "" { - return "" - } - - // If it's key content, return the key file path - if isAgeKeyContent(value) { - return s.getAgeIdentityKeyPath() - } - - // It's a path - expand ~ - if strings.HasPrefix(value, "~") { - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, value[1:]) - } - } - - return value -} - // handleAgeIdentity routes to GET or POST handlers func (s *Server) handleAgeIdentity(w http.ResponseWriter, r *http.Request) { switch r.Method { diff --git a/apps/stackpanel-go/internal/agent/server/cors_auth.go b/apps/stackpanel-go/internal/agent/server/cors_auth.go index 7bd42210..9cdfd7c8 100644 --- a/apps/stackpanel-go/internal/agent/server/cors_auth.go +++ b/apps/stackpanel-go/internal/agent/server/cors_auth.go @@ -127,7 +127,7 @@ func (s *Server) withAllowedOrigin(next http.HandlerFunc) http.HandlerFunc { // hasValidToken checks for a valid token in three places (in priority order): // 1. X-Stackpanel-Token header (preferred, used by the web UI) // 2. Authorization: Bearer header (standard, used by API clients) -// 3. ?token= query param (fallback for WebSocket connections that can't set headers) +// 3. ?token= query param (fallback for EventSource connections that can't set headers) func (s *Server) hasValidToken(r *http.Request) bool { if token := strings.TrimSpace(r.Header.Get("X-Stackpanel-Token")); token != "" { return s.isValidToken(token) @@ -138,8 +138,8 @@ func (s *Server) hasValidToken(r *http.Request) bool { return s.isValidToken(strings.TrimSpace(auth[7:])) } - // Browser WebSocket connections can't set custom headers, so we also allow - // providing the token via the query string, e.g. /ws?token=... + // Browser EventSource connections can't set custom headers, so we also allow + // providing the token via the query string, e.g. /api/events?token=... if token := strings.TrimSpace(r.URL.Query().Get("token")); token != "" { return s.isValidToken(token) } diff --git a/apps/stackpanel-go/internal/agent/server/healthchecks.go b/apps/stackpanel-go/internal/agent/server/healthchecks.go index e5912a7f..0e220c1a 100644 --- a/apps/stackpanel-go/internal/agent/server/healthchecks.go +++ b/apps/stackpanel-go/internal/agent/server/healthchecks.go @@ -554,27 +554,6 @@ func (s *Server) runNixHealthcheck( return healthy, outputStr, nil } -// runHealthchecksParallel runs multiple healthchecks in parallel -func (s *Server) runHealthchecksParallel( - ctx context.Context, - checks []Healthcheck, -) []HealthcheckResult { - results := make([]HealthcheckResult, len(checks)) - var wg sync.WaitGroup - - for i, check := range checks { - wg.Add(1) - go func(idx int, c Healthcheck) { - defer wg.Done() - result := s.runHealthcheck(ctx, c) - results[idx] = *result - }(i, check) - } - - wg.Wait() - return results -} - // runHealthchecksParallelStreaming runs checks in parallel and broadcasts each // result via SSE as soon as it completes. This lets the UI update incrementally // instead of waiting for every check to finish. @@ -812,11 +791,3 @@ func (s *Server) cacheResult(result *HealthcheckResult) { globalHealthcheckCache.results[result.CheckID] = result globalHealthcheckCache.lastUpdated = time.Now() } - -// InvalidateHealthcheckCache clears the healthcheck cache -func InvalidateHealthcheckCache() { - globalHealthcheckCache.mu.Lock() - defer globalHealthcheckCache.mu.Unlock() - globalHealthcheckCache.results = make(map[string]*HealthcheckResult) - globalHealthcheckCache.lastUpdated = time.Time{} -} diff --git a/apps/stackpanel-go/internal/agent/server/json_transform.go b/apps/stackpanel-go/internal/agent/server/json_transform.go deleted file mode 100644 index 84c0453b..00000000 --- a/apps/stackpanel-go/internal/agent/server/json_transform.go +++ /dev/null @@ -1,60 +0,0 @@ -// json_transform.go provides package-level aliases for the JSON key transformation -// functions in pkg/nixdata/transform.go. -// -// Nix uses kebab-case for attribute names while protojson (and the web UI) uses -// camelCase. These wrappers exist so existing callers in the server package -// (connect_entities_gen.go, connect_patch.go, etc.) compile without changes. -// New code should use nixdata.* directly. -package server - -import "github.com/darkmatter/stackpanel/stackpanel-go/pkg/nixdata" - -// kebabToCamel converts kebab-case to camelCase. -// e.g., "variable-id" -> "variableId", "ca-url" -> "caUrl" -func kebabToCamel(s string) string { - return nixdata.KebabToCamel(s) -} - -// camelToKebab converts camelCase to kebab-case. -// e.g., "variableId" -> "variable-id", "caUrl" -> "ca-url" -func camelToKebab(s string) string { - return nixdata.CamelToKebab(s) -} - -// transformJSONKeysToCamel recursively transforms all keys in a JSON structure -// from kebab-case to camelCase. It preserves map keys for known map fields. -func transformJSONKeysToCamel( - data any, - mapFields map[string]struct{}, - parentKey string, -) any { - return nixdata.TransformKeysToCamel(data, mapFields, parentKey) -} - -// transformJSONKeysToKebab recursively transforms all keys in a JSON structure -// from camelCase to kebab-case. It preserves map keys for known map fields. -func transformJSONKeysToKebab( - data any, - mapFields map[string]struct{}, - parentKey string, -) any { - return nixdata.TransformKeysToKebab(data, mapFields, parentKey) -} - -// transformNixJSONToCamelCase takes JSON data from Nix (which uses kebab-case) -// and converts keys to camelCase for protojson compatibility. -func transformNixJSONToCamelCase( - data []byte, - mapFields map[string]struct{}, -) ([]byte, error) { - return nixdata.NixJSONToCamelCase(data, mapFields) -} - -// transformCamelCaseToNixJSON takes JSON data from protojson (camelCase) -// and converts keys to kebab-case for Nix compatibility. -func transformCamelCaseToNixJSON( - data []byte, - mapFields map[string]struct{}, -) ([]byte, error) { - return nixdata.CamelCaseToNixJSON(data, mapFields) -} diff --git a/apps/stackpanel-go/internal/agent/server/nix_data.go b/apps/stackpanel-go/internal/agent/server/nix_data.go index d15a01e7..8ff2ffe7 100644 --- a/apps/stackpanel-go/internal/agent/server/nix_data.go +++ b/apps/stackpanel-go/internal/agent/server/nix_data.go @@ -565,14 +565,6 @@ func validateEntityName( return nixdata.ValidateEntityName(name) } func isExternalEntity(name string) bool { return nixdata.IsExternalEntity(name) } -func isMapEntity(entity string) bool { return nixdata.IsMapEntity(entity) } - -func isEvaluatedEntity( - entity string, -) bool { - return nixdata.IsEvaluatedEntity(entity) -} -func mapFieldNames() map[string]struct{} { return nixdata.MapFieldNames() } func parseConfigPath( configPath string, diff --git a/apps/stackpanel-go/internal/agent/server/nix_ui.go b/apps/stackpanel-go/internal/agent/server/nix_ui.go deleted file mode 100644 index e6365dee..00000000 --- a/apps/stackpanel-go/internal/agent/server/nix_ui.go +++ /dev/null @@ -1,135 +0,0 @@ -// nix_ui.go serves the JSON-safe runtime snapshot used by the studio web UI. -// -// This is separate from the main config pipeline (nix_config.go) because -// STACKPANEL_CONFIG_JSON is pre-serialized by Nix (no derivations/functions) -// and is much faster to evaluate. It's the primary source for runtime state -// like ports, URLs, and UI extensions. -package server - -import ( - "encoding/json" - "net/http" - "sync" - "time" -) - -// uiRuntimeCache caches the JSON-safe Stackpanel runtime config that we read -// via nix eval (impure) from STACKPANEL_CONFIG_JSON. -// -// This is intentionally separate from the main config evaluation pipeline: -// - The runtime snapshot is JSON-safe by construction (written by stackpanel core/cli.nix) -// - Evaluating it is fast and avoids encoding derivations/packages/etc. -type uiRuntimeCache struct { - mu sync.RWMutex - data any - updatedAt time.Time -} - -var globalUIRuntimeCache uiRuntimeCache - -const uiRuntimeCacheTTL = 2 * time.Second - -// getUIRuntimeSnapshot returns the cached runtime config, refreshing via -// nix eval if the cache is older than uiRuntimeCacheTTL. We use nix eval -// rather than reading the env var directly because the agent process may -// not have STACKPANEL_CONFIG_JSON in its environment, while the nix -// executor's devshell env does. -func (s *Server) getUIRuntimeSnapshot() (any, int, error) { - // Fast path: cached - globalUIRuntimeCache.mu.RLock() - if globalUIRuntimeCache.data != nil && - time.Since(globalUIRuntimeCache.updatedAt) < uiRuntimeCacheTTL { - data := globalUIRuntimeCache.data - globalUIRuntimeCache.mu.RUnlock() - return data, http.StatusOK, nil - } - globalUIRuntimeCache.mu.RUnlock() - - // Slow path: nix eval (impure) to read STACKPANEL_CONFIG_JSON - // - // We keep this as a nix eval instead of reading the env var directly in Go because: - // - The executor may have loaded the devshell env (STACKPANEL_CONFIG_JSON) for nix runs - // - The agent process environment may not include it - args := []string{ - "eval", - "--impure", - "--json", - "--expr", - `builtins.fromJSON (builtins.readFile (builtins.getEnv "STACKPANEL_CONFIG_JSON"))`, - } - - res, err := s.exec.RunNix(args...) - if err != nil { - return nil, http.StatusInternalServerError, err - } - if res.ExitCode != 0 { - return nil, http.StatusBadRequest, errString(res.Stderr) - } - - var v any - if err := json.Unmarshal([]byte(res.Stdout), &v); err != nil { - return nil, http.StatusInternalServerError, errString( - "failed to parse nix eval output as JSON", - ) - } - - globalUIRuntimeCache.mu.Lock() - globalUIRuntimeCache.data = v - globalUIRuntimeCache.updatedAt = time.Now() - globalUIRuntimeCache.mu.Unlock() - - return v, http.StatusOK, nil -} - -func errString(s string) error { - return &runtimeError{msg: s} -} - -type runtimeError struct { - msg string -} - -func (e *runtimeError) Error() string { return e.msg } - -// handleNixUIRuntime returns the JSON-safe runtime snapshot for the current project. -// This is sourced from STACKPANEL_CONFIG_JSON (Nix store path) and evaluated via nix. -func (s *Server) handleNixUIRuntime(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - s.writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - v, status, err := s.getUIRuntimeSnapshot() - if err != nil { - s.writeAPIError(w, status, err.Error()) - return - } - s.writeAPI(w, http.StatusOK, v) -} - -// handleNixUIExtensions returns the declarative UI extension registry. -// This is a subset of the runtime snapshot: runtime.ui.extensions -func (s *Server) handleNixUIExtensions(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - s.writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - v, status, err := s.getUIRuntimeSnapshot() - if err != nil { - s.writeAPIError(w, status, err.Error()) - return - } - - // Extract runtime.ui.extensions (default to empty object) - extensions := map[string]any{} - if root, ok := v.(map[string]any); ok { - if ui, ok := root["ui"].(map[string]any); ok { - if exts, ok := ui["extensions"].(map[string]any); ok { - extensions = exts - } - } - } - - s.writeAPI(w, http.StatusOK, extensions) -} diff --git a/apps/stackpanel-go/internal/agent/server/nixpkgs_search.go b/apps/stackpanel-go/internal/agent/server/nixpkgs_search.go index c2d0d584..67510e39 100644 --- a/apps/stackpanel-go/internal/agent/server/nixpkgs_search.go +++ b/apps/stackpanel-go/internal/agent/server/nixpkgs_search.go @@ -35,21 +35,6 @@ type NixpkgsPackage struct { NixpkgsURL string `json:"nixpkgs_url"` // Link to search.nixos.org } -// NixpkgsPackageMeta represents detailed metadata for a package -type NixpkgsPackageMeta struct { - Name string `json:"name"` - AttrPath string `json:"attr_path"` - Version string `json:"version"` - Description string `json:"description"` - Homepage string `json:"homepage,omitempty"` - Changelog string `json:"changelog,omitempty"` - License string `json:"license,omitempty"` - LicenseURL string `json:"license_url,omitempty"` - Maintainers []string `json:"maintainers,omitempty"` - Platforms []string `json:"platforms,omitempty"` - NixpkgsURL string `json:"nixpkgs_url"` -} - // NixpkgsSearchResponse is the response from the search endpoint type NixpkgsSearchResponse struct { Packages []NixpkgsPackage `json:"packages"` @@ -235,107 +220,6 @@ func (s *Server) handleInstalledPackages(w http.ResponseWriter, r *http.Request) }) } -// handleNixpkgsPackageMeta returns detailed metadata for a single package -func (s *Server) handleNixpkgsPackageMeta(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - s.writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - attrPath := r.URL.Query().Get("attr") - if attrPath == "" { - s.writeAPIError(w, http.StatusBadRequest, "attr query parameter is required") - return - } - - // Check if executor is available - if s.exec == nil { - s.writeAPIError(w, http.StatusServiceUnavailable, "no project is open") - return - } - - // Evaluate the package meta - // Use nix eval 'nixpkgs#.meta' to get metadata - res, err := s.exec.RunNix("eval", "nixpkgs#"+attrPath+".meta", "--json") - if err != nil { - s.writeAPIError( - w, - http.StatusInternalServerError, - "failed to evaluate package meta: "+err.Error(), - ) - return - } - - if res.ExitCode != 0 { - s.writeAPIError(w, http.StatusNotFound, "package not found: "+attrPath) - return - } - - // Parse the meta JSON - var meta struct { - Description string `json:"description"` - Homepage string `json:"homepage"` - Changelog string `json:"changelog"` - License struct { - FullName string `json:"fullName"` - URL string `json:"url"` - } `json:"license"` - Maintainers []struct { - Name string `json:"name"` - GitHub string `json:"github"` - } `json:"maintainers"` - Platforms []string `json:"platforms"` - } - - if err := json.Unmarshal([]byte(res.Stdout), &meta); err != nil { - s.writeAPIError( - w, - http.StatusInternalServerError, - "failed to parse package meta: "+err.Error(), - ) - return - } - - // Also get the version from the package - verRes, _ := s.exec.RunNix("eval", "nixpkgs#"+attrPath+".version", "--json") - version := "" - if verRes != nil && verRes.ExitCode == 0 { - // Remove quotes from the version string - version = strings.Trim(strings.TrimSpace(verRes.Stdout), "\"") - } - - // Also get the pname - pnameRes, _ := s.exec.RunNix("eval", "nixpkgs#"+attrPath+".pname", "--json") - pname := attrPath - if pnameRes != nil && pnameRes.ExitCode == 0 { - pname = strings.Trim(strings.TrimSpace(pnameRes.Stdout), "\"") - } - - // Build maintainers list - maintainers := make([]string, 0, len(meta.Maintainers)) - for _, m := range meta.Maintainers { - if m.GitHub != "" { - maintainers = append(maintainers, m.GitHub) - } else if m.Name != "" { - maintainers = append(maintainers, m.Name) - } - } - - s.writeAPI(w, http.StatusOK, NixpkgsPackageMeta{ - Name: pname, - AttrPath: attrPath, - Version: version, - Description: meta.Description, - Homepage: meta.Homepage, - Changelog: meta.Changelog, - License: meta.License.FullName, - LicenseURL: meta.License.URL, - Maintainers: maintainers, - Platforms: meta.Platforms, - NixpkgsURL: nixpkgsSearchURL(attrPath), - }) -} - // getInstalledPackageSet returns a set of installed package names for fast lookup func (s *Server) getInstalledPackageSet() map[string]bool { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/apps/stackpanel-go/internal/agent/server/project_context.go b/apps/stackpanel-go/internal/agent/server/project_context.go deleted file mode 100644 index 48ed7472..00000000 --- a/apps/stackpanel-go/internal/agent/server/project_context.go +++ /dev/null @@ -1,228 +0,0 @@ -// project_context.go implements per-request project resolution for multi-project support. -// The web UI sends an X-Stackpanel-Project header to select which project each -// request operates on. This allows a single agent to manage multiple projects -// without requiring separate agent instances. -// -// Resolution priority: header > query param > default project > current project > none. - -package server - -import ( - "context" - "net/http" - "strings" - - "github.com/darkmatter/stackpanel/stackpanel-go/internal/agent/project" - "github.com/darkmatter/stackpanel/stackpanel-go/pkg/userconfig" - "github.com/rs/zerolog/log" -) - -// contextKey is an unexported type to prevent collisions with context keys -// from other packages. -type contextKey string - -const ( - // ProjectContextKey is the context key for the resolved project - ProjectContextKey contextKey = "project" - - // ProjectPathContextKey is the context key for the project path - ProjectPathContextKey contextKey = "project_path" - - // ProjectIDContextKey is the context key for the project ID - ProjectIDContextKey contextKey = "project_id" -) - -const ( - // ProjectHeader is the HTTP header used to specify a project - ProjectHeader = "X-Stackpanel-Project" - - // ProjectQueryParam is the query parameter used to specify a project - ProjectQueryParam = "project" -) - -// ProjectContext holds the resolved project information for a request. -type ProjectContext struct { - // Project is the resolved project (may be nil if no project) - Project *userconfig.Project - - // Path is the absolute path to the project root - Path string - - // ID is the project's unique identifier - ID string - - // Source indicates how the project was determined - // "header", "query", "default", "current", or "none" - Source string -} - -// withProjectContext is middleware that resolves the project for each request. -// It checks (in order): -// 1. X-Stackpanel-Project header -// 2. "project" query parameter -// 3. Default project from user config -// 4. Current project from user config -// -// The resolved project is stored in the request context. -func (s *Server) withProjectContext(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - projectCtx := s.resolveProjectForRequest(r) - - // Store in context - ctx = context.WithValue(ctx, ProjectContextKey, projectCtx) - if projectCtx.Project != nil { - ctx = context.WithValue(ctx, ProjectPathContextKey, projectCtx.Path) - ctx = context.WithValue(ctx, ProjectIDContextKey, projectCtx.ID) - } - - next(w, r.WithContext(ctx)) - } -} - -// resolveProjectForRequest determines which project to use for a request. -func (s *Server) resolveProjectForRequest(r *http.Request) *ProjectContext { - ucm := s.projectMgr.UserConfigManager() - if ucm == nil { - return &ProjectContext{Source: "none"} - } - - // Check header first - if projectID := r.Header.Get(ProjectHeader); projectID != "" { - projectID = strings.TrimSpace(projectID) - if proj := ucm.ResolveProject(projectID); proj != nil { - id := proj.ID - if id == "" { - id = userconfig.GenerateProjectID(proj.Path) - } - return &ProjectContext{ - Project: proj, - Path: proj.Path, - ID: id, - Source: "header", - } - } - log.Debug(). - Str("project_id", projectID). - Msg("Project from header not found, falling back") - } - - // Check query parameter - if projectID := r.URL.Query().Get(ProjectQueryParam); projectID != "" { - projectID = strings.TrimSpace(projectID) - if proj := ucm.ResolveProject(projectID); proj != nil { - id := proj.ID - if id == "" { - id = userconfig.GenerateProjectID(proj.Path) - } - return &ProjectContext{ - Project: proj, - Path: proj.Path, - ID: id, - Source: "query", - } - } - log.Debug(). - Str("project_id", projectID). - Msg("Project from query not found, falling back") - } - - // Try default project - if proj := ucm.GetDefaultProject(); proj != nil { - id := proj.ID - if id == "" { - id = userconfig.GenerateProjectID(proj.Path) - } - return &ProjectContext{ - Project: proj, - Path: proj.Path, - ID: id, - Source: "default", - } - } - - // Fall back to current project - if proj := ucm.CurrentProject(); proj != nil { - id := proj.ID - if id == "" { - id = userconfig.GenerateProjectID(proj.Path) - } - return &ProjectContext{ - Project: proj, - Path: proj.Path, - ID: id, - Source: "current", - } - } - - // No project available - return &ProjectContext{ - Source: "none", - } -} - -// GetProjectContext retrieves the project context from a request context. -func GetProjectContext(ctx context.Context) *ProjectContext { - if pc, ok := ctx.Value(ProjectContextKey).(*ProjectContext); ok { - return pc - } - return &ProjectContext{Source: "none"} -} - -// GetProjectPath retrieves the project path from a request context. -func GetProjectPath(ctx context.Context) string { - if path, ok := ctx.Value(ProjectPathContextKey).(string); ok { - return path - } - return "" -} - -// GetProjectID retrieves the project ID from a request context. -func GetProjectID(ctx context.Context) string { - if id, ok := ctx.Value(ProjectIDContextKey).(string); ok { - return id - } - return "" -} - -// requireProjectContext is middleware that requires a valid project in the request context. -// This should be used after withProjectContext. -func (s *Server) requireProjectContext(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - pc := GetProjectContext(r.Context()) - if pc.Project == nil { - s.writeAPI(w, http.StatusPreconditionRequired, map[string]any{ - "error": "no_project", - "message": "No project specified. Use X-Stackpanel-Project header, 'project' query parameter, or set a default project.", - "hint": "GET /api/project/list to see available projects", - }) - return - } - - // Validate the project still exists on disk - if err := project.QuickValidate(pc.Path); err != nil { - s.writeAPI(w, http.StatusPreconditionFailed, map[string]any{ - "error": "project_invalid", - "message": "The specified project is no longer valid", - "path": pc.Path, - "details": err.Error(), - }) - return - } - - next(w, r) - } -} - -// getProjectForRequest returns the project path for the current request. -// Falls back to config.ProjectRoot for backwards compatibility with clients -// that don't send X-Stackpanel-Project (e.g., the CLI or older UI versions). -func (s *Server) getProjectForRequest(r *http.Request) string { - // Try context first (new per-request project support) - if path := GetProjectPath(r.Context()); path != "" { - return path - } - - // Fall back to server's current project (backwards compatibility) - return s.config.ProjectRoot -} diff --git a/apps/stackpanel-go/internal/agent/server/project_handlers.go b/apps/stackpanel-go/internal/agent/server/project_handlers.go index ddcf378d..9e1871e7 100644 --- a/apps/stackpanel-go/internal/agent/server/project_handlers.go +++ b/apps/stackpanel-go/internal/agent/server/project_handlers.go @@ -320,152 +320,3 @@ func (s *Server) handleProjectRemove(w http.ResponseWriter, r *http.Request) { "success": true, }) } - -// projectDefaultRequest represents a request to set the default project. -type projectDefaultRequest struct { - // Path or ID or name of the project to set as default (empty to clear) - Project string `json:"project"` -} - -// handleProjectDefault gets or sets the default project. -// GET /api/project/default - returns the current default project -// POST /api/project/default - sets the default project -// DELETE /api/project/default - clears the default project -func (s *Server) handleProjectDefault(w http.ResponseWriter, r *http.Request) { - ucm := s.projectMgr.UserConfigManager() - if ucm == nil { - s.writeAPIError(w, http.StatusInternalServerError, "user config not available") - return - } - - switch r.Method { - case http.MethodGet: - // Get current default - proj := ucm.GetDefaultProject() - if proj == nil { - s.writeAPI(w, http.StatusOK, map[string]any{ - "has_default": false, - "project": nil, - }) - return - } - - id := proj.ID - if id == "" { - id = userconfig.GenerateProjectID(proj.Path) - } - - s.writeAPI(w, http.StatusOK, map[string]any{ - "has_default": true, - "project": map[string]any{ - "id": id, - "path": proj.Path, - "name": proj.Name, - "last_opened": proj.LastOpened, - }, - }) - - case http.MethodPost: - // Set default project - var req projectDefaultRequest - if err := s.readJSON(r.Body, &req); err != nil { - s.writeAPIError(w, http.StatusBadRequest, err.Error()) - return - } - - // Resolve the project identifier - proj := ucm.ResolveProject(req.Project) - if proj == nil { - s.writeAPI(w, http.StatusBadRequest, map[string]any{ - "error": "project_not_found", - "message": "Project not found. Use /api/project/list to see available projects.", - "project": req.Project, - }) - return - } - - // Set as default - if err := ucm.SetDefaultProject(proj.Path); err != nil { - s.writeAPIError(w, http.StatusInternalServerError, err.Error()) - return - } - - id := proj.ID - if id == "" { - id = userconfig.GenerateProjectID(proj.Path) - } - - s.writeAPI(w, http.StatusOK, map[string]any{ - "success": true, - "project": map[string]any{ - "id": id, - "path": proj.Path, - "name": proj.Name, - "last_opened": proj.LastOpened, - }, - }) - - case http.MethodDelete: - // Clear default - if err := ucm.ClearDefaultProject(); err != nil { - s.writeAPIError(w, http.StatusInternalServerError, err.Error()) - return - } - - s.writeAPI(w, http.StatusOK, map[string]any{ - "success": true, - "message": "Default project cleared", - }) - - default: - s.writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") - } -} - -// handleProjectResolve looks up a project by any identifier (ID, name, or path). -// This is the same resolution logic used by the X-Stackpanel-Project header, -// exposed as an endpoint so the UI can preview which project an identifier maps to. -func (s *Server) handleProjectResolve(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - s.writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - ucm := s.projectMgr.UserConfigManager() - if ucm == nil { - s.writeAPIError(w, http.StatusInternalServerError, "user config not available") - return - } - - identifier := strings.TrimSpace(r.URL.Query().Get("project")) - if identifier == "" { - s.writeAPIError(w, http.StatusBadRequest, "project parameter is required") - return - } - - proj := ucm.ResolveProject(identifier) - if proj == nil { - s.writeAPI(w, http.StatusOK, map[string]any{ - "found": false, - "project": nil, - "query": identifier, - }) - return - } - - id := proj.ID - if id == "" { - id = userconfig.GenerateProjectID(proj.Path) - } - - s.writeAPI(w, http.StatusOK, map[string]any{ - "found": true, - "project": map[string]any{ - "id": id, - "path": proj.Path, - "name": proj.Name, - "last_opened": proj.LastOpened, - }, - "query": identifier, - }) -} diff --git a/apps/stackpanel-go/internal/agent/server/server.go b/apps/stackpanel-go/internal/agent/server/server.go index a536f079..59ce6c24 100644 --- a/apps/stackpanel-go/internal/agent/server/server.go +++ b/apps/stackpanel-go/internal/agent/server/server.go @@ -173,7 +173,6 @@ func New(cfg *config.Config) (*Server, error) { // Project management endpoints - list and current are public for UI discovery mux.HandleFunc("/api/project/current", s.withCORS(s.handleProjectCurrent)) mux.HandleFunc("/api/project/list", s.withCORS(s.handleProjectList)) - mux.HandleFunc("/api/project/resolve", s.withCORS(s.handleProjectResolve)) // These require auth since they modify state mux.HandleFunc("/api/project/open", s.withCORS(s.requireAuth(s.handleProjectOpen))) mux.HandleFunc("/api/project/close", s.withCORS(s.requireAuth(s.handleProjectClose))) @@ -185,10 +184,6 @@ func New(cfg *config.Config) (*Server, error) { "/api/project/remove", s.withCORS(s.requireAuth(s.handleProjectRemove)), ) - mux.HandleFunc( - "/api/project/default", - s.withCORS(s.requireAuth(s.handleProjectDefault)), - ) // HTTP API (used by apps/web fallback client and by other tools) // These require a project to be open @@ -201,14 +196,6 @@ func New(cfg *config.Config) (*Server, error) { "/api/nix/generate", s.withCORS(s.requireAuth(s.requireProject(s.handleNixGenerate))), ) - mux.HandleFunc( - "/api/nix/ui/runtime", - s.withCORS(s.requireAuth(s.requireProject(s.handleNixUIRuntime))), - ) - mux.HandleFunc( - "/api/nix/ui/extensions", - s.withCORS(s.requireAuth(s.requireProject(s.handleNixUIExtensions))), - ) mux.HandleFunc( "/api/nix/config", s.withCORS(s.requireAuth(s.requireProject(s.handleNixConfig))), @@ -356,10 +343,6 @@ func New(cfg *config.Config) (*Server, error) { "/api/nixpkgs/installed", s.withCORS(s.requireAuth(s.handleInstalledPackages)), ) - mux.HandleFunc( - "/api/nixpkgs/meta", - s.withCORS(s.requireAuth(s.handleNixpkgsPackageMeta)), - ) // SST infrastructure management endpoints mux.HandleFunc( @@ -458,9 +441,6 @@ func New(cfg *config.Config) (*Server, error) { // SSE endpoint for real-time config updates mux.HandleFunc("/api/events", s.withCORS(s.requireAuth(s.handleSSE))) - // WebSocket API (legacy — prefer HTTP API + SSE for new features) - mux.HandleFunc("/ws", s.withCORS(s.requireAuth(s.requireProject(s.handleWS)))) - s.httpServer = &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.BindAddress, cfg.Port), Handler: s.withLogging(mux.ServeHTTP), diff --git a/apps/stackpanel-go/internal/agent/server/ws.go b/apps/stackpanel-go/internal/agent/server/ws.go index 8f220f4d..cc46b49d 100644 --- a/apps/stackpanel-go/internal/agent/server/ws.go +++ b/apps/stackpanel-go/internal/agent/server/ws.go @@ -1,436 +1,22 @@ -// ws.go implements a legacy multiplexed WebSocket API that predates the REST endpoints. -// -// Clients send JSON frames with a type field (e.g., "exec", "nix.eval", "secrets.write") -// and receive JSON responses with matching IDs. Each message type maps to the same -// logic as the corresponding REST handler. This is kept for backwards compatibility -// with older studio UI versions; new features should use REST/Connect-RPC instead. +// ws.go holds the WebSocket upgrader used by the process-compose log +// streaming endpoint. package server import ( - "encoding/json" - "errors" - "fmt" "net/http" - "os" - "path/filepath" - "strings" "github.com/gorilla/websocket" - "github.com/rs/zerolog/log" ) -// wsUpgrader is shared across WebSocket endpoints (legacy WS + process-compose log streaming). +// wsUpgrader upgrades process-compose log streaming connections. var wsUpgrader = websocket.Upgrader{ ReadBufferSize: 32 * 1024, WriteBufferSize: 32 * 1024, - // Origin is validated in requireAuth + isOriginAllowed, but gorilla requires a CheckOrigin too. + // CheckOrigin only requires an Origin header; the origin allowlist is + // applied by requireAuth/withAllowedOrigin on routes that use them. CheckOrigin: func(r *http.Request) bool { origin := r.Header.Get("Origin") // Require Origin for browser-based WebSocket connections. return origin != "" }, } - -type wsMessage struct { - ID string `json:"id"` - Type string `json:"type"` - Payload json.RawMessage `json:"payload"` -} - -type wsResponse struct { - ID string `json:"id"` - Success bool `json:"success"` - Data any `json:"data,omitempty"` - Error string `json:"error,omitempty"` -} - -func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { - conn, err := wsUpgrader.Upgrade(w, r, nil) - if err != nil { - log.Warn().Err(err).Msg("websocket upgrade failed") - return - } - defer conn.Close() - - for { - _, b, err := conn.ReadMessage() - if err != nil { - return - } - - var msg wsMessage - if err := json.Unmarshal(b, &msg); err != nil { - _ = conn.WriteJSON(wsResponse{ID: "0", Success: false, Error: "invalid message"}) - continue - } - - resp := s.handleWSMessage(msg) - _ = conn.WriteJSON(resp) - } -} - -func (s *Server) handleWSMessage(msg wsMessage) wsResponse { - switch msg.Type { - case "exec": - var req execRequest - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: "invalid exec payload"} - } - req.Command = strings.TrimSpace(req.Command) - if req.Command == "" { - return wsResponse{ID: msg.ID, Success: false, Error: "command is required"} - } - if strings.Contains(req.Command, " ") || strings.Contains(req.Command, "\t") { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "command must not contain spaces", - } - } - - cwd := s.config.ProjectRoot - if strings.TrimSpace(req.Cwd) != "" { - abs, err := safeJoin(s.config.ProjectRoot, req.Cwd) - if err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: "invalid cwd"} - } - cwd = abs - } - - res, err := s.exec.RunWithOptions(req.Command, cwd, req.Env, req.Args...) - if err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - return wsResponse{ID: msg.ID, Success: true, Data: res} - - case "nix.eval": - var req nixEvalRequest - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: "invalid nix.eval payload"} - } - expr := strings.TrimSpace(req.Expression) - if expr == "" { - return wsResponse{ID: msg.ID, Success: false, Error: "expression is required"} - } - - args := buildNixEvalArgs(expr) - res, err := s.exec.RunNix(args...) - if err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - if res.ExitCode != 0 { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: strings.TrimSpace(res.Stderr), - } - } - - var v any - if err := json.Unmarshal([]byte(res.Stdout), &v); err != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "failed to parse nix eval output as JSON", - } - } - return wsResponse{ID: msg.ID, Success: true, Data: v} - - case "nix.generate": - res, err := s.exec.RunNix("run", ".#generate") - if err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - out := res.Stdout - if strings.TrimSpace(res.Stderr) != "" { - out += res.Stderr - } - return wsResponse{ - ID: msg.ID, - Success: true, - Data: map[string]any{ - "success": res.ExitCode == 0, - "output": out, - "error": strings.TrimSpace(res.Stderr), - }, - } - - case "file.read": - var req struct { - Path string `json:"path"` - } - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: "invalid file.read payload"} - } - path := strings.TrimSpace(req.Path) - if path == "" { - return wsResponse{ID: msg.ID, Success: false, Error: "path is required"} - } - abs, err := safeJoin(s.config.ProjectRoot, path) - if err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: "invalid path"} - } - - b, err := os.ReadFile(abs) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return wsResponse{ID: msg.ID, Success: true, Data: map[string]any{ - "path": path, - "content": "", - "exists": false, - }} - } - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - - return wsResponse{ID: msg.ID, Success: true, Data: map[string]any{ - "path": path, - "content": string(b), - "exists": true, - }} - - case "file.write": - var req fileWriteRequest - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: "invalid file.write payload"} - } - req.Path = strings.TrimSpace(req.Path) - if req.Path == "" { - return wsResponse{ID: msg.ID, Success: false, Error: "path is required"} - } - - abs, err := safeJoin(s.config.ProjectRoot, req.Path) - if err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: "invalid path"} - } - - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - if err := os.WriteFile(abs, []byte(req.Content), 0o644); err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - - return wsResponse{ID: msg.ID, Success: true, Data: map[string]any{"ok": true}} - - case "secrets.set": - var req secretSetRequest - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "invalid secrets.set payload", - } - } - env, err := normalizeEnv(req.Env) - if err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - key := strings.TrimSpace(req.Key) - if key == "" { - return wsResponse{ID: msg.ID, Success: false, Error: "key is required"} - } - path, err := s.setSopsSecret(env, key, req.Value) - if err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - return wsResponse{ID: msg.ID, Success: true, Data: map[string]any{"path": path}} - - case "secrets.write": - // Secrets are now written to group YAML files via SOPS - var req GroupSecretRequest - if err := json.Unmarshal(msg.Payload, &req); err != nil { - // Fall back to legacy format - var legacyReq AgenixSecretRequest - if err2 := json.Unmarshal(msg.Payload, &legacyReq); err2 != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "invalid secrets.write payload", - } - } - req.Key = legacyReq.Key - req.Value = legacyReq.Value - req.Description = legacyReq.Description - req.Group = "dev" - if len(legacyReq.Environments) > 0 { - req.Group = legacyReq.Environments[0] - } - } - if strings.TrimSpace(req.Key) == "" { - return wsResponse{ID: msg.ID, Success: false, Error: "key is required"} - } - if strings.TrimSpace(req.Group) == "" { - req.Group = "dev" - } - safeGroup := sanitizeSecretID(req.Group) - recipients, err := s.getGroupRecipients(safeGroup) - if err != nil || len(recipients) == 0 { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "no recipients found for group", - } - } - secrets, err := s.readGroupSecrets(safeGroup) - if err != nil { - secrets = make(map[string]interface{}) - } - secrets[req.Key] = req.Value - if err := s.writeGroupSecrets(safeGroup, secrets, recipients); err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - groupPath, _ := s.getGroupFilePath(safeGroup) - relPath, _ := filepath.Rel(s.config.ProjectRoot, groupPath) - return wsResponse{ID: msg.ID, Success: true, Data: GroupSecretResponse{ - Key: req.Key, - Group: safeGroup, - Path: relPath, - RecipientCount: len(recipients), - }} - - case "secrets.read": - // Read a secret from a group's SOPS file - var req GroupSecretReadRequest - if err := json.Unmarshal(msg.Payload, &req); err != nil { - // Fall back to legacy format - var legacyReq AgenixDecryptRequest - if err2 := json.Unmarshal(msg.Payload, &legacyReq); err2 != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "invalid secrets.read payload", - } - } - req.Key = legacyReq.ID - req.Group = "dev" - } - if strings.TrimSpace(req.Key) == "" { - return wsResponse{ID: msg.ID, Success: false, Error: "key is required"} - } - if strings.TrimSpace(req.Group) == "" { - req.Group = "dev" - } - safeGroup := sanitizeSecretID(req.Group) - groupSecrets, err := s.readGroupSecrets(safeGroup) - if err != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "failed to decrypt group secrets: " + err.Error(), - } - } - value, exists := groupSecrets[req.Key] - if !exists { - return wsResponse{ID: msg.ID, Success: false, Error: "secret not found in group"} - } - return wsResponse{ID: msg.ID, Success: true, Data: GroupSecretReadResponse{ - Key: req.Key, - Group: safeGroup, - Value: fmt.Sprintf("%v", value), - }} - - case "secrets.delete": - var req struct { - Key string `json:"key"` - ID string `json:"id"` - Group string `json:"group"` - } - if err := json.Unmarshal(msg.Payload, &req); err != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "invalid secrets.delete payload", - } - } - key := req.Key - if key == "" { - key = req.ID - } - if strings.TrimSpace(key) == "" { - return wsResponse{ID: msg.ID, Success: false, Error: "key is required"} - } - group := req.Group - if group == "" { - group = "dev" - } - safeGroup := sanitizeSecretID(group) - recipients, err := s.getGroupRecipients(safeGroup) - if err != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "failed to get recipients: " + err.Error(), - } - } - groupSecrets, err := s.readGroupSecrets(safeGroup) - if err != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "failed to decrypt group secrets: " + err.Error(), - } - } - if _, exists := groupSecrets[key]; !exists { - return wsResponse{ID: msg.ID, Success: false, Error: "secret not found in group"} - } - delete(groupSecrets, key) - if len(groupSecrets) == 0 { - groupPath, _ := s.getGroupFilePath(safeGroup) - _ = os.Remove(groupPath) - } else { - if err := s.writeGroupSecrets(safeGroup, groupSecrets, recipients); err != nil { - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - } - _ = s.removeVariableEntry(key) - return wsResponse{ - ID: msg.ID, - Success: true, - Data: map[string]any{"deleted": true, "key": key, "group": safeGroup}, - } - - case "secrets.list": - // List all groups and their keys - groupsDir, err := s.getGroupsDir() - if err != nil { - return wsResponse{ - ID: msg.ID, - Success: false, - Error: "failed to get groups directory: " + err.Error(), - } - } - entries, err := os.ReadDir(groupsDir) - if err != nil { - if os.IsNotExist(err) { - return wsResponse{ - ID: msg.ID, - Success: true, - Data: map[string]any{"groups": map[string][]string{}}, - } - } - return wsResponse{ID: msg.ID, Success: false, Error: err.Error()} - } - groups := make(map[string][]string) - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { - continue - } - groupName := strings.TrimSuffix(entry.Name(), ".yaml") - groupSecrets, err := s.readGroupSecrets(groupName) - if err != nil { - groups[groupName] = []string{} - continue - } - keys := make([]string, 0, len(groupSecrets)) - for k := range groupSecrets { - keys = append(keys, k) - } - groups[groupName] = keys - } - return wsResponse{ID: msg.ID, Success: true, Data: map[string]any{"groups": groups}} - - default: - return wsResponse{ID: msg.ID, Success: false, Error: "unknown message type"} - } -} diff --git a/apps/stackpanel-go/internal/config/config.go b/apps/stackpanel-go/internal/config/config.go deleted file mode 100644 index a79389ef..00000000 --- a/apps/stackpanel-go/internal/config/config.go +++ /dev/null @@ -1,65 +0,0 @@ -package config - -import ( - "os" - "path/filepath" -) - -// Config holds the agent configuration. -type Config struct { - // Project root directory (contains .stack/config.nix) - ProjectRoot string - - // Port to listen on for HTTP/WebSocket connections - Port int - - // API endpoint to connect to (for cloud mode) - APIEndpoint string - - // Authentication token - AuthToken string - - // Allowed commands (empty = all allowed) - AllowedCommands []string - - // Data directory for agent state (~/.stack) - DataDir string - - // Allowed web UI origins (CORS + WebSocket Origin) - AllowedOrigins []string -} - -// ApplyDefaults applies default values to missing config fields. -func (c *Config) ApplyDefaults() error { - if c.Port == 0 { - c.Port = 9876 - } - - if c.APIEndpoint == "" { - c.APIEndpoint = "https://stackpanel.dev/api/agent" - } - - if c.DataDir == "" { - home, err := os.UserHomeDir() - if err != nil { - return err - } - c.DataDir = filepath.Join(home, ".stack") - } - - // Ensure data directory exists - if err := os.MkdirAll(c.DataDir, 0o755); err != nil { - return err - } - - // Resolve project root to absolute path (only if set) - if c.ProjectRoot != "" && !filepath.IsAbs(c.ProjectRoot) { - abs, err := filepath.Abs(c.ProjectRoot) - if err != nil { - return err - } - c.ProjectRoot = abs - } - - return nil -} diff --git a/apps/stackpanel-go/internal/docgen/docgen.go b/apps/stackpanel-go/internal/docgen/docgen.go index c6d4c555..3461fa6e 100644 --- a/apps/stackpanel-go/internal/docgen/docgen.go +++ b/apps/stackpanel-go/internal/docgen/docgen.go @@ -37,12 +37,6 @@ func mkpath(topic Topic, basedir string) string { return fmt.Sprintf("%s/%s", basedir, topic) } -// Run generates documentation without CLI command docs. -// Deprecated: Use RunWithCLI to also generate CLI documentation. -func Run(optionsPath string, docsDir string, nixModulesDir string) error { - return RunWithCLI(optionsPath, docsDir, nixModulesDir, nil) -} - // RunWithCLI is the main entry point. It generates all documentation sections // in sequence: options reference, module docs, and CLI reference. // Pass nil for rootCmd to skip CLI doc generation. diff --git a/apps/stackpanel-go/internal/docgen/types.go b/apps/stackpanel-go/internal/docgen/types.go index d255e824..b7edd48d 100644 --- a/apps/stackpanel-go/internal/docgen/types.go +++ b/apps/stackpanel-go/internal/docgen/types.go @@ -93,32 +93,3 @@ type ParsedDoc struct { Content string // Content after frontmatter OutputPath string // Resolved output path } - -// CLICommand represents a Cobra command's metadata extracted for documentation. -// The tree structure mirrors Cobra's command hierarchy, allowing recursive -// generation of nested command pages. -type CLICommand struct { - Name string // Command name (e.g., "services") - FullPath string // Full command path (e.g., "stack services start") - Use string // Usage string from cobra (e.g., "start [service...]") - Short string // Short description - Long string // Long description - Example string // Example usage - Aliases []string // Command aliases - Flags []CLIFlag // Command-specific flags - GlobalFlags []CLIFlag // Inherited/persistent flags - Subcommands []CLICommand // Nested subcommands - Deprecated string // Deprecation message if any - Hidden bool // Whether command is hidden -} - -// CLIFlag represents a single CLI flag extracted from a Cobra FlagSet. -type CLIFlag struct { - Name string // Long flag name (e.g., "verbose") - Shorthand string // Short flag (e.g., "v") - Type string // Flag type (e.g., "bool", "string", "int") - Default string // Default value - Description string // Flag description - Required bool // Whether flag is required - Persistent bool // Whether flag is inherited by subcommands -} diff --git a/apps/stackpanel-go/internal/fileops/apply.go b/apps/stackpanel-go/internal/fileops/apply.go index f49260e0..9a541f67 100644 --- a/apps/stackpanel-go/internal/fileops/apply.go +++ b/apps/stackpanel-go/internal/fileops/apply.go @@ -794,11 +794,6 @@ func loadStructured(path string, c codec) (map[string]any, bool, error) { return decoded, true, nil } -// loadJSONObject is kept for callers that only deal with JSON. -func loadJSONObject(path string) (map[string]any, bool, error) { - return loadStructured(path, jsonCodec{}) -} - func (a *applier) writeStructured( path string, c codec, diff --git a/apps/stackpanel-go/internal/fileops/codec.go b/apps/stackpanel-go/internal/fileops/codec.go index 31d0a158..0f116323 100644 --- a/apps/stackpanel-go/internal/fileops/codec.go +++ b/apps/stackpanel-go/internal/fileops/codec.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "strings" toml "github.com/pelletier/go-toml/v2" "gopkg.in/yaml.v3" @@ -198,8 +197,3 @@ func normalizeKeys(value any) any { return typed } } - -// describeCodec is used in error messages. -func describeCodec(entryType string) string { - return strings.TrimSuffix(entryType, "-ops") -} diff --git a/apps/stackpanel-go/internal/reconcile/plan.go b/apps/stackpanel-go/internal/reconcile/plan.go index 27b86d23..ab8a2913 100644 --- a/apps/stackpanel-go/internal/reconcile/plan.go +++ b/apps/stackpanel-go/internal/reconcile/plan.go @@ -2,8 +2,6 @@ package reconcile import ( "bytes" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "os" @@ -414,14 +412,3 @@ func describeSource(e PlanEntry) string { } return e.Format } - -// sha256File hashes a file; used by the files reconciler to compare disk -// against realized store paths without loading both into memory twice. -func sha256File(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]), nil -} diff --git a/apps/stackpanel-go/internal/tui/REFACTOR_PLAN.md b/apps/stackpanel-go/internal/tui/REFACTOR_PLAN.md deleted file mode 100644 index 0e0549a4..00000000 --- a/apps/stackpanel-go/internal/tui/REFACTOR_PLAN.md +++ /dev/null @@ -1,771 +0,0 @@ -# TUI Refactor Plan - -## Progress Summary - -| Phase | Status | Description | -|-------|--------|-------------| -| Phase 1 | ✅ Complete | Navigation Infrastructure (tree, model, breadcrumbs, menu) | -| Phase 2 | ✅ Complete | Output Handling (buffer, viewer with glamour) | -| Phase 3 | ✅ Complete | View Refactors (status, services, agent views) | -| Phase 4 | ✅ Complete | Root Command Integration (daemon mode, TUI launch) | -| Phase 5 | ✅ Complete | Polish & Testing | - -**Files Created:** -- `navigation/` - 4 files + tests (tree.go, model.go, breadcrumbs.go, menu.go) -- `output/` - 2 files + tests (buffer.go, viewer.go) -- `views/` - 3 files + tests (status.go, services.go, agent.go) -- `daemon.go` + tests - daemon mode utilities - -**Dependencies Added:** -- `github.com/charmbracelet/glamour` - Markdown rendering -- `github.com/mattn/go-isatty` - TTY detection (already a transitive dependency) - ---- - -## Overview - -Refactor the stack CLI to use an interactive TUI as the default interface. The TUI provides hierarchical navigation through commands, with proper handling of execution output and return-to-menu flows. - -## Design Principles - -1. **TUI by default**: Running `stack` without args launches the interactive TUI -2. **Hierarchical navigation**: Commands with subcommands show a menu; leaf commands execute -3. **Smart execution**: Leaf commands execute directly if all required args are provided -4. **Navigation controls**: - - `q` - Quit the application - - `esc` - Go up one level - - Breadcrumbs show current location (e.g., `stack > services > start`) -5. **Post-execution flow**: After running a command, show output (using glamour for markdown), then return to the same menu -6. **Daemon mode**: `-d` flag runs without TUI renderer for background processes - -## Architecture - -### Current State - -``` -apps/cli/internal/tui/ -├── motd.go # MOTD rendering (static, works fine) -├── services.go # Service start UI (bubble tea model) -├── status.go # Status dashboard (bubble tea model) -└── styles.go # Shared styles (works fine) -``` - -### Target State - -``` -apps/cli/internal/tui/ -├── motd.go # MOTD rendering (keep as-is) -├── styles.go # Shared styles (keep as-is) -├── REFACTOR_PLAN.md # This file -├── navigation/ -│ ├── model.go # Main navigation model (bubble tea) ✓ -│ ├── breadcrumbs.go # Breadcrumb rendering ✓ -│ ├── menu.go # Menu/selector view ✓ -│ ├── tree.go # Command tree structure ✓ -│ └── navigation_test.go # Test suite ✓ -├── output/ -│ ├── buffer.go # Capture command output ✓ -│ ├── viewer.go # Output viewing with glamour ✓ -│ └── output_test.go # Test suite ✓ -├── views/ -│ ├── status.go # Refactored status (table-based) -│ ├── services.go # Service management UI -│ └── agent.go # Agent split-screen view -└── daemon.go # Daemon mode support -``` - -## Implementation Checklist - -### Phase 1: Navigation Infrastructure ✅ COMPLETE - -- [x] **Create command tree structure** (`navigation/tree.go`) - - Define `CommandNode` type with name, description, isLeaf, children - - Build tree from cobra command hierarchy - - Handle arg requirements (skip TUI if args provided for leaf commands) - - `FindByPath()` for navigating to nodes by path - - `GetPath()`/`GetPathNodes()` for getting path to any node - - `CanExecute()` and `HasRequiredArgs()` for leaf command handling - -- [x] **Create base navigation model** (`navigation/model.go`) - - Implement bubble tea Init/Update/View - - Track current node in tree - - Track selection stack for going back (restores previous selection) - - Handle `q` (quit) and `esc` (back) keys - - Three view states: Menu, Output, Custom - - `RunNavigation(rootCmd)` entry point to launch TUI - -- [x] **Implement breadcrumbs** (`navigation/breadcrumbs.go`) - - Render path to current node (e.g., `stack > services > start`) - - Customizable styling with `BreadcrumbsStyle` - - Uses existing TUI theme colors - -- [x] **Implement menu/selector** (`navigation/menu.go`) - - List available subcommands at current node - - Arrow keys to navigate, enter to select - - Show command descriptions alongside names - - Highlight selected item with cursor - - Scrollable with `RenderWithMaxHeight()` for long menus - -- [x] **Test suite** (`navigation/navigation_test.go`) - - 8 tests covering tree, menu, breadcrumbs, and model - - All tests passing - -### Phase 2: Output Handling ✅ COMPLETE - -- [x] **Create output buffer** (`output/buffer.go`) - - Thread-safe `Buffer` type for concurrent writes - - Separate stdout/stderr capture with combined view - - `Capture()` function to capture output from function execution - - `CaptureWithWriters()` for manual capture control - - `TeeBuffer` for capturing while also writing to another destination - - io.Writer interfaces for stdout and stderr - -- [x] **Create output viewer** (`output/viewer.go`) - - `ViewerModel` - scrollable viewport with glamour markdown rendering - - Configurable with options: `WithTitle()`, `WithMarkdown()`, `WithShowHelp()` - - Scroll indicators and keyboard navigation (↑/↓, q/esc/enter to return) - - `SimpleViewer` for short, non-scrolling output - - `ReturnFromViewerMsg` for integration with navigation model - - Standalone runner functions: `RunViewer()`, `RunMarkdownViewer()` - -- [x] **Test suite** (`output/output_test.go`) - - 9 tests covering buffer and viewer functionality - - All tests passing - -### Phase 3: View Refactors ✅ COMPLETE - -- [x] **Refactor status view** (`views/status.go`) - - Table-based layout with columns: Service, Status, PID, Port, Details - - Caddy and certificate status sections - - Auto-refresh every 2 seconds with spinner during refresh - - Custom `StatusViewOption` for navigation integration - - `ReturnFromStatusMsg` for returning to navigation - - Styled with theme colors (header, selected, borders) - -- [x] **Create services view** (`views/services.go`) - - `ServicesView` for starting/stopping services - - `ServiceStartState` enum: Waiting, Starting, Running, Failed, Skipped - - Progress indicator with spinner for active service - - `ServicesViewMode` for Start, Stop, Restart operations - - Sequential service processing with status messages - - Navigation integration via `ReturnFromServicesMsg` - -- [x] **Create agent split view** (`views/agent.go`) - - Split screen with configurable ratio (0.2-0.8, default 0.6) - - Left panel: streaming logs viewport with auto-scroll - - Right panel: command menu with Status, Restart, Clear Logs, Back - - Tab to toggle focus between panels - - Log history limit (1000 lines) - - Connection status indicator with spinner - - `AgentViewOption` functions for customization - -- [x] **Test suite** (`views/views_test.go`) - - 16 tests covering all view functionality - - Tests for options, state changes, navigation, rendering - -### Phase 4: Root Command Integration ✅ COMPLETE - -- [x] **Add daemon mode support** (`daemon.go`) - - `DaemonMode` struct with `Enabled` and `LogOutput` fields - - `DetermineRunMode()` function to detect run mode from flags and environment - - `IsInteractive()` and `IsTTY()` for terminal detection using `go-isatty` - - `DaemonProgramOptions()` returns tea options for daemon mode (`WithoutRenderer`, no input) - - `NewProgram()` and `NewInteractiveProgram()` helper functions - - `LogWriter()` returns `io.Discard` for interactive, `os.Stderr` for daemon/direct - - `RunMode` enum: `RunModeInteractive`, `RunModeDaemon`, `RunModeDirect` - -- [x] **Update root command** (`cmd/root.go`) - - Added `--no-tui` flag to force non-interactive mode - - Added `-d/--daemon` flag for daemon mode - - Root command now has `Run` function that: - - Detects run mode using `tui.DetermineRunMode()` - - Launches TUI navigator when in interactive mode - - Falls back to help text in daemon/direct mode - - Import navigation and tui packages - -- [x] **Test suite** (`daemon_test.go`) - - 5 tests covering all daemon mode functionality - - Tests for `DetermineRunMode`, `LogWriter`, `DaemonProgramOptions` - - All 38 TUI tests passing - -- [ ] **Update all subcommands** (deferred to Phase 5) - - Commands already work from CLI, TUI integration via navigation model - - Return structured output - not needed for current scope - - Error handling is already consistent - -### Phase 5: Polish & Testing ✅ COMPLETE - -- [x] **Test navigation flows** (`navigation/integration_test.go`) - - Navigate through all command levels (TestDeepNavigationFlow) - - Verify `esc` and `q` work at all levels (TestNavigationModelEscapeAtAllLevels, TestQuitKeyAtAllLevels) - - Test breadcrumbs at various depths (TestBreadcrumbsAtAllLevels) - - Test menu navigation up/down (TestMenuNavigationUpDown) - - Test menu selection restoration on back navigation (TestMenuSelectionRestoration) - - Test leaf node detection (TestLeafNodeDetection) - - Test view renders without panic at all levels (TestViewRendersWithoutPanic) - -- [x] **Test command execution** (`navigation/execution_test.go`) - - Leaf command detection (TestLeafCommandIsLeaf, TestNonLeafCommandIsNotLeaf) - - Navigation model enter on leaf vs non-leaf (TestNavigationModelEnterOnLeaf, TestNavigationModelEnterOnNonLeaf) - - View state transitions (TestViewStateTransitions) - - Command tree preserves CobraCmd reference (TestCommandTreePreservesCobraCmd) - - Selection stack management (TestSelectionStackManagement) - - CommandExecutedMsg type (TestCommandExecutedMsg) - -- [x] **Test daemon mode** (`daemon_test.go`) - - DefaultDaemonMode initialization (TestDefaultDaemonMode) - - DetermineRunMode flag logic (TestDetermineRunMode) - - LogWriter output selection (TestLogWriter) - - DaemonProgramOptions (TestDaemonProgramOptions) - - RunMode constants (TestRunModeConstants) - -- [x] **Test special views** (`views/integration_test.go`) - - StatusView navigation integration (TestStatusViewNavigationIntegration) - - ServicesView navigation integration (TestServicesViewNavigationIntegration) - - AgentView navigation integration (TestAgentViewNavigationIntegration) - - All views render without panic (TestAllViewsRenderWithoutPanic) - - All views handle quit (TestAllViewsHandleQuit) - - All views handle Ctrl+C (TestAllViewsHandleCtrlC) - - Views show help hints (TestViewsShowHelpHints) - - ServicesView mode integration (TestServicesViewModeIntegration) - - AgentView focus indicators (TestAgentViewFocusIndicators) - - AgentView log operations (TestAgentViewLogOperations) - -**Test Summary:** -- Total tests: 53 passing -- Navigation tests: 26 tests -- Output tests: 9 tests -- Views tests: 23 tests -- Daemon tests: 5 tests - -## Examples to Reference - -### Table View (for status) -```go -// From the table example: -// - Use table.New() with columns and rows -// - Apply custom styles for header and selected -// - Handle focus/blur with esc key -// - Handle enter key for actions -``` - -### Spinner/Daemon Mode -```go -// From the spinner example: -// - Check if TTY with isatty.IsTerminal() -// - Use tea.WithoutRenderer() for daemon mode -// - Use io.Discard for logs in TUI mode -``` - -### Navigation Pattern -```go -type NavigationModel struct { - tree *CommandTree - currentNode *CommandNode - viewStack []View // for going back - breadcrumbs []string - selectedIdx int - quitting bool -} - -// q to quit at any level -// esc to go back one level -// enter to select/execute -``` - -## Migration Notes - -### Breaking Changes -- `stack` now shows TUI instead of help (use `--help` for help) -- Must use `--no-tui` flag to force non-interactive mode - -### Backward Compatibility -- All existing commands still work with direct args -- Scripts using `stack ` are unaffected -- Only affects interactive usage - -### Configuration -- Consider adding config file option to set TUI preferences -- Allow disabling TUI by default (for environments without TTY) - -## Future Enhancements - -- [ ] Tab-based views (if multiple contexts need simultaneous display) -- [ ] Command history (up/down to recall previous commands) -- [ ] Search/filter in menus (when many subcommands) -- [ ] Configurable keybindings -- [ ] Theme customization -- [ ] Mouse support (click to select) - -## Questions to Resolve - -1. Should we keep the current `RunStatusDashboard()` public API or migrate callers? -2. How should we handle commands that stream output (logs, etc)? -3. Should the TUI persist state between sessions (last viewed screen)? -4. Do we need a config file for TUI preferences or use flags only? - -## Dependencies - -- `github.com/charmbracelet/bubbletea` - TUI framework (already used) -- `github.com/charmbracelet/bubbles` - TUI components (already used) -- `github.com/charmbracelet/lipgloss` - Styling (already used) -- `github.com/charmbracelet/glamour` - Markdown rendering (NEW) -- `github.com/mattn/go-isatty` - TTY detection (NEW) - -## Success Criteria - -- [x] `stack` launches TUI by default when no args provided -- [x] Can navigate all command levels with esc/q -- [x] Breadcrumbs accurately show current location -- [x] Command output is displayed and user can return to menu -- [x] Status view uses table with proper styling -- [x] Agent view shows split screen correctly -- [x] Daemon mode works without TUI rendering -- [x] All existing CLI commands still work with args -- [x] No performance degradation in TUI mode -- [x] Tests pass for both TUI and non-TUI modes (53 tests passing) - - -## References - -Building tables - -```go -package main - -import ( - "fmt" - "os" - - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - -type model struct { - table table.Model -} - -func (m model) Init() tea.Cmd { return nil } - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc": - if m.table.Focused() { - m.table.Blur() - } else { - m.table.Focus() - } - case "q", "ctrl+c": - return m, tea.Quit - case "enter": - return m, tea.Batch( - tea.Printf("Let's go to %s!", m.table.SelectedRow()[1]), - ) - } - } - m.table, cmd = m.table.Update(msg) - return m, cmd -} - -func (m model) View() string { - return baseStyle.Render(m.table.View()) + "\n" -} - -func main() { - columns := []table.Column{ - {Title: "Rank", Width: 4}, - {Title: "City", Width: 10}, - {Title: "Country", Width: 10}, - {Title: "Population", Width: 10}, - } - - rows := []table.Row{ - {"1", "Tokyo", "Japan", "37,274,000"}, - {"2", "Delhi", "India", "32,065,760"}, - {"3", "Shanghai", "China", "28,516,904"}, - {"4", "Dhaka", "Bangladesh", "22,478,116"}, - {"5", "São Paulo", "Brazil", "22,429,800"}, - {"6", "Mexico City", "Mexico", "22,085,140"}, - {"7", "Cairo", "Egypt", "21,750,020"}, - {"8", "Beijing", "China", "21,333,332"}, - {"9", "Mumbai", "India", "20,961,472"}, - {"10", "Osaka", "Japan", "19,059,856"}, - {"11", "Chongqing", "China", "16,874,740"}, - {"12", "Karachi", "Pakistan", "16,839,950"}, - {"13", "Istanbul", "Turkey", "15,636,243"}, - {"14", "Kinshasa", "DR Congo", "15,628,085"}, - {"15", "Lagos", "Nigeria", "15,387,639"}, - {"16", "Buenos Aires", "Argentina", "15,369,919"}, - {"17", "Kolkata", "India", "15,133,888"}, - {"18", "Manila", "Philippines", "14,406,059"}, - {"19", "Tianjin", "China", "14,011,828"}, - {"20", "Guangzhou", "China", "13,964,637"}, - {"21", "Rio De Janeiro", "Brazil", "13,634,274"}, - {"22", "Lahore", "Pakistan", "13,541,764"}, - {"23", "Bangalore", "India", "13,193,035"}, - {"24", "Shenzhen", "China", "12,831,330"}, - {"25", "Moscow", "Russia", "12,640,818"}, - {"26", "Chennai", "India", "11,503,293"}, - {"27", "Bogota", "Colombia", "11,344,312"}, - {"28", "Paris", "France", "11,142,303"}, - {"29", "Jakarta", "Indonesia", "11,074,811"}, - {"30", "Lima", "Peru", "11,044,607"}, - {"31", "Bangkok", "Thailand", "10,899,698"}, - {"32", "Hyderabad", "India", "10,534,418"}, - {"33", "Seoul", "South Korea", "9,975,709"}, - {"34", "Nagoya", "Japan", "9,571,596"}, - {"35", "London", "United Kingdom", "9,540,576"}, - {"36", "Chengdu", "China", "9,478,521"}, - {"37", "Nanjing", "China", "9,429,381"}, - {"38", "Tehran", "Iran", "9,381,546"}, - {"39", "Ho Chi Minh City", "Vietnam", "9,077,158"}, - {"40", "Luanda", "Angola", "8,952,496"}, - {"41", "Wuhan", "China", "8,591,611"}, - {"42", "Xi An Shaanxi", "China", "8,537,646"}, - {"43", "Ahmedabad", "India", "8,450,228"}, - {"44", "Kuala Lumpur", "Malaysia", "8,419,566"}, - {"45", "New York City", "United States", "8,177,020"}, - {"46", "Hangzhou", "China", "8,044,878"}, - {"47", "Surat", "India", "7,784,276"}, - {"48", "Suzhou", "China", "7,764,499"}, - {"49", "Hong Kong", "Hong Kong", "7,643,256"}, - {"50", "Riyadh", "Saudi Arabia", "7,538,200"}, - {"51", "Shenyang", "China", "7,527,975"}, - {"52", "Baghdad", "Iraq", "7,511,920"}, - {"53", "Dongguan", "China", "7,511,851"}, - {"54", "Foshan", "China", "7,497,263"}, - {"55", "Dar Es Salaam", "Tanzania", "7,404,689"}, - {"56", "Pune", "India", "6,987,077"}, - {"57", "Santiago", "Chile", "6,856,939"}, - {"58", "Madrid", "Spain", "6,713,557"}, - {"59", "Haerbin", "China", "6,665,951"}, - {"60", "Toronto", "Canada", "6,312,974"}, - {"61", "Belo Horizonte", "Brazil", "6,194,292"}, - {"62", "Khartoum", "Sudan", "6,160,327"}, - {"63", "Johannesburg", "South Africa", "6,065,354"}, - {"64", "Singapore", "Singapore", "6,039,577"}, - {"65", "Dalian", "China", "5,930,140"}, - {"66", "Qingdao", "China", "5,865,232"}, - {"67", "Zhengzhou", "China", "5,690,312"}, - {"68", "Ji Nan Shandong", "China", "5,663,015"}, - {"69", "Barcelona", "Spain", "5,658,472"}, - {"70", "Saint Petersburg", "Russia", "5,535,556"}, - {"71", "Abidjan", "Ivory Coast", "5,515,790"}, - {"72", "Yangon", "Myanmar", "5,514,454"}, - {"73", "Fukuoka", "Japan", "5,502,591"}, - {"74", "Alexandria", "Egypt", "5,483,605"}, - {"75", "Guadalajara", "Mexico", "5,339,583"}, - {"76", "Ankara", "Turkey", "5,309,690"}, - {"77", "Chittagong", "Bangladesh", "5,252,842"}, - {"78", "Addis Ababa", "Ethiopia", "5,227,794"}, - {"79", "Melbourne", "Australia", "5,150,766"}, - {"80", "Nairobi", "Kenya", "5,118,844"}, - {"81", "Hanoi", "Vietnam", "5,067,352"}, - {"82", "Sydney", "Australia", "5,056,571"}, - {"83", "Monterrey", "Mexico", "5,036,535"}, - {"84", "Changsha", "China", "4,809,887"}, - {"85", "Brasilia", "Brazil", "4,803,877"}, - {"86", "Cape Town", "South Africa", "4,800,954"}, - {"87", "Jiddah", "Saudi Arabia", "4,780,740"}, - {"88", "Urumqi", "China", "4,710,203"}, - {"89", "Kunming", "China", "4,657,381"}, - {"90", "Changchun", "China", "4,616,002"}, - {"91", "Hefei", "China", "4,496,456"}, - {"92", "Shantou", "China", "4,490,411"}, - {"93", "Xinbei", "Taiwan", "4,470,672"}, - {"94", "Kabul", "Afghanistan", "4,457,882"}, - {"95", "Ningbo", "China", "4,405,292"}, - {"96", "Tel Aviv", "Israel", "4,343,584"}, - {"97", "Yaounde", "Cameroon", "4,336,670"}, - {"98", "Rome", "Italy", "4,297,877"}, - {"99", "Shijiazhuang", "China", "4,285,135"}, - {"100", "Montreal", "Canada", "4,276,526"}, - } - - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(7), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) - t.SetStyles(s) - - m := model{t} - if _, err := tea.NewProgram(m).Run(); err != nil { - fmt.Println("Error running program:", err) - os.Exit(1) - } -} - -``` - -**Building Tabs** - -```go - -package main - -import ( - "fmt" - "os" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type model struct { - Tabs []string - TabContent []string - activeTab int -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch keypress := msg.String(); keypress { - case "ctrl+c", "q": - return m, tea.Quit - case "right", "l", "n", "tab": - m.activeTab = min(m.activeTab+1, len(m.Tabs)-1) - return m, nil - case "left", "h", "p", "shift+tab": - m.activeTab = max(m.activeTab-1, 0) - return m, nil - } - } - - return m, nil -} - -func tabBorderWithBottom(left, middle, right string) lipgloss.Border { - border := lipgloss.RoundedBorder() - border.BottomLeft = left - border.Bottom = middle - border.BottomRight = right - return border -} - -var ( - inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴") - activeTabBorder = tabBorderWithBottom("┘", " ", "└") - docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2) - highlightColor = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} - inactiveTabStyle = lipgloss.NewStyle().Border(inactiveTabBorder, true).BorderForeground(highlightColor).Padding(0, 1) - activeTabStyle = inactiveTabStyle.Border(activeTabBorder, true) - windowStyle = lipgloss.NewStyle().BorderForeground(highlightColor).Padding(2, 0).Align(lipgloss.Center).Border(lipgloss.NormalBorder()).UnsetBorderTop() -) - -func (m model) View() string { - doc := strings.Builder{} - - var renderedTabs []string - - for i, t := range m.Tabs { - var style lipgloss.Style - isFirst, isLast, isActive := i == 0, i == len(m.Tabs)-1, i == m.activeTab - if isActive { - style = activeTabStyle - } else { - style = inactiveTabStyle - } - border, _, _, _, _ := style.GetBorder() - if isFirst && isActive { - border.BottomLeft = "│" - } else if isFirst && !isActive { - border.BottomLeft = "├" - } else if isLast && isActive { - border.BottomRight = "│" - } else if isLast && !isActive { - border.BottomRight = "┤" - } - style = style.Border(border) - renderedTabs = append(renderedTabs, style.Render(t)) - } - - row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) - doc.WriteString(row) - doc.WriteString("\n") - doc.WriteString(windowStyle.Width((lipgloss.Width(row) - windowStyle.GetHorizontalFrameSize())).Render(m.TabContent[m.activeTab])) - return docStyle.Render(doc.String()) -} - -func main() { - tabs := []string{"Lip Gloss", "Blush", "Eye Shadow", "Mascara", "Foundation"} - tabContent := []string{"Lip Gloss Tab", "Blush Tab", "Eye Shadow Tab", "Mascara Tab", "Foundation Tab"} - m := model{Tabs: tabs, TabContent: tabContent} - if _, err := tea.NewProgram(m).Run(); err != nil { - fmt.Println("Error running program:", err) - os.Exit(1) - } -} - -``` - - - -**glamour: good for view that render logs** - -```go -package main - -import ( - "fmt" - "os" - - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/lipgloss" -) - -const content = ` -# Today’s Menu - -## Appetizers - -| Name | Price | Notes | -| --- | --- | --- | -| Tsukemono | $2 | Just an appetizer | -| Tomato Soup | $4 | Made with San Marzano tomatoes | -| Okonomiyaki | $4 | Takes a few minutes to make | -| Curry | $3 | We can add squash if you’d like | - -## Seasonal Dishes - -| Name | Price | Notes | -| --- | --- | --- | -| Steamed bitter melon | $2 | Not so bitter | -| Takoyaki | $3 | Fun to eat | -| Winter squash | $3 | Today it's pumpkin | - -## Desserts - -| Name | Price | Notes | -| --- | --- | --- | -| Dorayaki | $4 | Looks good on rabbits | -| Banana Split | $5 | A classic | -| Cream Puff | $3 | Pretty creamy! | - -All our dishes are made in-house by Karen, our chef. Most of our ingredients -are from our garden or the fish market down the street. - -Some famous people that have eaten here lately: - -* [x] René Redzepi -* [x] David Chang -* [ ] Jiro Ono (maybe some day) - -Bon appétit! -` - -var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render - -type example struct { - viewport viewport.Model -} - -func newExample() (*example, error) { - const width = 78 - - vp := viewport.New(width, 20) - vp.Style = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - PaddingRight(2) - - // We need to adjust the width of the glamour render from our main width - // to account for a few things: - // - // * The viewport border width - // * The viewport padding - // * The viewport margins - // * The gutter glamour applies to the left side of the content - // - const glamourGutter = 2 - glamourRenderWidth := width - vp.Style.GetHorizontalFrameSize() - glamourGutter - - renderer, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(glamourRenderWidth), - ) - if err != nil { - return nil, err - } - - str, err := renderer.Render(content) - if err != nil { - return nil, err - } - - vp.SetContent(str) - - return &example{ - viewport: vp, - }, nil -} - -func (e example) Init() tea.Cmd { - return nil -} - -func (e example) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": - return e, tea.Quit - default: - var cmd tea.Cmd - e.viewport, cmd = e.viewport.Update(msg) - return e, cmd - } - default: - return e, nil - } -} - -func (e example) View() string { - return e.viewport.View() + e.helpView() -} - -func (e example) helpView() string { - return helpStyle("\n ↑/↓: Navigate • q: Quit\n") -} - -func main() { - model, err := newExample() - if err != nil { - fmt.Println("Could not initialize Bubble Tea model:", err) - os.Exit(1) - } - - if _, err := tea.NewProgram(model).Run(); err != nil { - fmt.Println("Bummer, there's been an error:", err) - os.Exit(1) - } -} -``` \ No newline at end of file diff --git a/apps/stackpanel-go/internal/tui/daemon.go b/apps/stackpanel-go/internal/tui/daemon.go index 9893a6c9..41370cd7 100644 --- a/apps/stackpanel-go/internal/tui/daemon.go +++ b/apps/stackpanel-go/internal/tui/daemon.go @@ -1,66 +1,18 @@ package tui import ( - "io" "os" tea "github.com/charmbracelet/bubbletea" "github.com/mattn/go-isatty" ) -// DaemonMode configures the TUI for headless operation (CI, background processes). -// When enabled, Bubble Tea's renderer and input are disabled so the program -// can run without a terminal. Logs go to LogOutput instead of the TUI. -type DaemonMode struct { - Enabled bool - LogOutput io.Writer -} - -// DefaultDaemonMode returns a DaemonMode with default settings -func DefaultDaemonMode() DaemonMode { - return DaemonMode{ - Enabled: false, - LogOutput: os.Stderr, - } -} - // IsInteractive returns true if both stdin and stdout are connected to a terminal. // Both must be TTYs because Bubble Tea needs to read key events and render ANSI. func IsInteractive() bool { return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) } -// IsTTY returns true if stdout is a terminal -func IsTTY() bool { - return isatty.IsTerminal(os.Stdout.Fd()) -} - -// DaemonProgramOptions returns tea.ProgramOption slice for daemon mode. -// Disabling the renderer and input prevents ANSI escape sequences from -// polluting log files or non-TTY output. -func DaemonProgramOptions(daemon DaemonMode) []tea.ProgramOption { - if daemon.Enabled { - return []tea.ProgramOption{ - tea.WithoutRenderer(), - tea.WithInput(nil), // No input in daemon mode - } - } - return nil -} - -// NewProgram creates a new tea.Program with the appropriate options based on mode -func NewProgram( - model tea.Model, - daemon DaemonMode, - opts ...tea.ProgramOption, -) *tea.Program { - // Combine daemon options with any provided options - allOpts := DaemonProgramOptions(daemon) - allOpts = append(allOpts, opts...) - - return tea.NewProgram(model, allOpts...) -} - // NewInteractiveProgram creates a tea.Program configured for interactive use func NewInteractiveProgram(model tea.Model, opts ...tea.ProgramOption) *tea.Program { defaultOpts := []tea.ProgramOption{ @@ -101,13 +53,3 @@ func DetermineRunMode(daemonFlag, noTUIFlag bool) RunMode { return RunModeInteractive } - -// LogWriter returns an appropriate writer for logs based on run mode. -// Interactive mode discards logs because the TUI manages its own display; -// writing to stdout/stderr would corrupt the Bubble Tea rendering. -func LogWriter(mode RunMode) io.Writer { - if mode == RunModeInteractive { - return io.Discard - } - return os.Stderr -} diff --git a/apps/stackpanel-go/internal/tui/daemon_test.go b/apps/stackpanel-go/internal/tui/daemon_test.go index 11d493b3..4c2c757d 100644 --- a/apps/stackpanel-go/internal/tui/daemon_test.go +++ b/apps/stackpanel-go/internal/tui/daemon_test.go @@ -1,22 +1,9 @@ package tui import ( - "io" - "os" "testing" ) -func TestDefaultDaemonMode(t *testing.T) { - dm := DefaultDaemonMode() - - if dm.Enabled { - t.Error("DefaultDaemonMode should have Enabled = false") - } - if dm.LogOutput != os.Stderr { - t.Error("DefaultDaemonMode should use os.Stderr for LogOutput") - } -} - func TestDetermineRunMode(t *testing.T) { tests := []struct { name string @@ -57,55 +44,6 @@ func TestDetermineRunMode(t *testing.T) { } } -func TestLogWriter(t *testing.T) { - tests := []struct { - name string - mode RunMode - expected io.Writer - }{ - { - name: "interactive mode discards logs", - mode: RunModeInteractive, - expected: io.Discard, - }, - { - name: "daemon mode writes to stderr", - mode: RunModeDaemon, - expected: os.Stderr, - }, - { - name: "direct mode writes to stderr", - mode: RunModeDirect, - expected: os.Stderr, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := LogWriter(tt.mode) - if result != tt.expected { - t.Errorf("LogWriter(%v) = %v, want %v", tt.mode, result, tt.expected) - } - }) - } -} - -func TestDaemonProgramOptions(t *testing.T) { - // Test that daemon mode returns options - dm := DaemonMode{Enabled: true} - opts := DaemonProgramOptions(dm) - if len(opts) == 0 { - t.Error("DaemonProgramOptions should return options when daemon mode is enabled") - } - - // Test that non-daemon mode returns nil - dm = DaemonMode{Enabled: false} - opts = DaemonProgramOptions(dm) - if opts != nil { - t.Error("DaemonProgramOptions should return nil when daemon mode is disabled") - } -} - func TestRunModeConstants(t *testing.T) { // Verify constants have expected values if RunModeInteractive != 0 { diff --git a/apps/stackpanel-go/internal/tui/healthcheck_runner.go b/apps/stackpanel-go/internal/tui/healthcheck_runner.go index 9b2226c2..5648bdae 100644 --- a/apps/stackpanel-go/internal/tui/healthcheck_runner.go +++ b/apps/stackpanel-go/internal/tui/healthcheck_runner.go @@ -4,7 +4,7 @@ // Nix module system. The runner supports script, HTTP, and TCP check types. // Nix-eval checks are deliberately skipped in CLI context (too slow). // -// Results are cached to .stack/state/healthchecks.json with a 5-minute TTL. +// Results are cached to .stack/state/healthchecks.json. // The MOTD reads this cache to display health status without re-running checks. package tui @@ -59,13 +59,12 @@ type ModuleHealthResult struct { const ( healthcheckCacheFile = "healthchecks.json" - healthcheckCacheTTL = 5 * time.Minute ) // LoadHealthcheckResults loads cached healthcheck results from disk without -// ever running checks. Returns nil if no cache file exists. Unlike -// LoadHealthcheckCache this ignores the TTL — results are always returned -// regardless of age so callers can display elapsed time and a re-run hint. +// ever running checks. Returns nil if no cache file exists. Results are +// returned regardless of age so callers can display elapsed time and a +// re-run hint. func LoadHealthcheckResults(stateDir string) *HealthcheckCache { path := filepath.Join(stateDir, healthcheckCacheFile) @@ -93,7 +92,7 @@ func RunFailedHealthchecks( return nil } - // Load existing cache (ignore TTL) + // Load existing cache existingCache := LoadHealthcheckResults(stateDir) cachedByID := make(map[string]HealthcheckResult) if existingCache != nil { @@ -334,29 +333,6 @@ func runTCPCheck(ctx context.Context, check nixconfig.Healthcheck, timeout time. return result } -// LoadHealthcheckCache reads cached results from disk. -// Returns nil if cache doesn't exist or is expired. -func LoadHealthcheckCache(stateDir string) *HealthcheckCache { - path := filepath.Join(stateDir, healthcheckCacheFile) - - data, err := os.ReadFile(path) - if err != nil { - return nil - } - - var cache HealthcheckCache - if err := json.Unmarshal(data, &cache); err != nil { - return nil - } - - // Check TTL - if time.Since(cache.Timestamp) > healthcheckCacheTTL { - return nil - } - - return &cache -} - // SaveHealthcheckCache writes results to disk cache. func SaveHealthcheckCache(stateDir string, results []HealthcheckResult) error { cache := HealthcheckCache{ @@ -378,31 +354,6 @@ func SaveHealthcheckCache(stateDir string, results []HealthcheckResult) error { return os.WriteFile(path, data, 0o644) } -// RunOrLoadHealthchecks returns cached results if within TTL, otherwise runs all -// checks fresh. This is the main entry point for non-MOTD healthcheck consumers -// (e.g., the `stack healthcheck` command). Results are persisted after running. -func RunOrLoadHealthchecks( - stateDir string, - checks []nixconfig.Healthcheck, -) []HealthcheckResult { - if len(checks) == 0 { - return nil - } - - // Try cache first - if cache := LoadHealthcheckCache(stateDir); cache != nil { - return cache.Results - } - - // Run checks - results := RunHealthchecks(checks) - - // Persist to disk (best-effort) - _ = SaveHealthcheckCache(stateDir, results) - - return results -} - // AggregateByModule groups results by module and computes per-module summaries. // Insertion order is preserved so modules appear in the same order as the checks. // Skipped checks don't count toward TotalChecks (only pass/fail do). diff --git a/apps/stackpanel-go/internal/tui/output/buffer.go b/apps/stackpanel-go/internal/tui/output/buffer.go index d1ca6ec2..c8fe012e 100644 --- a/apps/stackpanel-go/internal/tui/output/buffer.go +++ b/apps/stackpanel-go/internal/tui/output/buffer.go @@ -189,69 +189,3 @@ func Capture(fn func()) *Buffer { return buf } - -// CaptureWithWriters provides writers for manual capture control. -// Returns stdout writer, stderr writer, and a function to get the final buffer. -func CaptureWithWriters() (stdout io.Writer, stderr io.Writer, getBuffer func() *Buffer) { - buf := NewBuffer() - return buf.StdoutWriter(), buf.StderrWriter(), func() *Buffer { return buf } -} - -// TeeBuffer wraps a Buffer and also writes to additional writers (like os.Stdout). -// Useful for capturing output while still showing it in real-time (e.g., RunModeDirect). -type TeeBuffer struct { - *Buffer - stdoutTee io.Writer - stderrTee io.Writer -} - -// NewTeeBuffer creates a buffer that also writes to the provided tee writers -func NewTeeBuffer(stdoutTee, stderrTee io.Writer) *TeeBuffer { - return &TeeBuffer{ - Buffer: NewBuffer(), - stdoutTee: stdoutTee, - stderrTee: stderrTee, - } -} - -// WriteStdout writes to both the buffer and the tee writer -func (t *TeeBuffer) WriteStdout(p []byte) (n int, err error) { - if t.stdoutTee != nil { - t.stdoutTee.Write(p) - } - return t.Buffer.WriteStdout(p) -} - -// WriteStderr writes to both the buffer and the tee writer -func (t *TeeBuffer) WriteStderr(p []byte) (n int, err error) { - if t.stderrTee != nil { - t.stderrTee.Write(p) - } - return t.Buffer.WriteStderr(p) -} - -// StdoutWriter returns an io.Writer that writes to both buffer and tee -func (t *TeeBuffer) StdoutWriter() io.Writer { - return &teeStdoutWriter{t: t} -} - -// StderrWriter returns an io.Writer that writes to both buffer and tee -func (t *TeeBuffer) StderrWriter() io.Writer { - return &teeStderrWriter{t: t} -} - -type teeStdoutWriter struct { - t *TeeBuffer -} - -func (w *teeStdoutWriter) Write(p []byte) (n int, err error) { - return w.t.WriteStdout(p) -} - -type teeStderrWriter struct { - t *TeeBuffer -} - -func (w *teeStderrWriter) Write(p []byte) (n int, err error) { - return w.t.WriteStderr(p) -} diff --git a/apps/stackpanel-go/internal/tui/output/output_test.go b/apps/stackpanel-go/internal/tui/output/output_test.go index cee0ca34..29923bfe 100644 --- a/apps/stackpanel-go/internal/tui/output/output_test.go +++ b/apps/stackpanel-go/internal/tui/output/output_test.go @@ -111,39 +111,6 @@ func TestBufferBytes(t *testing.T) { } } -func TestCaptureWithWriters(t *testing.T) { - stdout, stderr, getBuffer := CaptureWithWriters() - - stdout.Write([]byte("captured stdout\n")) - stderr.Write([]byte("captured stderr\n")) - - buf := getBuffer() - if !strings.Contains(buf.Stdout(), "captured stdout") { - t.Error("CaptureWithWriters should capture stdout") - } - if !strings.Contains(buf.Stderr(), "captured stderr") { - t.Error("CaptureWithWriters should capture stderr") - } -} - -func TestTeeBuffer(t *testing.T) { - // Create a buffer to tee to - var teeBuf strings.Builder - - tee := NewTeeBuffer(&teeBuf, &teeBuf) - tee.WriteStdout([]byte("teed stdout")) - - // Should be in main buffer - if tee.Stdout() != "teed stdout" { - t.Errorf("TeeBuffer.Stdout = %q, want %q", tee.Stdout(), "teed stdout") - } - - // Should also be in tee buffer - if !strings.Contains(teeBuf.String(), "teed stdout") { - t.Error("Tee buffer should contain output") - } -} - func TestViewerModel(t *testing.T) { content := "Test content for viewer" viewer := NewViewerModel(content) @@ -194,29 +161,6 @@ func TestViewerModelSetters(t *testing.T) { } } -func TestSimpleViewer(t *testing.T) { - viewer := NewSimpleViewer("Title", "Content") - - if viewer.title != "Title" { - t.Errorf("title = %q, want %q", viewer.title, "Title") - } - if viewer.content != "Content" { - t.Errorf("content = %q, want %q", viewer.content, "Content") - } - - // View should contain title and content - view := viewer.View() - if !strings.Contains(view, "Title") { - t.Error("View should contain title") - } - if !strings.Contains(view, "Content") { - t.Error("View should contain content") - } - if !strings.Contains(view, "Press any key") { - t.Error("View should contain help text") - } -} - func TestViewerModelView(t *testing.T) { viewer := NewViewerModel("Test content", WithTitle("Test")) diff --git a/apps/stackpanel-go/internal/tui/output/viewer.go b/apps/stackpanel-go/internal/tui/output/viewer.go index 5f7e80c1..8faecb6c 100644 --- a/apps/stackpanel-go/internal/tui/output/viewer.go +++ b/apps/stackpanel-go/internal/tui/output/viewer.go @@ -275,93 +275,3 @@ func (m *ViewerModel) SetMarkdown(enabled bool) { func (m ViewerModel) Content() string { return m.content } - -// RunViewer launches the output viewer as a standalone program -func RunViewer(content string, opts ...ViewerOption) error { - model := NewViewerModel(content, opts...) - p := tea.NewProgram(model, tea.WithAltScreen()) - _, err := p.Run() - return err -} - -// RunViewerWithTitle is a convenience function to run a viewer with a title -func RunViewerWithTitle(title, content string) error { - return RunViewer(content, WithTitle(title)) -} - -// RunMarkdownViewer runs a viewer with markdown rendering enabled -func RunMarkdownViewer(content string, opts ...ViewerOption) error { - opts = append([]ViewerOption{WithMarkdown()}, opts...) - return RunViewer(content, opts...) -} - -// SimpleViewer provides a non-scrolling view for short output that fits on one screen. -// Any keypress dismisses it. Use ViewerModel instead for long/scrollable content. -type SimpleViewer struct { - content string - title string - quitting bool -} - -// NewSimpleViewer creates a simple viewer for short content -func NewSimpleViewer(title, content string) SimpleViewer { - return SimpleViewer{ - title: title, - content: content, - } -} - -// Init implements tea.Model -func (m SimpleViewer) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model -func (m SimpleViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if _, ok := msg.(tea.KeyMsg); ok { - // Any key returns from viewer - m.quitting = true - return m, func() tea.Msg { return ReturnFromViewerMsg{} } - } - return m, nil -} - -// View implements tea.Model -func (m SimpleViewer) View() string { - if m.quitting { - return "" - } - - var b strings.Builder - - // Title - if m.title != "" { - titleStyle := lipgloss.NewStyle(). - Foreground(tui.ColorSecondary). - Bold(true) - b.WriteString(titleStyle.Render(m.title)) - b.WriteString("\n\n") - } - - // Content - contentStyle := lipgloss.NewStyle(). - PaddingLeft(2) - b.WriteString(contentStyle.Render(m.content)) - b.WriteString("\n") - - // Help - help := lipgloss.NewStyle(). - Foreground(tui.ColorDim). - MarginTop(1). - Render("\nPress any key to return...") - b.WriteString(help) - - return b.String() -} - -// RunSimpleViewer runs a simple viewer for short content -func RunSimpleViewer(title, content string) error { - p := tea.NewProgram(NewSimpleViewer(title, content)) - _, err := p.Run() - return err -} diff --git a/apps/stackpanel-go/internal/tui/status.go b/apps/stackpanel-go/internal/tui/status.go index 49677399..a04d34dd 100644 --- a/apps/stackpanel-go/internal/tui/status.go +++ b/apps/stackpanel-go/internal/tui/status.go @@ -27,8 +27,7 @@ type ServiceInfo struct { } // StatusModel is the Bubble Tea model for the standalone status dashboard. -// Deprecated: Use views.StatusView instead, which integrates with the navigation system. -// This remains for the `stack status` command when run without the TUI navigator. +// It backs the `stack status` command when run without the TUI navigator. type StatusModel struct { services []ServiceInfo caddyInfo ServiceInfo diff --git a/apps/stackpanel-go/internal/tui/views/agent.go b/apps/stackpanel-go/internal/tui/views/agent.go deleted file mode 100644 index 8cf9dfe8..00000000 --- a/apps/stackpanel-go/internal/tui/views/agent.go +++ /dev/null @@ -1,520 +0,0 @@ -package views - -import ( - "strings" - "time" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/darkmatter/stackpanel/stackpanel-go/internal/tui" -) - -// AgentView provides a split-screen view with streaming logs (left panel) and -// a command menu (right panel). Tab switches focus between panels. The logs -// panel auto-scrolls by default and caps history at 1000 lines. -type AgentView struct { - // Layout - width int - height int - splitRatio float64 // 0.0-1.0, portion for logs panel - - // Logs panel (left) - logsViewport viewport.Model - logs []string - autoScroll bool - - // Menu panel (right) - menuItems []AgentMenuItem - selectedIdx int - - // State - spinner spinner.Model - focus AgentFocus - quitting bool - connected bool - - // For navigation integration - returnMsg tea.Msg -} - -// AgentFocus indicates which panel has focus -type AgentFocus int - -const ( - FocusLogs AgentFocus = iota - FocusMenu -) - -// AgentMenuItem represents a menu item in the agent view -type AgentMenuItem struct { - Name string - Description string - Action func() tea.Cmd -} - -// Agent messages -type ( - agentLogMsg string - agentConnectedMsg struct{} - agentTickMsg struct{} -) - -// ReturnFromAgentMsg signals return to navigation -type ReturnFromAgentMsg struct{} - -// AgentViewOption configures the AgentView -type AgentViewOption func(*AgentView) - -// WithAgentReturnMsg sets a custom message to send when returning -func WithAgentReturnMsg(msg tea.Msg) AgentViewOption { - return func(v *AgentView) { - v.returnMsg = msg - } -} - -// WithSplitRatio sets the split ratio (0.0-1.0 for logs panel width). -// Clamped to [0.2, 0.8] to keep both panels usable. -func WithSplitRatio(ratio float64) AgentViewOption { - return func(v *AgentView) { - if ratio < 0.2 { - ratio = 0.2 - } - if ratio > 0.8 { - ratio = 0.8 - } - v.splitRatio = ratio - } -} - -// WithMenuItems sets the menu items -func WithMenuItems(items []AgentMenuItem) AgentViewOption { - return func(v *AgentView) { - v.menuItems = items - } -} - -// NewAgentView creates a new agent split view -func NewAgentView(opts ...AgentViewOption) AgentView { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = tui.SpinnerStyle - - // Default menu items - defaultMenu := []AgentMenuItem{ - {Name: "Status", Description: "Show agent status"}, - {Name: "Restart", Description: "Restart the agent"}, - {Name: "Clear Logs", Description: "Clear the log view"}, - {Name: "Back", Description: "Return to menu"}, - } - - view := AgentView{ - splitRatio: 0.6, // 60% for logs - logsViewport: viewport.New(40, 20), - logs: []string{}, - autoScroll: true, - menuItems: defaultMenu, - selectedIdx: 0, - spinner: s, - focus: FocusLogs, - connected: false, - } - - for _, opt := range opts { - opt(&view) - } - - return view -} - -// Init implements tea.Model -func (m AgentView) Init() tea.Cmd { - return tea.Batch( - m.spinner.Tick, - m.tickCmd(), - ) -} - -func (m AgentView) tickCmd() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return agentTickMsg{} - }) -} - -// Update implements tea.Model -func (m AgentView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - return m.handleKeyMsg(msg) - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.updateLayout() - - case agentLogMsg: - m.addLog(string(msg)) - - case agentConnectedMsg: - m.connected = true - m.addLog("Agent connected") - - case agentTickMsg: - cmds = append(cmds, m.tickCmd()) - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - cmds = append(cmds, cmd) - } - - // Update viewport if focused on logs - if m.focus == FocusLogs { - var cmd tea.Cmd - m.logsViewport, cmd = m.logsViewport.Update(msg) - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} - -func (m AgentView) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - m.quitting = true - return m, tea.Quit - - case "esc": - if m.returnMsg != nil { - return m, func() tea.Msg { return m.returnMsg } - } - return m, func() tea.Msg { return ReturnFromAgentMsg{} } - - case "tab": - // Toggle focus between panels - if m.focus == FocusLogs { - m.focus = FocusMenu - } else { - m.focus = FocusLogs - } - return m, nil - - case "up", "k": - if m.focus == FocusMenu { - if m.selectedIdx > 0 { - m.selectedIdx-- - } - } - return m, nil - - case "down", "j": - if m.focus == FocusMenu { - if m.selectedIdx < len(m.menuItems)-1 { - m.selectedIdx++ - } - } - return m, nil - - case "enter", " ": - if m.focus == FocusMenu { - return m.executeMenuItem() - } - return m, nil - - case "a": - // Toggle auto-scroll - m.autoScroll = !m.autoScroll - return m, nil - - case "c": - // Clear logs - m.logs = []string{} - m.updateLogsContent() - return m, nil - } - - return m, nil -} - -func (m AgentView) executeMenuItem() (tea.Model, tea.Cmd) { - if m.selectedIdx >= len(m.menuItems) { - return m, nil - } - - item := m.menuItems[m.selectedIdx] - - // Handle built-in actions - switch item.Name { - case "Back": - if m.returnMsg != nil { - return m, func() tea.Msg { return m.returnMsg } - } - return m, func() tea.Msg { return ReturnFromAgentMsg{} } - case "Clear Logs": - m.logs = []string{} - m.updateLogsContent() - return m, nil - case "Status": - m.addLog("Agent status: " + m.statusText()) - return m, nil - case "Restart": - m.addLog("Restarting agent...") - m.connected = false - return m, nil - } - - // Custom action - if item.Action != nil { - return m, item.Action() - } - - return m, nil -} - -func (m *AgentView) addLog(line string) { - timestamp := time.Now().Format("15:04:05") - m.logs = append(m.logs, timestamp+" "+line) - - // Limit log history - if len(m.logs) > 1000 { - m.logs = m.logs[len(m.logs)-1000:] - } - - m.updateLogsContent() -} - -func (m *AgentView) updateLogsContent() { - content := strings.Join(m.logs, "\n") - m.logsViewport.SetContent(content) - - if m.autoScroll { - m.logsViewport.GotoBottom() - } -} - -func (m *AgentView) updateLayout() { - // Calculate panel widths - logsWidth := int(float64(m.width) * m.splitRatio) - menuWidth := m.width - logsWidth - 3 // 3 for divider - - // Account for borders and padding - contentHeight := m.height - 4 // header + footer - - // Update logs viewport - m.logsViewport.Width = logsWidth - 4 - m.logsViewport.Height = contentHeight - 2 - - _ = menuWidth // Used in View() -} - -func (m AgentView) statusText() string { - if m.connected { - return "Connected" - } - return "Disconnected" -} - -// View implements tea.Model -func (m AgentView) View() string { - if m.quitting { - return "" - } - - // Handle zero dimensions (before window size message) - if m.width < 20 || m.height < 10 { - return tui.RenderFrame("Initializing...") - } - - // Calculate panel widths - logsWidth := int(float64(m.width) * m.splitRatio) - menuWidth := m.width - logsWidth - 1 // 1 for divider - - // Header - header := m.renderHeader() - - // Logs panel - logsPanel := m.renderLogsPanel(logsWidth) - - // Menu panel - menuPanel := m.renderMenuPanel(menuWidth) - - // Divider - divider := lipgloss.NewStyle(). - Foreground(tui.ColorBorder). - Render(strings.Repeat("│\n", m.height-4)) - - // Join panels horizontally - content := lipgloss.JoinHorizontal( - lipgloss.Top, - logsPanel, - divider, - menuPanel, - ) - - // Footer - footer := m.renderFooter() - - return tui.RenderFrame(lipgloss.JoinVertical(lipgloss.Left, header, content, footer)) -} - -func (m AgentView) renderHeader() string { - title := lipgloss.NewStyle(). - Foreground(tui.ColorSecondary). - Bold(true). - Render("Agent Monitor") - - status := m.spinner.View() + " " - if m.connected { - status = tui.RenderSuccess("Connected") - } else { - status += tui.TextDim.Render("Connecting...") - } - - // Right-align status - gap := m.width - lipgloss.Width(title) - lipgloss.Width(status) - 2 - if gap < 0 { - gap = 0 - } - - return title + strings.Repeat(" ", gap) + status + "\n" -} - -func (m AgentView) renderLogsPanel(width int) string { - // Panel title - titleStyle := lipgloss.NewStyle(). - Foreground(tui.ColorPrimary). - Bold(true) - - title := titleStyle.Render("Logs") - if m.focus == FocusLogs { - title = titleStyle.Foreground(tui.ColorSecondary).Render("▶ Logs") - } - - autoScrollIndicator := "" - if m.autoScroll { - autoScrollIndicator = tui.TextDim.Render(" [auto-scroll]") - } - - // Logs content - logsStyle := lipgloss.NewStyle(). - Width(width-2). - Height(m.height-6). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(tui.ColorBorder). - Padding(0, 1) - - if m.focus == FocusLogs { - logsStyle = logsStyle.BorderForeground(tui.ColorPrimary) - } - - content := m.logsViewport.View() - if len(m.logs) == 0 { - content = tui.TextDim.Render("No logs yet...") - } - - return lipgloss.JoinVertical( - lipgloss.Left, - title+autoScrollIndicator, - logsStyle.Render(content), - ) -} - -func (m AgentView) renderMenuPanel(width int) string { - // Panel title - titleStyle := lipgloss.NewStyle(). - Foreground(tui.ColorPrimary). - Bold(true) - - title := titleStyle.Render("Commands") - if m.focus == FocusMenu { - title = titleStyle.Foreground(tui.ColorSecondary).Render("▶ Commands") - } - - // Menu items - var menuContent strings.Builder - for i, item := range m.menuItems { - isSelected := i == m.selectedIdx && m.focus == FocusMenu - - itemStyle := lipgloss.NewStyle(). - Width(width-6). - Padding(0, 1) - - cursor := " " - if isSelected { - cursor = tui.SymbolArrow + " " - itemStyle = itemStyle. - Foreground(tui.ColorWhite). - Background(tui.ColorPrimary). - Bold(true) - } - - name := itemStyle.Render(cursor + item.Name) - menuContent.WriteString(name) - menuContent.WriteString("\n") - - // Description - if item.Description != "" && isSelected { - descStyle := lipgloss.NewStyle(). - Foreground(tui.ColorDim). - PaddingLeft(4) - menuContent.WriteString(descStyle.Render(item.Description)) - menuContent.WriteString("\n") - } - } - - // Menu container - menuStyle := lipgloss.NewStyle(). - Width(width-2). - Height(m.height-6). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(tui.ColorBorder). - Padding(0, 1) - - if m.focus == FocusMenu { - menuStyle = menuStyle.BorderForeground(tui.ColorPrimary) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - title, - menuStyle.Render(menuContent.String()), - ) -} - -func (m AgentView) renderFooter() string { - var hints []string - - hints = append(hints, "tab: switch panel") - if m.focus == FocusLogs { - hints = append(hints, "a: toggle auto-scroll") - hints = append(hints, "↑/↓: scroll") - } else { - hints = append(hints, "↑/↓: select") - hints = append(hints, "enter: execute") - } - hints = append(hints, "esc: back") - hints = append(hints, "q: quit") - - return tui.HelpStyle.Render(strings.Join(hints, " • ")) -} - -// AddLog adds a log line to the agent view (for external use) -func (m *AgentView) AddLog(line string) { - m.addLog(line) -} - -// SetConnected sets the connection status -func (m *AgentView) SetConnected(connected bool) { - m.connected = connected -} - -// RunAgentView launches the agent view as a standalone program -func RunAgentView(opts ...AgentViewOption) error { - model := NewAgentView(opts...) - p := tea.NewProgram(model, tea.WithAltScreen()) - _, err := p.Run() - return err -} diff --git a/apps/stackpanel-go/internal/tui/views/integration_test.go b/apps/stackpanel-go/internal/tui/views/integration_test.go deleted file mode 100644 index fb59a59e..00000000 --- a/apps/stackpanel-go/internal/tui/views/integration_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package views - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -// TestStatusViewNavigationIntegration tests that StatusView works with navigation -func TestStatusViewNavigationIntegration(t *testing.T) { - // Create status view with return message - customReturn := ReturnFromStatusMsg{} - view := NewStatusView(WithStatusReturnMsg(customReturn)) - - // Verify return message is set - if view.returnMsg == nil { - t.Error("Return message should be set") - } - - // Set window size - newView, _ := view.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - view = newView.(StatusView) - - // Press escape should return the message - newView, cmd := view.Update(tea.KeyMsg{Type: tea.KeyEscape}) - view = newView.(StatusView) - - if cmd == nil { - t.Error("Escape should return a command") - } - - // Execute the command and verify it returns the right message type - msg := cmd() - if _, ok := msg.(ReturnFromStatusMsg); !ok { - t.Errorf("Expected ReturnFromStatusMsg, got %T", msg) - } -} - -func TestStatusViewDefaultReturn(t *testing.T) { - // Create status view without custom return - view := NewStatusView() - - // Set window size - newView, _ := view.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - view = newView.(StatusView) - - // Press escape should return default message - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyEscape}) - - if cmd == nil { - t.Error("Escape should return a command") - } - - msg := cmd() - if _, ok := msg.(ReturnFromStatusMsg); !ok { - t.Errorf("Expected ReturnFromStatusMsg, got %T", msg) - } -} - -func TestServicesViewNavigationIntegration(t *testing.T) { - // Create services view with return message - customReturn := ReturnFromServicesMsg{} - view := NewServicesView([]string{"test"}, WithServicesReturnMsg(customReturn)) - - // Verify return message is set - if view.returnMsg == nil { - t.Error("Return message should be set") - } - - // Mark as done so we can escape - view.done = true - - // Press escape should return the message - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyEscape}) - - if cmd == nil { - t.Error("Escape should return a command when done") - } - - msg := cmd() - if _, ok := msg.(ReturnFromServicesMsg); !ok { - t.Errorf("Expected ReturnFromServicesMsg, got %T", msg) - } -} - -func TestServicesViewEscapeBeforeDone(t *testing.T) { - view := NewServicesView([]string{"test"}) - - // Not done yet - view.done = false - - // Press escape should not return (can't escape during operation) - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyEscape}) - - // Should not return a navigation command (nil or spinner tick) - if cmd != nil { - msg := cmd() - if _, ok := msg.(ReturnFromServicesMsg); ok { - t.Error("Should not be able to escape before done") - } - } -} - -func TestAgentViewNavigationIntegration(t *testing.T) { - // Create agent view with return message - customReturn := ReturnFromAgentMsg{} - view := NewAgentView(WithAgentReturnMsg(customReturn)) - - // Verify return message is set - if view.returnMsg == nil { - t.Error("Return message should be set") - } - - // Press escape should return the message - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyEscape}) - - if cmd == nil { - t.Error("Escape should return a command") - } - - msg := cmd() - if _, ok := msg.(ReturnFromAgentMsg); !ok { - t.Errorf("Expected ReturnFromAgentMsg, got %T", msg) - } -} - -func TestAgentViewBackMenuItem(t *testing.T) { - view := NewAgentView() - - // Set focus to menu - view.focus = FocusMenu - - // Find the "Back" menu item - backIdx := -1 - for i, item := range view.menuItems { - if item.Name == "Back" { - backIdx = i - break - } - } - - if backIdx == -1 { - t.Fatal("Back menu item not found") - } - - // Select the back item - view.selectedIdx = backIdx - - // Press enter to execute - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyEnter}) - - if cmd == nil { - t.Error("Selecting Back should return a command") - } - - msg := cmd() - if _, ok := msg.(ReturnFromAgentMsg); !ok { - t.Errorf("Back should return ReturnFromAgentMsg, got %T", msg) - } -} - -func TestAllViewsRenderWithoutPanic(t *testing.T) { - tests := []struct { - name string - view tea.Model - }{ - {"StatusView", NewStatusView()}, - {"ServicesView", NewServicesView([]string{"test"})}, - {"AgentView", NewAgentView()}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Render before window size (should not panic) - output := tt.view.View() - if output == "" { - t.Error("View should return something even before window size") - } - - // Update with window size - newView, _ := tt.view.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - - // Render after window size - output = newView.View() - if output == "" { - t.Error("View should return content after window size") - } - }) - } -} - -func TestAllViewsHandleQuit(t *testing.T) { - tests := []struct { - name string - view tea.Model - }{ - {"StatusView", NewStatusView()}, - {"ServicesView", NewServicesView([]string{"test"})}, - {"AgentView", NewAgentView()}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Press 'q' should quit - _, cmd := tt.view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) - - // Should return quit command - if cmd == nil { - t.Error("q key should return a command") - } - }) - } -} - -func TestAllViewsHandleCtrlC(t *testing.T) { - tests := []struct { - name string - view tea.Model - }{ - {"StatusView", NewStatusView()}, - {"ServicesView", NewServicesView([]string{"test"})}, - {"AgentView", NewAgentView()}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Press Ctrl+C should quit - _, cmd := tt.view.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) - - // Should return quit command - if cmd == nil { - t.Error("Ctrl+C should return a command") - } - }) - } -} - -func TestViewsShowHelpHints(t *testing.T) { - // StatusView should show help hints - statusView := NewStatusView() - updated, _ := statusView.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - statusView = updated.(StatusView) - - // Simulate loading complete - statusView.loading = false - output := statusView.View() - if !strings.Contains(output, "esc") && !strings.Contains(output, "back") { - t.Error("StatusView should show escape/back hint") - } - - // AgentView should show help hints - agentView := NewAgentView() - agentView.width = 100 - agentView.height = 40 - output = agentView.View() - if !strings.Contains(output, "esc") && !strings.Contains(output, "back") { - t.Error("AgentView should show escape/back hint") - } -} - -func TestServicesViewModeIntegration(t *testing.T) { - modes := []struct { - mode ServicesViewMode - expected string - }{ - {ModeStart, "Starting"}, - {ModeStop, "Stopping"}, - {ModeRestart, "Restarting"}, - } - - for _, m := range modes { - t.Run(m.expected, func(t *testing.T) { - view := NewServicesView([]string{"test"}, WithMode(m.mode)) - output := view.View() - if !strings.Contains(output, m.expected) { - t.Errorf("Expected %q in output for mode %d", m.expected, m.mode) - } - }) - } -} - -func TestAgentViewFocusIndicators(t *testing.T) { - view := NewAgentView() - view.width = 100 - view.height = 40 - - // Test logs focus - view.focus = FocusLogs - output := view.View() - // Should have some indicator that logs are focused - if !strings.Contains(output, "Logs") { - t.Error("Should show Logs panel") - } - - // Test menu focus - view.focus = FocusMenu - output = view.View() - // Should have some indicator that menu is focused - if !strings.Contains(output, "Commands") { - t.Error("Should show Commands panel") - } -} - -func TestAgentViewLogOperations(t *testing.T) { - view := NewAgentView() - - // Add some logs - view.AddLog("Test log 1") - view.AddLog("Test log 2") - view.AddLog("Test log 3") - - if len(view.logs) != 3 { - t.Errorf("Expected 3 logs, got %d", len(view.logs)) - } - - // Logs should contain timestamps - for _, log := range view.logs { - if !strings.Contains(log, ":") { // Timestamps have colons - t.Error("Log should contain timestamp") - } - } - - // Clear logs (via menu item simulation) - view.focus = FocusMenu - for i, item := range view.menuItems { - if item.Name == "Clear Logs" { - view.selectedIdx = i - break - } - } - view.Update(tea.KeyMsg{Type: tea.KeyEnter}) - - // Note: Clear logs is handled internally, so logs should be cleared - // In the actual implementation, this happens in executeMenuItem -} diff --git a/apps/stackpanel-go/internal/tui/views/secrets/model.go b/apps/stackpanel-go/internal/tui/views/secrets/model.go deleted file mode 100644 index 1906e9ea..00000000 --- a/apps/stackpanel-go/internal/tui/views/secrets/model.go +++ /dev/null @@ -1,549 +0,0 @@ -package secrets - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/darkmatter/stackpanel/stackpanel-go/internal/tui" -) - -// Package secrets provides the TUI view for browsing and inspecting SOPS-encrypted -// secrets. It communicates with the stackpanel agent API to list groups, recipients, -// and decrypt individual secret values on demand. - -// --- Messages --- - -type dataLoadedMsg struct { - groups map[string]groupData - recipients []recipient - workflow workflowStatus - err error -} - -type secretValueMsg struct { - key string - group string - value string - err error -} - -type tickMsg time.Time - -type returnMsg struct{} - -// --- Data types --- - -type groupData struct { - Name string - Keys []string - PubKey string - Initialized bool -} - -type recipient struct { - Name string `json:"name"` - PublicKey string `json:"publicKey"` -} - -type workflowStatus struct { - Exists bool `json:"exists"` -} - -// --- View states --- - -type viewState int - -const ( - viewDashboard viewState = iota - viewGroupDetail - viewSecretDetail - viewRecipients - viewHelp -) - -// --- Sections in dashboard --- - -type section int - -const ( - sectionGroups section = iota - sectionRecipients - sectionSetup -) - -// --- Model --- - -// Model is the Bubble Tea model for the secrets dashboard. It supports -// drill-down navigation: dashboard → group detail → secret detail, with -// esc/backspace to go back. Secret values are decrypted lazily on demand. -type Model struct { - // Data - groups map[string]groupData - recipients []recipient - workflow workflowStatus - loading bool - err error - - // Navigation - state viewState - activeSection section - cursor int - selectedGroup string - selectedKey string - - // Secret value (when viewing a secret) - secretValue string - secretErr error - loadingSecret bool - - // UI - spinner spinner.Model - width int - height int - returnMsg tea.Msg - - // Agent - agentURL string - agentToken string -} - -// Option pattern -type Option func(*Model) - -func WithReturnMsg(msg tea.Msg) Option { - return func(m *Model) { - m.returnMsg = msg - } -} - -// New creates a secrets Model. The agent URL defaults to localhost:21234 (the -// standard agent port for secrets), not the main agent port 9876. The agent -// token is read from STACKPANEL_AGENT_TOKEN for authenticated requests. -func New(opts ...Option) Model { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = tui.SpinnerStyle - - agentURL := os.Getenv("STACKPANEL_AGENT_URL") - if agentURL == "" { - agentURL = "http://localhost:21234" - } - - m := Model{ - spinner: s, - loading: true, - agentURL: agentURL, - agentToken: os.Getenv("STACKPANEL_AGENT_TOKEN"), - groups: make(map[string]groupData), - } - - for _, opt := range opts { - opt(&m) - } - - return m -} - -func (m Model) Init() tea.Cmd { - return tea.Batch( - m.spinner.Tick, - m.loadData(), - ) -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - return m.handleKey(msg) - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - case dataLoadedMsg: - m.loading = false - if msg.err != nil { - m.err = msg.err - } else { - m.groups = msg.groups - m.recipients = msg.recipients - m.workflow = msg.workflow - m.err = nil - } - return m, nil - - case secretValueMsg: - m.loadingSecret = false - if msg.err != nil { - m.secretErr = msg.err - } else { - m.secretValue = msg.value - m.secretErr = nil - } - return m, nil - - case tickMsg: - return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { - return tickMsg(t) - }) - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - return m, nil -} - -func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.state { - case viewDashboard: - return m.handleDashboardKey(msg) - case viewGroupDetail: - return m.handleGroupDetailKey(msg) - case viewSecretDetail: - return m.handleSecretDetailKey(msg) - case viewRecipients: - return m.handleRecipientsKey(msg) - case viewHelp: - return m.handleHelpKey(msg) - } - return m, nil -} - -func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc": - if m.returnMsg != nil { - return m, func() tea.Msg { return m.returnMsg } - } - return m, tea.Quit - case "?": - m.state = viewHelp - return m, nil - case "r": - m.loading = true - return m, m.loadData() - case "tab": - m.activeSection = (m.activeSection + 1) % 3 - m.cursor = 0 - return m, nil - case "shift+tab": - m.activeSection = (m.activeSection + 2) % 3 - m.cursor = 0 - return m, nil - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - return m, nil - case "down", "j": - m.cursor = min(m.cursor+1, m.maxCursor()-1) - return m, nil - case "enter": - return m.handleEnter() - } - return m, nil -} - -func (m Model) handleEnter() (tea.Model, tea.Cmd) { - switch m.activeSection { - case sectionGroups: - groupNames := m.sortedGroupNames() - if m.cursor < len(groupNames) { - m.selectedGroup = groupNames[m.cursor] - m.state = viewGroupDetail - m.cursor = 0 - } - case sectionRecipients: - m.state = viewRecipients - m.cursor = 0 - } - return m, nil -} - -func (m Model) handleGroupDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - group, ok := m.groups[m.selectedGroup] - if !ok { - m.state = viewDashboard - return m, nil - } - - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc", "backspace": - m.state = viewDashboard - m.cursor = 0 - m.secretValue = "" - m.secretErr = nil - return m, nil - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - return m, nil - case "down", "j": - m.cursor = min(m.cursor+1, len(group.Keys)-1) - return m, nil - case "enter": - if m.cursor < len(group.Keys) { - m.selectedKey = group.Keys[m.cursor] - m.state = viewSecretDetail - m.loadingSecret = true - m.secretValue = "" - m.secretErr = nil - return m, m.loadSecretValue(m.selectedGroup, m.selectedKey) - } - case "r": - m.loading = true - return m, m.loadData() - case "?": - m.state = viewHelp - return m, nil - } - return m, nil -} - -func (m Model) handleSecretDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc", "backspace": - m.state = viewGroupDetail - m.secretValue = "" - m.secretErr = nil - return m, nil - case "r": - m.loadingSecret = true - return m, m.loadSecretValue(m.selectedGroup, m.selectedKey) - } - return m, nil -} - -func (m Model) handleRecipientsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc", "backspace": - m.state = viewDashboard - m.cursor = 0 - return m, nil - case "r": - m.loading = true - return m, m.loadData() - } - return m, nil -} - -func (m Model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc", "backspace", "?": - m.state = viewDashboard - return m, nil - } - return m, nil -} - -// --- Data loading --- - -func (m Model) loadData() tea.Cmd { - return func() tea.Msg { - groups := make(map[string]groupData) - var recipients []recipient - var workflow workflowStatus - - // Load groups + secrets - groupResp, err := m.fetchAPI("/api/secrets/group/list") - if err != nil { - return dataLoadedMsg{err: fmt.Errorf("agent not reachable: %w", err)} - } - if groupMap, ok := groupResp["groups"].(map[string]interface{}); ok { - for name, data := range groupMap { - gd := groupData{Name: name, Initialized: true} - if dataMap, ok := data.(map[string]interface{}); ok { - if keys, ok := dataMap["keys"].([]interface{}); ok { - for _, k := range keys { - if s, ok := k.(string); ok { - gd.Keys = append(gd.Keys, s) - } - } - } - } - groups[name] = gd - } - } - - // Load recipients - recipResp, err := m.fetchAPI("/api/secrets/recipients") - if err == nil { - if recipList, ok := recipResp["recipients"].([]interface{}); ok { - for _, r := range recipList { - b, _ := json.Marshal(r) - var rec recipient - json.Unmarshal(b, &rec) - recipients = append(recipients, rec) - } - } - } - - // Load workflow status - wfResp, err := m.fetchAPI("/api/secrets/rekey-workflow") - if err == nil { - if exists, ok := wfResp["exists"].(bool); ok { - workflow.Exists = exists - } - } - - return dataLoadedMsg{ - groups: groups, - recipients: recipients, - workflow: workflow, - } - } -} - -func (m Model) loadSecretValue(group, key string) tea.Cmd { - return func() tea.Msg { - resp, err := m.fetchAPIPost("/api/secrets/group/read", map[string]string{ - "key": key, - "group": group, - }) - if err != nil { - return secretValueMsg{key: key, group: group, err: err} - } - value, _ := resp["value"].(string) - return secretValueMsg{key: key, group: group, value: value} - } -} - -// fetchAPI makes a GET request to the agent and unwraps the {"data": ...} envelope. -// Returns the inner data map, or the raw response if no "data" key exists. -func (m Model) fetchAPI(path string) (map[string]interface{}, error) { - url := strings.TrimRight(m.agentURL, "/") + path - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - if m.agentToken != "" { - req.Header.Set("Authorization", "Bearer "+m.agentToken) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - if data, ok := result["data"].(map[string]interface{}); ok { - return data, nil - } - return result, nil -} - -func (m Model) fetchAPIPost( - path string, - body interface{}, -) (map[string]interface{}, error) { - url := strings.TrimRight(m.agentURL, "/") + path - bodyBytes, _ := json.Marshal(body) - req, err := http.NewRequest("POST", url, strings.NewReader(string(bodyBytes))) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - if m.agentToken != "" { - req.Header.Set("Authorization", "Bearer "+m.agentToken) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body2, _ := io.ReadAll(resp.Body) - - var result map[string]interface{} - if err := json.Unmarshal(body2, &result); err != nil { - return nil, err - } - - if data, ok := result["data"].(map[string]interface{}); ok { - return data, nil - } - return result, nil -} - -// --- Helpers --- - -// sortedGroupNames returns group names in alphabetical order for stable rendering. -// Uses bubble sort since group count is always small (typically 1-5). -func (m Model) sortedGroupNames() []string { - names := make([]string, 0, len(m.groups)) - for name := range m.groups { - names = append(names, name) - } - for i := 0; i < len(names); i++ { - for j := i + 1; j < len(names); j++ { - if names[i] > names[j] { - names[i], names[j] = names[j], names[i] - } - } - } - return names -} - -func (m Model) maxCursor() int { - switch m.activeSection { - case sectionGroups: - n := len(m.groups) - if n == 0 { - return 1 - } - return n - case sectionRecipients: - return 1 - case sectionSetup: - return 1 - } - return 1 -} - -func (m Model) totalSecrets() int { - total := 0 - for _, g := range m.groups { - total += len(g.Keys) - } - return total -} - -// --- Entry point --- - -func RunSecretsDashboard() error { - m := New() - p := tui.NewInteractiveProgram(m) - _, err := p.Run() - return err -} - -// compile-time interface check -var _ tea.Model = Model{} diff --git a/apps/stackpanel-go/internal/tui/views/secrets/view.go b/apps/stackpanel-go/internal/tui/views/secrets/view.go deleted file mode 100644 index 2d5b77a9..00000000 --- a/apps/stackpanel-go/internal/tui/views/secrets/view.go +++ /dev/null @@ -1,515 +0,0 @@ -package secrets - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/darkmatter/stackpanel/stackpanel-go/internal/tui" -) - -// Styles specific to the secrets view. These extend the base tui styles with -// additional semantics (key names, decrypted values, setup badges). -var ( - sectionTitle = lipgloss.NewStyle(). - Bold(true). - Foreground(tui.ColorPrimary). - MarginBottom(1) - - sectionTitleActive = lipgloss.NewStyle(). - Bold(true). - Foreground(tui.ColorSecondary). - MarginBottom(1) - - itemStyle = lipgloss.NewStyle(). - PaddingLeft(2) - - itemActiveStyle = lipgloss.NewStyle(). - PaddingLeft(1). - Foreground(tui.ColorSecondary) - - dimStyle = lipgloss.NewStyle(). - Foreground(tui.ColorDim) - - keyStyle = lipgloss.NewStyle(). - Foreground(tui.ColorInfo). - Bold(true) - - valueStyle = lipgloss.NewStyle(). - Foreground(tui.ColorSuccess) - - errorStyle = lipgloss.NewStyle(). - Foreground(tui.ColorError) - - warningStyle = lipgloss.NewStyle(). - Foreground(tui.ColorWarning) - - badgeOK = lipgloss.NewStyle(). - Foreground(tui.ColorSuccess). - Bold(true) - - badgeWarn = lipgloss.NewStyle(). - Foreground(tui.ColorWarning). - Bold(true) - - headerStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(tui.ColorPrimary). - BorderBottom(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(tui.ColorBorder). - Width(60). - MarginBottom(1) - - helpStyle = lipgloss.NewStyle(). - Foreground(tui.ColorDim). - MarginTop(1) - - boxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(tui.ColorBorder). - Padding(1, 2) - - detailLabel = lipgloss.NewStyle(). - Foreground(tui.ColorDim). - Width(14) - - detailValue = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ffffff")) -) - -// View dispatches to the appropriate sub-view based on the current navigation state. -// The drill-down flow is: dashboard → group → secret, with help/recipients as side views. -func (m Model) View() string { - switch m.state { - case viewDashboard: - return m.viewDashboard() - case viewGroupDetail: - return m.viewGroupDetail() - case viewSecretDetail: - return m.viewSecretDetail() - case viewRecipients: - return m.viewRecipients() - case viewHelp: - return m.viewHelp() - } - return "" -} - -func (m Model) viewDashboard() string { - var b strings.Builder - - // Header - b.WriteString(headerStyle.Render("Secrets Dashboard")) - b.WriteString("\n") - - if m.loading { - b.WriteString(m.spinner.View() + " Loading secrets...") - return tui.RenderFrame(b.String()) - } - - if m.err != nil { - b.WriteString(errorStyle.Render(tui.SymbolError+" Error: "+m.err.Error()) + "\n\n") - b.WriteString(dimStyle.Render("Make sure the agent is running (stack agent)")) - b.WriteString("\n\n") - b.WriteString(helpStyle.Render("r refresh q quit")) - return tui.RenderFrame(b.String()) - } - - // Status bar - b.WriteString(m.renderStatusBar()) - b.WriteString("\n\n") - - // Groups section - b.WriteString(m.renderGroupsSection()) - b.WriteString("\n") - - // Recipients section - b.WriteString(m.renderRecipientsSection()) - b.WriteString("\n") - - // Setup section - b.WriteString(m.renderSetupSection()) - b.WriteString("\n") - - // Help - b.WriteString( - helpStyle.Render( - "tab section ↑↓ navigate enter select r refresh ? help q quit", - ), - ) - - return tui.RenderFrame(b.String()) -} - -func (m Model) renderStatusBar() string { - groupCount := len(m.groups) - secretCount := m.totalSecrets() - recipientCount := len(m.recipients) - - parts := []string{ - fmt.Sprintf("%s %d group(s)", tui.SymbolInfo, groupCount), - fmt.Sprintf("%s %d secret(s)", tui.SymbolDot, secretCount), - fmt.Sprintf("%s %d recipient(s)", tui.SymbolDot, recipientCount), - } - - if m.workflow.Exists { - parts = append(parts, badgeOK.Render(tui.SymbolSuccess+" rekey workflow")) - } else { - parts = append(parts, badgeWarn.Render(tui.SymbolWarning+" no rekey workflow")) - } - - return dimStyle.Render(strings.Join(parts, " ")) -} - -func (m Model) renderGroupsSection() string { - var b strings.Builder - - title := sectionTitle - if m.activeSection == sectionGroups { - title = sectionTitleActive - } - b.WriteString(title.Render(tui.SymbolArrow + " Groups")) - b.WriteString("\n") - - groupNames := m.sortedGroupNames() - - if len(groupNames) == 0 { - b.WriteString(dimStyle.Render(" No groups found. Run: secrets:init-group ")) - return b.String() - } - - for i, name := range groupNames { - group := m.groups[name] - secretCount := len(group.Keys) - - cursor := " " - style := itemStyle - if m.activeSection == sectionGroups && m.cursor == i { - cursor = tui.SymbolArrow + " " - style = itemActiveStyle - } - - status := badgeOK.Render(tui.SymbolRunning) - if !group.Initialized { - status = badgeWarn.Render(tui.SymbolStopped) - } - - line := fmt.Sprintf( - "%s%s %s %s", - cursor, - status, - style.Render(name), - dimStyle.Render(fmt.Sprintf("(%d secrets)", secretCount)), - ) - b.WriteString(line + "\n") - } - - return b.String() -} - -func (m Model) renderRecipientsSection() string { - var b strings.Builder - - title := sectionTitle - if m.activeSection == sectionRecipients { - title = sectionTitleActive - } - b.WriteString(title.Render(tui.SymbolArrow + " Recipients")) - b.WriteString("\n") - - cursor := " " - style := itemStyle - if m.activeSection == sectionRecipients && m.cursor == 0 { - cursor = tui.SymbolArrow + " " - style = itemActiveStyle - } - - count := len(m.recipients) - if count == 0 { - b.WriteString( - fmt.Sprintf( - "%s%s", - cursor, - style.Render( - dimStyle.Render("No recipients. Enter devshell to auto-register."), - ), - ), - ) - } else { - names := make([]string, 0, count) - for _, r := range m.recipients { - names = append(names, r.Name) - } - b.WriteString(fmt.Sprintf( - "%s%s %s", - cursor, - style.Render(fmt.Sprintf("%d recipient(s)", count)), - dimStyle.Render(strings.Join(names, ", ")), - )) - } - - return b.String() -} - -func (m Model) renderSetupSection() string { - var b strings.Builder - - title := sectionTitle - if m.activeSection == sectionSetup { - title = sectionTitleActive - } - b.WriteString(title.Render(tui.SymbolArrow + " Setup Status")) - b.WriteString("\n") - - // Group keys - if len(m.groups) > 0 { - b.WriteString(" " + badgeOK.Render(tui.SymbolSuccess) + " Groups initialized\n") - } else { - b.WriteString( - " " + badgeWarn.Render(tui.SymbolWarning) + " No groups initialized\n", - ) - b.WriteString(dimStyle.Render(" Run: secrets:init-group dev") + "\n") - } - - // Recipients - if len(m.recipients) > 0 { - b.WriteString(" " + badgeOK.Render(tui.SymbolSuccess) + " Recipients registered\n") - } else { - b.WriteString( - " " + badgeWarn.Render(tui.SymbolWarning) + " No recipients registered\n", - ) - } - - // Rekey workflow - if m.workflow.Exists { - b.WriteString( - " " + badgeOK.Render(tui.SymbolSuccess) + " Rekey workflow configured\n", - ) - } else { - b.WriteString( - " " + badgeWarn.Render(tui.SymbolWarning) + " Rekey workflow missing\n", - ) - b.WriteString( - dimStyle.Render(" Run: secrets:init-group --force-gh") + "\n", - ) - } - - return b.String() -} - -// --- Group detail view --- - -func (m Model) viewGroupDetail() string { - var b strings.Builder - - group, ok := m.groups[m.selectedGroup] - if !ok { - b.WriteString(errorStyle.Render("Group not found: " + m.selectedGroup)) - return tui.RenderFrame(b.String()) - } - - b.WriteString(headerStyle.Render(fmt.Sprintf("Group: %s", m.selectedGroup))) - b.WriteString("\n") - - // Group info - b.WriteString( - detailLabel.Render( - "Secrets:", - ) + detailValue.Render( - fmt.Sprintf("%d", len(group.Keys)), - ) + "\n", - ) - if group.PubKey != "" { - b.WriteString( - detailLabel.Render( - "Public Key:", - ) + dimStyle.Render( - truncate(group.PubKey, 40), - ) + "\n", - ) - } - b.WriteString("\n") - - // Secrets list - if len(group.Keys) == 0 { - b.WriteString(dimStyle.Render(" No secrets in this group.") + "\n") - b.WriteString( - dimStyle.Render( - " Run: secrets:set --group "+m.selectedGroup+" --value ", - ) + "\n", - ) - } else { - for i, key := range group.Keys { - cursor := " " - style := itemStyle - if m.cursor == i { - cursor = tui.SymbolArrow + " " - style = itemActiveStyle - } - b.WriteString(fmt.Sprintf("%s%s\n", cursor, style.Render(keyStyle.Render(key)))) - } - } - - b.WriteString("\n") - b.WriteString(helpStyle.Render("enter view esc back r refresh q quit")) - - return tui.RenderFrame(b.String()) -} - -// --- Secret detail view --- - -func (m Model) viewSecretDetail() string { - var b strings.Builder - - b.WriteString( - headerStyle.Render(fmt.Sprintf("Secret: %s/%s", m.selectedGroup, m.selectedKey)), - ) - b.WriteString("\n") - - b.WriteString( - detailLabel.Render("Group:") + detailValue.Render(m.selectedGroup) + "\n", - ) - b.WriteString(detailLabel.Render("Key:") + keyStyle.Render(m.selectedKey) + "\n") - b.WriteString("\n") - - if m.loadingSecret { - b.WriteString(m.spinner.View() + " Decrypting...") - } else if m.secretErr != nil { - b.WriteString( - detailLabel.Render( - "Value:", - ) + errorStyle.Render( - "decryption failed: "+m.secretErr.Error(), - ), - ) - } else { - b.WriteString(detailLabel.Render("Value:") + "\n") - b.WriteString(boxStyle.Render(valueStyle.Render(m.secretValue))) - } - - b.WriteString("\n\n") - b.WriteString(helpStyle.Render("esc back r refresh q quit")) - - return tui.RenderFrame(b.String()) -} - -// --- Recipients view --- - -func (m Model) viewRecipients() string { - var b strings.Builder - - b.WriteString(headerStyle.Render("Recipients")) - b.WriteString("\n") - - if m.loading { - b.WriteString(m.spinner.View() + " Loading...") - return tui.RenderFrame(b.String()) - } - - if len(m.recipients) == 0 { - b.WriteString(dimStyle.Render("No recipients registered.") + "\n\n") - b.WriteString( - dimStyle.Render( - "Recipients are configured in Nix and rendered into .stack/secrets/.sops.yaml.", - ) + "\n", - ) - } else { - for _, r := range m.recipients { - b.WriteString(fmt.Sprintf( - " %s %s %s\n", - badgeOK.Render(tui.SymbolRunning), - keyStyle.Render(r.Name), - dimStyle.Render(truncate(r.PublicKey, 30)), - )) - } - } - - b.WriteString("\n") - - // Workflow status - if m.workflow.Exists { - b.WriteString(badgeOK.Render(tui.SymbolSuccess+" Rekey workflow active") + "\n") - b.WriteString( - dimStyle.Render( - " New recipients are automatically re-keyed via GitHub Actions.", - ) + "\n", - ) - } else { - b.WriteString( - warningStyle.Render(tui.SymbolWarning+" Rekey workflow not configured") + "\n", - ) - b.WriteString(dimStyle.Render(" Run: secrets:init-group --force-gh") + "\n") - } - - b.WriteString("\n") - b.WriteString(helpStyle.Render("esc back r refresh q quit")) - - return tui.RenderFrame(b.String()) -} - -// --- Help view --- - -func (m Model) viewHelp() string { - var b strings.Builder - - b.WriteString(headerStyle.Render("Secrets Help")) - b.WriteString("\n") - - sections := []struct { - title string - items []string - }{ - {"Navigation", []string{ - "tab / shift+tab Switch section", - "↑ / ↓ / j / k Navigate items", - "enter Select / drill in", - "esc / backspace Go back", - "q / ctrl+c Quit", - }}, - {"Actions", []string{ - "r Refresh data", - "? Toggle help", - }}, - {"CLI Commands", []string{ - "secrets:set --group --value Set a secret", - "secrets:get --group Get a secret", - "secrets:list [group] List secrets", - "secrets:init-group Initialize group", - "secrets:init-group --force-gh Sync to GitHub", - "secrets:show-keys Show key status", - }}, - {"Key Naming", []string{ - "Rules: lowercase alphanumeric + hyphens only", - "Valid: database-url, api-key-v2, my-secret", - "Invalid: DATABASE_URL, /dev/key, my.secret", - }}, - {"Team Onboarding", []string{ - "1. New member adds their public key in Nix config", - "2. Re-enter the devshell to regenerate .stack/secrets/.sops.yaml", - "3. Run .stack/secrets/bin/rekey.sh", - "4. Pull — secrets are now accessible", - }}, - } - - for _, s := range sections { - b.WriteString(sectionTitle.Render(s.title) + "\n") - for _, item := range s.items { - b.WriteString(" " + dimStyle.Render(item) + "\n") - } - b.WriteString("\n") - } - - b.WriteString(helpStyle.Render("? close help q quit")) - - return tui.RenderFrame(b.String()) -} - -// --- Utilities --- - -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max-3] + "..." -} diff --git a/apps/stackpanel-go/internal/tui/views/services.go b/apps/stackpanel-go/internal/tui/views/services.go deleted file mode 100644 index 8b34a560..00000000 --- a/apps/stackpanel-go/internal/tui/views/services.go +++ /dev/null @@ -1,492 +0,0 @@ -package views - -import ( - "fmt" - "strings" - "time" - - "github.com/charmbracelet/bubbles/progress" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/darkmatter/stackpanel/stackpanel-go/internal/tui" - svc "github.com/darkmatter/stackpanel/stackpanel-go/pkg/services" -) - -// ServiceStartState represents the state of a service being started -type ServiceStartState int - -const ( - StateWaiting ServiceStartState = iota - StateStarting - StateRunning - StateFailed - StateSkipped -) - -// ServiceStartInfo holds information about a service being started -type ServiceStartInfo struct { - Name string - DisplayName string - State ServiceStartState - Progress float64 - Message string - Error error -} - -// ServicesView is the Bubble Tea model for starting/stopping/restarting services. -// Services are processed sequentially (one at a time) to respect dependency -// ordering and avoid port conflicts during startup. -type ServicesView struct { - services []ServiceStartInfo - currentIdx int - spinner spinner.Model - progress progress.Model - width int - height int - done bool - quitting bool - mode ServicesViewMode - // For navigation integration - returnMsg tea.Msg -} - -// ServicesViewMode determines what the view is doing -type ServicesViewMode int - -const ( - ModeStart ServicesViewMode = iota - ModeStop - ModeRestart -) - -// Service start messages -type ( - serviceStartedMsg struct { - idx int - success bool - message string - err error - } - serviceStoppedMsg struct { - idx int - success bool - message string - err error - } - startNextServiceMsg struct{} - stopNextServiceMsg struct{} -) - -// ReturnFromServicesMsg signals return to navigation -type ReturnFromServicesMsg struct{} - -// ServicesViewOption configures the ServicesView -type ServicesViewOption func(*ServicesView) - -// WithServicesReturnMsg sets a custom message to send when returning -func WithServicesReturnMsg(msg tea.Msg) ServicesViewOption { - return func(v *ServicesView) { - v.returnMsg = msg - } -} - -// WithMode sets the operation mode (start, stop, restart) -func WithMode(mode ServicesViewMode) ServicesViewOption { - return func(v *ServicesView) { - v.mode = mode - } -} - -// NewServicesView creates a model for starting/stopping services -func NewServicesView(serviceNames []string, opts ...ServicesViewOption) ServicesView { - // If no services specified, use all - if len(serviceNames) == 0 { - serviceNames = svc.Names() - } - - svcInfos := make([]ServiceStartInfo, len(serviceNames)) - for i, name := range serviceNames { - displayName := name - if s := svc.Get(name); s != nil { - displayName = s.DisplayName() - } - svcInfos[i] = ServiceStartInfo{ - Name: name, - DisplayName: displayName, - State: StateWaiting, - } - } - - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = tui.SpinnerStyle - - p := progress.New( - progress.WithDefaultGradient(), - progress.WithWidth(40), - progress.WithoutPercentage(), - ) - - view := ServicesView{ - services: svcInfos, - currentIdx: 0, - spinner: s, - progress: p, - mode: ModeStart, - } - - for _, opt := range opts { - opt(&view) - } - - // Mark first service as starting - if len(view.services) > 0 { - view.services[0].State = StateStarting - } - - return view -} - -// Init implements tea.Model -func (m ServicesView) Init() tea.Cmd { - if len(m.services) == 0 { - return nil - } - - switch m.mode { - case ModeStop: - return tea.Batch( - m.spinner.Tick, - m.stopServiceCmd(m.services[0].Name, 0), - ) - default: - return tea.Batch( - m.spinner.Tick, - m.startServiceCmd(m.services[0].Name, 0), - ) - } -} - -func (m ServicesView) startServiceCmd(name string, idx int) tea.Cmd { - return func() tea.Msg { - s := svc.Get(name) - if s == nil { - return serviceStartedMsg{ - idx: idx, - success: false, - message: "Unknown service", - err: fmt.Errorf("unknown service: %s", name), - } - } - - // Check if already running - status := s.Status() - if status.Running { - return serviceStartedMsg{ - idx: idx, - success: true, - message: fmt.Sprintf("Already running (PID: %d)", status.PID), - } - } - - // Start the service - if err := s.Start(); err != nil { - return serviceStartedMsg{ - idx: idx, - success: false, - message: "Start failed", - err: err, - } - } - - // Get new status for the message - newStatus := s.Status() - message := fmt.Sprintf("Started on port %d", s.Port()) - if newStatus.PID > 0 { - message = fmt.Sprintf("Started (PID: %d)", newStatus.PID) - } - - return serviceStartedMsg{ - idx: idx, - success: true, - message: message, - } - } -} - -func (m ServicesView) stopServiceCmd(name string, idx int) tea.Cmd { - return func() tea.Msg { - s := svc.Get(name) - if s == nil { - return serviceStoppedMsg{ - idx: idx, - success: false, - message: "Unknown service", - err: fmt.Errorf("unknown service: %s", name), - } - } - - // Check if already stopped - status := s.Status() - if !status.Running { - return serviceStoppedMsg{ - idx: idx, - success: true, - message: "Already stopped", - } - } - - // Stop the service - if err := s.Stop(); err != nil { - return serviceStoppedMsg{ - idx: idx, - success: false, - message: "Stop failed", - err: err, - } - } - - return serviceStoppedMsg{ - idx: idx, - success: true, - message: "Stopped", - } - } -} - -// Update implements tea.Model -func (m ServicesView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - m.quitting = true - return m, tea.Quit - case "esc": - if m.done { - if m.returnMsg != nil { - return m, func() tea.Msg { return m.returnMsg } - } - return m, func() tea.Msg { return ReturnFromServicesMsg{} } - } - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.progress.Width = msg.Width - 20 - - case serviceStartedMsg: - return m.handleServiceStarted(msg) - - case serviceStoppedMsg: - return m.handleServiceStopped(msg) - - case startNextServiceMsg: - if m.currentIdx < len(m.services) { - return m, m.startServiceCmd(m.services[m.currentIdx].Name, m.currentIdx) - } - - case stopNextServiceMsg: - if m.currentIdx < len(m.services) { - return m, m.stopServiceCmd(m.services[m.currentIdx].Name, m.currentIdx) - } - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - - case progress.FrameMsg: - newModel, cmd := m.progress.Update(msg) - if pm, ok := newModel.(progress.Model); ok { - m.progress = pm - } - return m, cmd - } - - return m, nil -} - -func (m ServicesView) handleServiceStarted(msg serviceStartedMsg) (tea.Model, tea.Cmd) { - if msg.idx < len(m.services) { - if msg.success { - m.services[msg.idx].State = StateRunning - } else { - m.services[msg.idx].State = StateFailed - m.services[msg.idx].Error = msg.err - } - m.services[msg.idx].Message = msg.message - m.services[msg.idx].Progress = 1.0 - - // Start next service - m.currentIdx++ - if m.currentIdx < len(m.services) { - m.services[m.currentIdx].State = StateStarting - return m, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { - return startNextServiceMsg{} - }) - } - m.done = true - return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { - return nil // Stay on screen, don't auto-quit - }) - } - return m, nil -} - -func (m ServicesView) handleServiceStopped(msg serviceStoppedMsg) (tea.Model, tea.Cmd) { - if msg.idx < len(m.services) { - if msg.success { - // Reuses StateRunning to mean "completed successfully" — the view renders - // a checkmark regardless of whether this was a start or stop operation. - m.services[msg.idx].State = StateRunning - m.services[msg.idx].Message = msg.message - } else { - m.services[msg.idx].State = StateFailed - m.services[msg.idx].Error = msg.err - m.services[msg.idx].Message = msg.message - } - m.services[msg.idx].Progress = 1.0 - - // Stop next service - m.currentIdx++ - if m.currentIdx < len(m.services) { - m.services[m.currentIdx].State = StateStarting - return m, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { - return stopNextServiceMsg{} - }) - } - m.done = true - return m, nil - } - return m, nil -} - -// View implements tea.Model -func (m ServicesView) View() string { - if m.quitting { - return "" - } - - var b strings.Builder - - // Header - var title string - switch m.mode { - case ModeStop: - title = "Stopping Services" - case ModeRestart: - title = "Restarting Services" - default: - title = "Starting Services" - } - - header := lipgloss.NewStyle(). - Foreground(tui.ColorSecondary). - Bold(true). - MarginBottom(1). - Render(title) - b.WriteString(header) - b.WriteString("\n\n") - - // Services list - for i, s := range m.services { - line := " " - - switch s.State { - case StateWaiting: - line += tui.TextDim.Render(tui.SymbolStopped + " " + s.DisplayName) - line += tui.TextDim.Render(" (waiting)") - case StateStarting: - line += tui.SpinnerStyle.Render(m.spinner.View()) - line += " " + s.DisplayName - if m.mode == ModeStop { - line += tui.TextDim.Render(" (stopping...)") - } else { - line += tui.TextDim.Render(" (starting...)") - } - case StateRunning: - line += tui.RenderSuccess(s.DisplayName) - if s.Message != "" { - line += tui.TextDim.Render(" - " + s.Message) - } - case StateFailed: - line += tui.RenderError(s.DisplayName) - if s.Message != "" { - line += " " + tui.TextDim.Render(s.Message) - } - case StateSkipped: - line += tui.TextDim.Render(tui.SymbolStopped + " " + s.DisplayName + " (skipped)") - } - - b.WriteString(line) - b.WriteString("\n") - - // Show progress bar for current service - if s.State == StateStarting && i == m.currentIdx { - b.WriteString(" ") - b.WriteString(m.progress.ViewAs(0.5)) - b.WriteString("\n") - } - } - - // Done message - if m.done { - b.WriteString("\n") - successCount := 0 - failCount := 0 - for _, s := range m.services { - if s.State == StateRunning { - successCount++ - } else if s.State == StateFailed { - failCount++ - } - } - - var doneMsg string - switch m.mode { - case ModeStop: - if failCount == 0 { - doneMsg = fmt.Sprintf("All %d services stopped successfully!", successCount) - } else { - doneMsg = fmt.Sprintf("%d stopped, %d failed", successCount, failCount) - } - default: - if failCount == 0 { - doneMsg = fmt.Sprintf("All %d services started successfully!", successCount) - } else { - doneMsg = fmt.Sprintf("%d started, %d failed", successCount, failCount) - } - } - - if failCount == 0 { - b.WriteString(tui.RenderSuccess(doneMsg)) - } else { - b.WriteString(tui.RenderWarning(doneMsg)) - } - b.WriteString("\n") - - // Help text for returning - help := tui.HelpStyle.Render("\nPress esc to return") - b.WriteString(help) - } - - return tui.RenderFrame(b.String()) -} - -// RunServicesStart launches the service start view -func RunServicesStart(serviceNames []string) error { - model := NewServicesView(serviceNames) - p := tea.NewProgram(model) - _, err := p.Run() - return err -} - -// RunServicesStop launches the service stop view -func RunServicesStop(serviceNames []string) error { - model := NewServicesView(serviceNames, WithMode(ModeStop)) - p := tea.NewProgram(model) - _, err := p.Run() - return err -} diff --git a/apps/stackpanel-go/internal/tui/views/status.go b/apps/stackpanel-go/internal/tui/views/status.go deleted file mode 100644 index 61c2f9e7..00000000 --- a/apps/stackpanel-go/internal/tui/views/status.go +++ /dev/null @@ -1,482 +0,0 @@ -package views - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/darkmatter/stackpanel/stackpanel-go/internal/tui" - svc "github.com/darkmatter/stackpanel/stackpanel-go/pkg/services" -) - -// ServiceInfo holds status information for a service -type ServiceInfo struct { - Name string - DisplayName string - Status string // running, stopped, starting, error - PID int - Port int - Details string -} - -// StatusView is the Bubble Tea model for the status dashboard. -// Unlike tui.StatusModel, this version integrates with the navigation system -// by supporting a returnMsg for back-navigation via esc. -type StatusView struct { - services []ServiceInfo - caddyInfo ServiceInfo - certStatus string - table table.Model - spinner spinner.Model - width int - height int - loading bool - lastRefresh time.Time - refreshing bool - quitting bool - // For navigation integration - returnMsg tea.Msg -} - -// Status messages -type ( - statusServicesUpdatedMsg []ServiceInfo - statusCaddyUpdatedMsg ServiceInfo - statusCertUpdatedMsg string - statusTickMsg struct{} -) - -// ReturnFromStatusMsg signals return to navigation -type ReturnFromStatusMsg struct{} - -// Configuration -var ( - caddyConfigDir = filepath.Join(os.Getenv("HOME"), ".config", "caddy") -) - -// StatusViewOption configures the StatusView -type StatusViewOption func(*StatusView) - -// WithReturnMsg sets a custom message to send when returning -func WithStatusReturnMsg(msg tea.Msg) StatusViewOption { - return func(v *StatusView) { - v.returnMsg = msg - } -} - -// NewStatusView creates a new status dashboard view -func NewStatusView(opts ...StatusViewOption) StatusView { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = tui.SpinnerStyle - - // Create table with proper column widths - columns := []table.Column{ - {Title: "Service", Width: 14}, - {Title: "Status", Width: 12}, - {Title: "PID", Width: 8}, - {Title: "Port", Width: 8}, - {Title: "Details", Width: 30}, - } - - t := table.New( - table.WithColumns(columns), - table.WithFocused(true), - table.WithHeight(7), - ) - - // Apply styles - styles := table.DefaultStyles() - styles.Header = styles.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(tui.ColorBorder). - BorderBottom(true). - Bold(true). - Foreground(tui.ColorSubtle) - styles.Selected = styles.Selected. - Foreground(lipgloss.Color("#FFFFFF")). - Background(tui.ColorPrimary). - Bold(false) - styles.Cell = styles.Cell. - Padding(0, 1) - t.SetStyles(styles) - - view := StatusView{ - spinner: s, - table: t, - loading: true, - lastRefresh: time.Now(), - } - - for _, opt := range opts { - opt(&view) - } - - return view -} - -// Init implements tea.Model -func (m StatusView) Init() tea.Cmd { - return tea.Batch( - m.spinner.Tick, - m.refreshServices, - m.refreshCaddy, - m.refreshCerts, - m.tickCmd(), - ) -} - -func (m StatusView) tickCmd() tea.Cmd { - return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { - return statusTickMsg{} - }) -} - -func (m StatusView) refreshServices() tea.Msg { - var svcInfos []ServiceInfo - - for _, s := range svc.All() { - status := s.Status() - info := ServiceInfo{ - Name: s.Name(), - DisplayName: s.DisplayName(), - Port: s.Port(), - Status: "stopped", - } - - if status.Running { - info.Status = "running" - info.PID = status.PID - // Get extended status info - var details []string - for key, val := range s.StatusInfo() { - if key != "Socket" { // Skip verbose socket paths - details = append(details, val) - } - } - if len(details) > 0 { - info.Details = strings.Join(details, ", ") - } - } - - svcInfos = append(svcInfos, info) - } - - return statusServicesUpdatedMsg(svcInfos) -} - -func (m StatusView) refreshCaddy() tea.Msg { - info := ServiceInfo{ - Name: "caddy", - DisplayName: "Caddy", - Status: "stopped", - } - - pidFile := filepath.Join(caddyConfigDir, "caddy.pid") - pid := readPidFile(pidFile) - - if pid > 0 && svc.IsProcessRunning(pid) { - info.Status = "running" - info.PID = pid - info.Details = countSites() - } - - return statusCaddyUpdatedMsg(info) -} - -func (m StatusView) refreshCerts() tea.Msg { - stateDir := findStateDir() - hostname, _ := os.Hostname() - certFile := filepath.Join(stateDir, hostname+".crt") - - if _, err := os.Stat(certFile); os.IsNotExist(err) { - return statusCertUpdatedMsg("No certificate") - } - - // Check validity - cmd := exec.Command("step", "certificate", "inspect", certFile, "--format", "json") - output, err := cmd.Output() - if err != nil { - return statusCertUpdatedMsg("Error checking cert") - } - - if strings.Contains(string(output), "expired") { - return statusCertUpdatedMsg("Expired") - } - - return statusCertUpdatedMsg("Valid") -} - -// Update implements tea.Model -func (m StatusView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - m.quitting = true - return m, tea.Quit - case "esc": - // Return to navigation - if m.returnMsg != nil { - return m, func() tea.Msg { return m.returnMsg } - } - return m, func() tea.Msg { return ReturnFromStatusMsg{} } - case "r": - m.refreshing = true - return m, tea.Batch( - m.refreshServices, - m.refreshCaddy, - m.refreshCerts, - ) - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - // Adjust table width with padding - m.table.SetWidth(msg.Width - 4) - m.table.SetHeight(min(10, msg.Height-20)) - - case statusServicesUpdatedMsg: - m.services = msg - m.loading = false - m.refreshing = false - m.lastRefresh = time.Now() - m.updateTable() - - case statusCaddyUpdatedMsg: - m.caddyInfo = ServiceInfo(msg) - - case statusCertUpdatedMsg: - m.certStatus = string(msg) - - case statusTickMsg: - cmds = append(cmds, tea.Batch( - m.refreshServices, - m.refreshCaddy, - m.refreshCerts, - m.tickCmd(), - )) - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - cmds = append(cmds, cmd) - } - - // Update table - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m *StatusView) updateTable() { - var rows []table.Row - for _, s := range m.services { - status := m.renderStatusBadge(s.Status) - pidStr := "-" - if s.PID > 0 { - pidStr = strconv.Itoa(s.PID) - } - portStr := "-" - if s.Port > 0 { - portStr = strconv.Itoa(s.Port) - } - rows = append(rows, table.Row{ - s.DisplayName, - status, - pidStr, - portStr, - s.Details, - }) - } - m.table.SetRows(rows) -} - -func (m StatusView) renderStatusBadge(status string) string { - switch status { - case "running": - return tui.StatusRunning.Render(tui.SymbolRunning + " running") - case "stopped": - return tui.StatusStopped.Render(tui.SymbolStopped + " stopped") - case "starting": - return tui.StatusStarting.Render(tui.SymbolStarting + " starting") - case "error": - return tui.StatusError.Render(tui.SymbolError + " error") - default: - return tui.TextDim.Render(status) - } -} - -// View implements tea.Model -func (m StatusView) View() string { - if m.quitting { - return "" - } - - var b strings.Builder - - // Header - header := lipgloss.NewStyle(). - Foreground(tui.ColorSecondary). - Bold(true). - MarginBottom(1). - Render("Status Dashboard") - b.WriteString(header) - b.WriteString("\n\n") - - // Loading state - if m.loading { - b.WriteString(m.spinner.View()) - b.WriteString(" Loading services...") - return tui.RenderFrame(b.String()) - } - - // Services section - sectionHeader := lipgloss.NewStyle(). - Foreground(tui.ColorPrimary). - Bold(true) - - b.WriteString(sectionHeader.Render(tui.SymbolArrow + " Development Services")) - if m.refreshing { - b.WriteString(" ") - b.WriteString(m.spinner.View()) - } - b.WriteString("\n\n") - - // Table with border - tableStyle := lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(tui.ColorBorder). - Padding(0, 1) - b.WriteString(tableStyle.Render(m.table.View())) - b.WriteString("\n") - - // Caddy section - b.WriteString("\n") - b.WriteString(sectionHeader.Render(tui.SymbolArrow + " Reverse Proxy")) - b.WriteString("\n") - caddyLine := " " - if m.caddyInfo.Status == "running" { - caddyLine += tui.StatusRunning.Render(tui.SymbolRunning + " Caddy") - caddyLine += tui.TextDim.Render(fmt.Sprintf(" (PID: %d)", m.caddyInfo.PID)) - if m.caddyInfo.Details != "" { - caddyLine += tui.TextDim.Render(" - " + m.caddyInfo.Details) - } - } else { - caddyLine += tui.StatusStopped.Render(tui.SymbolStopped + " Caddy (stopped)") - } - b.WriteString(caddyLine) - b.WriteString("\n") - - // Certs section - b.WriteString("\n") - b.WriteString(sectionHeader.Render(tui.SymbolArrow + " Certificates")) - b.WriteString("\n") - certLine := " " - switch m.certStatus { - case "Valid": - certLine += tui.RenderSuccess("Device certificate valid") - case "Expired": - certLine += tui.RenderWarning("Device certificate expired") - case "No certificate": - certLine += tui.TextDim.Render(tui.SymbolStopped + " No device certificate") - default: - certLine += tui.TextDim.Render(tui.SymbolStopped + " " + m.certStatus) - } - b.WriteString(certLine) - b.WriteString("\n") - - // Last refresh time - b.WriteString("\n") - refreshInfo := tui.TextDim.Render( - fmt.Sprintf("Last refresh: %s", m.lastRefresh.Format("15:04:05")), - ) - b.WriteString(refreshInfo) - - // Help - help := tui.HelpStyle.Render("\n\nr: refresh • esc: back • q: quit") - b.WriteString(help) - - return tui.RenderFrame(b.String()) -} - -// Helper functions — duplicated from tui/status.go because the views package -// can't import tui without creating a cycle. Consider extracting to a shared -// internal/pidfile or internal/caddy package if this grows. - -func readPidFile(path string) int { - data, err := os.ReadFile(path) - if err != nil { - return 0 - } - pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) - return pid -} - -func countSites() string { - sitesDir := filepath.Join(caddyConfigDir, "sites.d") - entries, _ := os.ReadDir(sitesDir) - count := 0 - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".caddy") { - count++ - } - } - if count > 0 { - return fmt.Sprintf("%d site(s)", count) - } - return "" -} - -// findStateDir walks up from cwd looking for a .stack directory. -// BUG: stackDir and stackpanelDir are identical — the second check is redundant. -// See the same bug in tui/status.go's findStepStateDir. -func findStateDir() string { - cwd, _ := os.Getwd() - dir := cwd - for { - stackDir := filepath.Join(dir, ".stack") - stackpanelDir := filepath.Join(dir, ".stack") - if info, err := os.Stat(stackDir); err == nil && info.IsDir() { - return filepath.Join(stackDir, "profile", "step") - } - if info, err := os.Stat(stackpanelDir); err == nil && info.IsDir() { - return filepath.Join(stackpanelDir, "state", "step") - } - if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { - if _, err := os.Stat(stackDir); err == nil { - return filepath.Join(stackDir, "profile", "step") - } - return filepath.Join(stackpanelDir, "state", "step") - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - if info, err := os.Stat(filepath.Join(cwd, ".stack")); err == nil && info.IsDir() { - return filepath.Join(cwd, ".stack", "profile", "step") - } - return filepath.Join(cwd, ".stack", "state", "step") -} - -// RunStatusView launches the status dashboard as a standalone program -func RunStatusView() error { - p := tea.NewProgram(NewStatusView(), tea.WithAltScreen()) - _, err := p.Run() - return err -} diff --git a/apps/stackpanel-go/internal/tui/views/views_test.go b/apps/stackpanel-go/internal/tui/views/views_test.go deleted file mode 100644 index dd645d64..00000000 --- a/apps/stackpanel-go/internal/tui/views/views_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package views - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -func TestStatusView(t *testing.T) { - view := NewStatusView() - - // Check initial state - if !view.loading { - t.Error("StatusView should start in loading state") - } - - // Init should return commands - cmd := view.Init() - if cmd == nil { - t.Error("StatusView.Init() should return commands") - } - - // View should render without panic - output := view.View() - if output == "" { - t.Error("StatusView.View() should return content") - } - if !strings.Contains(output, "Loading") { - t.Error("StatusView should show loading state initially") - } -} - -func TestStatusViewWithOptions(t *testing.T) { - customMsg := ReturnFromStatusMsg{} - view := NewStatusView(WithStatusReturnMsg(customMsg)) - - if view.returnMsg == nil { - t.Error("WithStatusReturnMsg should set returnMsg") - } -} - -func TestStatusViewUpdate(t *testing.T) { - view := NewStatusView() - - // Test window size message - newView, _ := view.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) - updatedView := newView.(StatusView) - if updatedView.width != 100 || updatedView.height != 50 { - t.Error("StatusView should update dimensions on WindowSizeMsg") - } - - // Test services updated message - services := []ServiceInfo{ - { - Name: "test", - DisplayName: "Test Service", - Status: "running", - PID: 1234, - Port: 8080, - }, - } - newView, _ = view.Update(statusServicesUpdatedMsg(services)) - updatedView = newView.(StatusView) - if updatedView.loading { - t.Error("StatusView should not be loading after services update") - } - if len(updatedView.services) != 1 { - t.Errorf("StatusView should have 1 service, got %d", len(updatedView.services)) - } -} - -func TestServicesView(t *testing.T) { - // Test with empty service list (should use all services) - view := NewServicesView(nil) - - // View should render without panic - output := view.View() - if output == "" { - t.Error("ServicesView.View() should return content") - } - if !strings.Contains(output, "Starting Services") { - t.Error("ServicesView should show title") - } -} - -func TestServicesViewWithOptions(t *testing.T) { - customMsg := ReturnFromServicesMsg{} - view := NewServicesView( - []string{"postgres"}, - WithServicesReturnMsg(customMsg), - WithMode(ModeStop), - ) - - if view.returnMsg == nil { - t.Error("WithServicesReturnMsg should set returnMsg") - } - if view.mode != ModeStop { - t.Error("WithMode should set mode") - } -} - -func TestServicesViewModes(t *testing.T) { - tests := []struct { - mode ServicesViewMode - expected string - }{ - {ModeStart, "Starting Services"}, - {ModeStop, "Stopping Services"}, - {ModeRestart, "Restarting Services"}, - } - - for _, tt := range tests { - view := NewServicesView(nil, WithMode(tt.mode)) - output := view.View() - if !strings.Contains(output, tt.expected) { - t.Errorf("Mode %d should show %q in title", tt.mode, tt.expected) - } - } -} - -func TestServicesViewUpdate(t *testing.T) { - view := NewServicesView([]string{"test"}) - - // Test window size message - newView, _ := view.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) - updatedView := newView.(ServicesView) - if updatedView.width != 100 { - t.Error("ServicesView should update dimensions on WindowSizeMsg") - } -} - -func TestAgentView(t *testing.T) { - view := NewAgentView() - - // Check initial state - if view.connected { - t.Error("AgentView should start disconnected") - } - if view.focus != FocusLogs { - t.Error("AgentView should start with focus on logs") - } - - // View should render without panic (before window size, shows initializing) - output := view.View() - if output == "" { - t.Error("AgentView.View() should return content") - } - if !strings.Contains(output, "Initializing") { - t.Error("AgentView should show initializing before window size") - } - - // After window size, should show full view - view.width = 100 - view.height = 30 - output = view.View() - if !strings.Contains(output, "Agent Monitor") { - t.Error("AgentView should show title after resize") - } -} - -func TestAgentViewWithOptions(t *testing.T) { - customMenu := []AgentMenuItem{ - {Name: "Custom", Description: "Custom action"}, - } - - view := NewAgentView( - WithSplitRatio(0.7), - WithMenuItems(customMenu), - ) - - if view.splitRatio != 0.7 { - t.Errorf("splitRatio = %f, want 0.7", view.splitRatio) - } - if len(view.menuItems) != 1 { - t.Errorf("menuItems length = %d, want 1", len(view.menuItems)) - } - if view.menuItems[0].Name != "Custom" { - t.Error("Custom menu item not set") - } -} - -func TestAgentViewSplitRatioBounds(t *testing.T) { - // Test lower bound - view := NewAgentView(WithSplitRatio(0.1)) - if view.splitRatio < 0.2 { - t.Error("splitRatio should be clamped to minimum 0.2") - } - - // Test upper bound - view = NewAgentView(WithSplitRatio(0.9)) - if view.splitRatio > 0.8 { - t.Error("splitRatio should be clamped to maximum 0.8") - } -} - -func TestAgentViewAddLog(t *testing.T) { - view := NewAgentView() - - view.AddLog("Test log message") - - if len(view.logs) != 1 { - t.Errorf("logs length = %d, want 1", len(view.logs)) - } - if !strings.Contains(view.logs[0], "Test log message") { - t.Error("Log should contain the message") - } -} - -func TestAgentViewSetConnected(t *testing.T) { - view := NewAgentView() - - view.SetConnected(true) - if !view.connected { - t.Error("SetConnected(true) should set connected to true") - } - - view.SetConnected(false) - if view.connected { - t.Error("SetConnected(false) should set connected to false") - } -} - -func TestAgentViewUpdate(t *testing.T) { - view := NewAgentView() - - // Test tab to switch focus - newView, _ := view.Update(tea.KeyMsg{Type: tea.KeyTab}) - updatedView := newView.(AgentView) - if updatedView.focus != FocusMenu { - t.Error("Tab should switch focus to menu") - } - - // Test tab again to switch back - newView, _ = updatedView.Update(tea.KeyMsg{Type: tea.KeyTab}) - updatedView = newView.(AgentView) - if updatedView.focus != FocusLogs { - t.Error("Tab should switch focus back to logs") - } -} - -func TestAgentViewMenuNavigation(t *testing.T) { - view := NewAgentView() - view.focus = FocusMenu - - // Move down - newView, _ := view.Update(tea.KeyMsg{Type: tea.KeyDown}) - updatedView := newView.(AgentView) - if updatedView.selectedIdx != 1 { - t.Errorf("Down should move selection to 1, got %d", updatedView.selectedIdx) - } - - // Move up - newView, _ = updatedView.Update(tea.KeyMsg{Type: tea.KeyUp}) - updatedView = newView.(AgentView) - if updatedView.selectedIdx != 0 { - t.Errorf("Up should move selection to 0, got %d", updatedView.selectedIdx) - } - - // Can't go above 0 - newView, _ = updatedView.Update(tea.KeyMsg{Type: tea.KeyUp}) - updatedView = newView.(AgentView) - if updatedView.selectedIdx != 0 { - t.Error("Up at 0 should stay at 0") - } -} - -func TestServiceInfo(t *testing.T) { - info := ServiceInfo{ - Name: "postgres", - DisplayName: "PostgreSQL", - Status: "running", - PID: 1234, - Port: 5432, - Details: "10 connections", - } - - if info.Name != "postgres" { - t.Error("ServiceInfo.Name not set") - } - if info.DisplayName != "PostgreSQL" { - t.Error("ServiceInfo.DisplayName not set") - } - if info.Status != "running" { - t.Error("ServiceInfo.Status not set") - } - if info.PID != 1234 { - t.Error("ServiceInfo.PID not set") - } - if info.Port != 5432 { - t.Error("ServiceInfo.Port not set") - } -} - -func TestServiceStartInfo(t *testing.T) { - info := ServiceStartInfo{ - Name: "postgres", - DisplayName: "PostgreSQL", - State: StateRunning, - Progress: 1.0, - Message: "Started", - } - - if info.State != StateRunning { - t.Error("ServiceStartInfo.State not set") - } - if info.Progress != 1.0 { - t.Error("ServiceStartInfo.Progress not set") - } -} diff --git a/apps/stackpanel-go/internal/util/tests.go b/apps/stackpanel-go/internal/util/tests.go deleted file mode 100644 index dcb8eb86..00000000 --- a/apps/stackpanel-go/internal/util/tests.go +++ /dev/null @@ -1,117 +0,0 @@ -package util - -import ( - "os" - "path/filepath" - "testing" -) - -const ( - plainFlakeContent = `{ - description = "plain test repo"; - - outputs = { self }: {}; -} -` - - stackpanelFlakeContent = `{ - description = "stackpanel test repo"; - - inputs.stackpanel.url = "github:darkmatter/stackpanel"; - - outputs = { self, stackpanel }: {}; -} -` - - stackpanelConfigContent = `{ ... }: { - stackpanel = { - enable = true; - name = "test-project"; - }; -} -` -) - -// TestRepo describes a temporary repository scaffold created for tests. -type TestRepo struct { - Root string - GitDir string - FlakePath string - StackDir string - ConfigPath string -} - -// Path returns an absolute path under the test repository root. -func (r TestRepo) Path(elem ...string) string { - parts := append([]string{r.Root}, elem...) - return filepath.Join(parts...) -} - -// ScaffoldRepo creates a minimal non-Stackpanel git repository fixture. -func ScaffoldRepo(t testing.TB) TestRepo { - t.Helper() - - root := makeTestRepoRoot(t) - repo := TestRepo{ - Root: root, - GitDir: filepath.Join(root, ".git"), - FlakePath: filepath.Join(root, "flake.nix"), - StackDir: filepath.Join(root, ".stack"), - ConfigPath: filepath.Join(root, ".stack", "config.nix"), - } - - mkdirTestDir(t, repo.GitDir) - writeTestFile(t, repo.FlakePath, plainFlakeContent) - - return repo -} - -// ScaffoldStackpanelRepo creates a minimal Stackpanel git repository fixture. -func ScaffoldStackpanelRepo(t testing.TB) TestRepo { - t.Helper() - - repo := ScaffoldRepo(t) - mkdirTestDir(t, repo.StackDir) - writeTestFile(t, repo.FlakePath, stackpanelFlakeContent) - writeTestFile(t, repo.ConfigPath, stackpanelConfigContent) - - return repo -} - -func makeTestRepoRoot(t testing.TB) string { - t.Helper() - - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get working directory: %v", err) - } - - root, err := os.MkdirTemp(cwd, ".stackpanel-test-repo-*") - if err != nil { - t.Fatalf("failed to create test repo: %v", err) - } - t.Cleanup(func() { - _ = os.RemoveAll(root) - }) - - return root -} - -func mkdirTestDir(t testing.TB, path string) { - t.Helper() - - if err := os.MkdirAll(path, 0o755); err != nil { - t.Fatalf("failed to create directory %s: %v", path, err) - } -} - -func writeTestFile(t testing.TB, path string, content string) { - t.Helper() - - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("failed to create parent directory for %s: %v", path, err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("failed to write %s: %v", path, err) - } -} diff --git a/apps/stackpanel-go/internal/util/tests_test.go b/apps/stackpanel-go/internal/util/tests_test.go deleted file mode 100644 index f38f208d..00000000 --- a/apps/stackpanel-go/internal/util/tests_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package util - -import ( - "os" - "strings" - "testing" -) - -func TestScaffoldRepoCreatesPlainGitRepo(t *testing.T) { - t.Parallel() - - repo := ScaffoldRepo(t) - - assertDirExists(t, repo.Path(".git")) - assertFileExists(t, repo.FlakePath) - assertFileMissing(t, repo.ConfigPath) - - flake := readTestFile(t, repo.FlakePath) - if strings.Contains(flake, "stackpanel") { - t.Fatalf("plain repo flake should not reference stackpanel: %s", flake) - } -} - -func TestScaffoldStackpanelRepoCreatesStackpanelConfig(t *testing.T) { - t.Parallel() - - repo := ScaffoldStackpanelRepo(t) - - assertDirExists(t, repo.Path(".git")) - assertFileExists(t, repo.FlakePath) - assertFileExists(t, repo.ConfigPath) - - config := readTestFile(t, repo.ConfigPath) - if !strings.Contains(config, "enable = true;") { - t.Fatalf("stack config should enable stackpanel: %s", config) - } - if !strings.Contains(config, `name = "test-project";`) { - t.Fatalf("stack config should set project name: %s", config) - } -} - -func assertDirExists(t *testing.T, path string) { - t.Helper() - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("expected directory %s to exist: %v", path, err) - } - if !info.IsDir() { - t.Fatalf("expected %s to be a directory", path) - } -} - -func assertFileExists(t *testing.T, path string) { - t.Helper() - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("expected file %s to exist: %v", path, err) - } - if info.IsDir() { - t.Fatalf("expected %s to be a file", path) - } -} - -func assertFileMissing(t *testing.T, path string) { - t.Helper() - - if _, err := os.Stat(path); err == nil { - t.Fatalf("expected %s to be missing", path) - } else if !os.IsNotExist(err) { - t.Fatalf("expected %s to be missing, got error: %v", path, err) - } -} - -func readTestFile(t *testing.T, path string) string { - t.Helper() - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("failed to read %s: %v", path, err) - } - return string(data) -} diff --git a/apps/stackpanel-go/pkg/common/logger.go b/apps/stackpanel-go/pkg/common/logger.go deleted file mode 100644 index e13a8124..00000000 --- a/apps/stackpanel-go/pkg/common/logger.go +++ /dev/null @@ -1,89 +0,0 @@ -// Package common provides shared utilities for the stackpanel CLI and agent, -// including a unified logging facade that renders through Charmbracelet's -// styled output for consistent terminal aesthetics. -package common - -import ( - "os" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/log" - "go.uber.org/zap" - "go.uber.org/zap/buffer" - "go.uber.org/zap/zapcore" -) - -// Lipgloss styles for direct terminal rendering outside the logger. -// Used by CLI commands that need styled output without going through zap. -var ( - StyleInfo = lipgloss.NewStyle().Foreground(lipgloss.Color("#00afff")) - StyleWarning = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaf00")) - StyleError = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff5f5f")).Bold(true) - StyleDebug = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) -) - -var ( - logger *zap.SugaredLogger - charmLog = log.NewWithOptions(os.Stderr, log.Options{ReportTimestamp: false}) -) - -func init() { - // Wire zap through Charmbracelet's log renderer so all logging—whether from - // our code or third-party libs using zap globals—gets styled terminal output. - logger = zap.New(zapcore.NewCore( - &charmEncoder{zapcore.NewConsoleEncoder(zapcore.EncoderConfig{})}, - zapcore.AddSync(os.Stderr), - zapcore.DebugLevel, - )).Sugar() - zap.ReplaceGlobals(logger.Desugar()) -} - -// charmEncoder is a zapcore.Encoder adapter that delegates actual rendering to -// Charmbracelet's log package. The embedded ConsoleEncoder is unused for output; -// it only satisfies the interface. All formatting comes from charmLog. -type charmEncoder struct{ zapcore.Encoder } - -func (e *charmEncoder) Clone() zapcore.Encoder { return &charmEncoder{e.Encoder.Clone()} } - -// EncodeEntry routes zap log entries to charmLog for styled output. -// Returns an empty buffer since charmLog writes directly to stderr. -func (e *charmEncoder) EncodeEntry( - ent zapcore.Entry, - fields []zapcore.Field, -) (*buffer.Buffer, error) { - switch ent.Level { - case zapcore.DebugLevel: - charmLog.Debug(ent.Message) - case zapcore.InfoLevel: - charmLog.Info(ent.Message) - case zapcore.WarnLevel: - charmLog.Warn(ent.Message) - default: - charmLog.Error(ent.Message) - } - return buffer.NewPool().Get(), nil -} - -// L returns the package-level sugared logger for structured logging. -func L() *zap.SugaredLogger { return logger } - -// Structured logging with key-value pairs: common.Info("connected", "port", 8080) -func Debug(msg string, args ...any) { zap.S().Debugw(msg, args...) } -func Info(msg string, args ...any) { zap.S().Infow(msg, args...) } -func Warn(msg string, args ...any) { zap.S().Warnw(msg, args...) } -func Error(msg string, args ...any) { zap.S().Errorw(msg, args...) } -func Fatal(msg string, args ...any) { zap.S().Fatalw(msg, args...) } -func Print(msg string, args ...any) { zap.S().Infow(msg, args...) } - -// Printf-style formatted logging: common.Infof("listening on :%d", port) -func Debugf(t string, args ...any) { zap.S().Debugf(t, args...) } -func Infof(t string, args ...any) { zap.S().Infof(t, args...) } -func Warnf(t string, args ...any) { zap.S().Warnf(t, args...) } -func Errorf(t string, args ...any) { zap.S().Errorf(t, args...) } -func Fatalf(t string, args ...any) { zap.S().Fatalf(t, args...) } -func Printf(t string, args ...any) { zap.S().Infof(t, args...) } - -// With returns a child logger with additional context fields baked in. -func With(args ...any) *zap.SugaredLogger { - return zap.S().With(args...) -} diff --git a/apps/stackpanel-go/pkg/exec/exec.go b/apps/stackpanel-go/pkg/exec/exec.go index 6638a3a3..62ebc55c 100644 --- a/apps/stackpanel-go/pkg/exec/exec.go +++ b/apps/stackpanel-go/pkg/exec/exec.go @@ -27,8 +27,8 @@ import ( // cached copy of the Nix devshell environment so commands spawned outside // a devshell still have access to Nix-provided packages and env vars. // -// Thread-safe for reads; callers should not call SetProjectRoot or -// LoadDevshellEnv concurrently with command execution. +// Thread-safe for reads; callers should not call LoadDevshellEnv +// concurrently with command execution. type Executor struct { projectRoot string allowedCommands map[string]bool // empty map means allow all @@ -375,12 +375,6 @@ func mergeEnv(base, overrides []string) []string { return result } -// ClearDevshellEnv clears the cached devshell environment. -// Useful if you want to force a reload. -func (e *Executor) ClearDevshellEnv() { - e.devshellEnv = nil -} - // BuildEnv returns a merged environment suitable for passing to exec.Cmd.Env // directly. Uses the same precedence as RunWithOptions: process env → devshell // env → extra overrides. Useful when callers need to construct their own @@ -410,22 +404,3 @@ func (e *Executor) GetEnv(key string) string { // Fall back to process environment return os.Getenv(key) } - -// SetProjectRoot updates the project root and optionally reloads the devshell -// environment. Clears the cached devshell env even if reloadDevshell is false, -// since the old env was for a different project. -func (e *Executor) SetProjectRoot(projectRoot string, reloadDevshell bool) error { - e.projectRoot = projectRoot - e.devshellEnv = nil - - if reloadDevshell && !e.inDevshell && projectRoot != "" { - return e.LoadDevshellEnv(context.Background()) - } - - return nil -} - -// ProjectRoot returns the current project root -func (e *Executor) ProjectRoot() string { - return e.projectRoot -} diff --git a/apps/stackpanel-go/pkg/nix/path.go b/apps/stackpanel-go/pkg/nix/path.go deleted file mode 100644 index fe348425..00000000 --- a/apps/stackpanel-go/pkg/nix/path.go +++ /dev/null @@ -1,78 +0,0 @@ -package nix - -import ( - "encoding/json" - "fmt" - "path/filepath" -) - -// NixPath represents a Nix path literal (e.g. ./hardware/prod.nix). -// It serializes to/from a tagged object {"__nixPath": "..."} to preserve -// path type information through JSON round-trips. -// -// Round-trip: -// -// ./hardware/prod.nix → {"__nixPath": "/abs/.../prod.nix"} -// → NixPath("./hardware/prod.nix") -// → ./hardware/prod.nix (Nix expression) -type NixPath string - -// MarshalJSON encodes the path as {"__nixPath": ""}. -func (p NixPath) MarshalJSON() ([]byte, error) { - return json.Marshal(map[string]string{"__nixPath": string(p)}) -} - -// UnmarshalJSON decodes {"__nixPath": "..."} back to a NixPath. -func (p *NixPath) UnmarshalJSON(data []byte) error { - var m map[string]string - if err := json.Unmarshal(data, &m); err != nil { - return err - } - path, ok := m["__nixPath"] - if !ok { - return fmt.Errorf("NixPath: missing __nixPath key in %s", data) - } - *p = NixPath(path) - return nil -} - -// ConvertNixPaths walks a decoded JSON value and replaces any map of the form -// {"__nixPath": "/abs/path"} with a NixPath relative to projectRoot. -// All other values are returned unchanged. -// -// This is used in Phase 2 (config.nix editing) to preserve path literals -// through the nix eval → Go → serialize round-trip. -func ConvertNixPaths(v any, projectRoot string) any { - switch val := v.(type) { - case map[string]any: - // Check if this is a __nixPath sentinel (exactly one key) - if raw, ok := val["__nixPath"]; ok && len(val) == 1 { - if absPath, ok := raw.(string); ok { - rel, err := filepath.Rel(projectRoot, absPath) - if err != nil { - // Cannot make relative — keep absolute - return NixPath(absPath) - } - // Ensure path starts with ./ or ../ - if !filepath.IsAbs(rel) && len(rel) > 0 && rel[0] != '.' { - rel = "./" + rel - } - return NixPath(rel) - } - } - // Recursively process map values - result := make(map[string]any, len(val)) - for k, v2 := range val { - result[k] = ConvertNixPaths(v2, projectRoot) - } - return result - case []any: - result := make([]any, len(val)) - for i, elem := range val { - result[i] = ConvertNixPaths(elem, projectRoot) - } - return result - default: - return v - } -} diff --git a/apps/stackpanel-go/pkg/nix/path_test.go b/apps/stackpanel-go/pkg/nix/path_test.go deleted file mode 100644 index 189fc76b..00000000 --- a/apps/stackpanel-go/pkg/nix/path_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package nix - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNixPath_MarshalJSON(t *testing.T) { - p := NixPath("./hardware/prod.nix") - data, err := json.Marshal(p) - require.NoError(t, err) - assert.Equal(t, `{"__nixPath":"./hardware/prod.nix"}`, string(data)) -} - -func TestNixPath_UnmarshalJSON(t *testing.T) { - var p NixPath - err := json.Unmarshal([]byte(`{"__nixPath":"./hardware/prod.nix"}`), &p) - require.NoError(t, err) - assert.Equal(t, NixPath("./hardware/prod.nix"), p) -} - -func TestNixPath_UnmarshalJSON_MissingKey(t *testing.T) { - var p NixPath - err := json.Unmarshal([]byte(`{"other":"value"}`), &p) - assert.Error(t, err) - assert.Contains(t, err.Error(), "__nixPath") -} - -func TestNixPath_RoundTrip(t *testing.T) { - original := NixPath("./hardware/server/hardware-configuration.nix") - data, err := json.Marshal(original) - require.NoError(t, err) - - var decoded NixPath - err = json.Unmarshal(data, &decoded) - require.NoError(t, err) - assert.Equal(t, original, decoded) -} - -func TestConvertNixPaths_PathSentinel(t *testing.T) { - projectRoot := "/home/user/project" - input := map[string]any{ - "__nixPath": "/home/user/project/hardware/prod.nix", - } - result := ConvertNixPaths(input, projectRoot) - assert.Equal(t, NixPath("./hardware/prod.nix"), result) -} - -func TestConvertNixPaths_NonPathMap(t *testing.T) { - projectRoot := "/home/user/project" - input := map[string]any{ - "host": "192.168.1.1", - "port": 22, - } - result := ConvertNixPaths(input, projectRoot) - m, ok := result.(map[string]any) - require.True(t, ok) - assert.Equal(t, "192.168.1.1", m["host"]) - assert.Equal(t, 22, m["port"]) -} - -func TestConvertNixPaths_NestedMaps(t *testing.T) { - projectRoot := "/home/user/project" - input := map[string]any{ - "machine": map[string]any{ - "host": "192.168.1.1", - "hardwareConfig": map[string]any{ - "__nixPath": "/home/user/project/hw/prod.nix", - }, - }, - } - result := ConvertNixPaths(input, projectRoot) - m := result.(map[string]any) - machine := m["machine"].(map[string]any) - assert.Equal(t, "192.168.1.1", machine["host"]) - assert.Equal(t, NixPath("./hw/prod.nix"), machine["hardwareConfig"]) -} - -func TestConvertNixPaths_Slice(t *testing.T) { - projectRoot := "/home/user/project" - input := []any{ - map[string]any{"__nixPath": "/home/user/project/a.nix"}, - "plain string", - 42, - } - result := ConvertNixPaths(input, projectRoot) - arr, ok := result.([]any) - require.True(t, ok) - assert.Equal(t, NixPath("./a.nix"), arr[0]) - assert.Equal(t, "plain string", arr[1]) - assert.Equal(t, 42, arr[2]) -} - -func TestConvertNixPaths_OutsideProjectRoot(t *testing.T) { - projectRoot := "/home/user/project" - input := map[string]any{ - "__nixPath": "/etc/nixos/hardware-configuration.nix", - } - result := ConvertNixPaths(input, projectRoot) - // Should use a relative path starting with ../ - nixPath, ok := result.(NixPath) - require.True(t, ok) - assert.Contains(t, string(nixPath), "..") -} - -func TestConvertNixPaths_Primitives(t *testing.T) { - projectRoot := "/home/user/project" - assert.Equal(t, "hello", ConvertNixPaths("hello", projectRoot)) - assert.Equal(t, 42, ConvertNixPaths(42, projectRoot)) - assert.Equal(t, true, ConvertNixPaths(true, projectRoot)) - assert.Nil(t, ConvertNixPaths(nil, projectRoot)) -} diff --git a/apps/stackpanel-go/pkg/nix/serialize.go b/apps/stackpanel-go/pkg/nix/serialize.go index 7b41cb93..98baec29 100644 --- a/apps/stackpanel-go/pkg/nix/serialize.go +++ b/apps/stackpanel-go/pkg/nix/serialize.go @@ -141,18 +141,6 @@ func serializeValue(v reflect.Value, depth int) (string, error) { v = v.Elem() } - // Emit NixPath as an unquoted Nix path literal. - // Must come before the Kind switch because NixPath has Kind == String. - if v.Type() == reflect.TypeOf(NixPath("")) { - path := v.String() - if !strings.HasPrefix(path, "/") && - !strings.HasPrefix(path, "./") && - !strings.HasPrefix(path, "../") { - path = "./" + path - } - return path, nil - } - switch v.Kind() { case reflect.Bool: if v.Bool() { diff --git a/apps/stackpanel-go/pkg/nixdata/store.go b/apps/stackpanel-go/pkg/nixdata/store.go index c1672e8e..75a218a0 100644 --- a/apps/stackpanel-go/pkg/nixdata/store.go +++ b/apps/stackpanel-go/pkg/nixdata/store.go @@ -270,40 +270,6 @@ func (s *Store) WriteEntity(entity string, data any) (string, error) { return dataPath, nil } -// WriteEntityJSON accepts camelCase JSON bytes (e.g. from protojson), -// transforms keys to kebab-case, unwraps map entity envelopes (removing the -// outer {"apps": ...} wrapper), and writes the result. This is the primary -// entry point for the agent HTTP handlers. -func (s *Store) WriteEntityJSON(entity string, data []byte) (string, error) { - if err := ValidateEntityName(entity); err != nil { - return "", err - } - if IsExternalEntity(entity) { - return "", errors.New("external entities are read-only") - } - - transformed, err := CamelCaseToNixJSON(data, MapFieldNames()) - if err != nil { - return "", fmt.Errorf("transform keys: %w", err) - } - - var value any - if err := json.Unmarshal(transformed, &value); err != nil { - return "", fmt.Errorf("parse json: %w", err) - } - - // Unwrap map entities to match Nix data files (store raw map). - if IsMapEntity(entity) { - if obj, ok := value.(map[string]any); ok { - if inner, ok := obj[entity]; ok { - value = inner - } - } - } - - return s.WriteEntity(entity, value) -} - // --------------------------------------------------------------------------- // Key-level updates // --------------------------------------------------------------------------- diff --git a/apps/stackpanel-go/pkg/nixeval/eval.go b/apps/stackpanel-go/pkg/nixeval/eval.go index bca8b4cb..59a0893d 100644 --- a/apps/stackpanel-go/pkg/nixeval/eval.go +++ b/apps/stackpanel-go/pkg/nixeval/eval.go @@ -330,22 +330,3 @@ func findProjectRoot() string { return "" } - -// MustEvalOnceConfig is like GetConfigWithEval but panics on error. -// Only appropriate for program init where failure is unrecoverable. -func MustEvalOnceConfig(ctx context.Context, projectRoot string) *Config { - config, err := GetConfigWithEval(ctx, projectRoot) - if err != nil { - panic(fmt.Sprintf("failed to evaluate stackpanel config: %v", err)) - } - return config -} - -// MustEvalOnce is like EvalOnce but panics on error. -func MustEvalOnce(ctx context.Context, opts EvalOnceParams) []byte { - result, err := EvalOnce(ctx, opts) - if err != nil { - panic(fmt.Sprintf("failed to evaluate stackpanel result: %v", err)) - } - return result -} diff --git a/apps/stackpanel-go/pkg/nixeval/nixeval.go b/apps/stackpanel-go/pkg/nixeval/nixeval.go index 6a845619..05967094 100644 --- a/apps/stackpanel-go/pkg/nixeval/nixeval.go +++ b/apps/stackpanel-go/pkg/nixeval/nixeval.go @@ -3,8 +3,8 @@ // // - One-shot evaluation ([GetConfigWithEval], [EvalExpr]) for CLI commands // that need the config once and exit. -// - Cached evaluation with file watching ([Evaluator]) for the long-running -// agent, which re-evaluates only when Nix source files change on disk. +// - Cached evaluation ([Evaluator]) for the long-running agent, which +// re-evaluates only when its cache expires or is invalidated. // // Both paths prefer live Nix evaluation over state files to eliminate drift, // but fall back gracefully when Nix is unavailable. @@ -13,15 +13,11 @@ package nixeval import ( "bytes" "context" - "encoding/json" "fmt" - "os" "os/exec" "path/filepath" "sync" "time" - - "github.com/fsnotify/fsnotify" ) // Config is the JSON shape returned by eval.nix (and the state file fallback). @@ -79,21 +75,17 @@ type StepConfig struct { CAUrl *string `json:"caUrl,omitempty"` } -// Evaluator provides cached Nix evaluation with file watching. It is the core -// mechanism the agent uses to keep its in-memory config in sync with the Nix -// source tree. Callers use [Evaluator.Eval] to get the latest result; the -// evaluator returns a cached copy unless the cache TTL has expired or a -// watched file has been modified. +// Evaluator provides cached Nix evaluation. It is the core mechanism the +// agent uses to keep its in-memory config in sync with the Nix source tree. +// Callers use [Evaluator.Eval] to get the latest result; the evaluator returns +// a cached copy unless the cache TTL has expired or [Evaluator.Invalidate] was +// called (the agent's FlakeWatcher does so when Nix sources change). // // The locking strategy uses a double-checked read pattern: a fast RLock path // for cache hits and a full Lock only when re-evaluation is needed. type Evaluator struct { projectRoot string - nixFile string // Path to .nix file; ignored when nixExpr or flakeAttr is set - nixExpr string // Inline Nix expression (--expr) - flakeAttr string // Flake installable (e.g. ".#stackpanelConfig") - flakeAttrs []string // Ordered fallback installables; first success wins - nixArgs map[string]string // Passed as --argstr pairs + flakeAttrs []string // Ordered fallback installables; first success wins timeout time.Duration mu sync.RWMutex @@ -101,9 +93,7 @@ type Evaluator struct { cachedAt time.Time cacheTTL time.Duration - watcher *fsnotify.Watcher - watchPaths []string - invalidated bool // set by file watcher; cleared after successful re-eval + invalidated bool // set by Invalidate; cleared after successful re-eval } // Option configures an Evaluator @@ -123,42 +113,6 @@ func WithCacheTTL(d time.Duration) Option { } } -// WithWatchPaths sets additional paths to watch for changes -func WithWatchPaths(paths []string) Option { - return func(e *Evaluator) { - e.watchPaths = append(e.watchPaths, paths...) - } -} - -// WithNixFile sets the path to a .nix file to evaluate with `nix eval -f`. -func WithNixFile(nixFile string) Option { - return func(e *Evaluator) { - e.nixFile = nixFile - } -} - -// WithArgs passes additional --argstr pairs to nix eval. -func WithArgs(args map[string]string) Option { - return func(e *Evaluator) { - e.nixArgs = args - } -} - -// WithExpression sets an inline Nix expression to evaluate with --expr. -func WithExpression(expr string) Option { - return func(e *Evaluator) { - e.nixExpr = expr - } -} - -// WithFlakeAttr sets a flake attribute to evaluate (e.g., ".#stackpanelConfig") -// This takes precedence over WithExpression and WithNixFile -func WithFlakeAttr(attr string) Option { - return func(e *Evaluator) { - e.flakeAttr = attr - } -} - // WithFlakeAttrFallbacks sets multiple flake attributes to try in order // The first one that succeeds will be used. This is useful for supporting // both user projects (devshell passthru) and the stackpanel repo itself. @@ -175,25 +129,10 @@ func New(projectRoot string, opts ...Option) (*Evaluator, error) { return nil, fmt.Errorf("failed to resolve project root: %w", err) } - // nixFile := filepath.Join(absRoot, "nix", "eval", "stackpanel-config.nix") - // if _, err := os.Stat(nixFile); os.IsNotExist(err) { - // return nil, fmt.Errorf("nix eval file not found: %s", nixFile) - // } - defaultWatchPaths := []string{ - filepath.Join(absRoot, "flake.nix"), - filepath.Join(absRoot, ".stack"), - filepath.Join(absRoot, ".stackpanel"), - } - if os.Getenv("STACKPANEL_CONFIG_JSON") != "" { - defaultWatchPaths = append(defaultWatchPaths, os.Getenv("STACKPANEL_CONFIG_JSON")) - } - e := &Evaluator{ projectRoot: absRoot, - // nixFile: nixFile, - timeout: 10 * time.Second, - cacheTTL: 5 * time.Second, - watchPaths: defaultWatchPaths, + timeout: 10 * time.Second, + cacheTTL: 5 * time.Second, } for _, opt := range opts { @@ -203,25 +142,6 @@ func New(projectRoot string, opts ...Option) (*Evaluator, error) { return e, nil } -// NewWatchConfig is a helper to create an Evaluator to watch the standard -// config -func NewWatchConfig(projectRoot string, opts ...Option) (*Evaluator, error) { - nixFile := filepath.Join(projectRoot, "nix", "eval", "stackpanel-config.nix") - if _, err := os.Stat(nixFile); os.IsNotExist(err) { - return nil, fmt.Errorf("nix eval file not found: %s", nixFile) - } - defaultOpts := []Option{ - WithWatchPaths([]string{ - filepath.Join(projectRoot, "flake.nix"), - filepath.Join(projectRoot, ".stack"), - filepath.Join(projectRoot, ".stackpanel"), - }), - WithNixFile(nixFile), - } - opts = append(opts, defaultOpts...) - return New(projectRoot, opts...) -} - // Eval returns the latest nix eval result, using the cache when valid. // This is the primary method the agent calls on every API request. It uses a // double-checked locking pattern: the fast path (RLock) serves cached data, @@ -254,33 +174,8 @@ func (e *Evaluator) Eval(ctx context.Context) ([]byte, error) { return result, nil } -// evalNixConfig runs nix eval and unmarshals the result into a Config. -func (e *Evaluator) evalNixConfig(ctx context.Context) (*Config, error) { - result, err := e.evalNix(ctx) - if err != nil { - return nil, err - } - - var config Config - if err := json.Unmarshal(result, &config); err != nil { - return nil, fmt.Errorf("failed to parse nix eval output: %w", err) - } - - // Check for error in the config itself - if config.Error != "" { - return nil, fmt.Errorf("%s: %s", config.Error, config.Hint) - } - - // Set project root if not in config - if config.ProjectRoot == "" { - config.ProjectRoot = e.projectRoot - } - - return &config, nil -} - -// evalNix shells out to `nix eval` and returns raw JSON bytes. -// Expression source priority: flakeAttrs (fallback list) > flakeAttr > nixExpr > nixFile. +// evalNix shells out to `nix eval` and returns raw JSON bytes, trying the +// configured flake attributes in order. func (e *Evaluator) evalNix(ctx context.Context) ([]byte, error) { ctx, cancel := context.WithTimeout(ctx, e.timeout) defer cancel() @@ -291,19 +186,6 @@ func (e *Evaluator) evalNix(ctx context.Context) ([]byte, error) { args := []string{"eval", "--impure", "--json"} - if e.flakeAttr != "" { - // Flake attribute (e.g., ".#stackpanelConfig") - append directly - args = append(args, e.flakeAttr) - } else if e.nixExpr != "" { - args = append(args, "--expr", e.nixExpr) - } else if e.nixFile != "" { - args = append(args, "-f", e.nixFile) - } - - for k, v := range e.nixArgs { - args = append(args, "--argstr", k, v) - } - cmd := exec.CommandContext(ctx, "nix", args...) cmd.Dir = e.projectRoot @@ -335,10 +217,6 @@ func (e *Evaluator) evalNixWithFallbacks( for _, attr := range attrs { args := []string{"eval", "--impure", "--json", attr} - for k, v := range e.nixArgs { - args = append(args, "--argstr", k, v) - } - cmd := exec.CommandContext(ctx, "nix", args...) cmd.Dir = e.projectRoot @@ -366,89 +244,6 @@ func (e *Evaluator) evalNixWithFallbacks( return nil, fmt.Errorf("all flake attribute paths failed, last error: %w", lastErr) } -// StartWatching begins watching files for changes -// When a change is detected, the cache is invalidated -func (e *Evaluator) StartWatching() error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("failed to create file watcher: %w", err) - } - - e.mu.Lock() - e.watcher = watcher - e.mu.Unlock() - - // Add watch paths - for _, path := range e.watchPaths { - if err := e.addWatchRecursive(path); err != nil { - // Log but don't fail - some paths may not exist - continue - } - } - - // Start watching goroutine - go e.watchLoop() - - return nil -} - -// addWatchRecursive adds a path and all its subdirectories to the fsnotify -// watcher. Errors on individual paths are silently skipped -- missing or -// inaccessible paths are expected (e.g. .stack/ may not exist yet). -func (e *Evaluator) addWatchRecursive(path string) error { - return filepath.Walk(path, func(p string, info os.FileInfo, err error) error { - if err != nil { - return nil // Skip inaccessible paths - } - if info.IsDir() { - if err := e.watcher.Add(p); err != nil { - return nil // Skip unwatchable dirs - } - } - return nil - }) -} - -// watchLoop processes fsnotify events. Only .nix, .yaml, and .json file -// changes trigger cache invalidation -- this avoids spurious re-evaluations -// from editor swap files, .git operations, etc. -func (e *Evaluator) watchLoop() { - for { - select { - case event, ok := <-e.watcher.Events: - if !ok { - return - } - // Invalidate cache on any write or create - if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove) != 0 { - // Only care about .nix, .yaml, .json files - ext := filepath.Ext(event.Name) - if ext == ".nix" || ext == ".yaml" || ext == ".json" { - e.mu.Lock() - e.invalidated = true - e.mu.Unlock() - } - } - case _, ok := <-e.watcher.Errors: - if !ok { - return - } - // Log error but continue watching - } - } -} - -// StopWatching stops the file watcher -func (e *Evaluator) StopWatching() error { - e.mu.Lock() - defer e.mu.Unlock() - - if e.watcher != nil { - return e.watcher.Close() - } - return nil -} - // Invalidate manually invalidates the cache func (e *Evaluator) Invalidate() { e.mu.Lock() diff --git a/apps/stackpanel-go/pkg/nixeval/presets.go b/apps/stackpanel-go/pkg/nixeval/presets.go index a1b29116..27c4d69b 100644 --- a/apps/stackpanel-go/pkg/nixeval/presets.go +++ b/apps/stackpanel-go/pkg/nixeval/presets.go @@ -15,29 +15,6 @@ import ( // Preset Nix expressions for common evaluations // These are self-contained snippets that can be evaluated directly const ( - // UsersPreset evaluates the users configuration from .stackpanel/data/users.nix - // Returns the users attrset in stackpanel.users format - UsersPreset = ` -let - root = builtins.getEnv "STACKPANEL_ROOT"; - usersPath = root + "/.stack/data/users.nix"; -in - if builtins.pathExists usersPath - then import usersPath - else {} -` - - // GitHubCollaboratorsPreset evaluates raw collaborators data - GitHubCollaboratorsPreset = ` -let - root = builtins.getEnv "STACKPANEL_ROOT"; - collabsPath = root + "/.stack/data/github-collaborators.nix"; -in - if builtins.pathExists collabsPath - then import collabsPath - else { collaborators = {}; } -` - // StackpanelConfigPreset evaluates the full stackpanel config from .stackpanel/config.nix StackpanelConfigPreset = ` let @@ -55,16 +32,6 @@ in // ActiveConfig returns the current active stackpanel configuration as JSON (evaluated) ActiveConfigPreset = `.#devShells.${builtins.currentSystem}.default.passthru.moduleConfig.stackpanel` - - // DbSchemasPreset evaluates all schemas from the db module for codegen - // Returns a map of entity names to JSON Schema - DbSchemasPreset = ` -let - root = builtins.getEnv "STACKPANEL_ROOT"; - dbModule = import (root + "/nix/stackpanel/db") { }; -in - dbModule.forCodegen -` ) // InstalledPackagesExpr builds a Nix expression to get installed packages from a flake. @@ -129,10 +96,10 @@ func findNixBin() (string, error) { // // Example: // -// result, err := EvalExpr(ctx, nixeval.UsersPreset) +// result, err := EvalExpr(ctx, nixeval.StackpanelConfigPreset) // if err != nil { ... } -// var users map[string]User -// result.Unmarshal(&users) +// var config map[string]interface{} +// result.Unmarshal(&config) func EvalExpr(ctx context.Context, nixExpr string) (*EvalExprResult, error) { return EvalExprWithTimeout(ctx, nixExpr, 10*time.Second) } @@ -180,79 +147,8 @@ func EvalExprWithTimeout( return &EvalExprResult{Raw: stdout.Bytes()}, nil } -// User represents a stackpanel user from the users config -type User struct { - Name string `json:"name,omitempty"` - GitHub string `json:"github,omitempty"` - PublicKeys []string `json:"public-keys,omitempty"` - SecretsAllowedEnvironments []string `json:"secrets-allowed-environments,omitempty"` -} - -// GetUsers evaluates and returns the users configuration -func GetUsers(ctx context.Context) (map[string]User, error) { - result, err := EvalExpr(ctx, UsersPreset) - if err != nil { - return nil, fmt.Errorf("failed to evaluate users: %w", err) - } - - var users map[string]User - if err := result.Unmarshal(&users); err != nil { - return nil, fmt.Errorf("failed to parse users: %w", err) - } - - return users, nil -} - -// GitHubCollaborator represents a collaborator from github-collaborators.nix -type GitHubCollaborator struct { - Login string `json:"login"` - ID int `json:"id"` - Role string `json:"role"` - IsAdmin bool `json:"isAdmin"` - PublicKeys []string `json:"publicKeys"` -} - -// GitHubCollaboratorsData represents the full github-collaborators.nix structure -type GitHubCollaboratorsData struct { - Meta struct { - Source string `json:"source"` - GeneratedAt string `json:"generatedAt"` - } `json:"_meta"` - Collaborators map[string]GitHubCollaborator `json:"collaborators"` -} - -// GetGitHubCollaborators evaluates and returns the GitHub collaborators -func GetGitHubCollaborators(ctx context.Context) (*GitHubCollaboratorsData, error) { - result, err := EvalExpr(ctx, GitHubCollaboratorsPreset) - if err != nil { - return nil, fmt.Errorf("failed to evaluate collaborators: %w", err) - } - - var data GitHubCollaboratorsData - if err := result.Unmarshal(&data); err != nil { - return nil, fmt.Errorf("failed to parse collaborators: %w", err) - } - - return &data, nil -} - -// GetStackpanelConfig evaluates the stackpanel section from .stackpanel/config.nix -// Note: This is a simplified evaluation that doesn't have access to pkgs/lib -func GetStackpanelConfig(ctx context.Context) (map[string]interface{}, error) { - result, err := EvalExpr(ctx, StackpanelConfigPreset) - if err != nil { - return nil, fmt.Errorf("failed to evaluate config: %w", err) - } - - var config map[string]interface{} - if err := result.Unmarshal(&config); err != nil { - return nil, fmt.Errorf("failed to parse config: %w", err) - } - - return config, nil -} - -// GetInitFilesFromFlake evaluates initFiles from a stackpanel flake reference. +// GetInitFilesFromFlakeTemplate evaluates a template's initFiles from a +// stackpanel flake reference. // This is the portable way to get scaffold templates - works from any directory. // // The flakeRef can be: @@ -262,15 +158,8 @@ func GetStackpanelConfig(ctx context.Context) (map[string]interface{}, error) { // // Example: // -// files, err := GetInitFilesFromFlake(ctx, "github:darkmatter/stackpanel") +// files, err := GetInitFilesFromFlakeTemplate(ctx, "github:darkmatter/stackpanel", "default") // // files[".stack/config.nix"] = "..." -func GetInitFilesFromFlake( - ctx context.Context, - flakeRef string, -) (map[string]string, error) { - return GetInitFilesFromFlakeTemplate(ctx, flakeRef, "default") -} - func GetInitFilesFromFlakeTemplate( ctx context.Context, flakeRef string, @@ -396,12 +285,6 @@ func validateInitTemplateName(template string) error { return nil } -// EvalFlakeAttr evaluates a flake attribute and returns the JSON result. -// The flakeAttr should be in the form "flakeRef#attrPath" (e.g., "github:owner/repo#lib.foo"). -func EvalFlakeAttr(ctx context.Context, flakeAttr string) (*EvalExprResult, error) { - return EvalFlakeAttrWithTimeout(ctx, flakeAttr, 30*time.Second) -} - // EvalFlakeAttrWithTimeout evaluates a flake attribute with a custom timeout. func EvalFlakeAttrWithTimeout( ctx context.Context, @@ -430,35 +313,6 @@ func EvalFlakeAttrWithTimeout( return &EvalExprResult{Raw: stdout.Bytes()}, nil } -// GetDbSchemas evaluates the db module and returns all schemas for codegen -func GetDbSchemas(ctx context.Context) (map[string]interface{}, error) { - result, err := EvalExpr(ctx, DbSchemasPreset) - if err != nil { - return nil, fmt.Errorf("failed to evaluate db schemas: %w", err) - } - - var schemas map[string]interface{} - if err := result.Unmarshal(&schemas); err != nil { - return nil, fmt.Errorf("failed to parse db schemas: %w", err) - } - - return schemas, nil -} - -// BuildExpr builds a Nix expression from a template with variables -// Variables are substituted as string interpolations -func BuildExpr(template string, vars map[string]string) string { - result := template - for k, v := range vars { - // Escape the value for Nix string embedding - escaped := strings.ReplaceAll(v, "\\", "\\\\") - escaped = strings.ReplaceAll(escaped, "\"", "\\\"") - escaped = strings.ReplaceAll(escaped, "${", "\\${") - result = strings.ReplaceAll(result, "${"+k+"}", escaped) - } - return result -} - // InstalledPackage represents a package installed in the stackpanel config type InstalledPackage struct { Name string `json:"name"` diff --git a/apps/stackpanel-go/pkg/services/base.go b/apps/stackpanel-go/pkg/services/base.go index 2901fbd5..2bae06a3 100644 --- a/apps/stackpanel-go/pkg/services/base.go +++ b/apps/stackpanel-go/pkg/services/base.go @@ -35,16 +35,9 @@ const ( // Call InitForProject() to set this based on project root. var BaseDir string -// GlobalBaseDir is the directory for services shared across projects (like Caddy). -// Unlike BaseDir, this isn't scoped to a project since Caddy serves all projects. -var GlobalBaseDir = filepath.Join(os.Getenv("HOME"), ".local", "share", "devservices") - // projectRoot stores the detected project root directory var projectRoot string -// initialized tracks whether InitForProject has been called -var initialized bool - func init() { // Set a sensible default - will be overridden by InitForProject BaseDir = filepath.Join(os.Getenv("HOME"), ".local", "share", "devservices") @@ -59,7 +52,6 @@ func InitForProject(projectDir string) { } projectRoot = projectDir BaseDir = filepath.Join(projectDir, ".stack", "state", "services") - initialized = true } // GetProjectRoot returns the detected or configured project root @@ -70,11 +62,6 @@ func GetProjectRoot() string { return projectRoot } -// IsInitialized returns true if InitForProject has been called -func IsInitialized() bool { - return initialized -} - // detectProjectRoot walks up from cwd looking for .stack or .git func detectProjectRoot() string { cwd, err := os.Getwd() diff --git a/apps/web/src/lib/nix-data/index.ts b/apps/web/src/lib/nix-data/index.ts index 037e306f..4d7b6f61 100644 --- a/apps/web/src/lib/nix-data/index.ts +++ b/apps/web/src/lib/nix-data/index.ts @@ -15,7 +15,7 @@ export type * from "@stackpanel/proto"; /** * Fields that are maps with user-defined keys that should NOT be transformed. * When the parent key matches one of these, child keys are preserved as-is. - * This must match the Go agent's mapFieldNames() in json_transform.go. + * This must match MapFieldNames() in apps/stackpanel-go/pkg/nixdata/entities.go. */ const MAP_FIELD_NAMES = new Set([ "aliases", diff --git a/docs/PROJECT_REGISTRATION.md b/docs/PROJECT_REGISTRATION.md index 8175d9b8..4777da3e 100644 --- a/docs/PROJECT_REGISTRATION.md +++ b/docs/PROJECT_REGISTRATION.md @@ -231,7 +231,7 @@ For projects with `flake.nix` but no `.stack/config.nix`, validation uses multip | **2. Flake metadata** | Runs `nix flake metadata --json` and checks if any input contains "stack" | ~1-3s | | **3. Text search** | Falls back to searching `flake.nix` for "stack" string patterns | Instant | -The nix-based checks can be skipped for faster validation using `ValidateProjectFast()` or `--skip-nix-eval` (when available). +The nix-based checks can be skipped for faster validation using `ValidationOptions.SkipNixEval` or `--skip-nix-eval` (when available). ### Validation Levels