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 <virgil@lethean.io>
This commit is contained in:
parent
e7db4b163f
commit
81deee8598
17 changed files with 2427 additions and 0 deletions
3
go.mod
3
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
|
||||
|
|
|
|||
11
pkg/api/embed.go
Normal file
11
pkg/api/embed.go
Normal file
|
|
@ -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
|
||||
450
pkg/api/provider.go
Normal file
450
pkg/api/provider.go
Normal file
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
237
pkg/api/provider_test.go
Normal file
237
pkg/api/provider_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
0
pkg/api/ui/dist/.gitkeep
vendored
Normal file
0
pkg/api/ui/dist/.gitkeep
vendored
Normal file
79
ui/index.html
Normal file
79
ui/index.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Core SCM — Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #f3f4f6;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
colour: #111827;
|
||||
}
|
||||
|
||||
.demo-panel {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
height: 80vh;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.125rem;
|
||||
margin: 2rem auto 1rem;
|
||||
max-width: 960px;
|
||||
colour: #374151;
|
||||
}
|
||||
|
||||
.standalone {
|
||||
max-width: 960px;
|
||||
margin: 0 auto 2rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="./src/index.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Core SCM — Custom Element Demo</h1>
|
||||
|
||||
<div class="demo-panel">
|
||||
<core-scm-panel api-url=""></core-scm-panel>
|
||||
</div>
|
||||
|
||||
<h2>Standalone Elements</h2>
|
||||
|
||||
<div class="standalone">
|
||||
<core-scm-marketplace api-url=""></core-scm-marketplace>
|
||||
</div>
|
||||
|
||||
<div class="standalone">
|
||||
<core-scm-manifest api-url=""></core-scm-manifest>
|
||||
</div>
|
||||
|
||||
<div class="standalone">
|
||||
<core-scm-installed api-url=""></core-scm-installed>
|
||||
</div>
|
||||
|
||||
<div class="standalone">
|
||||
<core-scm-registry api-url=""></core-scm-registry>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
18
ui/package.json
Normal file
18
ui/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
11
ui/src/index.ts
Normal file
11
ui/src/index.ts
Normal file
|
|
@ -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';
|
||||
250
ui/src/scm-installed.ts
Normal file
250
ui/src/scm-installed.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* <core-scm-installed> — 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<string>();
|
||||
|
||||
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`<div class="loading">Loading installed providers\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${this.error ? html`<div class="error">${this.error}</div>` : nothing}
|
||||
${this.modules.length === 0
|
||||
? html`<div class="empty">No providers installed.</div>`
|
||||
: html`
|
||||
<div class="list">
|
||||
${this.modules.map(
|
||||
(mod) => html`
|
||||
<div class="item">
|
||||
<div class="item-info">
|
||||
<div class="item-name">${mod.name}</div>
|
||||
<div class="item-meta">
|
||||
<span class="item-code">${mod.code}</span>
|
||||
<span>v${mod.version}</span>
|
||||
<span>Installed ${this.formatDate(mod.installed_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button
|
||||
class="update"
|
||||
?disabled=${this.updating.has(mod.code)}
|
||||
@click=${() => this.handleUpdate(mod.code)}
|
||||
>
|
||||
${this.updating.has(mod.code) ? 'Updating\u2026' : 'Update'}
|
||||
</button>
|
||||
<button class="remove" @click=${() => this.handleRemove(mod.code)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-scm-installed': ScmInstalled;
|
||||
}
|
||||
}
|
||||
411
ui/src/scm-manifest.ts
Normal file
411
ui/src/scm-manifest.ts
Normal file
|
|
@ -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<string, string>;
|
||||
permissions?: {
|
||||
read?: string[];
|
||||
write?: string[];
|
||||
net?: string[];
|
||||
run?: string[];
|
||||
};
|
||||
modules?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* <core-scm-manifest> — 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`
|
||||
<div class="field">
|
||||
<div class="field-label">Permissions</div>
|
||||
<div class="permissions-grid">
|
||||
${groups.map(
|
||||
(g) => html`
|
||||
<div class="perm-group">
|
||||
<div class="perm-group-label">${g.label}</div>
|
||||
${g.items!.map((item) => html`<div class="perm-item">${item}</div>`)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
return html`<div class="loading">Loading manifest\u2026</div>`;
|
||||
}
|
||||
if (this.error && !this.manifest) {
|
||||
return html`<div class="error">${this.error}</div>`;
|
||||
}
|
||||
if (!this.manifest) {
|
||||
return html`<div class="empty">No manifest found. Create a .core/manifest.yaml to get started.</div>`;
|
||||
}
|
||||
|
||||
const m = this.manifest;
|
||||
const hasSig = !!m.sign;
|
||||
|
||||
return html`
|
||||
${this.error ? html`<div class="error">${this.error}</div>` : nothing}
|
||||
<div class="manifest">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>${m.name}</h3>
|
||||
<span class="code">${m.code}</span>
|
||||
</div>
|
||||
<span class="version">v${m.version}</span>
|
||||
</div>
|
||||
|
||||
${m.description
|
||||
? html`
|
||||
<div class="field">
|
||||
<div class="field-label">Description</div>
|
||||
<div class="field-value">${m.description}</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${m.layout
|
||||
? html`
|
||||
<div class="field">
|
||||
<div class="field-label">Layout</div>
|
||||
<div class="field-value code">${m.layout}</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${m.slots && Object.keys(m.slots).length > 0
|
||||
? html`
|
||||
<div class="field">
|
||||
<div class="field-label">Slots</div>
|
||||
<div class="slots">
|
||||
${Object.entries(m.slots).map(
|
||||
([k, v]) => html`
|
||||
<span class="slot-key">${k}</span>
|
||||
<span class="slot-value">${v}</span>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${this.renderPermissions(m.permissions)}
|
||||
${m.modules && m.modules.length > 0
|
||||
? html`
|
||||
<div class="field">
|
||||
<div class="field-label">Modules</div>
|
||||
${m.modules.map((mod) => html`<div class="code" style="margin-bottom:0.25rem">${mod}</div>`)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div class="signature ${hasSig ? (this.verifyResult ? (this.verifyResult.valid ? 'signed' : 'invalid') : 'signed') : 'unsigned'}">
|
||||
<span class="badge ${hasSig ? (this.verifyResult ? (this.verifyResult.valid ? 'verified' : 'invalid') : 'unsigned') : 'unsigned'}">
|
||||
${hasSig ? (this.verifyResult ? (this.verifyResult.valid ? 'Verified' : 'Invalid') : 'Signed') : 'Unsigned'}
|
||||
</span>
|
||||
${hasSig ? html`<span>Signature present</span>` : html`<span>No signature</span>`}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input
|
||||
type="text"
|
||||
class="verify-input"
|
||||
placeholder="Public key (hex)\u2026"
|
||||
.value=${this.verifyKey}
|
||||
@input=${(e: Event) => (this.verifyKey = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button @click=${this.handleVerify}>Verify</button>
|
||||
<button class="primary" @click=${this.handleSign}>Sign</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-scm-manifest': ScmManifest;
|
||||
}
|
||||
}
|
||||
331
ui/src/scm-marketplace.ts
Normal file
331
ui/src/scm-marketplace.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* <core-scm-marketplace> — 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<string>();
|
||||
|
||||
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<string>();
|
||||
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`
|
||||
<div class="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
class="search"
|
||||
placeholder="Search providers\u2026"
|
||||
.value=${this.searchQuery}
|
||||
@input=${this.handleSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
${this.categories.length > 0
|
||||
? html`
|
||||
<div class="categories">
|
||||
${this.categories.map(
|
||||
(cat) => html`
|
||||
<button
|
||||
class="category-btn ${this.activeCategory === cat ? 'active' : ''}"
|
||||
@click=${() => this.handleCategoryClick(cat)}
|
||||
>
|
||||
${cat}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${this.error ? html`<div class="error">${this.error}</div>` : nothing}
|
||||
${this.loading
|
||||
? html`<div class="loading">Loading marketplace\u2026</div>`
|
||||
: this.modules.length === 0
|
||||
? html`<div class="empty">No providers found.</div>`
|
||||
: html`
|
||||
<div class="grid">
|
||||
${this.modules.map(
|
||||
(mod) => html`
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-name">${mod.name}</div>
|
||||
<div class="card-code">${mod.code}</div>
|
||||
</div>
|
||||
${mod.category
|
||||
? html`<span class="card-category">${mod.category}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="install"
|
||||
?disabled=${this.installing.has(mod.code)}
|
||||
@click=${() => this.handleInstall(mod.code)}
|
||||
>
|
||||
${this.installing.has(mod.code) ? 'Installing\u2026' : 'Install'}
|
||||
</button>
|
||||
<button class="remove" @click=${() => this.handleRemove(mod.code)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-scm-marketplace': ScmMarketplace;
|
||||
}
|
||||
}
|
||||
264
ui/src/scm-panel.ts
Normal file
264
ui/src/scm-panel.ts
Normal file
|
|
@ -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';
|
||||
|
||||
/**
|
||||
* <core-scm-panel> — 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`<core-scm-marketplace api-url=${this.apiUrl}></core-scm-marketplace>`;
|
||||
case 'installed':
|
||||
return html`<core-scm-installed api-url=${this.apiUrl}></core-scm-installed>`;
|
||||
case 'manifest':
|
||||
return html`<core-scm-manifest api-url=${this.apiUrl}></core-scm-manifest>`;
|
||||
case 'registry':
|
||||
return html`<core-scm-registry api-url=${this.apiUrl}></core-scm-registry>`;
|
||||
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`
|
||||
<div class="header">
|
||||
<span class="title">SCM</span>
|
||||
<button class="refresh-btn" @click=${this.handleRefresh}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
${this.tabs.map(
|
||||
(tab) => html`
|
||||
<button
|
||||
class="tab ${this.activeTab === tab.id ? 'active' : ''}"
|
||||
@click=${() => this.handleTabClick(tab.id)}
|
||||
>
|
||||
${tab.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="content">${this.renderContent()}</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="ws-status">
|
||||
<span class="ws-dot ${wsState}"></span>
|
||||
<span>${wsState === 'connected' ? 'Connected' : wsState === 'disconnected' ? 'Disconnected' : 'No WebSocket'}</span>
|
||||
</div>
|
||||
${this.lastEvent ? html`<span>Last: ${this.lastEvent}</span>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-scm-panel': ScmPanel;
|
||||
}
|
||||
}
|
||||
216
ui/src/scm-registry.ts
Normal file
216
ui/src/scm-registry.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* <core-scm-registry> — 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`<div class="loading">Loading registry\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${this.error ? html`<div class="error">${this.error}</div>` : nothing}
|
||||
${this.repos.length === 0
|
||||
? html`<div class="empty">No repositories in registry.</div>`
|
||||
: html`
|
||||
<div class="list">
|
||||
${this.repos.map(
|
||||
(repo) => html`
|
||||
<div class="repo">
|
||||
<div class="repo-info">
|
||||
<div class="repo-name">${repo.name}</div>
|
||||
${repo.description
|
||||
? html`<div class="repo-desc">${repo.description}</div>`
|
||||
: nothing}
|
||||
<div class="repo-meta">
|
||||
<span class="type-badge ${repo.type}">${repo.type}</span>
|
||||
${repo.depends_on && repo.depends_on.length > 0
|
||||
? html`<span class="deps">depends: ${repo.depends_on.join(', ')}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="status-dot ${repo.exists ? 'present' : 'missing'}"></span>
|
||||
<span class="status-label">${repo.exists ? 'Present' : 'Missing'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-scm-registry': ScmRegistry;
|
||||
}
|
||||
}
|
||||
77
ui/src/shared/api.ts
Normal file
77
ui/src/shared/api.ts
Normal file
|
|
@ -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<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
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<any[]>(`/marketplace${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
marketplaceItem(code: string) {
|
||||
return this.request<any>(`/marketplace/${code}`);
|
||||
}
|
||||
|
||||
install(code: string) {
|
||||
return this.request<any>(`/marketplace/${code}/install`, { method: 'POST' });
|
||||
}
|
||||
|
||||
remove(code: string) {
|
||||
return this.request<any>(`/marketplace/${code}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
installed() {
|
||||
return this.request<any[]>('/installed');
|
||||
}
|
||||
|
||||
updateInstalled(code: string) {
|
||||
return this.request<any>(`/installed/${code}/update`, { method: 'POST' });
|
||||
}
|
||||
|
||||
manifest() {
|
||||
return this.request<any>('/manifest');
|
||||
}
|
||||
|
||||
verify(publicKey: string) {
|
||||
return this.request<any>('/manifest/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ public_key: publicKey }),
|
||||
});
|
||||
}
|
||||
|
||||
sign(privateKey: string) {
|
||||
return this.request<any>('/manifest/sign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ private_key: privateKey }),
|
||||
});
|
||||
}
|
||||
|
||||
permissions() {
|
||||
return this.request<any>('/manifest/permissions');
|
||||
}
|
||||
|
||||
registry() {
|
||||
return this.request<any[]>('/registry');
|
||||
}
|
||||
}
|
||||
32
ui/src/shared/events.ts
Normal file
32
ui/src/shared/events.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
17
ui/tsconfig.json
Normal file
17
ui/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
20
ui/vite.config.ts
Normal file
20
ui/vite.config.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue