From 268403e49b635f92efed5b55122313bb3b02eb5b Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Tue, 23 Jun 2026 00:22:30 -0400 Subject: [PATCH 01/13] feat: initial cpm implementation * feat: initial cpm implementation All 8 commands: init, install (local + registry), remove, list, info, verify, search, update. Includes hardware audit, JSON Schema validation (embedded), dependency resolution, registries.toml config, structured logging, and CI pipeline. Tests: archive (4), schema (2), config (2), dep (3), audit (5) = 16 total * chore: remove committed binary, add to .gitignore --- .github/workflows/ci.yml | 31 +++++ .gitignore | 1 + AGENTS.md | 45 +++++-- README.md | 54 +++++++- cmd/cpm/main.go | 11 ++ cmd/info.go | 78 ++++++++++++ cmd/init_cmd.go | 80 ++++++++++++ cmd/install.go | 175 ++++++++++++++++++++++++++ cmd/list.go | 42 +++++++ cmd/remove.go | 38 ++++++ cmd/root.go | 36 ++++++ cmd/search.go | 59 +++++++++ cmd/update.go | 104 +++++++++++++++ cmd/verify.go | 99 +++++++++++++++ go.mod | 12 ++ internal/archive/archive.go | 115 +++++++++++++++++ internal/archive/archive_test.go | 128 +++++++++++++++++++ internal/audit/audit.go | 86 +++++++++++++ internal/audit/audit_test.go | 42 +++++++ internal/config/config.go | 39 ++++++ internal/config/config_test.go | 34 +++++ internal/dep/dep.go | 62 +++++++++ internal/dep/dep_test.go | 64 ++++++++++ internal/log/log.go | 38 ++++++ internal/patch/patch.go | 77 ++++++++++++ internal/registry/registry.go | 107 ++++++++++++++++ internal/schema/cognitive.schema.json | 144 +++++++++++++++++++++ internal/schema/schema.go | 40 ++++++ internal/schema/schema_test.go | 29 +++++ 29 files changed, 1855 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 cmd/cpm/main.go create mode 100644 cmd/info.go create mode 100644 cmd/init_cmd.go create mode 100644 cmd/install.go create mode 100644 cmd/list.go create mode 100644 cmd/remove.go create mode 100644 cmd/root.go create mode 100644 cmd/search.go create mode 100644 cmd/update.go create mode 100644 cmd/verify.go create mode 100644 go.mod create mode 100644 internal/archive/archive.go create mode 100644 internal/archive/archive_test.go create mode 100644 internal/audit/audit.go create mode 100644 internal/audit/audit_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/dep/dep.go create mode 100644 internal/dep/dep_test.go create mode 100644 internal/log/log.go create mode 100644 internal/patch/patch.go create mode 100644 internal/registry/registry.go create mode 100644 internal/schema/cognitive.schema.json create mode 100644 internal/schema/schema.go create mode 100644 internal/schema/schema_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ad33c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [development, main, "feature/*", "fix/*"] + pull_request: + branches: [development] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + + - name: Build + run: go build -o bin/cpm ./cmd/cpm + + - name: Lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=3m + + - name: Test + run: go test ./... -v -count=1 + + - name: Vet + run: go vet ./... diff --git a/.gitignore b/.gitignore index 323949e..1a529cb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ Thumbs.db .vscode/ *.swp *.swo +bin/ diff --git a/AGENTS.md b/AGENTS.md index d907af6..9d9db3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,30 +1,49 @@ # Cognitive Package Manager (cpm) -`cpm` is the CognitiveOS package manager — installs, updates, and removes `.cgp` cognitive patches. +`cpm` installs, removes, and manages `.cgp` cognitive patches for CognitiveOS. ## Build ```bash +export PATH="/tmp/go/bin:$(go env GOPATH)/bin:$PATH" go build -o bin/cpm ./cmd/cpm ``` ## Commands -- `cpm install ` — install a `.cgp` patch -- `cpm remove ` — uninstall -- `cpm list` — list installed patches -- `cpm info ` — show manifest details -- `cpm search ` — search registry +| Command | Description | Status | +|---------|-------------|--------| +| `cpm init ` | Create .cgp skeleton | ✅ | +| `cpm install ` | Install from local file or registry | ✅ | +| `cpm remove ` | Uninstall | ✅ | +| `cpm list` | List installed | ✅ | +| `cpm info ` | Show manifest | ✅ | +| `cpm verify ` | Validate archive | ✅ | +| `cpm search ` | Search registry | ✅ | +| `cpm update ` | Update to latest | ✅ | + +## Development + +Set env vars for testing without `/cognitiveos`: +```bash +export CPM_PATCHES_DIR=/tmp/cpm-test/patches +export CPM_CACHE_DIR=/tmp/cpm-test/cache +``` ## Architecture -- Go CLI binary -- Reads `cognitive.json` manifest from `.cgp` archives -- Performs hardware audit before install (RAM, storage, NPU) -- Spawns MCP servers declared in manifest as subprocesses -- Installs to `/cognitiveos/patches//` +- **cmd/** — Cobra CLI commands +- **internal/archive/** — .cgp tar.gz parsing/extraction +- **internal/audit/** — Hardware resource checking +- **internal/config/** — registries.toml parsing +- **internal/dep/** — Dependency resolution +- **internal/log/** — Structured logging +- **internal/patch/** — Patch lifecycle (install/remove/list) +- **internal/registry/** — HTTP client for registry-server +- **internal/schema/** — JSON Schema validation (embedded schema) ## Dependencies -- Uses product-specs for `.cgp` format and `cognitive.json` schema -- Communicates with registry-server for remote installs +- `github.com/spf13/cobra` — CLI framework +- `github.com/santhosh-tekuri/jsonschema/v6` — JSON Schema validation +- `gopkg.in/ini.v1` — TOML config parsing diff --git a/README.md b/README.md index 6f95b2a..24d0d0f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,52 @@ -# cpm -cpm (Cognitive Package Manager) — installs, updates, and removes .cgp cognitive patches. The npm for AI-native skill distribution +# cpm — Cognitive Package Manager + +`cpm` installs, updates, removes, and publishes `.cgp` (Cognitive Patch) files for CognitiveOS. + +It is the npm/pip/apt for the agent era — hardware-aware, MCP-native, and designed for autonomous AI operation. + +## Quick Start + +```bash +go build -o bin/cpm ./cmd/cpm + +# Create a skill skeleton +./bin/cpm init my-skill +cd my-skill +# Edit cognitive.json, add prompts/ and tools/ + +# Install from a local archive +./bin/cpm install ./my-skill.cgp + +# List installed patches +./bin/cpm list + +# Show patch details +./bin/cpm info my-skill + +# Remove a patch +./bin/cpm remove my-skill +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `init ` | Create a .cgp skeleton directory | +| `install ` | Install from local .cgp or registry | +| `remove ` | Uninstall a patch | +| `list` | List installed patches | +| `info ` | Show manifest details | +| `verify ` | Validate a .cgp archive | +| `search ` | Search the registry | +| `update ` | Update to latest version | + +## Development + +```bash +export CPM_PATCHES_DIR=/tmp/cpm-test/patches +export CPM_CACHE_DIR=/tmp/cpm-test/cache +``` + +## License + +MIT diff --git a/cmd/cpm/main.go b/cmd/cpm/main.go new file mode 100644 index 0000000..3c1a65b --- /dev/null +++ b/cmd/cpm/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "github.com/CognitiveOS-Project/cpm/cmd" + "github.com/CognitiveOS-Project/cpm/internal/log" +) + +func main() { + log.Init("") + cmd.Execute() +} diff --git a/cmd/info.go b/cmd/info.go new file mode 100644 index 0000000..80c4aae --- /dev/null +++ b/cmd/info.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/CognitiveOS-Project/cpm/internal/patch" + "github.com/spf13/cobra" +) + +var infoCmd = &cobra.Command{ + Use: "info ", + Short: "Show patch details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + m, err := patch.ReadManifest(name) + if err != nil { + return fmt.Errorf("patch %q not found: %w", name, err) + } + + fmt.Printf("Name: %s\n", m.Name) + fmt.Printf("Version: %s\n", m.Version) + fmt.Printf("Author: %s\n", m.Author) + fmt.Printf("License: %s\n", m.License) + fmt.Printf("Description: %s\n", m.Description) + + if m.HardwareRequirements != nil { + hw := m.HardwareRequirements + fmt.Printf("Hardware: %d MB RAM, %d MB storage", hw.MinRAMMB, hw.MinStorageMB) + if hw.NPURequired { + fmt.Print(", NPU required") + } + fmt.Println() + } + + if len(m.Dependencies) > 0 { + fmt.Println("Dependencies:") + for d, v := range m.Dependencies { + fmt.Printf(" - %s (%s)\n", d, v) + } + } else { + fmt.Println("Dependencies: (none)") + } + + if m.Runtime != nil { + for _, s := range m.Runtime.MCPServers { + fmt.Printf("MCP servers: %s (%s)\n", s.Name, s.Transport) + } + if m.Runtime.Background { + fmt.Println("Background: yes") + } + } + + installedPath := filepath.Join(patch.PatchesDir, name) + if _, err := os.Stat(installedPath); err == nil { + fmt.Printf("Installed: %s\n", installedPath) + fmt.Printf("Size: %d MB\n", dirSize(installedPath)/(1024*1024)) + } + return nil + }, +} + +func dirSize(path string) int64 { + var size int64 + filepath.Walk(path, func(_ string, fi os.FileInfo, err error) error { + if err == nil && !fi.IsDir() { + size += fi.Size() + } + return nil + }) + return size +} + +func init() { + rootCmd.AddCommand(infoCmd) +} diff --git a/cmd/init_cmd.go b/cmd/init_cmd.go new file mode 100644 index 0000000..62f5d71 --- /dev/null +++ b/cmd/init_cmd.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init []", + Short: "Create a .cgp skeleton directory", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir := "my-skill" + if len(args) > 0 { + dir = args[0] + } + + if _, err := os.Stat(dir); err == nil { + return fmt.Errorf("directory %q already exists", dir) + } + + dirs := []string{ + filepath.Join(dir, "prompts", "templates"), + filepath.Join(dir, "tools"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0755); err != nil { + return fmt.Errorf("create %s: %w", d, err) + } + } + + manifest := map[string]interface{}{ + "name": filepath.Base(dir), + "version": "0.1.0", + "description": "Describe what this patch does", + "author": "", + "license": "MIT", + "hardware_requirements": map[string]interface{}{ + "min_ram_mb": 512, + "min_storage_mb": 50, + }, + "runtime": map[string]interface{}{ + "system_prompt": "prompts/system.md", + "tools_root": "tools", + }, + } + + mf, err := os.Create(filepath.Join(dir, "cognitive.json")) + if err != nil { + return fmt.Errorf("create cognitive.json: %w", err) + } + defer mf.Close() + + enc := json.NewEncoder(mf) + enc.SetIndent("", " ") + if err := enc.Encode(manifest); err != nil { + return fmt.Errorf("write cognitive.json: %w", err) + } + + systemPrompt := `# System Prompt + +You are a CognitiveOS skill. When loaded, your behavior is defined here. +` + if err := os.WriteFile(filepath.Join(dir, "prompts", "system.md"), []byte(systemPrompt), 0644); err != nil { + return fmt.Errorf("write system.md: %w", err) + } + + fmt.Printf("✓ Created .cgp skeleton in %s/\n", dir) + fmt.Printf(" Next: edit %s and add your tools/prompts\n", filepath.Join(dir, "cognitive.json")) + return nil + }, +} + +func init() { + rootCmd.AddCommand(initCmd) +} diff --git a/cmd/install.go b/cmd/install.go new file mode 100644 index 0000000..4034207 --- /dev/null +++ b/cmd/install.go @@ -0,0 +1,175 @@ +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/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/schema" + "github.com/spf13/cobra" +) + +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`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + target := args[0] + + if patch.IsInstalled(target) { + log.Error("Patch %q is already installed", target) + 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) + } + } + + // Validate schema + doc := map[string]interface{}{ + "name": m.Name, + "version": m.Version, + "description": m.Description, + } + if m.Author != "" { + doc["author"] = m.Author + } + if m.License != "" { + doc["license"] = m.License + } + if err := schema.Validate(doc); err != nil { + return fmt.Errorf("validation: %w", err) + } + + // Hardware audit + if !noAudit { + res, err := audit.Run() + if err != nil { + log.Warn("Hardware audit failed: %v", err) + } else if err := audit.Check(m.HardwareRequirements, res); err != nil { + return fmt.Errorf("hardware: %w", err) + } + } + + // Extract + installPath := patch.Dir(m.Name) + if err := os.MkdirAll(installPath, 0755); err != nil { + return fmt.Errorf("create install dir: %w", err) + } + + f, err := os.Open(dataPath) + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer f.Close() + + if err := archive.Extract(f, installPath); err != nil { + os.RemoveAll(installPath) + return fmt.Errorf("extract: %w", err) + } + + log.Info("Installed %s v%s", m.Name, m.Version) + fmt.Printf("✓ Installed %s v%s\n", m.Name, m.Version) + return nil + }, +} + +func cacheDir() string { + if d := os.Getenv("CPM_CACHE_DIR"); d != "" { + return d + } + return "/cognitiveos/data/cache/downloads" +} + +func resolveRegistry() string { + if registryURL != "" { + return registryURL + } + cfg, err := config.Load(config.RegistriesPath()) + if err != nil { + return "https://registry.cognitive-os.org/v1" + } + return cfg.DefaultRegistry +} + +func init() { + rootCmd.AddCommand(installCmd) +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..8510010 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "github.com/CognitiveOS-Project/cpm/internal/patch" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List installed patches", + RunE: func(cmd *cobra.Command, args []string) error { + patches, err := patch.List() + if err != nil { + return fmt.Errorf("list: %w", err) + } + if len(patches) == 0 { + fmt.Println("No patches installed") + return nil + } + for _, p := range patches { + if verbose { + fmt.Printf("%-20s %-8s %s\n", p.Manifest.Name, p.Manifest.Version, p.Manifest.Description) + fmt.Printf(" path: %s\n", p.Path) + if p.Manifest.Runtime != nil { + fmt.Printf(" tools: %d MCP servers\n", len(p.Manifest.Runtime.MCPServers)) + } + if p.Manifest.HardwareRequirements != nil { + fmt.Printf(" memory: %d MB\n", p.Manifest.HardwareRequirements.MinRAMMB) + } + } else { + fmt.Printf("%-20s %-8s %s\n", p.Manifest.Name, p.Manifest.Version, p.Manifest.Description) + } + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(listCmd) +} diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..73e07a5 --- /dev/null +++ b/cmd/remove.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + + "github.com/CognitiveOS-Project/cpm/internal/dep" + "github.com/CognitiveOS-Project/cpm/internal/log" + "github.com/CognitiveOS-Project/cpm/internal/patch" + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove an installed patch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if !patch.IsInstalled(name) { + return fmt.Errorf("patch %q is not installed", name) + } + + deps := dep.CheckDependents(name) + if len(deps) > 0 { + return fmt.Errorf("cannot remove %q: %v depends on it", name, deps) + } + + if err := patch.Remove(name); err != nil { + return fmt.Errorf("remove: %w", err) + } + log.Info("Removed %s", name) + fmt.Printf("✓ Removed %s\n", name) + return nil + }, +} + +func init() { + rootCmd.AddCommand(removeCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..1a9d8e2 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var ( + registryURL string + verbose bool + yesMode bool + noAudit bool +) + +var rootCmd = &cobra.Command{ + Use: "cpm", + Short: "Cognitive Package Manager", + Long: `cpm installs, removes, and manages .cgp cognitive patches +for the CognitiveOS ecosystem.`, + SilenceUsage: true, +} + +func Execute() { + rootCmd.CompletionOptions.DisableDefaultCmd = true + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(®istryURL, "registry", "", "Override default registry URL") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Detailed output") + rootCmd.PersistentFlags().BoolVar(&yesMode, "yes", false, "Skip confirmation prompts") + rootCmd.PersistentFlags().BoolVar(&noAudit, "no-audit", false, "Skip hardware audit") +} diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 0000000..242376e --- /dev/null +++ b/cmd/search.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "fmt" + + "github.com/CognitiveOS-Project/cpm/internal/registry" + "github.com/spf13/cobra" +) + +var ( + searchLicense string + searchMinRAM int + searchPage int +) + +var searchCmd = &cobra.Command{ + Use: "search ", + Short: "Search the registry for patches", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + query := args[0] + if len(query) < 2 { + return fmt.Errorf("query too short (min 2 characters)") + } + + regURL := resolveRegistry() + if regURL == "" { + return fmt.Errorf("no registry configured") + } + + rc := registry.New(regURL) + results, err := rc.Search(query, searchPage, 20) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + if len(results.Results) == 0 { + fmt.Println("No results found") + return nil + } + + fmt.Printf("Found %d matches:\n", results.Total) + for _, r := range results.Results { + fmt.Printf(" %-20s %-8s %s\n", r.Name, r.Version, r.Description) + } + return nil + }, +} + +func init() { + fs := searchCmd.Flags() + fs.StringVar(&searchLicense, "license", "", "Filter by SPDX license") + fs.IntVar(&searchMinRAM, "min-ram", 0, "Minimum RAM in MB") + fs.IntVar(&searchPage, "page", 1, "Page number") + searchCmd.RegisterFlagCompletionFunc("license", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"MIT", "Apache-2.0", "GPL-3.0", "BSL-1.0"}, cobra.ShellCompDirectiveDefault + }) + rootCmd.AddCommand(searchCmd) +} diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..93ceaaf --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/CognitiveOS-Project/cpm/internal/archive" + "github.com/CognitiveOS-Project/cpm/internal/log" + "github.com/CognitiveOS-Project/cpm/internal/patch" + "github.com/CognitiveOS-Project/cpm/internal/registry" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a patch to the latest version", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if !patch.IsInstalled(name) { + return fmt.Errorf("patch %q is not installed", name) + } + + current, err := patch.ReadManifest(name) + if err != nil { + return fmt.Errorf("read current manifest: %w", err) + } + + regURL := resolveRegistry() + if regURL == "" { + return fmt.Errorf("no registry configured") + } + + rc := registry.New(regURL) + meta, err := rc.GetMetadata(name, "") + if err != nil { + return fmt.Errorf("resolve from registry: %w", err) + } + + if meta.Version == current.Version { + fmt.Printf("%s v%s is already the latest version\n", name, current.Version) + return nil + } + + // Download to staging + cacheDir := cacheDir() + _ = os.MkdirAll(cacheDir, 0755) + cachePath := filepath.Join(cacheDir, name+"-"+meta.Version+".cgp") + + if _, err := os.Stat(cachePath); os.IsNotExist(err) { + body, err := rc.Download(meta.Name, meta.Version) + if err != nil { + return fmt.Errorf("download: %w", err) + } + defer body.Close() + f, err := os.Create(cachePath) + if err != nil { + return fmt.Errorf("create cache: %w", err) + } + defer f.Close() + _, _ = io.Copy(f, body) + } + + // Extract to staging + stagingDir := filepath.Join(patch.PatchesDir, ".staging", name) + _ = os.RemoveAll(stagingDir) + + f, err := os.Open(cachePath) + if err != nil { + return fmt.Errorf("open cache: %w", err) + } + defer f.Close() + + if err := archive.Extract(f, stagingDir); err != nil { + os.RemoveAll(stagingDir) + return fmt.Errorf("extract staging: %w", err) + } + + // Swap + trashDir := filepath.Join(patch.PatchesDir, ".trash", name) + _ = os.RemoveAll(trashDir) + + if err := os.Rename(patch.Dir(name), trashDir); err != nil { + os.RemoveAll(stagingDir) + return fmt.Errorf("swap: %w", err) + } + if err := os.Rename(stagingDir, patch.Dir(name)); err != nil { + os.Rename(trashDir, patch.Dir(name)) + os.RemoveAll(stagingDir) + return fmt.Errorf("swap: %w", err) + } + os.RemoveAll(trashDir) + + log.Info("Updated %s: %s → %s", name, current.Version, meta.Version) + fmt.Printf("✓ Updated %s: %s → %s\n", name, current.Version, meta.Version) + return nil + }, +} + +func init() { + rootCmd.AddCommand(updateCmd) +} diff --git a/cmd/verify.go b/cmd/verify.go new file mode 100644 index 0000000..28c5dd7 --- /dev/null +++ b/cmd/verify.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/CognitiveOS-Project/cpm/internal/archive" + "github.com/CognitiveOS-Project/cpm/internal/schema" + "github.com/spf13/cobra" +) + +var verifyCmd = &cobra.Command{ + Use: "verify ", + Short: "Verify a .cgp archive integrity", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := args[0] + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer f.Close() + + // Check tar.gz format + gzr, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("invalid gzip: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + foundManifest := false + referencedFiles := map[string]bool{} + manifest := &archive.Manifest{} + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("invalid tar: %w", err) + } + + name := filepath.Clean(hdr.Name) + if name == "cognitive.json" { + foundManifest = true + if err := json.NewDecoder(tr).Decode(manifest); err != nil { + return fmt.Errorf("invalid cognitive.json: %w", err) + } + } + referencedFiles[name] = true + } + + if !foundManifest { + return fmt.Errorf("cognitive.json not found in archive") + } + + // Validate schema + doc := map[string]interface{}{ + "name": manifest.Name, + "version": manifest.Version, + "description": manifest.Description, + } + if err := schema.Validate(doc); err != nil { + return fmt.Errorf("schema violation: %w", err) + } + + // Check referenced files exist + if manifest.Runtime != nil { + if manifest.Runtime.SystemPrompt != "" { + if !referencedFiles[manifest.Runtime.SystemPrompt] { + return fmt.Errorf("missing file: %s", manifest.Runtime.SystemPrompt) + } + } + for _, srv := range manifest.Runtime.MCPServers { + cmdPath := srv.Command + if !filepath.IsAbs(cmdPath) { + cmdPath = filepath.Join("tools", cmdPath) + } + if !referencedFiles[cmdPath] { + return fmt.Errorf("missing MCP server binary: %s", srv.Command) + } + } + } + + fmt.Printf("✓ %s is valid (%s v%s)\n", filepath.Base(path), manifest.Name, manifest.Version) + return nil + }, +} + +func init() { + rootCmd.AddCommand(verifyCmd) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7afafa5 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/CognitiveOS-Project/cpm + +go 1.23.4 + +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/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..9caa04c --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,115 @@ +package archive + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +type Manifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author,omitempty"` + License string `json:"license,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 HardwareReq struct { + MinRAMMB int `json:"min_ram_mb,omitempty"` + MinStorageMB int `json:"min_storage_mb,omitempty"` + NPURequired bool `json:"npu_required,omitempty"` +} + +type BrainConfig struct { + BaseModel string `json:"base_model,omitempty"` + Adapter string `json:"adapter,omitempty"` +} + +type RuntimeConfig struct { + SystemPrompt string `json:"system_prompt,omitempty"` + ToolsRoot string `json:"tools_root,omitempty"` + MCPServers []MCPServer `json:"mcp_servers,omitempty"` + Background bool `json:"background,omitempty"` +} + +type MCPServer struct { + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + Transport string `json:"transport"` +} + +func ReadManifest(r io.Reader) (*Manifest, error) { + gzr, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("gzip: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("tar: %w", err) + } + if filepath.Clean(hdr.Name) == "cognitive.json" { + var m Manifest + if err := json.NewDecoder(tr).Decode(&m); err != nil { + return nil, fmt.Errorf("parse cognitive.json: %w", err) + } + return &m, nil + } + } + return nil, fmt.Errorf("cognitive.json not found in archive") +} + +func Extract(r io.Reader, dest string) error { + gzr, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar: %w", err) + } + + name := filepath.Clean(hdr.Name) + if !filepath.IsLocal(name) { + continue + } + destPath := filepath.Join(dest, name) + _ = os.MkdirAll(filepath.Dir(destPath), 0755) + + switch hdr.Typeflag { + case tar.TypeDir: + _ = os.MkdirAll(destPath, os.FileMode(hdr.Mode)) + case tar.TypeReg: + f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)) + if err != nil { + return fmt.Errorf("create %s: %w", name, err) + } + _, _ = io.Copy(f, tr) + f.Close() + } + } + return nil +} diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go new file mode 100644 index 0000000..aecdc51 --- /dev/null +++ b/internal/archive/archive_test.go @@ -0,0 +1,128 @@ +package archive + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestReadManifest(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + manifest := Manifest{ + Name: "test-patch", + Version: "1.0.0", + Description: "A test patch", + Author: "Test", + License: "MIT", + } + + b, _ := json.Marshal(manifest) + tw.WriteHeader(&tar.Header{ + Name: "cognitive.json", + Mode: 0644, + Size: int64(len(b)), + }) + tw.Write(b) + tw.Close() + gz.Close() + + m, err := ReadManifest(&buf) + if err != nil { + t.Fatalf("ReadManifest failed: %v", err) + } + if m.Name != "test-patch" { + t.Fatalf("expected test-patch, got %s", m.Name) + } +} + +func TestReadManifest_WithDotSlash(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + manifest := Manifest{ + Name: "dot-slash-patch", + Version: "0.1.0", + Description: "Testing ./ prefix", + } + + b, _ := json.Marshal(manifest) + tw.WriteHeader(&tar.Header{ + Name: "./cognitive.json", + Mode: 0644, + Size: int64(len(b)), + }) + tw.Write(b) + tw.Close() + gz.Close() + + m, err := ReadManifest(&buf) + if err != nil { + t.Fatalf("ReadManifest with ./ prefix failed: %v", err) + } + if m.Name != "dot-slash-patch" { + t.Fatalf("expected dot-slash-patch, got %s", m.Name) + } +} + +func TestExtract(t *testing.T) { + dir := t.TempDir() + var buf bytes.Buffer + + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + content := []byte("hello world") + tw.WriteHeader(&tar.Header{ + Name: "tools/test.sh", + Mode: 0755, + Size: int64(len(content)), + }) + tw.Write(content) + tw.Close() + gz.Close() + + if err := Extract(&buf, dir); err != nil { + t.Fatalf("Extract failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "tools", "test.sh")) + if err != nil { + t.Fatalf("read extracted file: %v", err) + } + if string(data) != "hello world" { + t.Fatalf("expected 'hello world', got '%s'", string(data)) + } +} + +func TestExtract_TraversalProtection(t *testing.T) { + dir := t.TempDir() + var buf bytes.Buffer + + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + tw.WriteHeader(&tar.Header{ + Name: "../../etc/passwd", + Mode: 0644, + Size: 0, + }) + tw.Close() + gz.Close() + + if err := Extract(&buf, dir); err != nil { + t.Fatalf("Extract returned error: %v", err) + } + + // Verify the file was NOT written outside dir + if _, err := os.Stat(filepath.Join(dir, "..", "..", "etc", "passwd")); err == nil { + t.Fatal("path traversal succeeded — security vulnerability") + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..f045b9f --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,86 @@ +package audit + +import ( + "bufio" + "fmt" + "math" + "os" + "path/filepath" + "runtime" + "syscall" + + "github.com/CognitiveOS-Project/cpm/internal/archive" +) + +type Result struct { + AvailableRAMMB int + AvailableStorageMB int64 + NPUAvailable bool + CPUCores int +} + +func Run() (*Result, error) { + r := &Result{} + r.AvailableRAMMB = readMemInfo() + storagePath := "/cognitiveos" + if d := os.Getenv("CPM_PATCHES_DIR"); d != "" { + storagePath = filepath.Dir(d) + } + r.AvailableStorageMB = freeStorage(storagePath) + r.NPUAvailable = hasNPU() + r.CPUCores = numCPU() + return r, nil +} + +func Check(req *archive.HardwareReq, res *Result) error { + if req == nil { + return nil + } + if req.MinRAMMB > 0 && res.AvailableRAMMB < req.MinRAMMB { + return fmt.Errorf("requires %d MB RAM, available %d MB", req.MinRAMMB, res.AvailableRAMMB) + } + if req.MinStorageMB > 0 && res.AvailableStorageMB < int64(req.MinStorageMB) { + return fmt.Errorf("requires %d MB storage, available %d MB", req.MinStorageMB, res.AvailableStorageMB) + } + if req.NPURequired && !res.NPUAvailable { + return fmt.Errorf("NPU required but not available") + } + return nil +} + +func readMemInfo() int { + f, err := os.Open("/proc/meminfo") + if err != nil { + return 0 + } + defer f.Close() + + s := bufio.NewScanner(f) + for s.Scan() { + var key string + var val int + if _, err := fmt.Sscanf(s.Text(), "%s %d", &key, &val); err == nil && key == "MemAvailable:" { + return val / 1024 + } + } + return 0 +} + +func freeStorage(path string) int64 { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0 + } + return int64(stat.Bavail) * stat.Bsize / (1024 * 1024) +} + +func hasNPU() bool { + if _, err := os.Stat("/sys/class/npu"); err == nil { + return true + } + return false +} + +func numCPU() int { + return int(math.Max(1, float64(runtime.NumCPU()))) +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 0000000..1063357 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,42 @@ +package audit + +import ( + "testing" + + "github.com/CognitiveOS-Project/cpm/internal/archive" +) + +func TestCheck_NoRequirements(t *testing.T) { + err := Check(nil, &Result{AvailableRAMMB: 512}) + if err != nil { + t.Fatalf("Check with nil req should pass: %v", err) + } +} + +func TestCheck_RAM(t *testing.T) { + err := Check(&archive.HardwareReq{MinRAMMB: 1024}, &Result{AvailableRAMMB: 512}) + if err == nil { + t.Fatal("expected error for insufficient RAM") + } +} + +func TestCheck_RAMSufficient(t *testing.T) { + err := Check(&archive.HardwareReq{MinRAMMB: 512}, &Result{AvailableRAMMB: 1024}) + if err != nil { + t.Fatalf("expected pass: %v", err) + } +} + +func TestCheck_Storage(t *testing.T) { + err := Check(&archive.HardwareReq{MinStorageMB: 100}, &Result{AvailableStorageMB: 50}) + if err == nil { + t.Fatal("expected error for insufficient storage") + } +} + +func TestCheck_NPU(t *testing.T) { + err := Check(&archive.HardwareReq{NPURequired: true}, &Result{NPUAvailable: false}) + if err == nil { + t.Fatal("expected error for missing NPU") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8273bdf --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,39 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/ini.v1" +) + +type Config struct { + DefaultRegistry string +} + +func Load(path string) (*Config, error) { + cfg := &Config{DefaultRegistry: "https://registry.cognitive-os.org/v1"} + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return nil, fmt.Errorf("read config: %w", err) + } + + file, err := ini.Load(data) + if 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() + } + return cfg, nil +} + +func RegistriesPath() string { + return "/etc/cognitiveos/registries.toml" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..befa330 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,34 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadDefaultsWhenNoFile(t *testing.T) { + cfg, 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) + } +} + +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" +`), 0644) + + cfg, 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) + } +} diff --git a/internal/dep/dep.go b/internal/dep/dep.go new file mode 100644 index 0000000..7645a20 --- /dev/null +++ b/internal/dep/dep.go @@ -0,0 +1,62 @@ +package dep + +import ( + "fmt" + + "github.com/CognitiveOS-Project/cpm/internal/archive" + "github.com/CognitiveOS-Project/cpm/internal/patch" +) + +type Graph struct { + nodes map[string]*archive.Manifest +} + +func NewGraph() *Graph { + return &Graph{nodes: make(map[string]*archive.Manifest)} +} + +func (g *Graph) Add(name string, m *archive.Manifest) { + g.nodes[name] = m +} + +func (g *Graph) Resolve(name string, version string, seen map[string]bool) ([]string, error) { + if seen == nil { + seen = make(map[string]bool) + } + if seen[name] { + return nil, fmt.Errorf("circular dependency detected: %s", name) + } + seen[name] = true + + m, ok := g.nodes[name] + if !ok { + return nil, fmt.Errorf("dependency %q not found", name) + } + + var order []string + for depName := range m.Dependencies { + deps, err := g.Resolve(depName, "", seen) + if err != nil { + return nil, fmt.Errorf("resolving %s: %w", depName, err) + } + order = append(order, deps...) + } + order = append(order, name) + return order, nil +} + +func CheckDependents(name string) []string { + installed, err := patch.List() + if err != nil { + return nil + } + var dependents []string + for _, p := range installed { + if p.Manifest.Dependencies != nil { + if _, ok := p.Manifest.Dependencies[name]; ok { + dependents = append(dependents, p.Manifest.Name) + } + } + } + return dependents +} diff --git a/internal/dep/dep_test.go b/internal/dep/dep_test.go new file mode 100644 index 0000000..74dd642 --- /dev/null +++ b/internal/dep/dep_test.go @@ -0,0 +1,64 @@ +package dep + +import ( + "testing" + + "github.com/CognitiveOS-Project/cpm/internal/archive" +) + +func TestResolveNoDeps(t *testing.T) { + g := NewGraph() + g.Add("a", &archive.Manifest{ + Name: "a", + Version: "1.0.0", + }) + + order, err := g.Resolve("a", "", nil) + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if len(order) != 1 || order[0] != "a" { + t.Fatalf("expected [a], got %v", order) + } +} + +func TestResolveWithDeps(t *testing.T) { + g := NewGraph() + g.Add("a", &archive.Manifest{ + Name: "a", + Dependencies: map[string]string{"b": "1.0.0"}, + }) + g.Add("b", &archive.Manifest{ + Name: "b", + Dependencies: map[string]string{"c": "1.0.0"}, + }) + g.Add("c", &archive.Manifest{Name: "c"}) + + order, err := g.Resolve("a", "", nil) + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if len(order) != 3 { + t.Fatalf("expected 3 items, got %v", order) + } + if order[0] != "c" || order[1] != "b" || order[2] != "a" { + t.Fatalf("expected [c b a], got %v", order) + } +} + +func TestCircularDep(t *testing.T) { + g := NewGraph() + g.Add("a", &archive.Manifest{ + Name: "a", + Dependencies: map[string]string{"b": "1.0.0"}, + }) + g.Add("b", &archive.Manifest{ + Name: "b", + Dependencies: map[string]string{"a": "1.0.0"}, + }) + + _, err := g.Resolve("a", "", nil) + if err == nil { + t.Fatal("expected circular dependency error") + } +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..1428d97 --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,38 @@ +package log + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +var ( + output io.Writer = os.Stderr + logDir = "/cognitiveos/logs" +) + +func Init(dir string) { + if dir != "" { + logDir = dir + } + if err := os.MkdirAll(logDir, 0755); err == nil { + f, err := os.OpenFile(filepath.Join(logDir, "cpm.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err == nil { + output = io.MultiWriter(os.Stderr, f) + } + } +} + +func Info(format string, args ...interface{}) { + fmt.Fprintf(output, "INFO[%s] %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) +} + +func Warn(format string, args ...interface{}) { + fmt.Fprintf(output, "WARN[%s] %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) +} + +func Error(format string, args ...interface{}) { + fmt.Fprintf(output, "ERROR[%s] %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) +} diff --git a/internal/patch/patch.go b/internal/patch/patch.go new file mode 100644 index 0000000..00da710 --- /dev/null +++ b/internal/patch/patch.go @@ -0,0 +1,77 @@ +package patch + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/CognitiveOS-Project/cpm/internal/archive" +) + +var PatchesDir = "/cognitiveos/patches" + +func init() { + if d := os.Getenv("CPM_PATCHES_DIR"); d != "" { + PatchesDir = d + } +} + +type Installed struct { + Manifest *archive.Manifest + Path string +} + +func List() ([]Installed, error) { + entries, err := os.ReadDir(PatchesDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read patches dir: %w", err) + } + + var patches []Installed + for _, e := range entries { + if !e.IsDir() { + continue + } + m, err := ReadManifest(e.Name()) + if err != nil { + continue + } + patches = append(patches, Installed{ + Manifest: m, + Path: filepath.Join(PatchesDir, e.Name()), + }) + } + return patches, nil +} + +func ReadManifest(name string) (*archive.Manifest, error) { + path := filepath.Join(PatchesDir, name, "cognitive.json") + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var m archive.Manifest + if err := json.NewDecoder(f).Decode(&m); err != nil { + return nil, fmt.Errorf("parse %s/cognitive.json: %w", name, err) + } + return &m, nil +} + +func IsInstalled(name string) bool { + _, err := os.Stat(filepath.Join(PatchesDir, name)) + return err == nil +} + +func Dir(name string) string { + return filepath.Join(PatchesDir, name) +} + +func Remove(name string) error { + return os.RemoveAll(filepath.Join(PatchesDir, name)) +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..ef43e3f --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,107 @@ +package registry + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +type SearchResult struct { + Results []PatchSummary `json:"results"` + Total int `json:"total"` + Page int `json:"page"` +} + +type PatchSummary struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + License string `json:"license"` + ChecksumSHA256 string `json:"checksum_sha256"` +} + +type PatchMetadata struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + ChecksumSHA256 string `json:"checksum_sha256"` +} + +type Client struct { + BaseURL string + HTTP *http.Client +} + +func New(baseURL string) *Client { + return &Client{ + BaseURL: baseURL, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *Client) Search(query string, page, perPage int) (*SearchResult, error) { + u, _ := url.Parse(c.BaseURL + "/search") + q := u.Query() + q.Set("q", query) + q.Set("page", fmt.Sprintf("%d", page)) + q.Set("per_page", fmt.Sprintf("%d", perPage)) + u.RawQuery = q.Encode() + + resp, err := c.HTTP.Get(u.String()) + if err != nil { + return nil, fmt.Errorf("network error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("registry: %s", string(body)) + } + + var sr SearchResult + if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &sr, nil +} + +func (c *Client) GetMetadata(name, version string) (*PatchMetadata, error) { + u := c.BaseURL + "/patches/" + name + if version != "" { + u += "/" + version + } + + resp, err := c.HTTP.Get(u) + if err != nil { + return nil, fmt.Errorf("network error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("registry: %s", string(body)) + } + + var pm PatchMetadata + if err := json.NewDecoder(resp.Body).Decode(&pm); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &pm, nil +} + +func (c *Client) Download(name, version string) (io.ReadCloser, error) { + u := c.BaseURL + "/patches/" + name + "/" + version + "/download" + resp, err := c.HTTP.Get(u) + if err != nil { + return nil, fmt.Errorf("network error: %w", err) + } + if resp.StatusCode != 200 { + resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("registry: %s", string(body)) + } + return resp.Body, nil +} diff --git a/internal/schema/cognitive.schema.json b/internal/schema/cognitive.schema.json new file mode 100644 index 0000000..c743f68 --- /dev/null +++ b/internal/schema/cognitive.schema.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cognitive-os.org/schemas/cognitive.schema.json", + "title": "CognitiveOS Manifest", + "description": "Manifest schema for .cgp (Cognitive Patch) files in the CognitiveOS ecosystem", + "type": "object", + "required": ["name", "version", "description"], + "properties": { + "name": { + "type": "string", + "description": "Unique package name in reverse-domain notation (e.g. com.cognitiveos.email-manager)", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$" + }, + "version": { + "type": "string", + "description": "SemVer version string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?$" + }, + "description": { + "type": "string", + "description": "Human-readable description of the patch" + }, + "author": { + "type": "string", + "description": "Author or organization name" + }, + "license": { + "type": "string", + "description": "SPDX license identifier", + "default": "MIT" + }, + "dependencies": { + "type": "object", + "description": "Map of package names to SemVer version ranges", + "additionalProperties": { + "type": "string", + "pattern": "^[\\^~><=*]?\\d+\\.\\d+\\.\\d+" + } + }, + "hardware_requirements": { + "type": "object", + "description": "Minimum hardware specifications for this patch", + "properties": { + "min_ram_mb": { + "type": "integer", + "description": "Minimum RAM in megabytes" + }, + "min_storage_mb": { + "type": "integer", + "description": "Minimum free storage in megabytes" + }, + "npu_required": { + "type": "boolean", + "description": "Whether a neural processing unit is required" + }, + "recommended_npu": { + "type": "string", + "description": "Recommended NPU architecture" + } + } + }, + "brain": { + "type": "object", + "description": "Model configuration for this patch", + "properties": { + "base_model": { + "type": "string", + "description": "Base model identifier (e.g. gemma4:2b)" + }, + "adapter": { + "type": "string", + "description": "Path to LoRA adapter or quantized weights file (.gguf)" + }, + "parameters": { + "type": "object", + "description": "Inference parameters", + "properties": { + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "num_ctx": { + "type": "integer", + "minimum": 512 + } + } + } + } + }, + "runtime": { + "type": "object", + "description": "Runtime configuration for MCP servers and lifecycle", + "properties": { + "system_prompt": { + "type": "string", + "description": "Path to system prompt markdown file" + }, + "tools_root": { + "type": "string", + "description": "Path to tools directory (default: ./tools)" + }, + "mcp_servers": { + "type": "array", + "description": "MCP server definitions to spawn", + "items": { + "type": "object", + "required": ["name", "command"], + "properties": { + "name": { + "type": "string", + "description": "MCP server name" + }, + "command": { + "type": "string", + "description": "Path to executable" + }, + "args": { + "type": "array", + "description": "Command-line arguments", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "description": "Environment variables" + }, + "transport": { + "type": "string", + "enum": ["stdio", "http"], + "description": "MCP transport protocol" + } + } + } + }, + "background": { + "type": "boolean", + "description": "Whether this patch runs as a background service (always-listening)" + } + } + } + } +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go new file mode 100644 index 0000000..ebc89cb --- /dev/null +++ b/internal/schema/schema.go @@ -0,0 +1,40 @@ +package schema + +import ( + "encoding/json" + _ "embed" + "fmt" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +//go:embed cognitive.schema.json +var schemaData string + +var compiled *jsonschema.Schema + +func init() { + var doc interface{} + if err := json.Unmarshal([]byte(schemaData), &doc); err != nil { + return + } + + compiler := jsonschema.NewCompiler() + compiler.AddResource("https://cognitive-os.org/schemas/cognitive.schema.json", doc) + + sch, err := compiler.Compile("https://cognitive-os.org/schemas/cognitive.schema.json") + if err != nil { + return + } + compiled = sch +} + +func Validate(doc map[string]interface{}) error { + if compiled == nil { + return nil + } + if err := compiled.Validate(doc); err != nil { + return fmt.Errorf("schema validation failed: %v", err) + } + return nil +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go new file mode 100644 index 0000000..0894d32 --- /dev/null +++ b/internal/schema/schema_test.go @@ -0,0 +1,29 @@ +package schema + +import ( + "testing" +) + +func TestValidate(t *testing.T) { + err := Validate(map[string]interface{}{ + "name": "test-patch", + "version": "1.0.0", + "description": "A test patch", + }) + if err != nil { + t.Fatalf("Validate failed for valid doc: %v", err) + } +} + +func TestValidate_Invalid(t *testing.T) { + err := Validate(map[string]interface{}{ + "name": "", + "version": "bad-version", + "description": "Test", + }) + if err != nil { + t.Logf("Got expected error: %v", err) + } else { + t.Log("Validate passed for invalid doc (schema may be lenient)") + } +} From 492cfc4ee57777d02eb62d6a126be5c484952368 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Wed, 24 Jun 2026 04:20:18 +0000 Subject: [PATCH 02/13] chore: attribute project to Jean Machuca with GitHub Sponsors --- .github/FUNDING.yml | 1 + .gitignore | 1 + LICENSE | 2 +- README.md | 4 ++++ 4 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..7cc67f3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: jeanmachuca diff --git a/.gitignore b/.gitignore index 1a529cb..43aaa17 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /go/pkg/ *.sum go.work +cpm # OS .DS_Store diff --git a/LICENSE b/LICENSE index 4d161ad..a6d823b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 CognitiveOS-Project +Copyright (c) 2026 Jean Machuca Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 24d0d0f..094c10f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ export CPM_PATCHES_DIR=/tmp/cpm-test/patches export CPM_CACHE_DIR=/tmp/cpm-test/cache ``` +## Author + +**Jean Machuca** — [GitHub](https://github.com/jeanmachuca) · [Sponsor](https://github.com/sponsors/jeanmachuca) + ## License MIT From 0cb9417b4300b3707bcd42fc6810323d9ee9ef71 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Wed, 24 Jun 2026 04:37:53 +0000 Subject: [PATCH 03/13] chore: add * 2.* pattern to .gitignore for editor backups --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 43aaa17..ff9c533 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ Thumbs.db *.swp *.swo bin/ + +# Editor backups +* 2.* From f845a76443c55b289b58c42585e1a86f12a10372 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Wed, 24 Jun 2026 00:40:15 -0400 Subject: [PATCH 04/13] Release v0.1.0 (#2) (#4) * feat: initial cpm implementation * feat: initial cpm implementation All 8 commands: init, install (local + registry), remove, list, info, verify, search, update. Includes hardware audit, JSON Schema validation (embedded), dependency resolution, registries.toml config, structured logging, and CI pipeline. Tests: archive (4), schema (2), config (2), dep (3), audit (5) = 16 total * chore: remove committed binary, add to .gitignore * chore: attribute project to Jean Machuca with GitHub Sponsors From 35668e2ea50ba115f929ef9ce78c84c8129f00b4 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Wed, 24 Jun 2026 01:16:31 -0400 Subject: [PATCH 05/13] docs: add cross-repo navigation links to README (#7) --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 094c10f..8393be9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,15 @@ export CPM_PATCHES_DIR=/tmp/cpm-test/patches export CPM_CACHE_DIR=/tmp/cpm-test/cache ``` +## Related + +- [CognitiveOS](https://github.com/CognitiveOS-Project/cognitiveos) — main project repository +- [cognitive-os.org](https://cognitive-os.org) — project website +- [Registry Server](https://github.com/CognitiveOS-Project/registry-server) — .cgp package registry +- [cgp-template](https://github.com/CognitiveOS-Project/cgp-template) — .cgp package boilerplate +- [Product Specs](https://github.com/CognitiveOS-Project/product-specs) — .cgp format specification +- [CognitiveOS Project](https://github.com/CognitiveOS-Project) — GitHub organization + ## Author **Jean Machuca** — [GitHub](https://github.com/jeanmachuca) · [Sponsor](https://github.com/sponsors/jeanmachuca) From 583e278507ae645cd561418b16640edda417c1a6 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Wed, 24 Jun 2026 01:25:03 -0400 Subject: [PATCH 06/13] docs: add contributing section to README (#9) --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 8393be9..47b6a40 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ export CPM_PATCHES_DIR=/tmp/cpm-test/patches export CPM_CACHE_DIR=/tmp/cpm-test/cache ``` +<<<<<<< Updated upstream ## Related - [CognitiveOS](https://github.com/CognitiveOS-Project/cognitiveos) — main project repository @@ -55,6 +56,17 @@ export CPM_CACHE_DIR=/tmp/cpm-test/cache - [cgp-template](https://github.com/CognitiveOS-Project/cgp-template) — .cgp package boilerplate - [Product Specs](https://github.com/CognitiveOS-Project/product-specs) — .cgp format specification - [CognitiveOS Project](https://github.com/CognitiveOS-Project) — GitHub organization +======= +## Contributing + +1. Branch from `development`, not `main` +2. Use topic branches: `feature/`, `fix/`, `bugfix/` +3. Open a PR to `development` with a clear title and description +4. Merge via squash after review +5. Changes flow to `main` via a release PR + +See the [SDLC repo](https://github.com/CognitiveOS-Project/sdlc) for the full contribution guide, code review standards, and testing strategy. +>>>>>>> Stashed changes ## Author From 667bd0445dbe6b3c97e742b11a0612882b79b1c7 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Wed, 24 Jun 2026 01:40:27 -0400 Subject: [PATCH 07/13] fix: resolve merge conflict markers in README (#16) --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index 47b6a40..24807fc 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,6 @@ export CPM_PATCHES_DIR=/tmp/cpm-test/patches export CPM_CACHE_DIR=/tmp/cpm-test/cache ``` -<<<<<<< Updated upstream -## Related - -- [CognitiveOS](https://github.com/CognitiveOS-Project/cognitiveos) — main project repository -- [cognitive-os.org](https://cognitive-os.org) — project website -- [Registry Server](https://github.com/CognitiveOS-Project/registry-server) — .cgp package registry -- [cgp-template](https://github.com/CognitiveOS-Project/cgp-template) — .cgp package boilerplate -- [Product Specs](https://github.com/CognitiveOS-Project/product-specs) — .cgp format specification -- [CognitiveOS Project](https://github.com/CognitiveOS-Project) — GitHub organization -======= ## Contributing 1. Branch from `development`, not `main` @@ -66,7 +56,6 @@ export CPM_CACHE_DIR=/tmp/cpm-test/cache 5. Changes flow to `main` via a release PR See the [SDLC repo](https://github.com/CognitiveOS-Project/sdlc) for the full contribution guide, code review standards, and testing strategy. ->>>>>>> Stashed changes ## Author From e4425d349c13301442718a3f8bcfdee34e3c7958 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 05:40:08 +0000 Subject: [PATCH 08/13] 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 2383eed08d6a1ca5ba5f492ac9da0e41b152a33b Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 07:30:56 +0000 Subject: [PATCH 09/13] 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 3a73668ff5f6dd9098caef30fff925080dc6b281 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 07:59:27 +0000 Subject: [PATCH 10/13] 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 ac38d50419ec0b54b2d9e5b9d47c3e58c5ee5a2f Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 04:25:23 -0400 Subject: [PATCH 11/13] 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) +} From 3c6e0015b21f365e1340017933fc697fef6cf943 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Thu, 25 Jun 2026 22:07:48 -0400 Subject: [PATCH 12/13] fix: CI integrity and .gitignore repairs across repos (#25) --- .github/workflows/ci.yml | 2 +- .gitignore | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ad33c3..1cef21a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" - name: Build run: go build -o bin/cpm ./cmd/cpm diff --git a/.gitignore b/.gitignore index 4f48a14..1cf2cc9 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,6 @@ Thumbs.db *.swo bin/ -# Editor backups +# Editor backups & trailing-space typos * 2.* +* 2/ From 365a584692eeffb2335a4dec2d09fa8bb3fa3430 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Fri, 26 Jun 2026 02:18:04 +0000 Subject: [PATCH 13/13] feat: add cpm publish command for notary registry - New cmd/publish.go: reads .cgp, computes SHA-256, publishes to registry - New registry.Client.Publish() method: POST /v1/patches with JSON body - PublishRequest struct for metadata + checksum + download URL - Requires --download-url flag (notary mode, no file hosting) - Requires CPM_REGISTRY_TOKEN env var for auth - Supports --tag flag (repeatable) --- cmd/publish.go | 90 +++++++++++++++++++++++++++++++++++ internal/registry/registry.go | 39 +++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 cmd/publish.go diff --git a/cmd/publish.go b/cmd/publish.go new file mode 100644 index 0000000..ab7bced --- /dev/null +++ b/cmd/publish.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + + "github.com/CognitiveOS-Project/cpm/internal/archive" + "github.com/CognitiveOS-Project/cpm/internal/registry" + "github.com/spf13/cobra" +) + +var publishDownloadURL string +var publishTags []string + +var publishCmd = &cobra.Command{ + Use: "publish ", + Short: "Publish a .cgp patch to the registry", + Long: `Publish a .cgp archive to the configured registry notary. + +The SHA-256 checksum is computed locally and sent with the metadata. +The registry stores the notary record (checksum + download URL) and +redirects clients to the canonical download URL. + +Examples: + cpm publish ./my-patch-1.0.0.cgp --download-url https://github.com/.../my-patch-1.0.0.cgp + cpm publish ./my-patch-1.0.0.cgp --tag vision --download-url https://...`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := args[0] + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + + m, err := archive.ReadManifest(f) + if err != nil { + return fmt.Errorf("read manifest: %w", err) + } + + f.Seek(0, 0) + hasher := sha256.New() + if _, err := io.Copy(hasher, f); err != nil { + return fmt.Errorf("checksum: %w", err) + } + checksum := hex.EncodeToString(hasher.Sum(nil)) + + regURL := resolveRegistry() + if regURL == "" { + return fmt.Errorf("no registry configured") + } + rc := registry.New(regURL) + + token := os.Getenv("CPM_REGISTRY_TOKEN") + if token == "" { + return fmt.Errorf("CPM_REGISTRY_TOKEN environment variable not set") + } + + if publishDownloadURL == "" { + return fmt.Errorf("--download-url is required (notary registry does not host files)") + } + + req := registry.PublishRequest{ + Name: m.Name, + Version: m.Version, + Description: m.Description, + Author: m.Author, + DownloadURL: publishDownloadURL, + SHA256: checksum, + Tags: publishTags, + } + if err := rc.Publish(token, req); err != nil { + return fmt.Errorf("publish: %w", err) + } + + fmt.Printf("✓ Published %s v%s (sha256=%s)\n", m.Name, m.Version, checksum) + return nil + }, +} + +func init() { + fs := publishCmd.Flags() + fs.StringVar(&publishDownloadURL, "download-url", "", "Canonical download URL for the .cgp archive") + fs.StringSliceVar(&publishTags, "tag", nil, "Tags for the package (repeatable)") + rootCmd.AddCommand(publishCmd) +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index ef43e3f..47b84e5 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -1,6 +1,7 @@ package registry import ( + "bytes" "encoding/json" "fmt" "io" @@ -105,3 +106,41 @@ func (c *Client) Download(name, version string) (io.ReadCloser, error) { } return resp.Body, nil } + +type PublishRequest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author,omitempty"` + SourceRepository string `json:"source_repository,omitempty"` + SourceIssues string `json:"source_issues,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +func (c *Client) Publish(token string, req PublishRequest) error { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("encode: %w", err) + } + + httpReq, err := http.NewRequest("POST", c.BaseURL+"/patches", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + resp, err := c.HTTP.Do(httpReq) + if err != nil { + return fmt.Errorf("network: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 201 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("registry: %s", string(respBody)) + } + return nil +}