feat(api): add build service provider with Lit custom elements

Wraps the existing build, release, and SDK subsystems as REST endpoints
via a BuildProvider that implements Provider, Streamable, Describable,
and Renderable. Includes 6 Lit custom elements for GUI display within
the Core IDE, following the same pattern established in go-scm.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-14 12:29:45 +00:00
parent d6086a92f4
commit 77e4c06599
20 changed files with 5832 additions and 0 deletions

View file

@ -0,0 +1,115 @@
# Build UI Service Provider + Lit Custom Elements
**Date**: 14 March 2026
**Module**: `forge.lthn.ai/core/go-build`
**Pattern**: Follows `go-scm/pkg/api/` provider + `go-scm/ui/` Lit elements
## Overview
Add a service provider (`BuildProvider`) that exposes the existing build, release, and SDK subsystems as REST endpoints, plus a set of Lit custom elements for GUI display within the Core IDE.
The provider wraps existing functions — no business logic is reimplemented.
## Architecture
```
go-build/
├── pkg/api/
│ ├── provider.go # BuildProvider (Provider + Streamable + Describable + Renderable)
│ ├── embed.go # //go:embed for UI assets
│ └── ui/dist/ # Built JS bundle (populated by npm run build)
├── pkg/api/
│ └── provider_test.go # Identity + endpoint tests
└── ui/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── index.html # Demo page
└── src/
├── index.ts # Bundle entry
├── build-panel.ts # <core-build-panel> — tabs container
├── build-config.ts # <core-build-config> — .core/build.yaml + discovery
├── build-artifacts.ts # <core-build-artifacts> — dist/ contents
├── build-release.ts # <core-build-release> — version, changelog, publish
├── build-sdk.ts # <core-build-sdk> — OpenAPI diff, SDK generation
└── shared/
├── api.ts # Typed fetch wrapper for /api/v1/build/*
└── events.ts # WS event connection for build.* channels
```
## REST Endpoints
All under `/api/v1/build`:
| Method | Path | Handler | Wraps |
|--------|---------------------|------------------|------------------------------------|
| GET | /config | getConfig | `build.LoadConfig(io.Local, cwd)` |
| GET | /discover | discoverProject | `build.Discover(io.Local, cwd)` |
| POST | /build | triggerBuild | Full build pipeline |
| GET | /artifacts | listArtifacts | Scan `dist/` directory |
| GET | /release/version | getVersion | `release.DetermineVersion(cwd)` |
| GET | /release/changelog | getChangelog | `release.Generate(cwd, "", "")` |
| POST | /release | triggerRelease | `release.Run()` or `Publish()` |
| GET | /sdk/diff | getSdkDiff | `sdk.Diff(base, revision)` |
| POST | /sdk/generate | generateSdk | `sdk.SDK.Generate(ctx)` |
## WS Channels
- `build.started` — Build commenced (includes project type, targets)
- `build.complete` — Build finished (includes artifact list)
- `build.failed` — Build error (includes error message)
- `release.started` — Release pipeline started
- `release.complete` — Release published
- `sdk.generated` — SDK generation complete
## Custom Elements
### `<core-build-panel>` (build-panel.ts)
Top-level tabbed container with tabs: Config, Build, Release, SDK.
Follows HLCRF layout from go-scm.
### `<core-build-config>` (build-config.ts)
- Displays `.core/build.yaml` fields (project name, binary, targets, flags, signing)
- Shows detected project type from discovery
- Read-only display of current configuration
### `<core-build-artifacts>` (build-artifacts.ts)
- Lists files in `dist/` with size, checksum status
- "Build" button with confirmation dialogue (POST /build is destructive)
- Real-time progress via WS events
### `<core-build-release>` (build-release.ts)
- Current version from git tags
- Changelog preview (rendered markdown)
- Publisher targets from `.core/release.yaml`
- "Release" button with confirmation dialogue
### `<core-build-sdk>` (build-sdk.ts)
- OpenAPI diff results (breaking/non-breaking changes)
- SDK generation controls (language selection)
- Generation status
## Dependencies Added to go.mod
```
forge.lthn.ai/core/api v0.1.0
forge.lthn.ai/core/go-ws v0.1.0
github.com/gin-gonic/gin v1.11.0
```
## Safety Considerations
- POST /build and POST /release are destructive operations
- UI elements include confirmation dialogues before triggering
- Provider accepts a `projectDir` parameter (defaults to CWD)
- Build/release operations run synchronously; WS events provide progress
## Implementation Tasks
1. Create `pkg/api/provider.go` — BuildProvider struct + all handlers
2. Create `pkg/api/provider_test.go` — Identity + config/discover tests
3. Create `pkg/api/embed.go` — //go:embed directive
4. Create `ui/` directory with full Lit element suite
5. Update `go.mod` — add api, go-ws, gin dependencies
6. Build UI (`cd ui && npm install && npm run build`)
7. Verify Go compilation (`go build ./...`)

3
go.mod
View file

@ -3,12 +3,15 @@ module forge.lthn.ai/core/go-build
go 1.26.0
require (
forge.lthn.ai/core/api v0.1.0
forge.lthn.ai/core/cli 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/Snider/Borg v0.2.0
github.com/getkin/kin-openapi v0.133.0
github.com/gin-gonic/gin v1.11.0
github.com/leaanthony/debme v1.2.1
github.com/leaanthony/gosod v1.0.4
github.com/oasdiff/oasdiff v1.11.10

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-build.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

582
pkg/api/provider.go Normal file
View file

@ -0,0 +1,582 @@
// SPDX-Licence-Identifier: EUPL-1.2
// Package api provides a service provider that wraps go-build's build,
// release, and SDK subsystems as REST endpoints with WebSocket event
// streaming.
package api
import (
"net/http"
"os"
"path/filepath"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"forge.lthn.ai/core/go-build/pkg/build"
"forge.lthn.ai/core/go-build/pkg/build/builders"
"forge.lthn.ai/core/go-build/pkg/release"
"forge.lthn.ai/core/go-build/pkg/sdk"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-ws"
"github.com/gin-gonic/gin"
)
// BuildProvider wraps go-build's build, release, and SDK operations as a
// service provider. It implements Provider, Streamable, Describable, and
// Renderable.
type BuildProvider struct {
hub *ws.Hub
projectDir string
medium io.Medium
}
// compile-time interface checks
var (
_ provider.Provider = (*BuildProvider)(nil)
_ provider.Streamable = (*BuildProvider)(nil)
_ provider.Describable = (*BuildProvider)(nil)
_ provider.Renderable = (*BuildProvider)(nil)
)
// NewProvider creates a BuildProvider for the given project directory.
// If projectDir is empty, the current working directory is used.
// The WS hub is used to emit real-time build events; pass nil if not available.
func NewProvider(projectDir string, hub *ws.Hub) *BuildProvider {
if projectDir == "" {
projectDir = "."
}
return &BuildProvider{
hub: hub,
projectDir: projectDir,
medium: io.Local,
}
}
// Name implements api.RouteGroup.
func (p *BuildProvider) Name() string { return "build" }
// BasePath implements api.RouteGroup.
func (p *BuildProvider) BasePath() string { return "/api/v1/build" }
// Element implements provider.Renderable.
func (p *BuildProvider) Element() provider.ElementSpec {
return provider.ElementSpec{
Tag: "core-build-panel",
Source: "/assets/core-build.js",
}
}
// Channels implements provider.Streamable.
func (p *BuildProvider) Channels() []string {
return []string{
"build.started",
"build.complete",
"build.failed",
"release.started",
"release.complete",
"sdk.generated",
}
}
// RegisterRoutes implements api.RouteGroup.
func (p *BuildProvider) RegisterRoutes(rg *gin.RouterGroup) {
// Build
rg.GET("/config", p.getConfig)
rg.GET("/discover", p.discoverProject)
rg.POST("/build", p.triggerBuild)
rg.GET("/artifacts", p.listArtifacts)
// Release
rg.GET("/release/version", p.getVersion)
rg.GET("/release/changelog", p.getChangelog)
rg.POST("/release", p.triggerRelease)
// SDK
rg.GET("/sdk/diff", p.getSdkDiff)
rg.POST("/sdk/generate", p.generateSdk)
}
// Describe implements api.DescribableGroup.
func (p *BuildProvider) Describe() []api.RouteDescription {
return []api.RouteDescription{
{
Method: "GET",
Path: "/config",
Summary: "Read build configuration",
Description: "Loads and returns the .core/build.yaml from the project directory.",
Tags: []string{"build", "config"},
},
{
Method: "GET",
Path: "/discover",
Summary: "Detect project type",
Description: "Scans the project directory for marker files and returns detected project types.",
Tags: []string{"build", "discovery"},
},
{
Method: "POST",
Path: "/build",
Summary: "Trigger a build",
Description: "Runs the full build pipeline for the project, producing artifacts in dist/.",
Tags: []string{"build"},
},
{
Method: "GET",
Path: "/artifacts",
Summary: "List build artifacts",
Description: "Returns the contents of the dist/ directory with file sizes.",
Tags: []string{"build", "artifacts"},
},
{
Method: "GET",
Path: "/release/version",
Summary: "Get current version",
Description: "Determines the current version from git tags.",
Tags: []string{"release", "version"},
},
{
Method: "GET",
Path: "/release/changelog",
Summary: "Generate changelog",
Description: "Generates a conventional-commit changelog from git history.",
Tags: []string{"release", "changelog"},
},
{
Method: "POST",
Path: "/release",
Summary: "Trigger release pipeline",
Description: "Publishes pre-built artifacts from dist/ to configured targets.",
Tags: []string{"release"},
},
{
Method: "GET",
Path: "/sdk/diff",
Summary: "OpenAPI breaking change diff",
Description: "Compares two OpenAPI specs and reports breaking changes. Requires base and revision query parameters.",
Tags: []string{"sdk", "diff"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"base": map[string]any{"type": "string", "description": "Path to the base OpenAPI spec"},
"revision": map[string]any{"type": "string", "description": "Path to the revision OpenAPI spec"},
},
},
},
{
Method: "POST",
Path: "/sdk/generate",
Summary: "Generate SDK",
Description: "Generates SDK client libraries for configured languages from the OpenAPI spec.",
Tags: []string{"sdk"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"language": map[string]any{"type": "string", "description": "Target language (typescript, python, go, php). Omit for all."},
},
},
},
}
}
// resolveDir returns the absolute project directory.
func (p *BuildProvider) resolveDir() (string, error) {
return filepath.Abs(p.projectDir)
}
// -- Build Handlers -----------------------------------------------------------
func (p *BuildProvider) getConfig(c *gin.Context) {
dir, err := p.resolveDir()
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("resolve_failed", err.Error()))
return
}
cfg, err := build.LoadConfig(p.medium, dir)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("config_load_failed", err.Error()))
return
}
hasConfig := build.ConfigExists(p.medium, dir)
c.JSON(http.StatusOK, api.OK(map[string]any{
"config": cfg,
"has_config": hasConfig,
"path": build.ConfigPath(dir),
}))
}
func (p *BuildProvider) discoverProject(c *gin.Context) {
dir, err := p.resolveDir()
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("resolve_failed", err.Error()))
return
}
types, err := build.Discover(p.medium, dir)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("discover_failed", err.Error()))
return
}
// Convert to string slice for JSON
typeStrings := make([]string, len(types))
for i, t := range types {
typeStrings[i] = string(t)
}
primary := ""
if len(types) > 0 {
primary = string(types[0])
}
c.JSON(http.StatusOK, api.OK(map[string]any{
"types": typeStrings,
"primary": primary,
"dir": dir,
}))
}
func (p *BuildProvider) triggerBuild(c *gin.Context) {
dir, err := p.resolveDir()
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("resolve_failed", err.Error()))
return
}
// Load build config
cfg, err := build.LoadConfig(p.medium, dir)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("config_load_failed", err.Error()))
return
}
// Detect project type
projectType, err := build.PrimaryType(p.medium, dir)
if err != nil || projectType == "" {
c.JSON(http.StatusBadRequest, api.Fail("no_project", "no buildable project detected"))
return
}
// Get builder
builder, err := getBuilder(projectType)
if err != nil {
c.JSON(http.StatusBadRequest, api.Fail("unsupported_type", err.Error()))
return
}
// Determine version
version, verr := release.DetermineVersion(dir)
if verr != nil {
version = "dev"
}
// Build name
binaryName := cfg.Project.Binary
if binaryName == "" {
binaryName = cfg.Project.Name
}
if binaryName == "" {
binaryName = filepath.Base(dir)
}
outputDir := filepath.Join(dir, "dist")
buildConfig := &build.Config{
FS: p.medium,
ProjectDir: dir,
OutputDir: outputDir,
Name: binaryName,
Version: version,
LDFlags: cfg.Build.LDFlags,
CGO: cfg.Build.CGO,
}
targets := cfg.ToTargets()
p.emitEvent("build.started", map[string]any{
"project_type": string(projectType),
"targets": targets,
"version": version,
})
artifacts, err := builder.Build(c.Request.Context(), buildConfig, targets)
if err != nil {
p.emitEvent("build.failed", map[string]any{"error": err.Error()})
c.JSON(http.StatusInternalServerError, api.Fail("build_failed", err.Error()))
return
}
// Archive and checksum
archived, err := build.ArchiveAll(p.medium, artifacts)
if err != nil {
p.emitEvent("build.failed", map[string]any{"error": err.Error()})
c.JSON(http.StatusInternalServerError, api.Fail("archive_failed", err.Error()))
return
}
checksummed, err := build.ChecksumAll(p.medium, archived)
if err != nil {
p.emitEvent("build.failed", map[string]any{"error": err.Error()})
c.JSON(http.StatusInternalServerError, api.Fail("checksum_failed", err.Error()))
return
}
p.emitEvent("build.complete", map[string]any{
"artifact_count": len(checksummed),
"version": version,
})
c.JSON(http.StatusOK, api.OK(map[string]any{
"artifacts": checksummed,
"version": version,
}))
}
// artifactInfo holds JSON-friendly metadata about a dist/ file.
type artifactInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
}
func (p *BuildProvider) listArtifacts(c *gin.Context) {
dir, err := p.resolveDir()
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("resolve_failed", err.Error()))
return
}
distDir := filepath.Join(dir, "dist")
if !p.medium.IsDir(distDir) {
c.JSON(http.StatusOK, api.OK(map[string]any{
"artifacts": []artifactInfo{},
"exists": false,
}))
return
}
entries, err := p.medium.List(distDir)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("list_failed", err.Error()))
return
}
var artifacts []artifactInfo
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
artifacts = append(artifacts, artifactInfo{
Name: entry.Name(),
Path: filepath.Join(distDir, entry.Name()),
Size: info.Size(),
})
}
if artifacts == nil {
artifacts = []artifactInfo{}
}
c.JSON(http.StatusOK, api.OK(map[string]any{
"artifacts": artifacts,
"exists": true,
}))
}
// -- Release Handlers ---------------------------------------------------------
func (p *BuildProvider) getVersion(c *gin.Context) {
dir, err := p.resolveDir()
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("resolve_failed", err.Error()))
return
}
version, err := release.DetermineVersion(dir)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("version_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(map[string]any{
"version": version,
}))
}
func (p *BuildProvider) getChangelog(c *gin.Context) {
dir, err := p.resolveDir()
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("resolve_failed", err.Error()))
return
}
// Optional query params for ref range
fromRef := c.Query("from")
toRef := c.Query("to")
changelog, err := release.Generate(dir, fromRef, toRef)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("changelog_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(map[string]any{
"changelog": changelog,
}))
}
func (p *BuildProvider) triggerRelease(c *gin.Context) {
dir, err := p.resolveDir()
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("resolve_failed", err.Error()))
return
}
cfg, err := release.LoadConfig(dir)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("config_load_failed", err.Error()))
return
}
// Parse optional dry_run parameter
dryRun := c.Query("dry_run") == "true"
p.emitEvent("release.started", map[string]any{
"dry_run": dryRun,
})
rel, err := release.Publish(c.Request.Context(), cfg, dryRun)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("release_failed", err.Error()))
return
}
p.emitEvent("release.complete", map[string]any{
"version": rel.Version,
"artifact_count": len(rel.Artifacts),
"dry_run": dryRun,
})
c.JSON(http.StatusOK, api.OK(map[string]any{
"version": rel.Version,
"artifacts": rel.Artifacts,
"changelog": rel.Changelog,
"dry_run": dryRun,
}))
}
// -- SDK Handlers -------------------------------------------------------------
func (p *BuildProvider) getSdkDiff(c *gin.Context) {
basePath := c.Query("base")
revisionPath := c.Query("revision")
if basePath == "" || revisionPath == "" {
c.JSON(http.StatusBadRequest, api.Fail("missing_params", "base and revision query parameters are required"))
return
}
result, err := sdk.Diff(basePath, revisionPath)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("diff_failed", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(result))
}
type sdkGenerateRequest struct {
Language string `json:"language"`
}
func (p *BuildProvider) generateSdk(c *gin.Context) {
dir, err := p.resolveDir()
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("resolve_failed", err.Error()))
return
}
var req sdkGenerateRequest
if err := c.ShouldBindJSON(&req); err != nil {
// No body is fine — generate all languages
req.Language = ""
}
// Load SDK config from release config
relCfg, err := release.LoadConfig(dir)
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("config_load_failed", err.Error()))
return
}
var sdkCfg *sdk.Config
if relCfg.SDK != nil {
sdkCfg = &sdk.Config{
Spec: relCfg.SDK.Spec,
Languages: relCfg.SDK.Languages,
Output: relCfg.SDK.Output,
Package: sdk.PackageConfig{
Name: relCfg.SDK.Package.Name,
Version: relCfg.SDK.Package.Version,
},
Diff: sdk.DiffConfig{
Enabled: relCfg.SDK.Diff.Enabled,
FailOnBreaking: relCfg.SDK.Diff.FailOnBreaking,
},
}
}
s := sdk.New(dir, sdkCfg)
ctx := c.Request.Context()
if req.Language != "" {
err = s.GenerateLanguage(ctx, req.Language)
} else {
err = s.Generate(ctx)
}
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("sdk_generate_failed", err.Error()))
return
}
p.emitEvent("sdk.generated", map[string]any{
"language": req.Language,
})
c.JSON(http.StatusOK, api.OK(map[string]any{
"generated": true,
"language": req.Language,
}))
}
// -- Internal Helpers ---------------------------------------------------------
// getBuilder returns the appropriate builder for the project type.
func getBuilder(projectType build.ProjectType) (build.Builder, error) {
switch projectType {
case build.ProjectTypeWails:
return builders.NewWailsBuilder(), nil
case build.ProjectTypeGo:
return builders.NewGoBuilder(), nil
default:
return nil, os.ErrNotExist
}
}
// emitEvent sends a WS event if the hub is available.
func (p *BuildProvider) emitEvent(channel string, data any) {
if p.hub == nil {
return
}
_ = p.hub.SendToChannel(channel, ws.Message{
Type: ws.TypeEvent,
Data: data,
})
}

77
pkg/api/provider_test.go Normal file
View file

@ -0,0 +1,77 @@
// SPDX-Licence-Identifier: EUPL-1.2
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildProvider_Good_Identity(t *testing.T) {
p := NewProvider(".", nil)
assert.Equal(t, "build", p.Name())
assert.Equal(t, "/api/v1/build", p.BasePath())
}
func TestBuildProvider_Good_Element(t *testing.T) {
p := NewProvider(".", nil)
el := p.Element()
assert.Equal(t, "core-build-panel", el.Tag)
assert.Equal(t, "/assets/core-build.js", el.Source)
}
func TestBuildProvider_Good_Channels(t *testing.T) {
p := NewProvider(".", nil)
channels := p.Channels()
assert.Contains(t, channels, "build.started")
assert.Contains(t, channels, "build.complete")
assert.Contains(t, channels, "build.failed")
assert.Contains(t, channels, "release.started")
assert.Contains(t, channels, "release.complete")
assert.Contains(t, channels, "sdk.generated")
assert.Len(t, channels, 6)
}
func TestBuildProvider_Good_Describe(t *testing.T) {
p := NewProvider(".", nil)
routes := p.Describe()
// Should have 9 endpoint descriptions
assert.Len(t, routes, 9)
// Verify key routes exist
paths := make(map[string]string)
for _, r := range routes {
paths[r.Path] = r.Method
}
assert.Equal(t, "GET", paths["/config"])
assert.Equal(t, "GET", paths["/discover"])
assert.Equal(t, "POST", paths["/build"])
assert.Equal(t, "GET", paths["/artifacts"])
assert.Equal(t, "GET", paths["/release/version"])
assert.Equal(t, "GET", paths["/release/changelog"])
assert.Equal(t, "POST", paths["/release"])
assert.Equal(t, "GET", paths["/sdk/diff"])
assert.Equal(t, "POST", paths["/sdk/generate"])
}
func TestBuildProvider_Good_DefaultProjectDir(t *testing.T) {
p := NewProvider("", nil)
assert.Equal(t, ".", p.projectDir)
}
func TestBuildProvider_Good_CustomProjectDir(t *testing.T) {
p := NewProvider("/tmp/myproject", nil)
assert.Equal(t, "/tmp/myproject", p.projectDir)
}
func TestBuildProvider_Good_NilHub(t *testing.T) {
p := NewProvider(".", nil)
// emitEvent should not panic with nil hub
p.emitEvent("build.started", map[string]any{"test": true})
}

2048
pkg/api/ui/dist/core-build.js vendored Normal file

File diff suppressed because it is too large Load diff

1
ui/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

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 Build — 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 Build — Custom Element Demo</h1>
<div class="demo-panel">
<core-build-panel api-url=""></core-build-panel>
</div>
<h2>Standalone Elements</h2>
<div class="standalone">
<core-build-config api-url=""></core-build-config>
</div>
<div class="standalone">
<core-build-artifacts api-url=""></core-build-artifacts>
</div>
<div class="standalone">
<core-build-release api-url=""></core-build-release>
</div>
<div class="standalone">
<core-build-sdk api-url=""></core-build-sdk>
</div>
</body>
</html>

1213
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
ui/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "@core/build-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"
}
}

278
ui/src/build-artifacts.ts Normal file
View file

@ -0,0 +1,278 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { BuildApi } from './shared/api.js';
interface ArtifactInfo {
name: string;
path: string;
size: number;
}
/**
* <core-build-artifacts> Shows dist/ contents and provides build trigger.
* Includes a confirmation dialogue before triggering a build.
*/
@customElement('core-build-artifacts')
export class BuildArtifacts extends LitElement {
static styles = css`
:host {
display: block;
font-family: system-ui, -apple-system, sans-serif;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.toolbar-info {
font-size: 0.8125rem;
colour: #6b7280;
}
button.build {
padding: 0.5rem 1.25rem;
background: #6366f1;
colour: #fff;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
button.build:hover {
background: #4f46e5;
}
button.build:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.confirm {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
}
.confirm-text {
flex: 1;
colour: #92400e;
}
button.confirm-yes {
padding: 0.375rem 1rem;
background: #dc2626;
colour: #fff;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
}
button.confirm-yes:hover {
background: #b91c1c;
}
button.confirm-no {
padding: 0.375rem 0.75rem;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
}
.list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.artifact {
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
padding: 0.625rem 1rem;
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
}
.artifact-name {
font-size: 0.875rem;
font-family: monospace;
font-weight: 500;
colour: #111827;
}
.artifact-size {
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;
}
.success {
padding: 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.375rem;
font-size: 0.875rem;
colour: #166534;
margin-bottom: 1rem;
}
`;
@property({ attribute: 'api-url' }) apiUrl = '';
@state() private artifacts: ArtifactInfo[] = [];
@state() private distExists = false;
@state() private loading = true;
@state() private error = '';
@state() private building = false;
@state() private confirmBuild = false;
@state() private buildSuccess = '';
private api!: BuildApi;
connectedCallback() {
super.connectedCallback();
this.api = new BuildApi(this.apiUrl);
this.reload();
}
async reload() {
this.loading = true;
this.error = '';
try {
const data = await this.api.artifacts();
this.artifacts = data.artifacts ?? [];
this.distExists = data.exists ?? false;
} catch (e: any) {
this.error = e.message ?? 'Failed to load artifacts';
} finally {
this.loading = false;
}
}
private handleBuildClick() {
this.confirmBuild = true;
this.buildSuccess = '';
}
private handleCancelBuild() {
this.confirmBuild = false;
}
private async handleConfirmBuild() {
this.confirmBuild = false;
this.building = true;
this.error = '';
this.buildSuccess = '';
try {
const result = await this.api.build();
this.buildSuccess = `Build complete — ${result.artifacts?.length ?? 0} artifact(s) produced (${result.version})`;
await this.reload();
} catch (e: any) {
this.error = e.message ?? 'Build failed';
} finally {
this.building = false;
}
}
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
render() {
if (this.loading) {
return html`<div class="loading">Loading artifacts\u2026</div>`;
}
return html`
<div class="toolbar">
<span class="toolbar-info">
${this.distExists
? `${this.artifacts.length} file(s) in dist/`
: 'No dist/ directory'}
</span>
<button
class="build"
?disabled=${this.building}
@click=${this.handleBuildClick}
>
${this.building ? 'Building\u2026' : 'Build'}
</button>
</div>
${this.confirmBuild
? html`
<div class="confirm">
<span class="confirm-text">This will run a full build and overwrite dist/. Continue?</span>
<button class="confirm-yes" @click=${this.handleConfirmBuild}>Build</button>
<button class="confirm-no" @click=${this.handleCancelBuild}>Cancel</button>
</div>
`
: nothing}
${this.error ? html`<div class="error">${this.error}</div>` : nothing}
${this.buildSuccess ? html`<div class="success">${this.buildSuccess}</div>` : nothing}
${this.artifacts.length === 0
? html`<div class="empty">${this.distExists ? 'dist/ is empty.' : 'Run a build to create artifacts.'}</div>`
: html`
<div class="list">
${this.artifacts.map(
(a) => html`
<div class="artifact">
<span class="artifact-name">${a.name}</span>
<span class="artifact-size">${this.formatSize(a.size)}</span>
</div>
`,
)}
</div>
`}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'core-build-artifacts': BuildArtifacts;
}
}

347
ui/src/build-config.ts Normal file
View file

@ -0,0 +1,347 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { BuildApi } from './shared/api.js';
interface TargetConfig {
os: string;
arch: string;
}
interface BuildConfigData {
config: {
version: number;
project: {
name: string;
description: string;
main: string;
binary: string;
};
build: {
type: string;
cgo: boolean;
flags: string[];
ldflags: string[];
env: string[];
};
targets: TargetConfig[];
sign: any;
};
has_config: boolean;
path: string;
}
interface DiscoverData {
types: string[];
primary: string;
dir: string;
}
/**
* <core-build-config> Displays .core/build.yaml fields and project type detection.
*/
@customElement('core-build-config')
export class BuildConfig extends LitElement {
static styles = css`
:host {
display: block;
font-family: system-ui, -apple-system, sans-serif;
}
.section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
background: #fff;
margin-bottom: 1rem;
}
.section-title {
font-size: 0.75rem;
font-weight: 700;
colour: #6b7280;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.75rem;
}
.field {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.375rem 0;
border-bottom: 1px solid #f3f4f6;
}
.field:last-child {
border-bottom: none;
}
.field-label {
font-size: 0.8125rem;
font-weight: 500;
colour: #374151;
}
.field-value {
font-size: 0.8125rem;
font-family: monospace;
colour: #6b7280;
}
.badge {
display: inline-block;
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
}
.badge.present {
background: #dcfce7;
colour: #166534;
}
.badge.absent {
background: #fef3c7;
colour: #92400e;
}
.badge.type-go {
background: #dbeafe;
colour: #1e40af;
}
.badge.type-wails {
background: #f3e8ff;
colour: #6b21a8;
}
.badge.type-node {
background: #dcfce7;
colour: #166534;
}
.badge.type-php {
background: #fef3c7;
colour: #92400e;
}
.badge.type-docker {
background: #e0e7ff;
colour: #3730a3;
}
.targets {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.25rem;
}
.target-badge {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: #f3f4f6;
border-radius: 0.25rem;
font-family: monospace;
colour: #374151;
}
.flags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.flag {
font-size: 0.75rem;
padding: 0.0625rem 0.375rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
font-family: monospace;
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;
}
`;
@property({ attribute: 'api-url' }) apiUrl = '';
@state() private configData: BuildConfigData | null = null;
@state() private discoverData: DiscoverData | null = null;
@state() private loading = true;
@state() private error = '';
private api!: BuildApi;
connectedCallback() {
super.connectedCallback();
this.api = new BuildApi(this.apiUrl);
this.reload();
}
async reload() {
this.loading = true;
this.error = '';
try {
const [configData, discoverData] = await Promise.all([
this.api.config(),
this.api.discover(),
]);
this.configData = configData;
this.discoverData = discoverData;
} catch (e: any) {
this.error = e.message ?? 'Failed to load configuration';
} finally {
this.loading = false;
}
}
render() {
if (this.loading) {
return html`<div class="loading">Loading configuration\u2026</div>`;
}
if (this.error) {
return html`<div class="error">${this.error}</div>`;
}
if (!this.configData) {
return html`<div class="empty">No configuration available.</div>`;
}
const cfg = this.configData.config;
const disc = this.discoverData;
return html`
<!-- Discovery -->
<div class="section">
<div class="section-title">Project Detection</div>
<div class="field">
<span class="field-label">Config file</span>
<span class="badge ${this.configData.has_config ? 'present' : 'absent'}">
${this.configData.has_config ? 'Present' : 'Using defaults'}
</span>
</div>
${disc
? html`
<div class="field">
<span class="field-label">Primary type</span>
<span class="badge type-${disc.primary || 'unknown'}">${disc.primary || 'none'}</span>
</div>
${disc.types.length > 1
? html`
<div class="field">
<span class="field-label">Detected types</span>
<span class="field-value">${disc.types.join(', ')}</span>
</div>
`
: nothing}
<div class="field">
<span class="field-label">Directory</span>
<span class="field-value">${disc.dir}</span>
</div>
`
: nothing}
</div>
<!-- Project -->
<div class="section">
<div class="section-title">Project</div>
${cfg.project.name
? html`
<div class="field">
<span class="field-label">Name</span>
<span class="field-value">${cfg.project.name}</span>
</div>
`
: nothing}
${cfg.project.binary
? html`
<div class="field">
<span class="field-label">Binary</span>
<span class="field-value">${cfg.project.binary}</span>
</div>
`
: nothing}
<div class="field">
<span class="field-label">Main</span>
<span class="field-value">${cfg.project.main}</span>
</div>
</div>
<!-- Build Settings -->
<div class="section">
<div class="section-title">Build Settings</div>
${cfg.build.type
? html`
<div class="field">
<span class="field-label">Type override</span>
<span class="field-value">${cfg.build.type}</span>
</div>
`
: nothing}
<div class="field">
<span class="field-label">CGO</span>
<span class="field-value">${cfg.build.cgo ? 'Enabled' : 'Disabled'}</span>
</div>
${cfg.build.flags && cfg.build.flags.length > 0
? html`
<div class="field">
<span class="field-label">Flags</span>
<div class="flags">
${cfg.build.flags.map((f: string) => html`<span class="flag">${f}</span>`)}
</div>
</div>
`
: nothing}
${cfg.build.ldflags && cfg.build.ldflags.length > 0
? html`
<div class="field">
<span class="field-label">LD flags</span>
<div class="flags">
${cfg.build.ldflags.map((f: string) => html`<span class="flag">${f}</span>`)}
</div>
</div>
`
: nothing}
</div>
<!-- Targets -->
<div class="section">
<div class="section-title">Targets</div>
<div class="targets">
${cfg.targets.map(
(t: TargetConfig) => html`<span class="target-badge">${t.os}/${t.arch}</span>`,
)}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'core-build-config': BuildConfig;
}
}

257
ui/src/build-panel.ts Normal file
View file

@ -0,0 +1,257 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { connectBuildEvents, type BuildEvent } from './shared/events.js';
// Side-effect imports to register child elements
import './build-config.js';
import './build-artifacts.js';
import './build-release.js';
import './build-sdk.js';
type TabId = 'config' | 'build' | 'release' | 'sdk';
/**
* <core-build-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 event)
*/
@customElement('core-build-panel')
export class BuildPanel 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 = 'config';
@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 = connectBuildEvents(this.wsUrl, (event: BuildEvent) => {
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() {
const content = this.shadowRoot?.querySelector('.content');
if (content) {
const child = content.firstElementChild;
if (child && 'reload' in child) {
(child as any).reload();
}
}
}
private renderContent() {
switch (this.activeTab) {
case 'config':
return html`<core-build-config api-url=${this.apiUrl}></core-build-config>`;
case 'build':
return html`<core-build-artifacts api-url=${this.apiUrl}></core-build-artifacts>`;
case 'release':
return html`<core-build-release api-url=${this.apiUrl}></core-build-release>`;
case 'sdk':
return html`<core-build-sdk api-url=${this.apiUrl}></core-build-sdk>`;
default:
return nothing;
}
}
private tabs: { id: TabId; label: string }[] = [
{ id: 'config', label: 'Config' },
{ id: 'build', label: 'Build' },
{ id: 'release', label: 'Release' },
{ id: 'sdk', label: 'SDK' },
];
render() {
const wsState = this.wsUrl
? this.wsConnected
? 'connected'
: 'disconnected'
: 'idle';
return html`
<div class="header">
<span class="title">Build</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-build-panel': BuildPanel;
}
}

307
ui/src/build-release.ts Normal file
View file

@ -0,0 +1,307 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { BuildApi } from './shared/api.js';
/**
* <core-build-release> Version display, changelog preview, and release trigger.
* Includes confirmation dialogue and dry-run support for safety.
*/
@customElement('core-build-release')
export class BuildRelease extends LitElement {
static styles = css`
:host {
display: block;
font-family: system-ui, -apple-system, sans-serif;
}
.version-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.version-label {
font-size: 0.75rem;
font-weight: 600;
colour: #6b7280;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.version-value {
font-size: 1.25rem;
font-weight: 700;
font-family: monospace;
colour: #111827;
}
.actions {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s;
}
button.release {
background: #6366f1;
colour: #fff;
border: none;
font-weight: 500;
}
button.release:hover {
background: #4f46e5;
}
button.release:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.dry-run {
background: #fff;
colour: #6366f1;
border: 1px solid #6366f1;
}
button.dry-run:hover {
background: #eef2ff;
}
.confirm {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
}
.confirm-text {
flex: 1;
colour: #991b1b;
}
button.confirm-yes {
padding: 0.375rem 1rem;
background: #dc2626;
colour: #fff;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
}
button.confirm-no {
padding: 0.375rem 0.75rem;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
}
.changelog-section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background: #fff;
}
.changelog-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
font-size: 0.75rem;
font-weight: 700;
colour: #6b7280;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.changelog-content {
padding: 1rem;
font-size: 0.875rem;
line-height: 1.6;
white-space: pre-wrap;
font-family: system-ui, -apple-system, sans-serif;
colour: #374151;
max-height: 400px;
overflow-y: auto;
}
.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;
}
.success {
padding: 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.375rem;
font-size: 0.875rem;
colour: #166534;
margin-bottom: 1rem;
}
`;
@property({ attribute: 'api-url' }) apiUrl = '';
@state() private version = '';
@state() private changelog = '';
@state() private loading = true;
@state() private error = '';
@state() private releasing = false;
@state() private confirmRelease = false;
@state() private releaseSuccess = '';
private api!: BuildApi;
connectedCallback() {
super.connectedCallback();
this.api = new BuildApi(this.apiUrl);
this.reload();
}
async reload() {
this.loading = true;
this.error = '';
try {
const [versionData, changelogData] = await Promise.all([
this.api.version(),
this.api.changelog(),
]);
this.version = versionData.version ?? '';
this.changelog = changelogData.changelog ?? '';
} catch (e: any) {
this.error = e.message ?? 'Failed to load release information';
} finally {
this.loading = false;
}
}
private handleReleaseClick() {
this.confirmRelease = true;
this.releaseSuccess = '';
}
private handleCancelRelease() {
this.confirmRelease = false;
}
private async handleConfirmRelease() {
this.confirmRelease = false;
await this.doRelease(false);
}
private async handleDryRun() {
await this.doRelease(true);
}
private async doRelease(dryRun: boolean) {
this.releasing = true;
this.error = '';
this.releaseSuccess = '';
try {
const result = await this.api.release(dryRun);
const prefix = dryRun ? 'Dry run complete' : 'Release published';
this.releaseSuccess = `${prefix}${result.version} (${result.artifacts?.length ?? 0} artifact(s))`;
await this.reload();
} catch (e: any) {
this.error = e.message ?? 'Release failed';
} finally {
this.releasing = false;
}
}
render() {
if (this.loading) {
return html`<div class="loading">Loading release information\u2026</div>`;
}
return html`
${this.error ? html`<div class="error">${this.error}</div>` : nothing}
${this.releaseSuccess ? html`<div class="success">${this.releaseSuccess}</div>` : nothing}
<div class="version-bar">
<div>
<div class="version-label">Current Version</div>
<div class="version-value">${this.version || 'unknown'}</div>
</div>
<div class="actions">
<button
class="dry-run"
?disabled=${this.releasing}
@click=${this.handleDryRun}
>
Dry Run
</button>
<button
class="release"
?disabled=${this.releasing}
@click=${this.handleReleaseClick}
>
${this.releasing ? 'Publishing\u2026' : 'Publish Release'}
</button>
</div>
</div>
${this.confirmRelease
? html`
<div class="confirm">
<span class="confirm-text">This will publish ${this.version} to all configured targets. This action cannot be undone. Continue?</span>
<button class="confirm-yes" @click=${this.handleConfirmRelease}>Publish</button>
<button class="confirm-no" @click=${this.handleCancelRelease}>Cancel</button>
</div>
`
: nothing}
${this.changelog
? html`
<div class="changelog-section">
<div class="changelog-header">Changelog</div>
<div class="changelog-content">${this.changelog}</div>
</div>
`
: html`<div class="empty">No changelog available.</div>`}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'core-build-release': BuildRelease;
}
}

335
ui/src/build-sdk.ts Normal file
View file

@ -0,0 +1,335 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { BuildApi } from './shared/api.js';
/**
* <core-build-sdk> OpenAPI diff results and SDK generation controls.
*/
@customElement('core-build-sdk')
export class BuildSdk extends LitElement {
static styles = css`
:host {
display: block;
font-family: system-ui, -apple-system, sans-serif;
}
.section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
background: #fff;
margin-bottom: 1rem;
}
.section-title {
font-size: 0.75rem;
font-weight: 700;
colour: #6b7280;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.75rem;
}
.diff-form {
display: flex;
gap: 0.5rem;
align-items: flex-end;
margin-bottom: 1rem;
}
.diff-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.diff-field label {
font-size: 0.75rem;
font-weight: 500;
colour: #6b7280;
}
.diff-field input {
padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-family: monospace;
}
.diff-field input:focus {
outline: none;
border-colour: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
button {
padding: 0.375rem 1rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s;
}
button.primary {
background: #6366f1;
colour: #fff;
border: none;
}
button.primary:hover {
background: #4f46e5;
}
button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.secondary {
background: #fff;
colour: #374151;
border: 1px solid #d1d5db;
}
button.secondary:hover {
background: #f3f4f6;
}
.diff-result {
padding: 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
margin-top: 0.75rem;
}
.diff-result.breaking {
background: #fef2f2;
border: 1px solid #fecaca;
colour: #991b1b;
}
.diff-result.safe {
background: #f0fdf4;
border: 1px solid #bbf7d0;
colour: #166534;
}
.diff-summary {
font-weight: 600;
margin-bottom: 0.5rem;
}
.diff-changes {
list-style: disc;
padding-left: 1.25rem;
margin: 0;
}
.diff-changes li {
font-size: 0.8125rem;
margin-bottom: 0.25rem;
font-family: monospace;
}
.generate-form {
display: flex;
gap: 0.5rem;
align-items: center;
}
.generate-form select {
padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.8125rem;
background: #fff;
}
.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;
margin-bottom: 1rem;
}
.success {
padding: 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.375rem;
font-size: 0.875rem;
colour: #166534;
margin-bottom: 1rem;
}
.loading {
text-align: center;
padding: 1rem;
colour: #6b7280;
font-size: 0.875rem;
}
`;
@property({ attribute: 'api-url' }) apiUrl = '';
@state() private basePath = '';
@state() private revisionPath = '';
@state() private diffResult: any = null;
@state() private diffing = false;
@state() private diffError = '';
@state() private selectedLanguage = '';
@state() private generating = false;
@state() private generateError = '';
@state() private generateSuccess = '';
private api!: BuildApi;
connectedCallback() {
super.connectedCallback();
this.api = new BuildApi(this.apiUrl);
}
async reload() {
// Reset state
this.diffResult = null;
this.diffError = '';
this.generateError = '';
this.generateSuccess = '';
}
private async handleDiff() {
if (!this.basePath.trim() || !this.revisionPath.trim()) {
this.diffError = 'Both base and revision spec paths are required.';
return;
}
this.diffing = true;
this.diffError = '';
this.diffResult = null;
try {
this.diffResult = await this.api.sdkDiff(this.basePath.trim(), this.revisionPath.trim());
} catch (e: any) {
this.diffError = e.message ?? 'Diff failed';
} finally {
this.diffing = false;
}
}
private async handleGenerate() {
this.generating = true;
this.generateError = '';
this.generateSuccess = '';
try {
const result = await this.api.sdkGenerate(this.selectedLanguage || undefined);
const lang = result.language || 'all languages';
this.generateSuccess = `SDK generated successfully for ${lang}.`;
} catch (e: any) {
this.generateError = e.message ?? 'Generation failed';
} finally {
this.generating = false;
}
}
render() {
return html`
<!-- OpenAPI Diff -->
<div class="section">
<div class="section-title">OpenAPI Diff</div>
<div class="diff-form">
<div class="diff-field">
<label>Base spec</label>
<input
type="text"
placeholder="path/to/base.yaml"
.value=${this.basePath}
@input=${(e: Event) => (this.basePath = (e.target as HTMLInputElement).value)}
/>
</div>
<div class="diff-field">
<label>Revision spec</label>
<input
type="text"
placeholder="path/to/revision.yaml"
.value=${this.revisionPath}
@input=${(e: Event) => (this.revisionPath = (e.target as HTMLInputElement).value)}
/>
</div>
<button
class="primary"
?disabled=${this.diffing}
@click=${this.handleDiff}
>
${this.diffing ? 'Comparing\u2026' : 'Compare'}
</button>
</div>
${this.diffError ? html`<div class="error">${this.diffError}</div>` : nothing}
${this.diffResult
? html`
<div class="diff-result ${this.diffResult.Breaking ? 'breaking' : 'safe'}">
<div class="diff-summary">${this.diffResult.Summary}</div>
${this.diffResult.Changes && this.diffResult.Changes.length > 0
? html`
<ul class="diff-changes">
${this.diffResult.Changes.map(
(change: string) => html`<li>${change}</li>`,
)}
</ul>
`
: nothing}
</div>
`
: nothing}
</div>
<!-- SDK Generation -->
<div class="section">
<div class="section-title">SDK Generation</div>
${this.generateError ? html`<div class="error">${this.generateError}</div>` : nothing}
${this.generateSuccess ? html`<div class="success">${this.generateSuccess}</div>` : nothing}
<div class="generate-form">
<select
.value=${this.selectedLanguage}
@change=${(e: Event) => (this.selectedLanguage = (e.target as HTMLSelectElement).value)}
>
<option value="">All languages</option>
<option value="typescript">TypeScript</option>
<option value="python">Python</option>
<option value="go">Go</option>
<option value="php">PHP</option>
</select>
<button
class="primary"
?disabled=${this.generating}
@click=${this.handleGenerate}
>
${this.generating ? 'Generating\u2026' : 'Generate SDK'}
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'core-build-sdk': BuildSdk;
}
}

11
ui/src/index.ts Normal file
View file

@ -0,0 +1,11 @@
// SPDX-Licence-Identifier: EUPL-1.2
// Bundle entry — exports all Build custom elements.
export { BuildPanel } from './build-panel.js';
export { BuildConfig } from './build-config.js';
export { BuildArtifacts } from './build-artifacts.js';
export { BuildRelease } from './build-release.js';
export { BuildSdk } from './build-sdk.js';
export { BuildApi } from './shared/api.js';
export { connectBuildEvents } from './shared/events.js';

74
ui/src/shared/api.ts Normal file
View file

@ -0,0 +1,74 @@
// SPDX-Licence-Identifier: EUPL-1.2
/**
* BuildApi provides a typed fetch wrapper for the /api/v1/build/* endpoints.
*/
export class BuildApi {
constructor(private baseUrl: string = '') {}
private get base(): string {
return `${this.baseUrl}/api/v1/build`;
}
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;
}
// -- Build ------------------------------------------------------------------
config() {
return this.request<any>('/config');
}
discover() {
return this.request<any>('/discover');
}
build() {
return this.request<any>('/build', { method: 'POST' });
}
artifacts() {
return this.request<any>('/artifacts');
}
// -- Release ----------------------------------------------------------------
version() {
return this.request<any>('/release/version');
}
changelog(from?: string, to?: string) {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
const qs = params.toString();
return this.request<any>(`/release/changelog${qs ? `?${qs}` : ''}`);
}
release(dryRun = false) {
const qs = dryRun ? '?dry_run=true' : '';
return this.request<any>(`/release${qs}`, { method: 'POST' });
}
// -- SDK --------------------------------------------------------------------
sdkDiff(base: string, revision: string) {
const params = new URLSearchParams({ base, revision });
return this.request<any>(`/sdk/diff?${params.toString()}`);
}
sdkGenerate(language?: string) {
const body = language ? JSON.stringify({ language }) : undefined;
return this.request<any>('/sdk/generate', {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body,
});
}
}

39
ui/src/shared/events.ts Normal file
View file

@ -0,0 +1,39 @@
// SPDX-Licence-Identifier: EUPL-1.2
export interface BuildEvent {
type: string;
channel?: string;
data?: any;
timestamp?: string;
}
/**
* Connects to a WebSocket endpoint and dispatches build events to a handler.
* Returns the WebSocket instance for lifecycle management.
*/
export function connectBuildEvents(
wsUrl: string,
handler: (event: BuildEvent) => void,
): WebSocket {
const ws = new WebSocket(wsUrl);
ws.onmessage = (e: MessageEvent) => {
try {
const event: BuildEvent = JSON.parse(e.data);
if (
event.type?.startsWith?.('build.') ||
event.type?.startsWith?.('release.') ||
event.type?.startsWith?.('sdk.') ||
event.channel?.startsWith?.('build.') ||
event.channel?.startsWith?.('release.') ||
event.channel?.startsWith?.('sdk.')
) {
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: 'CoreBuild',
fileName: 'core-build',
formats: ['es'],
},
outDir: resolve(__dirname, '../pkg/api/ui/dist'),
emptyOutDir: true,
rollupOptions: {
output: {
entryFileNames: 'core-build.js',
},
},
},
});