From 631ddd48875166a045b166f9569028032c41f64a Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 15 Mar 2026 13:45:24 +0000 Subject: [PATCH] feat(manifest): add compile step and marketplace index builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/scm/cmd_compile.go | 102 +++++++++++++++ cmd/scm/cmd_export.go | 59 +++++++++ cmd/scm/cmd_index.go | 61 +++++++++ cmd/scm/cmd_scm.go | 39 ++++++ manifest/compile.go | 98 +++++++++++++++ manifest/compile_test.go | 181 ++++++++++++++++++++++++++ marketplace/builder.go | 158 +++++++++++++++++++++++ marketplace/builder_test.go | 244 ++++++++++++++++++++++++++++++++++++ 8 files changed, 942 insertions(+) create mode 100644 cmd/scm/cmd_compile.go create mode 100644 cmd/scm/cmd_export.go create mode 100644 cmd/scm/cmd_index.go create mode 100644 cmd/scm/cmd_scm.go create mode 100644 manifest/compile.go create mode 100644 manifest/compile_test.go create mode 100644 marketplace/builder.go create mode 100644 marketplace/builder_test.go diff --git a/cmd/scm/cmd_compile.go b/cmd/scm/cmd_compile.go new file mode 100644 index 0000000..1e5333f --- /dev/null +++ b/cmd/scm/cmd_compile.go @@ -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)) +} diff --git a/cmd/scm/cmd_export.go b/cmd/scm/cmd_export.go new file mode 100644 index 0000000..dd4286c --- /dev/null +++ b/cmd/scm/cmd_export.go @@ -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 +} diff --git a/cmd/scm/cmd_index.go b/cmd/scm/cmd_index.go new file mode 100644 index 0000000..2f1db2f --- /dev/null +++ b/cmd/scm/cmd_index.go @@ -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 +} diff --git a/cmd/scm/cmd_scm.go b/cmd/scm/cmd_scm.go new file mode 100644 index 0000000..4a7ad1b --- /dev/null +++ b/cmd/scm/cmd_scm.go @@ -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) +} diff --git a/manifest/compile.go b/manifest/compile.go new file mode 100644 index 0000000..30508ed --- /dev/null +++ b/manifest/compile.go @@ -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)) +} diff --git a/manifest/compile_test.go b/manifest/compile_test.go new file mode 100644 index 0000000..99363a1 --- /dev/null +++ b/manifest/compile_test.go @@ -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) +} diff --git a/marketplace/builder.go b/marketplace/builder.go new file mode 100644 index 0000000..347b27e --- /dev/null +++ b/marketplace/builder.go @@ -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 +} diff --git a/marketplace/builder_test.go b/marketplace/builder_test.go new file mode 100644 index 0000000..13e237e --- /dev/null +++ b/marketplace/builder_test.go @@ -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) +}