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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user