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

Core SCM — Custom Element Demo

+ +
+ +
+ +

Standalone Elements

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

${m.name}

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