feat(manifest): add compile step and marketplace index builder
Add manifest compilation (.core/manifest.yaml → core.json) with build metadata (commit, tag, timestamp, signature) and marketplace index generation by crawling directories for compiled or source manifests. New files: - manifest/compile.go: CompiledManifest, Compile(), ParseCompiled(), WriteCompiled(), LoadCompiled(), MarshalJSON() - marketplace/builder.go: Builder.BuildFromDirs(), BuildFromManifests(), WriteIndex() - cmd/scm/: CLI commands — compile, index, export Tests: 26 new (12 manifest, 14 marketplace), all passing. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
b89228030b
commit
631ddd4887
8 changed files with 942 additions and 0 deletions
102
cmd/scm/cmd_compile.go
Normal file
102
cmd/scm/cmd_compile.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package scm
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/manifest"
|
||||
)
|
||||
|
||||
func addCompileCommand(parent *cli.Command) {
|
||||
var (
|
||||
dir string
|
||||
signKey string
|
||||
builtBy string
|
||||
)
|
||||
|
||||
cmd := &cli.Command{
|
||||
Use: "compile",
|
||||
Short: "Compile manifest.yaml into core.json",
|
||||
Long: "Read .core/manifest.yaml, attach build metadata (commit, tag), and write core.json to the project root.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runCompile(dir, signKey, builtBy)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&dir, "dir", "d", ".", "Project root directory")
|
||||
cmd.Flags().StringVar(&signKey, "sign-key", "", "Hex-encoded ed25519 private key for signing")
|
||||
cmd.Flags().StringVar(&builtBy, "built-by", "core scm compile", "Builder identity")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runCompile(dir, signKeyHex, builtBy string) error {
|
||||
medium, err := io.NewSandboxed(dir)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "open", dir)
|
||||
}
|
||||
|
||||
m, err := manifest.Load(medium, ".")
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "load", "manifest")
|
||||
}
|
||||
|
||||
opts := manifest.CompileOptions{
|
||||
Commit: gitCommit(dir),
|
||||
Tag: gitTag(dir),
|
||||
BuiltBy: builtBy,
|
||||
}
|
||||
|
||||
if signKeyHex != "" {
|
||||
keyBytes, err := hex.DecodeString(signKeyHex)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "decode", "sign key")
|
||||
}
|
||||
opts.SignKey = ed25519.PrivateKey(keyBytes)
|
||||
}
|
||||
|
||||
cm, err := manifest.Compile(m, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := manifest.WriteCompiled(medium, ".", cm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", successStyle.Render("compiled"), valueStyle.Render(m.Code))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("version:"), valueStyle.Render(m.Version))
|
||||
if opts.Commit != "" {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("commit:"), valueStyle.Render(opts.Commit))
|
||||
}
|
||||
if opts.Tag != "" {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("tag:"), valueStyle.Render(opts.Tag))
|
||||
}
|
||||
cli.Print(" %s %s\n", dimStyle.Render("output:"), valueStyle.Render("core.json"))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitCommit returns the current HEAD commit hash, or empty on error.
|
||||
func gitCommit(dir string) string {
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// gitTag returns the tag pointing at HEAD, or empty if none.
|
||||
func gitTag(dir string) string {
|
||||
out, err := exec.Command("git", "-C", dir, "describe", "--tags", "--exact-match", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
59
cmd/scm/cmd_export.go
Normal file
59
cmd/scm/cmd_export.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package scm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/manifest"
|
||||
)
|
||||
|
||||
func addExportCommand(parent *cli.Command) {
|
||||
var dir string
|
||||
|
||||
cmd := &cli.Command{
|
||||
Use: "export",
|
||||
Short: "Export compiled manifest as JSON",
|
||||
Long: "Read core.json from the project root and print it to stdout. Falls back to compiling .core/manifest.yaml if core.json is not found.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runExport(dir)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&dir, "dir", "d", ".", "Project root directory")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runExport(dir string) error {
|
||||
medium, err := io.NewSandboxed(dir)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "open", dir)
|
||||
}
|
||||
|
||||
// Try core.json first.
|
||||
cm, err := manifest.LoadCompiled(medium, ".")
|
||||
if err != nil {
|
||||
// Fall back to compiling from source.
|
||||
m, loadErr := manifest.Load(medium, ".")
|
||||
if loadErr != nil {
|
||||
return cli.WrapVerb(loadErr, "load", "manifest")
|
||||
}
|
||||
cm, err = manifest.Compile(m, manifest.CompileOptions{
|
||||
Commit: gitCommit(dir),
|
||||
Tag: gitTag(dir),
|
||||
BuiltBy: "core scm export",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
data, err := manifest.MarshalJSON(cm)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "marshal", "manifest")
|
||||
}
|
||||
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
61
cmd/scm/cmd_index.go
Normal file
61
cmd/scm/cmd_index.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package scm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/marketplace"
|
||||
)
|
||||
|
||||
func addIndexCommand(parent *cli.Command) {
|
||||
var (
|
||||
dirs []string
|
||||
output string
|
||||
baseURL string
|
||||
org string
|
||||
)
|
||||
|
||||
cmd := &cli.Command{
|
||||
Use: "index",
|
||||
Short: "Build marketplace index from directories",
|
||||
Long: "Scan directories for core.json or .core/manifest.yaml files and generate a marketplace index.json.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
if len(dirs) == 0 {
|
||||
dirs = []string{"."}
|
||||
}
|
||||
return runIndex(dirs, output, baseURL, org)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVarP(&dirs, "dir", "d", nil, "Directories to scan (repeatable, default: current directory)")
|
||||
cmd.Flags().StringVarP(&output, "output", "o", "index.json", "Output path for the index file")
|
||||
cmd.Flags().StringVar(&baseURL, "base-url", "", "Base URL for repo links (e.g. https://forge.lthn.ai)")
|
||||
cmd.Flags().StringVar(&org, "org", "", "Organisation for repo links")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runIndex(dirs []string, output, baseURL, org string) error {
|
||||
b := &marketplace.Builder{
|
||||
BaseURL: baseURL,
|
||||
Org: org,
|
||||
}
|
||||
|
||||
idx, err := b.BuildFromDirs(dirs...)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "build", "index")
|
||||
}
|
||||
|
||||
absOutput, _ := filepath.Abs(output)
|
||||
if err := marketplace.WriteIndex(absOutput, idx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", successStyle.Render("index built"), valueStyle.Render(output))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("modules:"), numberStyle.Render(fmt.Sprintf("%d", len(idx.Modules))))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
39
cmd/scm/cmd_scm.go
Normal file
39
cmd/scm/cmd_scm.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Package scm provides CLI commands for manifest compilation and marketplace
|
||||
// index generation.
|
||||
//
|
||||
// Commands:
|
||||
// - compile: Compile .core/manifest.yaml into core.json
|
||||
// - index: Build marketplace index from repository directories
|
||||
// - export: Export a compiled manifest as JSON to stdout
|
||||
package scm
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddScmCommands)
|
||||
}
|
||||
|
||||
// Style aliases from shared package.
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
dimStyle = cli.DimStyle
|
||||
valueStyle = cli.ValueStyle
|
||||
numberStyle = cli.NumberStyle
|
||||
)
|
||||
|
||||
// AddScmCommands registers the 'scm' command and all subcommands.
|
||||
func AddScmCommands(root *cli.Command) {
|
||||
scmCmd := &cli.Command{
|
||||
Use: "scm",
|
||||
Short: "SCM manifest and marketplace operations",
|
||||
Long: "Compile manifests, build marketplace indexes, and export distribution metadata.",
|
||||
}
|
||||
root.AddCommand(scmCmd)
|
||||
|
||||
addCompileCommand(scmCmd)
|
||||
addIndexCommand(scmCmd)
|
||||
addExportCommand(scmCmd)
|
||||
}
|
||||
98
manifest/compile.go
Normal file
98
manifest/compile.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
// CompiledManifest is the distribution-ready form of a manifest, written as
|
||||
// core.json at the repository root (not inside .core/). It embeds the
|
||||
// original Manifest and adds build metadata stapled at compile time.
|
||||
type CompiledManifest struct {
|
||||
Manifest `json:",inline" yaml:",inline"`
|
||||
|
||||
// Build metadata — populated by Compile.
|
||||
Commit string `json:"commit,omitempty" yaml:"commit,omitempty"`
|
||||
Tag string `json:"tag,omitempty" yaml:"tag,omitempty"`
|
||||
BuiltAt string `json:"built_at,omitempty" yaml:"built_at,omitempty"`
|
||||
BuiltBy string `json:"built_by,omitempty" yaml:"built_by,omitempty"`
|
||||
}
|
||||
|
||||
// CompileOptions controls how Compile populates the build metadata.
|
||||
type CompileOptions struct {
|
||||
Commit string // Git commit hash
|
||||
Tag string // Git tag (e.g. v1.0.0)
|
||||
BuiltBy string // Builder identity (e.g. "core build")
|
||||
SignKey ed25519.PrivateKey // Optional — signs before compiling
|
||||
}
|
||||
|
||||
// Compile produces a CompiledManifest from a source manifest and build
|
||||
// options. If opts.SignKey is provided the manifest is signed first.
|
||||
func Compile(m *Manifest, opts CompileOptions) (*CompiledManifest, error) {
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("manifest.Compile: nil manifest")
|
||||
}
|
||||
if m.Code == "" {
|
||||
return nil, fmt.Errorf("manifest.Compile: missing code")
|
||||
}
|
||||
if m.Version == "" {
|
||||
return nil, fmt.Errorf("manifest.Compile: missing version")
|
||||
}
|
||||
|
||||
// Sign if a key is supplied.
|
||||
if opts.SignKey != nil {
|
||||
if err := Sign(m, opts.SignKey); err != nil {
|
||||
return nil, fmt.Errorf("manifest.Compile: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &CompiledManifest{
|
||||
Manifest: *m,
|
||||
Commit: opts.Commit,
|
||||
Tag: opts.Tag,
|
||||
BuiltAt: time.Now().UTC().Format(time.RFC3339),
|
||||
BuiltBy: opts.BuiltBy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarshalJSON serialises a CompiledManifest to JSON bytes.
|
||||
func MarshalJSON(cm *CompiledManifest) ([]byte, error) {
|
||||
return json.MarshalIndent(cm, "", " ")
|
||||
}
|
||||
|
||||
// ParseCompiled decodes a core.json into a CompiledManifest.
|
||||
func ParseCompiled(data []byte) (*CompiledManifest, error) {
|
||||
var cm CompiledManifest
|
||||
if err := json.Unmarshal(data, &cm); err != nil {
|
||||
return nil, fmt.Errorf("manifest.ParseCompiled: %w", err)
|
||||
}
|
||||
return &cm, nil
|
||||
}
|
||||
|
||||
const compiledPath = "core.json"
|
||||
|
||||
// WriteCompiled writes a CompiledManifest as core.json to the given root
|
||||
// directory. The file lives at the distribution root, not inside .core/.
|
||||
func WriteCompiled(medium io.Medium, root string, cm *CompiledManifest) error {
|
||||
data, err := MarshalJSON(cm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("manifest.WriteCompiled: %w", err)
|
||||
}
|
||||
path := filepath.Join(root, compiledPath)
|
||||
return medium.Write(path, string(data))
|
||||
}
|
||||
|
||||
// LoadCompiled reads and parses a core.json from the given root directory.
|
||||
func LoadCompiled(medium io.Medium, root string) (*CompiledManifest, error) {
|
||||
path := filepath.Join(root, compiledPath)
|
||||
data, err := medium.Read(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("manifest.LoadCompiled: %w", err)
|
||||
}
|
||||
return ParseCompiled([]byte(data))
|
||||
}
|
||||
181
manifest/compile_test.go
Normal file
181
manifest/compile_test.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCompile_Good(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Code: "my-widget",
|
||||
Name: "My Widget",
|
||||
Version: "1.2.3",
|
||||
Author: "tester",
|
||||
}
|
||||
|
||||
cm, err := Compile(m, CompileOptions{
|
||||
Commit: "abc1234",
|
||||
Tag: "v1.2.3",
|
||||
BuiltBy: "core build",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "my-widget", cm.Code)
|
||||
assert.Equal(t, "My Widget", cm.Name)
|
||||
assert.Equal(t, "1.2.3", cm.Version)
|
||||
assert.Equal(t, "abc1234", cm.Commit)
|
||||
assert.Equal(t, "v1.2.3", cm.Tag)
|
||||
assert.Equal(t, "core build", cm.BuiltBy)
|
||||
assert.NotEmpty(t, cm.BuiltAt)
|
||||
}
|
||||
|
||||
func TestCompile_Good_WithSigning(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := &Manifest{
|
||||
Code: "signed-mod",
|
||||
Name: "Signed Module",
|
||||
Version: "0.1.0",
|
||||
}
|
||||
|
||||
cm, err := Compile(m, CompileOptions{
|
||||
Commit: "def5678",
|
||||
SignKey: priv,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, cm.Sign)
|
||||
|
||||
// Verify signature is valid.
|
||||
ok, vErr := Verify(&cm.Manifest, pub)
|
||||
require.NoError(t, vErr)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestCompile_Bad_NilManifest(t *testing.T) {
|
||||
_, err := Compile(nil, CompileOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil manifest")
|
||||
}
|
||||
|
||||
func TestCompile_Bad_MissingCode(t *testing.T) {
|
||||
m := &Manifest{Version: "1.0.0"}
|
||||
_, err := Compile(m, CompileOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing code")
|
||||
}
|
||||
|
||||
func TestCompile_Bad_MissingVersion(t *testing.T) {
|
||||
m := &Manifest{Code: "test"}
|
||||
_, err := Compile(m, CompileOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing version")
|
||||
}
|
||||
|
||||
func TestMarshalJSON_Good(t *testing.T) {
|
||||
cm := &CompiledManifest{
|
||||
Manifest: Manifest{
|
||||
Code: "test-mod",
|
||||
Name: "Test Module",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Commit: "abc123",
|
||||
Tag: "v1.0.0",
|
||||
BuiltAt: "2026-03-15T10:00:00Z",
|
||||
BuiltBy: "test",
|
||||
}
|
||||
|
||||
data, err := MarshalJSON(cm)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Round-trip: parse back.
|
||||
parsed, err := ParseCompiled(data)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-mod", parsed.Code)
|
||||
assert.Equal(t, "abc123", parsed.Commit)
|
||||
assert.Equal(t, "v1.0.0", parsed.Tag)
|
||||
assert.Equal(t, "2026-03-15T10:00:00Z", parsed.BuiltAt)
|
||||
}
|
||||
|
||||
func TestParseCompiled_Good(t *testing.T) {
|
||||
raw := `{
|
||||
"code": "demo",
|
||||
"name": "Demo",
|
||||
"version": "0.5.0",
|
||||
"commit": "aaa111",
|
||||
"tag": "v0.5.0",
|
||||
"built_at": "2026-03-15T12:00:00Z",
|
||||
"built_by": "ci"
|
||||
}`
|
||||
cm, err := ParseCompiled([]byte(raw))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "demo", cm.Code)
|
||||
assert.Equal(t, "aaa111", cm.Commit)
|
||||
assert.Equal(t, "ci", cm.BuiltBy)
|
||||
}
|
||||
|
||||
func TestParseCompiled_Bad(t *testing.T) {
|
||||
_, err := ParseCompiled([]byte("not json"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestWriteCompiled_Good(t *testing.T) {
|
||||
medium := io.NewMockMedium()
|
||||
cm := &CompiledManifest{
|
||||
Manifest: Manifest{
|
||||
Code: "write-test",
|
||||
Name: "Write Test",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Commit: "ccc333",
|
||||
}
|
||||
|
||||
err := WriteCompiled(medium, "/project", cm)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the file was written.
|
||||
content, err := medium.Read("/project/core.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed CompiledManifest
|
||||
require.NoError(t, json.Unmarshal([]byte(content), &parsed))
|
||||
assert.Equal(t, "write-test", parsed.Code)
|
||||
assert.Equal(t, "ccc333", parsed.Commit)
|
||||
}
|
||||
|
||||
func TestLoadCompiled_Good(t *testing.T) {
|
||||
medium := io.NewMockMedium()
|
||||
raw := `{"code":"load-test","name":"Load Test","version":"2.0.0","commit":"ddd444"}`
|
||||
medium.Files["/project/core.json"] = raw
|
||||
|
||||
cm, err := LoadCompiled(medium, "/project")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "load-test", cm.Code)
|
||||
assert.Equal(t, "ddd444", cm.Commit)
|
||||
}
|
||||
|
||||
func TestLoadCompiled_Bad_NotFound(t *testing.T) {
|
||||
medium := io.NewMockMedium()
|
||||
_, err := LoadCompiled(medium, "/missing")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCompile_Good_MinimalOptions(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Code: "minimal",
|
||||
Name: "Minimal",
|
||||
Version: "0.0.1",
|
||||
}
|
||||
cm, err := Compile(m, CompileOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cm.Commit)
|
||||
assert.Empty(t, cm.Tag)
|
||||
assert.Empty(t, cm.BuiltBy)
|
||||
assert.NotEmpty(t, cm.BuiltAt)
|
||||
}
|
||||
158
marketplace/builder.go
Normal file
158
marketplace/builder.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package marketplace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"forge.lthn.ai/core/go-scm/manifest"
|
||||
)
|
||||
|
||||
// IndexVersion is the current marketplace index format version.
|
||||
const IndexVersion = 1
|
||||
|
||||
// Builder constructs a marketplace Index by crawling directories for
|
||||
// core.json (compiled manifests) or .core/manifest.yaml files.
|
||||
type Builder struct {
|
||||
// BaseURL is the prefix for constructing repository URLs, e.g.
|
||||
// "https://forge.lthn.ai". When set, module Repo is derived as
|
||||
// BaseURL + "/" + org + "/" + code.
|
||||
BaseURL string
|
||||
|
||||
// Org is the default organisation used when constructing Repo URLs.
|
||||
Org string
|
||||
}
|
||||
|
||||
// BuildFromDirs scans each directory for subdirectories containing either
|
||||
// core.json (preferred) or .core/manifest.yaml. Each valid manifest is
|
||||
// added to the resulting Index as a Module.
|
||||
func (b *Builder) BuildFromDirs(dirs ...string) (*Index, error) {
|
||||
var modules []Module
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("marketplace.Builder: read %s: %w", dir, err)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
m, err := b.loadFromDir(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
log.Printf("marketplace: skipping %s: %v", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if seen[m.Code] {
|
||||
continue
|
||||
}
|
||||
seen[m.Code] = true
|
||||
|
||||
mod := Module{
|
||||
Code: m.Code,
|
||||
Name: m.Name,
|
||||
Repo: b.repoURL(m.Code),
|
||||
}
|
||||
modules = append(modules, mod)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(modules, func(i, j int) bool {
|
||||
return modules[i].Code < modules[j].Code
|
||||
})
|
||||
|
||||
return &Index{
|
||||
Version: IndexVersion,
|
||||
Modules: modules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildFromManifests constructs an Index from pre-loaded manifests.
|
||||
// This is useful when manifests have already been collected (e.g. from
|
||||
// a Forge API crawl).
|
||||
func BuildFromManifests(manifests []*manifest.Manifest) *Index {
|
||||
var modules []Module
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, m := range manifests {
|
||||
if m == nil || m.Code == "" {
|
||||
continue
|
||||
}
|
||||
if seen[m.Code] {
|
||||
continue
|
||||
}
|
||||
seen[m.Code] = true
|
||||
|
||||
modules = append(modules, Module{
|
||||
Code: m.Code,
|
||||
Name: m.Name,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(modules, func(i, j int) bool {
|
||||
return modules[i].Code < modules[j].Code
|
||||
})
|
||||
|
||||
return &Index{
|
||||
Version: IndexVersion,
|
||||
Modules: modules,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteIndex serialises an Index to JSON and writes it to the given path.
|
||||
func WriteIndex(path string, idx *Index) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("marketplace.WriteIndex: mkdir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(idx, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marketplace.WriteIndex: marshal: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// loadFromDir tries core.json first, then falls back to .core/manifest.yaml.
|
||||
func (b *Builder) loadFromDir(dir string) (*manifest.Manifest, error) {
|
||||
// Prefer compiled manifest (core.json).
|
||||
coreJSON := filepath.Join(dir, "core.json")
|
||||
if data, err := os.ReadFile(coreJSON); err == nil {
|
||||
cm, err := manifest.ParseCompiled(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse core.json: %w", err)
|
||||
}
|
||||
return &cm.Manifest, nil
|
||||
}
|
||||
|
||||
// Fall back to source manifest.
|
||||
manifestYAML := filepath.Join(dir, ".core", "manifest.yaml")
|
||||
data, err := os.ReadFile(manifestYAML)
|
||||
if err != nil {
|
||||
return nil, nil // No manifest — skip silently.
|
||||
}
|
||||
|
||||
m, err := manifest.Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse manifest.yaml: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// repoURL constructs a module repository URL from the builder config.
|
||||
func (b *Builder) repoURL(code string) string {
|
||||
if b.BaseURL == "" || b.Org == "" {
|
||||
return ""
|
||||
}
|
||||
return b.BaseURL + "/" + b.Org + "/" + code
|
||||
}
|
||||
244
marketplace/builder_test.go
Normal file
244
marketplace/builder_test.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
package marketplace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go-scm/manifest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// writeManifestYAML writes a .core/manifest.yaml for a module directory.
|
||||
func writeManifestYAML(t *testing.T, dir, code, name, version string) {
|
||||
t.Helper()
|
||||
coreDir := filepath.Join(dir, ".core")
|
||||
require.NoError(t, os.MkdirAll(coreDir, 0755))
|
||||
yaml := "code: " + code + "\nname: " + name + "\nversion: " + version + "\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(coreDir, "manifest.yaml"), []byte(yaml), 0644))
|
||||
}
|
||||
|
||||
// writeCoreJSON writes a core.json for a module directory.
|
||||
func writeCoreJSON(t *testing.T, dir, code, name, version string) {
|
||||
t.Helper()
|
||||
cm := manifest.CompiledManifest{
|
||||
Manifest: manifest.Manifest{
|
||||
Code: code,
|
||||
Name: name,
|
||||
Version: version,
|
||||
},
|
||||
Commit: "abc123",
|
||||
}
|
||||
data, err := json.Marshal(cm)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "core.json"), data, 0644))
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_ManifestYAML(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
modDir := filepath.Join(root, "my-widget")
|
||||
require.NoError(t, os.MkdirAll(modDir, 0755))
|
||||
writeManifestYAML(t, modDir, "my-widget", "My Widget", "1.0.0")
|
||||
|
||||
b := &Builder{BaseURL: "https://forge.lthn.ai", Org: "core"}
|
||||
idx, err := b.BuildFromDirs(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, idx.Modules, 1)
|
||||
assert.Equal(t, "my-widget", idx.Modules[0].Code)
|
||||
assert.Equal(t, "My Widget", idx.Modules[0].Name)
|
||||
assert.Equal(t, "https://forge.lthn.ai/core/my-widget", idx.Modules[0].Repo)
|
||||
assert.Equal(t, IndexVersion, idx.Version)
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_CoreJSON(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
modDir := filepath.Join(root, "compiled-mod")
|
||||
require.NoError(t, os.MkdirAll(modDir, 0755))
|
||||
writeCoreJSON(t, modDir, "compiled-mod", "Compiled Module", "2.0.0")
|
||||
|
||||
b := &Builder{}
|
||||
idx, err := b.BuildFromDirs(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, idx.Modules, 1)
|
||||
assert.Equal(t, "compiled-mod", idx.Modules[0].Code)
|
||||
assert.Equal(t, "Compiled Module", idx.Modules[0].Name)
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_PrefersCompiledOverSource(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
modDir := filepath.Join(root, "dual-mod")
|
||||
require.NoError(t, os.MkdirAll(modDir, 0755))
|
||||
writeManifestYAML(t, modDir, "source-code", "Source Name", "1.0.0")
|
||||
writeCoreJSON(t, modDir, "compiled-code", "Compiled Name", "2.0.0")
|
||||
|
||||
b := &Builder{}
|
||||
idx, err := b.BuildFromDirs(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
// core.json is preferred — its code should appear.
|
||||
require.Len(t, idx.Modules, 1)
|
||||
assert.Equal(t, "compiled-code", idx.Modules[0].Code)
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_SkipsNoManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Directory with no manifest.
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "no-manifest"), 0755))
|
||||
// Directory with a manifest.
|
||||
modDir := filepath.Join(root, "has-manifest")
|
||||
require.NoError(t, os.MkdirAll(modDir, 0755))
|
||||
writeManifestYAML(t, modDir, "has-manifest", "Has Manifest", "0.1.0")
|
||||
|
||||
b := &Builder{}
|
||||
idx, err := b.BuildFromDirs(root)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, idx.Modules, 1)
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_Deduplicates(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
mod1 := filepath.Join(dir1, "shared")
|
||||
mod2 := filepath.Join(dir2, "shared")
|
||||
require.NoError(t, os.MkdirAll(mod1, 0755))
|
||||
require.NoError(t, os.MkdirAll(mod2, 0755))
|
||||
writeManifestYAML(t, mod1, "shared", "Shared V1", "1.0.0")
|
||||
writeManifestYAML(t, mod2, "shared", "Shared V2", "2.0.0")
|
||||
|
||||
b := &Builder{}
|
||||
idx, err := b.BuildFromDirs(dir1, dir2)
|
||||
require.NoError(t, err)
|
||||
// First occurrence wins.
|
||||
assert.Len(t, idx.Modules, 1)
|
||||
assert.Equal(t, "shared", idx.Modules[0].Code)
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_SortsByCode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"charlie", "alpha", "bravo"} {
|
||||
d := filepath.Join(root, name)
|
||||
require.NoError(t, os.MkdirAll(d, 0755))
|
||||
writeManifestYAML(t, d, name, name, "1.0.0")
|
||||
}
|
||||
|
||||
b := &Builder{}
|
||||
idx, err := b.BuildFromDirs(root)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, idx.Modules, 3)
|
||||
assert.Equal(t, "alpha", idx.Modules[0].Code)
|
||||
assert.Equal(t, "bravo", idx.Modules[1].Code)
|
||||
assert.Equal(t, "charlie", idx.Modules[2].Code)
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_EmptyDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
b := &Builder{}
|
||||
idx, err := b.BuildFromDirs(root)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, idx.Modules)
|
||||
assert.Equal(t, IndexVersion, idx.Version)
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_NonexistentDir(t *testing.T) {
|
||||
b := &Builder{}
|
||||
idx, err := b.BuildFromDirs("/nonexistent/path")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, idx.Modules)
|
||||
}
|
||||
|
||||
func TestBuildFromDirs_Good_NoRepoURLWithoutConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
modDir := filepath.Join(root, "mod")
|
||||
require.NoError(t, os.MkdirAll(modDir, 0755))
|
||||
writeManifestYAML(t, modDir, "mod", "Module", "1.0.0")
|
||||
|
||||
b := &Builder{} // No BaseURL or Org.
|
||||
idx, err := b.BuildFromDirs(root)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, idx.Modules[0].Repo)
|
||||
}
|
||||
|
||||
func TestBuildFromManifests_Good(t *testing.T) {
|
||||
manifests := []*manifest.Manifest{
|
||||
{Code: "bravo", Name: "Bravo"},
|
||||
{Code: "alpha", Name: "Alpha"},
|
||||
}
|
||||
idx := BuildFromManifests(manifests)
|
||||
require.Len(t, idx.Modules, 2)
|
||||
assert.Equal(t, "alpha", idx.Modules[0].Code)
|
||||
assert.Equal(t, "bravo", idx.Modules[1].Code)
|
||||
assert.Equal(t, IndexVersion, idx.Version)
|
||||
}
|
||||
|
||||
func TestBuildFromManifests_Good_SkipsNil(t *testing.T) {
|
||||
manifests := []*manifest.Manifest{
|
||||
nil,
|
||||
{Code: "valid", Name: "Valid"},
|
||||
{Code: "", Name: "Empty Code"},
|
||||
}
|
||||
idx := BuildFromManifests(manifests)
|
||||
assert.Len(t, idx.Modules, 1)
|
||||
assert.Equal(t, "valid", idx.Modules[0].Code)
|
||||
}
|
||||
|
||||
func TestBuildFromManifests_Good_Deduplicates(t *testing.T) {
|
||||
manifests := []*manifest.Manifest{
|
||||
{Code: "dup", Name: "First"},
|
||||
{Code: "dup", Name: "Second"},
|
||||
}
|
||||
idx := BuildFromManifests(manifests)
|
||||
assert.Len(t, idx.Modules, 1)
|
||||
}
|
||||
|
||||
func TestWriteIndex_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "marketplace", "index.json")
|
||||
|
||||
idx := &Index{
|
||||
Version: 1,
|
||||
Modules: []Module{
|
||||
{Code: "test-mod", Name: "Test Module"},
|
||||
},
|
||||
}
|
||||
|
||||
err := WriteIndex(path, idx)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed Index
|
||||
require.NoError(t, json.Unmarshal(data, &parsed))
|
||||
assert.Len(t, parsed.Modules, 1)
|
||||
assert.Equal(t, "test-mod", parsed.Modules[0].Code)
|
||||
}
|
||||
|
||||
func TestWriteIndex_Good_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "index.json")
|
||||
|
||||
root := t.TempDir()
|
||||
modDir := filepath.Join(root, "roundtrip")
|
||||
require.NoError(t, os.MkdirAll(modDir, 0755))
|
||||
writeManifestYAML(t, modDir, "roundtrip", "Roundtrip Module", "3.0.0")
|
||||
|
||||
b := &Builder{BaseURL: "https://forge.lthn.ai", Org: "core"}
|
||||
idx, err := b.BuildFromDirs(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, WriteIndex(path, idx))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
parsed, err := ParseIndex(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, parsed.Modules, 1)
|
||||
assert.Equal(t, "roundtrip", parsed.Modules[0].Code)
|
||||
assert.Equal(t, "https://forge.lthn.ai/core/roundtrip", parsed.Modules[0].Repo)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue