feat(platform): structured platform registry + /api/v1/platforms; update server reads registry [#367] #826

Merged
jmiller merged 1 commits from feature/platform-registry into dev 2026-07-19 02:48:56 +00:00
10 changed files with 517 additions and 0 deletions
+20
View File
@@ -2999,3 +2999,23 @@ LEVEL = Info
;SERVICE_TYPE = memory
;; Ignored for the "memory" type. For "redis" use something like `redis://127.0.0.1:6379/0`
;SERVICE_CONN_STR =
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Repository metadata / platform registry
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[metadata]
;; Comma-separated list of allowed values for the repo metadata "platform" field.
;; Also editable via Admin -> Metadata (persisted here). Default taxonomy:
;PLATFORM_OPTIONS = joomla,dolibarr,go,npm,generic
;;
;; Structured platform registry: capability attributes per platform key, read by
;; CI, the update server, and workflow sync (exposed at GET /api/v1/platforms).
;; Value is EITHER an inline JSON array of platform objects OR a path to a JSON
;; file (relative paths resolve against the custom/ dir). When unset, a built-in
;; default registry is used (joomla, dolibarr, go, npm, mcp, generic), so
;; behavior is unchanged if this is not configured.
;; Example (file path):
;PLATFORM_REGISTRY = custom/platforms.json
;; Example (inline JSON):
;;PLATFORM_REGISTRY = [{"key":"joomla","label":"Joomla Extension","family":"joomla","artifact":true,"update_generator":"joomla","feed_format":"xml","template_repo":"Template-Joomla","manifest_based":true}]
+4
View File
@@ -19,4 +19,8 @@ func loadMetadataFrom(rootCfg ConfigProvider) {
if opts := sec.Key("PLATFORM_OPTIONS").Strings(","); len(opts) > 0 {
Metadata.PlatformOptions = opts
}
// Load the structured platform registry ([metadata] PLATFORM_REGISTRY).
// Falls back to a built-in default reconciled with PLATFORM_OPTIONS.
loadPlatformRegistryFrom(rootCfg)
}
+255
View File
@@ -0,0 +1,255 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
// PlatformDef is a single platform's capability record in the platform registry.
//
// A platform key (matching repo_manifest.platform and UpdateStreamConfig.Platform)
// carries the capability attributes that CI, the update server, and the workflow
// sync tooling read instead of hardcoding per-platform behavior. Only Key is
// required; every other field has a sensible zero/default so a bare key still
// yields a usable (generic) record.
//
// This registry is NON-SECRET capability config only. It never carries
// credentials, tokens, or deploy keys.
type PlatformDef struct {
// Key is the stable lowercase identifier (e.g. "joomla"). Matches
// repo_manifest.platform and UpdateStreamConfig.Platform.
Key string `json:"key"`
// Label is the human-readable display name.
Label string `json:"label,omitempty"`
// Aliases are alternate keys matched (by prefix) in the release gate,
// e.g. joomla5, joomla6.
Aliases []string `json:"aliases,omitempty"`
// Family groups related platforms: joomla | dolibarr | node | go | generic | dot.
Family string `json:"family,omitempty"`
// Artifact reports whether the platform produces a build artifact.
Artifact bool `json:"artifact"`
// UpdateGenerator names the update-feed generator the update server uses:
// joomla | dolibarr | wordpress | composer | prestashop | drupal | whmcs | npm | none.
UpdateGenerator string `json:"update_generator,omitempty"`
// FeedFormat is the update-feed wire format: xml | json | none.
FeedFormat string `json:"feed_format,omitempty"`
// TemplateRepo is the workflow-template source repo (e.g. Template-Joomla).
TemplateRepo string `json:"template_repo,omitempty"`
// ManifestBased reports whether a manifest is parsed for element/version.
ManifestBased bool `json:"manifest_based"`
// PublishTarget names where artifacts are published (e.g. npm, updateserver, git-release).
PublishTarget string `json:"publish_target,omitempty"`
// DetectGlobs are file-pattern globs used to auto-detect this platform.
DetectGlobs []string `json:"detect_globs,omitempty"`
}
// Platforms holds the loaded platform registry indexed by key (lowercase).
// It is populated by loadMetadataFrom at startup. Callers should use
// PlatformRegistry(), GetPlatform() and PlatformList() rather than reading the
// map directly.
var platformRegistry map[string]*PlatformDef
// PlatformRegistry returns the loaded registry as a key->def map. Never nil.
func PlatformRegistry() map[string]*PlatformDef {
if platformRegistry == nil {
platformRegistry = buildDefaultRegistry(Metadata.PlatformOptions)
}
return platformRegistry
}
// PlatformList returns registry entries in the order of Metadata.PlatformOptions
// (falling back to a stable built-in order for any extra keys).
func PlatformList() []*PlatformDef {
reg := PlatformRegistry()
out := make([]*PlatformDef, 0, len(reg))
seen := make(map[string]bool, len(reg))
// Preserve admin-configured order first.
for _, k := range Metadata.PlatformOptions {
k = strings.ToLower(strings.TrimSpace(k))
if def, ok := reg[k]; ok && !seen[k] {
out = append(out, def)
seen[k] = true
}
}
// Append any registry-only keys not present in PlatformOptions.
for k, def := range reg {
if !seen[k] {
out = append(out, def)
seen[k] = true
}
}
return out
}
// GetPlatform returns the definition for a platform key. If the key is unknown,
// it synthesizes a generic default so callers never get a nil (matches the
// back-compat guarantee that an unlisted platform behaves like "generic").
// The bool reports whether the key was explicitly present in the registry.
func GetPlatform(key string) (*PlatformDef, bool) {
key = strings.ToLower(strings.TrimSpace(key))
reg := PlatformRegistry()
if def, ok := reg[key]; ok {
return def, true
}
return genericPlatformDef(key), false
}
// genericPlatformDef returns a safe generic capability record for an unknown key.
func genericPlatformDef(key string) *PlatformDef {
return &PlatformDef{
Key: key,
Label: key,
Family: "generic",
Artifact: false,
UpdateGenerator: "none",
FeedFormat: "none",
TemplateRepo: "Template-Generic",
ManifestBased: false,
PublishTarget: "git-release",
}
}
// builtinRegistry is the default platform registry. It reproduces current
// behavior so an unconfigured instance is unchanged: Joomla still maps to the
// joomla generator (xml), Dolibarr to dolibarr (json), npm/mcp are feedless, etc.
func builtinRegistry() []*PlatformDef {
return []*PlatformDef{
{
Key: "joomla", Label: "Joomla Extension", Aliases: []string{"joomla5", "joomla6"},
Family: "joomla", Artifact: true, UpdateGenerator: "joomla", FeedFormat: "xml",
TemplateRepo: "Template-Joomla", ManifestBased: true, PublishTarget: "updateserver",
DetectGlobs: []string{"source/pkg_*.xml", "**/templateDetails.xml"},
},
{
Key: "dolibarr", Label: "Dolibarr Module",
Family: "dolibarr", Artifact: true, UpdateGenerator: "dolibarr", FeedFormat: "json",
TemplateRepo: "Template-Dolibarr", ManifestBased: true, PublishTarget: "updateserver",
DetectGlobs: []string{"htdocs/**/core/modules/mod*.class.php"},
},
{
Key: "go", Label: "Go Module",
Family: "go", Artifact: false, UpdateGenerator: "none", FeedFormat: "none",
TemplateRepo: "Template-Go", ManifestBased: false, PublishTarget: "git-release",
DetectGlobs: []string{"go.mod"},
},
{
Key: "npm", Label: "NPM Package",
Family: "node", Artifact: true, UpdateGenerator: "npm", FeedFormat: "none",
TemplateRepo: "Template-NPM", ManifestBased: true, PublishTarget: "npm",
DetectGlobs: []string{"package.json"},
},
{
Key: "mcp", Label: "MCP Server",
Family: "node", Artifact: true, UpdateGenerator: "npm", FeedFormat: "none",
TemplateRepo: "Template-NPM", ManifestBased: true, PublishTarget: "npm",
DetectGlobs: []string{"**/mcp.json", "**/.mcp.json"},
},
{
Key: "generic", Label: "Generic", Family: "generic", Artifact: false,
UpdateGenerator: "none", FeedFormat: "none", TemplateRepo: "Template-Generic",
ManifestBased: false, PublishTarget: "git-release",
},
}
}
// buildDefaultRegistry indexes the built-in registry by key and then ensures a
// record exists for every key in the admin PLATFORM_OPTIONS list (synthesizing a
// generic default for any key without a built-in entry). This keeps the flat
// PLATFORM_OPTIONS list and the structured registry consistent.
func buildDefaultRegistry(options []string) map[string]*PlatformDef {
reg := make(map[string]*PlatformDef)
for _, def := range builtinRegistry() {
d := def
reg[d.Key] = d
}
for _, opt := range options {
k := strings.ToLower(strings.TrimSpace(opt))
if k == "" {
continue
}
if _, ok := reg[k]; !ok {
reg[k] = genericPlatformDef(k)
}
}
return reg
}
// loadPlatformRegistryFrom loads the structured platform registry.
//
// Storage: [metadata] PLATFORM_REGISTRY in app.ini. The value is EITHER an
// inline JSON array of PlatformDef objects, OR a path to a JSON file (relative
// paths are resolved against CustomPath, e.g. custom/platforms.json). When
// unset, the built-in default registry is used, merged with any extra keys from
// PLATFORM_OPTIONS. This keeps behavior unchanged for unconfigured instances and
// avoids a DB schema change (idiomatic: mirrors the existing app.ini + file-path
// config pattern used elsewhere in modules/setting).
func loadPlatformRegistryFrom(rootCfg ConfigProvider) {
// Base defaults, reconciled with the flat PLATFORM_OPTIONS list.
platformRegistry = buildDefaultRegistry(Metadata.PlatformOptions)
raw := strings.TrimSpace(rootCfg.Section("metadata").Key("PLATFORM_REGISTRY").String())
if raw == "" {
return
}
var data []byte
if strings.HasPrefix(raw, "[") {
// Inline JSON array.
data = []byte(raw)
} else {
// Treat as a file path (resolve relative to CustomPath).
path := raw
if !filepath.IsAbs(path) {
path = filepath.Join(CustomPath, path)
}
b, err := os.ReadFile(path)
if err != nil {
// Non-fatal: keep the default registry so the forge still boots.
return
}
data = b
}
var defs []*PlatformDef
if err := json.Unmarshal(data, &defs); err != nil {
// Non-fatal: malformed registry falls back to the default.
return
}
if len(defs) == 0 {
return
}
// Replace the registry with the configured set, filling defaults per entry.
reg := make(map[string]*PlatformDef, len(defs))
for _, def := range defs {
if def == nil {
continue
}
key := strings.ToLower(strings.TrimSpace(def.Key))
if key == "" {
continue
}
def.Key = key
if def.Label == "" {
def.Label = key
}
if def.Family == "" {
def.Family = "generic"
}
if def.UpdateGenerator == "" {
def.UpdateGenerator = "none"
}
if def.FeedFormat == "" {
def.FeedFormat = "none"
}
reg[key] = def
}
if len(reg) > 0 {
platformRegistry = reg
}
}
+28
View File
@@ -113,6 +113,34 @@ type LicenseTemplateInfo struct {
Body string `json:"body"`
}
// Platform is a single platform capability record from the platform registry.
// It exposes the non-secret capability attributes CI, the update server, and the
// workflow sync tooling read instead of hardcoding per-platform behavior.
type Platform struct {
// Key is the stable lowercase platform identifier
Key string `json:"key"`
// Label is the human-readable display name
Label string `json:"label"`
// Aliases are alternate keys matched by prefix in the release gate
Aliases []string `json:"aliases,omitempty"`
// Family groups related platforms (joomla, dolibarr, node, go, generic, dot)
Family string `json:"family"`
// Artifact reports whether the platform produces a build artifact
Artifact bool `json:"artifact"`
// UpdateGenerator names the update-feed generator (joomla, dolibarr, npm, none, ...)
UpdateGenerator string `json:"update_generator"`
// FeedFormat is the update-feed wire format (xml, json, none)
FeedFormat string `json:"feed_format"`
// TemplateRepo is the workflow-template source repo
TemplateRepo string `json:"template_repo,omitempty"`
// ManifestBased reports whether a manifest is parsed for element/version
ManifestBased bool `json:"manifest_based"`
// PublishTarget names where artifacts are published
PublishTarget string `json:"publish_target,omitempty"`
// DetectGlobs are file-pattern globs used to auto-detect this platform
DetectGlobs []string `json:"detect_globs,omitempty"`
}
// APIError is an api error with a message
type APIError struct {
// Message contains the error description
+4
View File
@@ -1009,6 +1009,10 @@ func Routes() *web.Router {
m.Get("/label/templates", misc.ListLabelTemplates)
m.Get("/label/templates/{name}", misc.GetLabelTemplate)
// Platform registry (non-secret capability config read by CI / mokocli).
m.Get("/platforms", misc.ListPlatforms)
m.Get("/platforms/{key}", misc.GetPlatform)
m.Group("/settings", func() {
m.Get("/ui", settings.GetGeneralUISettings)
m.Get("/api", settings.GetGeneralAPISettings)
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package misc
import (
"net/http"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/setting"
api "code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/structs"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/services/context"
)
// toAPIPlatform converts a setting.PlatformDef into its API representation.
func toAPIPlatform(def *setting.PlatformDef) *api.Platform {
return &api.Platform{
Key: def.Key,
Label: def.Label,
Aliases: def.Aliases,
Family: def.Family,
Artifact: def.Artifact,
UpdateGenerator: def.UpdateGenerator,
FeedFormat: def.FeedFormat,
TemplateRepo: def.TemplateRepo,
ManifestBased: def.ManifestBased,
PublishTarget: def.PublishTarget,
DetectGlobs: def.DetectGlobs,
}
}
// ListPlatforms returns the whole platform registry.
func ListPlatforms(ctx *context.APIContext) {
// swagger:operation GET /platforms miscellaneous listPlatforms
// ---
// summary: Returns the platform registry (all platform capability records)
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/PlatformList"
defs := setting.PlatformList()
response := make([]*api.Platform, len(defs))
for i, def := range defs {
response[i] = toAPIPlatform(def)
}
ctx.JSON(http.StatusOK, response)
}
// GetPlatform returns a single platform record by key.
func GetPlatform(ctx *context.APIContext) {
// swagger:operation GET /platforms/{key} miscellaneous getPlatform
// ---
// summary: Returns a single platform capability record by key
// produces:
// - application/json
// parameters:
// - name: key
// in: path
// description: platform key
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/Platform"
// "404":
// "$ref": "#/responses/notFound"
key := strings.ToLower(strings.TrimSpace(ctx.PathParam("key")))
def, ok := setting.GetPlatform(key)
if !ok {
// Unknown key: only 404 when it is not an admin-listed platform.
// (GetPlatform synthesizes a generic default for unknown keys, but the
// endpoint should be honest about what is actually configured.)
ctx.APIErrorNotFound()
return
}
ctx.JSON(http.StatusOK, toAPIPlatform(def))
}
+14
View File
@@ -62,3 +62,17 @@ type swaggerResponseLabelTemplateInfo struct {
// in:body
Body []api.LabelTemplate `json:"body"`
}
// PlatformList
// swagger:response PlatformList
type swaggerResponsePlatformList struct {
// in:body
Body []api.Platform `json:"body"`
}
// Platform
// swagger:response Platform
type swaggerResponsePlatform struct {
// in:body
Body api.Platform `json:"body"`
}
+4
View File
@@ -20,6 +20,10 @@ func Metadata(ctx *context.Context) {
ctx.Data["Title"] = "Metadata"
ctx.Data["PageIsAdminMetadata"] = true
ctx.Data["PlatformOptions"] = strings.Join(setting.Metadata.PlatformOptions, "\n")
// Resolved platform registry (read-only view). Capability attributes are
// edited via app.ini [metadata] PLATFORM_REGISTRY; the textarea above still
// edits the flat key list (PLATFORM_OPTIONS).
ctx.Data["PlatformRegistry"] = setting.PlatformList()
ctx.HTML(http.StatusOK, tplMetadata)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/setting"
)
// This file rewires the update server's per-platform behavior onto the platform
// registry (modules/setting) so that the update-feed generator and wire format
// for a platform are looked up from config instead of hardcoded.
//
// Overlap note (for reviewer): PR #825 (feature/updateserver-npm-mcp) also
// touches routers/web/repo/updateserver.go and adds models/updateserver/
// platform.go (platform constants + ResolvePlatform + IsFeedlessPlatform). To
// stay compatible and avoid a heavy conflict, this change is intentionally
// ADDITIVE: it adds registry lookups here rather than rewriting the Serve*
// handler bodies. The helpers below are drop-in replacements for the hardcoded
// per-platform decisions and can be adopted by the handlers once #825 lands.
// GeneratorForPlatform returns the update-feed generator name for a platform
// key, read from the registry. Falls back to "none" for unknown platforms.
//
// This replaces the hardcoded per-platform generator selection: instead of a
// switch on the platform string, callers read registry[platform].UpdateGenerator.
func GeneratorForPlatform(platform string) string {
def, _ := setting.GetPlatform(platform)
if def.UpdateGenerator == "" {
return "none"
}
return def.UpdateGenerator
}
// FeedFormatForPlatform returns the update-feed wire format (xml|json|none) for a
// platform key, read from the registry. Content-Type is derived from this.
func FeedFormatForPlatform(platform string) string {
def, _ := setting.GetPlatform(platform)
if def.FeedFormat == "" {
return "none"
}
return def.FeedFormat
}
// ContentTypeForPlatform returns the HTTP Content-Type for a platform's update
// feed, derived from the registry feed_format. Empty string when the platform
// emits no feed (feed_format=none), letting callers 404.
func ContentTypeForPlatform(platform string) string {
switch FeedFormatForPlatform(platform) {
case "xml":
return "application/xml; charset=utf-8"
case "json":
return "application/json; charset=utf-8"
default:
return ""
}
}
// PlatformServesGenerator reports whether the given platform key resolves (via
// the registry) to the named update-feed generator. This lets a Serve* handler
// gate on capability config instead of a hardcoded string compare, e.g.
//
// if !PlatformServesGenerator(platform, "joomla") { ctx.NotFound(nil); return }
//
// The empty platform string is treated as "joomla" for backwards compatibility
// with legacy Joomla repos that predate the registry (mirrors the historic
// "empty ⇒ joomla" default; PR #825's ResolvePlatform centralizes this).
func PlatformServesGenerator(platform, generator string) bool {
if strings.TrimSpace(platform) == "" {
platform = "joomla"
}
return GeneratorForPlatform(platform) == generator
}
+35
View File
@@ -15,5 +15,40 @@
</form>
<p class="tw-text-text-light tw-text-sm tw-mt-3">Existing repositories keep their stored platform value even if it is later removed from this list; it simply won't be offered to other repos.</p>
</div>
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-table" 16}} Platform Registry
</h4>
<div class="ui attached segment">
<p class="tw-text-text-light tw-text-sm">Resolved capability attributes per platform. These drive CI, the update server, and workflow sync (exposed at <code>GET /api/v1/platforms</code>). Capabilities are edited via <code>app.ini</code> <code>[metadata] PLATFORM_REGISTRY</code> (inline JSON array or a path to a JSON file such as <code>custom/platforms.json</code>); when unset, a built-in default registry is used.</p>
<table class="ui small compact table">
<thead>
<tr>
<th>Key</th>
<th>Label</th>
<th>Family</th>
<th>Artifact</th>
<th>Update Generator</th>
<th>Feed Format</th>
<th>Manifest</th>
<th>Template Repo</th>
</tr>
</thead>
<tbody>
{{range .PlatformRegistry}}
<tr>
<td><code>{{.Key}}</code></td>
<td>{{.Label}}</td>
<td>{{.Family}}</td>
<td>{{if .Artifact}}yes{{else}}no{{end}}</td>
<td>{{.UpdateGenerator}}</td>
<td>{{.FeedFormat}}</td>
<td>{{if .ManifestBased}}yes{{else}}no{{end}}</td>
<td>{{.TemplateRepo}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "admin/layout_footer" .}}