feat: project structure and nix build setup
- Add CLI entry point with urfave/cli/v2 (serve, index, list, search commands) - Add database interface and implementations for PostgreSQL and SQLite - Add schema versioning with automatic recreation on version mismatch - Add MCP protocol types and server scaffold - Add NixOS option types - Configure flake.nix with devShell and buildGoModule package Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
99
cmd/nixos-options/main.go
Normal file
99
cmd/nixos-options/main.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := &cli.App{
|
||||||
|
Name: "nixos-options",
|
||||||
|
Usage: "MCP server for NixOS options search and query",
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "serve",
|
||||||
|
Usage: "Run MCP server (stdio)",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "database",
|
||||||
|
Aliases: []string{"d"},
|
||||||
|
Usage: "Database connection string (postgres://... or sqlite://...)",
|
||||||
|
EnvVars: []string{"NIXOS_OPTIONS_DATABASE"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
fmt.Println("MCP server not yet implemented")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "index",
|
||||||
|
Usage: "Index a nixpkgs revision",
|
||||||
|
ArgsUsage: "<revision>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "database",
|
||||||
|
Aliases: []string{"d"},
|
||||||
|
Usage: "Database connection string",
|
||||||
|
EnvVars: []string{"NIXOS_OPTIONS_DATABASE"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.NArg() < 1 {
|
||||||
|
return fmt.Errorf("revision argument required")
|
||||||
|
}
|
||||||
|
fmt.Printf("Indexing revision: %s\n", c.Args().First())
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Usage: "List indexed revisions",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "database",
|
||||||
|
Aliases: []string{"d"},
|
||||||
|
Usage: "Database connection string",
|
||||||
|
EnvVars: []string{"NIXOS_OPTIONS_DATABASE"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
fmt.Println("List revisions not yet implemented")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "search",
|
||||||
|
Usage: "Search for options",
|
||||||
|
ArgsUsage: "<query>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "database",
|
||||||
|
Aliases: []string{"d"},
|
||||||
|
Usage: "Database connection string",
|
||||||
|
EnvVars: []string{"NIXOS_OPTIONS_DATABASE"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "revision",
|
||||||
|
Aliases: []string{"r"},
|
||||||
|
Usage: "Revision to search (default: nixos-stable)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.NArg() < 1 {
|
||||||
|
return fmt.Errorf("query argument required")
|
||||||
|
}
|
||||||
|
fmt.Printf("Searching for: %s\n", c.Args().First())
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Run(os.Args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
flake.nix
59
flake.nix
@@ -1,15 +1,64 @@
|
|||||||
{
|
{
|
||||||
description = "A very basic flake";
|
description = "LabMCP - Collection of MCP servers";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs }: {
|
outputs = { self, nixpkgs }:
|
||||||
|
let
|
||||||
|
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
pkgsFor = system: nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
nixos-options = pkgs.buildGoModule {
|
||||||
|
pname = "nixos-options-mcp";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
|
vendorHash = null; # Will be set after first build
|
||||||
|
|
||||||
packages.x86_64-linux.default = self.packages.x86_64-linux.hello;
|
subPackages = [ "cmd/nixos-options" ];
|
||||||
|
|
||||||
};
|
meta = with pkgs.lib; {
|
||||||
|
description = "MCP server for NixOS options search and query";
|
||||||
|
homepage = "https://git.t-juice.club/torjus/labmcp";
|
||||||
|
license = licenses.mit;
|
||||||
|
maintainers = [ ];
|
||||||
|
mainProgram = "nixos-options";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
default = self.packages.${system}.nixos-options;
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forAllSystems (system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
go_1_24
|
||||||
|
gopls
|
||||||
|
gotools
|
||||||
|
go-tools
|
||||||
|
golangci-lint
|
||||||
|
postgresql
|
||||||
|
sqlite
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "LabMCP development shell"
|
||||||
|
echo "Go version: $(go version)"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
27
go.mod
27
go.mod
@@ -1,3 +1,28 @@
|
|||||||
module git.t-juice.club/torjus/labmcp
|
module git.t-juice.club/torjus/labmcp
|
||||||
|
|
||||||
go 1.25.5
|
go 1.24
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/urfave/cli/v2 v2.27.5
|
||||||
|
modernc.org/sqlite v1.34.4
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
|
modernc.org/token v1.1.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
59
go.sum
Normal file
59
go.sum
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||||
|
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
|
||||||
|
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
88
internal/database/interface.go
Normal file
88
internal/database/interface.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Package database provides database abstraction for storing NixOS options.
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Revision represents an indexed nixpkgs revision.
|
||||||
|
type Revision struct {
|
||||||
|
ID int64
|
||||||
|
GitHash string
|
||||||
|
ChannelName string
|
||||||
|
CommitDate time.Time
|
||||||
|
IndexedAt time.Time
|
||||||
|
OptionCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option represents a NixOS configuration option.
|
||||||
|
type Option struct {
|
||||||
|
ID int64
|
||||||
|
RevisionID int64
|
||||||
|
Name string
|
||||||
|
ParentPath string
|
||||||
|
Type string
|
||||||
|
DefaultValue string // JSON text
|
||||||
|
Example string // JSON text
|
||||||
|
Description string
|
||||||
|
ReadOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declaration represents a file where an option is declared.
|
||||||
|
type Declaration struct {
|
||||||
|
ID int64
|
||||||
|
OptionID int64
|
||||||
|
FilePath string
|
||||||
|
Line int
|
||||||
|
}
|
||||||
|
|
||||||
|
// File represents a cached file from nixpkgs.
|
||||||
|
type File struct {
|
||||||
|
ID int64
|
||||||
|
RevisionID int64
|
||||||
|
FilePath string
|
||||||
|
Extension string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchFilters contains optional filters for option search.
|
||||||
|
type SearchFilters struct {
|
||||||
|
Type string
|
||||||
|
Namespace string
|
||||||
|
HasDefault *bool
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store defines the interface for database operations.
|
||||||
|
type Store interface {
|
||||||
|
// Schema operations
|
||||||
|
Initialize(ctx context.Context) error
|
||||||
|
Close() error
|
||||||
|
|
||||||
|
// Revision operations
|
||||||
|
CreateRevision(ctx context.Context, rev *Revision) error
|
||||||
|
GetRevision(ctx context.Context, gitHash string) (*Revision, error)
|
||||||
|
GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error)
|
||||||
|
ListRevisions(ctx context.Context) ([]*Revision, error)
|
||||||
|
DeleteRevision(ctx context.Context, id int64) error
|
||||||
|
UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error
|
||||||
|
|
||||||
|
// Option operations
|
||||||
|
CreateOption(ctx context.Context, opt *Option) error
|
||||||
|
CreateOptionsBatch(ctx context.Context, opts []*Option) error
|
||||||
|
GetOption(ctx context.Context, revisionID int64, name string) (*Option, error)
|
||||||
|
GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error)
|
||||||
|
SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error)
|
||||||
|
|
||||||
|
// Declaration operations
|
||||||
|
CreateDeclaration(ctx context.Context, decl *Declaration) error
|
||||||
|
CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error
|
||||||
|
GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error)
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
CreateFile(ctx context.Context, file *File) error
|
||||||
|
CreateFilesBatch(ctx context.Context, files []*File) error
|
||||||
|
GetFile(ctx context.Context, revisionID int64, path string) (*File, error)
|
||||||
|
}
|
||||||
473
internal/database/postgres.go
Normal file
473
internal/database/postgres.go
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostgresStore implements Store using PostgreSQL.
|
||||||
|
type PostgresStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostgresStore creates a new PostgreSQL store.
|
||||||
|
func NewPostgresStore(connStr string) (*PostgresStore, error) {
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PostgresStore{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize creates or migrates the database schema.
|
||||||
|
func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||||
|
// Check current schema version
|
||||||
|
var version int
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT version FROM schema_info LIMIT 1").Scan(&version)
|
||||||
|
|
||||||
|
needsRecreate := err != nil || version != SchemaVersion
|
||||||
|
|
||||||
|
if needsRecreate {
|
||||||
|
// Drop all tables in correct order (respecting foreign keys)
|
||||||
|
dropStmts := []string{
|
||||||
|
DropDeclarations,
|
||||||
|
DropOptions,
|
||||||
|
DropFiles,
|
||||||
|
DropRevisions,
|
||||||
|
DropSchemaInfo,
|
||||||
|
}
|
||||||
|
for _, stmt := range dropStmts {
|
||||||
|
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("failed to drop table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
createStmts := []string{
|
||||||
|
SchemaInfoTable,
|
||||||
|
// PostgreSQL uses SERIAL for auto-increment
|
||||||
|
`CREATE TABLE IF NOT EXISTS revisions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
git_hash TEXT NOT NULL UNIQUE,
|
||||||
|
channel_name TEXT,
|
||||||
|
commit_date TIMESTAMP,
|
||||||
|
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
option_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS options (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent_path TEXT NOT NULL,
|
||||||
|
type TEXT,
|
||||||
|
default_value TEXT,
|
||||||
|
example TEXT,
|
||||||
|
description TEXT,
|
||||||
|
read_only BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS declarations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
line INTEGER
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
extension TEXT,
|
||||||
|
content TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
IndexOptionsRevisionName,
|
||||||
|
IndexOptionsRevisionParent,
|
||||||
|
IndexFilesRevisionPath,
|
||||||
|
IndexDeclarationsOption,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range createStmts {
|
||||||
|
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("failed to create schema: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create full-text search index for PostgreSQL
|
||||||
|
_, err = s.db.ExecContext(ctx, `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_options_fts
|
||||||
|
ON options USING GIN(to_tsvector('english', name || ' ' || COALESCE(description, '')))
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create FTS index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set schema version
|
||||||
|
if needsRecreate {
|
||||||
|
_, err = s.db.ExecContext(ctx,
|
||||||
|
"INSERT INTO schema_info (version) VALUES ($1)", SchemaVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set schema version: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (s *PostgresStore) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRevision creates a new revision record.
|
||||||
|
func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error {
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, indexed_at`,
|
||||||
|
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount,
|
||||||
|
).Scan(&rev.ID, &rev.IndexedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevision retrieves a revision by git hash.
|
||||||
|
func (s *PostgresStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) {
|
||||||
|
rev := &Revision{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
|
||||||
|
FROM revisions WHERE git_hash = $1`, gitHash,
|
||||||
|
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get revision: %w", err)
|
||||||
|
}
|
||||||
|
return rev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevisionByChannel retrieves a revision by channel name.
|
||||||
|
func (s *PostgresStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) {
|
||||||
|
rev := &Revision{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
|
||||||
|
FROM revisions WHERE channel_name = $1
|
||||||
|
ORDER BY indexed_at DESC LIMIT 1`, channel,
|
||||||
|
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get revision by channel: %w", err)
|
||||||
|
}
|
||||||
|
return rev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRevisions returns all indexed revisions.
|
||||||
|
func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
|
||||||
|
FROM revisions ORDER BY indexed_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list revisions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var revisions []*Revision
|
||||||
|
for rows.Next() {
|
||||||
|
rev := &Revision{}
|
||||||
|
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan revision: %w", err)
|
||||||
|
}
|
||||||
|
revisions = append(revisions, rev)
|
||||||
|
}
|
||||||
|
return revisions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRevision removes a revision and all associated data.
|
||||||
|
func (s *PostgresStore) DeleteRevision(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete revision: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRevisionOptionCount updates the option count for a revision.
|
||||||
|
func (s *PostgresStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error {
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
"UPDATE revisions SET option_count = $1 WHERE id = $2", count, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update option count: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOption creates a new option record.
|
||||||
|
func (s *PostgresStore) CreateOption(ctx context.Context, opt *Option) error {
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
|
||||||
|
).Scan(&opt.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create option: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOptionsBatch creates multiple options in a batch.
|
||||||
|
func (s *PostgresStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
err := stmt.QueryRowContext(ctx,
|
||||||
|
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
|
||||||
|
).Scan(&opt.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert option %s: %w", opt.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOption retrieves an option by revision and name.
|
||||||
|
func (s *PostgresStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) {
|
||||||
|
opt := &Option{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
|
||||||
|
FROM options WHERE revision_id = $1 AND name = $2`, revisionID, name,
|
||||||
|
).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get option: %w", err)
|
||||||
|
}
|
||||||
|
return opt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChildren retrieves direct children of an option.
|
||||||
|
func (s *PostgresStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
|
||||||
|
FROM options WHERE revision_id = $1 AND parent_path = $2
|
||||||
|
ORDER BY name`, revisionID, parentPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get children: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var options []*Option
|
||||||
|
for rows.Next() {
|
||||||
|
opt := &Option{}
|
||||||
|
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan option: %w", err)
|
||||||
|
}
|
||||||
|
options = append(options, opt)
|
||||||
|
}
|
||||||
|
return options, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOptions searches for options matching a query.
|
||||||
|
func (s *PostgresStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
|
||||||
|
// Use PostgreSQL full-text search
|
||||||
|
baseQuery := `
|
||||||
|
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
|
||||||
|
FROM options
|
||||||
|
WHERE revision_id = $1
|
||||||
|
AND to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)`
|
||||||
|
|
||||||
|
args := []interface{}{revisionID, query}
|
||||||
|
argNum := 3
|
||||||
|
|
||||||
|
if filters.Type != "" {
|
||||||
|
baseQuery += fmt.Sprintf(" AND type = $%d", argNum)
|
||||||
|
args = append(args, filters.Type)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
if filters.Namespace != "" {
|
||||||
|
baseQuery += fmt.Sprintf(" AND name LIKE $%d", argNum)
|
||||||
|
args = append(args, filters.Namespace+"%")
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
if filters.HasDefault != nil {
|
||||||
|
if *filters.HasDefault {
|
||||||
|
baseQuery += " AND default_value IS NOT NULL"
|
||||||
|
} else {
|
||||||
|
baseQuery += " AND default_value IS NULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseQuery += " ORDER BY name"
|
||||||
|
|
||||||
|
if filters.Limit > 0 {
|
||||||
|
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)
|
||||||
|
}
|
||||||
|
if filters.Offset > 0 {
|
||||||
|
baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, baseQuery, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search options: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var options []*Option
|
||||||
|
for rows.Next() {
|
||||||
|
opt := &Option{}
|
||||||
|
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan option: %w", err)
|
||||||
|
}
|
||||||
|
options = append(options, opt)
|
||||||
|
}
|
||||||
|
return options, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeclaration creates a new declaration record.
|
||||||
|
func (s *PostgresStore) CreateDeclaration(ctx context.Context, decl *Declaration) error {
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO declarations (option_id, file_path, line)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id`,
|
||||||
|
decl.OptionID, decl.FilePath, decl.Line,
|
||||||
|
).Scan(&decl.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create declaration: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeclarationsBatch creates multiple declarations in a batch.
|
||||||
|
func (s *PostgresStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO declarations (option_id, file_path, line)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, decl := range decls {
|
||||||
|
err := stmt.QueryRowContext(ctx, decl.OptionID, decl.FilePath, decl.Line).Scan(&decl.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert declaration: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeclarations retrieves declarations for an option.
|
||||||
|
func (s *PostgresStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, option_id, file_path, line
|
||||||
|
FROM declarations WHERE option_id = $1`, optionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get declarations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var decls []*Declaration
|
||||||
|
for rows.Next() {
|
||||||
|
decl := &Declaration{}
|
||||||
|
if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan declaration: %w", err)
|
||||||
|
}
|
||||||
|
decls = append(decls, decl)
|
||||||
|
}
|
||||||
|
return decls, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFile creates a new file record.
|
||||||
|
func (s *PostgresStore) CreateFile(ctx context.Context, file *File) error {
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO files (revision_id, file_path, extension, content)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id`,
|
||||||
|
file.RevisionID, file.FilePath, file.Extension, file.Content,
|
||||||
|
).Scan(&file.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFilesBatch creates multiple files in a batch.
|
||||||
|
func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO files (revision_id, file_path, extension, content)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content).Scan(&file.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile retrieves a file by revision and path.
|
||||||
|
func (s *PostgresStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
|
||||||
|
file := &File{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, revision_id, file_path, extension, content
|
||||||
|
FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path,
|
||||||
|
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
102
internal/database/schema.go
Normal file
102
internal/database/schema.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
// SchemaVersion is the current database schema version.
|
||||||
|
// When this changes, the database will be dropped and recreated.
|
||||||
|
const SchemaVersion = 1
|
||||||
|
|
||||||
|
// Common SQL statements shared between implementations.
|
||||||
|
const (
|
||||||
|
// SchemaInfoTable creates the schema version tracking table.
|
||||||
|
SchemaInfoTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_info (
|
||||||
|
version INTEGER NOT NULL
|
||||||
|
)`
|
||||||
|
|
||||||
|
// RevisionsTable creates the revisions table.
|
||||||
|
RevisionsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS revisions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
git_hash TEXT NOT NULL UNIQUE,
|
||||||
|
channel_name TEXT,
|
||||||
|
commit_date TIMESTAMP,
|
||||||
|
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
option_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`
|
||||||
|
|
||||||
|
// OptionsTable creates the options table.
|
||||||
|
OptionsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS options (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent_path TEXT NOT NULL,
|
||||||
|
type TEXT,
|
||||||
|
default_value TEXT,
|
||||||
|
example TEXT,
|
||||||
|
description TEXT,
|
||||||
|
read_only BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
)`
|
||||||
|
|
||||||
|
// DeclarationsTable creates the declarations table.
|
||||||
|
DeclarationsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS declarations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
line INTEGER
|
||||||
|
)`
|
||||||
|
|
||||||
|
// FilesTable creates the files table.
|
||||||
|
FilesTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
extension TEXT,
|
||||||
|
content TEXT NOT NULL
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Index creation statements.
|
||||||
|
const (
|
||||||
|
// IndexOptionsRevisionName creates an index on options(revision_id, name).
|
||||||
|
IndexOptionsRevisionName = `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_options_revision_name
|
||||||
|
ON options(revision_id, name)`
|
||||||
|
|
||||||
|
// IndexOptionsRevisionParent creates an index on options(revision_id, parent_path).
|
||||||
|
IndexOptionsRevisionParent = `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_options_revision_parent
|
||||||
|
ON options(revision_id, parent_path)`
|
||||||
|
|
||||||
|
// IndexFilesRevisionPath creates an index on files(revision_id, file_path).
|
||||||
|
IndexFilesRevisionPath = `
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_files_revision_path
|
||||||
|
ON files(revision_id, file_path)`
|
||||||
|
|
||||||
|
// IndexDeclarationsOption creates an index on declarations(option_id).
|
||||||
|
IndexDeclarationsOption = `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_declarations_option
|
||||||
|
ON declarations(option_id)`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drop statements for schema recreation.
|
||||||
|
const (
|
||||||
|
DropSchemaInfo = `DROP TABLE IF EXISTS schema_info`
|
||||||
|
DropDeclarations = `DROP TABLE IF EXISTS declarations`
|
||||||
|
DropOptions = `DROP TABLE IF EXISTS options`
|
||||||
|
DropFiles = `DROP TABLE IF EXISTS files`
|
||||||
|
DropRevisions = `DROP TABLE IF EXISTS revisions`
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParentPath extracts the parent path from an option name.
|
||||||
|
// For example, "services.nginx.enable" returns "services.nginx".
|
||||||
|
// Top-level options return an empty string.
|
||||||
|
func ParentPath(name string) string {
|
||||||
|
for i := len(name) - 1; i >= 0; i-- {
|
||||||
|
if name[i] == '.' {
|
||||||
|
return name[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
507
internal/database/sqlite.go
Normal file
507
internal/database/sqlite.go
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLiteStore implements Store using SQLite.
|
||||||
|
type SQLiteStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSQLiteStore creates a new SQLite store.
|
||||||
|
func NewSQLiteStore(path string) (*SQLiteStore, error) {
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SQLiteStore{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize creates or migrates the database schema.
|
||||||
|
func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||||
|
// Check current schema version
|
||||||
|
var version int
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT version FROM schema_info LIMIT 1").Scan(&version)
|
||||||
|
|
||||||
|
needsRecreate := err != nil || version != SchemaVersion
|
||||||
|
|
||||||
|
if needsRecreate {
|
||||||
|
// Drop all tables in correct order (respecting foreign keys)
|
||||||
|
dropStmts := []string{
|
||||||
|
DropDeclarations,
|
||||||
|
DropOptions,
|
||||||
|
DropFiles,
|
||||||
|
DropRevisions,
|
||||||
|
DropSchemaInfo,
|
||||||
|
"DROP TABLE IF EXISTS options_fts",
|
||||||
|
}
|
||||||
|
for _, stmt := range dropStmts {
|
||||||
|
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("failed to drop table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tables (SQLite uses INTEGER PRIMARY KEY for auto-increment)
|
||||||
|
createStmts := []string{
|
||||||
|
SchemaInfoTable,
|
||||||
|
RevisionsTable,
|
||||||
|
OptionsTable,
|
||||||
|
DeclarationsTable,
|
||||||
|
FilesTable,
|
||||||
|
IndexOptionsRevisionName,
|
||||||
|
IndexOptionsRevisionParent,
|
||||||
|
IndexFilesRevisionPath,
|
||||||
|
IndexDeclarationsOption,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range createStmts {
|
||||||
|
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("failed to create schema: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create FTS5 virtual table for SQLite full-text search
|
||||||
|
_, err = s.db.ExecContext(ctx, `
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS options_fts USING fts5(
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
content='options',
|
||||||
|
content_rowid='id'
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create FTS table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create triggers to keep FTS in sync
|
||||||
|
triggers := []string{
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS options_ai AFTER INSERT ON options BEGIN
|
||||||
|
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
||||||
|
END`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS options_ad AFTER DELETE ON options BEGIN
|
||||||
|
INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description);
|
||||||
|
END`,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS options_au AFTER UPDATE ON options BEGIN
|
||||||
|
INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description);
|
||||||
|
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
||||||
|
END`,
|
||||||
|
}
|
||||||
|
for _, trigger := range triggers {
|
||||||
|
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
|
||||||
|
return fmt.Errorf("failed to create trigger: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set schema version
|
||||||
|
if needsRecreate {
|
||||||
|
_, err = s.db.ExecContext(ctx,
|
||||||
|
"INSERT INTO schema_info (version) VALUES (?)", SchemaVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set schema version: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (s *SQLiteStore) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRevision creates a new revision record.
|
||||||
|
func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
rev.ID = id
|
||||||
|
|
||||||
|
// Fetch the indexed_at timestamp
|
||||||
|
err = s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT indexed_at FROM revisions WHERE id = ?", id).Scan(&rev.IndexedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get indexed_at: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevision retrieves a revision by git hash.
|
||||||
|
func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) {
|
||||||
|
rev := &Revision{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
|
||||||
|
FROM revisions WHERE git_hash = ?`, gitHash,
|
||||||
|
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get revision: %w", err)
|
||||||
|
}
|
||||||
|
return rev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevisionByChannel retrieves a revision by channel name.
|
||||||
|
func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) {
|
||||||
|
rev := &Revision{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
|
||||||
|
FROM revisions WHERE channel_name = ?
|
||||||
|
ORDER BY indexed_at DESC LIMIT 1`, channel,
|
||||||
|
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get revision by channel: %w", err)
|
||||||
|
}
|
||||||
|
return rev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRevisions returns all indexed revisions.
|
||||||
|
func (s *SQLiteStore) ListRevisions(ctx context.Context) ([]*Revision, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
|
||||||
|
FROM revisions ORDER BY indexed_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list revisions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var revisions []*Revision
|
||||||
|
for rows.Next() {
|
||||||
|
rev := &Revision{}
|
||||||
|
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan revision: %w", err)
|
||||||
|
}
|
||||||
|
revisions = append(revisions, rev)
|
||||||
|
}
|
||||||
|
return revisions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRevision removes a revision and all associated data.
|
||||||
|
func (s *SQLiteStore) DeleteRevision(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete revision: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRevisionOptionCount updates the option count for a revision.
|
||||||
|
func (s *SQLiteStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error {
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
"UPDATE revisions SET option_count = ? WHERE id = ?", count, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update option count: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOption creates a new option record.
|
||||||
|
func (s *SQLiteStore) CreateOption(ctx context.Context, opt *Option) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create option: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
opt.ID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOptionsBatch creates multiple options in a batch.
|
||||||
|
func (s *SQLiteStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
result, err := stmt.ExecContext(ctx,
|
||||||
|
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert option %s: %w", opt.Name, err)
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
opt.ID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOption retrieves an option by revision and name.
|
||||||
|
func (s *SQLiteStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) {
|
||||||
|
opt := &Option{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
|
||||||
|
FROM options WHERE revision_id = ? AND name = ?`, revisionID, name,
|
||||||
|
).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get option: %w", err)
|
||||||
|
}
|
||||||
|
return opt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChildren retrieves direct children of an option.
|
||||||
|
func (s *SQLiteStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
|
||||||
|
FROM options WHERE revision_id = ? AND parent_path = ?
|
||||||
|
ORDER BY name`, revisionID, parentPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get children: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var options []*Option
|
||||||
|
for rows.Next() {
|
||||||
|
opt := &Option{}
|
||||||
|
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan option: %w", err)
|
||||||
|
}
|
||||||
|
options = append(options, opt)
|
||||||
|
}
|
||||||
|
return options, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOptions searches for options matching a query.
|
||||||
|
func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
|
||||||
|
// Use SQLite FTS5 for full-text search
|
||||||
|
baseQuery := `
|
||||||
|
SELECT o.id, o.revision_id, o.name, o.parent_path, o.type, o.default_value, o.example, o.description, o.read_only
|
||||||
|
FROM options o
|
||||||
|
INNER JOIN options_fts fts ON o.id = fts.rowid
|
||||||
|
WHERE o.revision_id = ?
|
||||||
|
AND options_fts MATCH ?`
|
||||||
|
|
||||||
|
args := []interface{}{revisionID, query}
|
||||||
|
|
||||||
|
if filters.Type != "" {
|
||||||
|
baseQuery += " AND o.type = ?"
|
||||||
|
args = append(args, filters.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filters.Namespace != "" {
|
||||||
|
baseQuery += " AND o.name LIKE ?"
|
||||||
|
args = append(args, filters.Namespace+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if filters.HasDefault != nil {
|
||||||
|
if *filters.HasDefault {
|
||||||
|
baseQuery += " AND o.default_value IS NOT NULL"
|
||||||
|
} else {
|
||||||
|
baseQuery += " AND o.default_value IS NULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseQuery += " ORDER BY o.name"
|
||||||
|
|
||||||
|
if filters.Limit > 0 {
|
||||||
|
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)
|
||||||
|
}
|
||||||
|
if filters.Offset > 0 {
|
||||||
|
baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, baseQuery, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search options: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var options []*Option
|
||||||
|
for rows.Next() {
|
||||||
|
opt := &Option{}
|
||||||
|
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan option: %w", err)
|
||||||
|
}
|
||||||
|
options = append(options, opt)
|
||||||
|
}
|
||||||
|
return options, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeclaration creates a new declaration record.
|
||||||
|
func (s *SQLiteStore) CreateDeclaration(ctx context.Context, decl *Declaration) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO declarations (option_id, file_path, line)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
decl.OptionID, decl.FilePath, decl.Line,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create declaration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
decl.ID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeclarationsBatch creates multiple declarations in a batch.
|
||||||
|
func (s *SQLiteStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO declarations (option_id, file_path, line)
|
||||||
|
VALUES (?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, decl := range decls {
|
||||||
|
result, err := stmt.ExecContext(ctx, decl.OptionID, decl.FilePath, decl.Line)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert declaration: %w", err)
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
decl.ID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeclarations retrieves declarations for an option.
|
||||||
|
func (s *SQLiteStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, option_id, file_path, line
|
||||||
|
FROM declarations WHERE option_id = ?`, optionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get declarations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var decls []*Declaration
|
||||||
|
for rows.Next() {
|
||||||
|
decl := &Declaration{}
|
||||||
|
if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan declaration: %w", err)
|
||||||
|
}
|
||||||
|
decls = append(decls, decl)
|
||||||
|
}
|
||||||
|
return decls, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFile creates a new file record.
|
||||||
|
func (s *SQLiteStore) CreateFile(ctx context.Context, file *File) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO files (revision_id, file_path, extension, content)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
file.RevisionID, file.FilePath, file.Extension, file.Content,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
file.ID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFilesBatch creates multiple files in a batch.
|
||||||
|
func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO files (revision_id, file_path, extension, content)
|
||||||
|
VALUES (?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert file: %w", err)
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
file.ID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile retrieves a file by revision and path.
|
||||||
|
func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
|
||||||
|
file := &File{}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, revision_id, file_path, extension, content
|
||||||
|
FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path,
|
||||||
|
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
329
internal/mcp/server.go
Normal file
329
internal/mcp/server.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is an MCP server that handles JSON-RPC requests over stdio.
|
||||||
|
type Server struct {
|
||||||
|
store database.Store
|
||||||
|
tools map[string]ToolHandler
|
||||||
|
initialized bool
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolHandler is a function that handles a tool call.
|
||||||
|
type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error)
|
||||||
|
|
||||||
|
// NewServer creates a new MCP server.
|
||||||
|
func NewServer(store database.Store, logger *log.Logger) *Server {
|
||||||
|
if logger == nil {
|
||||||
|
logger = log.New(io.Discard, "", 0)
|
||||||
|
}
|
||||||
|
s := &Server{
|
||||||
|
store: store,
|
||||||
|
tools: make(map[string]ToolHandler),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
s.registerTools()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerTools registers all available tools.
|
||||||
|
func (s *Server) registerTools() {
|
||||||
|
// Tools will be implemented in handlers.go
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the server, reading from r and writing to w.
|
||||||
|
func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var req Request
|
||||||
|
if err := json.Unmarshal(line, &req); err != nil {
|
||||||
|
s.logger.Printf("Failed to parse request: %v", err)
|
||||||
|
resp := Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Error: &Error{
|
||||||
|
Code: ParseError,
|
||||||
|
Message: "Parse error",
|
||||||
|
Data: err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := encoder.Encode(resp); err != nil {
|
||||||
|
return fmt.Errorf("failed to write response: %w", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := s.handleRequest(ctx, &req)
|
||||||
|
if resp != nil {
|
||||||
|
if err := encoder.Encode(resp); err != nil {
|
||||||
|
return fmt.Errorf("failed to write response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("scanner error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequest processes a single request and returns a response.
|
||||||
|
func (s *Server) handleRequest(ctx context.Context, req *Request) *Response {
|
||||||
|
s.logger.Printf("Received request: method=%s id=%v", req.Method, req.ID)
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case MethodInitialize:
|
||||||
|
return s.handleInitialize(req)
|
||||||
|
case MethodInitialized:
|
||||||
|
// This is a notification, no response needed
|
||||||
|
s.initialized = true
|
||||||
|
return nil
|
||||||
|
case MethodToolsList:
|
||||||
|
return s.handleToolsList(req)
|
||||||
|
case MethodToolsCall:
|
||||||
|
return s.handleToolsCall(ctx, req)
|
||||||
|
default:
|
||||||
|
return &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Error: &Error{
|
||||||
|
Code: MethodNotFound,
|
||||||
|
Message: "Method not found",
|
||||||
|
Data: req.Method,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInitialize processes the initialize request.
|
||||||
|
func (s *Server) handleInitialize(req *Request) *Response {
|
||||||
|
var params InitializeParams
|
||||||
|
if req.Params != nil {
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Error: &Error{
|
||||||
|
Code: InvalidParams,
|
||||||
|
Message: "Invalid params",
|
||||||
|
Data: err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("Client: %s %s, protocol: %s",
|
||||||
|
params.ClientInfo.Name, params.ClientInfo.Version, params.ProtocolVersion)
|
||||||
|
|
||||||
|
result := InitializeResult{
|
||||||
|
ProtocolVersion: ProtocolVersion,
|
||||||
|
Capabilities: Capabilities{
|
||||||
|
Tools: &ToolsCapability{
|
||||||
|
ListChanged: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ServerInfo: Implementation{
|
||||||
|
Name: "nixos-options",
|
||||||
|
Version: "0.1.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleToolsList returns the list of available tools.
|
||||||
|
func (s *Server) handleToolsList(req *Request) *Response {
|
||||||
|
tools := s.getToolDefinitions()
|
||||||
|
return &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: ListToolsResult{Tools: tools},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getToolDefinitions returns the tool definitions.
|
||||||
|
func (s *Server) getToolDefinitions() []Tool {
|
||||||
|
return []Tool{
|
||||||
|
{
|
||||||
|
Name: "search_options",
|
||||||
|
Description: "Search for NixOS configuration options by name or description",
|
||||||
|
InputSchema: InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]Property{
|
||||||
|
"query": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Search query (matches option names and descriptions)",
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Git hash or channel name (e.g., 'nixos-unstable'). Uses default if not specified.",
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Filter by option type (e.g., 'boolean', 'string', 'list')",
|
||||||
|
},
|
||||||
|
"namespace": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Filter by namespace prefix (e.g., 'services.nginx')",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: "Maximum number of results (default: 50)",
|
||||||
|
Default: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"query"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "get_option",
|
||||||
|
Description: "Get full details for a specific NixOS option including its children",
|
||||||
|
InputSchema: InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]Property{
|
||||||
|
"name": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Full option path (e.g., 'services.nginx.enable')",
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Git hash or channel name. Uses default if not specified.",
|
||||||
|
},
|
||||||
|
"include_children": {
|
||||||
|
Type: "boolean",
|
||||||
|
Description: "Include direct children of this option (default: true)",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"name"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "get_file",
|
||||||
|
Description: "Fetch the contents of a file from nixpkgs",
|
||||||
|
InputSchema: InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]Property{
|
||||||
|
"path": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "File path relative to nixpkgs root (e.g., 'nixos/modules/services/web-servers/nginx/default.nix')",
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Git hash or channel name. Uses default if not specified.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"path"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "index_revision",
|
||||||
|
Description: "Index a nixpkgs revision to make its options searchable",
|
||||||
|
InputSchema: InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]Property{
|
||||||
|
"revision": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Git hash (full or short) or channel name (e.g., 'nixos-unstable', 'nixos-24.05')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"revision"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list_revisions",
|
||||||
|
Description: "List all indexed nixpkgs revisions",
|
||||||
|
InputSchema: InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]Property{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete_revision",
|
||||||
|
Description: "Delete an indexed revision and all its data",
|
||||||
|
InputSchema: InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]Property{
|
||||||
|
"revision": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Git hash or channel name of the revision to delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"revision"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleToolsCall handles a tool invocation.
|
||||||
|
func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
|
||||||
|
var params CallToolParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Error: &Error{
|
||||||
|
Code: InvalidParams,
|
||||||
|
Message: "Invalid params",
|
||||||
|
Data: err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("Tool call: %s with args %v", params.Name, params.Arguments)
|
||||||
|
|
||||||
|
handler, ok := s.tools[params.Name]
|
||||||
|
if !ok {
|
||||||
|
return &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: CallToolResult{
|
||||||
|
Content: []Content{TextContent(fmt.Sprintf("Unknown tool: %s", params.Name))},
|
||||||
|
IsError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := handler(ctx, params.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("Tool error: %v", err)
|
||||||
|
return &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: ErrorContent(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: result,
|
||||||
|
}
|
||||||
|
}
|
||||||
139
internal/mcp/types.go
Normal file
139
internal/mcp/types.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// Package mcp implements the Model Context Protocol (MCP) over JSON-RPC.
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// JSON-RPC 2.0 types
|
||||||
|
|
||||||
|
// Request represents a JSON-RPC request.
|
||||||
|
type Request struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID interface{} `json:"id,omitempty"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response represents a JSON-RPC response.
|
||||||
|
type Response struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID interface{} `json:"id,omitempty"`
|
||||||
|
Result interface{} `json:"result,omitempty"`
|
||||||
|
Error *Error `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error represents a JSON-RPC error.
|
||||||
|
type Error struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard JSON-RPC error codes
|
||||||
|
const (
|
||||||
|
ParseError = -32700
|
||||||
|
InvalidRequest = -32600
|
||||||
|
MethodNotFound = -32601
|
||||||
|
InvalidParams = -32602
|
||||||
|
InternalError = -32603
|
||||||
|
)
|
||||||
|
|
||||||
|
// MCP Protocol types
|
||||||
|
|
||||||
|
// InitializeParams are sent by the client during initialization.
|
||||||
|
type InitializeParams struct {
|
||||||
|
ProtocolVersion string `json:"protocolVersion"`
|
||||||
|
Capabilities Capabilities `json:"capabilities"`
|
||||||
|
ClientInfo Implementation `json:"clientInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitializeResult is returned after successful initialization.
|
||||||
|
type InitializeResult struct {
|
||||||
|
ProtocolVersion string `json:"protocolVersion"`
|
||||||
|
Capabilities Capabilities `json:"capabilities"`
|
||||||
|
ServerInfo Implementation `json:"serverInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities describes client or server capabilities.
|
||||||
|
type Capabilities struct {
|
||||||
|
Tools *ToolsCapability `json:"tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolsCapability describes tool-related capabilities.
|
||||||
|
type ToolsCapability struct {
|
||||||
|
ListChanged bool `json:"listChanged,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implementation describes a client or server implementation.
|
||||||
|
type Implementation struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool describes an MCP tool.
|
||||||
|
type Tool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
InputSchema InputSchema `json:"inputSchema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputSchema describes the JSON Schema for tool inputs.
|
||||||
|
type InputSchema struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Properties map[string]Property `json:"properties,omitempty"`
|
||||||
|
Required []string `json:"required,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property describes a single property in an input schema.
|
||||||
|
type Property struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Enum []string `json:"enum,omitempty"`
|
||||||
|
Default any `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListToolsResult is returned by tools/list.
|
||||||
|
type ListToolsResult struct {
|
||||||
|
Tools []Tool `json:"tools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallToolParams are sent when calling a tool.
|
||||||
|
type CallToolParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallToolResult is returned after calling a tool.
|
||||||
|
type CallToolResult struct {
|
||||||
|
Content []Content `json:"content"`
|
||||||
|
IsError bool `json:"isError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content represents a piece of content in a tool result.
|
||||||
|
type Content struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextContent creates a text content item.
|
||||||
|
func TextContent(text string) Content {
|
||||||
|
return Content{Type: "text", Text: text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorContent creates an error content item.
|
||||||
|
func ErrorContent(err error) CallToolResult {
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(err.Error())},
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP method names
|
||||||
|
const (
|
||||||
|
MethodInitialize = "initialize"
|
||||||
|
MethodInitialized = "notifications/initialized"
|
||||||
|
MethodToolsList = "tools/list"
|
||||||
|
MethodToolsCall = "tools/call"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Protocol version
|
||||||
|
const ProtocolVersion = "2024-11-05"
|
||||||
45
internal/nixos/types.go
Normal file
45
internal/nixos/types.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Package nixos contains types and logic specific to NixOS options.
|
||||||
|
package nixos
|
||||||
|
|
||||||
|
// RawOption represents an option as parsed from options.json.
|
||||||
|
// The structure matches the output of `nix-build '<nixpkgs/nixos/release.nix>' -A options`.
|
||||||
|
type RawOption struct {
|
||||||
|
Declarations []string `json:"declarations"`
|
||||||
|
Default *OptionValue `json:"default,omitempty"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Example *OptionValue `json:"example,omitempty"`
|
||||||
|
ReadOnly bool `json:"readOnly"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Loc []string `json:"loc,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionValue wraps a value that may be a literal or a Nix expression.
|
||||||
|
type OptionValue struct {
|
||||||
|
// Text is the raw JSON representation of the value
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionsFile represents the top-level structure of options.json.
|
||||||
|
// It's a map from option name to option definition.
|
||||||
|
type OptionsFile map[string]RawOption
|
||||||
|
|
||||||
|
// AllowedExtensions is the default set of file extensions to index.
|
||||||
|
var AllowedExtensions = map[string]bool{
|
||||||
|
".nix": true,
|
||||||
|
".json": true,
|
||||||
|
".md": true,
|
||||||
|
".txt": true,
|
||||||
|
".toml": true,
|
||||||
|
".yaml": true,
|
||||||
|
".yml": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelAliases maps friendly channel names to their git branch/ref patterns.
|
||||||
|
var ChannelAliases = map[string]string{
|
||||||
|
"nixos-unstable": "nixos-unstable",
|
||||||
|
"nixos-stable": "nixos-24.11", // Update this as new stable releases come out
|
||||||
|
"nixos-24.11": "nixos-24.11",
|
||||||
|
"nixos-24.05": "nixos-24.05",
|
||||||
|
"nixos-23.11": "nixos-23.11",
|
||||||
|
"nixos-23.05": "nixos-23.05",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user