Skip to content

Commit c948708

Browse files
committed
feat: serve Foundry module manifest and zip directly from Chronicle
Chronicle now serves the Foundry VTT module at /foundry-module/: - GET /foundry-module/module.json — manifest with URLs dynamically rewritten to use the Chronicle instance's BaseURL - GET /foundry-module/chronicle-sync.zip — on-the-fly zip of the module directory (excludes .ai.md, TESTING.md) - Other module files (scripts, styles, etc.) served as static files No GitHub releases needed — Foundry installs directly from any Chronicle instance. Version upgrades detected via module.json version field, not release tags. https://claude.ai/code/session_01XMwxFR8BCi5XvgaSVMSBZB
1 parent 464ddac commit c948708

2 files changed

Lines changed: 85 additions & 2 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,13 @@ Chronicle is purpose-built for tabletop RPGs, open source, and designed to be se
8080

8181
Chronicle includes a Foundry VTT module for bidirectional sync between your Chronicle worldbuilding and your Foundry VTT game. Sync journals, maps (drawings, tokens, fog of war), and calendars in real-time.
8282

83-
**Install in Foundry VTT:**
83+
**Install in Foundry VTT** — paste this manifest URL into Foundry's module installer:
8484
```
85-
https://raw.githubusercontent.com/keyxmakerx/Chronicle/main/foundry-module/module.json
85+
https://your-chronicle-instance.com/foundry-module/module.json
8686
```
8787

88+
Replace `your-chronicle-instance.com` with your Chronicle server's URL. Chronicle serves the module manifest and zip directly — no GitHub releases needed. The manifest URLs are automatically rewritten to match your instance.
89+
8890
Supports [Calendaria](https://foundryvtt.com/packages/calendaria) and [Simple Calendar](https://foundryvtt.com/packages/foundryvtt-simple-calendar) for calendar sync.
8991

9092
## Planned Features

internal/app/app.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
package app
55

66
import (
7+
"archive/zip"
78
"database/sql"
89
"errors"
910
"fmt"
11+
"io/fs"
1012
"log/slog"
1113
"net/http"
14+
"os"
15+
"path/filepath"
1216
"strings"
1317

1418
"github.com/labstack/echo/v4"
@@ -93,6 +97,12 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, pluginHealth *databa
9397
// Serve static files (CSS, JS, vendor libs, fonts, images).
9498
e.Static("/static", "static")
9599

100+
// Serve the Foundry VTT module directory for easy installation.
101+
// module.json is served with dynamic URL injection; zip built on-the-fly.
102+
e.GET("/foundry-module/module.json", app.serveFoundryModuleManifest)
103+
e.GET("/foundry-module/chronicle-sync.zip", app.serveFoundryModuleZip)
104+
e.Static("/foundry-module", "foundry-module")
105+
96106
return app
97107
}
98108

@@ -268,6 +278,77 @@ func isHTMXRequest(c echo.Context) bool {
268278
return c.Request().Header.Get("HX-Request") == "true"
269279
}
270280

281+
// serveFoundryModuleManifest serves foundry-module/module.json with the
282+
// manifest and download URLs rewritten to use the Chronicle instance's BaseURL.
283+
// This allows Foundry VTT to install the module directly from any Chronicle
284+
// instance without needing GitHub releases.
285+
func (a *App) serveFoundryModuleManifest(c echo.Context) error {
286+
data, err := os.ReadFile("foundry-module/module.json")
287+
if err != nil {
288+
return echo.NewHTTPError(http.StatusNotFound, "module.json not found")
289+
}
290+
291+
baseURL := strings.TrimRight(a.Config.BaseURL, "/")
292+
content := string(data)
293+
294+
// Replace manifest and download URLs with this Chronicle instance's URLs.
295+
content = strings.Replace(content,
296+
`"manifest": "https://raw.githubusercontent.com/keyxmakerx/Chronicle/main/foundry-module/module.json"`,
297+
fmt.Sprintf(`"manifest": "%s/foundry-module/module.json"`, baseURL), 1)
298+
content = strings.Replace(content,
299+
`"download": "https://github.com/keyxmakerx/Chronicle/releases/download/foundry-v0.1.0/chronicle-sync.zip"`,
300+
fmt.Sprintf(`"download": "%s/foundry-module/chronicle-sync.zip"`, baseURL), 1)
301+
302+
return c.JSONBlob(http.StatusOK, []byte(content))
303+
}
304+
305+
// serveFoundryModuleZip dynamically zips the foundry-module/ directory and
306+
// serves it as chronicle-sync.zip. Foundry VTT downloads this during module
307+
// installation. The zip contains all files under a chronicle-sync/ root
308+
// directory, which is the expected structure for Foundry module archives.
309+
func (a *App) serveFoundryModuleZip(c echo.Context) error {
310+
moduleDir := "foundry-module"
311+
if _, err := os.Stat(moduleDir); err != nil {
312+
return echo.NewHTTPError(http.StatusNotFound, "foundry module directory not found")
313+
}
314+
315+
c.Response().Header().Set("Content-Type", "application/zip")
316+
c.Response().Header().Set("Content-Disposition", "attachment; filename=chronicle-sync.zip")
317+
c.Response().WriteHeader(http.StatusOK)
318+
319+
zw := zip.NewWriter(c.Response().Writer)
320+
defer zw.Close()
321+
322+
return filepath.WalkDir(moduleDir, func(path string, d fs.DirEntry, err error) error {
323+
if err != nil {
324+
return err
325+
}
326+
// Skip non-distributable files.
327+
name := d.Name()
328+
if name == ".ai.md" || name == "TESTING.md" {
329+
return nil
330+
}
331+
if d.IsDir() {
332+
return nil
333+
}
334+
// Create zip entry under chronicle-sync/ root.
335+
relPath, _ := filepath.Rel(moduleDir, path)
336+
zipPath := filepath.Join("chronicle-sync", relPath)
337+
338+
w, err := zw.Create(filepath.ToSlash(zipPath))
339+
if err != nil {
340+
return err
341+
}
342+
343+
data, err := os.ReadFile(path)
344+
if err != nil {
345+
return err
346+
}
347+
_, err = w.Write(data)
348+
return err
349+
})
350+
}
351+
271352
// Start begins listening for HTTP requests on the configured port.
272353
func (a *App) Start() error {
273354
addr := fmt.Sprintf(":%d", a.Config.Port)

0 commit comments

Comments
 (0)