From 81deee8598c04a7832612d4f56f8955d49be8f3e Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 14 Mar 2026 10:42:37 +0000 Subject: [PATCH] feat(api): add SCM service provider with Lit custom elements ScmProvider implements Provider + Streamable + Describable + Renderable, wrapping marketplace, manifest, installed, and registry endpoints as REST API with WS event streaming. Includes Lit custom element bundle with panel, marketplace browser, manifest viewer, installed manager, and registry status display. All 14 tests pass. Co-Authored-By: Virgil --- go.mod | 3 + pkg/api/embed.go | 11 + pkg/api/provider.go | 450 ++++++++++++++++++++++++++++++++++++++ pkg/api/provider_test.go | 237 ++++++++++++++++++++ pkg/api/ui/dist/.gitkeep | 0 ui/index.html | 79 +++++++ ui/package.json | 18 ++ ui/src/index.ts | 11 + ui/src/scm-installed.ts | 250 +++++++++++++++++++++ ui/src/scm-manifest.ts | 411 ++++++++++++++++++++++++++++++++++ ui/src/scm-marketplace.ts | 331 ++++++++++++++++++++++++++++ ui/src/scm-panel.ts | 264 ++++++++++++++++++++++ ui/src/scm-registry.ts | 216 ++++++++++++++++++ ui/src/shared/api.ts | 77 +++++++ ui/src/shared/events.ts | 32 +++ ui/tsconfig.json | 17 ++ ui/vite.config.ts | 20 ++ 17 files changed, 2427 insertions(+) create mode 100644 pkg/api/embed.go create mode 100644 pkg/api/provider.go create mode 100644 pkg/api/provider_test.go create mode 100644 pkg/api/ui/dist/.gitkeep create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/src/index.ts create mode 100644 ui/src/scm-installed.ts create mode 100644 ui/src/scm-manifest.ts create mode 100644 ui/src/scm-marketplace.ts create mode 100644 ui/src/scm-panel.ts create mode 100644 ui/src/scm-registry.ts create mode 100644 ui/src/shared/api.ts create mode 100644 ui/src/shared/events.ts create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/go.mod b/go.mod index 5b34ddf..8259630 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,14 @@ go 1.26.0 require ( code.gitea.io/sdk/gitea v0.23.2 codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 + forge.lthn.ai/core/api v0.1.0 forge.lthn.ai/core/cli v0.1.0 forge.lthn.ai/core/config v0.1.0 forge.lthn.ai/core/go-i18n v0.1.0 forge.lthn.ai/core/go-io v0.0.3 forge.lthn.ai/core/go-log v0.0.1 + forge.lthn.ai/core/go-ws v0.1.0 + github.com/gin-gonic/gin v1.11.0 github.com/stretchr/testify v1.11.1 golang.org/x/net v0.50.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/pkg/api/embed.go b/pkg/api/embed.go new file mode 100644 index 0000000..981cfb7 --- /dev/null +++ b/pkg/api/embed.go @@ -0,0 +1,11 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package api + +import "embed" + +// Assets holds the built UI bundle (core-scm.js and related files). +// The directory is populated by running `npm run build` in the ui/ directory. +// +//go:embed all:ui/dist +var Assets embed.FS diff --git a/pkg/api/provider.go b/pkg/api/provider.go new file mode 100644 index 0000000..11d8b52 --- /dev/null +++ b/pkg/api/provider.go @@ -0,0 +1,450 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +// Package api provides a service provider that wraps go-scm marketplace, +// manifest, and registry functionality as REST endpoints with WebSocket +// event streaming. +package api + +import ( + "context" + "crypto/ed25519" + "encoding/hex" + "net/http" + + "forge.lthn.ai/core/api" + "forge.lthn.ai/core/api/pkg/provider" + "forge.lthn.ai/core/go-io" + "forge.lthn.ai/core/go-scm/manifest" + "forge.lthn.ai/core/go-scm/marketplace" + "forge.lthn.ai/core/go-scm/repos" + "forge.lthn.ai/core/go-ws" + "github.com/gin-gonic/gin" +) + +// ScmProvider wraps go-scm marketplace, manifest, and registry operations +// as a service provider. It implements Provider, Streamable, Describable, +// and Renderable. +type ScmProvider struct { + index *marketplace.Index + installer *marketplace.Installer + registry *repos.Registry + hub *ws.Hub + medium io.Medium +} + +// compile-time interface checks +var ( + _ provider.Provider = (*ScmProvider)(nil) + _ provider.Streamable = (*ScmProvider)(nil) + _ provider.Describable = (*ScmProvider)(nil) + _ provider.Renderable = (*ScmProvider)(nil) +) + +// NewProvider creates an SCM provider backed by the given marketplace index, +// installer, and registry. The WS hub is used to emit real-time events. +// Pass nil for any dependency that is not available. +func NewProvider(idx *marketplace.Index, inst *marketplace.Installer, reg *repos.Registry, hub *ws.Hub) *ScmProvider { + return &ScmProvider{ + index: idx, + installer: inst, + registry: reg, + hub: hub, + medium: io.Local, + } +} + +// Name implements api.RouteGroup. +func (p *ScmProvider) Name() string { return "scm" } + +// BasePath implements api.RouteGroup. +func (p *ScmProvider) BasePath() string { return "/api/v1/scm" } + +// Element implements provider.Renderable. +func (p *ScmProvider) Element() provider.ElementSpec { + return provider.ElementSpec{ + Tag: "core-scm-panel", + Source: "/assets/core-scm.js", + } +} + +// Channels implements provider.Streamable. +func (p *ScmProvider) Channels() []string { + return []string{ + "scm.marketplace.refreshed", + "scm.marketplace.installed", + "scm.marketplace.removed", + "scm.manifest.verified", + "scm.registry.changed", + } +} + +// RegisterRoutes implements api.RouteGroup. +func (p *ScmProvider) RegisterRoutes(rg *gin.RouterGroup) { + // Marketplace + rg.GET("/marketplace", p.listMarketplace) + rg.GET("/marketplace/:code", p.getMarketplaceItem) + rg.POST("/marketplace/:code/install", p.installItem) + rg.DELETE("/marketplace/:code", p.removeItem) + + // Manifest + rg.GET("/manifest", p.getManifest) + rg.POST("/manifest/verify", p.verifyManifest) + rg.POST("/manifest/sign", p.signManifest) + rg.GET("/manifest/permissions", p.getPermissions) + + // Installed + rg.GET("/installed", p.listInstalled) + rg.POST("/installed/:code/update", p.updateInstalled) + + // Registry + rg.GET("/registry", p.listRegistry) +} + +// Describe implements api.DescribableGroup. +func (p *ScmProvider) Describe() []api.RouteDescription { + return []api.RouteDescription{ + { + Method: "GET", + Path: "/marketplace", + Summary: "List available providers", + Description: "Returns all providers from the marketplace index, optionally filtered by query or category.", + Tags: []string{"scm", "marketplace"}, + }, + { + Method: "GET", + Path: "/marketplace/:code", + Summary: "Get provider details", + Description: "Returns a single provider entry from the marketplace by its code.", + Tags: []string{"scm", "marketplace"}, + }, + { + Method: "POST", + Path: "/marketplace/:code/install", + Summary: "Install a provider", + Description: "Clones the provider repository, verifies its manifest signature, and registers it.", + Tags: []string{"scm", "marketplace"}, + }, + { + Method: "DELETE", + Path: "/marketplace/:code", + Summary: "Remove an installed provider", + Description: "Uninstalls a provider by removing its files and store entry.", + Tags: []string{"scm", "marketplace"}, + }, + { + Method: "GET", + Path: "/manifest", + Summary: "Read manifest", + Description: "Reads and parses the .core/manifest.yaml from the current directory.", + Tags: []string{"scm", "manifest"}, + }, + { + Method: "POST", + Path: "/manifest/verify", + Summary: "Verify manifest signature", + Description: "Verifies the Ed25519 signature of the manifest using the provided public key.", + Tags: []string{"scm", "manifest"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "public_key": map[string]any{"type": "string", "description": "Hex-encoded Ed25519 public key"}, + }, + }, + }, + { + Method: "POST", + Path: "/manifest/sign", + Summary: "Sign manifest", + Description: "Signs the manifest with the provided Ed25519 private key.", + Tags: []string{"scm", "manifest"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "private_key": map[string]any{"type": "string", "description": "Hex-encoded Ed25519 private key"}, + }, + }, + }, + { + Method: "GET", + Path: "/manifest/permissions", + Summary: "List manifest permissions", + Description: "Returns the declared permissions from the current manifest.", + Tags: []string{"scm", "manifest"}, + }, + { + Method: "GET", + Path: "/installed", + Summary: "List installed providers", + Description: "Returns all installed provider metadata.", + Tags: []string{"scm", "installed"}, + }, + { + Method: "POST", + Path: "/installed/:code/update", + Summary: "Update an installed provider", + Description: "Pulls latest changes from git and re-verifies the manifest.", + Tags: []string{"scm", "installed"}, + }, + { + Method: "GET", + Path: "/registry", + Summary: "List registry repos", + Description: "Returns all repositories from the repos.yaml registry.", + Tags: []string{"scm", "registry"}, + }, + } +} + +// -- Marketplace Handlers ----------------------------------------------------- + +func (p *ScmProvider) listMarketplace(c *gin.Context) { + if p.index == nil { + c.JSON(http.StatusOK, api.OK([]marketplace.Module{})) + return + } + + query := c.Query("q") + category := c.Query("category") + + var modules []marketplace.Module + switch { + case query != "": + modules = p.index.Search(query) + case category != "": + modules = p.index.ByCategory(category) + default: + modules = p.index.Modules + } + + if modules == nil { + modules = []marketplace.Module{} + } + c.JSON(http.StatusOK, api.OK(modules)) +} + +func (p *ScmProvider) getMarketplaceItem(c *gin.Context) { + if p.index == nil { + c.JSON(http.StatusNotFound, api.Fail("not_found", "marketplace index not loaded")) + return + } + + code := c.Param("code") + mod, ok := p.index.Find(code) + if !ok { + c.JSON(http.StatusNotFound, api.Fail("not_found", "provider not found in marketplace")) + return + } + c.JSON(http.StatusOK, api.OK(mod)) +} + +func (p *ScmProvider) installItem(c *gin.Context) { + if p.index == nil || p.installer == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("unavailable", "marketplace not configured")) + return + } + + code := c.Param("code") + mod, ok := p.index.Find(code) + if !ok { + c.JSON(http.StatusNotFound, api.Fail("not_found", "provider not found in marketplace")) + return + } + + if err := p.installer.Install(context.Background(), mod); err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("install_failed", err.Error())) + return + } + + p.emitEvent("scm.marketplace.installed", map[string]any{ + "code": mod.Code, + "name": mod.Name, + }) + + c.JSON(http.StatusOK, api.OK(map[string]any{"installed": true, "code": mod.Code})) +} + +func (p *ScmProvider) removeItem(c *gin.Context) { + if p.installer == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("unavailable", "installer not configured")) + return + } + + code := c.Param("code") + if err := p.installer.Remove(code); err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("remove_failed", err.Error())) + return + } + + p.emitEvent("scm.marketplace.removed", map[string]any{"code": code}) + + c.JSON(http.StatusOK, api.OK(map[string]any{"removed": true, "code": code})) +} + +// -- Manifest Handlers -------------------------------------------------------- + +func (p *ScmProvider) getManifest(c *gin.Context) { + m, err := manifest.Load(p.medium, ".") + if err != nil { + c.JSON(http.StatusNotFound, api.Fail("manifest_not_found", err.Error())) + return + } + c.JSON(http.StatusOK, api.OK(m)) +} + +type verifyRequest struct { + PublicKey string `json:"public_key" binding:"required"` +} + +func (p *ScmProvider) verifyManifest(c *gin.Context) { + var req verifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "public_key is required")) + return + } + + m, err := manifest.Load(p.medium, ".") + if err != nil { + c.JSON(http.StatusNotFound, api.Fail("manifest_not_found", err.Error())) + return + } + + pubBytes, err := hex.DecodeString(req.PublicKey) + if err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_key", "public key must be hex-encoded")) + return + } + + valid, err := manifest.Verify(m, ed25519.PublicKey(pubBytes)) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, api.Fail("verify_failed", err.Error())) + return + } + + p.emitEvent("scm.manifest.verified", map[string]any{ + "code": m.Code, + "valid": valid, + }) + + c.JSON(http.StatusOK, api.OK(map[string]any{"valid": valid, "code": m.Code})) +} + +type signRequest struct { + PrivateKey string `json:"private_key" binding:"required"` +} + +func (p *ScmProvider) signManifest(c *gin.Context) { + var req signRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "private_key is required")) + return + } + + m, err := manifest.Load(p.medium, ".") + if err != nil { + c.JSON(http.StatusNotFound, api.Fail("manifest_not_found", err.Error())) + return + } + + privBytes, err := hex.DecodeString(req.PrivateKey) + if err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_key", "private key must be hex-encoded")) + return + } + + if err := manifest.Sign(m, ed25519.PrivateKey(privBytes)); err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("sign_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"signed": true, "code": m.Code, "signature": m.Sign})) +} + +func (p *ScmProvider) getPermissions(c *gin.Context) { + m, err := manifest.Load(p.medium, ".") + if err != nil { + c.JSON(http.StatusNotFound, api.Fail("manifest_not_found", err.Error())) + return + } + c.JSON(http.StatusOK, api.OK(m.Permissions)) +} + +// -- Installed Handlers ------------------------------------------------------- + +func (p *ScmProvider) listInstalled(c *gin.Context) { + if p.installer == nil { + c.JSON(http.StatusOK, api.OK([]marketplace.InstalledModule{})) + return + } + + installed, err := p.installer.Installed() + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("list_failed", err.Error())) + return + } + if installed == nil { + installed = []marketplace.InstalledModule{} + } + c.JSON(http.StatusOK, api.OK(installed)) +} + +func (p *ScmProvider) updateInstalled(c *gin.Context) { + if p.installer == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("unavailable", "installer not configured")) + return + } + + code := c.Param("code") + if err := p.installer.Update(context.Background(), code); err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("update_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"updated": true, "code": code})) +} + +// -- Registry Handlers -------------------------------------------------------- + +// repoSummary is a JSON-friendly representation of a registry repo. +type repoSummary struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` + Path string `json:"path,omitempty"` + Exists bool `json:"exists"` +} + +func (p *ScmProvider) listRegistry(c *gin.Context) { + if p.registry == nil { + c.JSON(http.StatusOK, api.OK([]repoSummary{})) + return + } + + repoList := p.registry.List() + summaries := make([]repoSummary, 0, len(repoList)) + for _, r := range repoList { + summaries = append(summaries, repoSummary{ + Name: r.Name, + Type: r.Type, + Description: r.Description, + DependsOn: r.DependsOn, + Path: r.Path, + Exists: r.Exists(), + }) + } + + c.JSON(http.StatusOK, api.OK(summaries)) +} + +// -- Internal Helpers --------------------------------------------------------- + +// emitEvent sends a WS event if the hub is available. +func (p *ScmProvider) emitEvent(channel string, data any) { + if p.hub == nil { + return + } + _ = p.hub.SendToChannel(channel, ws.Message{ + Type: ws.TypeEvent, + Data: data, + }) +} diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go new file mode 100644 index 0000000..643cdce --- /dev/null +++ b/pkg/api/provider_test.go @@ -0,0 +1,237 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + goapi "forge.lthn.ai/core/api" + "forge.lthn.ai/core/go-scm/marketplace" + scmapi "forge.lthn.ai/core/go-scm/pkg/api" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// -- Provider Identity -------------------------------------------------------- + +func TestScmProvider_Name_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + assert.Equal(t, "scm", p.Name()) +} + +func TestScmProvider_BasePath_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + assert.Equal(t, "/api/v1/scm", p.BasePath()) +} + +func TestScmProvider_Channels_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + channels := p.Channels() + assert.Contains(t, channels, "scm.marketplace.refreshed") + assert.Contains(t, channels, "scm.marketplace.installed") + assert.Contains(t, channels, "scm.marketplace.removed") + assert.Contains(t, channels, "scm.manifest.verified") + assert.Contains(t, channels, "scm.registry.changed") +} + +func TestScmProvider_Element_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + el := p.Element() + assert.Equal(t, "core-scm-panel", el.Tag) + assert.Equal(t, "/assets/core-scm.js", el.Source) +} + +func TestScmProvider_Describe_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + descs := p.Describe() + assert.GreaterOrEqual(t, len(descs), 11) + + for _, d := range descs { + assert.NotEmpty(t, d.Method) + assert.NotEmpty(t, d.Path) + assert.NotEmpty(t, d.Summary) + assert.NotEmpty(t, d.Tags) + } +} + +// -- Marketplace Endpoints ---------------------------------------------------- + +func TestScmProvider_ListMarketplace_Good(t *testing.T) { + idx := &marketplace.Index{ + Version: 1, + Modules: []marketplace.Module{ + {Code: "analytics", Name: "Analytics", Category: "product"}, + {Code: "bio", Name: "Bio Links", Category: "product"}, + }, + Categories: []string{"product"}, + } + p := scmapi.NewProvider(idx, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]marketplace.Module] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Len(t, resp.Data, 2) +} + +func TestScmProvider_ListMarketplace_Search_Good(t *testing.T) { + idx := &marketplace.Index{ + Version: 1, + Modules: []marketplace.Module{ + {Code: "analytics", Name: "Analytics", Category: "product"}, + {Code: "bio", Name: "Bio Links", Category: "product"}, + }, + } + p := scmapi.NewProvider(idx, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace?q=bio", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]marketplace.Module] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Len(t, resp.Data, 1) + assert.Equal(t, "bio", resp.Data[0].Code) +} + +func TestScmProvider_ListMarketplace_NilIndex_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]marketplace.Module] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Empty(t, resp.Data) +} + +func TestScmProvider_GetMarketplaceItem_Good(t *testing.T) { + idx := &marketplace.Index{ + Version: 1, + Modules: []marketplace.Module{ + {Code: "analytics", Name: "Analytics", Category: "product"}, + }, + } + p := scmapi.NewProvider(idx, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace/analytics", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[marketplace.Module] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Equal(t, "analytics", resp.Data.Code) +} + +func TestScmProvider_GetMarketplaceItem_Bad(t *testing.T) { + idx := &marketplace.Index{Version: 1} + p := scmapi.NewProvider(idx, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace/nonexistent", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// -- Installed Endpoints ------------------------------------------------------ + +func TestScmProvider_ListInstalled_NilInstaller_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/installed", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]marketplace.InstalledModule] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Empty(t, resp.Data) +} + +// -- Registry Endpoints ------------------------------------------------------- + +func TestScmProvider_ListRegistry_NilRegistry_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/registry", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]any] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Empty(t, resp.Data) +} + +// -- Route Registration ------------------------------------------------------- + +func TestScmProvider_RegistersAsRouteGroup_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + engine, err := goapi.New() + require.NoError(t, err) + + engine.Register(p) + assert.Len(t, engine.Groups(), 1) + assert.Equal(t, "scm", engine.Groups()[0].Name()) +} + +func TestScmProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) { + p := scmapi.NewProvider(nil, nil, nil, nil) + + engine, err := goapi.New() + require.NoError(t, err) + + engine.Register(p) + + channels := engine.Channels() + assert.Contains(t, channels, "scm.marketplace.refreshed") +} + +// -- Test helpers ------------------------------------------------------------- + +func setupRouter(p *scmapi.ScmProvider) *gin.Engine { + r := gin.New() + rg := r.Group(p.BasePath()) + p.RegisterRoutes(rg) + return r +} diff --git a/pkg/api/ui/dist/.gitkeep b/pkg/api/ui/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..cf687ba --- /dev/null +++ b/ui/index.html @@ -0,0 +1,79 @@ + + + + + + Core SCM — Demo + + + + +

Core SCM — Custom Element Demo

+ +
+ +
+ +

Standalone Elements

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..7ad9e18 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "@core/scm-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lit": "^3.2.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/ui/src/index.ts b/ui/src/index.ts new file mode 100644 index 0000000..cfe43bc --- /dev/null +++ b/ui/src/index.ts @@ -0,0 +1,11 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +// Bundle entry — exports all SCM custom elements. + +export { ScmPanel } from './scm-panel.js'; +export { ScmMarketplace } from './scm-marketplace.js'; +export { ScmManifest } from './scm-manifest.js'; +export { ScmInstalled } from './scm-installed.js'; +export { ScmRegistry } from './scm-registry.js'; +export { ScmApi } from './shared/api.js'; +export { connectScmEvents } from './shared/events.js'; diff --git a/ui/src/scm-installed.ts b/ui/src/scm-installed.ts new file mode 100644 index 0000000..165c67a --- /dev/null +++ b/ui/src/scm-installed.ts @@ -0,0 +1,250 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ScmApi } from './shared/api.js'; + +interface InstalledModule { + code: string; + name: string; + version: string; + repo: string; + entry_point: string; + permissions: { + read?: string[]; + write?: string[]; + net?: string[]; + run?: string[]; + }; + sign_key?: string; + installed_at: string; +} + +/** + * — Manage installed providers. + * Displays installed provider list with update/remove actions. + */ +@customElement('core-scm-installed') +export class ScmInstalled extends LitElement { + static styles = css` + :host { + display: block; + font-family: system-ui, -apple-system, sans-serif; + } + + .list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .item { + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 1rem; + background: #fff; + display: flex; + justify-content: space-between; + align-items: center; + transition: box-shadow 0.15s; + } + + .item:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } + + .item-info { + flex: 1; + } + + .item-name { + font-weight: 600; + font-size: 0.9375rem; + } + + .item-meta { + font-size: 0.75rem; + colour: #6b7280; + margin-top: 0.25rem; + display: flex; + gap: 1rem; + } + + .item-code { + font-family: monospace; + } + + .item-actions { + display: flex; + gap: 0.5rem; + } + + button { + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + cursor: pointer; + transition: background 0.15s; + } + + button.update { + background: #fff; + colour: #6366f1; + border: 1px solid #6366f1; + } + + button.update:hover { + background: #eef2ff; + } + + button.update:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + button.remove { + background: #fff; + colour: #dc2626; + border: 1px solid #dc2626; + } + + button.remove:hover { + background: #fef2f2; + } + + .empty { + text-align: center; + padding: 2rem; + colour: #9ca3af; + font-size: 0.875rem; + } + + .loading { + text-align: center; + padding: 2rem; + colour: #6b7280; + } + + .error { + colour: #dc2626; + padding: 0.75rem; + background: #fef2f2; + border-radius: 0.375rem; + font-size: 0.875rem; + margin-bottom: 1rem; + } + `; + + @property({ attribute: 'api-url' }) apiUrl = ''; + + @state() private modules: InstalledModule[] = []; + @state() private loading = true; + @state() private error = ''; + @state() private updating = new Set(); + + private api!: ScmApi; + + connectedCallback() { + super.connectedCallback(); + this.api = new ScmApi(this.apiUrl); + this.loadInstalled(); + } + + async loadInstalled() { + this.loading = true; + this.error = ''; + try { + this.modules = await this.api.installed(); + } catch (e: any) { + this.error = e.message ?? 'Failed to load installed providers'; + } finally { + this.loading = false; + } + } + + private async handleUpdate(code: string) { + this.updating = new Set([...this.updating, code]); + try { + await this.api.updateInstalled(code); + await this.loadInstalled(); + } catch (e: any) { + this.error = e.message ?? 'Update failed'; + } finally { + const next = new Set(this.updating); + next.delete(code); + this.updating = next; + } + } + + private async handleRemove(code: string) { + try { + await this.api.remove(code); + this.dispatchEvent( + new CustomEvent('scm-removed', { detail: { code }, bubbles: true }), + ); + await this.loadInstalled(); + } catch (e: any) { + this.error = e.message ?? 'Removal failed'; + } + } + + private formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + } catch { + return iso; + } + } + + render() { + if (this.loading) { + return html`
Loading installed providers\u2026
`; + } + + return html` + ${this.error ? html`
${this.error}
` : nothing} + ${this.modules.length === 0 + ? html`
No providers installed.
` + : html` +
+ ${this.modules.map( + (mod) => html` +
+
+
${mod.name}
+
+ ${mod.code} + v${mod.version} + Installed ${this.formatDate(mod.installed_at)} +
+
+
+ + +
+
+ `, + )} +
+ `} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'core-scm-installed': ScmInstalled; + } +} diff --git a/ui/src/scm-manifest.ts b/ui/src/scm-manifest.ts new file mode 100644 index 0000000..ab4c0f0 --- /dev/null +++ b/ui/src/scm-manifest.ts @@ -0,0 +1,411 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ScmApi } from './shared/api.js'; + +interface ManifestData { + code: string; + name: string; + description?: string; + version: string; + sign?: string; + layout?: string; + slots?: Record; + permissions?: { + read?: string[]; + write?: string[]; + net?: string[]; + run?: string[]; + }; + modules?: string[]; +} + +/** + * — View and verify a .core/manifest.yaml file. + */ +@customElement('core-scm-manifest') +export class ScmManifest extends LitElement { + static styles = css` + :host { + display: block; + font-family: system-ui, -apple-system, sans-serif; + } + + .manifest { + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 1.25rem; + background: #fff; + } + + .header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + } + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + } + + .version { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + background: #f3f4f6; + border-radius: 1rem; + colour: #6b7280; + } + + .field { + margin-bottom: 0.75rem; + } + + .field-label { + font-size: 0.75rem; + font-weight: 600; + colour: #6b7280; + text-transform: uppercase; + letter-spacing: 0.025em; + margin-bottom: 0.25rem; + } + + .field-value { + font-size: 0.875rem; + } + + .code { + font-family: monospace; + font-size: 0.8125rem; + background: #f9fafb; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + } + + .slots { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + font-size: 0.8125rem; + } + + .slot-key { + font-weight: 600; + colour: #374151; + } + + .slot-value { + font-family: monospace; + colour: #6b7280; + } + + .permissions-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.5rem; + } + + .perm-group { + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + padding: 0.5rem; + } + + .perm-group-label { + font-size: 0.6875rem; + font-weight: 700; + colour: #6b7280; + text-transform: uppercase; + margin-bottom: 0.25rem; + } + + .perm-item { + font-size: 0.8125rem; + font-family: monospace; + colour: #374151; + } + + .signature { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 1rem; + padding: 0.75rem; + border-radius: 0.375rem; + font-size: 0.875rem; + } + + .signature.signed { + background: #f0fdf4; + border: 1px solid #bbf7d0; + } + + .signature.unsigned { + background: #fffbeb; + border: 1px solid #fde68a; + } + + .signature.invalid { + background: #fef2f2; + border: 1px solid #fecaca; + } + + .badge { + font-size: 0.75rem; + font-weight: 600; + padding: 0.125rem 0.5rem; + border-radius: 1rem; + } + + .badge.verified { + background: #dcfce7; + colour: #166534; + } + + .badge.unsigned { + background: #fef3c7; + colour: #92400e; + } + + .badge.invalid { + background: #fee2e2; + colour: #991b1b; + } + + .actions { + margin-top: 1rem; + display: flex; + gap: 0.5rem; + } + + .verify-input { + flex: 1; + padding: 0.375rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.8125rem; + font-family: monospace; + } + + button { + padding: 0.375rem 1rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + cursor: pointer; + border: 1px solid #d1d5db; + background: #fff; + transition: background 0.15s; + } + + button:hover { + background: #f3f4f6; + } + + button.primary { + background: #6366f1; + colour: #fff; + border-colour: #6366f1; + } + + button.primary:hover { + background: #4f46e5; + } + + .empty { + text-align: center; + padding: 2rem; + colour: #9ca3af; + font-size: 0.875rem; + } + + .error { + colour: #dc2626; + padding: 0.75rem; + background: #fef2f2; + border-radius: 0.375rem; + font-size: 0.875rem; + } + + .loading { + text-align: center; + padding: 2rem; + colour: #6b7280; + } + `; + + @property({ attribute: 'api-url' }) apiUrl = ''; + @property() path = ''; + + @state() private manifest: ManifestData | null = null; + @state() private loading = true; + @state() private error = ''; + @state() private verifyKey = ''; + @state() private verifyResult: { valid: boolean } | null = null; + + private api!: ScmApi; + + connectedCallback() { + super.connectedCallback(); + this.api = new ScmApi(this.apiUrl); + this.loadManifest(); + } + + async loadManifest() { + this.loading = true; + this.error = ''; + try { + this.manifest = await this.api.manifest(); + } catch (e: any) { + this.error = e.message ?? 'Failed to load manifest'; + } finally { + this.loading = false; + } + } + + private async handleVerify() { + if (!this.verifyKey.trim()) return; + try { + this.verifyResult = await this.api.verify(this.verifyKey.trim()); + } catch (e: any) { + this.error = e.message ?? 'Verification failed'; + } + } + + private async handleSign() { + const key = prompt('Enter hex-encoded Ed25519 private key:'); + if (!key) return; + try { + await this.api.sign(key); + await this.loadManifest(); + } catch (e: any) { + this.error = e.message ?? 'Signing failed'; + } + } + + private renderPermissions(perms: ManifestData['permissions']) { + if (!perms) return nothing; + + const groups = [ + { label: 'Read', items: perms.read }, + { label: 'Write', items: perms.write }, + { label: 'Network', items: perms.net }, + { label: 'Run', items: perms.run }, + ].filter((g) => g.items && g.items.length > 0); + + if (groups.length === 0) return nothing; + + return html` +
+
Permissions
+
+ ${groups.map( + (g) => html` +
+
${g.label}
+ ${g.items!.map((item) => html`
${item}
`)} +
+ `, + )} +
+
+ `; + } + + render() { + if (this.loading) { + return html`
Loading manifest\u2026
`; + } + if (this.error && !this.manifest) { + return html`
${this.error}
`; + } + if (!this.manifest) { + return html`
No manifest found. Create a .core/manifest.yaml to get started.
`; + } + + const m = this.manifest; + const hasSig = !!m.sign; + + return html` + ${this.error ? html`
${this.error}
` : nothing} +
+
+
+

${m.name}

+ ${m.code} +
+ v${m.version} +
+ + ${m.description + ? html` +
+
Description
+
${m.description}
+
+ ` + : nothing} + ${m.layout + ? html` +
+
Layout
+
${m.layout}
+
+ ` + : nothing} + ${m.slots && Object.keys(m.slots).length > 0 + ? html` +
+
Slots
+
+ ${Object.entries(m.slots).map( + ([k, v]) => html` + ${k} + ${v} + `, + )} +
+
+ ` + : nothing} + + ${this.renderPermissions(m.permissions)} + ${m.modules && m.modules.length > 0 + ? html` +
+
Modules
+ ${m.modules.map((mod) => html`
${mod}
`)} +
+ ` + : nothing} + +
+ + ${hasSig ? (this.verifyResult ? (this.verifyResult.valid ? 'Verified' : 'Invalid') : 'Signed') : 'Unsigned'} + + ${hasSig ? html`Signature present` : html`No signature`} +
+ +
+ (this.verifyKey = (e.target as HTMLInputElement).value)} + /> + + +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'core-scm-manifest': ScmManifest; + } +} diff --git a/ui/src/scm-marketplace.ts b/ui/src/scm-marketplace.ts new file mode 100644 index 0000000..5270d21 --- /dev/null +++ b/ui/src/scm-marketplace.ts @@ -0,0 +1,331 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ScmApi } from './shared/api.js'; + +interface Module { + code: string; + name: string; + repo: string; + sign_key: string; + category: string; +} + +/** + * — Browse, search, and install providers + * from the SCM marketplace. + */ +@customElement('core-scm-marketplace') +export class ScmMarketplace extends LitElement { + static styles = css` + :host { + display: block; + font-family: system-ui, -apple-system, sans-serif; + } + + .toolbar { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 1rem; + } + + .search { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.875rem; + outline: none; + } + + .search:focus { + border-colour: #6366f1; + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); + } + + .categories { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + } + + .category-btn { + padding: 0.25rem 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 1rem; + background: #fff; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.15s; + } + + .category-btn:hover { + background: #f3f4f6; + } + + .category-btn.active { + background: #6366f1; + colour: #fff; + border-colour: #6366f1; + } + + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + margin-top: 1rem; + } + + .card { + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 1rem; + background: #fff; + transition: box-shadow 0.15s; + } + + .card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + } + + .card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; + } + + .card-name { + font-weight: 600; + font-size: 0.9375rem; + } + + .card-code { + font-size: 0.75rem; + colour: #6b7280; + font-family: monospace; + } + + .card-category { + font-size: 0.6875rem; + padding: 0.125rem 0.5rem; + background: #f3f4f6; + border-radius: 1rem; + colour: #6b7280; + } + + .card-actions { + margin-top: 0.75rem; + display: flex; + gap: 0.5rem; + } + + button.install { + padding: 0.375rem 1rem; + background: #6366f1; + colour: #fff; + border: none; + border-radius: 0.375rem; + font-size: 0.8125rem; + cursor: pointer; + transition: background 0.15s; + } + + button.install:hover { + background: #4f46e5; + } + + button.install:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + button.remove { + padding: 0.375rem 1rem; + background: #fff; + colour: #dc2626; + border: 1px solid #dc2626; + border-radius: 0.375rem; + font-size: 0.8125rem; + cursor: pointer; + } + + button.remove:hover { + background: #fef2f2; + } + + .empty { + text-align: center; + padding: 2rem; + colour: #9ca3af; + font-size: 0.875rem; + } + + .loading { + text-align: center; + padding: 2rem; + colour: #6b7280; + } + + .error { + colour: #dc2626; + padding: 0.75rem; + background: #fef2f2; + border-radius: 0.375rem; + font-size: 0.875rem; + } + `; + + @property({ attribute: 'api-url' }) apiUrl = ''; + @property() category = ''; + + @state() private modules: Module[] = []; + @state() private categories: string[] = []; + @state() private searchQuery = ''; + @state() private activeCategory = ''; + @state() private loading = true; + @state() private error = ''; + @state() private installing = new Set(); + + private api!: ScmApi; + + connectedCallback() { + super.connectedCallback(); + this.api = new ScmApi(this.apiUrl); + this.activeCategory = this.category; + this.loadModules(); + } + + async loadModules() { + this.loading = true; + this.error = ''; + try { + this.modules = await this.api.marketplace( + this.searchQuery || undefined, + this.activeCategory || undefined, + ); + // Extract unique categories + const cats = new Set(); + this.modules.forEach((m) => { + if (m.category) cats.add(m.category); + }); + this.categories = Array.from(cats).sort(); + } catch (e: any) { + this.error = e.message ?? 'Failed to load marketplace'; + } finally { + this.loading = false; + } + } + + private handleSearch(e: Event) { + this.searchQuery = (e.target as HTMLInputElement).value; + this.loadModules(); + } + + private handleCategoryClick(cat: string) { + this.activeCategory = this.activeCategory === cat ? '' : cat; + this.loadModules(); + } + + private async handleInstall(code: string) { + this.installing = new Set([...this.installing, code]); + try { + await this.api.install(code); + this.dispatchEvent( + new CustomEvent('scm-installed', { detail: { code }, bubbles: true }), + ); + } catch (e: any) { + this.error = e.message ?? 'Installation failed'; + } finally { + const next = new Set(this.installing); + next.delete(code); + this.installing = next; + } + } + + private async handleRemove(code: string) { + try { + await this.api.remove(code); + this.dispatchEvent( + new CustomEvent('scm-removed', { detail: { code }, bubbles: true }), + ); + } catch (e: any) { + this.error = e.message ?? 'Removal failed'; + } + } + + render() { + return html` +
+ +
+ + ${this.categories.length > 0 + ? html` +
+ ${this.categories.map( + (cat) => html` + + `, + )} +
+ ` + : nothing} + ${this.error ? html`
${this.error}
` : nothing} + ${this.loading + ? html`
Loading marketplace\u2026
` + : this.modules.length === 0 + ? html`
No providers found.
` + : html` +
+ ${this.modules.map( + (mod) => html` +
+
+
+
${mod.name}
+
${mod.code}
+
+ ${mod.category + ? html`${mod.category}` + : nothing} +
+
+ + +
+
+ `, + )} +
+ `} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'core-scm-marketplace': ScmMarketplace; + } +} diff --git a/ui/src/scm-panel.ts b/ui/src/scm-panel.ts new file mode 100644 index 0000000..9aa88d6 --- /dev/null +++ b/ui/src/scm-panel.ts @@ -0,0 +1,264 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { connectScmEvents, type ScmEvent } from './shared/events.js'; + +// Side-effect imports to register child elements +import './scm-marketplace.js'; +import './scm-installed.js'; +import './scm-manifest.js'; +import './scm-registry.js'; + +type TabId = 'marketplace' | 'installed' | 'manifest' | 'registry'; + +/** + * — Top-level HLCRF panel with tabs. + * + * Arranges child elements in HLCRF layout: + * - H: Title bar with refresh button + * - H-L: Navigation tabs + * - C: Active tab content (one of the child elements) + * - F: Status bar (connection state, last refresh) + */ +@customElement('core-scm-panel') +export class ScmPanel extends LitElement { + static styles = css` + :host { + display: flex; + flex-direction: column; + font-family: system-ui, -apple-system, sans-serif; + height: 100%; + background: #fafafa; + } + + /* H — Header */ + .header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: #fff; + border-bottom: 1px solid #e5e7eb; + } + + .title { + font-weight: 700; + font-size: 1rem; + colour: #111827; + } + + .refresh-btn { + padding: 0.375rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + background: #fff; + font-size: 0.8125rem; + cursor: pointer; + transition: background 0.15s; + } + + .refresh-btn:hover { + background: #f3f4f6; + } + + /* H-L — Tabs */ + .tabs { + display: flex; + gap: 0; + background: #fff; + border-bottom: 1px solid #e5e7eb; + padding: 0 1rem; + } + + .tab { + padding: 0.625rem 1rem; + font-size: 0.8125rem; + font-weight: 500; + colour: #6b7280; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; + background: none; + border-top: none; + border-left: none; + border-right: none; + } + + .tab:hover { + colour: #374151; + } + + .tab.active { + colour: #6366f1; + border-bottom-colour: #6366f1; + } + + /* C — Content */ + .content { + flex: 1; + padding: 1rem; + overflow-y: auto; + } + + /* F — Footer / Status bar */ + .footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: #fff; + border-top: 1px solid #e5e7eb; + font-size: 0.75rem; + colour: #9ca3af; + } + + .ws-status { + display: flex; + align-items: center; + gap: 0.375rem; + } + + .ws-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + } + + .ws-dot.connected { + background: #22c55e; + } + + .ws-dot.disconnected { + background: #ef4444; + } + + .ws-dot.idle { + background: #d1d5db; + } + `; + + @property({ attribute: 'api-url' }) apiUrl = ''; + @property({ attribute: 'ws-url' }) wsUrl = ''; + + @state() private activeTab: TabId = 'marketplace'; + @state() private wsConnected = false; + @state() private lastEvent = ''; + + private ws: WebSocket | null = null; + + connectedCallback() { + super.connectedCallback(); + if (this.wsUrl) { + this.connectWs(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private connectWs() { + this.ws = connectScmEvents(this.wsUrl, (event: ScmEvent) => { + this.lastEvent = event.channel ?? event.type ?? ''; + this.requestUpdate(); + }); + this.ws.onopen = () => { + this.wsConnected = true; + }; + this.ws.onclose = () => { + this.wsConnected = false; + }; + } + + private handleTabClick(tab: TabId) { + this.activeTab = tab; + } + + private handleRefresh() { + // Force re-render of active child by toggling a key + const content = this.shadowRoot?.querySelector('.content'); + if (content) { + const child = content.firstElementChild; + if (child && 'loadModules' in child) { + (child as any).loadModules(); + } else if (child && 'loadInstalled' in child) { + (child as any).loadInstalled(); + } else if (child && 'loadManifest' in child) { + (child as any).loadManifest(); + } else if (child && 'loadRegistry' in child) { + (child as any).loadRegistry(); + } + } + } + + private renderContent() { + switch (this.activeTab) { + case 'marketplace': + return html``; + case 'installed': + return html``; + case 'manifest': + return html``; + case 'registry': + return html``; + default: + return nothing; + } + } + + private tabs: { id: TabId; label: string }[] = [ + { id: 'marketplace', label: 'Marketplace' }, + { id: 'installed', label: 'Installed' }, + { id: 'manifest', label: 'Manifest' }, + { id: 'registry', label: 'Registry' }, + ]; + + render() { + const wsState = this.wsUrl + ? this.wsConnected + ? 'connected' + : 'disconnected' + : 'idle'; + + return html` +
+ SCM + +
+ +
+ ${this.tabs.map( + (tab) => html` + + `, + )} +
+ +
${this.renderContent()}
+ + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'core-scm-panel': ScmPanel; + } +} diff --git a/ui/src/scm-registry.ts b/ui/src/scm-registry.ts new file mode 100644 index 0000000..6eaac0c --- /dev/null +++ b/ui/src/scm-registry.ts @@ -0,0 +1,216 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ScmApi } from './shared/api.js'; + +interface RepoSummary { + name: string; + type: string; + description?: string; + depends_on?: string[]; + path?: string; + exists: boolean; +} + +/** + * — Show repos.yaml registry status. + * Read-only display of repository status. + */ +@customElement('core-scm-registry') +export class ScmRegistry extends LitElement { + static styles = css` + :host { + display: block; + font-family: system-ui, -apple-system, sans-serif; + } + + .list { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .repo { + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 0.75rem 1rem; + background: #fff; + display: flex; + justify-content: space-between; + align-items: center; + } + + .repo-info { + flex: 1; + } + + .repo-name { + font-weight: 600; + font-size: 0.9375rem; + font-family: monospace; + } + + .repo-desc { + font-size: 0.8125rem; + colour: #6b7280; + margin-top: 0.125rem; + } + + .repo-meta { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.25rem; + } + + .type-badge { + font-size: 0.6875rem; + padding: 0.0625rem 0.5rem; + border-radius: 1rem; + font-weight: 600; + } + + .type-badge.foundation { + background: #dbeafe; + colour: #1e40af; + } + + .type-badge.module { + background: #f3e8ff; + colour: #6b21a8; + } + + .type-badge.product { + background: #dcfce7; + colour: #166534; + } + + .type-badge.template { + background: #fef3c7; + colour: #92400e; + } + + .deps { + font-size: 0.75rem; + colour: #9ca3af; + } + + .status { + display: flex; + align-items: center; + gap: 0.375rem; + } + + .status-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + } + + .status-dot.present { + background: #22c55e; + } + + .status-dot.missing { + background: #ef4444; + } + + .status-label { + font-size: 0.75rem; + colour: #6b7280; + } + + .empty { + text-align: center; + padding: 2rem; + colour: #9ca3af; + font-size: 0.875rem; + } + + .loading { + text-align: center; + padding: 2rem; + colour: #6b7280; + } + + .error { + colour: #dc2626; + padding: 0.75rem; + background: #fef2f2; + border-radius: 0.375rem; + font-size: 0.875rem; + margin-bottom: 1rem; + } + `; + + @property({ attribute: 'api-url' }) apiUrl = ''; + + @state() private repos: RepoSummary[] = []; + @state() private loading = true; + @state() private error = ''; + + private api!: ScmApi; + + connectedCallback() { + super.connectedCallback(); + this.api = new ScmApi(this.apiUrl); + this.loadRegistry(); + } + + async loadRegistry() { + this.loading = true; + this.error = ''; + try { + this.repos = await this.api.registry(); + } catch (e: any) { + this.error = e.message ?? 'Failed to load registry'; + } finally { + this.loading = false; + } + } + + render() { + if (this.loading) { + return html`
Loading registry\u2026
`; + } + + return html` + ${this.error ? html`
${this.error}
` : nothing} + ${this.repos.length === 0 + ? html`
No repositories in registry.
` + : html` +
+ ${this.repos.map( + (repo) => html` +
+
+
${repo.name}
+ ${repo.description + ? html`
${repo.description}
` + : nothing} +
+ ${repo.type} + ${repo.depends_on && repo.depends_on.length > 0 + ? html`depends: ${repo.depends_on.join(', ')}` + : nothing} +
+
+
+ + ${repo.exists ? 'Present' : 'Missing'} +
+
+ `, + )} +
+ `} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'core-scm-registry': ScmRegistry; + } +} diff --git a/ui/src/shared/api.ts b/ui/src/shared/api.ts new file mode 100644 index 0000000..6a0034f --- /dev/null +++ b/ui/src/shared/api.ts @@ -0,0 +1,77 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +/** + * ScmApi provides a typed fetch wrapper for the /api/v1/scm/* endpoints. + */ +export class ScmApi { + constructor(private baseUrl: string = '') {} + + private get base(): string { + return `${this.baseUrl}/api/v1/scm`; + } + + private async request(path: string, opts?: RequestInit): Promise { + const res = await fetch(`${this.base}${path}`, opts); + const json = await res.json(); + if (!json.success) { + throw new Error(json.error?.message ?? 'Request failed'); + } + return json.data as T; + } + + marketplace(query?: string, category?: string) { + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (category) params.set('category', category); + const qs = params.toString(); + return this.request(`/marketplace${qs ? `?${qs}` : ''}`); + } + + marketplaceItem(code: string) { + return this.request(`/marketplace/${code}`); + } + + install(code: string) { + return this.request(`/marketplace/${code}/install`, { method: 'POST' }); + } + + remove(code: string) { + return this.request(`/marketplace/${code}`, { method: 'DELETE' }); + } + + installed() { + return this.request('/installed'); + } + + updateInstalled(code: string) { + return this.request(`/installed/${code}/update`, { method: 'POST' }); + } + + manifest() { + return this.request('/manifest'); + } + + verify(publicKey: string) { + return this.request('/manifest/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ public_key: publicKey }), + }); + } + + sign(privateKey: string) { + return this.request('/manifest/sign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ private_key: privateKey }), + }); + } + + permissions() { + return this.request('/manifest/permissions'); + } + + registry() { + return this.request('/registry'); + } +} diff --git a/ui/src/shared/events.ts b/ui/src/shared/events.ts new file mode 100644 index 0000000..a9a474c --- /dev/null +++ b/ui/src/shared/events.ts @@ -0,0 +1,32 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +export interface ScmEvent { + type: string; + channel?: string; + data?: any; + timestamp?: string; +} + +/** + * Connects to a WebSocket endpoint and dispatches SCM events to a handler. + * Returns the WebSocket instance for lifecycle management. + */ +export function connectScmEvents( + wsUrl: string, + handler: (event: ScmEvent) => void, +): WebSocket { + const ws = new WebSocket(wsUrl); + + ws.onmessage = (e: MessageEvent) => { + try { + const event: ScmEvent = JSON.parse(e.data); + if (event.type?.startsWith?.('scm.') || event.channel?.startsWith?.('scm.')) { + handler(event); + } + } catch { + // Ignore malformed messages + } + }; + + return ws; +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..e7540a6 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "declaration": true, + "sourceMap": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..312f000 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'CoreScm', + fileName: 'core-scm', + formats: ['es'], + }, + outDir: resolve(__dirname, '../pkg/api/ui/dist'), + emptyOutDir: true, + rollupOptions: { + output: { + entryFileNames: 'core-scm.js', + }, + }, + }, +});