Skip to content

Commit cc528c7

Browse files
committed
feat: add package manager plugin for external repo management
New plugin at internal/plugins/packages/ that manages external GitHub repositories for game system packs and the Foundry VTT module. Provides admin UI with version management, auto-update policies, version pinning, and background update worker. Routes registered under /admin/packages. https://claude.ai/code/session_01NnKM8NqJzGz8756CZ4PEGg
1 parent effaba7 commit cc528c7

15 files changed

Lines changed: 1759 additions & 1 deletion

File tree

.ai/status.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-17 -- **Sprint: Draw Steel System Module + Foundry Infrastructure Fixes.**
11+
2026-03-17 -- **Sprint: Package Manager Plugin + Repo Separation Docs.**
12+
13+
45. **Sprint: Package Manager Plugin + Repo Separation.**
14+
- **Repo Separation Documentation (COMPLETE)** — Created comprehensive documentation templates in `docs/repo-templates/` for `chronicle-foundry-module` and `chronicle-systems` repos. Includes README, CLAUDE.md, .ai/ architecture docs, API contracts, adapter docs, JSON schemas, and step-by-step system creation guides.
15+
- **Package Manager Plugin (COMPLETE)** — New `internal/plugins/packages/` plugin with full admin UI. Features: GitHub release fetching, version management (install/pin/unpin), auto-update policies (off/nightly/weekly/on_release), background update worker, package CRUD, HTMX version picker and usage tracking UI. Database: `packages` and `package_versions` tables. Routes under `/admin/packages`. Sidebar link added.
16+
- **Next Steps**: Wire usage tracking to campaign addon tables. Replace bundled Foundry module serving with package manager's installed path. Remove bundled system data and load from package manager instead.
1217

1318
44. **Sprint: Draw Steel System Module + Infrastructure.**
1419
- **Draw Steel System (COMPLETE)** — Full manifest expansion: 24-field character preset with Foundry path annotations, creature preset with EV/role/role_type, kit preset. Reference data: 8 abilities, 6 creatures, 10 ancestries, 6 kits (CC-BY-4.0). Status changed from `coming_soon` to `available`.

cmd/server/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/keyxmakerx/chronicle/internal/database"
1818
"github.com/keyxmakerx/chronicle/internal/plugins/calendar"
1919
"github.com/keyxmakerx/chronicle/internal/plugins/maps"
20+
"github.com/keyxmakerx/chronicle/internal/plugins/packages"
2021
"github.com/keyxmakerx/chronicle/internal/plugins/sessions"
2122
"github.com/keyxmakerx/chronicle/internal/plugins/syncapi"
2223
"github.com/keyxmakerx/chronicle/internal/plugins/timeline"
@@ -166,5 +167,6 @@ func registeredPlugins() []database.PluginSchema {
166167
{Slug: "sessions", MigrationsFS: mustSub(sessions.MigrationsFS, database.PluginMigrationsSubdir)},
167168
{Slug: "timeline", MigrationsFS: mustSub(timeline.MigrationsFS, database.PluginMigrationsSubdir)},
168169
{Slug: "syncapi", MigrationsFS: mustSub(syncapi.MigrationsFS, database.PluginMigrationsSubdir)},
170+
{Slug: "packages", MigrationsFS: mustSub(packages.MigrationsFS, database.PluginMigrationsSubdir)},
169171
}
170172
}

internal/app/routes.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/keyxmakerx/chronicle/internal/plugins/campaigns"
2424
"github.com/keyxmakerx/chronicle/internal/plugins/entities"
2525
"github.com/keyxmakerx/chronicle/internal/plugins/media"
26+
"github.com/keyxmakerx/chronicle/internal/plugins/packages"
2627
"github.com/keyxmakerx/chronicle/internal/plugins/settings"
2728
"github.com/keyxmakerx/chronicle/internal/plugins/smtp"
2829
"github.com/keyxmakerx/chronicle/internal/plugins/calendar"
@@ -971,6 +972,19 @@ func (a *App) RegisterRoutes() {
971972
extensions.RegisterCampaignRoutes(e, extHandler, campaignService, authService)
972973
extensions.RegisterAssetRoutes(e, extHandler)
973974

975+
// Package manager: external repo management for systems and Foundry module.
976+
pkgRepo := packages.NewPackageRepository(a.DB)
977+
pkgGitHub := packages.NewGitHubClient()
978+
pkgService := packages.NewPackageService(pkgRepo, pkgGitHub, a.Config.Upload.MediaPath)
979+
pkgHandler := packages.NewHandler(pkgService)
980+
if a.PluginHealth.IsHealthy("packages") {
981+
packages.RegisterRoutes(adminGroup, pkgHandler)
982+
// Start background auto-update worker.
983+
go pkgService.StartAutoUpdateWorker(context.Background())
984+
} else {
985+
slog.Warn("packages plugin degraded — routes not registered")
986+
}
987+
974988
// Security admin: event logging, session management, user account actions.
975989
securityRepo := admin.NewSecurityEventRepository(a.DB)
976990
securityService := admin.NewSecurityService(securityRepo, authRepo, authService)

internal/plugins/packages/.ai.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Packages Plugin
2+
3+
The packages plugin manages external package repositories for Chronicle. It pulls
4+
game system packs and the Foundry VTT module from GitHub repos, providing version
5+
management, auto-updates, and an admin UI.
6+
7+
## Files
8+
9+
| File | Purpose |
10+
|------|---------|
11+
| `model.go` | Domain types: Package, PackageVersion, PackageUsage, input DTOs |
12+
| `embed.go` | Embeds SQL migrations via `//go:embed` |
13+
| `repository.go` | PackageRepository interface + MariaDB implementation |
14+
| `github_client.go` | GitHubClient for fetching releases and downloading assets |
15+
| `service.go` | PackageService with business logic, version management, auto-updates |
16+
| `handler.go` | Admin HTTP handlers (thin controllers) |
17+
| `routes.go` | Route registration under `/admin/packages` |
18+
| `packages.templ` | Templ templates for admin UI (package list, version picker, usage) |
19+
20+
## Key Concepts
21+
22+
- **PackageType**: `system` (game content packs) or `foundry-module` (Foundry VTT sync module)
23+
- **UpdatePolicy**: `off`, `nightly`, `weekly`, `on_release` — controls auto-update frequency
24+
- **Version pinning**: Locks a package to a specific version, preventing auto-updates
25+
- **GitHub integration**: Uses GitHub Releases API; supports GITHUB_TOKEN for higher rate limits
26+
27+
## Database Tables
28+
29+
- `packages` — registered package repositories with installed state
30+
- `package_versions` — discovered versions from GitHub releases
31+
32+
## Admin Routes
33+
34+
All under `/admin/packages`, requiring site admin authentication:
35+
- `GET /admin/packages` — package management page
36+
- `POST /admin/packages` — add new package repo
37+
- `DELETE /admin/packages/:id` — remove package
38+
- `GET /admin/packages/:id/versions` — list versions (HTMX fragment)
39+
- `PUT /admin/packages/:id/version` — install version
40+
- `PUT /admin/packages/:id/pin` — pin version
41+
- `DELETE /admin/packages/:id/pin` — unpin
42+
- `PUT /admin/packages/:id/auto-update` — change policy
43+
- `POST /admin/packages/:id/check` — manual update check
44+
- `GET /admin/packages/:id/usage` — campaign usage (HTMX fragment)

internal/plugins/packages/embed.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Package packages provides the package manager plugin for Chronicle.
2+
// This file embeds the plugin's SQL migration files so they are available
3+
// in the compiled binary regardless of the runtime working directory.
4+
package packages
5+
6+
import "embed"
7+
8+
// MigrationsFS contains the embedded SQL migration files for the packages plugin.
9+
//
10+
//go:embed migrations/*.sql
11+
var MigrationsFS embed.FS
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package packages
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"regexp"
11+
"strings"
12+
"time"
13+
)
14+
15+
// repoPattern extracts owner/repo from a GitHub URL.
16+
var repoPattern = regexp.MustCompile(`github\.com[/:]([^/]+)/([^/.]+)`)
17+
18+
// GitHubRelease represents a release from the GitHub Releases API.
19+
type GitHubRelease struct {
20+
TagName string `json:"tag_name"`
21+
Name string `json:"name"`
22+
Body string `json:"body"`
23+
PublishedAt time.Time `json:"published_at"`
24+
Assets []GitHubAsset `json:"assets"`
25+
}
26+
27+
// GitHubAsset represents a downloadable file in a GitHub release.
28+
type GitHubAsset struct {
29+
Name string `json:"name"`
30+
BrowserDownloadURL string `json:"browser_download_url"`
31+
Size int64 `json:"size"`
32+
ContentType string `json:"content_type"`
33+
}
34+
35+
// GitHubClient fetches release information from GitHub repositories.
36+
type GitHubClient struct {
37+
httpClient *http.Client
38+
token string // Optional GitHub token for higher rate limits.
39+
}
40+
41+
// NewGitHubClient creates a GitHub API client. It reads GITHUB_TOKEN
42+
// from the environment for authenticated requests (5000 req/hour vs 60).
43+
func NewGitHubClient() *GitHubClient {
44+
return &GitHubClient{
45+
httpClient: &http.Client{Timeout: 30 * time.Second},
46+
token: os.Getenv("GITHUB_TOKEN"),
47+
}
48+
}
49+
50+
// parseRepo extracts owner and repo from a GitHub URL.
51+
// Supports https://github.com/owner/repo and git@github.com:owner/repo.
52+
func parseRepo(repoURL string) (owner, repo string, err error) {
53+
matches := repoPattern.FindStringSubmatch(repoURL)
54+
if len(matches) < 3 {
55+
return "", "", fmt.Errorf("cannot parse GitHub repo from URL: %s", repoURL)
56+
}
57+
return matches[1], strings.TrimSuffix(matches[2], ".git"), nil
58+
}
59+
60+
// ListReleases fetches all releases for a GitHub repository.
61+
// Returns them sorted by published_at descending (newest first).
62+
func (c *GitHubClient) ListReleases(ctx context.Context, repoURL string) ([]GitHubRelease, error) {
63+
owner, repo, err := parseRepo(repoURL)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?per_page=50", owner, repo)
69+
70+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
71+
if err != nil {
72+
return nil, fmt.Errorf("creating request: %w", err)
73+
}
74+
75+
req.Header.Set("Accept", "application/vnd.github+json")
76+
if c.token != "" {
77+
req.Header.Set("Authorization", "Bearer "+c.token)
78+
}
79+
80+
resp, err := c.httpClient.Do(req)
81+
if err != nil {
82+
return nil, fmt.Errorf("fetching releases: %w", err)
83+
}
84+
defer resp.Body.Close()
85+
86+
if resp.StatusCode != http.StatusOK {
87+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
88+
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body))
89+
}
90+
91+
var releases []GitHubRelease
92+
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
93+
return nil, fmt.Errorf("decoding releases: %w", err)
94+
}
95+
96+
return releases, nil
97+
}
98+
99+
// DownloadAsset downloads a release asset (ZIP file) to disk.
100+
// Returns the number of bytes written.
101+
func (c *GitHubClient) DownloadAsset(ctx context.Context, downloadURL, destPath string) (int64, error) {
102+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
103+
if err != nil {
104+
return 0, fmt.Errorf("creating download request: %w", err)
105+
}
106+
107+
if c.token != "" {
108+
req.Header.Set("Authorization", "Bearer "+c.token)
109+
}
110+
111+
resp, err := c.httpClient.Do(req)
112+
if err != nil {
113+
return 0, fmt.Errorf("downloading asset: %w", err)
114+
}
115+
defer resp.Body.Close()
116+
117+
if resp.StatusCode != http.StatusOK {
118+
return 0, fmt.Errorf("download returned HTTP %d", resp.StatusCode)
119+
}
120+
121+
out, err := os.Create(destPath)
122+
if err != nil {
123+
return 0, fmt.Errorf("creating file %s: %w", destPath, err)
124+
}
125+
defer out.Close()
126+
127+
n, err := io.Copy(out, resp.Body)
128+
if err != nil {
129+
_ = os.Remove(destPath)
130+
return 0, fmt.Errorf("writing file: %w", err)
131+
}
132+
133+
return n, nil
134+
}

0 commit comments

Comments
 (0)