From ea2d73d746dd6b994847dd552a2b967239632c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 3 Feb 2026 22:51:30 +0100 Subject: [PATCH] feat: add hm-options package for Home Manager options Add a new MCP server for Home Manager options, mirroring the functionality of nixos-options but targeting the home-manager repository. Changes: - Add shared options.Indexer interface for both implementations - Add internal/homemanager package with indexer and channel aliases - Add cmd/hm-options CLI entry point - Parameterize MCP server with ServerConfig for name/instructions - Parameterize nix/package.nix for building both packages - Add hm-options package and NixOS module to flake.nix - Add nix/hm-options-module.nix for systemd deployment Co-Authored-By: Claude Opus 4.5 --- cmd/hm-options/main.go | 521 +++++++++++++++++++++++++++ cmd/nixos-options/main.go | 3 +- flake.nix | 11 + internal/homemanager/indexer.go | 428 ++++++++++++++++++++++ internal/homemanager/indexer_test.go | 256 +++++++++++++ internal/homemanager/types.go | 24 ++ internal/mcp/handlers.go | 19 +- internal/mcp/server.go | 109 ++++-- internal/mcp/server_test.go | 12 +- internal/mcp/transport_http_test.go | 8 +- internal/nixos/indexer.go | 37 +- internal/nixos/indexer_test.go | 8 +- internal/options/indexer.go | 37 ++ nix/hm-options-module.nix | 261 ++++++++++++++ nix/package.nix | 17 +- 15 files changed, 1693 insertions(+), 58 deletions(-) create mode 100644 cmd/hm-options/main.go create mode 100644 internal/homemanager/indexer.go create mode 100644 internal/homemanager/indexer_test.go create mode 100644 internal/homemanager/types.go create mode 100644 internal/options/indexer.go create mode 100644 nix/hm-options-module.nix diff --git a/cmd/hm-options/main.go b/cmd/hm-options/main.go new file mode 100644 index 0000000..af3d466 --- /dev/null +++ b/cmd/hm-options/main.go @@ -0,0 +1,521 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/urfave/cli/v2" + + "git.t-juice.club/torjus/labmcp/internal/database" + "git.t-juice.club/torjus/labmcp/internal/homemanager" + "git.t-juice.club/torjus/labmcp/internal/mcp" + "git.t-juice.club/torjus/labmcp/internal/options" +) + +const ( + defaultDatabase = "sqlite://hm-options.db" + version = "0.1.0" +) + +func main() { + app := &cli.App{ + Name: "hm-options", + Usage: "MCP server for Home Manager options search and query", + Version: version, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "database", + Aliases: []string{"d"}, + Usage: "Database connection string (postgres://... or sqlite://...)", + EnvVars: []string{"HM_OPTIONS_DATABASE"}, + Value: defaultDatabase, + }, + }, + Commands: []*cli.Command{ + { + Name: "serve", + Usage: "Run MCP server", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "transport", + Aliases: []string{"t"}, + Usage: "Transport type: 'stdio' or 'http'", + Value: "stdio", + }, + &cli.StringFlag{ + Name: "http-address", + Usage: "HTTP listen address", + Value: "127.0.0.1:8080", + }, + &cli.StringFlag{ + Name: "http-endpoint", + Usage: "HTTP endpoint path", + Value: "/mcp", + }, + &cli.StringSliceFlag{ + Name: "allowed-origins", + Usage: "Allowed Origin headers for CORS (can be specified multiple times)", + }, + &cli.StringFlag{ + Name: "tls-cert", + Usage: "TLS certificate file", + }, + &cli.StringFlag{ + Name: "tls-key", + Usage: "TLS key file", + }, + &cli.DurationFlag{ + Name: "session-ttl", + Usage: "Session TTL for HTTP transport", + Value: 30 * time.Minute, + }, + }, + Action: func(c *cli.Context) error { + return runServe(c) + }, + }, + { + Name: "index", + Usage: "Index a home-manager revision", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "no-files", + Usage: "Skip indexing file contents (faster, disables get_file tool)", + }, + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "Force re-indexing even if revision already exists", + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("revision argument required") + } + return runIndex(c, c.Args().First(), !c.Bool("no-files"), c.Bool("force")) + }, + }, + { + Name: "list", + Usage: "List indexed revisions", + Action: func(c *cli.Context) error { + return runList(c) + }, + }, + { + Name: "search", + Usage: "Search for options", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "revision", + Aliases: []string{"r"}, + Usage: "Revision to search (default: most recent)", + }, + &cli.IntFlag{ + Name: "limit", + Aliases: []string{"n"}, + Usage: "Maximum number of results", + Value: 20, + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("query argument required") + } + return runSearch(c, c.Args().First()) + }, + }, + { + Name: "get", + Usage: "Get details for a specific option", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "revision", + Aliases: []string{"r"}, + Usage: "Revision to search (default: most recent)", + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("option name required") + } + return runGet(c, c.Args().First()) + }, + }, + { + Name: "delete", + Usage: "Delete an indexed revision", + ArgsUsage: "", + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("revision argument required") + } + return runDelete(c, c.Args().First()) + }, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +// openStore opens a database store based on the connection string. +func openStore(connStr string) (database.Store, error) { + if strings.HasPrefix(connStr, "sqlite://") { + path := strings.TrimPrefix(connStr, "sqlite://") + return database.NewSQLiteStore(path) + } + if strings.HasPrefix(connStr, "postgres://") || strings.HasPrefix(connStr, "postgresql://") { + return database.NewPostgresStore(connStr) + } + // Default to SQLite with the connection string as path + return database.NewSQLiteStore(connStr) +} + +func runServe(c *cli.Context) error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + store, err := openStore(c.String("database")) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer store.Close() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) + config := mcp.DefaultHomeManagerConfig() + server := mcp.NewServer(store, logger, config) + + indexer := homemanager.NewIndexer(store) + server.RegisterHandlers(indexer) + + transport := c.String("transport") + switch transport { + case "stdio": + logger.Println("Starting MCP server on stdio...") + return server.Run(ctx, os.Stdin, os.Stdout) + + case "http": + config := mcp.HTTPConfig{ + Address: c.String("http-address"), + Endpoint: c.String("http-endpoint"), + AllowedOrigins: c.StringSlice("allowed-origins"), + SessionTTL: c.Duration("session-ttl"), + TLSCertFile: c.String("tls-cert"), + TLSKeyFile: c.String("tls-key"), + } + httpTransport := mcp.NewHTTPTransport(server, config) + return httpTransport.Run(ctx) + + default: + return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport) + } +} + +func runIndex(c *cli.Context, revision string, indexFiles bool, force bool) error { + ctx := context.Background() + + store, err := openStore(c.String("database")) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer store.Close() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + indexer := homemanager.NewIndexer(store) + + fmt.Printf("Indexing revision: %s\n", revision) + + var result *options.IndexResult + if force { + result, err = indexer.ReindexRevision(ctx, revision) + } else { + result, err = indexer.IndexRevision(ctx, revision) + } + if err != nil { + return fmt.Errorf("indexing failed: %w", err) + } + + if result.AlreadyIndexed { + fmt.Printf("Revision already indexed (%d options). Use --force to re-index.\n", result.OptionCount) + return nil + } + + if dur, ok := result.Duration.(time.Duration); ok { + fmt.Printf("Indexed %d options in %s\n", result.OptionCount, dur) + } else { + fmt.Printf("Indexed %d options\n", result.OptionCount) + } + fmt.Printf("Git hash: %s\n", result.Revision.GitHash) + if result.Revision.ChannelName != "" { + fmt.Printf("Channel: %s\n", result.Revision.ChannelName) + } + + if indexFiles { + fmt.Println("Indexing files...") + fileCount, err := indexer.IndexFiles(ctx, result.Revision.ID, result.Revision.GitHash) + if err != nil { + return fmt.Errorf("file indexing failed: %w", err) + } + fmt.Printf("Indexed %d files\n", fileCount) + } + + return nil +} + +func runList(c *cli.Context) error { + ctx := context.Background() + + store, err := openStore(c.String("database")) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer store.Close() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + revisions, err := store.ListRevisions(ctx) + if err != nil { + return fmt.Errorf("failed to list revisions: %w", err) + } + + if len(revisions) == 0 { + fmt.Println("No revisions indexed.") + fmt.Println("Use 'hm-options index ' to index a home-manager version.") + return nil + } + + fmt.Printf("Indexed revisions (%d):\n\n", len(revisions)) + for _, rev := range revisions { + fmt.Printf(" %s", rev.GitHash[:12]) + if rev.ChannelName != "" { + fmt.Printf(" (%s)", rev.ChannelName) + } + fmt.Printf("\n Options: %d, Indexed: %s\n", + rev.OptionCount, rev.IndexedAt.Format("2006-01-02 15:04")) + } + + return nil +} + +func runSearch(c *cli.Context, query string) error { + ctx := context.Background() + + store, err := openStore(c.String("database")) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer store.Close() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + // Find revision + var rev *database.Revision + revisionArg := c.String("revision") + if revisionArg != "" { + rev, err = store.GetRevision(ctx, revisionArg) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + if rev == nil { + rev, err = store.GetRevisionByChannel(ctx, revisionArg) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + } + } else { + revisions, err := store.ListRevisions(ctx) + if err != nil { + return fmt.Errorf("failed to list revisions: %w", err) + } + if len(revisions) > 0 { + rev = revisions[0] + } + } + + if rev == nil { + return fmt.Errorf("no indexed revision found") + } + + filters := database.SearchFilters{ + Limit: c.Int("limit"), + } + + options, err := store.SearchOptions(ctx, rev.ID, query, filters) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + if len(options) == 0 { + fmt.Printf("No options found matching '%s'\n", query) + return nil + } + + fmt.Printf("Found %d options matching '%s':\n\n", len(options), query) + for _, opt := range options { + fmt.Printf(" %s\n", opt.Name) + fmt.Printf(" Type: %s\n", opt.Type) + if opt.Description != "" { + desc := opt.Description + if len(desc) > 100 { + desc = desc[:100] + "..." + } + fmt.Printf(" %s\n", desc) + } + fmt.Println() + } + + return nil +} + +func runGet(c *cli.Context, name string) error { + ctx := context.Background() + + store, err := openStore(c.String("database")) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer store.Close() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + // Find revision + var rev *database.Revision + revisionArg := c.String("revision") + if revisionArg != "" { + rev, err = store.GetRevision(ctx, revisionArg) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + if rev == nil { + rev, err = store.GetRevisionByChannel(ctx, revisionArg) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + } + } else { + revisions, err := store.ListRevisions(ctx) + if err != nil { + return fmt.Errorf("failed to list revisions: %w", err) + } + if len(revisions) > 0 { + rev = revisions[0] + } + } + + if rev == nil { + return fmt.Errorf("no indexed revision found") + } + + opt, err := store.GetOption(ctx, rev.ID, name) + if err != nil { + return fmt.Errorf("failed to get option: %w", err) + } + if opt == nil { + return fmt.Errorf("option '%s' not found", name) + } + + fmt.Printf("%s\n", opt.Name) + fmt.Printf(" Type: %s\n", opt.Type) + if opt.Description != "" { + fmt.Printf(" Description: %s\n", opt.Description) + } + if opt.DefaultValue != "" && opt.DefaultValue != "null" { + fmt.Printf(" Default: %s\n", opt.DefaultValue) + } + if opt.Example != "" && opt.Example != "null" { + fmt.Printf(" Example: %s\n", opt.Example) + } + if opt.ReadOnly { + fmt.Println(" Read-only: yes") + } + + // Get declarations + declarations, err := store.GetDeclarations(ctx, opt.ID) + if err == nil && len(declarations) > 0 { + fmt.Println(" Declared in:") + for _, decl := range declarations { + if decl.Line > 0 { + fmt.Printf(" - %s:%d\n", decl.FilePath, decl.Line) + } else { + fmt.Printf(" - %s\n", decl.FilePath) + } + } + } + + // Get children + children, err := store.GetChildren(ctx, rev.ID, opt.Name) + if err == nil && len(children) > 0 { + fmt.Println(" Sub-options:") + for _, child := range children { + shortName := child.Name + if strings.HasPrefix(child.Name, opt.Name+".") { + shortName = child.Name[len(opt.Name)+1:] + } + fmt.Printf(" - %s (%s)\n", shortName, child.Type) + } + } + + return nil +} + +func runDelete(c *cli.Context, revision string) error { + ctx := context.Background() + + store, err := openStore(c.String("database")) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer store.Close() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + // Find revision + rev, err := store.GetRevision(ctx, revision) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + if rev == nil { + rev, err = store.GetRevisionByChannel(ctx, revision) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + } + + if rev == nil { + return fmt.Errorf("revision '%s' not found", revision) + } + + if err := store.DeleteRevision(ctx, rev.ID); err != nil { + return fmt.Errorf("failed to delete revision: %w", err) + } + + fmt.Printf("Deleted revision %s\n", rev.GitHash) + return nil +} diff --git a/cmd/nixos-options/main.go b/cmd/nixos-options/main.go index b9e7451..c926367 100644 --- a/cmd/nixos-options/main.go +++ b/cmd/nixos-options/main.go @@ -197,7 +197,8 @@ func runServe(c *cli.Context) error { } logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) - server := mcp.NewServer(store, logger) + config := mcp.DefaultNixOSConfig() + server := mcp.NewServer(store, logger, config) indexer := nixos.NewIndexer(store) server.RegisterHandlers(indexer) diff --git a/flake.nix b/flake.nix index fe9e11e..0112d33 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,13 @@ in { nixos-options = pkgs.callPackage ./nix/package.nix { src = ./.; }; + hm-options = pkgs.callPackage ./nix/package.nix { + src = ./.; + pname = "hm-options-mcp"; + subPackage = "cmd/hm-options"; + mainProgram = "hm-options"; + description = "MCP server for Home Manager options search and query"; + }; default = self.packages.${system}.nixos-options; }); @@ -50,6 +57,10 @@ imports = [ ./nix/module.nix ]; services.nixos-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.nixos-options; }; + hm-options-mcp = { pkgs, ... }: { + imports = [ ./nix/hm-options-module.nix ]; + services.hm-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.hm-options; + }; default = self.nixosModules.nixos-options-mcp; }; }; diff --git a/internal/homemanager/indexer.go b/internal/homemanager/indexer.go new file mode 100644 index 0000000..4a46745 --- /dev/null +++ b/internal/homemanager/indexer.go @@ -0,0 +1,428 @@ +package homemanager + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "git.t-juice.club/torjus/labmcp/internal/database" + "git.t-juice.club/torjus/labmcp/internal/nixos" + "git.t-juice.club/torjus/labmcp/internal/options" +) + +// revisionPattern validates revision strings to prevent injection attacks. +// Allows: alphanumeric, hyphens, underscores, dots (for channel names like "release-24.11" +// and git hashes). Must be 1-64 characters. +var revisionPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`) + +// Indexer handles indexing of home-manager revisions. +type Indexer struct { + store database.Store + httpClient *http.Client +} + +// NewIndexer creates a new Home Manager indexer. +func NewIndexer(store database.Store) *Indexer { + return &Indexer{ + store: store, + httpClient: &http.Client{ + Timeout: 5 * time.Minute, + }, + } +} + +// IndexResult contains the results of an indexing operation. +type IndexResult struct { + Revision *database.Revision + OptionCount int + FileCount int + Duration time.Duration + AlreadyIndexed bool // True if revision was already indexed (skipped) +} + +// ValidateRevision checks if a revision string is safe to use. +// Returns an error if the revision contains potentially dangerous characters. +func ValidateRevision(revision string) error { + if !revisionPattern.MatchString(revision) { + return fmt.Errorf("invalid revision format: must be 1-64 alphanumeric characters, hyphens, underscores, or dots") + } + return nil +} + +// IndexRevision indexes a home-manager revision by git hash or channel name. +func (idx *Indexer) IndexRevision(ctx context.Context, revision string) (*options.IndexResult, error) { + start := time.Now() + + // Validate revision to prevent injection attacks + if err := ValidateRevision(revision); err != nil { + return nil, err + } + + // Resolve channel names to git refs + ref := idx.ResolveRevision(revision) + + // Check if already indexed + existing, err := idx.store.GetRevision(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to check existing revision: %w", err) + } + if existing != nil { + return &options.IndexResult{ + Revision: existing, + OptionCount: existing.OptionCount, + Duration: time.Since(start), + AlreadyIndexed: true, + }, nil + } + + // Build options.json using nix + optionsPath, cleanup, err := idx.buildOptions(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to build options: %w", err) + } + defer cleanup() + + // Parse options.json (reuse nixos parser - same format) + optionsFile, err := os.Open(optionsPath) + if err != nil { + return nil, fmt.Errorf("failed to open options.json: %w", err) + } + defer optionsFile.Close() + + opts, err := nixos.ParseOptions(optionsFile) + if err != nil { + return nil, fmt.Errorf("failed to parse options: %w", err) + } + + // Get commit info + commitDate, err := idx.getCommitDate(ctx, ref) + if err != nil { + // Non-fatal, use current time + commitDate = time.Now() + } + + // Create revision record + rev := &database.Revision{ + GitHash: ref, + ChannelName: idx.GetChannelName(revision), + CommitDate: commitDate, + OptionCount: len(opts), + } + if err := idx.store.CreateRevision(ctx, rev); err != nil { + return nil, fmt.Errorf("failed to create revision: %w", err) + } + + // Store options + if err := idx.storeOptions(ctx, rev.ID, opts); err != nil { + // Cleanup on failure + idx.store.DeleteRevision(ctx, rev.ID) + return nil, fmt.Errorf("failed to store options: %w", err) + } + + return &options.IndexResult{ + Revision: rev, + OptionCount: len(opts), + Duration: time.Since(start), + }, nil +} + +// ReindexRevision forces re-indexing of a revision, deleting existing data first. +func (idx *Indexer) ReindexRevision(ctx context.Context, revision string) (*options.IndexResult, error) { + // Validate revision to prevent injection attacks + if err := ValidateRevision(revision); err != nil { + return nil, err + } + + ref := idx.ResolveRevision(revision) + + // Delete existing revision if present + existing, err := idx.store.GetRevision(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to check existing revision: %w", err) + } + if existing != nil { + if err := idx.store.DeleteRevision(ctx, existing.ID); err != nil { + return nil, fmt.Errorf("failed to delete existing revision: %w", err) + } + } + + // Now index fresh + return idx.IndexRevision(ctx, revision) +} + +// buildOptions builds options.json for a home-manager revision. +func (idx *Indexer) buildOptions(ctx context.Context, ref string) (string, func(), error) { + // Create temp directory + tmpDir, err := os.MkdirTemp("", "hm-options-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp dir: %w", err) + } + + cleanup := func() { + os.RemoveAll(tmpDir) + } + + // Build options.json using nix-build + // This evaluates the Home Manager options from the specified revision + nixExpr := fmt.Sprintf(` + let + hm = builtins.fetchTarball { + url = "https://github.com/nix-community/home-manager/archive/%s.tar.gz"; + }; + nixpkgs = builtins.fetchTarball { + url = "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz"; + }; + pkgs = import nixpkgs { config = {}; }; + lib = import (hm + "/modules/lib/stdlib-extended.nix") pkgs.lib; + docs = import (hm + "/docs") { inherit pkgs lib; release = "24.11"; isReleaseBranch = false; }; + in docs.options.json + `, ref) + + cmd := exec.CommandContext(ctx, "nix-build", "--no-out-link", "-E", nixExpr) + cmd.Dir = tmpDir + + output, err := cmd.Output() + if err != nil { + cleanup() + if exitErr, ok := err.(*exec.ExitError); ok { + return "", nil, fmt.Errorf("nix-build failed: %s", string(exitErr.Stderr)) + } + return "", nil, fmt.Errorf("nix-build failed: %w", err) + } + + // The output is the store path containing share/doc/home-manager/options.json + storePath := strings.TrimSpace(string(output)) + optionsPath := filepath.Join(storePath, "share", "doc", "home-manager", "options.json") + + if _, err := os.Stat(optionsPath); err != nil { + cleanup() + return "", nil, fmt.Errorf("options.json not found at %s", optionsPath) + } + + return optionsPath, cleanup, nil +} + +// storeOptions stores parsed options in the database. +func (idx *Indexer) storeOptions(ctx context.Context, revisionID int64, opts map[string]*nixos.ParsedOption) error { + // Prepare batch of options + dbOpts := make([]*database.Option, 0, len(opts)) + declsByName := make(map[string][]*database.Declaration) + + for name, opt := range opts { + dbOpt := &database.Option{ + RevisionID: revisionID, + Name: name, + ParentPath: database.ParentPath(name), + Type: opt.Type, + DefaultValue: opt.Default, + Example: opt.Example, + Description: opt.Description, + ReadOnly: opt.ReadOnly, + } + dbOpts = append(dbOpts, dbOpt) + + // Prepare declarations for this option + decls := make([]*database.Declaration, 0, len(opt.Declarations)) + for _, path := range opt.Declarations { + decls = append(decls, &database.Declaration{ + FilePath: path, + }) + } + declsByName[name] = decls + } + + // Store options in batches + batchSize := 1000 + for i := 0; i < len(dbOpts); i += batchSize { + end := i + batchSize + if end > len(dbOpts) { + end = len(dbOpts) + } + batch := dbOpts[i:end] + + if err := idx.store.CreateOptionsBatch(ctx, batch); err != nil { + return fmt.Errorf("failed to store options batch: %w", err) + } + } + + // Store declarations + for _, opt := range dbOpts { + decls := declsByName[opt.Name] + for _, decl := range decls { + decl.OptionID = opt.ID + } + if len(decls) > 0 { + if err := idx.store.CreateDeclarationsBatch(ctx, decls); err != nil { + return fmt.Errorf("failed to store declarations for %s: %w", opt.Name, err) + } + } + } + + return nil +} + +// getCommitDate gets the commit date for a git ref. +func (idx *Indexer) getCommitDate(ctx context.Context, ref string) (time.Time, error) { + // Use GitHub API to get commit info + url := fmt.Sprintf("https://api.github.com/repos/nix-community/home-manager/commits/%s", ref) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return time.Time{}, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := idx.httpClient.Do(req) + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return time.Time{}, fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + + var commit struct { + Commit struct { + Committer struct { + Date time.Time `json:"date"` + } `json:"committer"` + } `json:"commit"` + } + + if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil { + return time.Time{}, err + } + + return commit.Commit.Committer.Date, nil +} + +// ResolveRevision resolves a channel name or ref to a git ref. +func (idx *Indexer) ResolveRevision(revision string) string { + // Check if it's a known channel alias + if ref, ok := ChannelAliases[revision]; ok { + return ref + } + return revision +} + +// GetChannelName returns the channel name if the revision matches one. +func (idx *Indexer) GetChannelName(revision string) string { + if _, ok := ChannelAliases[revision]; ok { + return revision + } + // Check if the revision is a channel ref value + for name, ref := range ChannelAliases { + if ref == revision { + return name + } + } + return "" +} + +// IndexFiles indexes files from a home-manager tarball. +// This is a separate operation that can be run after IndexRevision. +func (idx *Indexer) IndexFiles(ctx context.Context, revisionID int64, ref string) (int, error) { + // Download home-manager tarball + url := fmt.Sprintf("https://github.com/nix-community/home-manager/archive/%s.tar.gz", ref) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := idx.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to download tarball: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // Extract and index files + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return 0, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + count := 0 + batch := make([]*database.File, 0, 100) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return count, fmt.Errorf("tar read error: %w", err) + } + + // Skip directories + if header.Typeflag != tar.TypeReg { + continue + } + + // Check file extension + ext := filepath.Ext(header.Name) + if !AllowedExtensions[ext] { + continue + } + + // Skip very large files (> 1MB) + if header.Size > 1024*1024 { + continue + } + + // Remove the top-level directory (home-manager-/) + path := header.Name + if i := strings.Index(path, "/"); i >= 0 { + path = path[i+1:] + } + + // Read content + content, err := io.ReadAll(tr) + if err != nil { + continue + } + + file := &database.File{ + RevisionID: revisionID, + FilePath: path, + Extension: ext, + Content: string(content), + } + batch = append(batch, file) + count++ + + // Store in batches + if len(batch) >= 100 { + if err := idx.store.CreateFilesBatch(ctx, batch); err != nil { + return count, fmt.Errorf("failed to store files batch: %w", err) + } + batch = batch[:0] + } + } + + // Store remaining files + if len(batch) > 0 { + if err := idx.store.CreateFilesBatch(ctx, batch); err != nil { + return count, fmt.Errorf("failed to store final files batch: %w", err) + } + } + + return count, nil +} diff --git a/internal/homemanager/indexer_test.go b/internal/homemanager/indexer_test.go new file mode 100644 index 0000000..d1de049 --- /dev/null +++ b/internal/homemanager/indexer_test.go @@ -0,0 +1,256 @@ +package homemanager + +import ( + "context" + "os/exec" + "testing" + "time" + + "git.t-juice.club/torjus/labmcp/internal/database" +) + +// TestHomeManagerRevision is a known release branch for testing. +const TestHomeManagerRevision = "release-24.11" + +// TestValidateRevision tests the revision validation function. +func TestValidateRevision(t *testing.T) { + tests := []struct { + name string + revision string + wantErr bool + }{ + // Valid cases + {"valid git hash", "abc123def456abc123def456abc123def456abc1", false}, + {"valid short hash", "abc123d", false}, + {"valid channel name", "hm-unstable", false}, + {"valid release", "release-24.11", false}, + {"valid master", "master", false}, + {"valid underscore", "some_branch", false}, + {"valid mixed", "release-24.05_beta", false}, + + // Invalid cases - injection attempts + {"injection semicolon", "foo; rm -rf /", true}, + {"injection quotes", `"; builtins.readFile /etc/passwd; "`, true}, + {"injection backticks", "foo`whoami`", true}, + {"injection dollar", "foo$(whoami)", true}, + {"injection newline", "foo\nbar", true}, + {"injection space", "foo bar", true}, + {"injection slash", "foo/bar", true}, + {"injection backslash", "foo\\bar", true}, + {"injection pipe", "foo|bar", true}, + {"injection ampersand", "foo&bar", true}, + {"injection redirect", "foo>bar", true}, + {"injection less than", "foo&2 + exit 1 + fi + export HM_OPTIONS_DATABASE="$(cat "${cfg.database.connectionStringFile}")" + + ${indexCommands} + exec hm-options serve ${httpFlags} + '' else '' + ${indexCommands} + exec hm-options serve ${httpFlags} + ''; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + Restart = "on-failure"; + RestartSec = "5s"; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + + ReadWritePaths = [ cfg.dataDir ]; + WorkingDirectory = cfg.dataDir; + StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/hm-options-mcp") "hm-options-mcp"; + }; + }; + + # Open firewall for HTTP port if configured + networking.firewall = lib.mkIf cfg.openFirewall (let + # Extract port from address (format: "host:port" or ":port") + addressParts = lib.splitString ":" cfg.http.address; + port = lib.toInt (lib.last addressParts); + in { + allowedTCPPorts = [ port ]; + }); + }; +} diff --git a/nix/package.nix b/nix/package.nix index aedab46..449b1e1 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,26 +1,29 @@ -{ lib, buildGoModule, makeWrapper, nix, src }: +{ lib, buildGoModule, makeWrapper, nix, src +, pname ? "nixos-options-mcp" +, subPackage ? "cmd/nixos-options" +, mainProgram ? "nixos-options" +, description ? "MCP server for NixOS options search and query" +}: buildGoModule { - pname = "nixos-options-mcp"; + inherit pname src; version = "0.1.0"; - inherit src; vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY="; - subPackages = [ "cmd/nixos-options" ]; + subPackages = [ subPackage ]; nativeBuildInputs = [ makeWrapper ]; postInstall = '' - wrapProgram $out/bin/nixos-options \ + wrapProgram $out/bin/${mainProgram} \ --prefix PATH : ${lib.makeBinPath [ nix ]} ''; meta = with lib; { - description = "MCP server for NixOS options search and query"; + inherit description mainProgram; homepage = "https://git.t-juice.club/torjus/labmcp"; license = licenses.mit; maintainers = [ ]; - mainProgram = "nixos-options"; }; }