Merge pull request 'feature/nixpkgs-search' (#5) from feature/nixpkgs-search into master
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
20
.mcp.json
20
.mcp.json
@@ -1,15 +1,29 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nixos-options": {
|
||||
"nixpkgs-options": {
|
||||
"command": "nix",
|
||||
"args": [
|
||||
"run",
|
||||
".",
|
||||
".#nixpkgs-search",
|
||||
"--",
|
||||
"options",
|
||||
"serve"
|
||||
],
|
||||
"env": {
|
||||
"NIXOS_OPTIONS_DATABASE": "sqlite://:memory:"
|
||||
"NIXPKGS_SEARCH_DATABASE": "sqlite://:memory:"
|
||||
}
|
||||
},
|
||||
"nixpkgs-packages": {
|
||||
"command": "nix",
|
||||
"args": [
|
||||
"run",
|
||||
".#nixpkgs-search",
|
||||
"--",
|
||||
"packages",
|
||||
"serve"
|
||||
],
|
||||
"env": {
|
||||
"NIXPKGS_SEARCH_DATABASE": "sqlite://:memory:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
CLAUDE.md
102
CLAUDE.md
@@ -8,15 +8,21 @@ This file provides context for Claude when working on this project.
|
||||
|
||||
## MCP Servers
|
||||
|
||||
### NixOS Options (`nixos-options`)
|
||||
### Nixpkgs Search (`nixpkgs-search`) - **Primary**
|
||||
Combined search for NixOS options and Nix packages from nixpkgs. Provides two separate MCP servers:
|
||||
- **Options server**: Search NixOS configuration options (`nixpkgs-search options serve`)
|
||||
- **Packages server**: Search Nix packages (`nixpkgs-search packages serve`)
|
||||
|
||||
### NixOS Options (`nixos-options`) - Legacy
|
||||
Search and query NixOS configuration options. Uses nixpkgs as source.
|
||||
*Note: Prefer using `nixpkgs-search options` instead.*
|
||||
|
||||
### Home Manager Options (`hm-options`)
|
||||
Search and query Home Manager configuration options. Uses home-manager repository as source.
|
||||
|
||||
Both servers share the same architecture:
|
||||
- Full-text search across option names and descriptions
|
||||
- Query specific options with type, default, example, and declarations
|
||||
All servers share the same architecture:
|
||||
- Full-text search across option/package names and descriptions
|
||||
- Query specific options/packages with full metadata
|
||||
- Index multiple revisions (by git hash or channel name)
|
||||
- Fetch module source files
|
||||
- PostgreSQL and SQLite backends
|
||||
@@ -43,20 +49,22 @@ Both servers share the same architecture:
|
||||
```
|
||||
labmcp/
|
||||
├── cmd/
|
||||
│ ├── nixpkgs-search/
|
||||
│ │ └── main.go # Combined options+packages CLI (primary)
|
||||
│ ├── nixos-options/
|
||||
│ │ └── main.go # NixOS options CLI
|
||||
│ │ └── main.go # NixOS options CLI (legacy)
|
||||
│ └── hm-options/
|
||||
│ └── main.go # Home Manager options CLI
|
||||
├── internal/
|
||||
│ ├── database/
|
||||
│ │ ├── interface.go # Store interface
|
||||
│ │ ├── interface.go # Store interface (options + packages)
|
||||
│ │ ├── schema.go # Schema versioning
|
||||
│ │ ├── postgres.go # PostgreSQL implementation
|
||||
│ │ ├── sqlite.go # SQLite implementation
|
||||
│ │ └── *_test.go # Database tests
|
||||
│ ├── mcp/
|
||||
│ │ ├── server.go # MCP server core + ServerConfig
|
||||
│ │ ├── handlers.go # Tool implementations
|
||||
│ │ ├── server.go # MCP server core + ServerConfig + modes
|
||||
│ │ ├── handlers.go # Tool implementations (options + packages)
|
||||
│ │ ├── types.go # Protocol types
|
||||
│ │ ├── transport.go # Transport interface
|
||||
│ │ ├── transport_stdio.go # STDIO transport
|
||||
@@ -66,14 +74,19 @@ labmcp/
|
||||
│ ├── options/
|
||||
│ │ └── indexer.go # Shared Indexer interface
|
||||
│ ├── nixos/
|
||||
│ │ ├── indexer.go # Nixpkgs indexing
|
||||
│ │ ├── parser.go # options.json parsing (shared)
|
||||
│ │ ├── indexer.go # NixOS options indexing
|
||||
│ │ ├── parser.go # options.json parsing
|
||||
│ │ ├── types.go # Channel aliases, extensions
|
||||
│ │ └── *_test.go # Indexer tests
|
||||
│ └── homemanager/
|
||||
│ ├── indexer.go # Home Manager indexing
|
||||
│ ├── types.go # Channel aliases, extensions
|
||||
│ └── *_test.go # Indexer tests
|
||||
│ ├── homemanager/
|
||||
│ │ ├── indexer.go # Home Manager indexing
|
||||
│ │ ├── types.go # Channel aliases, extensions
|
||||
│ │ └── *_test.go # Indexer tests
|
||||
│ └── packages/
|
||||
│ ├── indexer.go # Nix packages indexing
|
||||
│ ├── parser.go # nix-env JSON parsing
|
||||
│ ├── types.go # Package types, channel aliases
|
||||
│ └── *_test.go # Parser tests
|
||||
├── nix/
|
||||
│ ├── module.nix # NixOS module for nixos-options
|
||||
│ ├── hm-options-module.nix # NixOS module for hm-options
|
||||
@@ -90,7 +103,7 @@ labmcp/
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Both servers provide the same 6 tools:
|
||||
### Options Servers (nixpkgs-search options, nixos-options, hm-options)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
@@ -101,6 +114,16 @@ Both servers provide the same 6 tools:
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
### Packages Server (nixpkgs-search packages)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_packages` | Full-text search across package names and descriptions |
|
||||
| `get_package` | Get full details for a specific package by attr path |
|
||||
| `get_file` | Fetch source file contents from nixpkgs |
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Database
|
||||
@@ -136,7 +159,32 @@ Both servers provide the same 6 tools:
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### nixos-options
|
||||
### nixpkgs-search (Primary)
|
||||
```bash
|
||||
# Options MCP Server
|
||||
nixpkgs-search options serve # Run options MCP server on STDIO
|
||||
nixpkgs-search options search <query> # Search options
|
||||
nixpkgs-search options get <option> # Get option details
|
||||
|
||||
# Packages MCP Server
|
||||
nixpkgs-search packages serve # Run packages MCP server on STDIO
|
||||
nixpkgs-search packages search <query> # Search packages
|
||||
nixpkgs-search packages get <attr> # Get package details
|
||||
|
||||
# Combined Indexing
|
||||
nixpkgs-search index <revision> # Index options AND packages
|
||||
nixpkgs-search index --no-packages <r> # Index options only (faster)
|
||||
nixpkgs-search index --no-options <r> # Index packages only
|
||||
nixpkgs-search index --no-files <r> # Skip file indexing
|
||||
nixpkgs-search index --force <r> # Force re-index
|
||||
|
||||
# Shared Commands
|
||||
nixpkgs-search list # List indexed revisions
|
||||
nixpkgs-search delete <revision> # Delete indexed revision
|
||||
nixpkgs-search --version # Show version
|
||||
```
|
||||
|
||||
### nixos-options (Legacy)
|
||||
```bash
|
||||
nixos-options serve # Run MCP server on STDIO (default)
|
||||
nixos-options serve --transport http # Run MCP server on HTTP
|
||||
@@ -166,7 +214,7 @@ hm-options --version # Show version
|
||||
|
||||
### Channel Aliases
|
||||
|
||||
**nixos-options**: `nixos-unstable`, `nixos-stable`, `nixos-24.11`, `nixos-24.05`, etc.
|
||||
**nixpkgs-search/nixos-options**: `nixos-unstable`, `nixos-stable`, `nixos-24.11`, `nixos-24.05`, etc.
|
||||
|
||||
**hm-options**: `hm-unstable`, `hm-stable`, `master`, `release-24.11`, `release-24.05`, etc.
|
||||
|
||||
@@ -175,7 +223,9 @@ hm-options --version # Show version
|
||||
### Development Workflow
|
||||
- **Always run `go fmt ./...` before committing Go code**
|
||||
- **Run Go commands using `nix develop -c`** (e.g., `nix develop -c go test ./...`)
|
||||
- **Use `nix run` to run binaries** (e.g., `nix run .#nixos-options -- serve`)
|
||||
- **Use `nix run` to run/test binaries** (e.g., `nix run .#nixpkgs-search -- options serve`)
|
||||
- Do NOT use `go build -o /tmp/...` to test binaries - always use `nix run`
|
||||
- Remember: modified files must be tracked by git for `nix run` to see them
|
||||
- File paths in responses should use format `path/to/file.go:123`
|
||||
|
||||
### Linting
|
||||
@@ -204,9 +254,10 @@ Version bumps should be done once per feature branch, not per commit. Rules:
|
||||
- **Major bump** (0.1.0 → 1.0.0): Breaking changes to CLI usage or MCP protocol
|
||||
|
||||
Version is defined in multiple places that must stay in sync:
|
||||
- `cmd/nixpkgs-search/main.go`
|
||||
- `cmd/nixos-options/main.go`
|
||||
- `cmd/hm-options/main.go`
|
||||
- `internal/mcp/server.go` (in `DefaultNixOSConfig` and `DefaultHomeManagerConfig`)
|
||||
- `internal/mcp/server.go` (in `DefaultNixOSConfig`, `DefaultHomeManagerConfig`, `DefaultNixpkgsPackagesConfig`)
|
||||
- `nix/package.nix`
|
||||
|
||||
### User Preferences
|
||||
@@ -230,19 +281,24 @@ nix develop -c go test -bench=. -benchtime=1x -timeout=30m ./internal/homemanage
|
||||
### Building
|
||||
```bash
|
||||
# Build with nix
|
||||
nix build .#nixpkgs-search
|
||||
nix build .#nixos-options
|
||||
nix build .#hm-options
|
||||
|
||||
# Run directly
|
||||
nix run .#nixos-options -- serve
|
||||
nix run .#nixpkgs-search -- options serve
|
||||
nix run .#nixpkgs-search -- packages serve
|
||||
nix run .#nixpkgs-search -- index nixos-unstable
|
||||
nix run .#hm-options -- serve
|
||||
nix run .#nixos-options -- index nixos-unstable
|
||||
nix run .#hm-options -- index hm-unstable
|
||||
```
|
||||
|
||||
### Indexing Performance
|
||||
Indexing operations are slow due to Nix evaluation and file downloads. When running index commands, use appropriate timeouts:
|
||||
- **nixos-options**: ~5-6 minutes for `nixos-unstable` (with files)
|
||||
- **nixpkgs-search (full)**: ~15-20 minutes for `nixos-unstable` (options + packages + files)
|
||||
- **nixpkgs-search (options only)**: ~5-6 minutes with `--no-packages`
|
||||
- **nixpkgs-search (packages only)**: ~10-15 minutes with `--no-options`
|
||||
- **hm-options**: ~1-2 minutes for `master` (with files)
|
||||
|
||||
Use `--no-files` flag for faster indexing (~1-2 minutes) if file content lookup isn't needed.
|
||||
Use `--no-files` flag to skip file indexing for faster results.
|
||||
Use `--no-packages` to index only options (matches legacy behavior).
|
||||
|
||||
225
README.md
225
README.md
@@ -4,18 +4,26 @@ A collection of Model Context Protocol (MCP) servers written in Go.
|
||||
|
||||
## MCP Servers
|
||||
|
||||
### NixOS Options (`nixos-options`)
|
||||
### Nixpkgs Search (`nixpkgs-search`) - Primary
|
||||
|
||||
Search and query NixOS configuration options across multiple nixpkgs revisions. Designed to help Claude (and other MCP clients) answer questions about NixOS configuration.
|
||||
Combined search for NixOS options and Nix packages from nixpkgs. Provides two separate MCP servers:
|
||||
- **Options server**: Search NixOS configuration options (`nixpkgs-search options serve`)
|
||||
- **Packages server**: Search Nix packages (`nixpkgs-search packages serve`)
|
||||
|
||||
Both servers share the same database, allowing you to index once and serve both.
|
||||
|
||||
### Home Manager Options (`hm-options`)
|
||||
|
||||
Search and query Home Manager configuration options across multiple home-manager revisions. Designed to help Claude (and other MCP clients) answer questions about Home Manager configuration.
|
||||
|
||||
### NixOS Options (`nixos-options`) - Legacy
|
||||
|
||||
Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-search` instead, which includes this functionality plus package search.
|
||||
|
||||
### Shared Features
|
||||
|
||||
- Full-text search across option names and descriptions
|
||||
- Query specific options with type, default, example, and declarations
|
||||
- Full-text search across option/package names and descriptions
|
||||
- Query specific options/packages with full metadata
|
||||
- Index multiple revisions (by git hash or channel name)
|
||||
- Fetch module source files
|
||||
- Support for PostgreSQL and SQLite backends
|
||||
@@ -26,18 +34,18 @@ Search and query Home Manager configuration options across multiple home-manager
|
||||
|
||||
```bash
|
||||
# Build the packages
|
||||
nix build git+https://git.t-juice.club/torjus/labmcp#nixos-options
|
||||
nix build git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search
|
||||
nix build git+https://git.t-juice.club/torjus/labmcp#hm-options
|
||||
|
||||
# Or run directly
|
||||
nix run git+https://git.t-juice.club/torjus/labmcp#nixos-options -- --help
|
||||
nix run git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search -- --help
|
||||
nix run git+https://git.t-juice.club/torjus/labmcp#hm-options -- --help
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
go install git.t-juice.club/torjus/labmcp/cmd/nixos-options@latest
|
||||
go install git.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
|
||||
go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest
|
||||
```
|
||||
|
||||
@@ -50,11 +58,18 @@ Configure in your MCP client (e.g., Claude Desktop):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nixos-options": {
|
||||
"command": "nixos-options",
|
||||
"args": ["serve"],
|
||||
"nixpkgs-options": {
|
||||
"command": "nixpkgs-search",
|
||||
"args": ["options", "serve"],
|
||||
"env": {
|
||||
"NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db"
|
||||
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
|
||||
}
|
||||
},
|
||||
"nixpkgs-packages": {
|
||||
"command": "nixpkgs-search",
|
||||
"args": ["packages", "serve"],
|
||||
"env": {
|
||||
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
|
||||
}
|
||||
},
|
||||
"hm-options": {
|
||||
@@ -73,11 +88,18 @@ Alternatively, if you have Nix installed, you can use the flake directly without
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"nixos-options": {
|
||||
"nixpkgs-options": {
|
||||
"command": "nix",
|
||||
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixos-options", "--", "serve"],
|
||||
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "options", "serve"],
|
||||
"env": {
|
||||
"NIXOS_OPTIONS_DATABASE": "sqlite:///path/to/nixos-options.db"
|
||||
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
|
||||
}
|
||||
},
|
||||
"nixpkgs-packages": {
|
||||
"command": "nix",
|
||||
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "packages", "serve"],
|
||||
"env": {
|
||||
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
|
||||
}
|
||||
},
|
||||
"hm-options": {
|
||||
@@ -93,20 +115,21 @@ Alternatively, if you have Nix installed, you can use the flake directly without
|
||||
|
||||
### As MCP Server (HTTP)
|
||||
|
||||
Both servers can run over HTTP with Server-Sent Events (SSE) for web-based MCP clients:
|
||||
All servers can run over HTTP with Server-Sent Events (SSE) for web-based MCP clients:
|
||||
|
||||
```bash
|
||||
# Start HTTP server on default address (127.0.0.1:8080)
|
||||
nixos-options serve --transport http
|
||||
nixpkgs-search options serve --transport http
|
||||
nixpkgs-search packages serve --transport http
|
||||
hm-options serve --transport http
|
||||
|
||||
# Custom address and CORS configuration
|
||||
nixos-options serve --transport http \
|
||||
nixpkgs-search options serve --transport http \
|
||||
--http-address 0.0.0.0:8080 \
|
||||
--allowed-origins https://example.com
|
||||
|
||||
# With TLS
|
||||
nixos-options serve --transport http \
|
||||
nixpkgs-search options serve --transport http \
|
||||
--tls-cert /path/to/cert.pem \
|
||||
--tls-key /path/to/key.pem
|
||||
```
|
||||
@@ -118,53 +141,77 @@ HTTP transport endpoints:
|
||||
|
||||
### CLI Examples
|
||||
|
||||
**Index a revision:**
|
||||
**Index a revision (nixpkgs-search):**
|
||||
|
||||
```bash
|
||||
# NixOS options - index by channel name
|
||||
nixos-options index nixos-unstable
|
||||
|
||||
# Home Manager options - index by channel name
|
||||
hm-options index hm-unstable
|
||||
# Index both options and packages
|
||||
nixpkgs-search index nixos-unstable
|
||||
|
||||
# Index by git hash
|
||||
nixos-options index e6eae2ee2110f3d31110d5c222cd395303343b08
|
||||
nixpkgs-search index e6eae2ee2110f3d31110d5c222cd395303343b08
|
||||
|
||||
# Index options only (faster, skip packages)
|
||||
nixpkgs-search index --no-packages nixos-unstable
|
||||
|
||||
# Index packages only (skip options)
|
||||
nixpkgs-search index --no-options nixos-unstable
|
||||
|
||||
# Index without file contents (faster, disables get_file tool)
|
||||
nixos-options index --no-files nixos-unstable
|
||||
nixpkgs-search index --no-files nixos-unstable
|
||||
```
|
||||
|
||||
**Index a revision (hm-options):**
|
||||
|
||||
```bash
|
||||
# Index by channel name
|
||||
hm-options index hm-unstable
|
||||
|
||||
# Index without file contents
|
||||
hm-options index --no-files release-24.11
|
||||
```
|
||||
|
||||
**List indexed revisions:**
|
||||
|
||||
```bash
|
||||
nixos-options list
|
||||
nixpkgs-search list
|
||||
hm-options list
|
||||
```
|
||||
|
||||
**Search for options:**
|
||||
|
||||
```bash
|
||||
# NixOS options
|
||||
nixos-options search nginx
|
||||
nixos-options search -n 10 postgresql
|
||||
# NixOS options via nixpkgs-search
|
||||
nixpkgs-search options search nginx
|
||||
nixpkgs-search options search -n 10 postgresql
|
||||
|
||||
# Home Manager options
|
||||
hm-options search git
|
||||
hm-options search -n 10 neovim
|
||||
```
|
||||
|
||||
**Get option details:**
|
||||
**Search for packages:**
|
||||
|
||||
```bash
|
||||
nixos-options get services.nginx.enable
|
||||
nixpkgs-search packages search firefox
|
||||
nixpkgs-search packages search -n 10 python
|
||||
|
||||
# Filter by status
|
||||
nixpkgs-search packages search --unfree nvidia
|
||||
nixpkgs-search packages search --broken deprecated-package
|
||||
```
|
||||
|
||||
**Get option/package details:**
|
||||
|
||||
```bash
|
||||
nixpkgs-search options get services.nginx.enable
|
||||
nixpkgs-search packages get firefox
|
||||
hm-options get programs.git.enable
|
||||
```
|
||||
|
||||
**Delete an indexed revision:**
|
||||
|
||||
```bash
|
||||
nixos-options delete nixos-23.11
|
||||
nixpkgs-search delete nixos-23.11
|
||||
hm-options delete release-23.11
|
||||
```
|
||||
|
||||
@@ -174,20 +221,21 @@ hm-options delete release-23.11
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NIXOS_OPTIONS_DATABASE` | Database connection string for nixos-options | `sqlite://nixos-options.db` |
|
||||
| `NIXPKGS_SEARCH_DATABASE` | Database connection string for nixpkgs-search | `sqlite://nixpkgs-search.db` |
|
||||
| `HM_OPTIONS_DATABASE` | Database connection string for hm-options | `sqlite://hm-options.db` |
|
||||
| `NIXOS_OPTIONS_DATABASE` | Database connection string for nixos-options (legacy) | `sqlite://nixos-options.db` |
|
||||
|
||||
### Database Connection Strings
|
||||
|
||||
**SQLite:**
|
||||
```bash
|
||||
export NIXOS_OPTIONS_DATABASE="sqlite:///path/to/database.db"
|
||||
export NIXOS_OPTIONS_DATABASE="sqlite://:memory:" # In-memory
|
||||
export NIXPKGS_SEARCH_DATABASE="sqlite:///path/to/database.db"
|
||||
export NIXPKGS_SEARCH_DATABASE="sqlite://:memory:" # In-memory
|
||||
```
|
||||
|
||||
**PostgreSQL:**
|
||||
```bash
|
||||
export NIXOS_OPTIONS_DATABASE="postgres://user:pass@localhost/nixos_options?sslmode=disable"
|
||||
export NIXPKGS_SEARCH_DATABASE="postgres://user:pass@localhost/nixpkgs_search?sslmode=disable"
|
||||
```
|
||||
|
||||
### Command-Line Flags
|
||||
@@ -195,13 +243,14 @@ export NIXOS_OPTIONS_DATABASE="postgres://user:pass@localhost/nixos_options?sslm
|
||||
The database can also be specified via the `-d` or `--database` flag:
|
||||
|
||||
```bash
|
||||
nixos-options -d "postgres://localhost/nixos" serve
|
||||
nixpkgs-search -d "postgres://localhost/nixpkgs" options serve
|
||||
nixpkgs-search -d "sqlite://my.db" index nixos-unstable
|
||||
hm-options -d "sqlite://my.db" index hm-unstable
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Both servers provide the following tools:
|
||||
### Options Servers (nixpkgs-search options, hm-options)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
@@ -212,11 +261,24 @@ Both servers provide the following tools:
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
### Packages Server (nixpkgs-search packages)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_packages` | Search for packages by name or description |
|
||||
| `get_package` | Get full details for a specific package |
|
||||
| `get_file` | Fetch source file contents from nixpkgs |
|
||||
| `index_revision` | Index a revision |
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
## NixOS Modules
|
||||
|
||||
NixOS modules are provided for running both MCP servers as systemd services.
|
||||
NixOS modules are provided for running the MCP servers as systemd services.
|
||||
|
||||
### nixos-options
|
||||
### nixpkgs-search (Recommended)
|
||||
|
||||
The `nixpkgs-search` module runs two separate MCP servers (options and packages) that share a database:
|
||||
|
||||
```nix
|
||||
{
|
||||
@@ -226,11 +288,12 @@ NixOS modules are provided for running both MCP servers as systemd services.
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
labmcp.nixosModules.nixos-options-mcp
|
||||
labmcp.nixosModules.nixpkgs-search-mcp
|
||||
{
|
||||
services.nixos-options-mcp = {
|
||||
services.nixpkgs-search = {
|
||||
enable = true;
|
||||
indexOnStart = [ "nixos-unstable" ];
|
||||
# Both options and packages servers are enabled by default
|
||||
};
|
||||
}
|
||||
];
|
||||
@@ -239,6 +302,19 @@ NixOS modules are provided for running both MCP servers as systemd services.
|
||||
}
|
||||
```
|
||||
|
||||
**Options-only configuration:**
|
||||
|
||||
```nix
|
||||
{
|
||||
services.nixpkgs-search = {
|
||||
enable = true;
|
||||
indexOnStart = [ "nixos-unstable" ];
|
||||
indexFlags = [ "--no-packages" ]; # Faster indexing
|
||||
packages.enable = false; # Don't run packages server
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### hm-options
|
||||
|
||||
```nix
|
||||
@@ -262,9 +338,60 @@ NixOS modules are provided for running both MCP servers as systemd services.
|
||||
}
|
||||
```
|
||||
|
||||
### nixos-options (Legacy)
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
|
||||
|
||||
outputs = { self, nixpkgs, labmcp }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
labmcp.nixosModules.nixos-options-mcp
|
||||
{
|
||||
services.nixos-options-mcp = {
|
||||
enable = true;
|
||||
indexOnStart = [ "nixos-unstable" ];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Module Options
|
||||
|
||||
Both modules have similar options. Shown here for `nixos-options-mcp` (replace with `hm-options-mcp` for Home Manager):
|
||||
#### nixpkgs-search
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enable` | bool | `false` | Enable the service |
|
||||
| `package` | package | from flake | Package to use |
|
||||
| `database.type` | enum | `"sqlite"` | `"sqlite"` or `"postgres"` |
|
||||
| `database.name` | string | `"nixpkgs-search.db"` | SQLite database filename |
|
||||
| `database.connectionString` | string | `""` | PostgreSQL connection URL (stored in Nix store) |
|
||||
| `database.connectionStringFile` | path | `null` | Path to file with PostgreSQL connection URL (recommended for secrets) |
|
||||
| `indexOnStart` | list of string | `[]` | Revisions to index on service start |
|
||||
| `indexFlags` | list of string | `[]` | Additional flags for indexing (e.g., `["--no-packages"]`) |
|
||||
| `user` | string | `"nixpkgs-search"` | User to run the service as |
|
||||
| `group` | string | `"nixpkgs-search"` | Group to run the service as |
|
||||
| `dataDir` | path | `/var/lib/nixpkgs-search` | Directory for data storage |
|
||||
| `options.enable` | bool | `true` | Enable the options MCP server |
|
||||
| `options.http.address` | string | `"127.0.0.1:8082"` | HTTP listen address for options server |
|
||||
| `options.openFirewall` | bool | `false` | Open firewall for options HTTP port |
|
||||
| `packages.enable` | bool | `true` | Enable the packages MCP server |
|
||||
| `packages.http.address` | string | `"127.0.0.1:8083"` | HTTP listen address for packages server |
|
||||
| `packages.openFirewall` | bool | `false` | Open firewall for packages HTTP port |
|
||||
|
||||
Both `options.http` and `packages.http` also support:
|
||||
- `endpoint` (default: `"/mcp"`)
|
||||
- `allowedOrigins` (default: `[]`)
|
||||
- `sessionTTL` (default: `"30m"`)
|
||||
- `tls.enable`, `tls.certFile`, `tls.keyFile`
|
||||
|
||||
#### hm-options-mcp / nixos-options-mcp (Legacy)
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
@@ -293,18 +420,18 @@ Using `connectionStringFile` (recommended for production with sensitive credenti
|
||||
|
||||
```nix
|
||||
{
|
||||
services.nixos-options-mcp = {
|
||||
services.nixpkgs-search = {
|
||||
enable = true;
|
||||
database = {
|
||||
type = "postgres";
|
||||
# File contains: postgres://user:secret@localhost/nixos_options?sslmode=disable
|
||||
connectionStringFile = "/run/secrets/nixos-options-db";
|
||||
# File contains: postgres://user:secret@localhost/nixpkgs_search?sslmode=disable
|
||||
connectionStringFile = "/run/secrets/nixpkgs-search-db";
|
||||
};
|
||||
indexOnStart = [ "nixos-unstable" ];
|
||||
};
|
||||
|
||||
# Example with agenix or sops-nix for secret management
|
||||
# age.secrets.nixos-options-db.file = ./secrets/nixos-options-db.age;
|
||||
# age.secrets.nixpkgs-search-db.file = ./secrets/nixpkgs-search-db.age;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -321,7 +448,7 @@ go test ./...
|
||||
go test -bench=. ./internal/database/...
|
||||
|
||||
# Build
|
||||
go build ./cmd/nixos-options
|
||||
go build ./cmd/nixpkgs-search
|
||||
go build ./cmd/hm-options
|
||||
```
|
||||
|
||||
|
||||
2
TODO.md
2
TODO.md
@@ -14,7 +14,7 @@
|
||||
|
||||
## New MCP Servers
|
||||
|
||||
- [ ] `nixpkgs-packages` - Index and search nixpkgs packages (similar architecture to nixos-options, but for packages instead of options)
|
||||
- [x] `nixpkgs-packages` - Index and search nixpkgs packages (implemented in `nixpkgs-search packages`)
|
||||
|
||||
## Nice to Have
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
const (
|
||||
defaultDatabase = "sqlite://hm-options.db"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
const (
|
||||
defaultDatabase = "sqlite://nixos-options.db"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
862
cmd/nixpkgs-search/main.go
Normal file
862
cmd/nixpkgs-search/main.go
Normal file
@@ -0,0 +1,862 @@
|
||||
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/mcp"
|
||||
"git.t-juice.club/torjus/labmcp/internal/nixos"
|
||||
"git.t-juice.club/torjus/labmcp/internal/packages"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDatabase = "sqlite://nixpkgs-search.db"
|
||||
version = "0.2.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "nixpkgs-search",
|
||||
Usage: "Search nixpkgs options and packages",
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "database",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Database connection string (postgres://... or sqlite://...)",
|
||||
EnvVars: []string{"NIXPKGS_SEARCH_DATABASE"},
|
||||
Value: defaultDatabase,
|
||||
},
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
optionsCommand(),
|
||||
packagesCommand(),
|
||||
indexCommand(),
|
||||
listCommand(),
|
||||
deleteCommand(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// optionsCommand returns the options subcommand.
|
||||
func optionsCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "options",
|
||||
Usage: "NixOS options commands",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "serve",
|
||||
Usage: "Run MCP server for NixOS options",
|
||||
Flags: serveFlags(),
|
||||
Action: func(c *cli.Context) error {
|
||||
return runOptionsServe(c)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search",
|
||||
Usage: "Search for options",
|
||||
ArgsUsage: "<query>",
|
||||
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 runOptionsSearch(c, c.Args().First())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get",
|
||||
Usage: "Get details for a specific option",
|
||||
ArgsUsage: "<option-name>",
|
||||
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 runOptionsGet(c, c.Args().First())
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// packagesCommand returns the packages subcommand.
|
||||
func packagesCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "packages",
|
||||
Usage: "Nix packages commands",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "serve",
|
||||
Usage: "Run MCP server for Nix packages",
|
||||
Flags: serveFlags(),
|
||||
Action: func(c *cli.Context) error {
|
||||
return runPackagesServe(c)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search",
|
||||
Usage: "Search for packages",
|
||||
ArgsUsage: "<query>",
|
||||
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,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "broken",
|
||||
Usage: "Include broken packages only",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "unfree",
|
||||
Usage: "Include unfree packages only",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return fmt.Errorf("query argument required")
|
||||
}
|
||||
return runPackagesSearch(c, c.Args().First())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get",
|
||||
Usage: "Get details for a specific package",
|
||||
ArgsUsage: "<attr-path>",
|
||||
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("attr path required")
|
||||
}
|
||||
return runPackagesGet(c, c.Args().First())
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexCommand returns the index command (indexes both options and packages).
|
||||
func indexCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "index",
|
||||
Usage: "Index a nixpkgs revision (options and packages)",
|
||||
ArgsUsage: "<revision>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "no-files",
|
||||
Usage: "Skip indexing file contents",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-packages",
|
||||
Usage: "Skip indexing packages (options only)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-options",
|
||||
Usage: "Skip indexing options (packages only)",
|
||||
},
|
||||
&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())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// listCommand returns the list command.
|
||||
func listCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List indexed revisions",
|
||||
Action: func(c *cli.Context) error {
|
||||
return runList(c)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// deleteCommand returns the delete command.
|
||||
func deleteCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "delete",
|
||||
Usage: "Delete an indexed revision",
|
||||
ArgsUsage: "<revision>",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return fmt.Errorf("revision argument required")
|
||||
}
|
||||
return runDelete(c, c.Args().First())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// serveFlags returns common flags for serve commands.
|
||||
func serveFlags() []cli.Flag {
|
||||
return []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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 runOptionsServe(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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
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.DefaultNixOSConfig()
|
||||
server := mcp.NewServer(store, logger, config)
|
||||
|
||||
indexer := nixos.NewIndexer(store)
|
||||
server.RegisterHandlers(indexer)
|
||||
|
||||
transport := c.String("transport")
|
||||
switch transport {
|
||||
case "stdio":
|
||||
logger.Println("Starting NixOS options MCP server on stdio...")
|
||||
return server.Run(ctx, os.Stdin, os.Stdout)
|
||||
|
||||
case "http":
|
||||
httpConfig := 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, httpConfig)
|
||||
return httpTransport.Run(ctx)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport)
|
||||
}
|
||||
}
|
||||
|
||||
func runPackagesServe(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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
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.DefaultNixpkgsPackagesConfig()
|
||||
server := mcp.NewServer(store, logger, config)
|
||||
|
||||
pkgIndexer := packages.NewIndexer(store)
|
||||
server.RegisterPackageHandlers(pkgIndexer)
|
||||
|
||||
transport := c.String("transport")
|
||||
switch transport {
|
||||
case "stdio":
|
||||
logger.Println("Starting nixpkgs packages MCP server on stdio...")
|
||||
return server.Run(ctx, os.Stdin, os.Stdout)
|
||||
|
||||
case "http":
|
||||
httpConfig := 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, httpConfig)
|
||||
return httpTransport.Run(ctx)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport)
|
||||
}
|
||||
}
|
||||
|
||||
func runIndex(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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
if err := store.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
indexFiles := !c.Bool("no-files")
|
||||
indexOptions := !c.Bool("no-options")
|
||||
indexPackages := !c.Bool("no-packages")
|
||||
force := c.Bool("force")
|
||||
|
||||
optionsIndexer := nixos.NewIndexer(store)
|
||||
pkgIndexer := packages.NewIndexer(store)
|
||||
|
||||
// Resolve revision
|
||||
ref := optionsIndexer.ResolveRevision(revision)
|
||||
|
||||
fmt.Printf("Indexing revision: %s\n", revision)
|
||||
|
||||
var optionCount, packageCount, fileCount int
|
||||
var rev *database.Revision
|
||||
|
||||
// Index options first (creates the revision record)
|
||||
if indexOptions {
|
||||
var result *nixos.IndexResult
|
||||
if force {
|
||||
result, err = optionsIndexer.ReindexRevision(ctx, revision)
|
||||
} else {
|
||||
result, err = optionsIndexer.IndexRevision(ctx, revision)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("options indexing failed: %w", err)
|
||||
}
|
||||
|
||||
if result.AlreadyIndexed && !force {
|
||||
fmt.Printf("Revision already indexed (%d options). Use --force to re-index.\n", result.OptionCount)
|
||||
rev = result.Revision
|
||||
} else {
|
||||
optionCount = result.OptionCount
|
||||
rev = result.Revision
|
||||
fmt.Printf("Indexed %d options\n", optionCount)
|
||||
}
|
||||
} else {
|
||||
// If not indexing options, check if revision exists
|
||||
rev, err = store.GetRevision(ctx, ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get revision: %w", err)
|
||||
}
|
||||
if rev == nil {
|
||||
// Create revision record without options
|
||||
commitDate, _ := pkgIndexer.GetCommitDate(ctx, ref)
|
||||
rev = &database.Revision{
|
||||
GitHash: ref,
|
||||
ChannelName: pkgIndexer.GetChannelName(revision),
|
||||
CommitDate: commitDate,
|
||||
}
|
||||
if err := store.CreateRevision(ctx, rev); err != nil {
|
||||
return fmt.Errorf("failed to create revision: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Index files
|
||||
if indexFiles && rev != nil {
|
||||
fmt.Println("Indexing files...")
|
||||
fileCount, err = optionsIndexer.IndexFiles(ctx, rev.ID, rev.GitHash)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: file indexing failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Indexed %d files\n", fileCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Index packages
|
||||
if indexPackages && rev != nil {
|
||||
fmt.Println("Indexing packages...")
|
||||
pkgResult, err := pkgIndexer.IndexPackages(ctx, rev.ID, rev.GitHash)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: package indexing failed: %v\n", err)
|
||||
} else {
|
||||
packageCount = pkgResult.PackageCount
|
||||
fmt.Printf("Indexed %d packages\n", packageCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
fmt.Printf("Git hash: %s\n", rev.GitHash)
|
||||
if rev.ChannelName != "" {
|
||||
fmt.Printf("Channel: %s\n", rev.ChannelName)
|
||||
}
|
||||
if optionCount > 0 {
|
||||
fmt.Printf("Options: %d\n", optionCount)
|
||||
}
|
||||
if packageCount > 0 {
|
||||
fmt.Printf("Packages: %d\n", packageCount)
|
||||
}
|
||||
if fileCount > 0 {
|
||||
fmt.Printf("Files: %d\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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
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 'nixpkgs-search index <revision>' to index a nixpkgs 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, Packages: %d, Indexed: %s\n",
|
||||
rev.OptionCount, rev.PackageCount, rev.IndexedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Options search and get functions
|
||||
func runOptionsSearch(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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
if err := store.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
rev, err := resolveRevision(ctx, store, c.String("revision"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 runOptionsGet(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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
if err := store.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
rev, err := resolveRevision(ctx, store, c.String("revision"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Packages search and get functions
|
||||
func runPackagesSearch(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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
if err := store.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
rev, err := resolveRevision(ctx, store, c.String("revision"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rev == nil {
|
||||
return fmt.Errorf("no indexed revision found")
|
||||
}
|
||||
|
||||
filters := database.PackageSearchFilters{
|
||||
Limit: c.Int("limit"),
|
||||
}
|
||||
|
||||
if c.IsSet("broken") {
|
||||
broken := c.Bool("broken")
|
||||
filters.Broken = &broken
|
||||
}
|
||||
if c.IsSet("unfree") {
|
||||
unfree := c.Bool("unfree")
|
||||
filters.Unfree = &unfree
|
||||
}
|
||||
|
||||
pkgs, err := store.SearchPackages(ctx, rev.ID, query, filters)
|
||||
if err != nil {
|
||||
return fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
|
||||
if len(pkgs) == 0 {
|
||||
fmt.Printf("No packages found matching '%s'\n", query)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d packages matching '%s':\n\n", len(pkgs), query)
|
||||
for _, pkg := range pkgs {
|
||||
fmt.Printf(" %s\n", pkg.AttrPath)
|
||||
fmt.Printf(" Name: %s", pkg.Pname)
|
||||
if pkg.Version != "" {
|
||||
fmt.Printf(" %s", pkg.Version)
|
||||
}
|
||||
fmt.Println()
|
||||
if pkg.Description != "" {
|
||||
desc := pkg.Description
|
||||
if len(desc) > 100 {
|
||||
desc = desc[:100] + "..."
|
||||
}
|
||||
fmt.Printf(" %s\n", desc)
|
||||
}
|
||||
if pkg.Broken || pkg.Unfree || pkg.Insecure {
|
||||
var flags []string
|
||||
if pkg.Broken {
|
||||
flags = append(flags, "broken")
|
||||
}
|
||||
if pkg.Unfree {
|
||||
flags = append(flags, "unfree")
|
||||
}
|
||||
if pkg.Insecure {
|
||||
flags = append(flags, "insecure")
|
||||
}
|
||||
fmt.Printf(" Flags: %s\n", strings.Join(flags, ", "))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPackagesGet(c *cli.Context, attrPath 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() //nolint:errcheck // cleanup on exit
|
||||
|
||||
if err := store.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
rev, err := resolveRevision(ctx, store, c.String("revision"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rev == nil {
|
||||
return fmt.Errorf("no indexed revision found")
|
||||
}
|
||||
|
||||
pkg, err := store.GetPackage(ctx, rev.ID, attrPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get package: %w", err)
|
||||
}
|
||||
if pkg == nil {
|
||||
return fmt.Errorf("package '%s' not found", attrPath)
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", pkg.AttrPath)
|
||||
fmt.Printf(" Name: %s\n", pkg.Pname)
|
||||
if pkg.Version != "" {
|
||||
fmt.Printf(" Version: %s\n", pkg.Version)
|
||||
}
|
||||
if pkg.Description != "" {
|
||||
fmt.Printf(" Description: %s\n", pkg.Description)
|
||||
}
|
||||
if pkg.Homepage != "" {
|
||||
fmt.Printf(" Homepage: %s\n", pkg.Homepage)
|
||||
}
|
||||
if pkg.License != "" && pkg.License != "[]" {
|
||||
fmt.Printf(" License: %s\n", pkg.License)
|
||||
}
|
||||
if pkg.Maintainers != "" && pkg.Maintainers != "[]" {
|
||||
fmt.Printf(" Maintainers: %s\n", pkg.Maintainers)
|
||||
}
|
||||
|
||||
// Status flags
|
||||
if pkg.Broken || pkg.Unfree || pkg.Insecure {
|
||||
fmt.Println(" Status:")
|
||||
if pkg.Broken {
|
||||
fmt.Println(" - broken")
|
||||
}
|
||||
if pkg.Unfree {
|
||||
fmt.Println(" - unfree")
|
||||
}
|
||||
if pkg.Insecure {
|
||||
fmt.Println(" - insecure")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRevision finds a revision by hash or channel, or returns the most recent.
|
||||
func resolveRevision(ctx context.Context, store database.Store, revisionArg string) (*database.Revision, error) {
|
||||
if revisionArg != "" {
|
||||
rev, err := store.GetRevision(ctx, revisionArg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get revision: %w", err)
|
||||
}
|
||||
if rev != nil {
|
||||
return rev, nil
|
||||
}
|
||||
rev, err = store.GetRevisionByChannel(ctx, revisionArg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get revision: %w", err)
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
|
||||
// Return most recent
|
||||
revisions, err := store.ListRevisions(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list revisions: %w", err)
|
||||
}
|
||||
if len(revisions) > 0 {
|
||||
return revisions[0], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
15
flake.nix
15
flake.nix
@@ -26,7 +26,14 @@
|
||||
mainProgram = "hm-options";
|
||||
description = "MCP server for Home Manager options search and query";
|
||||
};
|
||||
default = self.packages.${system}.nixos-options;
|
||||
nixpkgs-search = pkgs.callPackage ./nix/package.nix {
|
||||
src = ./.;
|
||||
pname = "nixpkgs-search";
|
||||
subPackage = "cmd/nixpkgs-search";
|
||||
mainProgram = "nixpkgs-search";
|
||||
description = "Search nixpkgs options and packages";
|
||||
};
|
||||
default = self.packages.${system}.nixpkgs-search;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
@@ -54,6 +61,10 @@
|
||||
});
|
||||
|
||||
nixosModules = {
|
||||
nixpkgs-search-mcp = { pkgs, ... }: {
|
||||
imports = [ ./nix/nixpkgs-search-module.nix ];
|
||||
services.nixpkgs-search.package = lib.mkDefault self.packages.${pkgs.system}.nixpkgs-search;
|
||||
};
|
||||
nixos-options-mcp = { pkgs, ... }: {
|
||||
imports = [ ./nix/module.nix ];
|
||||
services.nixos-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.nixos-options;
|
||||
@@ -62,7 +73,7 @@
|
||||
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;
|
||||
default = self.nixosModules.nixpkgs-search-mcp;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ type Revision struct {
|
||||
CommitDate time.Time
|
||||
IndexedAt time.Time
|
||||
OptionCount int
|
||||
PackageCount int
|
||||
}
|
||||
|
||||
// Option represents a NixOS configuration option.
|
||||
@@ -48,6 +49,24 @@ type File struct {
|
||||
LineCount int
|
||||
}
|
||||
|
||||
// Package represents a Nix package from nixpkgs.
|
||||
type Package struct {
|
||||
ID int64
|
||||
RevisionID int64
|
||||
AttrPath string // e.g., "python312Packages.requests"
|
||||
Pname string // Package name
|
||||
Version string
|
||||
Description string
|
||||
LongDescription string
|
||||
Homepage string
|
||||
License string // JSON array
|
||||
Platforms string // JSON array
|
||||
Maintainers string // JSON array
|
||||
Broken bool
|
||||
Unfree bool
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// DeclarationWithMetadata includes declaration info plus file metadata.
|
||||
type DeclarationWithMetadata struct {
|
||||
Declaration
|
||||
@@ -79,6 +98,15 @@ type SearchFilters struct {
|
||||
Offset int
|
||||
}
|
||||
|
||||
// PackageSearchFilters contains optional filters for package search.
|
||||
type PackageSearchFilters struct {
|
||||
Broken *bool
|
||||
Unfree *bool
|
||||
Insecure *bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Store defines the interface for database operations.
|
||||
type Store interface {
|
||||
// Schema operations
|
||||
@@ -111,4 +139,11 @@ type Store interface {
|
||||
CreateFilesBatch(ctx context.Context, files []*File) error
|
||||
GetFile(ctx context.Context, revisionID int64, path string) (*File, error)
|
||||
GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error)
|
||||
|
||||
// Package operations
|
||||
CreatePackage(ctx context.Context, pkg *Package) error
|
||||
CreatePackagesBatch(ctx context.Context, pkgs []*Package) error
|
||||
GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error)
|
||||
SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error)
|
||||
UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||
dropStmts := []string{
|
||||
DropDeclarations,
|
||||
DropOptions,
|
||||
DropPackages,
|
||||
DropFiles,
|
||||
DropRevisions,
|
||||
DropSchemaInfo,
|
||||
@@ -64,7 +65,8 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||
channel_name TEXT,
|
||||
commit_date TIMESTAMP,
|
||||
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
option_count INTEGER NOT NULL DEFAULT 0
|
||||
option_count INTEGER NOT NULL DEFAULT 0,
|
||||
package_count INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS options (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -92,10 +94,28 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||
line_count INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS packages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
attr_path TEXT NOT NULL,
|
||||
pname TEXT NOT NULL,
|
||||
version TEXT,
|
||||
description TEXT,
|
||||
long_description TEXT,
|
||||
homepage TEXT,
|
||||
license TEXT,
|
||||
platforms TEXT,
|
||||
maintainers TEXT,
|
||||
broken BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
unfree BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
insecure BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)`,
|
||||
IndexOptionsRevisionName,
|
||||
IndexOptionsRevisionParent,
|
||||
IndexFilesRevisionPath,
|
||||
IndexDeclarationsOption,
|
||||
IndexPackagesRevisionAttr,
|
||||
IndexPackagesRevisionPname,
|
||||
}
|
||||
|
||||
for _, stmt := range createStmts {
|
||||
@@ -104,13 +124,22 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Create full-text search index for PostgreSQL
|
||||
// Create full-text search index for PostgreSQL options
|
||||
_, 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)
|
||||
return fmt.Errorf("failed to create options FTS index: %w", err)
|
||||
}
|
||||
|
||||
// Create full-text search index for PostgreSQL packages
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_fts
|
||||
ON packages USING GIN(to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')))
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create packages FTS index: %w", err)
|
||||
}
|
||||
|
||||
// Set schema version
|
||||
@@ -133,10 +162,10 @@ func (s *PostgresStore) Close() error {
|
||||
// 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)
|
||||
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count, package_count)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, indexed_at`,
|
||||
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount,
|
||||
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.PackageCount,
|
||||
).Scan(&rev.ID, &rev.IndexedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create revision: %w", err)
|
||||
@@ -148,9 +177,9 @@ func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error
|
||||
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
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
|
||||
FROM revisions WHERE git_hash = $1`, gitHash,
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -164,10 +193,10 @@ func (s *PostgresStore) GetRevision(ctx context.Context, gitHash string) (*Revis
|
||||
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
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_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)
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -180,7 +209,7 @@ func (s *PostgresStore) GetRevisionByChannel(ctx context.Context, channel string
|
||||
// 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
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
|
||||
FROM revisions ORDER BY indexed_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list revisions: %w", err)
|
||||
@@ -190,7 +219,7 @@ func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error)
|
||||
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 {
|
||||
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan revision: %w", err)
|
||||
}
|
||||
revisions = append(revisions, rev)
|
||||
@@ -542,3 +571,146 @@ func (s *PostgresStore) GetFileWithRange(ctx context.Context, revisionID int64,
|
||||
|
||||
return applyLineRange(file, r), nil
|
||||
}
|
||||
|
||||
// CreatePackage creates a new package record.
|
||||
func (s *PostgresStore) CreatePackage(ctx context.Context, pkg *Package) error {
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id`,
|
||||
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
|
||||
).Scan(&pkg.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create package: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePackagesBatch creates multiple packages in a batch.
|
||||
func (s *PostgresStore) CreatePackagesBatch(ctx context.Context, pkgs []*Package) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
}
|
||||
defer stmt.Close() //nolint:errcheck // statement closed with transaction
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
err := stmt.QueryRowContext(ctx,
|
||||
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
|
||||
).Scan(&pkg.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert package %s: %w", pkg.AttrPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetPackage retrieves a package by revision and attr_path.
|
||||
func (s *PostgresStore) GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error) {
|
||||
pkg := &Package{}
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure
|
||||
FROM packages WHERE revision_id = $1 AND attr_path = $2`, revisionID, attrPath,
|
||||
).Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get package: %w", err)
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// SearchPackages searches for packages matching a query.
|
||||
func (s *PostgresStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) {
|
||||
// Query includes exact match priority:
|
||||
// - Priority 0: exact pname match
|
||||
// - Priority 1: exact attr_path match
|
||||
// - Priority 2: pname starts with query
|
||||
// - Priority 3: attr_path starts with query
|
||||
// - Priority 4: FTS match (ordered by ts_rank)
|
||||
baseQuery := `
|
||||
SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure
|
||||
FROM packages
|
||||
WHERE revision_id = $1
|
||||
AND to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)`
|
||||
args := []interface{}{revisionID, query}
|
||||
argNum := 3
|
||||
|
||||
if filters.Broken != nil {
|
||||
baseQuery += fmt.Sprintf(" AND broken = $%d", argNum)
|
||||
args = append(args, *filters.Broken)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if filters.Unfree != nil {
|
||||
baseQuery += fmt.Sprintf(" AND unfree = $%d", argNum)
|
||||
args = append(args, *filters.Unfree)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if filters.Insecure != nil {
|
||||
baseQuery += fmt.Sprintf(" AND insecure = $%d", argNum)
|
||||
args = append(args, *filters.Insecure)
|
||||
argNum++
|
||||
}
|
||||
|
||||
// Order by exact match priority, then ts_rank, then attr_path
|
||||
// CASE returns priority (lower = better), ts_rank returns positive scores (higher = better, so DESC)
|
||||
baseQuery += fmt.Sprintf(` ORDER BY
|
||||
CASE
|
||||
WHEN pname = $%d THEN 0
|
||||
WHEN attr_path = $%d THEN 1
|
||||
WHEN pname LIKE $%d THEN 2
|
||||
WHEN attr_path LIKE $%d THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
ts_rank(to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')), plainto_tsquery('english', $2)) DESC,
|
||||
attr_path`, argNum, argNum+1, argNum+2, argNum+3)
|
||||
// For LIKE comparisons, escape % and _ characters for PostgreSQL
|
||||
likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_") + "%"
|
||||
args = append(args, query, query, likeQuery, likeQuery)
|
||||
|
||||
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 packages: %w", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
|
||||
|
||||
var packages []*Package
|
||||
for rows.Next() {
|
||||
pkg := &Package{}
|
||||
if err := rows.Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan package: %w", err)
|
||||
}
|
||||
packages = append(packages, pkg)
|
||||
}
|
||||
return packages, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateRevisionPackageCount updates the package count for a revision.
|
||||
func (s *PostgresStore) UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
"UPDATE revisions SET package_count = $1 WHERE id = $2", count, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update package count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package database
|
||||
|
||||
// SchemaVersion is the current database schema version.
|
||||
// When this changes, the database will be dropped and recreated.
|
||||
const SchemaVersion = 2
|
||||
const SchemaVersion = 3
|
||||
|
||||
// Common SQL statements shared between implementations.
|
||||
const (
|
||||
@@ -20,7 +20,8 @@ const (
|
||||
channel_name TEXT,
|
||||
commit_date TIMESTAMP,
|
||||
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
option_count INTEGER NOT NULL DEFAULT 0
|
||||
option_count INTEGER NOT NULL DEFAULT 0,
|
||||
package_count INTEGER NOT NULL DEFAULT 0
|
||||
)`
|
||||
|
||||
// OptionsTable creates the options table.
|
||||
@@ -57,6 +58,25 @@ const (
|
||||
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||
line_count INTEGER NOT NULL DEFAULT 0
|
||||
)`
|
||||
|
||||
// PackagesTable creates the packages table.
|
||||
PackagesTable = `
|
||||
CREATE TABLE IF NOT EXISTS packages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
attr_path TEXT NOT NULL,
|
||||
pname TEXT NOT NULL,
|
||||
version TEXT,
|
||||
description TEXT,
|
||||
long_description TEXT,
|
||||
homepage TEXT,
|
||||
license TEXT,
|
||||
platforms TEXT,
|
||||
maintainers TEXT,
|
||||
broken BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
unfree BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
insecure BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)`
|
||||
)
|
||||
|
||||
// Index creation statements.
|
||||
@@ -80,6 +100,16 @@ const (
|
||||
IndexDeclarationsOption = `
|
||||
CREATE INDEX IF NOT EXISTS idx_declarations_option
|
||||
ON declarations(option_id)`
|
||||
|
||||
// IndexPackagesRevisionAttr creates an index on packages(revision_id, attr_path).
|
||||
IndexPackagesRevisionAttr = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_packages_revision_attr
|
||||
ON packages(revision_id, attr_path)`
|
||||
|
||||
// IndexPackagesRevisionPname creates an index on packages(revision_id, pname).
|
||||
IndexPackagesRevisionPname = `
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_revision_pname
|
||||
ON packages(revision_id, pname)`
|
||||
)
|
||||
|
||||
// Drop statements for schema recreation.
|
||||
@@ -87,6 +117,7 @@ const (
|
||||
DropSchemaInfo = `DROP TABLE IF EXISTS schema_info`
|
||||
DropDeclarations = `DROP TABLE IF EXISTS declarations`
|
||||
DropOptions = `DROP TABLE IF EXISTS options`
|
||||
DropPackages = `DROP TABLE IF EXISTS packages`
|
||||
DropFiles = `DROP TABLE IF EXISTS files`
|
||||
DropRevisions = `DROP TABLE IF EXISTS revisions`
|
||||
)
|
||||
|
||||
@@ -44,10 +44,12 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||
dropStmts := []string{
|
||||
DropDeclarations,
|
||||
DropOptions,
|
||||
DropPackages,
|
||||
DropFiles,
|
||||
DropRevisions,
|
||||
DropSchemaInfo,
|
||||
"DROP TABLE IF EXISTS options_fts",
|
||||
"DROP TABLE IF EXISTS packages_fts",
|
||||
}
|
||||
for _, stmt := range dropStmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -63,10 +65,13 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||
OptionsTable,
|
||||
DeclarationsTable,
|
||||
FilesTable,
|
||||
PackagesTable,
|
||||
IndexOptionsRevisionName,
|
||||
IndexOptionsRevisionParent,
|
||||
IndexFilesRevisionPath,
|
||||
IndexDeclarationsOption,
|
||||
IndexPackagesRevisionAttr,
|
||||
IndexPackagesRevisionPname,
|
||||
}
|
||||
|
||||
for _, stmt := range createStmts {
|
||||
@@ -88,8 +93,8 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to create FTS table: %w", err)
|
||||
}
|
||||
|
||||
// Create triggers to keep FTS in sync
|
||||
triggers := []string{
|
||||
// Create triggers to keep options FTS in sync
|
||||
optionsTriggers := []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`,
|
||||
@@ -101,9 +106,42 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
||||
END`,
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
for _, trigger := range optionsTriggers {
|
||||
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
|
||||
return fmt.Errorf("failed to create trigger: %w", err)
|
||||
return fmt.Errorf("failed to create options trigger: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create FTS5 virtual table for packages full-text search
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS packages_fts USING fts5(
|
||||
attr_path,
|
||||
pname,
|
||||
description,
|
||||
content='packages',
|
||||
content_rowid='id'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create packages FTS table: %w", err)
|
||||
}
|
||||
|
||||
// Create triggers to keep packages FTS in sync
|
||||
packagesTriggers := []string{
|
||||
`CREATE TRIGGER IF NOT EXISTS packages_ai AFTER INSERT ON packages BEGIN
|
||||
INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS packages_ad AFTER DELETE ON packages BEGIN
|
||||
INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS packages_au AFTER UPDATE ON packages BEGIN
|
||||
INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description);
|
||||
INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description);
|
||||
END`,
|
||||
}
|
||||
for _, trigger := range packagesTriggers {
|
||||
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
|
||||
return fmt.Errorf("failed to create packages trigger: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +165,9 @@ func (s *SQLiteStore) Close() error {
|
||||
// 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,
|
||||
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count, package_count)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.PackageCount,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create revision: %w", err)
|
||||
@@ -155,9 +193,9 @@ func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
|
||||
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
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
|
||||
FROM revisions WHERE git_hash = ?`, gitHash,
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -171,10 +209,10 @@ func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revisio
|
||||
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
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_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)
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -187,17 +225,17 @@ func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string)
|
||||
// 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
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
|
||||
FROM revisions ORDER BY indexed_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list revisions: %w", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration //nolint:errcheck // rows.Err() checked after iteration
|
||||
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
|
||||
|
||||
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 {
|
||||
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan revision: %w", err)
|
||||
}
|
||||
revisions = append(revisions, rev)
|
||||
@@ -588,6 +626,158 @@ func (s *SQLiteStore) GetFileWithRange(ctx context.Context, revisionID int64, pa
|
||||
return applyLineRange(file, r), nil
|
||||
}
|
||||
|
||||
// CreatePackage creates a new package record.
|
||||
func (s *SQLiteStore) CreatePackage(ctx context.Context, pkg *Package) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create package: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||
}
|
||||
pkg.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePackagesBatch creates multiple packages in a batch.
|
||||
func (s *SQLiteStore) CreatePackagesBatch(ctx context.Context, pkgs []*Package) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
}
|
||||
defer stmt.Close() //nolint:errcheck // statement closed with transaction
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
result, err := stmt.ExecContext(ctx,
|
||||
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert package %s: %w", pkg.AttrPath, err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||
}
|
||||
pkg.ID = id
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetPackage retrieves a package by revision and attr_path.
|
||||
func (s *SQLiteStore) GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error) {
|
||||
pkg := &Package{}
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure
|
||||
FROM packages WHERE revision_id = ? AND attr_path = ?`, revisionID, attrPath,
|
||||
).Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get package: %w", err)
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// SearchPackages searches for packages matching a query.
|
||||
func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) {
|
||||
// Query includes exact match priority:
|
||||
// - Priority 0: exact pname match
|
||||
// - Priority 1: exact attr_path match
|
||||
// - Priority 2: pname starts with query
|
||||
// - Priority 3: attr_path starts with query
|
||||
// - Priority 4: FTS match (ordered by bm25 rank)
|
||||
baseQuery := `
|
||||
SELECT p.id, p.revision_id, p.attr_path, p.pname, p.version, p.description, p.long_description, p.homepage, p.license, p.platforms, p.maintainers, p.broken, p.unfree, p.insecure
|
||||
FROM packages p
|
||||
INNER JOIN packages_fts fts ON p.id = fts.rowid
|
||||
WHERE p.revision_id = ?
|
||||
AND packages_fts MATCH ?`
|
||||
|
||||
// Escape the query for FTS5 by wrapping in double quotes for literal matching.
|
||||
escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
|
||||
// For LIKE comparisons, escape % and _ characters
|
||||
likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_")
|
||||
args := []interface{}{revisionID, escapedQuery}
|
||||
|
||||
if filters.Broken != nil {
|
||||
baseQuery += " AND p.broken = ?"
|
||||
args = append(args, *filters.Broken)
|
||||
}
|
||||
|
||||
if filters.Unfree != nil {
|
||||
baseQuery += " AND p.unfree = ?"
|
||||
args = append(args, *filters.Unfree)
|
||||
}
|
||||
|
||||
if filters.Insecure != nil {
|
||||
baseQuery += " AND p.insecure = ?"
|
||||
args = append(args, *filters.Insecure)
|
||||
}
|
||||
|
||||
// Order by exact match priority, then FTS5 rank, then attr_path
|
||||
// CASE returns priority (lower = better), bm25 returns negative scores (lower = better)
|
||||
baseQuery += ` ORDER BY
|
||||
CASE
|
||||
WHEN p.pname = ? THEN 0
|
||||
WHEN p.attr_path = ? THEN 1
|
||||
WHEN p.pname LIKE ? ESCAPE '\' THEN 2
|
||||
WHEN p.attr_path LIKE ? ESCAPE '\' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
bm25(packages_fts),
|
||||
p.attr_path`
|
||||
args = append(args, query, query, likeQuery+"%", likeQuery+"%")
|
||||
|
||||
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 packages: %w", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
|
||||
|
||||
var packages []*Package
|
||||
for rows.Next() {
|
||||
pkg := &Package{}
|
||||
if err := rows.Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan package: %w", err)
|
||||
}
|
||||
packages = append(packages, pkg)
|
||||
}
|
||||
return packages, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateRevisionPackageCount updates the package count for a revision.
|
||||
func (s *SQLiteStore) UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
"UPDATE revisions SET package_count = ? WHERE id = ?", count, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update package count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countLines counts the number of lines in content.
|
||||
func countLines(content string) int {
|
||||
if content == "" {
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
"git.t-juice.club/torjus/labmcp/internal/options"
|
||||
"git.t-juice.club/torjus/labmcp/internal/packages"
|
||||
)
|
||||
|
||||
// RegisterHandlers registers all tool handlers on the server.
|
||||
// RegisterHandlers registers all tool handlers on the server for options mode.
|
||||
func (s *Server) RegisterHandlers(indexer options.Indexer) {
|
||||
s.tools["search_options"] = s.handleSearchOptions
|
||||
s.tools["get_option"] = s.handleGetOption
|
||||
@@ -22,6 +23,15 @@ func (s *Server) RegisterHandlers(indexer options.Indexer) {
|
||||
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||
}
|
||||
|
||||
// RegisterPackageHandlers registers all tool handlers on the server for packages mode.
|
||||
func (s *Server) RegisterPackageHandlers(pkgIndexer *packages.Indexer) {
|
||||
s.tools["search_packages"] = s.handleSearchPackages
|
||||
s.tools["get_package"] = s.handleGetPackage
|
||||
s.tools["get_file"] = s.handleGetFile
|
||||
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
|
||||
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||
}
|
||||
|
||||
// handleSearchOptions handles the search_options tool.
|
||||
func (s *Server) handleSearchOptions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
query, _ := args["query"].(string)
|
||||
@@ -420,3 +430,196 @@ func formatJSON(s string) string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// handleSearchPackages handles the search_packages tool.
|
||||
func (s *Server) handleSearchPackages(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
query, _ := args["query"].(string)
|
||||
if query == "" {
|
||||
return ErrorContent(fmt.Errorf("query is required")), nil
|
||||
}
|
||||
|
||||
revision, _ := args["revision"].(string)
|
||||
rev, err := s.resolveRevision(ctx, revision)
|
||||
if err != nil {
|
||||
return ErrorContent(err), nil
|
||||
}
|
||||
if rev == nil {
|
||||
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
||||
}
|
||||
|
||||
filters := database.PackageSearchFilters{
|
||||
Limit: 50,
|
||||
}
|
||||
|
||||
if broken, ok := args["broken"].(bool); ok {
|
||||
filters.Broken = &broken
|
||||
}
|
||||
if unfree, ok := args["unfree"].(bool); ok {
|
||||
filters.Unfree = &unfree
|
||||
}
|
||||
if limit, ok := args["limit"].(float64); ok && limit > 0 {
|
||||
filters.Limit = int(limit)
|
||||
}
|
||||
|
||||
pkgs, err := s.store.SearchPackages(ctx, rev.ID, query, filters)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("search failed: %w", err)), nil
|
||||
}
|
||||
|
||||
// Format results
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d packages matching '%s' in revision %s:\n\n", len(pkgs), query, rev.GitHash[:8]))
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
sb.WriteString(fmt.Sprintf("## %s\n", pkg.AttrPath))
|
||||
sb.WriteString(fmt.Sprintf("**Name:** %s", pkg.Pname))
|
||||
if pkg.Version != "" {
|
||||
sb.WriteString(fmt.Sprintf(" %s", pkg.Version))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if pkg.Description != "" {
|
||||
desc := pkg.Description
|
||||
if len(desc) > 200 {
|
||||
desc = desc[:200] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Description:** %s\n", desc))
|
||||
}
|
||||
if pkg.Broken || pkg.Unfree || pkg.Insecure {
|
||||
var flags []string
|
||||
if pkg.Broken {
|
||||
flags = append(flags, "broken")
|
||||
}
|
||||
if pkg.Unfree {
|
||||
flags = append(flags, "unfree")
|
||||
}
|
||||
if pkg.Insecure {
|
||||
flags = append(flags, "insecure")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Flags:** %s\n", strings.Join(flags, ", ")))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(sb.String())},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleGetPackage handles the get_package tool.
|
||||
func (s *Server) handleGetPackage(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
attrPath, _ := args["attr_path"].(string)
|
||||
if attrPath == "" {
|
||||
return ErrorContent(fmt.Errorf("attr_path is required")), nil
|
||||
}
|
||||
|
||||
revision, _ := args["revision"].(string)
|
||||
rev, err := s.resolveRevision(ctx, revision)
|
||||
if err != nil {
|
||||
return ErrorContent(err), nil
|
||||
}
|
||||
if rev == nil {
|
||||
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
||||
}
|
||||
|
||||
pkg, err := s.store.GetPackage(ctx, rev.ID, attrPath)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("failed to get package: %w", err)), nil
|
||||
}
|
||||
if pkg == nil {
|
||||
return ErrorContent(fmt.Errorf("package '%s' not found", attrPath)), nil
|
||||
}
|
||||
|
||||
// Format result
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("# %s\n\n", pkg.AttrPath))
|
||||
sb.WriteString(fmt.Sprintf("**Package name:** %s\n", pkg.Pname))
|
||||
if pkg.Version != "" {
|
||||
sb.WriteString(fmt.Sprintf("**Version:** %s\n", pkg.Version))
|
||||
}
|
||||
|
||||
if pkg.Description != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Description:**\n%s\n", pkg.Description))
|
||||
}
|
||||
|
||||
if pkg.LongDescription != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Long description:**\n%s\n", pkg.LongDescription))
|
||||
}
|
||||
|
||||
if pkg.Homepage != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Homepage:** %s\n", pkg.Homepage))
|
||||
}
|
||||
|
||||
if pkg.License != "" && pkg.License != "[]" {
|
||||
sb.WriteString(fmt.Sprintf("\n**License:** %s\n", formatJSONArray(pkg.License)))
|
||||
}
|
||||
|
||||
if pkg.Maintainers != "" && pkg.Maintainers != "[]" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Maintainers:** %s\n", formatJSONArray(pkg.Maintainers)))
|
||||
}
|
||||
|
||||
if pkg.Platforms != "" && pkg.Platforms != "[]" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Platforms:** %s\n", formatJSONArray(pkg.Platforms)))
|
||||
}
|
||||
|
||||
// Status flags
|
||||
if pkg.Broken || pkg.Unfree || pkg.Insecure {
|
||||
sb.WriteString("\n**Status:**\n")
|
||||
if pkg.Broken {
|
||||
sb.WriteString("- ⚠️ This package is marked as **broken**\n")
|
||||
}
|
||||
if pkg.Unfree {
|
||||
sb.WriteString("- This package has an **unfree** license\n")
|
||||
}
|
||||
if pkg.Insecure {
|
||||
sb.WriteString("- ⚠️ This package is marked as **insecure**\n")
|
||||
}
|
||||
}
|
||||
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(sb.String())},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleListRevisionsWithPackages handles the list_revisions tool for packages mode.
|
||||
func (s *Server) handleListRevisionsWithPackages(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
revisions, err := s.store.ListRevisions(ctx)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("failed to list revisions: %w", err)), nil
|
||||
}
|
||||
|
||||
if len(revisions) == 0 {
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent("No revisions indexed. Use the nixpkgs-search CLI to index packages.")},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Indexed revisions (%d):\n\n", len(revisions)))
|
||||
|
||||
for _, rev := range revisions {
|
||||
sb.WriteString(fmt.Sprintf("- **%s**", rev.GitHash[:12]))
|
||||
if rev.ChannelName != "" {
|
||||
sb.WriteString(fmt.Sprintf(" (%s)", rev.ChannelName))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n Options: %d, Packages: %d, Indexed: %s\n",
|
||||
rev.OptionCount, rev.PackageCount, rev.IndexedAt.Format("2006-01-02 15:04")))
|
||||
}
|
||||
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(sb.String())},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// formatJSONArray formats a JSON array string as a comma-separated list.
|
||||
func formatJSONArray(s string) string {
|
||||
if s == "" || s == "[]" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(s), &arr); err != nil {
|
||||
return s
|
||||
}
|
||||
|
||||
return strings.Join(arr, ", ")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@ import (
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
)
|
||||
|
||||
// ServerMode indicates which type of tools the server should expose.
|
||||
type ServerMode string
|
||||
|
||||
const (
|
||||
// ModeOptions exposes only option-related tools.
|
||||
ModeOptions ServerMode = "options"
|
||||
// ModePackages exposes only package-related tools.
|
||||
ModePackages ServerMode = "packages"
|
||||
)
|
||||
|
||||
// ServerConfig contains configuration for the MCP server.
|
||||
type ServerConfig struct {
|
||||
// Name is the server name reported in initialization.
|
||||
@@ -22,15 +32,18 @@ type ServerConfig struct {
|
||||
DefaultChannel string
|
||||
// SourceName is the name of the source repository (e.g., "nixpkgs", "home-manager").
|
||||
SourceName string
|
||||
// Mode specifies which tools to expose (options or packages).
|
||||
Mode ServerMode
|
||||
}
|
||||
|
||||
// DefaultNixOSConfig returns the default configuration for NixOS options server.
|
||||
func DefaultNixOSConfig() ServerConfig {
|
||||
return ServerConfig{
|
||||
Name: "nixos-options",
|
||||
Version: "0.1.2",
|
||||
Version: "0.2.0",
|
||||
DefaultChannel: "nixos-stable",
|
||||
SourceName: "nixpkgs",
|
||||
Mode: ModeOptions,
|
||||
Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options.
|
||||
|
||||
If the current project contains a flake.lock file, you can index the exact nixpkgs revision used by the project:
|
||||
@@ -43,13 +56,32 @@ This ensures option documentation matches the nixpkgs version the project actual
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultNixpkgsPackagesConfig returns the default configuration for nixpkgs packages server.
|
||||
func DefaultNixpkgsPackagesConfig() ServerConfig {
|
||||
return ServerConfig{
|
||||
Name: "nixpkgs-packages",
|
||||
Version: "0.2.0",
|
||||
DefaultChannel: "nixos-stable",
|
||||
SourceName: "nixpkgs",
|
||||
Mode: ModePackages,
|
||||
Instructions: `Nixpkgs Packages MCP Server - Search and query Nix packages from nixpkgs.
|
||||
|
||||
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
|
||||
1. Read the flake.lock file to find the nixpkgs "rev" field
|
||||
2. Ensure the revision is indexed (packages are indexed separately from options)
|
||||
|
||||
This ensures package information matches the nixpkgs version the project actually uses.`,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultHomeManagerConfig returns the default configuration for Home Manager options server.
|
||||
func DefaultHomeManagerConfig() ServerConfig {
|
||||
return ServerConfig{
|
||||
Name: "hm-options",
|
||||
Version: "0.1.2",
|
||||
Version: "0.2.0",
|
||||
DefaultChannel: "hm-stable",
|
||||
SourceName: "home-manager",
|
||||
Mode: ModeOptions,
|
||||
Instructions: `Home Manager Options MCP Server - Search and query Home Manager configuration options.
|
||||
|
||||
If the current project contains a flake.lock file, you can index the exact home-manager revision used by the project:
|
||||
@@ -205,6 +237,17 @@ func (s *Server) handleToolsList(req *Request) *Response {
|
||||
|
||||
// getToolDefinitions returns the tool definitions.
|
||||
func (s *Server) getToolDefinitions() []Tool {
|
||||
// For packages mode, return package tools
|
||||
if s.config.Mode == ModePackages {
|
||||
return s.getPackageToolDefinitions()
|
||||
}
|
||||
|
||||
// Default: options mode
|
||||
return s.getOptionToolDefinitions()
|
||||
}
|
||||
|
||||
// getOptionToolDefinitions returns the tool definitions for options mode.
|
||||
func (s *Server) getOptionToolDefinitions() []Tool {
|
||||
// Determine naming based on source
|
||||
optionType := "NixOS"
|
||||
sourceRepo := "nixpkgs"
|
||||
@@ -344,6 +387,114 @@ func (s *Server) getToolDefinitions() []Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// getPackageToolDefinitions returns the tool definitions for packages mode.
|
||||
func (s *Server) getPackageToolDefinitions() []Tool {
|
||||
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
|
||||
exampleFilePath := "pkgs/applications/networking/browsers/firefox/default.nix"
|
||||
|
||||
return []Tool{
|
||||
{
|
||||
Name: "search_packages",
|
||||
Description: "Search for Nix packages by name or description",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"query": {
|
||||
Type: "string",
|
||||
Description: "Search query (matches package name, attr path, and description)",
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: fmt.Sprintf("Git hash or channel name (e.g., %s). Uses default if not specified.", exampleChannels),
|
||||
},
|
||||
"broken": {
|
||||
Type: "boolean",
|
||||
Description: "Filter by broken status (true = only broken, false = only working)",
|
||||
},
|
||||
"unfree": {
|
||||
Type: "boolean",
|
||||
Description: "Filter by license (true = only unfree, false = only free)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results (default: 50)",
|
||||
Default: 50,
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_package",
|
||||
Description: "Get full details for a specific Nix package by attribute path",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"attr_path": {
|
||||
Type: "string",
|
||||
Description: "Package attribute path (e.g., 'firefox', 'python312Packages.requests')",
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name. Uses default if not specified.",
|
||||
},
|
||||
},
|
||||
Required: []string{"attr_path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_file",
|
||||
Description: "Fetch the contents of a file from nixpkgs",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: fmt.Sprintf("File path relative to nixpkgs root (e.g., '%s')", exampleFilePath),
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name. Uses default if not specified.",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Line offset (0-based). Default: 0",
|
||||
Default: 0,
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum lines to return. Default: 250, use 0 for all lines",
|
||||
Default: 250,
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
257
internal/packages/indexer.go
Normal file
257
internal/packages/indexer.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
)
|
||||
|
||||
// revisionPattern validates revision strings to prevent injection attacks.
|
||||
// Allows: alphanumeric, hyphens, underscores, dots (for channel names like "nixos-24.11"
|
||||
// and git hashes). Must be 1-64 characters.
|
||||
var revisionPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
|
||||
|
||||
// Indexer handles indexing of packages from nixpkgs revisions.
|
||||
type Indexer struct {
|
||||
store database.Store
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewIndexer creates a new packages indexer.
|
||||
func NewIndexer(store database.Store) *Indexer {
|
||||
return &Indexer{
|
||||
store: store,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Minute, // Longer timeout for package evaluation
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// IndexPackages indexes packages for an existing revision.
|
||||
// The revision must already exist in the database (created by options indexer).
|
||||
func (idx *Indexer) IndexPackages(ctx context.Context, revisionID int64, ref string) (*IndexResult, error) {
|
||||
start := time.Now()
|
||||
|
||||
// Validate revision to prevent injection attacks
|
||||
if err := ValidateRevision(ref); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build packages JSON using nix-env
|
||||
packagesPath, cleanup, err := idx.buildPackages(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build packages: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Parse and store packages using streaming to reduce memory usage
|
||||
packagesFile, err := os.Open(packagesPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open packages.json: %w", err)
|
||||
}
|
||||
defer packagesFile.Close() //nolint:errcheck // read-only file
|
||||
|
||||
// Store packages in batches
|
||||
batch := make([]*database.Package, 0, 1000)
|
||||
count := 0
|
||||
|
||||
_, err = ParsePackagesStream(packagesFile, func(pkg *ParsedPackage) error {
|
||||
dbPkg := &database.Package{
|
||||
RevisionID: revisionID,
|
||||
AttrPath: pkg.AttrPath,
|
||||
Pname: pkg.Pname,
|
||||
Version: pkg.Version,
|
||||
Description: pkg.Description,
|
||||
LongDescription: pkg.LongDescription,
|
||||
Homepage: pkg.Homepage,
|
||||
License: pkg.License,
|
||||
Platforms: pkg.Platforms,
|
||||
Maintainers: pkg.Maintainers,
|
||||
Broken: pkg.Broken,
|
||||
Unfree: pkg.Unfree,
|
||||
Insecure: pkg.Insecure,
|
||||
}
|
||||
batch = append(batch, dbPkg)
|
||||
count++
|
||||
|
||||
// Store in batches
|
||||
if len(batch) >= 1000 {
|
||||
if err := idx.store.CreatePackagesBatch(ctx, batch); err != nil {
|
||||
return fmt.Errorf("failed to store packages batch: %w", err)
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse packages: %w", err)
|
||||
}
|
||||
|
||||
// Store remaining packages
|
||||
if len(batch) > 0 {
|
||||
if err := idx.store.CreatePackagesBatch(ctx, batch); err != nil {
|
||||
return nil, fmt.Errorf("failed to store final packages batch: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update revision package count
|
||||
if err := idx.store.UpdateRevisionPackageCount(ctx, revisionID, count); err != nil {
|
||||
return nil, fmt.Errorf("failed to update package count: %w", err)
|
||||
}
|
||||
|
||||
return &IndexResult{
|
||||
RevisionID: revisionID,
|
||||
PackageCount: count,
|
||||
Duration: time.Since(start),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildPackages builds a JSON file containing all packages for a nixpkgs revision.
|
||||
func (idx *Indexer) buildPackages(ctx context.Context, ref string) (string, func(), error) {
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "nixpkgs-packages-*")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.RemoveAll(tmpDir) //nolint:errcheck // best-effort temp dir cleanup
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(tmpDir, "packages.json")
|
||||
|
||||
// First, fetch the nixpkgs tarball to the nix store
|
||||
// This ensures it's available for nix-env evaluation
|
||||
nixExpr := fmt.Sprintf(`
|
||||
builtins.fetchTarball {
|
||||
url = "https://github.com/NixOS/nixpkgs/archive/%s.tar.gz";
|
||||
}
|
||||
`, ref)
|
||||
|
||||
fetchCmd := exec.CommandContext(ctx, "nix-instantiate", "--eval", "-E", nixExpr)
|
||||
fetchCmd.Dir = tmpDir
|
||||
fetchOutput, err := fetchCmd.Output()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", nil, fmt.Errorf("nix-instantiate fetch failed: %s", string(exitErr.Stderr))
|
||||
}
|
||||
return "", nil, fmt.Errorf("nix-instantiate fetch failed: %w", err)
|
||||
}
|
||||
|
||||
// The output is the store path in quotes, e.g., "/nix/store/xxx-source"
|
||||
nixpkgsPath := strings.Trim(strings.TrimSpace(string(fetchOutput)), "\"")
|
||||
|
||||
// Run nix-env to get all packages as JSON
|
||||
// Use --json --meta to get full metadata
|
||||
cmd := exec.CommandContext(ctx, "nix-env",
|
||||
"-f", nixpkgsPath,
|
||||
"-qaP", "--json", "--meta",
|
||||
)
|
||||
cmd.Dir = tmpDir
|
||||
|
||||
// Create output file
|
||||
outputFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
cmd.Stdout = outputFile
|
||||
// Suppress stderr warnings about unfree/broken packages
|
||||
cmd.Stderr = nil
|
||||
|
||||
err = cmd.Run()
|
||||
outputFile.Close() //nolint:errcheck // output file, will check stat below
|
||||
if err != nil {
|
||||
cleanup()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", nil, fmt.Errorf("nix-env failed: %s", string(exitErr.Stderr))
|
||||
}
|
||||
return "", nil, fmt.Errorf("nix-env failed: %w", err)
|
||||
}
|
||||
|
||||
// Verify output file exists and has content
|
||||
stat, err := os.Stat(outputPath)
|
||||
if err != nil || stat.Size() == 0 {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("packages.json not found or empty")
|
||||
}
|
||||
|
||||
return outputPath, cleanup, nil
|
||||
}
|
||||
|
||||
// ResolveRevision resolves a channel name or ref to a git ref.
|
||||
func (idx *Indexer) ResolveRevision(revision string) string {
|
||||
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 ""
|
||||
}
|
||||
|
||||
// GetCommitDate gets the commit date for a git ref using GitHub API.
|
||||
func (idx *Indexer) GetCommitDate(ctx context.Context, ref string) (time.Time, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/NixOS/nixpkgs/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() //nolint:errcheck // response body read-only
|
||||
|
||||
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
|
||||
}
|
||||
82
internal/packages/indexer_test.go
Normal file
82
internal/packages/indexer_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRevision(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
revision string
|
||||
expectErr bool
|
||||
}{
|
||||
{"valid hash", "abc123def456", false},
|
||||
{"valid channel", "nixos-unstable", false},
|
||||
{"valid version channel", "nixos-24.11", false},
|
||||
{"empty", "", true},
|
||||
{"too long", "a" + string(make([]byte, 100)), true},
|
||||
{"shell injection", "$(rm -rf /)", true},
|
||||
{"path traversal", "../../../etc/passwd", true},
|
||||
{"semicolon", "abc;rm -rf /", true},
|
||||
{"backtick", "`whoami`", true},
|
||||
{"space", "abc def", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateRevision(tc.revision)
|
||||
if tc.expectErr && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRevision(t *testing.T) {
|
||||
idx := &Indexer{}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"nixos-unstable", "nixos-unstable"},
|
||||
{"nixos-stable", "nixos-24.11"},
|
||||
{"nixos-24.11", "nixos-24.11"},
|
||||
{"abc123", "abc123"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := idx.ResolveRevision(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %q, got %q", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChannelName(t *testing.T) {
|
||||
idx := &Indexer{}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"nixos-unstable", "nixos-unstable"},
|
||||
{"nixos-stable", "nixos-stable"},
|
||||
{"nixos-24.11", "nixos-24.11"},
|
||||
{"abc123", ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := idx.GetChannelName(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %q, got %q", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
199
internal/packages/parser.go
Normal file
199
internal/packages/parser.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsePackages reads and parses a nix-env JSON output file.
|
||||
func ParsePackages(r io.Reader) (map[string]*ParsedPackage, error) {
|
||||
var raw PackagesFile
|
||||
if err := json.NewDecoder(r).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode packages JSON: %w", err)
|
||||
}
|
||||
|
||||
packages := make(map[string]*ParsedPackage, len(raw))
|
||||
for attrPath, pkg := range raw {
|
||||
parsed := &ParsedPackage{
|
||||
AttrPath: attrPath,
|
||||
Pname: pkg.Pname,
|
||||
Version: pkg.Version,
|
||||
Description: pkg.Meta.Description,
|
||||
LongDescription: pkg.Meta.LongDescription,
|
||||
Homepage: normalizeHomepage(pkg.Meta.Homepage),
|
||||
License: normalizeLicense(pkg.Meta.License),
|
||||
Platforms: normalizePlatforms(pkg.Meta.Platforms),
|
||||
Maintainers: normalizeMaintainers(pkg.Meta.Maintainers),
|
||||
Broken: pkg.Meta.Broken,
|
||||
Unfree: pkg.Meta.Unfree,
|
||||
Insecure: pkg.Meta.Insecure,
|
||||
}
|
||||
packages[attrPath] = parsed
|
||||
}
|
||||
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// normalizeHomepage converts homepage to a string.
|
||||
func normalizeHomepage(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch hp := v.(type) {
|
||||
case string:
|
||||
return hp
|
||||
case []interface{}:
|
||||
if len(hp) > 0 {
|
||||
if s, ok := hp[0].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeLicense converts license to a JSON array string.
|
||||
func normalizeLicense(v interface{}) string {
|
||||
if v == nil {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
licenses := make([]string, 0)
|
||||
|
||||
switch l := v.(type) {
|
||||
case string:
|
||||
licenses = append(licenses, l)
|
||||
case map[string]interface{}:
|
||||
// Single license object
|
||||
if spdxID, ok := l["spdxId"].(string); ok {
|
||||
licenses = append(licenses, spdxID)
|
||||
} else if fullName, ok := l["fullName"].(string); ok {
|
||||
licenses = append(licenses, fullName)
|
||||
} else if shortName, ok := l["shortName"].(string); ok {
|
||||
licenses = append(licenses, shortName)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range l {
|
||||
switch li := item.(type) {
|
||||
case string:
|
||||
licenses = append(licenses, li)
|
||||
case map[string]interface{}:
|
||||
if spdxID, ok := li["spdxId"].(string); ok {
|
||||
licenses = append(licenses, spdxID)
|
||||
} else if fullName, ok := li["fullName"].(string); ok {
|
||||
licenses = append(licenses, fullName)
|
||||
} else if shortName, ok := li["shortName"].(string); ok {
|
||||
licenses = append(licenses, shortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(licenses)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// normalizePlatforms converts platforms to a JSON array string.
|
||||
func normalizePlatforms(v []interface{}) string {
|
||||
if v == nil {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
platforms := make([]string, 0, len(v))
|
||||
for _, p := range v {
|
||||
switch pv := p.(type) {
|
||||
case string:
|
||||
platforms = append(platforms, pv)
|
||||
// Skip complex platform specs (objects)
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(platforms)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// normalizeMaintainers converts maintainers to a JSON array string.
|
||||
func normalizeMaintainers(maintainers []Maintainer) string {
|
||||
if len(maintainers) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(maintainers))
|
||||
for _, m := range maintainers {
|
||||
name := m.Name
|
||||
if name == "" && m.Github != "" {
|
||||
name = "@" + m.Github
|
||||
}
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(names)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ParsePackagesStream parses packages from a reader using streaming to reduce memory usage.
|
||||
// It yields parsed packages through a callback function.
|
||||
func ParsePackagesStream(r io.Reader, callback func(*ParsedPackage) error) (int, error) {
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
// Read the opening brace
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read opening token: %w", err)
|
||||
}
|
||||
if delim, ok := t.(json.Delim); !ok || delim != '{' {
|
||||
return 0, fmt.Errorf("expected opening brace, got %v", t)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for dec.More() {
|
||||
// Read the key (attr path)
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("failed to read attr path: %w", err)
|
||||
}
|
||||
attrPath, ok := t.(string)
|
||||
if !ok {
|
||||
return count, fmt.Errorf("expected string key, got %T", t)
|
||||
}
|
||||
|
||||
// Read the value (package)
|
||||
var pkg RawPackage
|
||||
if err := dec.Decode(&pkg); err != nil {
|
||||
// Skip malformed packages
|
||||
continue
|
||||
}
|
||||
|
||||
parsed := &ParsedPackage{
|
||||
AttrPath: attrPath,
|
||||
Pname: pkg.Pname,
|
||||
Version: pkg.Version,
|
||||
Description: pkg.Meta.Description,
|
||||
LongDescription: pkg.Meta.LongDescription,
|
||||
Homepage: normalizeHomepage(pkg.Meta.Homepage),
|
||||
License: normalizeLicense(pkg.Meta.License),
|
||||
Platforms: normalizePlatforms(pkg.Meta.Platforms),
|
||||
Maintainers: normalizeMaintainers(pkg.Meta.Maintainers),
|
||||
Broken: pkg.Meta.Broken,
|
||||
Unfree: pkg.Meta.Unfree,
|
||||
Insecure: pkg.Meta.Insecure,
|
||||
}
|
||||
|
||||
if err := callback(parsed); err != nil {
|
||||
return count, fmt.Errorf("callback error for %s: %w", attrPath, err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SplitAttrPath splits an attribute path into its components.
|
||||
// For example, "python312Packages.requests" returns ["python312Packages", "requests"].
|
||||
func SplitAttrPath(attrPath string) []string {
|
||||
return strings.Split(attrPath, ".")
|
||||
}
|
||||
215
internal/packages/parser_test.go
Normal file
215
internal/packages/parser_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePackages(t *testing.T) {
|
||||
input := `{
|
||||
"firefox": {
|
||||
"name": "firefox-120.0",
|
||||
"pname": "firefox",
|
||||
"version": "120.0",
|
||||
"system": "x86_64-linux",
|
||||
"meta": {
|
||||
"description": "A web browser built from Firefox source tree",
|
||||
"homepage": "https://www.mozilla.org/firefox/",
|
||||
"license": {"spdxId": "MPL-2.0", "fullName": "Mozilla Public License 2.0"},
|
||||
"maintainers": [
|
||||
{"name": "John Doe", "github": "johndoe", "githubId": 12345}
|
||||
],
|
||||
"platforms": ["x86_64-linux", "aarch64-linux"]
|
||||
}
|
||||
},
|
||||
"python312Packages.requests": {
|
||||
"name": "python3.12-requests-2.31.0",
|
||||
"pname": "requests",
|
||||
"version": "2.31.0",
|
||||
"system": "x86_64-linux",
|
||||
"meta": {
|
||||
"description": "HTTP library for Python",
|
||||
"homepage": ["https://requests.readthedocs.io/"],
|
||||
"license": [{"spdxId": "Apache-2.0"}],
|
||||
"unfree": false
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
packages, err := ParsePackages(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePackages failed: %v", err)
|
||||
}
|
||||
|
||||
if len(packages) != 2 {
|
||||
t.Errorf("Expected 2 packages, got %d", len(packages))
|
||||
}
|
||||
|
||||
// Check firefox
|
||||
firefox, ok := packages["firefox"]
|
||||
if !ok {
|
||||
t.Fatal("firefox package not found")
|
||||
}
|
||||
if firefox.Pname != "firefox" {
|
||||
t.Errorf("Expected pname 'firefox', got %q", firefox.Pname)
|
||||
}
|
||||
if firefox.Version != "120.0" {
|
||||
t.Errorf("Expected version '120.0', got %q", firefox.Version)
|
||||
}
|
||||
if firefox.Homepage != "https://www.mozilla.org/firefox/" {
|
||||
t.Errorf("Expected homepage 'https://www.mozilla.org/firefox/', got %q", firefox.Homepage)
|
||||
}
|
||||
if firefox.License != `["MPL-2.0"]` {
|
||||
t.Errorf("Expected license '[\"MPL-2.0\"]', got %q", firefox.License)
|
||||
}
|
||||
|
||||
// Check python requests
|
||||
requests, ok := packages["python312Packages.requests"]
|
||||
if !ok {
|
||||
t.Fatal("python312Packages.requests package not found")
|
||||
}
|
||||
if requests.Pname != "requests" {
|
||||
t.Errorf("Expected pname 'requests', got %q", requests.Pname)
|
||||
}
|
||||
// Homepage is array, should extract first element
|
||||
if requests.Homepage != "https://requests.readthedocs.io/" {
|
||||
t.Errorf("Expected homepage 'https://requests.readthedocs.io/', got %q", requests.Homepage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePackagesStream(t *testing.T) {
|
||||
input := `{
|
||||
"hello": {
|
||||
"name": "hello-2.12",
|
||||
"pname": "hello",
|
||||
"version": "2.12",
|
||||
"system": "x86_64-linux",
|
||||
"meta": {
|
||||
"description": "A program that produces a familiar, friendly greeting"
|
||||
}
|
||||
},
|
||||
"world": {
|
||||
"name": "world-1.0",
|
||||
"pname": "world",
|
||||
"version": "1.0",
|
||||
"system": "x86_64-linux",
|
||||
"meta": {}
|
||||
}
|
||||
}`
|
||||
|
||||
var packages []*ParsedPackage
|
||||
count, err := ParsePackagesStream(strings.NewReader(input), func(pkg *ParsedPackage) error {
|
||||
packages = append(packages, pkg)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePackagesStream failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("Expected count 2, got %d", count)
|
||||
}
|
||||
|
||||
if len(packages) != 2 {
|
||||
t.Errorf("Expected 2 packages, got %d", len(packages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLicense(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{"nil", nil, "[]"},
|
||||
{"string", "MIT", `["MIT"]`},
|
||||
{"object with spdxId", map[string]interface{}{"spdxId": "MIT"}, `["MIT"]`},
|
||||
{"object with fullName", map[string]interface{}{"fullName": "MIT License"}, `["MIT License"]`},
|
||||
{"array of strings", []interface{}{"MIT", "Apache-2.0"}, `["MIT","Apache-2.0"]`},
|
||||
{"array of objects", []interface{}{
|
||||
map[string]interface{}{"spdxId": "MIT"},
|
||||
map[string]interface{}{"spdxId": "Apache-2.0"},
|
||||
}, `["MIT","Apache-2.0"]`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := normalizeLicense(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %q, got %q", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHomepage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"string", "https://example.com", "https://example.com"},
|
||||
{"array", []interface{}{"https://example.com", "https://docs.example.com"}, "https://example.com"},
|
||||
{"empty array", []interface{}{}, ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := normalizeHomepage(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %q, got %q", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMaintainers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
maintainers []Maintainer
|
||||
expected string
|
||||
}{
|
||||
{"empty", nil, "[]"},
|
||||
{"with name", []Maintainer{{Name: "John Doe"}}, `["John Doe"]`},
|
||||
{"with github only", []Maintainer{{Github: "johndoe"}}, `["@johndoe"]`},
|
||||
{"multiple", []Maintainer{{Name: "Alice"}, {Name: "Bob"}}, `["Alice","Bob"]`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := normalizeMaintainers(tc.maintainers)
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %q, got %q", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAttrPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"firefox", []string{"firefox"}},
|
||||
{"python312Packages.requests", []string{"python312Packages", "requests"}},
|
||||
{"haskellPackages.aeson.components.library", []string{"haskellPackages", "aeson", "components", "library"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := SplitAttrPath(tc.input)
|
||||
if len(result) != len(tc.expected) {
|
||||
t.Errorf("Expected %v, got %v", tc.expected, result)
|
||||
return
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tc.expected[i] {
|
||||
t.Errorf("Expected %v, got %v", tc.expected, result)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
78
internal/packages/types.go
Normal file
78
internal/packages/types.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Package packages contains types and logic for indexing Nix packages.
|
||||
package packages
|
||||
|
||||
// RawPackage represents a package as parsed from nix-env --json output.
|
||||
type RawPackage struct {
|
||||
Pname string `json:"pname"`
|
||||
Version string `json:"version"`
|
||||
System string `json:"system"`
|
||||
Meta RawPackageMeta `json:"meta"`
|
||||
Name string `json:"name"`
|
||||
OutputName string `json:"outputName,omitempty"`
|
||||
Outputs map[string]interface{} `json:"outputs,omitempty"`
|
||||
}
|
||||
|
||||
// RawPackageMeta contains package metadata.
|
||||
type RawPackageMeta struct {
|
||||
Available bool `json:"available,omitempty"`
|
||||
Broken bool `json:"broken,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Homepage interface{} `json:"homepage,omitempty"` // Can be string or []string
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
License interface{} `json:"license,omitempty"` // Can be string, object, or []interface{}
|
||||
LongDescription string `json:"longDescription,omitempty"`
|
||||
Maintainers []Maintainer `json:"maintainers,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OutputsToInstall []string `json:"outputsToInstall,omitempty"`
|
||||
Platforms []interface{} `json:"platforms,omitempty"` // Can be strings or objects
|
||||
Position string `json:"position,omitempty"`
|
||||
Unfree bool `json:"unfree,omitempty"`
|
||||
}
|
||||
|
||||
// Maintainer represents a package maintainer.
|
||||
type Maintainer struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Github string `json:"github,omitempty"`
|
||||
GithubID int `json:"githubId,omitempty"`
|
||||
Matrix string `json:"matrix,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// ParsedPackage represents a package ready for database storage.
|
||||
type ParsedPackage struct {
|
||||
AttrPath string
|
||||
Pname string
|
||||
Version string
|
||||
Description string
|
||||
LongDescription string
|
||||
Homepage string
|
||||
License string // JSON array
|
||||
Platforms string // JSON array
|
||||
Maintainers string // JSON array
|
||||
Broken bool
|
||||
Unfree bool
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// PackagesFile represents the top-level structure of nix-env JSON output.
|
||||
// It's a map from attr path to package definition.
|
||||
type PackagesFile map[string]RawPackage
|
||||
|
||||
// ChannelAliases maps friendly channel names to their git branch/ref patterns.
|
||||
// These are the same as NixOS options since packages come from the same repo.
|
||||
var ChannelAliases = map[string]string{
|
||||
"nixos-unstable": "nixos-unstable",
|
||||
"nixos-stable": "nixos-24.11",
|
||||
"nixos-24.11": "nixos-24.11",
|
||||
"nixos-24.05": "nixos-24.05",
|
||||
"nixos-23.11": "nixos-23.11",
|
||||
"nixos-23.05": "nixos-23.05",
|
||||
}
|
||||
|
||||
// IndexResult contains the results of a package indexing operation.
|
||||
type IndexResult struct {
|
||||
RevisionID int64
|
||||
PackageCount int
|
||||
Duration interface{} // time.Duration - kept as interface to avoid import cycle
|
||||
AlreadyIndexed bool // True if revision already has packages
|
||||
}
|
||||
383
nix/nixpkgs-search-module.nix
Normal file
383
nix/nixpkgs-search-module.nix
Normal file
@@ -0,0 +1,383 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.nixpkgs-search;
|
||||
|
||||
# Determine database URL based on configuration
|
||||
# For postgres with connectionStringFile, the URL is set at runtime via script
|
||||
useConnectionStringFile = cfg.database.type == "postgres" && cfg.database.connectionStringFile != null;
|
||||
|
||||
databaseUrl = if cfg.database.type == "sqlite"
|
||||
then "sqlite://${cfg.dataDir}/${cfg.database.name}"
|
||||
else if useConnectionStringFile
|
||||
then "" # Will be set at runtime from file
|
||||
else cfg.database.connectionString;
|
||||
|
||||
# Build HTTP transport flags for a service
|
||||
mkHttpFlags = httpCfg: lib.concatStringsSep " " ([
|
||||
"--transport http"
|
||||
"--http-address '${httpCfg.address}'"
|
||||
"--http-endpoint '${httpCfg.endpoint}'"
|
||||
"--session-ttl '${httpCfg.sessionTTL}'"
|
||||
] ++ lib.optionals (httpCfg.allowedOrigins != []) (
|
||||
map (origin: "--allowed-origins '${origin}'") httpCfg.allowedOrigins
|
||||
) ++ lib.optionals httpCfg.tls.enable [
|
||||
"--tls-cert '${httpCfg.tls.certFile}'"
|
||||
"--tls-key '${httpCfg.tls.keyFile}'"
|
||||
]);
|
||||
|
||||
# Common HTTP options
|
||||
mkHttpOptions = defaultPort: {
|
||||
address = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1:${toString defaultPort}";
|
||||
description = "HTTP listen address for the MCP server.";
|
||||
};
|
||||
|
||||
endpoint = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/mcp";
|
||||
description = "HTTP endpoint path for MCP requests.";
|
||||
};
|
||||
|
||||
allowedOrigins = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "http://localhost:3000" "https://example.com" ];
|
||||
description = ''
|
||||
Allowed Origin headers for CORS.
|
||||
Empty list means only localhost origins are allowed.
|
||||
'';
|
||||
};
|
||||
|
||||
sessionTTL = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "30m";
|
||||
description = "Session TTL for HTTP transport (Go duration format).";
|
||||
};
|
||||
|
||||
tls = {
|
||||
enable = lib.mkEnableOption "TLS for HTTP transport";
|
||||
|
||||
certFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Path to TLS certificate file.";
|
||||
};
|
||||
|
||||
keyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Path to TLS private key file.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Service configuration factory
|
||||
mkServiceConfig = serviceName: subcommand: httpCfg: {
|
||||
description = "Nixpkgs Search ${serviceName} MCP Server";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ]
|
||||
++ lib.optional (cfg.database.type == "postgres") "postgresql.service";
|
||||
|
||||
environment = lib.mkIf (!useConnectionStringFile) {
|
||||
NIXPKGS_SEARCH_DATABASE = databaseUrl;
|
||||
};
|
||||
|
||||
path = [ cfg.package ];
|
||||
|
||||
script = let
|
||||
httpFlags = mkHttpFlags httpCfg;
|
||||
in
|
||||
if useConnectionStringFile then ''
|
||||
# Read database connection string from file
|
||||
if [ ! -f "${cfg.database.connectionStringFile}" ]; then
|
||||
echo "Error: connectionStringFile not found: ${cfg.database.connectionStringFile}" >&2
|
||||
exit 1
|
||||
fi
|
||||
export NIXPKGS_SEARCH_DATABASE="$(cat "${cfg.database.connectionStringFile}")"
|
||||
|
||||
exec nixpkgs-search ${subcommand} serve ${httpFlags}
|
||||
'' else ''
|
||||
exec nixpkgs-search ${subcommand} 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/nixpkgs-search") "nixpkgs-search";
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.nixpkgs-search = {
|
||||
enable = lib.mkEnableOption "Nixpkgs Search MCP server(s)";
|
||||
|
||||
package = lib.mkPackageOption pkgs "nixpkgs-search" { };
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "nixpkgs-search";
|
||||
description = "User account under which the service runs.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "nixpkgs-search";
|
||||
description = "Group under which the service runs.";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/var/lib/nixpkgs-search";
|
||||
description = "Directory to store data files.";
|
||||
};
|
||||
|
||||
database = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.enum [ "sqlite" "postgres" ];
|
||||
default = "sqlite";
|
||||
description = "Database backend to use.";
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "nixpkgs-search.db";
|
||||
description = "SQLite database filename (when using sqlite backend).";
|
||||
};
|
||||
|
||||
connectionString = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
PostgreSQL connection string (when using postgres backend).
|
||||
Example: "postgres://user:password@localhost/nixpkgs_search?sslmode=disable"
|
||||
|
||||
WARNING: This value will be stored in the Nix store, which is world-readable.
|
||||
For production use with sensitive credentials, use connectionStringFile instead.
|
||||
'';
|
||||
};
|
||||
|
||||
connectionStringFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to a file containing the PostgreSQL connection string.
|
||||
The file should contain just the connection string, e.g.:
|
||||
postgres://user:password@localhost/nixpkgs_search?sslmode=disable
|
||||
|
||||
This is the recommended way to configure PostgreSQL credentials
|
||||
as the file is not stored in the world-readable Nix store.
|
||||
The file must be readable by the service user.
|
||||
'';
|
||||
example = "/run/secrets/nixpkgs-search-db";
|
||||
};
|
||||
};
|
||||
|
||||
indexOnStart = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "nixos-unstable" "nixos-24.11" ];
|
||||
description = ''
|
||||
List of nixpkgs revisions to index on service start.
|
||||
Can be channel names (nixos-unstable) or git hashes.
|
||||
Indexing is skipped if the revision is already indexed.
|
||||
'';
|
||||
};
|
||||
|
||||
indexFlags = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "--no-packages" "--no-files" ];
|
||||
description = ''
|
||||
Additional flags to pass to the index command.
|
||||
Useful for skipping packages (--no-packages), options (--no-options),
|
||||
or files (--no-files) during indexing.
|
||||
'';
|
||||
};
|
||||
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable the NixOS options MCP server.";
|
||||
};
|
||||
|
||||
http = mkHttpOptions 8082;
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the firewall for the options MCP HTTP server.";
|
||||
};
|
||||
};
|
||||
|
||||
packages = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable the Nix packages MCP server.";
|
||||
};
|
||||
|
||||
http = mkHttpOptions 8083;
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the firewall for the packages MCP HTTP server.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.database.type == "sqlite"
|
||||
|| cfg.database.connectionString != ""
|
||||
|| cfg.database.connectionStringFile != null;
|
||||
message = "services.nixpkgs-search.database: when using postgres backend, either connectionString or connectionStringFile must be set";
|
||||
}
|
||||
{
|
||||
assertion = cfg.database.connectionString == "" || cfg.database.connectionStringFile == null;
|
||||
message = "services.nixpkgs-search.database: connectionString and connectionStringFile are mutually exclusive";
|
||||
}
|
||||
{
|
||||
assertion = !cfg.options.http.tls.enable || (cfg.options.http.tls.certFile != null && cfg.options.http.tls.keyFile != null);
|
||||
message = "services.nixpkgs-search.options.http.tls: both certFile and keyFile must be set when TLS is enabled";
|
||||
}
|
||||
{
|
||||
assertion = !cfg.packages.http.tls.enable || (cfg.packages.http.tls.certFile != null && cfg.packages.http.tls.keyFile != null);
|
||||
message = "services.nixpkgs-search.packages.http.tls: both certFile and keyFile must be set when TLS is enabled";
|
||||
}
|
||||
{
|
||||
assertion = cfg.options.enable || cfg.packages.enable;
|
||||
message = "services.nixpkgs-search: at least one of options.enable or packages.enable must be true";
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "nixpkgs-search") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
description = "Nixpkgs Search MCP server user";
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = lib.mkIf (cfg.group == "nixpkgs-search") { };
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
|
||||
];
|
||||
|
||||
# Indexing service (runs once on startup if indexOnStart is set)
|
||||
systemd.services.nixpkgs-search-index = lib.mkIf (cfg.indexOnStart != []) {
|
||||
description = "Nixpkgs Search Indexer";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ]
|
||||
++ lib.optional (cfg.database.type == "postgres") "postgresql.service";
|
||||
before = lib.optionals cfg.options.enable [ "nixpkgs-search-options.service" ]
|
||||
++ lib.optionals cfg.packages.enable [ "nixpkgs-search-packages.service" ];
|
||||
|
||||
environment = lib.mkIf (!useConnectionStringFile) {
|
||||
NIXPKGS_SEARCH_DATABASE = databaseUrl;
|
||||
};
|
||||
|
||||
path = [ cfg.package ];
|
||||
|
||||
script = let
|
||||
indexFlags = lib.concatStringsSep " " cfg.indexFlags;
|
||||
indexCommands = lib.concatMapStringsSep "\n" (rev: ''
|
||||
echo "Indexing revision: ${rev}"
|
||||
nixpkgs-search index ${indexFlags} "${rev}" || true
|
||||
'') cfg.indexOnStart;
|
||||
in
|
||||
if useConnectionStringFile then ''
|
||||
# Read database connection string from file
|
||||
if [ ! -f "${cfg.database.connectionStringFile}" ]; then
|
||||
echo "Error: connectionStringFile not found: ${cfg.database.connectionStringFile}" >&2
|
||||
exit 1
|
||||
fi
|
||||
export NIXPKGS_SEARCH_DATABASE="$(cat "${cfg.database.connectionStringFile}")"
|
||||
|
||||
${indexCommands}
|
||||
'' else ''
|
||||
${indexCommands}
|
||||
'';
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
RemainAfterExit = true;
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
LockPersonality = true;
|
||||
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/nixpkgs-search") "nixpkgs-search";
|
||||
};
|
||||
};
|
||||
|
||||
# Options MCP server
|
||||
systemd.services.nixpkgs-search-options = lib.mkIf cfg.options.enable
|
||||
(mkServiceConfig "Options" "options" cfg.options.http // {
|
||||
after = (mkServiceConfig "Options" "options" cfg.options.http).after
|
||||
++ lib.optionals (cfg.indexOnStart != []) [ "nixpkgs-search-index.service" ];
|
||||
});
|
||||
|
||||
# Packages MCP server
|
||||
systemd.services.nixpkgs-search-packages = lib.mkIf cfg.packages.enable
|
||||
(mkServiceConfig "Packages" "packages" cfg.packages.http // {
|
||||
after = (mkServiceConfig "Packages" "packages" cfg.packages.http).after
|
||||
++ lib.optionals (cfg.indexOnStart != []) [ "nixpkgs-search-index.service" ];
|
||||
});
|
||||
|
||||
# Open firewall ports if configured
|
||||
networking.firewall = lib.mkMerge [
|
||||
(lib.mkIf cfg.options.openFirewall (let
|
||||
addressParts = lib.splitString ":" cfg.options.http.address;
|
||||
port = lib.toInt (lib.last addressParts);
|
||||
in {
|
||||
allowedTCPPorts = [ port ];
|
||||
}))
|
||||
(lib.mkIf cfg.packages.openFirewall (let
|
||||
addressParts = lib.splitString ":" cfg.packages.http.address;
|
||||
port = lib.toInt (lib.last addressParts);
|
||||
in {
|
||||
allowedTCPPorts = [ port ];
|
||||
}))
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
buildGoModule {
|
||||
inherit pname src;
|
||||
version = "0.1.2";
|
||||
version = "0.2.0";
|
||||
|
||||
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user