From 2c7200380b173965db4ce509b4c67839fe6fadaa Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sat, 22 Aug 2026 21:52:30 +0200 Subject: [PATCH] feat: vendor add end-to-end with deterministic lockfile v2 - go-git in-process clone, default-branch/tag resolution, optional token auth - web-tree URL normalization (github/gitlab) with implied discovery root - discovery of skills/commands/agents/mcp, lenient frontmatter with warnings - selection: all by default, --include with missing-entry hard errors - external/// materialization preserving exec bits - lockfile v2: deterministic JSON, pinned url/ref/rev, inventory, warnings - offline black-box e2e suite against local fixture git repositories --- .beads/interactions.jsonl | 1 + go.mod | 25 ++- go.sum | 103 +++++++++ internal/cli/root.go | 8 +- internal/cli/vendor.go | 60 ++++++ internal/discovery/discovery.go | 171 +++++++++++++++ internal/discovery/frontmatter.go | 73 +++++++ internal/discovery/frontmatter_test.go | 54 +++++ internal/discovery/fstree.go | 55 +++++ internal/e2e/vendor_add_test.go | 282 +++++++++++++++++++++++++ internal/gitsource/git.go | 185 ++++++++++++++++ internal/gitsource/url.go | 41 ++++ internal/gitsource/url_test.go | 35 +++ internal/lockfile/lockfile.go | 148 +++++++++++++ internal/lockfile/lockfile_test.go | 129 +++++++++++ internal/vendor/add.go | 280 ++++++++++++++++++++++++ 16 files changed, 1648 insertions(+), 2 deletions(-) create mode 100644 internal/cli/vendor.go create mode 100644 internal/discovery/discovery.go create mode 100644 internal/discovery/frontmatter.go create mode 100644 internal/discovery/frontmatter_test.go create mode 100644 internal/discovery/fstree.go create mode 100644 internal/e2e/vendor_add_test.go create mode 100644 internal/gitsource/git.go create mode 100644 internal/gitsource/url.go create mode 100644 internal/gitsource/url_test.go create mode 100644 internal/lockfile/lockfile.go create mode 100644 internal/lockfile/lockfile_test.go create mode 100644 internal/vendor/add.go diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 42ca452..980d585 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -11,3 +11,4 @@ {"id":"int-f444e5d1","kind":"field_change","created_at":"2026-05-28T08:13:14.820734703Z","actor":"m3ta-chiron","issue_id":"agent-lib-4wx.11","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} {"id":"int-b1779011c451e8aecfac8a15e2c332b8","kind":"field_change","created_at":"2026-06-13T07:55:57.404556723Z","actor":"m3ta-chiron","issue_id":"agent-lib-j6b","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Architecture review report written and opened: /tmp/architecture-review-20260613-075521.html"}} {"id":"int-eaab83169be77c1e6a853e502b70362f","kind":"field_change","created_at":"2026-06-13T08:17:27.20786458Z","actor":"m3ta-chiron","issue_id":"agent-lib-feu","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Babysitter Deployment Plan exploration completed. Artifact: .a5c/deployment-plan-exploration.md. Run: 01KV00D3F73F6KY5Q8W4TFER5X"}} +{"id":"int-4d5bf5dc890a6963a0e1110a8fb71635","kind":"field_change","created_at":"2026-08-22T19:43:28.592284059Z","actor":"m3ta-chiron","issue_id":"agent-lib-j2w.1","extra":{"field":"status","new_value":"closed","old_value":"open"}} diff --git a/go.mod b/go.mod index f5046bd..0a8cfed 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,32 @@ module github.com/m3tam3re/agent-lib go 1.26.5 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/go-git/go-billy/v5 v5.9.1 + github.com/go-git/go-git/v5 v5.19.2 + github.com/spf13/cobra v1.10.2 +) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..6c42a79 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,113 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA= +github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 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/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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/cli/root.go b/internal/cli/root.go index 1c433a7..99662a6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -5,6 +5,7 @@ package cli import ( "fmt" "io" + "os" "runtime" "github.com/spf13/cobra" @@ -20,6 +21,7 @@ func NewRootCmd() *cobra.Command { SilenceErrors: true, } root.AddCommand(newVersionCmd()) + root.AddCommand(newVendorCmd()) return root } @@ -40,5 +42,9 @@ func printVersion(w io.Writer) error { } func Execute() error { - return NewRootCmd().Execute() + if err := NewRootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return err + } + return nil } diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go new file mode 100644 index 0000000..d0f39a7 --- /dev/null +++ b/internal/cli/vendor.go @@ -0,0 +1,60 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/m3tam3re/agent-lib/internal/vendor" +) + +func newVendorCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "vendor", + Short: "Curate external sources into this work repository", + } + cmd.AddCommand(newVendorAddCmd()) + return cmd +} + +func newVendorAddCmd() *cobra.Command { + var ref string + var include []string + cmd := &cobra.Command{ + Use: "add ", + Short: "Vendor a new external git source", + Long: "Clones the source (in-process, via go-git), discovers skills, commands,\n" + + "agents and MCP fragments, materializes the selection under external//\n" + + "and pins URL, ref, revision, selection and renames in the lockfile.\n" + + "Default selection is everything; --include switches to an explicit list.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + opts := vendor.AddOptions{ + Name: args[0], + URL: args[1], + Ref: ref, + Include: splitList(include), + } + if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil { + return fmt.Errorf("vendor add: %w", err) + } + return nil + }, + } + cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to track (default: the source's default branch)") + cmd.Flags().StringSliceVar(&include, "include", nil, "comma-separated item ids to vendor (switches selection to include mode)") + return cmd +} + +func splitList(in []string) []string { + var out []string + for _, s := range in { + for _, part := range strings.Split(s, ",") { + if t := strings.TrimSpace(part); t != "" { + out = append(out, t) + } + } + } + return out +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..c382c3f --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,171 @@ +package discovery + +import ( + "fmt" + "sort" + "strings" + + "github.com/m3tam3re/agent-lib/internal/lockfile" +) + +// Tree abstracts a readable content tree: a git commit tree or a filesystem +// root. Paths are slash-separated and relative to the tree root. +type Tree interface { + Walk(fn func(rel string, isDir bool) error) error + ReadFile(rel string) ([]byte, error) +} + +// Config directs Scan; zero values select the standard layout. +type Config struct { + Root string + SkillsDir string + CommandsDir string + AgentsDir string + McpDir string +} + +func FromLockfileDiscovery(d lockfile.Discovery) Config { + r := d.Resolve() + return Config{Root: r.Root, SkillsDir: r.SkillsDir, CommandsDir: r.CommandsDir, AgentsDir: r.AgentsDir, McpDir: r.McpDir} +} + +// Item is one discovered deployable artifact. +type Item struct { + Type string + UpstreamID string + RelPath string + Frontmatter *Frontmatter + Warnings []string +} + +// Scan discovers items of all four content types under cfg. Discovery is a +// read-only scan; it never evaluates content beyond reading files. +func Scan(tree Tree, cfg Config) ([]Item, error) { + dirs := lockfile.Discovery{ + Root: cfg.Root, SkillsDir: cfg.SkillsDir, CommandsDir: cfg.CommandsDir, + AgentsDir: cfg.AgentsDir, McpDir: cfg.McpDir, + }.Resolve() + + skillRoot := join(dirs.Root, dirs.SkillsDir) + commandRoot := join(dirs.Root, dirs.CommandsDir) + agentRoot := join(dirs.Root, dirs.AgentsDir) + mcpRoot := join(dirs.Root, dirs.McpDir) + + dirEntries := map[string]map[string]bool{} + fileEntries := map[string]map[string]bool{} + for _, d := range []string{skillRoot, commandRoot, agentRoot, mcpRoot} { + dirEntries[d] = map[string]bool{} + fileEntries[d] = map[string]bool{} + } + + err := tree.Walk(func(rel string, isDir bool) error { + parent, base := splitPath(rel) + if isDir { + if set, ok := dirEntries[parent]; ok { + set[base] = true + } + return nil + } + if set, ok := fileEntries[parent]; ok { + set[base] = true + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking source tree: %w", err) + } + + var items []Item + for name := range dirEntries[skillRoot] { + skillMd := join(skillRoot, name, "SKILL.md") + data, err := tree.ReadFile(skillMd) + if err != nil { + continue + } + it := Item{Type: lockfile.TypeSkill, UpstreamID: name, RelPath: join(skillRoot, name)} + fm, warnings := ParseFrontmatter(data) + it.Frontmatter = &fm + for _, w := range warnings { + it.Warnings = append(it.Warnings, fmt.Sprintf("skills/%s: %s", name, w)) + } + items = append(items, it) + } + items = append(items, scanFlatFiles(tree, fileEntries, commandRoot, lockfile.TypeCommand)...) + items = append(items, scanFlatFiles(tree, fileEntries, agentRoot, lockfile.TypeAgent)...) + items = append(items, scanMcpFiles(tree, fileEntries, mcpRoot)...) + + sort.Slice(items, func(i, j int) bool { + if items[i].Type != items[j].Type { + return typeRank(items[i].Type) < typeRank(items[j].Type) + } + return items[i].UpstreamID < items[j].UpstreamID + }) + return items, nil +} + +func scanFlatFiles(tree Tree, fileEntries map[string]map[string]bool, root, typ string) []Item { + var items []Item + for base := range fileEntries[root] { + if !strings.HasSuffix(base, ".md") { + continue + } + id := strings.TrimSuffix(base, ".md") + rel := join(root, base) + data, err := tree.ReadFile(rel) + if err != nil { + continue + } + it := Item{Type: typ, UpstreamID: id, RelPath: rel} + fm, warnings := ParseFrontmatter(data) + it.Frontmatter = &fm + for _, w := range warnings { + it.Warnings = append(it.Warnings, fmt.Sprintf("%s/%s: %s", typ, id, w)) + } + items = append(items, it) + } + return items +} + +func scanMcpFiles(tree Tree, fileEntries map[string]map[string]bool, root string) []Item { + var items []Item + for base := range fileEntries[root] { + var id string + switch { + case strings.HasSuffix(base, ".yaml"): + id = strings.TrimSuffix(base, ".yaml") + case strings.HasSuffix(base, ".yml"): + id = strings.TrimSuffix(base, ".yml") + default: + continue + } + items = append(items, Item{Type: lockfile.TypeMcp, UpstreamID: id, RelPath: join(root, base)}) + } + return items +} + +func typeRank(t string) int { + for i, tt := range lockfile.Types { + if tt == t { + return i + } + } + return len(lockfile.Types) +} + +func join(parts ...string) string { + var nonEmpty []string + for _, p := range parts { + if p != "" { + nonEmpty = append(nonEmpty, p) + } + } + return strings.Join(nonEmpty, "/") +} + +func splitPath(rel string) (parent, base string) { + i := strings.LastIndex(rel, "/") + if i < 0 { + return "", rel + } + return rel[:i], rel[i+1:] +} diff --git a/internal/discovery/frontmatter.go b/internal/discovery/frontmatter.go new file mode 100644 index 0000000..7f22740 --- /dev/null +++ b/internal/discovery/frontmatter.go @@ -0,0 +1,73 @@ +package discovery + +import ( + "strings" +) + +// Frontmatter holds the leniently parsed leading YAML block of an item. +type Frontmatter struct { + Name string + Description string + Tags []string + Requires []string +} + +// ParseFrontmatter extracts name, description, tags and requires from a +// leading `---` fenced block. Parsing is deliberately lenient: a block that +// opens but never closes, or contains lines without a colon, yields warnings +// instead of failures; the parseable prefix still wins. Files without a +// leading fence have no frontmatter and no warning. +func ParseFrontmatter(data []byte) (Frontmatter, []string) { + var fm Frontmatter + var warnings []string + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return fm, nil + } + closed := false + for _, line := range lines[1:] { + if strings.TrimSpace(line) == "---" { + closed = true + break + } + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + key, value, ok := strings.Cut(trimmed, ":") + if !ok { + warnings = append(warnings, "malformed frontmatter line (missing colon): "+trimmed) + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + switch strings.ToLower(key) { + case "name": + fm.Name = value + case "description": + fm.Description = value + case "tags": + fm.Tags = splitList(value) + case "requires": + fm.Requires = splitList(value) + } + } + if !closed { + warnings = append(warnings, "frontmatter block not closed") + } + return fm, warnings +} + +func splitList(v string) []string { + if v == "" { + return nil + } + parts := strings.Split(v, ",") + var out []string + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} diff --git a/internal/discovery/frontmatter_test.go b/internal/discovery/frontmatter_test.go new file mode 100644 index 0000000..da049e1 --- /dev/null +++ b/internal/discovery/frontmatter_test.go @@ -0,0 +1,54 @@ +package discovery + +import "testing" + +func TestParseFrontmatter(t *testing.T) { + cases := []struct { + name string + in string + want Frontmatter + warnLen int + }{ + { + name: "complete block", + in: "---\nname: Test Skill\ndescription: does things\ntags: a, b\nrequires: c,d\n---\nbody\n", + want: Frontmatter{Name: "Test Skill", Description: "does things", Tags: []string{"a", "b"}, Requires: []string{"c", "d"}}, + }, + { + name: "no frontmatter", + in: "# Just a file\nbody\n", + want: Frontmatter{}, + }, + { + name: "unclosed block", + in: "---\nname: Broken\ndescription: never closed\n", + want: Frontmatter{Name: "Broken", Description: "never closed"}, + warnLen: 1, + }, + { + name: "line missing colon", + in: "---\nname: X\nbroken line without colon\n---\n", + want: Frontmatter{Name: "X"}, + warnLen: 1, + }, + { + name: "crlf line endings", + in: "---\r\nname: Win\r\ndescription: windows author\r\n---\r\n", + want: Frontmatter{Name: "Win", Description: "windows author"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fm, warnings := ParseFrontmatter([]byte(tc.in)) + if fm.Name != tc.want.Name || fm.Description != tc.want.Description { + t.Errorf("got %+v, want %+v", fm, tc.want) + } + if len(fm.Tags) != len(tc.want.Tags) || len(fm.Requires) != len(tc.want.Requires) { + t.Errorf("tags/requires mismatch: got %+v want %+v", fm, tc.want) + } + if len(warnings) != tc.warnLen { + t.Errorf("warnings = %v (len %d), want len %d", warnings, len(warnings), tc.warnLen) + } + }) + } +} diff --git a/internal/discovery/fstree.go b/internal/discovery/fstree.go new file mode 100644 index 0000000..36e59e1 --- /dev/null +++ b/internal/discovery/fstree.go @@ -0,0 +1,55 @@ +package discovery + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// FsTree adapts a filesystem root to the Tree interface (used for scanning +// the work repository's own items). +type FsTree struct { + Root string +} + +func (f *FsTree) Walk(fn func(rel string, isDir bool) error) error { + var paths []string + err := filepath.WalkDir(f.Root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if p == f.Root { + return nil + } + paths = append(paths, p) + return nil + }) + if err != nil { + return err + } + sort.Strings(paths) + for _, p := range paths { + rel, err := filepath.Rel(f.Root, p) + if err != nil { + return err + } + info, err := os.Stat(p) + if err != nil { + return err + } + if err := fn(filepath.ToSlash(rel), info.IsDir()); err != nil { + return err + } + } + return nil +} + +func (f *FsTree) ReadFile(rel string) ([]byte, error) { + clean := filepath.FromSlash(rel) + if strings.Contains(clean, "..") { + return nil, os.ErrNotExist + } + return os.ReadFile(filepath.Join(f.Root, clean)) +} diff --git a/internal/e2e/vendor_add_test.go b/internal/e2e/vendor_add_test.go new file mode 100644 index 0000000..980389a --- /dev/null +++ b/internal/e2e/vendor_add_test.go @@ -0,0 +1,282 @@ +// Package e2e runs the compiled agent-lib binary as a black box against +// local fixture git repositories — fully offline. +package e2e + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +var binPath string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "agent-lib-e2e-bin") + if err != nil { + os.Exit(1) + } + binPath = filepath.Join(dir, "agent-lib") + build := exec.Command("go", "build", "-o", binPath, ".") + build.Dir = "../.." + if out, err := build.CombinedOutput(); err != nil { + os.Stderr.Write(out) + os.Exit(1) + } + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +type harness struct { + t *testing.T + workDir string + upstreamDir string +} + +func newHarness(t *testing.T) *harness { + t.Helper() + base := t.TempDir() + h := &harness{ + t: t, + workDir: filepath.Join(base, "work"), + upstreamDir: filepath.Join(base, "upstream"), + } + for _, d := range []string{h.workDir, h.upstreamDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + h.git(t, h.upstreamDir, "init", "-b", "main") + return h +} + +func (h *harness) git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + "GIT_AUTHOR_DATE=1700000000 +0000", "GIT_COMMITTER_DATE=1700000000 +0000", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) + } + return string(out) +} + +func (h *harness) write(t *testing.T, rel, content string) { + t.Helper() + path := filepath.Join(h.upstreamDir, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func (h *harness) commitUpstream(t *testing.T, msg string) { + t.Helper() + h.git(t, h.upstreamDir, "add", "-A") + h.git(t, h.upstreamDir, "commit", "-m", msg) +} + +func (h *harness) run(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := exec.Command(binPath, args...) + cmd.Dir = h.workDir + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (h *harness) mustRun(t *testing.T, args ...string) string { + t.Helper() + out, err := h.run(t, args...) + if err != nil { + t.Fatalf("agent-lib %v: %v\n%s", args, err, out) + } + return out +} + +func standardFixture(h *harness, t *testing.T) { + h.write(t, "skills/good-skill/SKILL.md", "---\nname: Good Skill\ndescription: works\ntags: a, b\n---\n# Good\n") + h.write(t, "skills/good-skill/helper.py", "print('hi')\n") + h.write(t, "skills/broken-skill/SKILL.md", "---\nname: Broken\nthis line has no colon\n") + h.write(t, "commands/review.md", "---\nname: Review\ndescription: review things\n---\nbody\n") + h.write(t, "agents/scout.md", "---\nname: Scout\n---\nbody\n") + h.write(t, "mcp/search.yaml", "servers:\n search:\n command: search\n") + h.write(t, "README.md", "upstream readme\n") + h.commitUpstream(t, "fixture: initial content") +} + +func TestVendorAddAllMode(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + out := h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + if !contains(out, "ref: main") { + t.Errorf("report missing resolved ref:\n%s", out) + } + if !contains(out, "4 skills") && !contains(out, "2 skills") { + t.Errorf("report should list skill count:\n%s", out) + } + if !contains(out, "warnings") { + t.Errorf("malformed frontmatter must produce a warning in the report:\n%s", out) + } + + for _, p := range []string{ + "external/superpowers/skills/good-skill/SKILL.md", + "external/superpowers/skills/good-skill/helper.py", + "external/superpowers/skills/broken-skill/SKILL.md", + "external/superpowers/commands/review.md", + "external/superpowers/agents/scout.md", + "external/superpowers/mcp/search.yaml", + } { + if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil { + t.Errorf("missing materialized file %s", p) + } + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/README.md")); err == nil { + t.Error("README must not be vendored") + } + + lockBytes, err := os.ReadFile(filepath.Join(h.workDir, "agent-lib.lock.json")) + if err != nil { + t.Fatal(err) + } + lock := string(lockBytes) + if !contains(lock, `"version": 2`) { + t.Errorf("lockfile must be schema v2:\n%s", lock) + } + if !contains(lock, `"mode": "all"`) { + t.Errorf("default selection must be all:\n%s", lock) + } + if !contains(lock, h.upstreamDir) { + t.Errorf("lockfile must pin the url:\n%s", lock) + } + if !contains(lock, "frontmatter") { + t.Errorf("recorded warnings missing from lockfile:\n%s", lock) + } +} + +func TestVendorAddDeterministicRerun(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + + secondWork := filepath.Join(t.TempDir(), "work2") + if err := os.MkdirAll(secondWork, 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command(binPath, "vendor", "add", "superpowers", h.upstreamDir) + cmd.Dir = secondWork + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("second add: %v\n%s", err, out) + } + + b1 := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + b2 := readFile(t, filepath.Join(secondWork, "agent-lib.lock.json")) + if b1 != b2 { + t.Fatalf("same upstream commit must produce identical lockfiles:\n%s\n---\n%s", b1, b2) + } +} + +func TestVendorAddIncludeMode(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill,review") + + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil { + t.Error("included skill must be materialized") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil { + t.Error("included command must be materialized") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/scout.md")); err == nil { + t.Error("non-included agent must NOT be materialized") + } + + lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + if !contains(lock, `"mode": "include"`) { + t.Errorf("selection mode must be include:\n%s", lock) + } + if !contains(lock, `"include": [`) { + t.Errorf("include list missing:\n%s", lock) + } +} + +func TestVendorAddIncludeTypoFailsClean(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill,nope") + if err == nil { + t.Fatalf("typo in include list must fail:\n%s", out) + } + if !contains(out, "nope") { + t.Errorf("error must name the missing entry:\n%s", out) + } + if _, err := os.Stat(filepath.Join(h.workDir, "agent-lib.lock.json")); err == nil { + t.Error("failed add must not write a lockfile") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external")); err == nil { + t.Error("failed add must not create the external area") + } +} + +func TestVendorAddDuplicateName(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + + out, err := h.run(t, "vendor", "add", "superpowers", h.upstreamDir) + if err == nil { + t.Fatalf("duplicate source name must fail:\n%s", out) + } + if !contains(out, "already exists") { + t.Errorf("error must be explicit about the duplicate:\n%s", out) + } +} + +func TestVendorAddOrphanExternalDir(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + os.MkdirAll(filepath.Join(h.workDir, "external", "ghost"), 0o755) + + out, err := h.run(t, "vendor", "add", "ghost", h.upstreamDir) + if err == nil { + t.Fatalf("orphan external dir must fail:\n%s", out) + } + if !contains(out, "not in the lockfile") { + t.Errorf("error must explain the orphan:\n%s", out) + } +} + +func TestVendorAddAtRef(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.git(t, h.upstreamDir, "tag", "v1.0") + + out := h.mustRun(t, "vendor", "add", "pinned", h.upstreamDir, "--ref", "v1.0") + if !contains(out, "ref: v1.0") { + t.Errorf("tag ref must be pinned:\n%s", out) + } +} + +func contains(haystack, needle string) bool { + return strings.Contains(haystack, needle) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/gitsource/git.go b/internal/gitsource/git.go new file mode 100644 index 0000000..c511894 --- /dev/null +++ b/internal/gitsource/git.go @@ -0,0 +1,185 @@ +// Package gitsource clones external git sources in-process via go-git and +// exposes their commit trees for discovery — no git subprocess at runtime. +package gitsource + +import ( + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/storage/memory" + + "github.com/m3tam3re/agent-lib/internal/discovery" +) + +// Env names for optional basic auth on HTTPS sources (deploy/personal token). +const ( + TokenEnv = "AGENT_LIB_GIT_TOKEN" + UserEnv = "AGENT_LIB_GIT_USER" +) + +// Repo is a cloned source pinned to one resolved revision. +type Repo struct { + Repository *git.Repository + Rev string + Ref string +} + +// Clone fetches url — at ref (branch or tag), otherwise the default branch — +// fully in-process and pins the resolved revision. +func Clone(url, ref string) (*Repo, error) { + repo, err := git.Clone(memory.NewStorage(), memfs.New(), cloneOptions(url, ref)) + if err != nil && ref != "" { + repo, err = git.Clone(memory.NewStorage(), memfs.New(), tagOptions(url, ref)) + } + if err != nil { + return nil, fmt.Errorf("cloning %s: %w", url, err) + } + return resolveHead(repo, ref) +} + +func cloneOptions(url, ref string) *git.CloneOptions { + opts := &git.CloneOptions{URL: url} + if ref != "" { + opts.ReferenceName = plumbing.NewBranchReferenceName(ref) + } + applyAuth(url, opts) + return opts +} + +func tagOptions(url, ref string) *git.CloneOptions { + opts := &git.CloneOptions{URL: url, ReferenceName: plumbing.NewTagReferenceName(ref)} + applyAuth(url, opts) + return opts +} + +func applyAuth(url string, opts *git.CloneOptions) { + if !strings.HasPrefix(url, "https://") { + return + } + token := os.Getenv(TokenEnv) + if token == "" { + return + } + user := os.Getenv(UserEnv) + if user == "" { + user = "agent-lib" + } + opts.Auth = &http.BasicAuth{Username: user, Password: token} +} + +func resolveHead(repo *git.Repository, requestedRef string) (*Repo, error) { + head, err := repo.Head() + if err != nil { + return nil, fmt.Errorf("resolving HEAD: %w", err) + } + r := &Repo{Repository: repo, Rev: head.Hash().String()} + switch { + case head.Name().IsBranch() || head.Name().IsTag(): + r.Ref = head.Name().Short() + case requestedRef != "": + r.Ref = requestedRef + default: + r.Ref = head.Name().String() + } + return r, nil +} + +// Tree returns the commit tree of the pinned revision as a discovery.Tree. +func (r *Repo) Tree() (discovery.Tree, error) { + commit, err := r.Repository.CommitObject(plumbing.NewHash(r.Rev)) + if err != nil { + return nil, fmt.Errorf("resolving commit %s: %w", r.Rev, err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("reading tree of %s: %w", r.Rev, err) + } + return &GitTree{tree: tree}, nil +} + +// GitTree adapts a git commit tree to the discovery.Tree interface. +type GitTree struct { + tree *object.Tree +} + +func (g *GitTree) Walk(fn func(rel string, isDir bool) error) error { + walker := object.NewTreeWalker(g.tree, true, nil) + defer walker.Close() + for { + name, entry, err := walker.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if name == "." { + continue + } + if err := fn(name, entry.Mode == filemode.Dir); err != nil { + return err + } + } +} + +func (g *GitTree) ReadFile(rel string) ([]byte, error) { + f, err := g.tree.File(rel) + if err != nil { + return nil, err + } + r, err := f.Blob.Reader() + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// FilesUnder lists every file (recursively) below prefix, sorted. A prefix of +// "" lists the whole tree. +func (g *GitTree) FilesUnder(prefix string) ([]string, error) { + var out []string + walker := object.NewTreeWalker(g.tree, true, nil) + defer walker.Close() + for { + name, entry, err := walker.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if name == "." || entry.Mode == filemode.Dir { + continue + } + if prefix == "" || strings.HasPrefix(name, strings.TrimSuffix(prefix, "/")+"/") { + out = append(out, name) + } + } + sort.Strings(out) + return out, nil +} + +// MaterializeFile writes the blob at rel to dst, preserving the executable +// bit from the tree entry. +func (g *GitTree) MaterializeFile(rel, dst string) error { + data, err := g.ReadFile(rel) + if err != nil { + return fmt.Errorf("reading %s: %w", rel, err) + } + mode := os.FileMode(0o644) + entry, err := g.tree.FindEntry(rel) + if err == nil && entry.Mode == filemode.Executable { + mode = 0o755 + } + return os.WriteFile(dst, data, mode) +} diff --git a/internal/gitsource/url.go b/internal/gitsource/url.go new file mode 100644 index 0000000..d99b953 --- /dev/null +++ b/internal/gitsource/url.go @@ -0,0 +1,41 @@ +package gitsource + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + githubTreeRe = regexp.MustCompile(`^https://([^/]*github[^/]*)/([^/]+)/([^/]+)/tree/([^/]+)(/.*)?$`) + gitlabTreeRe = regexp.MustCompile(`^https://([^/]*gitlab[^/]*)/([^/]+)/([^/]+)/-/tree/([^/]+)(/.*)?$`) +) + +// NormalizeURL converts pasted GitHub/GitLab web-tree URLs into plain git +// clone URLs and returns the implied discovery root ("" for the repo root). +// URLs that are already cloneable pass through unchanged. +func NormalizeURL(raw string) (url, root string, err error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", "", fmt.Errorf("empty source URL") + } + if m := githubTreeRe.FindStringSubmatch(s); m != nil { + return fmt.Sprintf("https://%s/%s/%s.git", m[1], m[2], m[3]), strings.TrimPrefix(m[5], "/"), nil + } + if m := gitlabTreeRe.FindStringSubmatch(s); m != nil { + return fmt.Sprintf("https://%s/%s/%s.git", m[1], m[2], m[3]), strings.TrimPrefix(m[5], "/"), nil + } + if strings.HasSuffix(s, ".git") { + return s, "", nil + } + if m := regexp.MustCompile(`^https://([^/]*github[^/]*)/([^/]+)/([^/]+)$`).FindStringSubmatch(s); m != nil { + return fmt.Sprintf("https://%s/%s/%s.git", m[1], m[2], m[3]), "", nil + } + if m := regexp.MustCompile(`^https://([^/]*gitlab[^/]*)/([^/]+)/([^/]+)$`).FindStringSubmatch(s); m != nil { + return fmt.Sprintf("https://%s/%s/%s.git", m[1], m[2], m[3]), "", nil + } + if strings.HasPrefix(s, "file://") { + return strings.TrimPrefix(s, "file://"), "", nil + } + return s, "", nil +} diff --git a/internal/gitsource/url_test.go b/internal/gitsource/url_test.go new file mode 100644 index 0000000..6bea9fc --- /dev/null +++ b/internal/gitsource/url_test.go @@ -0,0 +1,35 @@ +package gitsource + +import "testing" + +func TestNormalizeURL(t *testing.T) { + cases := []struct { + name, in, wantURL, wantRoot string + }{ + {"github repo", "https://github.com/org/repo", "https://github.com/org/repo.git", ""}, + {"github tree with subpath", "https://github.com/org/repo/tree/main/skills", "https://github.com/org/repo.git", "skills"}, + {"github tree nested subpath", "https://github.com/org/repo/tree/main/content/skills", "https://github.com/org/repo.git", "content/skills"}, + {"github tree no subpath", "https://github.com/org/repo/tree/main", "https://github.com/org/repo.git", ""}, + {"gitlab tree with subpath", "https://gitlab.com/org/repo/-/tree/main/agents", "https://gitlab.com/org/repo.git", "agents"}, + {"gitlab plain repo", "https://gitlab.com/org/repo", "https://gitlab.com/org/repo.git", ""}, + {"already normalized github", "https://github.com/org/repo.git", "https://github.com/org/repo.git", ""}, + {"ssh url untouched", "git@github.com:org/repo.git", "git@github.com:org/repo.git", ""}, + {"https url untouched", "https://example.com/repo.git", "https://example.com/repo.git", ""}, + {"local path untouched", "/tmp/fixture-upstream", "/tmp/fixture-upstream", ""}, + {"file url stripped", "file:///tmp/fixture-upstream", "/tmp/fixture-upstream", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + url, root, err := NormalizeURL(tc.in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != tc.wantURL { + t.Errorf("url = %q, want %q", url, tc.wantURL) + } + if root != tc.wantRoot { + t.Errorf("root = %q, want %q", root, tc.wantRoot) + } + }) + } +} diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go new file mode 100644 index 0000000..561d7cc --- /dev/null +++ b/internal/lockfile/lockfile.go @@ -0,0 +1,148 @@ +// Package lockfile defines agent-lib lockfile schema v2: the deterministic +// JSON document pinning vendored sources, their selection and renames. +package lockfile + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "sort" +) + +const ( + // Version is the schema version of lockfile v2. + Version = 2 + // FileName is the lockfile name inside the work repository root. + FileName = "agent-lib.lock.json" +) + +// ErrNotExist reports that no lockfile exists yet. +var ErrNotExist = errors.New("no lockfile found") + +// Content type names used across discovery, lockfile and the external area. +const ( + TypeSkill = "skills" + TypeCommand = "commands" + TypeAgent = "agents" + TypeMcp = "mcp" +) + +// Types lists all content types in canonical order. +var Types = []string{TypeSkill, TypeCommand, TypeAgent, TypeMcp} + +// Discovery configures where items are found inside a source repository. +// Empty values select the standard layouts (skills/, commands/, agents/, mcp/ +// relative to Root). +type Discovery struct { + Root string `json:"root,omitempty"` + SkillsDir string `json:"skills_dir,omitempty"` + CommandsDir string `json:"commands_dir,omitempty"` + AgentsDir string `json:"agents_dir,omitempty"` + McpDir string `json:"mcp_dir,omitempty"` +} + +func (d Discovery) Resolve() Discovery { + r := d + if r.SkillsDir == "" { + r.SkillsDir = TypeSkill + } + if r.CommandsDir == "" { + r.CommandsDir = TypeCommand + } + if r.AgentsDir == "" { + r.AgentsDir = TypeAgent + } + if r.McpDir == "" { + r.McpDir = TypeMcp + } + return r +} + +// SelectionMode is either "all" or "include". +type SelectionMode string + +const ( + ModeAll SelectionMode = "all" + ModeInclude SelectionMode = "include" +) + +// Selection expresses what enters the work repository: everything minus +// Exclude (mode all), or exactly Include (mode include). +type Selection struct { + Mode SelectionMode `json:"mode"` + Exclude []string `json:"exclude,omitempty"` + Include []string `json:"include,omitempty"` +} + +// Source pins one vendored git source. +type Source struct { + URL string `json:"url"` + Ref string `json:"ref"` + Rev string `json:"rev"` + Discovery *Discovery `json:"discovery,omitempty"` + Selection Selection `json:"selection"` + Renames map[string]string `json:"renames,omitempty"` + Inventory map[string][]string `json:"inventory,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// Lockfile is the root document of schema v2. +type Lockfile struct { + Version int `json:"version"` + Sources map[string]*Source `json:"sources"` +} + +// New returns an empty lockfile v2. +func New() *Lockfile { + return &Lockfile{Version: Version, Sources: map[string]*Source{}} +} + +// Load reads the lockfile at path. A missing file yields ErrNotExist. +func Load(path string) (*Lockfile, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotExist + } + return nil, err + } + var l Lockfile + if err := json.Unmarshal(data, &l); err != nil { + return nil, fmt.Errorf("lockfile %s is not valid JSON: %w", path, err) + } + if l.Version != Version { + return nil, fmt.Errorf("lockfile %s has schema version %d, want %d", path, l.Version, Version) + } + if l.Sources == nil { + l.Sources = map[string]*Source{} + } + return &l, nil +} + +// Save canonically orders all slices and maps, then writes deterministic JSON. +func (l *Lockfile) Save(path string) error { + for _, src := range l.Sources { + sort.Strings(src.Selection.Exclude) + sort.Strings(src.Selection.Include) + sort.Strings(src.Warnings) + for _, items := range src.Inventory { + sort.Strings(items) + } + } + data, err := Marshal(l) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// Marshal renders the lockfile as deterministic bytes: fixed field order via +// struct layout, sorted map keys, two-space indent, trailing newline. +func Marshal(l *Lockfile) ([]byte, error) { + data, err := json.MarshalIndent(l, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go new file mode 100644 index 0000000..cfc19fb --- /dev/null +++ b/internal/lockfile/lockfile_test.go @@ -0,0 +1,129 @@ +package lockfile + +import ( + "os" + "path/filepath" + + "testing" +) + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestMarshalDeterministic(t *testing.T) { + l1 := New() + l1.Sources["beta"] = &Source{ + URL: "https://example.com/beta.git", Ref: "main", Rev: "cafe123", + Selection: Selection{Mode: ModeAll}, + Renames: map[string]string{"zeta": "beta-zeta"}, + Inventory: map[string][]string{TypeSkill: {"a", "zeta"}}, + Warnings: []string{"a-warning", "b-warning"}, + } + l1.Sources["alpha"] = &Source{ + URL: "https://example.com/alpha.git", Ref: "v1.0", Rev: "abcd00", + Selection: Selection{Mode: ModeInclude, Include: []string{"a", "x"}}, + } + + l2 := New() + l2.Sources["alpha"] = l1.Sources["alpha"] + l2.Sources["beta"] = l1.Sources["beta"] + + b1, err := Marshal(l1) + if err != nil { + t.Fatal(err) + } + b2, err := Marshal(l2) + if err != nil { + t.Fatal(err) + } + if string(b1) != string(b2) { + t.Fatalf("marshal not deterministic:\n%s\n--- vs ---\n%s", b1, b2) + } + if b1[len(b1)-1] != '\n' { + t.Error("missing trailing newline") + } + + l1.Save(filepath.Join(t.TempDir(), FileName)) + b3, err := Marshal(l1) + if err != nil { + t.Fatal(err) + } + if string(b1) != string(b3) { + t.Fatalf("save mutated already-canonical bytes:\n%s\n--- vs ---\n%s", b1, b3) + } +} + +func TestSaveCanonicalizesUnsortedInput(t *testing.T) { + l := New() + l.Sources["s"] = &Source{ + URL: "u", Ref: "r", Rev: "rev", + Selection: Selection{Mode: ModeInclude, Include: []string{"z", "a"}}, + Warnings: []string{"w2", "w1"}, + Inventory: map[string][]string{TypeSkill: {"z", "a"}}, + } + path := filepath.Join(t.TempDir(), FileName) + if err := l.Save(path); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + src := got.Sources["s"] + wantInclude := []string{"a", "z"} + if !slicesEqual(src.Selection.Include, wantInclude) { + t.Errorf("include = %v, want %v", src.Selection.Include, wantInclude) + } + wantWarnings := []string{"w1", "w2"} + if !slicesEqual(src.Warnings, wantWarnings) { + t.Errorf("warnings = %v, want %v", src.Warnings, wantWarnings) + } + wantInv := []string{"a", "z"} + if !slicesEqual(src.Inventory[TypeSkill], wantInv) { + t.Errorf("inventory = %v, want %v", src.Inventory[TypeSkill], wantInv) + } +} + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, FileName) + l := New() + l.Sources["s"] = &Source{URL: "u", Ref: "r", Rev: "rev1", Selection: Selection{Mode: ModeAll}} + if err := l.Save(path); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got.Version != Version || got.Sources["s"].Rev != "rev1" { + t.Fatalf("round trip lost data: %+v", got) + } + + if _, err := Load(filepath.Join(dir, "missing.json")); err != ErrNotExist { + t.Fatalf("want ErrNotExist, got %v", err) + } + + os.WriteFile(path, []byte(`{"version": 1, "sources": {}}`), 0o644) + if _, err := Load(path); err == nil { + t.Fatal("version 1 lockfile must be rejected") + } +} diff --git a/internal/vendor/add.go b/internal/vendor/add.go new file mode 100644 index 0000000..a970ca9 --- /dev/null +++ b/internal/vendor/add.go @@ -0,0 +1,280 @@ +// Package vendor implements the curator-side commands: add, update, diff, +// remove, list and inspect over the external area and lockfile v2. +package vendor + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/m3tam3re/agent-lib/internal/discovery" + "github.com/m3tam3re/agent-lib/internal/gitsource" + "github.com/m3tam3re/agent-lib/internal/lockfile" +) + +const externalDir = "external" + +// AddOptions parameterize vendor add. +type AddOptions struct { + Name string + URL string + Ref string + Include []string +} + +// Add vendors a new source into the work repository at workDir: it clones, +// discovers, applies the selection and materializes external// plus the +// lockfile entry. Nothing is written before every check has passed. +func Add(workDir string, opts AddOptions, stdout io.Writer) error { + lockPath := filepath.Join(workDir, lockfile.FileName) + lf, err := lockfile.Load(lockPath) + if err != nil { + if err != lockfile.ErrNotExist { + return err + } + lf = lockfile.New() + } + if _, exists := lf.Sources[opts.Name]; exists { + return fmt.Errorf("source %q already exists in %s", opts.Name, lockfile.FileName) + } + extRoot := filepath.Join(workDir, externalDir, opts.Name) + if _, err := os.Stat(extRoot); err == nil { + return fmt.Errorf("%s exists but %q is not in the lockfile; remove the directory or pick another name", extRoot, opts.Name) + } + + normalized, root, err := gitsource.NormalizeURL(opts.URL) + if err != nil { + return err + } + + repo, err := gitsource.Clone(normalized, opts.Ref) + if err != nil { + return err + } + tree, err := repo.Tree() + if err != nil { + return err + } + + cfg := lockfile.Discovery{Root: root} + items, err := discovery.Scan(tree, discovery.FromLockfileDiscovery(cfg)) + if err != nil { + return err + } + + sel := lockfile.Selection{Mode: lockfile.ModeAll} + if len(opts.Include) > 0 { + sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include} + if err := checkIncludeEntries(items, opts.Include, opts.Name); err != nil { + return err + } + } + + selected := applySelection(items, sel) + if len(selected) == 0 { + return fmt.Errorf("source %q selected zero items; refusing to add an empty source", opts.Name) + } + + src := &lockfile.Source{ + URL: normalized, + Ref: repo.Ref, + Rev: repo.Rev, + Selection: sel, + Renames: map[string]string{}, + } + if cfg != (lockfile.Discovery{}) { + src.Discovery = &cfg + } + for _, it := range items { + src.Warnings = append(src.Warnings, it.Warnings...) + } + src.Warnings = dedupe(src.Warnings) + + inventory, err := materialize(workDir, opts.Name, tree, selected, src.Renames) + if err != nil { + return err + } + src.Inventory = inventory + + lf.Sources[opts.Name] = src + if err := lf.Save(lockPath); err != nil { + return err + } + + reportAdd(stdout, opts.Name, src, items, selected) + return nil +} + +func checkIncludeEntries(items []discovery.Item, include []string, name string) error { + available := map[string]bool{} + for _, it := range items { + available[it.UpstreamID] = true + } + for _, want := range include { + if !available[want] { + return fmt.Errorf("source %q: include entry %q not found upstream (available: %s)", name, want, strings.Join(sortedKeys(available), ", ")) + } + } + return nil +} + +func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.Item { + exclude := map[string]bool{} + for _, e := range sel.Exclude { + exclude[e] = true + } + include := map[string]bool{} + for _, i := range sel.Include { + include[i] = true + } + var out []discovery.Item + for _, it := range items { + switch sel.Mode { + case lockfile.ModeInclude: + if include[it.UpstreamID] { + out = append(out, it) + } + default: + if !exclude[it.UpstreamID] { + out = append(out, it) + } + } + } + return out +} + +func deployedName(upstreamID string, renames map[string]string) string { + if d, ok := renames[upstreamID]; ok && d != "" { + return d + } + return upstreamID +} + +func materialize(workDir, name string, tree discovery.Tree, selected []discovery.Item, renames map[string]string) (map[string][]string, error) { + inventory := map[string][]string{} + for _, it := range selected { + dep := deployedName(it.UpstreamID, renames) + base := filepath.Join(workDir, externalDir, name, it.Type) + var dst string + var err error + switch it.Type { + case lockfile.TypeSkill: + dst = filepath.Join(base, dep) + err = copyTreeDir(tree, it.RelPath, dst) + default: + dst = filepath.Join(base, dep+filepath.Ext(it.RelPath)) + err = copyTreeFile(tree, it.RelPath, dst) + } + if err != nil { + return nil, fmt.Errorf("materializing %s/%s: %w", it.Type, it.UpstreamID, err) + } + inventory[it.Type] = append(inventory[it.Type], it.UpstreamID) + } + return inventory, nil +} + +func copyTreeDir(tree discovery.Tree, srcRel, dst string) error { + lister, ok := tree.(interface { + FilesUnder(prefix string) ([]string, error) + }) + if !ok { + return fmt.Errorf("tree does not support directory copy") + } + files, err := lister.FilesUnder(srcRel) + if err != nil { + return err + } + if len(files) == 0 { + return fmt.Errorf("no files under %s", srcRel) + } + prefix := strings.TrimSuffix(srcRel, "/") + "/" + for _, f := range files { + relInside := strings.TrimPrefix(f, prefix) + target := filepath.Join(dst, filepath.FromSlash(relInside)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := writeFileFromTree(tree, f, target); err != nil { + return err + } + } + return nil +} + +func copyTreeFile(tree discovery.Tree, srcRel, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return writeFileFromTree(tree, srcRel, dst) +} + +func writeFileFromTree(tree discovery.Tree, rel, dst string) error { + if mt, ok := tree.(interface { + MaterializeFile(rel, dst string) error + }); ok { + return mt.MaterializeFile(rel, dst) + } + data, err := tree.ReadFile(rel) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selected []discovery.Item) { + counts := func(items []discovery.Item) string { + per := map[string]int{} + for _, it := range items { + per[it.Type]++ + } + var parts []string + for _, t := range lockfile.Types { + if per[t] > 0 { + parts = append(parts, fmt.Sprintf("%d %s", per[t], t)) + } + } + if len(parts) == 0 { + return "0 items" + } + return strings.Join(parts, ", ") + } + fmt.Fprintf(w, "source %s pinned\n", name) + fmt.Fprintf(w, " url: %s\n", src.URL) + fmt.Fprintf(w, " ref: %s\n", src.Ref) + fmt.Fprintf(w, " rev: %s\n", src.Rev) + fmt.Fprintf(w, "discovered %d items (%s)\n", len(discovered), counts(discovered)) + fmt.Fprintf(w, "selected %d items (%s)\n", len(selected), counts(selected)) + fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name)) + if len(src.Warnings) > 0 { + fmt.Fprintf(w, "warnings:\n") + for _, warn := range src.Warnings { + fmt.Fprintf(w, " - %s\n", warn) + } + } + fmt.Fprintf(w, "lockfile %s written\n", lockfile.FileName) +} + +func dedupe(in []string) []string { + seen := map[string]bool{} + var out []string + for _, s := range in { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + sort.Strings(out) + return out +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +}