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:
parent
d6086a92f4
commit
77e4c06599
20 changed files with 5832 additions and 0 deletions
115
docs/plans/2026-03-14-build-ui-elements.md
Normal file
115
docs/plans/2026-03-14-build-ui-elements.md
Normal 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
3
go.mod
|
|
@ -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
11
pkg/api/embed.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import "embed"
|
||||
|
||||
// Assets holds the built UI bundle (core-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
582
pkg/api/provider.go
Normal 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
77
pkg/api/provider_test.go
Normal 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
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
1
ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
79
ui/index.html
Normal file
79
ui/index.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Core 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
1213
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
ui/package.json
Normal file
18
ui/package.json
Normal 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
278
ui/src/build-artifacts.ts
Normal 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
347
ui/src/build-config.ts
Normal 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
257
ui/src/build-panel.ts
Normal 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
307
ui/src/build-release.ts
Normal 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
335
ui/src/build-sdk.ts
Normal 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
11
ui/src/index.ts
Normal 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
74
ui/src/shared/api.ts
Normal 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
39
ui/src/shared/events.ts
Normal 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
17
ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
ui/vite.config.ts
Normal file
20
ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
name: 'CoreBuild',
|
||||
fileName: 'core-build',
|
||||
formats: ['es'],
|
||||
},
|
||||
outDir: resolve(__dirname, '../pkg/api/ui/dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'core-build.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue