feat(api): add SCM service provider with Lit custom elements
Some checks failed
Security Scan / security (push) Failing after 7s
Test / test (push) Failing after 1m16s

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:
Snider 2026-03-14 10:42:37 +00:00
parent e7db4b163f
commit 81deee8598
17 changed files with 2427 additions and 0 deletions

3
go.mod
View file

@ -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
View 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
View 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
View 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
View file

79
ui/index.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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',
},
},
},
});