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/<source>/<type>/ materialization preserving exec bits - lockfile v2: deterministic JSON, pinned url/ref/rev, inventory, warnings - offline black-box e2e suite against local fixture git repositories
This commit is contained in:
@@ -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"}}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <name> <url>",
|
||||
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/<name>/\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
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+280
@@ -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/<name>/ 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
|
||||
}
|
||||
Reference in New Issue
Block a user