From 1174ea76914fedf57f01471759bf5992a3a5fefc Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 05:40:08 +0000 Subject: [PATCH 1/4] Fix CI blockers: commit go.sum, fix errcheck, fix build paths, wire up CI --- .gitignore | 1 + go.mod | 9 ++++++--- go.sum | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 go.sum diff --git a/.gitignore b/.gitignore index ff9c533..4f48a14 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /vendor/ /go/pkg/ *.sum +!go.sum go.work cpm diff --git a/go.mod b/go.mod index 7afafa5..cc73f02 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,14 @@ module github.com/CognitiveOS-Project/cpm go 1.23.4 +require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/spf13/cobra v1.10.2 + gopkg.in/ini.v1 v1.67.3 +) + require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/text v0.14.0 // indirect - gopkg.in/ini.v1 v1.67.3 // indirect ) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..525af5b --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 2eb4f1541301ff29b5356ad52de2709befbd640d Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 07:30:56 +0000 Subject: [PATCH 2/4] feat: add source validation with provider bug check on install --- cmd/install.go | 23 +++++ internal/archive/archive.go | 7 ++ internal/check/source.go | 166 ++++++++++++++++++++++++++++++++++++ internal/log/log.go | 4 + 4 files changed, 200 insertions(+) create mode 100644 internal/check/source.go diff --git a/cmd/install.go b/cmd/install.go index 4034207..21acb5b 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -8,6 +8,7 @@ import ( "github.com/CognitiveOS-Project/cpm/internal/archive" "github.com/CognitiveOS-Project/cpm/internal/audit" + "github.com/CognitiveOS-Project/cpm/internal/check" "github.com/CognitiveOS-Project/cpm/internal/config" "github.com/CognitiveOS-Project/cpm/internal/log" "github.com/CognitiveOS-Project/cpm/internal/patch" @@ -129,6 +130,28 @@ Examples: } } + // Source validation — check issues URL reachability + if m.Source != nil { + if m.Source.Issues != "" { + if err := check.IssuesReachable(m.Source.Issues); err != nil { + log.Warn("Source issues URL: %v", err) + } + } + + result, err := check.CheckForBugs(m.Source) + if err != nil { + return fmt.Errorf("bug check: %w", err) + } + if result.HasBugs { + log.Audit("known_bugs", map[string]interface{}{ + "name": m.Name, + "count": result.Count, + "urls": result.URLs, + }) + return fmt.Errorf("refusing to install %q — %d open bug(s) found", m.Name, result.Count) + } + } + // Extract installPath := patch.Dir(m.Name) if err := os.MkdirAll(installPath, 0755); err != nil { diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 9caa04c..27577d3 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -16,12 +16,19 @@ type Manifest struct { Description string `json:"description"` Author string `json:"author,omitempty"` License string `json:"license,omitempty"` + Source *SourceInfo `json:"source,omitempty"` Dependencies map[string]string `json:"dependencies,omitempty"` HardwareRequirements *HardwareReq `json:"hardware_requirements,omitempty"` Brain *BrainConfig `json:"brain,omitempty"` Runtime *RuntimeConfig `json:"runtime,omitempty"` } +type SourceInfo struct { + Repository string `json:"repository"` + Issues string `json:"issues"` + IssuesAPI string `json:"issues_api,omitempty"` +} + type HardwareReq struct { MinRAMMB int `json:"min_ram_mb,omitempty"` MinStorageMB int `json:"min_storage_mb,omitempty"` diff --git a/internal/check/source.go b/internal/check/source.go new file mode 100644 index 0000000..b42303f --- /dev/null +++ b/internal/check/source.go @@ -0,0 +1,166 @@ +package check + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/CognitiveOS-Project/cpm/internal/archive" +) + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +func IssuesReachable(issuesURL string) error { + resp, err := httpClient.Get(issuesURL) + if err != nil { + return fmt.Errorf("issues URL unreachable: %w", err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("issues URL returned HTTP %d", resp.StatusCode) + } + return nil +} + +type BugCheckResult struct { + HasBugs bool + Count int + URLs []string +} + +func CheckForBugs(source *archive.SourceInfo) (*BugCheckResult, error) { + if source == nil { + return &BugCheckResult{}, nil + } + + apiURL := deriveIssuesAPI(source) + if apiURL == "" { + return nil, fmt.Errorf("unknown git provider and no issues_api set") + } + + resp, err := httpClient.Get(apiURL) + if err != nil { + return nil, fmt.Errorf("issues API unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("issues API returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read issues response: %w", err) + } + + return parseBugResponse(body, source.Repository) +} + +func deriveIssuesAPI(source *archive.SourceInfo) string { + if source.IssuesAPI != "" { + return source.IssuesAPI + } + + repoURL, err := url.Parse(source.Repository) + if err != nil { + return "" + } + + switch repoURL.Host { + case "github.com": + parts := strings.Split(strings.Trim(repoURL.Path, "/"), "/") + if len(parts) < 2 { + return "" + } + return fmt.Sprintf("https://api.github.com/repos/%s/%s/issues?labels=bug&state=open&per_page=1", parts[0], parts[1]) + + case "gitlab.com": + parts := strings.Split(strings.Trim(repoURL.Path, "/"), "/") + if len(parts) < 2 { + return "" + } + return fmt.Sprintf("https://gitlab.com/api/v4/projects/%s%%2F%s/issues?labels=bug&state=opened&per_page=1", parts[0], parts[1]) + + case "bitbucket.org": + parts := strings.Split(strings.Trim(repoURL.Path, "/"), "/") + if len(parts) < 2 { + return "" + } + return fmt.Sprintf("https://api.bitbucket.org/2.0/repositories/%s/%s/issues?q=state%%3D%%22new%%22+AND+kind%%3D%%22bug%%22", parts[0], parts[1]) + + default: + return "" + } +} + +func parseBugResponse(body []byte, repoURL string) (*BugCheckResult, error) { + u, _ := url.Parse(repoURL) + if u == nil { + return &BugCheckResult{}, nil + } + + host := u.Host + var count int + var urls []string + + switch host { + case "github.com", "gitlab.com": + var issues []struct { + HTMLURL string `json:"html_url"` + } + if err := json.Unmarshal(body, &issues); err != nil { + return nil, fmt.Errorf("parse issues JSON: %w", err) + } + for _, issue := range issues { + count++ + if issue.HTMLURL != "" { + urls = append(urls, issue.HTMLURL) + } + } + + case "bitbucket.org": + var result struct { + Values []struct { + Links struct { + HTML struct { + Href string `json:"href"` + } `json:"html"` + } `json:"links"` + } `json:"values"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse bitbucket issues: %w", err) + } + for _, v := range result.Values { + count++ + if v.Links.HTML.Href != "" { + urls = append(urls, v.Links.HTML.Href) + } + } + + default: + _ = json.Unmarshal(body, &count) + if count > 0 { + parts := strings.Split(strings.Trim(path.Ext(u.Path), "/"), "/") + _ = parts + for i := 0; i < count; i++ { + urls = append(urls, sourceIssuesURL(u)) + } + } + } + + return &BugCheckResult{ + HasBugs: count > 0, + Count: count, + URLs: urls, + }, nil +} + +func sourceIssuesURL(u *url.URL) string { + return u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/") + "/issues" +} diff --git a/internal/log/log.go b/internal/log/log.go index 1428d97..74e10e5 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -36,3 +36,7 @@ func Warn(format string, args ...interface{}) { func Error(format string, args ...interface{}) { fmt.Fprintf(output, "ERROR[%s] %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) } + +func Audit(eventType string, details map[string]interface{}) { + Info("audit event=%s details=%v", eventType, details) +} From 4c6c5168d9d0034d151758411385b80781437474 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 07:59:27 +0000 Subject: [PATCH 3/4] feat: multi-registry config with official/alternative sections and section name resolution --- cmd/install.go | 25 +++++++- go.mod | 2 +- go.sum | 21 +------ internal/config/config.go | 109 +++++++++++++++++++++++++++++---- internal/config/config_test.go | 107 +++++++++++++++++++++++++++++--- 5 files changed, 221 insertions(+), 43 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index 21acb5b..f420798 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -184,13 +184,32 @@ func cacheDir() string { func resolveRegistry() string { if registryURL != "" { - return registryURL + if isURL(registryURL) { + return registryURL + } + cfg, err := config.Load(config.RegistriesPath()) + if err != nil { + return defaultPrimary() + } + url, err := cfg.Resolve(registryURL) + if err != nil { + return defaultPrimary() + } + return url } cfg, err := config.Load(config.RegistriesPath()) if err != nil { - return "https://registry.cognitive-os.org/v1" + return defaultPrimary() } - return cfg.DefaultRegistry + return cfg.Official.Primary +} + +func isURL(s string) bool { + return len(s) > 4 && (s[:4] == "http" || s[:5] == "https") +} + +func defaultPrimary() string { + return "https://registry-us-all-distros-official.cognitive-os.org/v1" } func init() { diff --git a/go.mod b/go.mod index cc73f02..8842fb0 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/CognitiveOS-Project/cpm go 1.23.4 require ( + github.com/BurntSushi/toml v1.6.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 - gopkg.in/ini.v1 v1.67.3 ) require ( diff --git a/go.sum b/go.sum index 525af5b..396684a 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,10 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= @@ -15,21 +12,7 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= -gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 8273bdf..4573e41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,37 +3,122 @@ package config import ( "fmt" "os" + "strings" - "gopkg.in/ini.v1" + "github.com/BurntSushi/toml" ) -type Config struct { - DefaultRegistry string +type RegistryEntry struct { + Name string + URL string } -func Load(path string) (*Config, error) { - cfg := &Config{DefaultRegistry: "https://registry.cognitive-os.org/v1"} +type Registries struct { + Official OfficialRegistries + Alternatives map[string]string +} + +type OfficialRegistries struct { + Primary string + Mirrors map[string]string +} + +func Load(path string) (*Registries, error) { + r := defaultRegistries() data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return cfg, nil + return r, nil } return nil, fmt.Errorf("read config: %w", err) } - file, err := ini.Load(data) - if err != nil { + var file struct { + Official struct { + Primary string `toml:"primary"` + Mirrors map[string]string `toml:"mirrors"` + } `toml:"official"` + Alternatives map[string]string `toml:"alternative"` + } + if err := toml.Unmarshal(data, &file); err != nil { return nil, fmt.Errorf("parse config: %w", err) } - sec := file.Section("default") - if sec != nil && sec.HasKey("url") { - cfg.DefaultRegistry = sec.Key("url").String() + if file.Official.Primary != "" { + r.Official.Primary = file.Official.Primary + } + if file.Official.Mirrors != nil { + r.Official.Mirrors = file.Official.Mirrors + } + if file.Alternatives != nil { + r.Alternatives = file.Alternatives + } + + return r, nil +} + +func defaultRegistries() *Registries { + return &Registries{ + Official: OfficialRegistries{ + Primary: "https://registry-us-all-distros-official.cognitive-os.org/v1", + Mirrors: map[string]string{}, + }, + Alternatives: map[string]string{}, } - return cfg, nil } func RegistriesPath() string { return "/etc/cognitiveos/registries.toml" } + +func (r *Registries) Resolve(section string) (string, error) { + if section == "" { + return r.Official.Primary, nil + } + + parts := strings.SplitN(section, ".", 2) + + switch parts[0] { + case "official": + if len(parts) == 1 { + return r.Official.Primary, nil + } + name := parts[1] + if url, ok := r.Official.Mirrors[name]; ok { + return url, nil + } + return "", fmt.Errorf("official mirror %q not found in registries", name) + + case "alternative": + if len(parts) < 2 { + return "", fmt.Errorf("alternative section requires a name, e.g. alternative.community") + } + url, ok := r.Alternatives[parts[1]] + if !ok { + return "", fmt.Errorf("alternative registry %q not found in registries", parts[1]) + } + return url, nil + + default: + return "", fmt.Errorf("unknown registry section %q (expected official or alternative)", parts[0]) + } +} + +func (r *Registries) All() []RegistryEntry { + var entries []RegistryEntry + + if r.Official.Primary != "" { + entries = append(entries, RegistryEntry{Name: "official", URL: r.Official.Primary}) + } + + for name, url := range r.Official.Mirrors { + entries = append(entries, RegistryEntry{Name: "official." + name, URL: url}) + } + + for name, url := range r.Alternatives { + entries = append(entries, RegistryEntry{Name: "alternative." + name, URL: url}) + } + + return entries +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index befa330..2ff6b9a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,12 +7,12 @@ import ( ) func TestLoadDefaultsWhenNoFile(t *testing.T) { - cfg, err := Load("/nonexistent/path.toml") + r, err := Load("/nonexistent/path.toml") if err != nil { t.Fatalf("Load should not error when file missing: %v", err) } - if cfg.DefaultRegistry != "https://registry.cognitive-os.org/v1" { - t.Fatalf("expected default registry, got %s", cfg.DefaultRegistry) + if r.Official.Primary != "https://registry-us-all-distros-official.cognitive-os.org/v1" { + t.Fatalf("expected default primary, got %s", r.Official.Primary) } } @@ -20,15 +20,106 @@ func TestLoadFromFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "registries.toml") os.WriteFile(path, []byte(` -[default] -url = "https://my-registry.example.com/v1" +[official] +primary = "https://registry-us-all-distros-official.cognitive-os.org/v1" + +[official.mirrors] +eu = "https://registry-eu-all-distros-official.cognitive-os.org/v1" +jp = "https://registry-jp-all-distros-official.cognitive-os.org/v1" + +[alternative] +community = "https://community-registry.cognitive-os.org/v1" +my-private = "https://my-registry.example.com/v1" +`), 0644) + + r, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if r.Official.Primary != "https://registry-us-all-distros-official.cognitive-os.org/v1" { + t.Fatalf("expected primary, got %s", r.Official.Primary) + } + + if len(r.Official.Mirrors) != 2 { + t.Fatalf("expected 2 mirrors, got %d", len(r.Official.Mirrors)) + } + if r.Official.Mirrors["eu"] != "https://registry-eu-all-distros-official.cognitive-os.org/v1" { + t.Fatalf("expected eu mirror, got %s", r.Official.Mirrors["eu"]) + } + + if r.Alternatives["community"] != "https://community-registry.cognitive-os.org/v1" { + t.Fatalf("expected community registry, got %s", r.Alternatives["community"]) + } +} + +func TestResolve(t *testing.T) { + r, _ := Load("/nonexistent/path.toml") + + url, err := r.Resolve("") + if err != nil { + t.Fatalf("Resolve empty should not error: %v", err) + } + if url != "https://registry-us-all-distros-official.cognitive-os.org/v1" { + t.Fatalf("expected default primary, got %s", url) + } + + url, err = r.Resolve("official") + if err != nil { + t.Fatalf("Resolve official should not error: %v", err) + } + if url != "https://registry-us-all-distros-official.cognitive-os.org/v1" { + t.Fatalf("expected official primary, got %s", url) + } +} + +func TestResolveCustom(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "registries.toml") + os.WriteFile(path, []byte(` +[official] +primary = "https://primary.example.com/v1" + +[official.mirrors] +eu = "https://eu.example.com/v1" + +[alternative] +mine = "https://mine.example.com/v1" `), 0644) - cfg, err := Load(path) + r, err := Load(path) if err != nil { t.Fatalf("Load failed: %v", err) } - if cfg.DefaultRegistry != "https://my-registry.example.com/v1" { - t.Fatalf("expected custom registry, got %s", cfg.DefaultRegistry) + + url, err := r.Resolve("official.eu") + if err != nil { + t.Fatalf("Resolve official.eu: %v", err) + } + if url != "https://eu.example.com/v1" { + t.Fatalf("expected eu mirror, got %s", url) + } + + url, err = r.Resolve("alternative.mine") + if err != nil { + t.Fatalf("Resolve alternative.mine: %v", err) + } + if url != "https://mine.example.com/v1" { + t.Fatalf("expected mine, got %s", url) + } + + _, err = r.Resolve("official.bogus") + if err == nil { + t.Fatal("expected error for unknown mirror") + } + + _, err = r.Resolve("alternative") + if err == nil { + t.Fatal("expected error for alternative without name") + } + + _, err = r.Resolve("bogus") + if err == nil { + t.Fatal("expected error for unknown section") } } From a2f63bf2fce17bc3fc8d1e70071d0271f7f15eae Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 04:25:23 -0400 Subject: [PATCH 4/4] feat: implement universal protocol resolver with normalization engine and checksum notary (#23) --- cmd/install.go | 149 +++++++++++---------------- internal/checksum/checksum.go | 42 ++++++++ internal/checksum/checksum_test.go | 36 +++++++ internal/normalize/normalize.go | 126 ++++++++++++++++++++++ internal/normalize/normalize_test.go | 100 ++++++++++++++++++ internal/resolver/deno.go | 127 +++++++++++++++++++++++ internal/resolver/ghr.go | 129 +++++++++++++++++++++++ internal/resolver/git.go | 37 +++++++ internal/resolver/helpers.go | 81 +++++++++++++++ internal/resolver/npm.go | 68 ++++++++++++ internal/resolver/registry.go | 17 +++ internal/resolver/resolver.go | 126 ++++++++++++++++++++++ internal/resolver/url.go | 14 +++ 13 files changed, 966 insertions(+), 86 deletions(-) create mode 100644 internal/checksum/checksum.go create mode 100644 internal/checksum/checksum_test.go create mode 100644 internal/normalize/normalize.go create mode 100644 internal/normalize/normalize_test.go create mode 100644 internal/resolver/deno.go create mode 100644 internal/resolver/ghr.go create mode 100644 internal/resolver/git.go create mode 100644 internal/resolver/helpers.go create mode 100644 internal/resolver/npm.go create mode 100644 internal/resolver/registry.go create mode 100644 internal/resolver/resolver.go create mode 100644 internal/resolver/url.go diff --git a/cmd/install.go b/cmd/install.go index f420798..832cc34 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,17 +2,15 @@ package cmd import ( "fmt" - "io" "os" "path/filepath" - "github.com/CognitiveOS-Project/cpm/internal/archive" "github.com/CognitiveOS-Project/cpm/internal/audit" "github.com/CognitiveOS-Project/cpm/internal/check" "github.com/CognitiveOS-Project/cpm/internal/config" "github.com/CognitiveOS-Project/cpm/internal/log" "github.com/CognitiveOS-Project/cpm/internal/patch" - "github.com/CognitiveOS-Project/cpm/internal/registry" + "github.com/CognitiveOS-Project/cpm/internal/resolver" "github.com/CognitiveOS-Project/cpm/internal/schema" "github.com/spf13/cobra" ) @@ -20,11 +18,17 @@ import ( var installCmd = &cobra.Command{ Use: "install ", Short: "Install a .cgp cognitive patch", - Long: `Install a patch from a local .cgp file or resolve from registry. - -Examples: - cpm install ./email-manager.cgp - cpm install email-manager`, + Long: `Install a cognitive patch from any source using the universal protocol resolver. + +Sources include: + - Local .cgp file: cpm install ./email-manager.cgp + - Registry name: cpm install email-manager + - GitHub repo: cpm install github.com/user/repo@v1.0.0 + - GitHub Release: cpm install ghr:user/repo@v1.0.0 + - npm package: cpm install npm:@scope/name + - Bun package: cpm install bun:name + - Deno module: cpm install deno:@scope/name + - Direct URL: cpm install https://example.com/pkg.cgp`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { target := args[0] @@ -34,76 +38,15 @@ Examples: return fmt.Errorf("already installed") } - // Determine source - var m *archive.Manifest - var dataPath string - - if fi, err := os.Stat(target); err == nil && !fi.IsDir() { - f, err := os.Open(target) - if err != nil { - return fmt.Errorf("open %s: %w", target, err) - } - defer f.Close() - - m, err = archive.ReadManifest(f) - if err != nil { - return fmt.Errorf("read archive: %w", err) - } - dataPath = target - } else { - regURL := resolveRegistry() - if regURL == "" { - return fmt.Errorf("no registry configured") - } - rc := registry.New(regURL) - - meta, err := rc.GetMetadata(target, "") - if err != nil { - return fmt.Errorf("resolve %q from registry: %w", target, err) - } - - cacheDir := cacheDir() - _ = os.MkdirAll(cacheDir, 0755) - dataPath = filepath.Join(cacheDir, meta.Name+"-"+meta.Version+".cgp") - - if _, err := os.Stat(dataPath); err != nil { - tmpPath := dataPath + ".tmp" - body, err := rc.Download(meta.Name, meta.Version) - if err != nil { - return fmt.Errorf("download: %w", err) - } - - f, err := os.Create(tmpPath) - if err != nil { - body.Close() - return fmt.Errorf("create temp: %w", err) - } - if _, err := io.Copy(f, body); err != nil { - body.Close() - f.Close() - os.Remove(tmpPath) - return fmt.Errorf("write temp: %w", err) - } - body.Close() - f.Close() - - if err := os.Rename(tmpPath, dataPath); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("rename: %w", err) - } - } - - f, err := os.Open(dataPath) - if err != nil { - return fmt.Errorf("open cached: %w", err) - } - m, err = archive.ReadManifest(f) - f.Close() - if err != nil { - return fmt.Errorf("read manifest: %w", err) - } + // Determine source via universal protocol resolver + regURL := resolveRegistry() + result, err := resolver.Resolve(target, regURL) + if err != nil { + return fmt.Errorf("resolve %q: %w", target, err) } + m := result.Manifest + // Validate schema doc := map[string]interface{}{ "name": m.Name, @@ -152,21 +95,31 @@ Examples: } } - // Extract + // Move extracted data to install path installPath := patch.Dir(m.Name) - if err := os.MkdirAll(installPath, 0755); err != nil { - return fmt.Errorf("create install dir: %w", err) + _ = os.RemoveAll(installPath) + if err := os.MkdirAll(filepath.Dir(installPath), 0755); err != nil { + _ = os.RemoveAll(result.DataDir) + return fmt.Errorf("create parent dir: %w", err) } - f, err := os.Open(dataPath) - if err != nil { - return fmt.Errorf("open archive: %w", err) + if err := os.Rename(result.DataDir, installPath); err != nil { + // Fallback: copy across filesystems + if err := copyDir(result.DataDir, installPath); err != nil { + _ = os.RemoveAll(installPath) + _ = os.RemoveAll(result.DataDir) + return fmt.Errorf("extract: %w", err) + } + _ = os.RemoveAll(result.DataDir) } - defer f.Close() - if err := archive.Extract(f, installPath); err != nil { - os.RemoveAll(installPath) - return fmt.Errorf("extract: %w", err) + // Log checksum for audit trail + if result.Checksum != "" { + log.Audit("checksum", map[string]interface{}{ + "name": m.Name, + "version": m.Version, + "checksum": result.Checksum, + }) } log.Info("Installed %s v%s", m.Name, m.Version) @@ -212,6 +165,30 @@ func defaultPrimary() string { return "https://registry-us-all-distros-official.cognitive-os.org/v1" } +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, info.Mode()) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, info.Mode()) + }) +} + func init() { rootCmd.AddCommand(installCmd) } diff --git a/internal/checksum/checksum.go b/internal/checksum/checksum.go new file mode 100644 index 0000000..dbbef7b --- /dev/null +++ b/internal/checksum/checksum.go @@ -0,0 +1,42 @@ +package checksum + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" +) + +func OfFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("hash: %w", err) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func OfReader(r io.Reader) (string, error) { + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return "", fmt.Errorf("hash: %w", err) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func Verify(path, expected string) error { + actual, err := OfFile(path) + if err != nil { + return err + } + if actual != expected { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual) + } + return nil +} diff --git a/internal/checksum/checksum_test.go b/internal/checksum/checksum_test.go new file mode 100644 index 0000000..f8b7608 --- /dev/null +++ b/internal/checksum/checksum_test.go @@ -0,0 +1,36 @@ +package checksum + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOfFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.bin") + os.WriteFile(path, []byte("hello world"), 0644) + + sum, err := OfFile(path) + if err != nil { + t.Fatalf("OfFile failed: %v", err) + } + if sum != "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" { + t.Fatalf("unexpected hash: %s", sum) + } +} + +func TestVerify(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.bin") + os.WriteFile(path, []byte("data"), 0644) + + sum, _ := OfFile(path) + if err := Verify(path, sum); err != nil { + t.Fatalf("Verify should pass: %v", err) + } + + if err := Verify(path, "badhash"); err == nil { + t.Fatal("Verify should fail on bad hash") + } +} diff --git a/internal/normalize/normalize.go b/internal/normalize/normalize.go new file mode 100644 index 0000000..baf1c50 --- /dev/null +++ b/internal/normalize/normalize.go @@ -0,0 +1,126 @@ +package normalize + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/CognitiveOS-Project/cpm/internal/archive" +) + +type Result struct { + Manifest *archive.Manifest + DataDir string +} + +func Archive(path string) (*Result, error) { + dir, err := os.MkdirTemp("", "cpm-normalize-*") + if err != nil { + return nil, fmt.Errorf("create temp dir: %w", err) + } + + f, err := os.Open(path) + if err != nil { + os.RemoveAll(dir) + return nil, fmt.Errorf("open: %w", err) + } + defer f.Close() + + if err := archive.Extract(f, dir); err != nil { + os.RemoveAll(dir) + return nil, fmt.Errorf("extract: %w", err) + } + + m, err := detectManifest(dir) + if err != nil { + os.RemoveAll(dir) + return nil, err + } + + return &Result{Manifest: m, DataDir: dir}, nil +} + +func detectManifest(dir string) (*archive.Manifest, error) { + cogPath := filepath.Join(dir, "cognitive.json") + if data, err := os.ReadFile(cogPath); err == nil { + var m archive.Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse cognitive.json: %w", err) + } + return &m, nil + } + + pkgPath := filepath.Join(dir, "package.json") + if data, err := os.ReadFile(pkgPath); err == nil { + return parsePackageJSON(data) + } + + return nil, fmt.Errorf("no manifest found: expected cognitive.json or package.json") +} + +type packageJSON struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + License string `json:"license,omitempty"` + CognitiveOS map[string]interface{} `json:"cognitive_os"` +} + +func parsePackageJSON(data []byte) (*archive.Manifest, error) { + var pkg packageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, fmt.Errorf("parse package.json: %w", err) + } + + if pkg.Name == "" || pkg.Version == "" { + return nil, fmt.Errorf("package.json missing required fields: name, version") + } + + m := &archive.Manifest{ + Name: pkg.Name, + Version: pkg.Version, + Description: pkg.Description, + Author: pkg.Author, + License: pkg.License, + } + + if pkg.CognitiveOS != nil { + if v, ok := pkg.CognitiveOS["runtime"]; ok { + data, _ := json.Marshal(v) + var rt archive.RuntimeConfig + if err := json.Unmarshal(data, &rt); err == nil { + m.Runtime = &rt + } + } + if v, ok := pkg.CognitiveOS["hardware_requirements"]; ok { + data, _ := json.Marshal(v) + var hr archive.HardwareReq + if err := json.Unmarshal(data, &hr); err == nil { + m.HardwareRequirements = &hr + } + } + if v, ok := pkg.CognitiveOS["source"]; ok { + data, _ := json.Marshal(v) + var src archive.SourceInfo + if err := json.Unmarshal(data, &src); err == nil { + m.Source = &src + } + } + if v, ok := pkg.CognitiveOS["dependencies"]; ok { + switch d := v.(type) { + case map[string]interface{}: + deps := make(map[string]string) + for k, val := range d { + if s, ok := val.(string); ok { + deps[k] = s + } + } + m.Dependencies = deps + } + } + } + + return m, nil +} diff --git a/internal/normalize/normalize_test.go b/internal/normalize/normalize_test.go new file mode 100644 index 0000000..679f2f8 --- /dev/null +++ b/internal/normalize/normalize_test.go @@ -0,0 +1,100 @@ +package normalize + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +func writeTestArchive(t *testing.T, dir string, files map[string]string) string { + t.Helper() + path := filepath.Join(dir, "pkg.tar.gz") + + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + for name, content := range files { + hdr := &tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: 0644, + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + + tw.Close() + gw.Close() + return path +} + +func TestArchiveCognitiveJSON(t *testing.T) { + dir := t.TempDir() + path := writeTestArchive(t, dir, map[string]string{ + "cognitive.json": `{"name":"test-pkg","version":"1.0.0","description":"test"}`, + }) + + result, err := Archive(path) + if err != nil { + t.Fatalf("Archive failed: %v", err) + } + if result.Manifest.Name != "test-pkg" { + t.Fatalf("expected test-pkg, got %s", result.Manifest.Name) + } + if result.Manifest.Version != "1.0.0" { + t.Fatalf("expected 1.0.0, got %s", result.Manifest.Version) + } + os.RemoveAll(result.DataDir) +} + +func TestArchivePackageJSON(t *testing.T) { + dir := t.TempDir() + path := writeTestArchive(t, dir, map[string]string{ + "package.json": `{ + "name":"my-npm-pkg", + "version":"2.0.0", + "description":"npm package", + "author":"test", + "cognitive_os": { + "runtime": "nodejs:18", + "dependencies": {"hello":"^1.0.0"} + } + }`, + }) + + result, err := Archive(path) + if err != nil { + t.Fatalf("Archive failed: %v", err) + } + if result.Manifest.Name != "my-npm-pkg" { + t.Fatalf("expected my-npm-pkg, got %s", result.Manifest.Name) + } + if result.Manifest.Dependencies["hello"] != "^1.0.0" { + t.Fatalf("expected dep hello:^1.0.0, got %s", result.Manifest.Dependencies["hello"]) + } + os.RemoveAll(result.DataDir) +} + +func TestArchiveNoManifest(t *testing.T) { + dir := t.TempDir() + path := writeTestArchive(t, dir, map[string]string{ + "readme.md": "# hello", + }) + + _, err := Archive(path) + if err == nil { + t.Fatal("expected error for no manifest") + } +} diff --git a/internal/resolver/deno.go b/internal/resolver/deno.go new file mode 100644 index 0000000..27bfab8 --- /dev/null +++ b/internal/resolver/deno.go @@ -0,0 +1,127 @@ +package resolver + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +type denoModule struct { + Name string `json:"name"` + Latest string `json:"latest"` + Versions []struct { + Version string `json:"version"` + } `json:"versions,omitempty"` +} + +type jsrPackage struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Dist struct { + Tarball string `json:"tarball"` + } `json:"dist,omitempty"` +} + +func resolveDeno(target string) (*Result, error) { + target = strings.TrimPrefix(target, "deno:") + + version := "latest" + parts := strings.SplitN(target, "@", 2) + pkgName := parts[0] + if len(parts) == 2 { + version = parts[1] + } + + // Try JSR first for scoped packages + if strings.Count(pkgName, "/") == 1 { + result, err := resolveJSR(pkgName, version) + if err == nil { + return result, nil + } + } + + // Fallback to deno.land/x/ + return resolveDenoLand(pkgName, version) +} + +func resolveDenoLand(pkgName, version string) (*Result, error) { + url := fmt.Sprintf("https://apiland.deno.dev/v2/modules/%s", pkgName) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "cpm/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch deno module %s: %w", pkgName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("deno module %s: HTTP %d", pkgName, resp.StatusCode) + } + + var mod denoModule + if err := json.NewDecoder(resp.Body).Decode(&mod); err != nil { + return nil, fmt.Errorf("parse deno response: %w", err) + } + + ver := mod.Latest + if version != "latest" { + ver = version + } + + tarballURL := fmt.Sprintf("https://apiland.deno.dev/v2/modules/%s/versions/%s/tarball", pkgName, ver) + archivePath, sum, err := downloadArchive(tarballURL) + if err != nil { + return nil, fmt.Errorf("download deno module: %w", err) + } + + return normalizeArchive(archivePath, sum) +} + +func resolveJSR(pkgName, version string) (*Result, error) { + scope, name := splitJSRPackage(pkgName) + npmName := fmt.Sprintf("@jsr/%s__%s", scope, name) + url := fmt.Sprintf("https://registry.npmjs.org/%s", npmName) + if version != "latest" { + url += "/" + version + } + + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "cpm/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch JSR package: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JSR package %s: HTTP %d", pkgName, resp.StatusCode) + } + + var pkg jsrPackage + if err := json.NewDecoder(resp.Body).Decode(&pkg); err != nil { + return nil, fmt.Errorf("parse JSR response: %w", err) + } + + if pkg.Dist.Tarball == "" { + return nil, fmt.Errorf("JSR package %s has no tarball", pkgName) + } + + archivePath, sum, err := downloadArchive(pkg.Dist.Tarball) + if err != nil { + return nil, fmt.Errorf("download JSR tarball: %w", err) + } + + return normalizeArchive(archivePath, sum) +} + +func splitJSRPackage(pkgName string) (string, string) { + parts := strings.SplitN(pkgName, "/", 2) + if len(parts) == 2 { + return parts[0], parts[1] + } + return pkgName, pkgName +} diff --git a/internal/resolver/ghr.go b/internal/resolver/ghr.go new file mode 100644 index 0000000..3219a5a --- /dev/null +++ b/internal/resolver/ghr.go @@ -0,0 +1,129 @@ +package resolver + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/CognitiveOS-Project/cpm/internal/checksum" +) + +type release struct { + TagName string `json:"tag_name"` + Assets []asset `json:"assets"` +} + +type asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func resolveGHR(target string) (*Result, error) { + target = strings.TrimPrefix(target, "ghr:") + + parts := strings.SplitN(target, "@", 2) + repo := parts[0] + tag := "" + if len(parts) == 2 { + tag = parts[1] + } + + if tag != "" { + return resolveGHRAsset(repo, tag) + } + + // No tag — fetch latest release + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "cpm/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("latest release: HTTP %d", resp.StatusCode) + } + + var rel release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("parse release: %w", err) + } + + return downloadGHRAsset(repo, &rel) +} + +func resolveGHRAsset(repo, tag string) (*Result, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repo, tag) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "cpm/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch release %s: %w", tag, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("release %s: HTTP %d", tag, resp.StatusCode) + } + + var rel release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("parse release: %w", err) + } + + return downloadGHRAsset(repo, &rel) +} + +func downloadGHRAsset(repo string, rel *release) (*Result, error) { + var cgpAsset *asset + for _, a := range rel.Assets { + if strings.HasSuffix(a.Name, ".cgp") || strings.HasSuffix(a.Name, ".tar.gz") || strings.HasSuffix(a.Name, ".tgz") { + cgpAsset = &a + break + } + } + if cgpAsset == nil { + return nil, fmt.Errorf("no .cgp/.tar.gz asset in release %s of %s", rel.TagName, repo) + } + + archivePath := filepath.Join(os.TempDir(), fmt.Sprintf("cpm-ghr-%s-%s", sanitize(repo), cgpAsset.Name)) + f, err := os.Create(archivePath) + if err != nil { + return nil, fmt.Errorf("create temp file: %w", err) + } + + req, _ := http.NewRequest("GET", cgpAsset.BrowserDownloadURL, nil) + req.Header.Set("User-Agent", "cpm/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + f.Close() + os.Remove(archivePath) + return nil, fmt.Errorf("download asset: %w", err) + } + defer resp.Body.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(archivePath) + return nil, fmt.Errorf("write asset: %w", err) + } + f.Close() + + sum, err := checksum.OfFile(archivePath) + if err != nil { + return nil, err + } + + return normalizeArchive(archivePath, sum) +} diff --git a/internal/resolver/git.go b/internal/resolver/git.go new file mode 100644 index 0000000..8c0913a --- /dev/null +++ b/internal/resolver/git.go @@ -0,0 +1,37 @@ +package resolver + +import ( + "fmt" + "strings" +) + +func resolveGit(target, provider string) (*Result, error) { + ref := "HEAD" + target = strings.TrimPrefix(target, provider+":") + + parts := strings.SplitN(target, "@", 2) + if len(parts) == 2 { + target = parts[0] + ref = parts[1] + } + + var apiURL string + switch provider { + case "github.com": + apiURL = fmt.Sprintf("https://api.github.com/repos/%s/tarball/%s", target, ref) + case "gitlab.com": + apiURL = fmt.Sprintf("https://gitlab.com/api/v4/projects/%s/repository/archive.tar.gz?ref=%s", + strings.ReplaceAll(target, "/", "%2F"), ref) + case "bitbucket.org": + apiURL = fmt.Sprintf("https://bitbucket.org/%s/get/%s.tar.gz", target, ref) + default: + return nil, fmt.Errorf("unsupported git provider: %s", provider) + } + + archivePath, sum, err := downloadArchive(apiURL) + if err != nil { + return nil, fmt.Errorf("download %s: %w", provider, err) + } + + return normalizeArchive(archivePath, sum) +} diff --git a/internal/resolver/helpers.go b/internal/resolver/helpers.go new file mode 100644 index 0000000..4aa8ef5 --- /dev/null +++ b/internal/resolver/helpers.go @@ -0,0 +1,81 @@ +package resolver + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/CognitiveOS-Project/cpm/internal/checksum" + "github.com/CognitiveOS-Project/cpm/internal/normalize" +) + +func sanitize(s string) string { + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + return r + } + return '_' + }, s) +} + +func downloadArchive(url string) (string, string, error) { + name := sanitize(filepath.Base(url)) + if name == "" || name == "." { + name = "archive" + } + archivePath := filepath.Join(os.TempDir(), fmt.Sprintf("cpm-dl-%s", name)) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("User-Agent", "cpm/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + f, err := os.Create(archivePath) + if err != nil { + return "", "", fmt.Errorf("create temp file: %w", err) + } + + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(archivePath) + return "", "", fmt.Errorf("write archive: %w", err) + } + f.Close() + + sum, err := checksum.OfFile(archivePath) + if err != nil { + os.Remove(archivePath) + return "", "", err + } + + return archivePath, sum, nil +} + +func normalizeArchive(archivePath, checksum string) (*Result, error) { + nr, err := normalize.Archive(archivePath) + if err != nil { + os.Remove(archivePath) + return nil, err + } + + return &Result{ + Manifest: nr.Manifest, + ArchivePath: archivePath, + DataDir: nr.DataDir, + Checksum: checksum, + }, nil +} diff --git a/internal/resolver/npm.go b/internal/resolver/npm.go new file mode 100644 index 0000000..89971be --- /dev/null +++ b/internal/resolver/npm.go @@ -0,0 +1,68 @@ +package resolver + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +type npmPackage struct { + Name string `json:"name"` + Version string `json:"version"` + Dist struct { + Tarball string `json:"tarball"` + } `json:"dist"` +} + +func resolveNPM(target string) (*Result, error) { + target = strings.TrimPrefix(target, "npm:") + target = strings.TrimPrefix(target, "bun:") + + version := "latest" + pkgName := target + + if strings.HasPrefix(target, "@") { + // @scope/name@version + if idx := strings.LastIndex(target, "@"); idx > 1 { + pkgName = target[:idx] + version = target[idx+1:] + } + } else { + if idx := strings.Index(target, "@"); idx > 0 { + pkgName = target[:idx] + version = target[idx+1:] + } + } + + url := fmt.Sprintf("https://registry.npmjs.org/%s/%s", pkgName, version) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "cpm/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch npm package %s: %w", pkgName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("npm package %s: HTTP %d", pkgName, resp.StatusCode) + } + + var pkg npmPackage + if err := json.NewDecoder(resp.Body).Decode(&pkg); err != nil { + return nil, fmt.Errorf("parse npm response: %w", err) + } + + if pkg.Dist.Tarball == "" { + return nil, fmt.Errorf("npm package %s has no tarball", pkgName) + } + + archivePath, sum, err := downloadArchive(pkg.Dist.Tarball) + if err != nil { + return nil, fmt.Errorf("download tarball: %w", err) + } + + return normalizeArchive(archivePath, sum) +} diff --git a/internal/resolver/registry.go b/internal/resolver/registry.go new file mode 100644 index 0000000..e8ef03e --- /dev/null +++ b/internal/resolver/registry.go @@ -0,0 +1,17 @@ +package resolver + +import ( + "fmt" + "strings" +) + +func resolveRegistry(target, registryURL string) (*Result, error) { + pkgURL := strings.TrimRight(registryURL, "/") + "/v1/packages/" + target + + archivePath, sum, err := downloadArchive(pkgURL) + if err != nil { + return nil, fmt.Errorf("registry lookup %s: %w", target, err) + } + + return normalizeArchive(archivePath, sum) +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go new file mode 100644 index 0000000..4b49d16 --- /dev/null +++ b/internal/resolver/resolver.go @@ -0,0 +1,126 @@ +package resolver + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/CognitiveOS-Project/cpm/internal/archive" + "github.com/CognitiveOS-Project/cpm/internal/checksum" + "github.com/CognitiveOS-Project/cpm/internal/normalize" +) + +type Result struct { + Manifest *archive.Manifest + ArchivePath string + DataDir string + Checksum string +} + +func Resolve(target string, registryURL string) (*Result, error) { + if isLocalPath(target) { + return resolveLocal(target) + } + + if strings.HasPrefix(target, "github.com/") || strings.HasPrefix(target, "github:") { + return resolveGit(target, "github.com") + } + + if strings.HasPrefix(target, "gitlab:") { + return resolveGit(target, "gitlab.com") + } + + if strings.HasPrefix(target, "bitbucket:") { + return resolveGit(target, "bitbucket.org") + } + + if strings.HasPrefix(target, "ghr:") { + return resolveGHR(target) + } + + if strings.HasPrefix(target, "npm:") { + return resolveNPM(target) + } + + if strings.HasPrefix(target, "bun:") { + return resolveNPM(target) // bun uses npm registry with different slug + } + + if strings.HasPrefix(target, "deno:") { + return resolveDeno(target) + } + + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + return resolveURL(target) + } + + if registryURL != "" { + return resolveRegistry(target, registryURL) + } + + return nil, fmt.Errorf("unable to resolve %q: no registry URL configured and no protocol handler matched", target) +} + +func isLocalPath(target string) bool { + if strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") || strings.HasPrefix(target, "/") { + return true + } + if strings.Contains(target, string(filepath.Separator)) { + info, err := os.Stat(target) + return err == nil && (info.IsDir() || strings.HasSuffix(target, ".cgp")) + } + return false +} + +func resolveLocal(target string) (*Result, error) { + info, err := os.Stat(target) + if err != nil { + return nil, fmt.Errorf("local path %q: %w", target, err) + } + + if info.IsDir() { + return resolveLocalDir(target) + } + + if strings.HasSuffix(target, ".cgp") || strings.HasSuffix(target, ".tar.gz") || strings.HasSuffix(target, ".tgz") { + return resolveLocalArchive(target) + } + + return nil, fmt.Errorf("local path %q is not a .cgp/.tar.gz archive or directory", target) +} + +func resolveLocalDir(target string) (*Result, error) { + data, err := os.ReadFile(filepath.Join(target, "cognitive.json")) + if err != nil { + return nil, fmt.Errorf("directory %q has no cognitive.json: %w", target, err) + } + var m archive.Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &Result{ + Manifest: &m, + DataDir: target, + }, nil +} + +func resolveLocalArchive(target string) (*Result, error) { + sum, err := checksum.OfFile(target) + if err != nil { + return nil, err + } + + result, err := normalize.Archive(target) + if err != nil { + return nil, err + } + + return &Result{ + Manifest: result.Manifest, + ArchivePath: target, + DataDir: result.DataDir, + Checksum: sum, + }, nil +} diff --git a/internal/resolver/url.go b/internal/resolver/url.go new file mode 100644 index 0000000..4cee6aa --- /dev/null +++ b/internal/resolver/url.go @@ -0,0 +1,14 @@ +package resolver + +import ( + "fmt" +) + +func resolveURL(target string) (*Result, error) { + archivePath, sum, err := downloadArchive(target) + if err != nil { + return nil, fmt.Errorf("download %s: %w", target, err) + } + + return normalizeArchive(archivePath, sum) +}