diff --git a/agentci/config.go b/agentci/config.go index 54a7a50..d8d80b0 100644 --- a/agentci/config.go +++ b/agentci/config.go @@ -2,10 +2,10 @@ package agentci import ( - "errors" "fmt" "forge.lthn.ai/core/config" + coreerr "forge.lthn.ai/core/go-log" ) // AgentConfig represents a single agent machine in the config file. @@ -43,7 +43,7 @@ func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) { continue } if ac.Host == "" { - return nil, fmt.Errorf("agent %q: host is required", name) + return nil, coreerr.E("agentci.LoadAgents", "agent "+name+": host is required", nil) } if ac.QueueDir == "" { ac.QueueDir = "/home/claude/ai-work/queue" @@ -126,10 +126,10 @@ func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error { func RemoveAgent(cfg *config.Config, name string) error { var agents map[string]AgentConfig if err := cfg.Get("agentci.agents", &agents); err != nil { - return errors.New("no agents configured") + return coreerr.E("agentci.RemoveAgent", "no agents configured", nil) } if _, ok := agents[name]; !ok { - return fmt.Errorf("agent %q not found", name) + return coreerr.E("agentci.RemoveAgent", "agent not found: "+name, nil) } delete(agents, name) return cfg.Set("agentci.agents", agents) diff --git a/agentci/security.go b/agentci/security.go index f917b3f..1bb32a3 100644 --- a/agentci/security.go +++ b/agentci/security.go @@ -1,11 +1,12 @@ package agentci import ( - "fmt" "os/exec" "path/filepath" "regexp" "strings" + + coreerr "forge.lthn.ai/core/go-log" ) var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`) @@ -15,10 +16,10 @@ var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`) func SanitizePath(input string) (string, error) { base := filepath.Base(input) if !safeNameRegex.MatchString(base) { - return "", fmt.Errorf("invalid characters in path element: %s", input) + return "", coreerr.E("agentci.SanitizePath", "invalid characters in path element: "+input, nil) } if base == "." || base == ".." || base == "/" { - return "", fmt.Errorf("invalid path element: %s", base) + return "", coreerr.E("agentci.SanitizePath", "invalid path element: "+base, nil) } return base, nil } diff --git a/cmd/forge/cmd_forge.go b/cmd/forge/cmd_forge.go index 65e0440..d85f0a5 100644 --- a/cmd/forge/cmd_forge.go +++ b/cmd/forge/cmd_forge.go @@ -14,10 +14,11 @@ package forge import ( "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-scm/locales" ) func init() { - cli.RegisterCommands(AddForgeCommands) + cli.RegisterCommands(AddForgeCommands, locales.FS) } // Style aliases from shared package. diff --git a/cmd/forge/cmd_sync.go b/cmd/forge/cmd_sync.go index 578dd7e..ab12798 100644 --- a/cmd/forge/cmd_sync.go +++ b/cmd/forge/cmd_sync.go @@ -10,6 +10,7 @@ import ( forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" "forge.lthn.ai/core/cli/pkg/cli" + coreerr "forge.lthn.ai/core/go-log" fg "forge.lthn.ai/core/go-scm/forge" ) @@ -64,7 +65,7 @@ func runSync(args []string) error { if strings.HasPrefix(basePath, "~/") { home, err := os.UserHomeDir() if err != nil { - return fmt.Errorf("failed to resolve home directory: %w", err) + return coreerr.E("forge.runSync", "failed to resolve home directory", err) } basePath = filepath.Join(home, basePath[2:]) } @@ -287,7 +288,7 @@ func syncConfigureForgeRemote(localPath, remoteURL string) error { if existing != remoteURL { cmd := exec.Command("git", "-C", localPath, "remote", "set-url", "forge", remoteURL) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to update remote: %w", err) + return coreerr.E("forge.syncConfigureForgeRemote", "failed to update remote", err) } } return nil @@ -295,7 +296,7 @@ func syncConfigureForgeRemote(localPath, remoteURL string) error { cmd := exec.Command("git", "-C", localPath, "remote", "add", "forge", remoteURL) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to add remote: %w", err) + return coreerr.E("forge.syncConfigureForgeRemote", "failed to add remote", err) } return nil @@ -306,7 +307,7 @@ func syncPushUpstream(localPath, defaultBranch string) error { cmd := exec.Command("git", "-C", localPath, "push", "--force", "forge", refspec) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + return coreerr.E("forge.syncPushUpstream", strings.TrimSpace(string(output)), err) } return nil @@ -316,7 +317,7 @@ func syncGitFetch(localPath, remote string) error { cmd := exec.Command("git", "-C", localPath, "fetch", remote) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + return coreerr.E("forge.syncGitFetch", strings.TrimSpace(string(output)), err) } return nil } @@ -327,7 +328,7 @@ func syncCreateMainFromUpstream(client *fg.Client, org, repo string) error { OldBranchName: "upstream", }) if err != nil { - return fmt.Errorf("create branch: %w", err) + return coreerr.E("forge.syncCreateMainFromUpstream", "create branch", err) } return nil diff --git a/cmd/gitea/cmd_sync.go b/cmd/gitea/cmd_sync.go index f6a7c02..6a3e4a4 100644 --- a/cmd/gitea/cmd_sync.go +++ b/cmd/gitea/cmd_sync.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/sdk/gitea" "forge.lthn.ai/core/cli/pkg/cli" + coreerr "forge.lthn.ai/core/go-log" gt "forge.lthn.ai/core/go-scm/gitea" ) @@ -64,7 +65,7 @@ func runSync(args []string) error { if strings.HasPrefix(basePath, "~/") { home, err := os.UserHomeDir() if err != nil { - return fmt.Errorf("failed to resolve home directory: %w", err) + return coreerr.E("gitea.runSync", "failed to resolve home directory", err) } basePath = filepath.Join(home, basePath[2:]) } @@ -299,7 +300,7 @@ func configureGiteaRemote(localPath, remoteURL string) error { if existing != remoteURL { cmd := exec.Command("git", "-C", localPath, "remote", "set-url", "gitea", remoteURL) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to update remote: %w", err) + return coreerr.E("gitea.configureGiteaRemote", "failed to update remote", err) } } return nil @@ -308,7 +309,7 @@ func configureGiteaRemote(localPath, remoteURL string) error { // Add new remote cmd := exec.Command("git", "-C", localPath, "remote", "add", "gitea", remoteURL) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to add remote: %w", err) + return coreerr.E("gitea.configureGiteaRemote", "failed to add remote", err) } return nil @@ -321,7 +322,7 @@ func pushUpstream(localPath, defaultBranch string) error { cmd := exec.Command("git", "-C", localPath, "push", "--force", "gitea", refspec) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + return coreerr.E("gitea.pushUpstream", strings.TrimSpace(string(output)), err) } return nil @@ -332,7 +333,7 @@ func gitFetch(localPath, remote string) error { cmd := exec.Command("git", "-C", localPath, "fetch", remote) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + return coreerr.E("gitea.gitFetch", strings.TrimSpace(string(output)), err) } return nil } @@ -344,7 +345,7 @@ func createMainFromUpstream(client *gt.Client, org, repo string) error { OldBranchName: "upstream", }) if err != nil { - return fmt.Errorf("create branch: %w", err) + return coreerr.E("gitea.createMainFromUpstream", "create branch", err) } return nil 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/docs/architecture.md b/docs/architecture.md index 9fe0250..7778996 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -621,7 +621,7 @@ byCategory := index.ByCategory("monitoring") mod, found := index.Find("my-module") // Installation -installer := marketplace.NewInstaller("/path/to/modules", store) +installer := marketplace.NewInstaller(medium, "/path/to/modules", store) installer.Install(ctx, mod) // Clone, verify manifest, register installer.Update(ctx, "code") // Pull, re-verify, update metadata installer.Remove("code") // Delete files and store entry diff --git a/docs/plans/2026-03-15-manifest-core-json.md b/docs/plans/2026-03-15-manifest-core-json.md new file mode 100644 index 0000000..a05d6a5 --- /dev/null +++ b/docs/plans/2026-03-15-manifest-core-json.md @@ -0,0 +1,414 @@ +# Manifest → core.json Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a build step that compiles `.core/manifest.yaml` into a `core.json` distribution artifact at the repo root, and a catalogue generator that indexes `core.json` files across repos into a marketplace index. + +**Architecture:** `manifest.Compile()` reads `.core/manifest.yaml`, injects version/commit metadata, and writes `core.json` at the distribution root. `marketplace.BuildIndex()` crawls a repos registry (or forge org), extracts manifests, and produces an `index.json` catalogue. Both use go-io Medium for filesystem abstraction. Tests use `io.NewMockMedium()`. + +**Tech Stack:** Go, go-io Medium, go-scm manifest/marketplace packages, testify + +--- + +## File Structure + +| File | Action | Purpose | +|------|--------|---------| +| `manifest/compile.go` | Create | `Compile()` — manifest.yaml → core.json with build metadata | +| `manifest/compile_test.go` | Create | Tests for compilation, metadata injection, signing | +| `marketplace/indexer.go` | Create | `BuildIndex()` — crawl repos, extract manifests, build catalogue | +| `marketplace/indexer_test.go` | Create | Tests for indexing, dedup, category extraction | + +--- + +## Task 1: Manifest Compilation (manifest.yaml → core.json) + +**Files:** +- Create: `manifest/compile.go` +- Create: `manifest/compile_test.go` + +The `Compile` function reads `.core/manifest.yaml`, injects build metadata (version, commit, build time), and writes `core.json` at the target root. The output is JSON (not YAML) so consumers don't need a YAML parser. + +- [ ] **Step 1: Write the failing test for Compile** + +```go +// compile_test.go +package manifest + +import ( + "encoding/json" + "testing" + + io "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompile_Good(t *testing.T) { + medium := io.NewMockMedium() + + // Write a manifest.yaml + manifest := `code: core/api +name: Core API +version: 0.1.0 +namespace: api +binary: ./bin/core-api +licence: EUPL-1.2 +` + medium.WriteString("/project/.core/manifest.yaml", manifest) + + // Compile with build metadata + err := Compile(medium, "/project", CompileOptions{ + Version: "1.2.3", + Commit: "abc1234", + }) + require.NoError(t, err) + + // Read core.json + data, err := medium.Read("/project/core.json") + require.NoError(t, err) + + var result CompiledManifest + require.NoError(t, json.Unmarshal([]byte(data), &result)) + + assert.Equal(t, "core/api", result.Code) + assert.Equal(t, "Core API", result.Name) + assert.Equal(t, "1.2.3", result.Version) + assert.Equal(t, "abc1234", result.Commit) + assert.Equal(t, "api", result.Namespace) + assert.NotEmpty(t, result.BuiltAt) +} + +func TestCompile_Good_PreservesSign(t *testing.T) { + medium := io.NewMockMedium() + + manifest := `code: core/api +name: Core API +version: 0.1.0 +sign: "dGVzdHNpZw==" +` + medium.WriteString("/project/.core/manifest.yaml", manifest) + + err := Compile(medium, "/project", CompileOptions{}) + require.NoError(t, err) + + data, err := medium.Read("/project/core.json") + require.NoError(t, err) + + var result CompiledManifest + require.NoError(t, json.Unmarshal([]byte(data), &result)) + + assert.Equal(t, "dGVzdHNpZw==", result.Sign) +} + +func TestCompile_Bad_NoManifest(t *testing.T) { + medium := io.NewMockMedium() + + err := Compile(medium, "/project", CompileOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "manifest.Compile") +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -v -run TestCompile ./manifest/` +Expected: FAIL — `Compile` undefined + +- [ ] **Step 3: Write minimal implementation** + +```go +// compile.go +package manifest + +import ( + "encoding/json" + "fmt" + "path/filepath" + "time" + + io "forge.lthn.ai/core/go-io" +) + +// CompiledManifest is the core.json distribution format. +// Embeds the full Manifest plus build metadata. +type CompiledManifest struct { + Manifest + + // Build metadata — injected at compile time, not in source manifest. + Commit string `json:"commit,omitempty"` + BuiltAt string `json:"built_at,omitempty"` +} + +// CompileOptions controls what metadata is injected during compilation. +type CompileOptions struct { + Version string // Override version (e.g. from git tag) + Commit string // Git commit hash + Output string // Output path (default: "core.json" at root) +} + +// Compile reads .core/manifest.yaml, injects build metadata, and writes +// core.json at the distribution root. +func Compile(medium io.Medium, root string, opts CompileOptions) error { + m, err := Load(medium, root) + if err != nil { + return fmt.Errorf("manifest.Compile: %w", err) + } + + compiled := CompiledManifest{ + Manifest: *m, + Commit: opts.Commit, + BuiltAt: time.Now().UTC().Format(time.RFC3339), + } + + // Override version if provided (e.g. from git tag) + if opts.Version != "" { + compiled.Version = opts.Version + } + + data, err := json.MarshalIndent(compiled, "", " ") + if err != nil { + return fmt.Errorf("manifest.Compile: marshal: %w", err) + } + + outPath := opts.Output + if outPath == "" { + outPath = filepath.Join(root, "core.json") + } + + if err := medium.Write(outPath, string(data)); err != nil { + return fmt.Errorf("manifest.Compile: write: %w", err) + } + + return nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test -v -run TestCompile ./manifest/` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add manifest/compile.go manifest/compile_test.go +git commit -m "feat(manifest): compile .core/manifest.yaml to core.json + +Co-Authored-By: Virgil " +``` + +--- + +## Task 2: Marketplace Index Builder + +**Files:** +- Create: `marketplace/indexer.go` +- Create: `marketplace/indexer_test.go` + +The `BuildIndex` function takes a list of directory paths (repos), loads each `.core/manifest.yaml`, extracts Module entries, deduplicates categories, and produces an `Index`. + +- [ ] **Step 1: Write the failing test for BuildIndex** + +```go +// indexer_test.go +package marketplace + +import ( + "testing" + + io "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildIndex_Good(t *testing.T) { + medium := io.NewMockMedium() + + // Two repos with manifests + medium.WriteString("/repos/core-api/.core/manifest.yaml", ` +code: core/api +name: Core API +version: 0.1.0 +namespace: api +binary: ./bin/api +`) + medium.WriteString("/repos/core-bio/.core/manifest.yaml", ` +code: core/bio +name: Bio +version: 0.2.0 +namespace: bio +binary: ./bin/bio +`) + + idx, err := BuildIndex(medium, []string{"/repos/core-api", "/repos/core-bio"}, IndexOptions{ + Org: "core", + }) + require.NoError(t, err) + + assert.Equal(t, 1, idx.Version) + assert.Len(t, idx.Modules, 2) + assert.Equal(t, "core/api", idx.Modules[0].Code) + assert.Equal(t, "core/bio", idx.Modules[1].Code) +} + +func TestBuildIndex_Good_SkipsMissingManifest(t *testing.T) { + medium := io.NewMockMedium() + + // Only one repo has a manifest + medium.WriteString("/repos/core-api/.core/manifest.yaml", ` +code: core/api +name: Core API +version: 0.1.0 +`) + + idx, err := BuildIndex(medium, []string{"/repos/core-api", "/repos/no-manifest"}, IndexOptions{}) + require.NoError(t, err) + + assert.Len(t, idx.Modules, 1) +} + +func TestBuildIndex_Good_ExtractsCategories(t *testing.T) { + medium := io.NewMockMedium() + + medium.WriteString("/repos/a/.core/manifest.yaml", ` +code: a +name: A +`) + medium.WriteString("/repos/b/.core/manifest.yaml", ` +code: b +name: B +`) + + idx, err := BuildIndex(medium, []string{"/repos/a", "/repos/b"}, IndexOptions{ + CategoryFn: func(code string) string { + if code == "a" { + return "tools" + } + return "products" + }, + }) + require.NoError(t, err) + + assert.Contains(t, idx.Categories, "tools") + assert.Contains(t, idx.Categories, "products") +} + +func TestBuildIndex_Bad_EmptyList(t *testing.T) { + medium := io.NewMockMedium() + + idx, err := BuildIndex(medium, []string{}, IndexOptions{}) + require.NoError(t, err) + assert.Len(t, idx.Modules, 0) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -v -run TestBuildIndex ./marketplace/` +Expected: FAIL — `BuildIndex` undefined + +- [ ] **Step 3: Write minimal implementation** + +```go +// indexer.go +package marketplace + +import ( + "fmt" + "sort" + + io "forge.lthn.ai/core/go-io" + "forge.lthn.ai/core/go-scm/manifest" +) + +// IndexOptions controls how the index is built. +type IndexOptions struct { + Org string // Git org for repo URLs (e.g. "core") + ForgeURL string // Forge base URL (default: "https://forge.lthn.ai") + CategoryFn func(code string) string // Optional function to assign category by code +} + +// BuildIndex reads .core/manifest.yaml from each repo path and produces +// a marketplace Index. Repos without a manifest are silently skipped. +func BuildIndex(medium io.Medium, repoPaths []string, opts IndexOptions) (*Index, error) { + if opts.ForgeURL == "" { + opts.ForgeURL = "https://forge.lthn.ai" + } + + idx := &Index{Version: 1} + seen := make(map[string]bool) + catSet := make(map[string]bool) + + for _, repoPath := range repoPaths { + m, err := manifest.Load(medium, repoPath) + if err != nil { + continue // Skip repos without manifest + } + + if m.Code == "" { + continue + } + + if seen[m.Code] { + continue // Deduplicate + } + seen[m.Code] = true + + module := Module{ + Code: m.Code, + Name: m.Name, + SignKey: m.Sign, + } + + // Build repo URL + if opts.Org != "" { + module.Repo = fmt.Sprintf("%s/%s/%s.git", opts.ForgeURL, opts.Org, m.Code) + } + + // Assign category + if opts.CategoryFn != nil { + module.Category = opts.CategoryFn(m.Code) + } + if module.Category != "" { + catSet[module.Category] = true + } + + idx.Modules = append(idx.Modules, module) + } + + // Sort categories + for cat := range catSet { + idx.Categories = append(idx.Categories, cat) + } + sort.Strings(idx.Categories) + + return idx, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test -v -run TestBuildIndex ./marketplace/` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add marketplace/indexer.go marketplace/indexer_test.go +git commit -m "feat(marketplace): index builder — crawl repos, build catalogue + +Co-Authored-By: Virgil " +``` + +--- + +## Summary + +**Total: 2 tasks, ~10 steps** + +After completion: +- `manifest.Compile()` produces `core.json` at distribution root +- `marketplace.BuildIndex()` crawls repo paths and produces `index.json` +- Both are testable via mock Medium (no filesystem) +- Ready for integration into `core build` and `core scm index` CLI commands diff --git a/forge/labels.go b/forge/labels.go index e5aa710..77a7b85 100644 --- a/forge/labels.go +++ b/forge/labels.go @@ -1,7 +1,6 @@ package forge import ( - "fmt" "strings" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" @@ -75,7 +74,7 @@ func (c *Client) GetLabelByName(owner, repo, name string) (*forgejo.Label, error } } - return nil, fmt.Errorf("forge.GetLabelByName: label %s not found in %s/%s", name, owner, repo) + return nil, log.E("forge.GetLabelByName", "label "+name+" not found in "+owner+"/"+repo, nil) } // EnsureLabel checks if a label exists, and creates it if it doesn't. diff --git a/go.mod b/go.mod index 369c243..c4ebb0b 100644 --- a/go.mod +++ b/go.mod @@ -5,32 +5,29 @@ go 1.26.0 require ( code.gitea.io/sdk/gitea v0.23.2 codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 - forge.lthn.ai/core/api v0.1.0 - forge.lthn.ai/core/cli v0.3.0 - forge.lthn.ai/core/config v0.1.0 - forge.lthn.ai/core/go v0.3.0 - forge.lthn.ai/core/go-i18n v0.1.0 - forge.lthn.ai/core/go-io v0.1.0 - forge.lthn.ai/core/go-log v0.0.1 - forge.lthn.ai/core/go-ws v0.1.0 + forge.lthn.ai/core/api v0.1.3 + forge.lthn.ai/core/cli v0.3.5 + forge.lthn.ai/core/config v0.1.6 + forge.lthn.ai/core/go v0.3.1 + forge.lthn.ai/core/go-i18n v0.1.5 + forge.lthn.ai/core/go-io v0.1.5 + forge.lthn.ai/core/go-log v0.0.4 + forge.lthn.ai/core/go-ws v0.2.3 github.com/gin-gonic/gin v1.12.0 github.com/stretchr/testify v1.11.1 - golang.org/x/net v0.51.0 + golang.org/x/net v0.52.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - forge.lthn.ai/core/go-crypt v0.1.0 // indirect - forge.lthn.ai/core/go-inference v0.0.2 // indirect - forge.lthn.ai/core/go-process v0.1.2 // indirect + forge.lthn.ai/core/go-inference v0.1.4 // indirect github.com/42wim/httpsig v1.2.3 // indirect - github.com/99designs/gqlgen v0.17.87 // indirect + github.com/99designs/gqlgen v0.17.88 // indirect github.com/KyleBanks/depth v1.2.1 // indirect - github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -38,18 +35,18 @@ require ( github.com/casbin/govaluate v1.10.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -71,21 +68,21 @@ require ( github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.2 // indirect - github.com/go-openapi/spec v0.22.0 // indirect - github.com/go-openapi/swag/conv v0.25.1 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.1 // indirect - github.com/go-openapi/swag/loading v0.25.1 // indirect - github.com/go-openapi/swag/stringutils v0.25.1 // indirect - github.com/go-openapi/swag/typeutils v0.25.1 // indirect - github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect @@ -101,7 +98,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -112,10 +109,11 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/sosodev/duration v1.3.1 // indirect + github.com/sosodev/duration v1.4.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect @@ -131,25 +129,25 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/arch v0.25.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - modernc.org/libc v1.68.0 // indirect + modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.46.1 // indirect + modernc.org/sqlite v1.46.2 // indirect ) diff --git a/go.sum b/go.sum index 45c6a39..c240fa4 100644 --- a/go.sum +++ b/go.sum @@ -2,36 +2,30 @@ code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= -forge.lthn.ai/core/api v0.1.0 h1:ZKnQx+L9vxLQSEjwpsD1eNcIQrE4YKV1c2AlMtseM6o= -forge.lthn.ai/core/api v0.1.0/go.mod h1:c86Lk9AmaS0xbiRCEG/+du8s9KyYNHnp8RED35gR/Fo= -forge.lthn.ai/core/cli v0.3.0 h1:FpP1Wp4GwhOd+ZHWrjKZUCEnGyWoXOVDTwhytFb6hrA= -forge.lthn.ai/core/cli v0.3.0/go.mod h1:pocya1fKLbIKnNJ9rmfUDqBsH5bg02P426JvDBomcJo= -forge.lthn.ai/core/config v0.1.0 h1:qj14x/dnOWcsXMBQWAT3FtA+/sy6Qd+1NFTg5Xoil1I= -forge.lthn.ai/core/config v0.1.0/go.mod h1:8HYA29drAWlX+bO4VI1JhmKUgGU66E2Xge8D3tKd3Dg= -forge.lthn.ai/core/go v0.3.0 h1:mOG97ApMprwx9Ked62FdWVwXTGSF6JO6m0DrVpoH2Q4= -forge.lthn.ai/core/go v0.3.0/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc= -forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw= -forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw= -forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI= -forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs= -forge.lthn.ai/core/go-inference v0.0.2 h1:aHjBkYyLKxLr9tbO4AvzzV/lsZueGq/jeo33SLh113k= -forge.lthn.ai/core/go-inference v0.0.2/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= -forge.lthn.ai/core/go-io v0.1.0 h1:aYNvmbU2VVsjXnut0WQ4DfVxcFdheziahJB32mfeJ7g= -forge.lthn.ai/core/go-io v0.1.0/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= -forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= -forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= -forge.lthn.ai/core/go-process v0.1.2 h1:0fdLJq/DPssilN9E5yude/xHNfZRKHghIjo++b5aXgc= -forge.lthn.ai/core/go-process v0.1.2/go.mod h1:9oxVALrZaZCqFe8YDdheIS5bRUV1SBz4tVW/MflAtxM= -forge.lthn.ai/core/go-ws v0.1.0 h1:P3lH2BM7UyIJAX5R2iVszEZ3M5B6oXGdEWGtuAW054M= -forge.lthn.ai/core/go-ws v0.1.0/go.mod h1:wBQLXDUod6FqESh1CM4OnAjyP3cmWg8Vd5M43RIdTwA= +forge.lthn.ai/core/api v0.1.3 h1:iYmNP6zK5SiNRunYEsXPvjppTh3bQADkMyoCC8lEs48= +forge.lthn.ai/core/api v0.1.3/go.mod h1:dBOZc6DS0HdnTfCJZ8FkZxWJio2cIf0d1UrCAlDanrA= +forge.lthn.ai/core/cli v0.3.5 h1:P7yK0DmSA1QnUMFuCjJZf/fk/akKPIxopQ6OwD8Sar8= +forge.lthn.ai/core/cli v0.3.5/go.mod h1:SeArHx+hbpX5iZqgASCD7Q1EDoc6uaaGiGBotmNzIx4= +forge.lthn.ai/core/config v0.1.6 h1:TSynnKJpoXpvsS//TSnwVH31GyMJF/lbT/oIiMaODNk= +forge.lthn.ai/core/config v0.1.6/go.mod h1:AIm7VlO/h4s1LmGSn0HZb+RqAbhmZFJppVGivcsJmGE= +forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM= +forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc= +forge.lthn.ai/core/go-i18n v0.1.5 h1:B4hV4eTl63akZiplM8lswuttctrcSOCWyFSGBZmu6Nc= +forge.lthn.ai/core/go-i18n v0.1.5/go.mod h1:hJsUxmqdPly73i3VkTDxvmbrpjxSd65hQVQqWA3+fnM= +forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0= +forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= +forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM= +forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI= +forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= +forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +forge.lthn.ai/core/go-ws v0.2.3 h1:qTeMtJQjtTdTwfPvtbOBdch2Dmbde+Aso8Ow1qvg/bk= +forge.lthn.ai/core/go-ws v0.2.3/go.mod h1:C3riJyLLcV6QhLvYlq3P/XkGTsN598qQeGBoLdoHBU4= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= -github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8= -github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg= +github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc= +github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= @@ -47,8 +41,12 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdK github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= -github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -64,8 +62,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -78,8 +76,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= @@ -91,6 +87,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -140,31 +138,33 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= -github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= -github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= -github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= -github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= -github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= -github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= -github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= -github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= -github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= -github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= -github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= -github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= -github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -175,8 +175,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0 github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= @@ -220,8 +220,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -244,6 +244,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -255,8 +257,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= -github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE= +github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -298,59 +300,63 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos= -go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg= -go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM= +go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU= +go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= -golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= +golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -360,25 +366,25 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= @@ -390,18 +396,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so= -modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= -modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -410,8 +416,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE= +modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/jobrunner/handlers/completion.go b/jobrunner/handlers/completion.go index 0355bda..67598d0 100644 --- a/jobrunner/handlers/completion.go +++ b/jobrunner/handlers/completion.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-scm/forge" "forge.lthn.ai/core/go-scm/jobrunner" ) @@ -47,11 +48,11 @@ func (h *CompletionHandler) Execute(ctx context.Context, signal *jobrunner.Pipel if signal.Success { completeLabel, err := h.forge.EnsureLabel(signal.RepoOwner, signal.RepoName, LabelAgentComplete, ColorAgentComplete) if err != nil { - return nil, fmt.Errorf("ensure label %s: %w", LabelAgentComplete, err) + return nil, coreerr.E("completion.Execute", "ensure label "+LabelAgentComplete, err) } if err := h.forge.AddIssueLabels(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), []int64{completeLabel.ID}); err != nil { - return nil, fmt.Errorf("add completed label: %w", err) + return nil, coreerr.E("completion.Execute", "add completed label", err) } if signal.Message != "" { @@ -60,11 +61,11 @@ func (h *CompletionHandler) Execute(ctx context.Context, signal *jobrunner.Pipel } else { failedLabel, err := h.forge.EnsureLabel(signal.RepoOwner, signal.RepoName, LabelAgentFailed, ColorAgentFailed) if err != nil { - return nil, fmt.Errorf("ensure label %s: %w", LabelAgentFailed, err) + return nil, coreerr.E("completion.Execute", "ensure label "+LabelAgentFailed, err) } if err := h.forge.AddIssueLabels(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), []int64{failedLabel.ID}); err != nil { - return nil, fmt.Errorf("add failed label: %w", err) + return nil, coreerr.E("completion.Execute", "add failed label", err) } msg := "Agent reported failure." diff --git a/jobrunner/handlers/dispatch.go b/jobrunner/handlers/dispatch.go index 57c33e9..0ea7372 100644 --- a/jobrunner/handlers/dispatch.go +++ b/jobrunner/handlers/dispatch.go @@ -8,10 +8,10 @@ import ( "path/filepath" "time" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-scm/agentci" "forge.lthn.ai/core/go-scm/forge" "forge.lthn.ai/core/go-scm/jobrunner" - "forge.lthn.ai/core/go-log" ) const ( @@ -83,23 +83,23 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin agentName, agent, ok := h.spinner.FindByForgejoUser(signal.Assignee) if !ok { - return nil, fmt.Errorf("unknown agent: %s", signal.Assignee) + return nil, coreerr.E("dispatch.Execute", "unknown agent: "+signal.Assignee, nil) } // Sanitize inputs to prevent path traversal. safeOwner, err := agentci.SanitizePath(signal.RepoOwner) if err != nil { - return nil, fmt.Errorf("invalid repo owner: %w", err) + return nil, coreerr.E("dispatch.Execute", "invalid repo owner", err) } safeRepo, err := agentci.SanitizePath(signal.RepoName) if err != nil { - return nil, fmt.Errorf("invalid repo name: %w", err) + return nil, coreerr.E("dispatch.Execute", "invalid repo name", err) } // Ensure in-progress label exists on repo. inProgressLabel, err := h.forge.EnsureLabel(safeOwner, safeRepo, LabelInProgress, ColorInProgress) if err != nil { - return nil, fmt.Errorf("ensure label %s: %w", LabelInProgress, err) + return nil, coreerr.E("dispatch.Execute", "ensure label "+LabelInProgress, err) } // Check if already in progress to prevent double-dispatch. @@ -107,7 +107,7 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin if err == nil { for _, l := range issue.Labels { if l.Name == LabelInProgress || l.Name == LabelAgentComplete { - log.Info("issue already processed, skipping", "issue", signal.ChildNumber, "label", l.Name) + coreerr.Info("issue already processed, skipping", "issue", signal.ChildNumber, "label", l.Name) return &jobrunner.ActionResult{ Action: "dispatch", Success: true, @@ -120,11 +120,11 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin // Assign agent and add in-progress label. if err := h.forge.AssignIssue(safeOwner, safeRepo, int64(signal.ChildNumber), []string{signal.Assignee}); err != nil { - log.Warn("failed to assign agent, continuing", "err", err) + coreerr.Warn("failed to assign agent, continuing", "err", err) } if err := h.forge.AddIssueLabels(safeOwner, safeRepo, int64(signal.ChildNumber), []int64{inProgressLabel.ID}); err != nil { - return nil, fmt.Errorf("add in-progress label: %w", err) + return nil, coreerr.E("dispatch.Execute", "add in-progress label", err) } // Remove agent-ready label if present. @@ -164,13 +164,13 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin ticketJSON, err := json.MarshalIndent(ticket, "", " ") if err != nil { h.failDispatch(signal, "Failed to marshal ticket JSON") - return nil, fmt.Errorf("marshal ticket: %w", err) + return nil, coreerr.E("dispatch.Execute", "marshal ticket", err) } // Check if ticket already exists on agent (dedup). ticketName := fmt.Sprintf("ticket-%s-%s-%d.json", safeOwner, safeRepo, signal.ChildNumber) if h.ticketExists(ctx, agent, ticketName) { - log.Info("ticket already queued, skipping", "ticket", ticketName, "agent", signal.Assignee) + coreerr.Info("ticket already queued, skipping", "ticket", ticketName, "agent", signal.Assignee) return &jobrunner.ActionResult{ Action: "dispatch", RepoOwner: safeOwner, @@ -263,7 +263,7 @@ func (h *DispatchHandler) secureTransfer(ctx context.Context, agent agentci.Agen output, err := cmd.CombinedOutput() if err != nil { - return log.E("dispatch.transfer", fmt.Sprintf("ssh to %s failed: %s", agent.Host, string(output)), err) + return coreerr.E("dispatch.transfer", fmt.Sprintf("ssh to %s failed: %s", agent.Host, string(output)), err) } return nil } diff --git a/jobrunner/handlers/resolve_threads.go b/jobrunner/handlers/resolve_threads.go index acb8477..4abbc6e 100644 --- a/jobrunner/handlers/resolve_threads.go +++ b/jobrunner/handlers/resolve_threads.go @@ -7,6 +7,7 @@ import ( forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-scm/forge" "forge.lthn.ai/core/go-scm/jobrunner" ) @@ -39,7 +40,7 @@ func (h *DismissReviewsHandler) Execute(ctx context.Context, signal *jobrunner.P reviews, err := h.forge.ListPRReviews(signal.RepoOwner, signal.RepoName, int64(signal.PRNumber)) if err != nil { - return nil, fmt.Errorf("dismiss_reviews: list reviews: %w", err) + return nil, coreerr.E("dismiss_reviews.Execute", "list reviews", err) } var dismissErrors []string diff --git a/jobrunner/handlers/tick_parent.go b/jobrunner/handlers/tick_parent.go index fa7db10..6ed0b26 100644 --- a/jobrunner/handlers/tick_parent.go +++ b/jobrunner/handlers/tick_parent.go @@ -8,6 +8,7 @@ import ( forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-scm/forge" "forge.lthn.ai/core/go-scm/jobrunner" ) @@ -41,7 +42,7 @@ func (h *TickParentHandler) Execute(ctx context.Context, signal *jobrunner.Pipel // Fetch the epic issue body. epic, err := h.forge.GetIssue(signal.RepoOwner, signal.RepoName, int64(signal.EpicNumber)) if err != nil { - return nil, fmt.Errorf("tick_parent: fetch epic: %w", err) + return nil, coreerr.E("tick_parent.Execute", "fetch epic", err) } oldBody := epic.Body diff --git a/jobrunner/journal.go b/jobrunner/journal.go index 5ac95aa..db037dc 100644 --- a/jobrunner/journal.go +++ b/jobrunner/journal.go @@ -2,13 +2,14 @@ package jobrunner import ( "encoding/json" - "errors" - "fmt" "os" "path/filepath" "regexp" "strings" "sync" + + coreerr "forge.lthn.ai/core/go-log" + coreio "forge.lthn.ai/core/go-io" ) // validPathComponent matches safe repo owner/name characters (alphanumeric, hyphen, underscore, dot). @@ -53,7 +54,7 @@ type Journal struct { // NewJournal creates a new Journal rooted at baseDir. func NewJournal(baseDir string) (*Journal, error) { if baseDir == "" { - return nil, errors.New("journal base directory is required") + return nil, coreerr.E("jobrunner.NewJournal", "base directory is required", nil) } return &Journal{baseDir: baseDir}, nil } @@ -64,12 +65,12 @@ func NewJournal(baseDir string) (*Journal, error) { func sanitizePathComponent(name string) (string, error) { // Reject empty or whitespace-only values. if name == "" || strings.TrimSpace(name) == "" { - return "", fmt.Errorf("invalid path component: %q", name) + return "", coreerr.E("jobrunner.sanitizePathComponent", "invalid path component: "+name, nil) } // Reject inputs containing path separators (directory traversal attempt). if strings.ContainsAny(name, `/\`) { - return "", fmt.Errorf("path component contains directory separator: %q", name) + return "", coreerr.E("jobrunner.sanitizePathComponent", "path component contains directory separator: "+name, nil) } // Use filepath.Clean to normalize (e.g., collapse redundant dots). @@ -77,12 +78,12 @@ func sanitizePathComponent(name string) (string, error) { // Reject traversal components. if clean == "." || clean == ".." { - return "", fmt.Errorf("invalid path component: %q", name) + return "", coreerr.E("jobrunner.sanitizePathComponent", "invalid path component: "+name, nil) } // Validate against the safe character set. if !validPathComponent.MatchString(clean) { - return "", fmt.Errorf("path component contains invalid characters: %q", name) + return "", coreerr.E("jobrunner.sanitizePathComponent", "path component contains invalid characters: "+name, nil) } return clean, nil @@ -91,10 +92,10 @@ func sanitizePathComponent(name string) (string, error) { // Append writes a journal entry for the given signal and result. func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error { if signal == nil { - return errors.New("signal is required") + return coreerr.E("jobrunner.Journal.Append", "signal is required", nil) } if result == nil { - return errors.New("result is required") + return coreerr.E("jobrunner.Journal.Append", "result is required", nil) } entry := JournalEntry{ @@ -122,18 +123,18 @@ func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error { data, err := json.Marshal(entry) if err != nil { - return fmt.Errorf("marshal journal entry: %w", err) + return coreerr.E("jobrunner.Journal.Append", "marshal journal entry", err) } data = append(data, '\n') // Sanitize path components to prevent path traversal (CVE: issue #46). owner, err := sanitizePathComponent(signal.RepoOwner) if err != nil { - return fmt.Errorf("invalid repo owner: %w", err) + return coreerr.E("jobrunner.Journal.Append", "invalid repo owner", err) } repo, err := sanitizePathComponent(signal.RepoName) if err != nil { - return fmt.Errorf("invalid repo name: %w", err) + return coreerr.E("jobrunner.Journal.Append", "invalid repo name", err) } date := result.Timestamp.UTC().Format("2006-01-02") @@ -142,27 +143,27 @@ func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error { // Resolve to absolute path and verify it stays within baseDir. absBase, err := filepath.Abs(j.baseDir) if err != nil { - return fmt.Errorf("resolve base directory: %w", err) + return coreerr.E("jobrunner.Journal.Append", "resolve base directory", err) } absDir, err := filepath.Abs(dir) if err != nil { - return fmt.Errorf("resolve journal directory: %w", err) + return coreerr.E("jobrunner.Journal.Append", "resolve journal directory", err) } if !strings.HasPrefix(absDir, absBase+string(filepath.Separator)) { - return fmt.Errorf("journal path %q escapes base directory %q", absDir, absBase) + return coreerr.E("jobrunner.Journal.Append", "journal path escapes base directory", nil) } j.mu.Lock() defer j.mu.Unlock() - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create journal directory: %w", err) + if err := coreio.Local.EnsureDir(dir); err != nil { + return coreerr.E("jobrunner.Journal.Append", "create journal directory", err) } path := filepath.Join(dir, date+".jsonl") f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { - return fmt.Errorf("open journal file: %w", err) + return coreerr.E("jobrunner.Journal.Append", "open journal file", err) } defer func() { _ = f.Close() }() diff --git a/locales/embed.go b/locales/embed.go new file mode 100644 index 0000000..410cb55 --- /dev/null +++ b/locales/embed.go @@ -0,0 +1,7 @@ +// Package locales embeds translation files for this module. +package locales + +import "embed" + +//go:embed *.json +var FS embed.FS diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..c30127b --- /dev/null +++ b/locales/en.json @@ -0,0 +1,75 @@ +{ + "common": { + "flag": { + "verbose": "Enable verbose output" + } + }, + "cmd": { + "collect": { + "short": "Collect data from external sources", + "long": "Collect and aggregate data from GitHub, BitcoinTalk, market APIs, academic papers, and other sources into a local directory for analysis.", + "flag": { + "output": "Output directory for collected data", + "dry_run": "Show what would be collected without writing" + }, + "bitcointalk": { + "short": "Collect from a BitcoinTalk topic", + "long": "Scrape posts from a BitcoinTalk forum topic by topic ID or URL. Supports pagination for large threads.", + "flag": { + "pages": "Maximum number of pages to collect (0 for all)" + } + }, + "dispatch": { + "short": "Dispatch a collection event", + "long": "Manually emit a collection lifecycle event (start, progress, item, error, complete) to trigger registered hooks.", + "hooks": { + "short": "Manage event hooks", + "list": { + "short": "List registered event hooks" + }, + "register": { + "short": "Register a hook for an event type" + } + } + }, + "excavate": { + "short": "Deep-collect all sources for a project", + "long": "Run all configured collectors for a known project (e.g. bitcoin, ethereum). Combines GitHub, market, and paper sources into a single pass with resumable state.", + "flag": { + "scan_only": "Scan available sources without collecting data", + "resume": "Resume a previously interrupted excavation" + } + }, + "github": { + "short": "Collect issues and PRs from GitHub", + "long": "Collect issues, pull requests, and metadata from a GitHub repository or organisation. Requires a valid GITHUB_TOKEN.", + "flag": { + "org": "Treat the argument as an organisation name", + "issues_only": "Collect issues only (skip pull requests)", + "prs_only": "Collect pull requests only (skip issues)" + } + }, + "market": { + "short": "Collect market data for a coin", + "long": "Fetch price, volume, and market capitalisation data for a cryptocurrency from CoinGecko and other market APIs.", + "flag": { + "historical": "Include full historical data", + "from": "Start date for historical data (YYYY-MM-DD)" + } + }, + "papers": { + "short": "Collect academic and research papers", + "long": "Search and download academic papers from arXiv, Semantic Scholar, and other sources. Requires a query term.", + "flag": { + "source": "Paper source to search (arXiv, semantic-scholar, or all)", + "category": "Filter by paper category", + "query": "Search query for finding papers" + } + }, + "process": { + "short": "Process collected raw data", + "long": "Transform and normalise previously collected raw data into structured formats suitable for analysis or training." + } + } + } +} diff --git a/manifest/compile.go b/manifest/compile.go new file mode 100644 index 0000000..a15cfe7 --- /dev/null +++ b/manifest/compile.go @@ -0,0 +1,98 @@ +package manifest + +import ( + "crypto/ed25519" + "encoding/json" + "path/filepath" + "time" + + coreerr "forge.lthn.ai/core/go-log" + "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, coreerr.E("manifest.Compile", "nil manifest", nil) + } + if m.Code == "" { + return nil, coreerr.E("manifest.Compile", "missing code", nil) + } + if m.Version == "" { + return nil, coreerr.E("manifest.Compile", "missing version", nil) + } + + // Sign if a key is supplied. + if opts.SignKey != nil { + if err := Sign(m, opts.SignKey); err != nil { + return nil, coreerr.E("manifest.Compile", "sign failed", 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, coreerr.E("manifest.ParseCompiled", "unmarshal failed", 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 coreerr.E("manifest.WriteCompiled", "marshal failed", 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, coreerr.E("manifest.LoadCompiled", "read failed", 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/manifest/loader.go b/manifest/loader.go index 9bba76c..5f943c4 100644 --- a/manifest/loader.go +++ b/manifest/loader.go @@ -2,9 +2,9 @@ package manifest import ( "crypto/ed25519" - "fmt" "path/filepath" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-io" "gopkg.in/yaml.v3" ) @@ -21,7 +21,7 @@ func Load(medium io.Medium, root string) (*Manifest, error) { path := filepath.Join(root, manifestPath) data, err := medium.Read(path) if err != nil { - return nil, fmt.Errorf("manifest.Load: %w", err) + return nil, coreerr.E("manifest.Load", "read failed", err) } return Parse([]byte(data)) } @@ -34,10 +34,10 @@ func LoadVerified(medium io.Medium, root string, pub ed25519.PublicKey) (*Manife } ok, err := Verify(m, pub) if err != nil { - return nil, fmt.Errorf("manifest.LoadVerified: %w", err) + return nil, coreerr.E("manifest.LoadVerified", "verification error", err) } if !ok { - return nil, fmt.Errorf("manifest.LoadVerified: signature verification failed for %q", m.Code) + return nil, coreerr.E("manifest.LoadVerified", "signature verification failed for "+m.Code, nil) } return m, nil } diff --git a/manifest/manifest.go b/manifest/manifest.go index c9e344d..7611220 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -1,8 +1,7 @@ package manifest import ( - "fmt" - + coreerr "forge.lthn.ai/core/go-log" "gopkg.in/yaml.v3" ) @@ -66,7 +65,7 @@ type DaemonSpec struct { func Parse(data []byte) (*Manifest, error) { var m Manifest if err := yaml.Unmarshal(data, &m); err != nil { - return nil, fmt.Errorf("manifest.Parse: %w", err) + return nil, coreerr.E("manifest.Parse", "unmarshal failed", err) } return &m, nil } diff --git a/manifest/sign.go b/manifest/sign.go index 857d15c..3edb94c 100644 --- a/manifest/sign.go +++ b/manifest/sign.go @@ -3,9 +3,8 @@ package manifest import ( "crypto/ed25519" "encoding/base64" - "errors" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "gopkg.in/yaml.v3" ) @@ -20,7 +19,7 @@ func signable(m *Manifest) ([]byte, error) { func Sign(m *Manifest, priv ed25519.PrivateKey) error { msg, err := signable(m) if err != nil { - return fmt.Errorf("manifest.Sign: marshal: %w", err) + return coreerr.E("manifest.Sign", "marshal failed", err) } sig := ed25519.Sign(priv, msg) m.Sign = base64.StdEncoding.EncodeToString(sig) @@ -30,15 +29,15 @@ func Sign(m *Manifest, priv ed25519.PrivateKey) error { // Verify checks the ed25519 signature in m.Sign against the public key. func Verify(m *Manifest, pub ed25519.PublicKey) (bool, error) { if m.Sign == "" { - return false, errors.New("manifest.Verify: no signature present") + return false, coreerr.E("manifest.Verify", "no signature present", nil) } sig, err := base64.StdEncoding.DecodeString(m.Sign) if err != nil { - return false, fmt.Errorf("manifest.Verify: decode: %w", err) + return false, coreerr.E("manifest.Verify", "decode failed", err) } msg, err := signable(m) if err != nil { - return false, fmt.Errorf("manifest.Verify: marshal: %w", err) + return false, coreerr.E("manifest.Verify", "marshal failed", err) } return ed25519.Verify(pub, msg, sig), nil } diff --git a/marketplace/builder.go b/marketplace/builder.go new file mode 100644 index 0000000..586bce5 --- /dev/null +++ b/marketplace/builder.go @@ -0,0 +1,159 @@ +package marketplace + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "sort" + + coreerr "forge.lthn.ai/core/go-log" + coreio "forge.lthn.ai/core/go-io" + "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, coreerr.E("marketplace.Builder.BuildFromDirs", "read "+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 := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil { + return coreerr.E("marketplace.WriteIndex", "mkdir failed", err) + } + data, err := json.MarshalIndent(idx, "", " ") + if err != nil { + return coreerr.E("marketplace.WriteIndex", "marshal failed", err) + } + return coreio.Local.Write(path, string(data)) +} + +// 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 raw, err := coreio.Local.Read(coreJSON); err == nil { + cm, err := manifest.ParseCompiled([]byte(raw)) + if err != nil { + return nil, coreerr.E("marketplace.Builder.loadFromDir", "parse core.json", err) + } + return &cm.Manifest, nil + } + + // Fall back to source manifest. + manifestYAML := filepath.Join(dir, ".core", "manifest.yaml") + raw, err := coreio.Local.Read(manifestYAML) + if err != nil { + return nil, nil // No manifest — skip silently. + } + + m, err := manifest.Parse([]byte(raw)) + if err != nil { + return nil, coreerr.E("marketplace.Builder.loadFromDir", "parse manifest.yaml", 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) +} diff --git a/marketplace/discovery.go b/marketplace/discovery.go index 49588c5..edb92ab 100644 --- a/marketplace/discovery.go +++ b/marketplace/discovery.go @@ -1,11 +1,12 @@ package marketplace import ( - "fmt" "log" "os" "path/filepath" + coreerr "forge.lthn.ai/core/go-log" + coreio "forge.lthn.ai/core/go-io" "forge.lthn.ai/core/go-scm/manifest" "gopkg.in/yaml.v3" ) @@ -29,7 +30,7 @@ func DiscoverProviders(dir string) ([]DiscoveredProvider, error) { if os.IsNotExist(err) { return nil, nil // No providers directory — not an error. } - return nil, fmt.Errorf("marketplace.DiscoverProviders: %w", err) + return nil, coreerr.E("marketplace.DiscoverProviders", "read directory", err) } var providers []DiscoveredProvider @@ -41,13 +42,13 @@ func DiscoverProviders(dir string) ([]DiscoveredProvider, error) { providerDir := filepath.Join(dir, e.Name()) manifestPath := filepath.Join(providerDir, ".core", "manifest.yaml") - data, err := os.ReadFile(manifestPath) + raw, err := coreio.Local.Read(manifestPath) if err != nil { log.Printf("marketplace: skipping %s: %v", e.Name(), err) continue } - m, err := manifest.Parse(data) + m, err := manifest.Parse([]byte(raw)) if err != nil { log.Printf("marketplace: skipping %s: invalid manifest: %v", e.Name(), err) continue @@ -84,7 +85,7 @@ type ProviderRegistryFile struct { // LoadProviderRegistry reads a registry.yaml file from the given path. // Returns an empty registry if the file does not exist. func LoadProviderRegistry(path string) (*ProviderRegistryFile, error) { - data, err := os.ReadFile(path) + raw, err := coreio.Local.Read(path) if err != nil { if os.IsNotExist(err) { return &ProviderRegistryFile{ @@ -92,12 +93,12 @@ func LoadProviderRegistry(path string) (*ProviderRegistryFile, error) { Providers: make(map[string]ProviderRegistryEntry), }, nil } - return nil, fmt.Errorf("marketplace.LoadProviderRegistry: %w", err) + return nil, coreerr.E("marketplace.LoadProviderRegistry", "read failed", err) } var reg ProviderRegistryFile - if err := yaml.Unmarshal(data, ®); err != nil { - return nil, fmt.Errorf("marketplace.LoadProviderRegistry: %w", err) + if err := yaml.Unmarshal([]byte(raw), ®); err != nil { + return nil, coreerr.E("marketplace.LoadProviderRegistry", "parse failed", err) } if reg.Providers == nil { @@ -109,16 +110,16 @@ func LoadProviderRegistry(path string) (*ProviderRegistryFile, error) { // SaveProviderRegistry writes the registry to the given path. func SaveProviderRegistry(path string, reg *ProviderRegistryFile) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("marketplace.SaveProviderRegistry: %w", err) + if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil { + return coreerr.E("marketplace.SaveProviderRegistry", "ensure directory", err) } data, err := yaml.Marshal(reg) if err != nil { - return fmt.Errorf("marketplace.SaveProviderRegistry: %w", err) + return coreerr.E("marketplace.SaveProviderRegistry", "marshal failed", err) } - return os.WriteFile(path, data, 0644) + return coreio.Local.Write(path, string(data)) } // Add adds or updates a provider entry in the registry. diff --git a/marketplace/installer.go b/marketplace/installer.go index 93c65be..e581bba 100644 --- a/marketplace/installer.go +++ b/marketplace/installer.go @@ -4,13 +4,12 @@ import ( "context" "encoding/hex" "encoding/json" - "fmt" - "os" "os/exec" "path/filepath" "strings" "time" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-io" "forge.lthn.ai/core/go-scm/manifest" "forge.lthn.ai/core/go-io/store" @@ -20,13 +19,15 @@ const storeGroup = "_modules" // Installer handles module installation from Git repos. type Installer struct { + medium io.Medium modulesDir string store *store.Store } // NewInstaller creates a new module installer. -func NewInstaller(modulesDir string, st *store.Store) *Installer { +func NewInstaller(m io.Medium, modulesDir string, st *store.Store) *Installer { return &Installer{ + medium: m, modulesDir: modulesDir, store: st, } @@ -48,28 +49,28 @@ type InstalledModule struct { func (i *Installer) Install(ctx context.Context, mod Module) error { // Check if already installed if _, err := i.store.Get(storeGroup, mod.Code); err == nil { - return fmt.Errorf("marketplace: module %q already installed", mod.Code) + return coreerr.E("marketplace.Installer.Install", "module already installed: "+mod.Code, nil) } dest := filepath.Join(i.modulesDir, mod.Code) - if err := os.MkdirAll(i.modulesDir, 0755); err != nil { - return fmt.Errorf("marketplace: mkdir: %w", err) + if err := i.medium.EnsureDir(i.modulesDir); err != nil { + return coreerr.E("marketplace.Installer.Install", "mkdir", err) } if err := gitClone(ctx, mod.Repo, dest); err != nil { - return fmt.Errorf("marketplace: clone %s: %w", mod.Repo, err) + return coreerr.E("marketplace.Installer.Install", "clone "+mod.Repo, err) } // On any error after clone, clean up the directory cleanup := true defer func() { if cleanup { - os.RemoveAll(dest) + _ = i.medium.DeleteAll(dest) } }() medium, err := io.NewSandboxed(dest) if err != nil { - return fmt.Errorf("marketplace: medium: %w", err) + return coreerr.E("marketplace.Installer.Install", "medium", err) } m, err := loadManifest(medium, mod.SignKey) @@ -91,11 +92,11 @@ func (i *Installer) Install(ctx context.Context, mod Module) error { data, err := json.Marshal(installed) if err != nil { - return fmt.Errorf("marketplace: marshal: %w", err) + return coreerr.E("marketplace.Installer.Install", "marshal", err) } if err := i.store.Set(storeGroup, mod.Code, string(data)); err != nil { - return fmt.Errorf("marketplace: store: %w", err) + return coreerr.E("marketplace.Installer.Install", "store", err) } cleanup = false @@ -105,11 +106,11 @@ func (i *Installer) Install(ctx context.Context, mod Module) error { // Remove uninstalls a module by deleting its files and store entry. func (i *Installer) Remove(code string) error { if _, err := i.store.Get(storeGroup, code); err != nil { - return fmt.Errorf("marketplace: module %q not installed", code) + return coreerr.E("marketplace.Installer.Remove", "module not installed: "+code, nil) } dest := filepath.Join(i.modulesDir, code) - os.RemoveAll(dest) + _ = i.medium.DeleteAll(dest) return i.store.Delete(storeGroup, code) } @@ -118,29 +119,29 @@ func (i *Installer) Remove(code string) error { func (i *Installer) Update(ctx context.Context, code string) error { raw, err := i.store.Get(storeGroup, code) if err != nil { - return fmt.Errorf("marketplace: module %q not installed", code) + return coreerr.E("marketplace.Installer.Update", "module not installed: "+code, nil) } var installed InstalledModule if err := json.Unmarshal([]byte(raw), &installed); err != nil { - return fmt.Errorf("marketplace: unmarshal: %w", err) + return coreerr.E("marketplace.Installer.Update", "unmarshal", err) } dest := filepath.Join(i.modulesDir, code) cmd := exec.CommandContext(ctx, "git", "-C", dest, "pull", "--ff-only") if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("marketplace: pull: %s: %w", strings.TrimSpace(string(output)), err) + return coreerr.E("marketplace.Installer.Update", "pull: "+strings.TrimSpace(string(output)), err) } // Reload and re-verify manifest with the same key used at install time medium, mErr := io.NewSandboxed(dest) if mErr != nil { - return fmt.Errorf("marketplace: medium: %w", mErr) + return coreerr.E("marketplace.Installer.Update", "medium", mErr) } m, mErr := loadManifest(medium, installed.SignKey) if mErr != nil { - return fmt.Errorf("marketplace: reload manifest: %w", mErr) + return coreerr.E("marketplace.Installer.Update", "reload manifest", mErr) } // Update stored metadata @@ -150,7 +151,7 @@ func (i *Installer) Update(ctx context.Context, code string) error { data, err := json.Marshal(installed) if err != nil { - return fmt.Errorf("marketplace: marshal: %w", err) + return coreerr.E("marketplace.Installer.Update", "marshal", err) } return i.store.Set(storeGroup, code, string(data)) @@ -160,7 +161,7 @@ func (i *Installer) Update(ctx context.Context, code string) error { func (i *Installer) Installed() ([]InstalledModule, error) { all, err := i.store.GetAll(storeGroup) if err != nil { - return nil, fmt.Errorf("marketplace: list: %w", err) + return nil, coreerr.E("marketplace.Installer.Installed", "list", err) } var modules []InstalledModule @@ -179,7 +180,7 @@ func loadManifest(medium io.Medium, signKey string) (*manifest.Manifest, error) if signKey != "" { pubBytes, err := hex.DecodeString(signKey) if err != nil { - return nil, fmt.Errorf("marketplace: decode sign key: %w", err) + return nil, coreerr.E("marketplace.loadManifest", "decode sign key", err) } return manifest.LoadVerified(medium, ".", pubBytes) } @@ -190,7 +191,7 @@ func loadManifest(medium io.Medium, signKey string) (*manifest.Manifest, error) func gitClone(ctx context.Context, repo, dest string) error { cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", repo, dest) if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + return coreerr.E("marketplace.gitClone", strings.TrimSpace(string(output)), err) } return nil } diff --git a/marketplace/installer_test.go b/marketplace/installer_test.go index f5118f9..772d18d 100644 --- a/marketplace/installer_test.go +++ b/marketplace/installer_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "forge.lthn.ai/core/go-io" "forge.lthn.ai/core/go-scm/manifest" "forge.lthn.ai/core/go-io/store" "github.com/stretchr/testify/assert" @@ -24,7 +25,7 @@ func createTestRepo(t *testing.T, code, version string) string { manifestYAML := "code: " + code + "\nname: Test " + code + "\nversion: \"" + version + "\"\n" require.NoError(t, os.WriteFile( - filepath.Join(dir, ".core", "view.yml"), + filepath.Join(dir, ".core", "manifest.yaml"), []byte(manifestYAML), 0644, )) require.NoError(t, os.WriteFile( @@ -33,7 +34,7 @@ func createTestRepo(t *testing.T, code, version string) string { )) runGit(t, dir, "init") - runGit(t, dir, "add", ".") + runGit(t, dir, "add", "--force", ".") runGit(t, dir, "commit", "-m", "init") return dir } @@ -57,11 +58,11 @@ func createSignedTestRepo(t *testing.T, code, version string) (string, string) { data, err := manifest.MarshalYAML(m) require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "view.yml"), data, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "manifest.yaml"), data, 0644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "main.ts"), []byte("export async function init(core: any) {}\n"), 0644)) runGit(t, dir, "init") - runGit(t, dir, "add", ".") + runGit(t, dir, "add", "--force", ".") runGit(t, dir, "commit", "-m", "init") return dir, hex.EncodeToString(pub) @@ -82,7 +83,7 @@ func TestInstall_Good(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(modulesDir, st) + inst := NewInstaller(io.Local, modulesDir, st) err = inst.Install(context.Background(), Module{ Code: "hello-mod", Repo: repo, @@ -108,7 +109,7 @@ func TestInstall_Good_Signed(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(modulesDir, st) + inst := NewInstaller(io.Local, modulesDir, st) err = inst.Install(context.Background(), Module{ Code: "signed-mod", Repo: repo, @@ -129,7 +130,7 @@ func TestInstall_Bad_AlreadyInstalled(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(modulesDir, st) + inst := NewInstaller(io.Local, modulesDir, st) mod := Module{Code: "dup-mod", Repo: repo} require.NoError(t, inst.Install(context.Background(), mod)) @@ -149,7 +150,7 @@ func TestInstall_Bad_InvalidSignature(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(modulesDir, st) + inst := NewInstaller(io.Local, modulesDir, st) err = inst.Install(context.Background(), Module{ Code: "bad-sig", Repo: repo, @@ -170,7 +171,7 @@ func TestRemove_Good(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(modulesDir, st) + inst := NewInstaller(io.Local, modulesDir, st) require.NoError(t, inst.Install(context.Background(), Module{Code: "rm-mod", Repo: repo})) err = inst.Remove("rm-mod") @@ -190,7 +191,7 @@ func TestRemove_Bad_NotInstalled(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(t.TempDir(), st) + inst := NewInstaller(io.Local, t.TempDir(), st) err = inst.Remove("nonexistent") assert.Error(t, err) assert.Contains(t, err.Error(), "not installed") @@ -203,7 +204,7 @@ func TestInstalled_Good(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(modulesDir, st) + inst := NewInstaller(io.Local, modulesDir, st) repo1 := createTestRepo(t, "mod-a", "1.0") repo2 := createTestRepo(t, "mod-b", "2.0") @@ -228,7 +229,7 @@ func TestInstalled_Good_Empty(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(t.TempDir(), st) + inst := NewInstaller(io.Local, t.TempDir(), st) installed, err := inst.Installed() require.NoError(t, err) assert.Empty(t, installed) @@ -242,12 +243,12 @@ func TestUpdate_Good(t *testing.T) { require.NoError(t, err) defer st.Close() - inst := NewInstaller(modulesDir, st) + inst := NewInstaller(io.Local, modulesDir, st) require.NoError(t, inst.Install(context.Background(), Module{Code: "upd-mod", Repo: repo})) // Update the origin repo newManifest := "code: upd-mod\nname: Updated Module\nversion: \"2.0\"\n" - require.NoError(t, os.WriteFile(filepath.Join(repo, ".core", "view.yml"), []byte(newManifest), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(repo, ".core", "manifest.yaml"), []byte(newManifest), 0644)) runGit(t, repo, "add", ".") runGit(t, repo, "commit", "-m", "bump version") diff --git a/marketplace/marketplace.go b/marketplace/marketplace.go index 52b4a8f..4cb455c 100644 --- a/marketplace/marketplace.go +++ b/marketplace/marketplace.go @@ -2,8 +2,9 @@ package marketplace import ( "encoding/json" - "fmt" "strings" + + coreerr "forge.lthn.ai/core/go-log" ) // Module is a marketplace entry pointing to a module's Git repo. @@ -26,7 +27,7 @@ type Index struct { func ParseIndex(data []byte) (*Index, error) { var idx Index if err := json.Unmarshal(data, &idx); err != nil { - return nil, fmt.Errorf("marketplace.ParseIndex: %w", err) + return nil, coreerr.E("marketplace.ParseIndex", "unmarshal failed", err) } return &idx, nil } diff --git a/plugin/installer.go b/plugin/installer.go index 8a595bb..4171977 100644 --- a/plugin/installer.go +++ b/plugin/installer.go @@ -159,7 +159,7 @@ func (i *Installer) cloneRepo(ctx context.Context, org, repo, version, dest stri cmd := exec.CommandContext(ctx, "gh", args...) if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) + return coreerr.E("plugin.Installer.cloneRepo", strings.TrimSpace(string(output)), err) } return nil diff --git a/repos/gitstate.go b/repos/gitstate.go index 1f21c71..d08aba7 100644 --- a/repos/gitstate.go +++ b/repos/gitstate.go @@ -1,10 +1,10 @@ package repos import ( - "fmt" "path/filepath" "time" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-io" "gopkg.in/yaml.v3" ) @@ -44,12 +44,12 @@ func LoadGitState(m io.Medium, root string) (*GitState, error) { content, err := m.Read(path) if err != nil { - return nil, fmt.Errorf("failed to read git state: %w", err) + return nil, coreerr.E("repos.LoadGitState", "failed to read git state", err) } var gs GitState if err := yaml.Unmarshal([]byte(content), &gs); err != nil { - return nil, fmt.Errorf("failed to parse git state: %w", err) + return nil, coreerr.E("repos.LoadGitState", "failed to parse git state", err) } if gs.Repos == nil { @@ -66,17 +66,17 @@ func LoadGitState(m io.Medium, root string) (*GitState, error) { func SaveGitState(m io.Medium, root string, gs *GitState) error { coreDir := filepath.Join(root, ".core") if err := m.EnsureDir(coreDir); err != nil { - return fmt.Errorf("failed to create .core directory: %w", err) + return coreerr.E("repos.SaveGitState", "failed to create .core directory", err) } data, err := yaml.Marshal(gs) if err != nil { - return fmt.Errorf("failed to marshal git state: %w", err) + return coreerr.E("repos.SaveGitState", "failed to marshal git state", err) } path := filepath.Join(coreDir, "git.yaml") if err := m.Write(path, string(data)); err != nil { - return fmt.Errorf("failed to write git state: %w", err) + return coreerr.E("repos.SaveGitState", "failed to write git state", err) } return nil diff --git a/repos/kbconfig.go b/repos/kbconfig.go index 26c1cba..49b393b 100644 --- a/repos/kbconfig.go +++ b/repos/kbconfig.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-io" "gopkg.in/yaml.v3" ) @@ -74,12 +75,12 @@ func LoadKBConfig(m io.Medium, root string) (*KBConfig, error) { content, err := m.Read(path) if err != nil { - return nil, fmt.Errorf("failed to read kb config: %w", err) + return nil, coreerr.E("repos.LoadKBConfig", "failed to read kb config", err) } kb := DefaultKBConfig() if err := yaml.Unmarshal([]byte(content), kb); err != nil { - return nil, fmt.Errorf("failed to parse kb config: %w", err) + return nil, coreerr.E("repos.LoadKBConfig", "failed to parse kb config", err) } return kb, nil @@ -89,17 +90,17 @@ func LoadKBConfig(m io.Medium, root string) (*KBConfig, error) { func SaveKBConfig(m io.Medium, root string, kb *KBConfig) error { coreDir := filepath.Join(root, ".core") if err := m.EnsureDir(coreDir); err != nil { - return fmt.Errorf("failed to create .core directory: %w", err) + return coreerr.E("repos.SaveKBConfig", "failed to create .core directory", err) } data, err := yaml.Marshal(kb) if err != nil { - return fmt.Errorf("failed to marshal kb config: %w", err) + return coreerr.E("repos.SaveKBConfig", "failed to marshal kb config", err) } path := filepath.Join(coreDir, "kb.yaml") if err := m.Write(path, string(data)); err != nil { - return fmt.Errorf("failed to write kb config: %w", err) + return coreerr.E("repos.SaveKBConfig", "failed to write kb config", err) } return nil diff --git a/repos/registry.go b/repos/registry.go index e10e2ba..88f5808 100644 --- a/repos/registry.go +++ b/repos/registry.go @@ -4,12 +4,11 @@ package repos import ( - "errors" - "fmt" "os" "path/filepath" "strings" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-io" "gopkg.in/yaml.v3" ) @@ -67,13 +66,13 @@ type Repo struct { func LoadRegistry(m io.Medium, path string) (*Registry, error) { content, err := m.Read(path) if err != nil { - return nil, fmt.Errorf("failed to read registry file: %w", err) + return nil, coreerr.E("repos.LoadRegistry", "failed to read registry file", err) } data := []byte(content) var reg Registry if err := yaml.Unmarshal(data, ®); err != nil { - return nil, fmt.Errorf("failed to parse registry file: %w", err) + return nil, coreerr.E("repos.LoadRegistry", "failed to parse registry file", err) } reg.medium = m @@ -147,7 +146,7 @@ func FindRegistry(m io.Medium) (string, error) { } } - return "", errors.New("repos.yaml not found") + return "", coreerr.E("repos.FindRegistry", "repos.yaml not found", nil) } // ScanDirectory creates a Registry by scanning a directory for git repos. @@ -156,7 +155,7 @@ func FindRegistry(m io.Medium) (string, error) { func ScanDirectory(m io.Medium, dir string) (*Registry, error) { entries, err := m.List(dir) if err != nil { - return nil, fmt.Errorf("failed to read directory: %w", err) + return nil, coreerr.E("repos.ScanDirectory", "failed to read directory", err) } reg := &Registry{ @@ -282,12 +281,12 @@ func (r *Registry) TopologicalOrder() ([]*Repo, error) { return nil } if visiting[name] { - return fmt.Errorf("circular dependency detected: %s", name) + return coreerr.E("repos.Registry.TopologicalOrder", "circular dependency detected: "+name, nil) } repo, ok := r.Repos[name] if !ok { - return fmt.Errorf("unknown repo: %s", name) + return coreerr.E("repos.Registry.TopologicalOrder", "unknown repo: "+name, nil) } visiting[name] = true diff --git a/repos/workconfig.go b/repos/workconfig.go index dfe1470..a7a1b58 100644 --- a/repos/workconfig.go +++ b/repos/workconfig.go @@ -1,10 +1,10 @@ package repos import ( - "fmt" "path/filepath" "time" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-io" "gopkg.in/yaml.v3" ) @@ -63,12 +63,12 @@ func LoadWorkConfig(m io.Medium, root string) (*WorkConfig, error) { content, err := m.Read(path) if err != nil { - return nil, fmt.Errorf("failed to read work config: %w", err) + return nil, coreerr.E("repos.LoadWorkConfig", "failed to read work config", err) } wc := DefaultWorkConfig() if err := yaml.Unmarshal([]byte(content), wc); err != nil { - return nil, fmt.Errorf("failed to parse work config: %w", err) + return nil, coreerr.E("repos.LoadWorkConfig", "failed to parse work config", err) } return wc, nil @@ -78,17 +78,17 @@ func LoadWorkConfig(m io.Medium, root string) (*WorkConfig, error) { func SaveWorkConfig(m io.Medium, root string, wc *WorkConfig) error { coreDir := filepath.Join(root, ".core") if err := m.EnsureDir(coreDir); err != nil { - return fmt.Errorf("failed to create .core directory: %w", err) + return coreerr.E("repos.SaveWorkConfig", "failed to create .core directory", err) } data, err := yaml.Marshal(wc) if err != nil { - return fmt.Errorf("failed to marshal work config: %w", err) + return coreerr.E("repos.SaveWorkConfig", "failed to marshal work config", err) } path := filepath.Join(coreDir, "work.yaml") if err := m.Write(path, string(data)); err != nil { - return fmt.Errorf("failed to write work config: %w", err) + return coreerr.E("repos.SaveWorkConfig", "failed to write work config", err) } return nil